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

2F003
2026년 09월 01일 | DBMS Error 가이드

이 글에서 다루는 내용

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

2F003 prohibited sql statement attempted 는?

PostgreSQL 에러 코드 2F003 prohibited sql statement attempted는 PL/pgSQL 또는 다른 절차적 언어(PL/Python, PL/Perl 등)로 작성된 함수 내부에서, 해당 함수의 변동성 분류(Volatility Category) 또는 보안 컨텍스트상 허용되지 않는 SQL 구문을 실행하려 할 때 발생합니다. 대표적으로 STABLE 또는 IMMUTABLE로 선언된 함수 내부에서 데이터를 변경하는 DML(INSERT, UPDATE, DELETE)을 실행하거나, 읽기 전용 트랜잭션 컨텍스트에서 쓰기 작업을 시도할 때 이 에러가 발생합니다. 이 에러는 PostgreSQL이 데이터 무결성과 실행 계획 최적화를 보호하기 위해 의도적으로 설계한 안전 장치입니다.


주요 발생 원인

1. IMMUTABLE 또는 STABLE 함수 내부에서 데이터 변경 시도

PostgreSQL의 함수 변동성(Volatility)은 VOLATILE, STABLE, IMMUTABLE 세 가지로 분류됩니다. IMMUTABLE이나 STABLE로 선언된 함수 내에서 INSERT, UPDATE, DELETE, TRUNCATE 같은 데이터 변경 구문을 실행하면 PostgreSQL은 즉시 2F003 에러를 발생시킵니다. 이는 옵티마이저가 해당 함수를 캐싱하거나 병렬 실행 계획에 포함시킬 수 있기 때문에, 부작용(side effect)이 있는 SQL을 허용하면 데이터 일관성이 깨질 수 있기 때문입니다.

2. 읽기 전용(Read-Only) 트랜잭션 또는 세션에서 쓰기 시도

SET TRANSACTION READ ONLY 또는 SET default_transaction_read_only = on으로 설정된 트랜잭션/세션 환경에서 데이터 변경 쿼리를 실행하면 이 에러가 발생합니다. 특히 PostgreSQL 스트리밍 복제(Streaming Replication)의 Standby 서버에 직접 연결하여 쓰기 쿼리를 실행할 경우에도 동일한 에러가 발생합니다. 핫 스탠바이(Hot Standby) 환경에서는 기본적으로 쓰기가 불가능하므로, 어플리케이션이 잘못된 서버에 연결되었는지 반드시 확인해야 합니다.

3. PL/pgSQL 함수 내 트리거에서 허용되지 않는 SQL 실행

BEFORE 또는 AFTER 트리거 함수 내에서 특정 제약 조건을 우회하거나 다른 테이블을 직접 수정하는 구문을 사용할 때 발생할 수 있습니다. 특히 STATEMENT 레벨 트리거에서 해당 트리거가 걸린 테이블 자체를 수정하거나, 읽기 전용으로 선언된 함수를 트리거로 등록한 경우에 이 에러가 발생합니다. 트리거 함수는 명시적으로 VOLATILE로 선언되어야 데이터 변경이 허용됩니다.


해결 방법

원인 1 해결: 함수의 변동성 분류 수정

IMMUTABLE 또는 STABLE로 잘못 선언된 함수를 VOLATILE로 변경합니다.

-- 문제가 되는 함수 예시 (STABLE로 선언되었지만 INSERT를 포함)
CREATE OR REPLACE FUNCTION log_access(user_id INT)
RETURNS VOID
LANGUAGE plpgsql
STABLE  -- ❌ 잘못된 선언: 데이터 변경이 있으므로 STABLE 불가
AS $$
BEGIN
    INSERT INTO access_log(user_id, access_time)
    VALUES (user_id, NOW());
END;
$$;

-- 해결: VOLATILE로 변경 (VOLATILE은 기본값이므로 생략 가능)
CREATE OR REPLACE FUNCTION log_access(user_id INT)
RETURNS VOID
LANGUAGE plpgsql
VOLATILE  -- ✅ 데이터 변경이 있는 함수는 반드시 VOLATILE
AS $$
BEGIN
    INSERT INTO access_log(user_id, access_time)
    VALUES (user_id, NOW());
END;
$$;
-- 현재 함수의 변동성 확인 쿼리
SELECT proname, provolatile
FROM pg_proc
WHERE proname = 'log_access';
-- provolatile: 'v' = VOLATILE, 's' = STABLE, 'i' = IMMUTABLE

원인 2 해결: 트랜잭션 읽기 전용 설정 확인 및 수정

-- 현재 세션이 읽기 전용인지 확인
SHOW default_transaction_read_only;
SHOW transaction_read_only;

-- 세션 레벨에서 읽기/쓰기 모드로 변경
SET SESSION default_transaction_read_only = off;

