PostgreSQL 38003 오류 원인과 해결 방법 완벽 가이드

38003
2026년 09월 02일 | DBMS Error 가이드

이 글에서 다루는 내용

38003 에러의 원인 분석, 해결 SQL, 예방 방법을 실무 관점에서 정리합니다.

38003 prohibited sql statement attempted 는?

PostgreSQL 에러 코드 38003은 PL/pgSQL 또는 다른 절차형 언어(PL/Python, PL/Perl 등)로 작성된 함수나 프로시저 내부에서, 해당 함수의 보안 정책 또는 실행 컨텍스트가 허용하지 않는 SQL 구문을 시도했을 때 발생합니다. 주로 SECURITY DEFINER 함수 내부에서 허용되지 않는 명령을 실행하거나, 읽기 전용(read-only)으로 선언된 함수 안에서 데이터를 변경하려 할 때 이 에러가 트리거됩니다. 이 에러는 데이터베이스의 보안 모델과 함수의 속성(attribute) 정의가 충돌할 때 나타나는 전형적인 증상입니다.


주요 발생 원인

1. READS SQL DATA 또는 CONTAINS SQL 속성이 있는 함수에서 DML 실행

PL/pgSQL 함수를 생성할 때 READS SQL DATA 또는 외부 언어(PL/Java 등) 함수에서 읽기 전용 속성을 부여했음에도 불구하고, 내부에서 INSERT, UPDATE, DELETE 같은 데이터 변경 구문을 실행하려고 하면 이 에러가 발생합니다. PostgreSQL은 함수 속성을 통해 쿼리 플래너와 실행 엔진이 최적화 결정을 내리기 때문에, 선언된 속성과 실제 실행 내용이 다를 경우 엄격하게 금지 처리합니다. 특히 병렬 실행 컨텍스트나 인덱스 스캔 도중 호출되는 함수에서 빈번하게 발생합니다.

2. 병렬 쿼리 워커(Parallel Worker) 환경에서 허용되지 않는 구문 실행

PostgreSQL 9.6 이후 도입된 병렬 쿼리 기능은 워커 프로세스에서 함수를 실행할 수 있는데, 병렬 워커는 트랜잭션 제어 명령(COMMIT, ROLLBACK, SAVEPOINT 등)이나 특정 유틸리티 명령을 실행할 수 없습니다. 함수가 PARALLEL SAFE로 선언되어 병렬 환경에서 실행되지만, 함수 내부에 트랜잭션 제어나 세션 변경 구문이 포함되어 있으면 38003 에러가 발생합니다. 이런 경우 함수 선언을 PARALLEL UNSAFE 또는 PARALLEL RESTRICTED로 변경해야 합니다.

3. SRF(Set Returning Function) 또는 트리거 함수 내부에서 금지된 명령 사용

트리거 함수나 집합 반환 함수(Set Returning Function) 내부에서 VACUUM, CLUSTER, CREATE INDEX CONCURRENTLY처럼 트랜잭션 블록 내에서 실행할 수 없는 유틸리티 명령을 실행하려 할 때 이 에러가 발생합니다. 트리거는 기본적으로 트랜잭션 컨텍스트 내에서 실행되므로, 해당 컨텍스트에서 금지된 DDL이나 유틸리티 명령을 호출하면 PostgreSQL이 이를 차단합니다. 이는 데이터 무결성 보호를 위한 의도적인 설계입니다.


해결 방법

원인 1 해결: 함수 속성을 실제 동작에 맞게 수정

함수 선언 시 VOLATILEMODIFIES SQL DATA(또는 해당 언어의 동등한 속성)를 올바르게 지정해야 합니다.

-- 잘못된 예: 읽기 전용 속성인데 INSERT를 실행
CREATE OR REPLACE FUNCTION get_or_create_user(p_username TEXT)
RETURNS INT
LANGUAGE plpgsql
STABLE  -- ← 이것이 문제: STABLE은 데이터 수정 불가를 의미
AS $$
DECLARE
  v_id INT;
BEGIN
  SELECT id INTO v_id FROM users WHERE username = p_username;
  IF NOT FOUND THEN
    INSERT INTO users (username) VALUES (p_username) RETURNING id INTO v_id; -- 38003 발생!
  END IF;
  RETURN v_id;
END;
$$;

-- 올바른 예: VOLATILE로 변경하여 데이터 수정 허용
CREATE OR REPLACE FUNCTION get_or_create_user(p_username TEXT)
RETURNS INT
LANGUAGE plpgsql
VOLATILE  -- ← 데이터 변경이 가능한 속성으로 수정
SECURITY INVOKER
AS $$
DECLARE
  v_id INT;
BEGIN
  SELECT id INTO v_id FROM users WHERE username = p_username;
  IF NOT FOUND THEN
    INSERT INTO users (username) VALUES (p_username) RETURNING id INTO v_id;
  END IF;
  RETURN v_id;
END;
$$;

원인 2 해결: 병렬 안전성 속성 조정

-- 현재 함수의 병렬 속성 확인
SELECT proname, proparallel
FROM pg_proc
WHERE proname = 'my_function';
-- proparallel: 's' = SAFE, 'r' = RESTRICTED, 'u' = UNSAFE

-- 잘못된 예: PARALLEL SAFE 함수 내에서 트랜잭션 제어
CREATE OR REPLACE FUNCTION log_and_process(p_id INT)
RETURNS VOID
LANGUAGE plpgsql
PARALLEL SAFE  -- ← 문제: 내부에서 SAVEPOINT를 사용함
AS $$
BEGIN
  SAVEPOINT my_sp;  -- 38003 발생 가능!
  UPDATE orders SET status = 'processed' WHERE id = p_id;
