2026년 06월 29일 | DBMS Error 가이드
이 글에서 다루는 내용
38003 에러의 원인 분석, 해결 SQL, 예방 방법을 실무 관점에서 정리합니다.
38003 prohibited sql statement attempted 는?
PostgreSQL 에러 코드 38003 prohibited sql statement attempted는 PL/pgSQL 또는 다른 절차적 언어(PL/Python, PL/Perl 등)로 작성된 함수 내부에서 해당 함수의 실행 컨텍스트가 허용하지 않는 SQL 구문을 실행하려 할 때 발생합니다. 가장 흔한 사례는 SECURITY DEFINER 속성의 함수나 STABLE, IMMUTABLE로 선언된 함수 내에서 데이터 변경(DML) 또는 트랜잭션 제어 문(TCL)을 시도할 때입니다. 이 에러는 데이터베이스의 무결성과 보안을 보호하기 위한 PostgreSQL의 엄격한 실행 정책에서 비롯됩니다.
주요 발생 원인
1. IMMUTABLE 또는 STABLE 함수 내부에서 DML 실행 시도
IMMUTABLE이나 STABLE로 선언된 함수는 PostgreSQL 옵티마이저에게 “이 함수는 데이터베이스 상태를 변경하지 않는다”는 계약을 맺는 것입니다. 이러한 함수 내에서 INSERT, UPDATE, DELETE, TRUNCATE 같은 데이터 변경 구문을 실행하면 PostgreSQL은 즉시 38003 에러를 발생시켜 계약 위반을 차단합니다.
2. 읽기 전용 트랜잭션 또는 읽기 전용 복제본에서의 쓰기 시도
트랜잭션이 SET TRANSACTION READ ONLY로 설정되어 있거나, Hot Standby(읽기 전용 복제본) 환경에서 쓰기 작업을 포함하는 함수를 호출할 때 이 에러가 발생할 수 있습니다. 특히 애플리케이션 레이어에서 읽기/쓰기 분리(Read/Write Splitting)를 구성했을 때, 실수로 쓰기 함수가 읽기 전용 연결로 라우팅되는 경우가 실무에서 빈번하게 발생합니다.
3. PL/pgSQL 함수 내 트랜잭션 제어 구문 잘못 사용
PostgreSQL 11 이전 버전에서는 일반 FUNCTION 내부에서 COMMIT이나 ROLLBACK을 직접 사용할 수 없었으며, 이를 시도하면 38003 에러가 발생합니다. PostgreSQL 11 이후에도 PROCEDURE가 아닌 FUNCTION 내부에서, 또는 잘못된 호출 컨텍스트에서 트랜잭션 제어 구문을 사용하면 여전히 동일한 에러가 발생할 수 있습니다.
해결 방법
원인 1 해결: 함수 선언 속성 수정
함수가 실제로 데이터를 변경해야 한다면, 함수 선언에서 IMMUTABLE 또는 STABLE을 VOLATILE로 변경해야 합니다. VOLATILE이 기본값이며, 데이터를 변경하는 함수에는 반드시 명시적으로 VOLATILE을 사용하거나 아무 속성도 지정하지 않는 것이 권장됩니다.
-- 문제가 되는 코드: IMMUTABLE 함수 내 DML
CREATE OR REPLACE FUNCTION bad_function(p_id INT)
RETURNS VOID
LANGUAGE plpgsql
IMMUTABLE -- 잘못된 선언!
AS $$
BEGIN
-- 38003 에러 발생: IMMUTABLE 함수에서 UPDATE 불가
UPDATE orders SET status = 'processed' WHERE id = p_id;
END;
$$;
-- 해결 방법: VOLATILE로 변경
CREATE OR REPLACE FUNCTION good_function(p_id INT)
RETURNS VOID
LANGUAGE plpgsql
VOLATILE -- 데이터 변경 함수에는 VOLATILE 사용
AS $$
BEGIN
UPDATE orders SET status = 'processed' WHERE id = p_id;
END;
$$;
-- 함수의 현재 volatility 확인 방법
SELECT proname, provolatile
FROM pg_proc
WHERE proname = 'bad_function';
-- provolatile: 'i' = IMMUTABLE, 's' = STABLE, 'v' = VOLATILE
원인 2 해결: 읽기 전용 트랜잭션 또는 복제본 문제 해결
-- 현재 트랜잭션이 읽기 전용인지 확인
SHOW transaction_read_only;
-- 세션 레벨에서 읽기/쓰기 모드 확인
SELECT current_setting('transaction_read_only');
-- 읽기/쓰기 가능한 트랜잭션으로 명시적 설정
BEGIN;
SET TRANSACTION READ WRITE;
SELECT good_function(42);
COMMIT;
-- 연결 자체가 읽기 전용인지 확인 (Hot Standby 등)
SELECT pg_is_in_recovery();
-- true이면 현재 서버는 읽기 전용 복제본
-- 애플리케이션에서 쓰기 작업은 반드시 Primary 서버로 라우팅
-- 예: pgBouncer 또는 애플리케이션 레벨 분기 처리
원인 3 해결: PROCEDURE 사용 또는 트랜잭션 제어 로직 분리
-- 잘못된 방법: FUNCTION 내에서 COMMIT 사용 (구버전 호환 문제)
-- PostgreSQL 11+에서도 FUNCTION 내 COMMIT은 제한적
CREATE OR REPLACE FUNCTION bad_transaction_function()
RETURNS VOID
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO audit_log(action) VALUES ('start');
COMMIT; -- 38003 에러 발생 가능
INSERT INTO audit_log(action) VALUES ('end');
END;
$$;
-- 올바른 방법 1: PROCEDURE 사용 (PostgreSQL 11+)
CREATE OR REPLACE PROCEDURE good_transaction_procedure()
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO audit_log(action) VALUES ('start');
COMMIT; -- PROCEDURE에서는 허용됨
INSERT INTO audit_log(action) VALUES ('end');
COMMIT;
END;
$$;
-- PROCEDURE 호출 방법
CALL good_transaction_procedure();
-- 올바른 방법 2: 트랜잭션 제어를 애플리케이션 레이어로 이동
-- 함수는 순수 로직만 담당하고, 커밋/롤백은 호출 측에서 처리
CREATE OR REPLACE FUNCTION process_order(p_order_id INT)
RETURNS BOOLEAN
LANGUAGE plpgsql
VOLATILE
AS $$
BEGIN
UPDATE orders SET status = 'processed' WHERE id = p_order_id;
INSERT INTO order_history(order_id, action) VALUES (p_order_id, 'processed');
RETURN TRUE;
EXCEPTION
WHEN OTHERS THEN
RETURN FALSE;
END;
$$;
-- 호출 측에서 트랜잭션 제어
BEGIN;
SELECT process_order(101);
COMMIT; -- 또는 ROLLBACK
예방 방법
1. 함수 생성 시 Volatility Category 명시 및 코드 리뷰 프로세스 정착
함수를 생성하거나 수정할 때 항상 VOLATILE, STABLE, IMMUTABLE 중 하나를 명시적으로 선언하는 코딩 컨벤션을 팀 내에 도입하세요. 특히 IMMUTABLE이나 STABLE 함수는 코드 리뷰 단계에서 내부에 DML이 없는지 반드시 검증해야 하며, CI/CD 파이프라인에 정적 분석 도구를 연동하면 배포 전에 이런 문제를 사전에 차단할 수 있습니다.
-- 기존 함수들의 volatility 일괄 점검 쿼리 (운영 환경 주기적 점검 권장)
SELECT
n.nspname AS schema_name,
p.proname AS function_name,
CASE p.provolatile
WHEN 'i' THEN 'IMMUTABLE'
WHEN 's' THEN 'STABLE'
WHEN 'v' THEN 'VOLATILE'
END AS volatility,
p.prosrc AS function_body
FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND p.provolatile IN ('i', 's') -- IMMUTABLE, STABLE 함수만 필터
ORDER BY n.nspname, p.proname;
2. 읽기/쓰기 분리 환경에서 연결 대상 검증 로직 구현
읽기 전용 복제본과 Primary 서버를 구분하는 환경에서는 애플리케이션 시작 시 또는 각 트랜잭션 시작 전에 pg_is_in_recovery() 함수를 통해 현재 연결이 올바른 서버를 가리키는지 확인하는 로직을 반드시 포함해야 합니다. pgBouncer, HAProxy, Patroni 같은 Connection Pooler 또는 HA 솔루션을 사용하는 경우 쓰기 연결 풀과 읽기 연결 풀을 분리하고, 쓰기 연산이 실수로 읽기 전용 풀로 라우팅되지 않도록 애플리케이션 레벨의 방어 코드를 작성하세요.
관련 에러
- 38001 (
reading_sql_data_not_permitted):NO SQL또는READS SQL DATA로 선언된 함수에서 SQL 읽기조차 금지된 경우 발생합니다. - 38002 (
modifying_sql_data_not_permitted): SQL 데이터 수정이 허용되지 않는 컨텍스트에서 DML을 시도할 때 발생하며, 38003과 유사하지만 발생 맥락이 다릅니다. - 25006 (
read_only_sql_transaction): 읽기 전용 트랜잭션에서 쓰기 작업을 직접 시도할 때 발생하는 에러로, 38003과 함께 읽기 전용 환경에서 자주 동반 발생합니다. - 0A000 (
feature_not_supported): 특정 컨텍스트에서 지원되지 않는 SQL 기능을 사용하려 할 때 발생하며, 함수 내 트랜잭션 제어 관련 문제에서 38003과 혼동될 수 있습니다.
주요 DBMS error code를 정리하는 시리즈입니다.
블로그 홈에서 다른 에러도 확인하세요.
본 포스트는 AI가 생성한 기술 가이드입니다. 운영 환경 적용 전 충분한 검토를 권장합니다.