-- 트랜잭션 레벨에서 읽기/쓰기로 명시
BEGIN;
SET TRANSACTION READ WRITE;
INSERT INTO my_table(col1) VALUES ('data');
COMMIT;
-- 현재 접속된 서버가 Primary인지 Standby인지 확인
SELECT pg_is_in_recovery();
-- true = Standby (읽기 전용), false = Primary (읽기/쓰기 가능)

-- Standby 서버에서는 쓰기가 불가능하므로 Primary로 연결 전환 필요
-- 연결 문자열 예시 (libpq)
-- host=primary-server port=5432 dbname=mydb target_session_attrs=read-write

원인 3 해결: 트리거 함수 변동성 확인 및 수정

-- 트리거 함수는 반드시 VOLATILE로 선언
CREATE OR REPLACE FUNCTION trg_audit_update()
RETURNS TRIGGER
LANGUAGE plpgsql
VOLATILE  -- ✅ 트리거 함수는 항상 VOLATILE이어야 함
AS $$
BEGIN
    INSERT INTO audit_table(table_name, operation, changed_at)
    VALUES (TG_TABLE_NAME, TG_OP, NOW());
    RETURN NEW;
END;
$$;

-- 트리거 등록
CREATE TRIGGER audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION trg_audit_update();
-- 등록된 트리거 및 함수의 변동성 확인
SELECT 
    t.tgname AS trigger_name,
    p.proname AS function_name,
    CASE p.provolatile
        WHEN 'v' THEN 'VOLATILE'
        WHEN 's' THEN 'STABLE'
        WHEN 'i' THEN 'IMMUTABLE'
    END AS volatility
FROM pg_trigger t
JOIN pg_proc p ON t.tgfoid = p.oid
JOIN pg_class c ON t.tgrelid = c.oid
WHERE c.relname = 'orders';

예방 방법

1. 함수 생성 시 변동성 분류를 명시적으로 지정하고 코드 리뷰 체계화

함수를 생성하거나 수정할 때 VOLATILE, STABLE, IMMUTABLE 중 하나를 반드시 명시적으로 선언하는 코딩 컨벤션을 팀 내에 정착시키세요. PostgreSQL의 기본값은 VOLATILE이지만, 명시하지 않으면 의도치 않은 선언이 코드베이스에 섞일 수 있습니다. CI/CD 파이프라인에 함수 변동성 검증 스크립트를 포함하거나, 코드 리뷰 체크리스트에 “DML이 포함된 함수는 VOLATILE인가?”를 항목으로 추가하는 것을 강력히 권장합니다.

-- 데이터베이스 내 모든 STABLE/IMMUTABLE 함수 중 DML 가능성 있는 함수 점검
SELECT 
    n.nspname AS schema_name,
    p.proname AS function_name,
    CASE p.provolatile
        WHEN 's' THEN 'STABLE'
        WHEN 'i' THEN 'IMMUTABLE'
    END AS volatility,
    p.prosrc AS function_body
FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE p.provolatile IN ('s', 'i')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
  AND (p.prosrc ILIKE '%INSERT%' 
    OR p.prosrc ILIKE '%UPDATE%' 
    OR p.prosrc ILIKE '%DELETE%'
    OR p.prosrc ILIKE '%TRUNCATE%');

2. 읽기 전용 환경과 쓰기 환경의 연결 분리 및 모니터링

애플리케이션 레벨에서 읽기 전용 쿼리와 쓰기 쿼리의 DB 연결 풀을 명확히 분리하세요. PgBouncer나 HAProxy 같은 연결 풀러를 사용하여 target_session_attrs=read-write 옵션으로 Primary 서버에만 쓰기 연결이 가도록 라우팅하면, Standby 서버로 쓰기 쿼리가 잘못 전달되는 상황을 원천 차단할 수 있습니다. 또한 pg_stat_activity 뷰를 주기적으로 모니터링하여 Standby에서 2F003 에러 패턴이 발생하는지 로그로 추적하는 것이 좋습니다.


관련 에러

  • 2F000 (sql_routine_exception): SQL 루틴 실행 중 발생하는 일반적인 예외의 부모 클래스 에러 코드로, 2F003은 이 카테고리의 하위 에러입니다.
  • 25006 (read_only_sql_transaction): 읽기 전용 트랜잭션에서 쓰기를 시도할 때 발생하며, 2F003과 유사한 상황에서 함께 나타날 수 있습니다. Standby 서버 쓰기 시도 시 주로 이 에러가 발생합니다.
  • 42501 (insufficient_privilege): 함수 실행 권한 부족으로 발생하며, 보안 컨텍스트 문제로 2F003과 혼동될 수 있습니다.
  • 0A000 (feature_not_supported): 특정 컨텍스트에서 지원되지 않는 기능을 사용할 때 발생하며, PL 함수의 제약과 연관될 수 있습니다.

DBMS 에러 코드 시리즈

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

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

댓글 남기기