END;
$$;

-- 올바른 예: PARALLEL UNSAFE로 변경
CREATE OR REPLACE FUNCTION log_and_process(p_id INT)
RETURNS VOID
LANGUAGE plpgsql
PARALLEL UNSAFE  -- ← 트랜잭션 제어가 있으므로 UNSAFE로 선언
AS $$
BEGIN
  SAVEPOINT my_sp;
  UPDATE orders SET status = 'processed' WHERE id = p_id;
EXCEPTION WHEN OTHERS THEN
  ROLLBACK TO SAVEPOINT my_sp;
  RAISE;
END;
$$;

-- 병렬 속성 변경 (기존 함수 수정)
ALTER FUNCTION log_and_process(INT) PARALLEL UNSAFE;

원인 3 해결: 트리거 함수에서 금지된 명령 분리

-- 잘못된 예: 트리거 내에서 VACUUM 시도
CREATE OR REPLACE FUNCTION after_bulk_delete_trigger()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
  -- 트리거 컨텍스트 내에서는 실행 불가!
  EXECUTE 'VACUUM ANALYZE deleted_log';  -- 38003 발생!
  RETURN NULL;
END;
$$;

-- 올바른 예: pg_notify를 사용해 외부 프로세스에 위임
CREATE OR REPLACE FUNCTION after_bulk_delete_trigger()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
  -- 직접 VACUUM 대신, 알림을 보내 외부 워커가 처리하도록 위임
  PERFORM pg_notify('vacuum_needed', TG_TABLE_NAME);
  RETURN NULL;
END;
$$;

-- 또는 별도 스케줄러(pg_cron 등)를 활용
-- pg_cron 설치 후 VACUUM 예약
SELECT cron.schedule('vacuum-deleted-log', '*/30 * * * *', 'VACUUM ANALYZE deleted_log');

-- 현재 에러 상황 진단: 문제가 되는 함수 속성 전체 확인
SELECT
  p.proname AS function_name,
  p.provolatile AS volatility,  -- 'i'=IMMUTABLE, 's'=STABLE, 'v'=VOLATILE
  p.proparallel AS parallel_safety,
  p.prosecdef AS security_definer,
  pg_get_functiondef(p.oid) AS function_definition
FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname = 'public'
  AND p.proname = 'your_function_name';

예방 방법

1. 함수 생성 시 속성(Attribute)을 명시적으로 검토하고 문서화하라

함수를 생성할 때 VOLATILE, STABLE, IMMUTABLE, PARALLEL SAFE/UNSAFE/RESTRICTED 속성의 의미를 팀 전체가 명확히 이해하고 있어야 합니다. 특히 CI/CD 파이프라인에 함수 속성 검증 스크립트를 포함시켜, DML 포함 여부와 STABLE/IMMUTABLE 선언이 충돌하지 않는지 자동으로 감지하도록 구성하는 것이 Best Practice입니다.

-- 속성 불일치 감지 쿼리 (모니터링 용도)
SELECT proname, provolatile, proparallel
FROM pg_proc
WHERE pronamespace = 'public'::regnamespace
  AND provolatile IN ('i', 's')  -- IMMUTABLE 또는 STABLE
  AND prosrc ILIKE '%INSERT%' OR prosrc ILIKE '%UPDATE%' OR prosrc ILIKE '%DELETE%';

2. 개발 환경에서 log_min_messages = DEBUG5로 설정해 함수 실행 컨텍스트를 항상 추적하라

운영 환경 배포 전에 개발/스테이징 환경에서 log_min_messageslog_error_verbosity = VERBOSE를 설정하여, 함수가 어떤 컨텍스트에서 호출되는지 반드시 확인해야 합니다. 특히 병렬 쿼리가 활성화된 환경에서는 SET max_parallel_workers_per_gather = 0으로 임시 비활성화한 뒤 차이를 비교하면 병렬 관련 38003 에러를 사전에 잡아낼 수 있습니다.


관련 에러

  • 38000 (external_routine_exception): 외부 루틴 실행 중 발생하는 일반적인 예외로, 38003의 상위 카테고리 에러입니다.
  • 38001 (containing_sql_not_permitted): SQL을 포함할 수 없는 컨텍스트에서 SQL을 포함한 루틴을 호출했을 때 발생합니다.
  • 38002 (modifying_sql_data_not_permitted): 데이터 수정이 허용되지 않는 함수(예: STABLE)에서 수정 쿼리를 실행할 때 발생하며, 38003과 가장 유사한 에러입니다.
  • 25006 (read_only_sql_transaction): 읽기 전용 트랜잭션(SET TRANSACTION READ ONLY) 내에서 데이터 변경을 시도할 때 발생하는 에러로, 함수 속성 문제가 아닌 트랜잭션 레벨의 제한입니다.
  • 0A000 (feature_not_supported): 특정 컨텍스트에서 지원되지 않는 기능을 사용할 때 발생하며, 트리거나 함수 내부의 유틸리티 명령 제한과 관련될 수 있습니다.

DBMS 에러 코드 시리즈

주요 DBMS error code를 정리하는 시리즈입니다.
블로그 홈에서 다른 에러도 확인하세요.

본 포스트는 AI가 생성한 기술 가이드입니다. 운영 환경 적용 전 충분한 검토를 권장합니다.

댓글 남기기