2026년 08월 26일 | DBMS Error 가이드
이 글에서 다루는 내용
ORA-04091 에러의 원인 분석, 해결 SQL, 예방 방법을 실무 관점에서 정리합니다.
ORA-04091 table is mutating, trigger/function may not see it 는?
ORA-04091은 DML(INSERT, UPDATE, DELETE) 작업 중 트리거가 실행될 때, 해당 트리거가 현재 변경 중인(mutating) 테이블을 읽거나 수정하려 할 때 발생하는 에러입니다. Oracle은 데이터 일관성을 보장하기 위해 트리거 실행 시점에 해당 테이블이 불완전한 상태(변경 진행 중)에 있을 경우 접근을 차단하며, 이로 인해 이 에러가 발생합니다. 특히 행 수준(ROW LEVEL) 트리거에서 자주 발생하며, 테이블에 대한 집계 함수나 SELECT 문을 사용할 때 빈번하게 나타납니다.
주요 발생 원인
1. 행 수준 트리거에서 동일 테이블 SELECT
가장 흔한 원인으로, FOR EACH ROW 트리거 내부에서 트리거가 부착된 바로 그 테이블을 SELECT하려 할 때 발생합니다. Oracle은 해당 트리거가 실행되는 시점에 테이블이 아직 변경 중인 상태이므로 읽기 자체를 허용하지 않으며, 이는 데이터 일관성 보호를 위한 Oracle의 핵심 메커니즘입니다.
2. 행 수준 트리거에서 동일 테이블 UPDATE/DELETE
트리거 내부에서 트리거가 걸린 테이블에 대해 UPDATE나 DELETE를 실행하면 ORA-04091이 발생합니다. 예를 들어 직원 급여 테이블에 UPDATE 트리거를 달고, 그 트리거 내부에서 다시 동일 테이블의 다른 컬럼을 수정하려는 경우가 대표적입니다. 이런 패턴은 무한 루프나 데이터 불일치를 유발할 수 있어 Oracle이 원천적으로 차단합니다.
3. 중첩 트리거(Cascading Trigger)로 인한 연쇄 발생
하나의 DML이 트리거를 실행하고, 그 트리거 내부의 DML이 또 다른 트리거를 실행하는 중첩 구조에서 결국 최초 mutating 테이블에 접근하는 경우 발생합니다. 이 경우 에러의 근본 원인을 추적하기가 어렵고, 복잡한 비즈니스 로직이 트리거 체인으로 구현되어 있을수록 더 자주 발생합니다.
해결 방법
해결책 1: 컴파운드 트리거(Compound Trigger) 사용 – Oracle 11g 이상 권장
가장 현대적이고 깔끔한 해결 방법입니다. 컴파운드 트리거는 하나의 트리거 안에서 여러 타이밍 포인트(BEFORE STATEMENT, BEFORE EACH ROW, AFTER EACH ROW, AFTER STATEMENT)를 사용할 수 있어, 행 수준에서 값을 수집하고 문장 수준에서 처리하는 방식으로 mutating 문제를 우회합니다.
-- 예시: 부서별 급여 합계를 직원 테이블에서 계산하는 컴파운드 트리거
CREATE OR REPLACE TRIGGER trg_emp_salary_check
FOR INSERT OR UPDATE ON employees
COMPOUND TRIGGER
-- 영향받는 부서 ID를 저장할 컬렉션
TYPE t_dept_ids IS TABLE OF employees.department_id%TYPE;
v_dept_ids t_dept_ids := t_dept_ids();
AFTER EACH ROW IS
BEGIN
-- 행 수준: 변경된 부서 ID만 수집 (테이블 직접 조회 없음)
v_dept_ids.EXTEND;
v_dept_ids(v_dept_ids.LAST) := :NEW.department_id;
END AFTER EACH ROW;
AFTER STATEMENT IS
v_total_salary NUMBER;
BEGIN
-- 문장 수준: DML 완료 후 안전하게 테이블 조회 가능
FOR i IN 1 .. v_dept_ids.COUNT LOOP
SELECT SUM(salary)
INTO v_total_salary
FROM employees
WHERE department_id = v_dept_ids(i);
IF v_total_salary > 500000 THEN
RAISE_APPLICATION_ERROR(-20001,
'부서 ' || v_dept_ids(i) || '의 급여 합계가 한도를 초과했습니다.');
END IF;
END LOOP;
END AFTER STATEMENT;
END trg_emp_salary_check;
/
해결책 2: 패키지 변수(Package-level Variable) 활용 – Oracle 10g 이하 호환
컴파운드 트리거를 사용할 수 없는 구버전 환경에서는 패키지 전역 변수를 임시 저장소로 활용합니다. 행 수준 트리거에서 필요한 값을 패키지 변수에 저장하고, 문장 수준 트리거에서 해당 값을 이용해 처리합니다.
-- Step 1: 임시 저장용 패키지 생성
CREATE OR REPLACE PACKAGE pkg_emp_audit AS
TYPE t_emp_id_list IS TABLE OF employees.employee_id%TYPE
INDEX BY PLS_INTEGER;
g_emp_ids t_emp_id_list;
g_count PLS_INTEGER := 0;
END pkg_emp_audit;
/
-- Step 2: 행 수준 트리거 - 패키지 변수에 저장만 수행
CREATE OR REPLACE TRIGGER trg_emp_row
AFTER UPDATE OF salary ON employees
FOR EACH ROW
BEGIN
pkg_emp_audit.g_count := pkg_emp_audit.g_count + 1;
pkg_emp_audit.g_emp_ids(pkg_emp_audit.g_count) := :NEW.employee_id;
END trg_emp_row;
/
-- Step 3: 문장 수준 트리거 - 실제 처리 수행
CREATE OR REPLACE TRIGGER trg_emp_statement
AFTER UPDATE OF salary ON employees
DECLARE
v_dept_id employees.department_id%TYPE;
BEGIN
FOR i IN 1 .. pkg_emp_audit.g_count LOOP
-- DML 완료 후이므로 안전하게 테이블 조회 가능
SELECT department_id
INTO v_dept_id
FROM employees
WHERE employee_id = pkg_emp_audit.g_emp_ids(i);
INSERT INTO emp_audit_log (employee_id, department_id, change_date)
VALUES (pkg_emp_audit.g_emp_ids(i), v_dept_id, SYSDATE);
END LOOP;
-- 패키지 변수 초기화 (중요!)
pkg_emp_audit.g_count := 0;
pkg_emp_audit.g_emp_ids.DELETE;
END trg_emp_statement;
/
해결책 3: 자율 트랜잭션(Autonomous Transaction) – 제한적 사용
자율 트랜잭션(PRAGMA AUTONOMOUS_TRANSACTION)을 선언하면 트리거가 별도의 독립적인 트랜잭션으로 실행되어 mutating 제약을 피할 수 있습니다. 단, 이 방법은 원래 트랜잭션과 독립적으로 커밋/롤백되므로 데이터 일관성 문제가 발생할 수 있어 로깅, 감사(Audit) 목적에만 제한적으로 사용해야 합니다.
-- 감사 로그 전용 자율 트랜잭션 트리거 예시
CREATE OR REPLACE TRIGGER trg_emp_audit_log
AFTER UPDATE ON employees
FOR EACH ROW
DECLARE
PRAGMA AUTONOMOUS_TRANSACTION; -- 독립 트랜잭션 선언
BEGIN
-- 원래 트랜잭션과 무관하게 별도 커밋
INSERT INTO emp_audit_log (
employee_id,
old_salary,
new_salary,
changed_by,
changed_at
) VALUES (
:OLD.employee_id,
:OLD.salary,
:NEW.salary,
SYS_CONTEXT('USERENV', 'SESSION_USER'),
SYSTIMESTAMP
);
COMMIT; -- 자율 트랜잭션 커밋
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
RAISE;
END trg_emp_audit_log;
/
해결책 4: 트리거 대신 비즈니스 로직을 애플리케이션 레이어로 이동
때로는 가장 좋은 해결책이 트리거 자체를 없애는 것입니다. 복잡한 비즈니스 로직은 저장 프로시저나 애플리케이션 레이어에서 처리하고, 트리거는 단순 감사나 기본값 설정 목적으로만 유지하는 것이 권장됩니다.
-- 트리거 대신 저장 프로시저에서 비즈니스 로직 처리
CREATE OR REPLACE PROCEDURE proc_update_employee_salary (
p_employee_id IN employees.employee_id%TYPE,
p_new_salary IN employees.salary%TYPE
) AS
v_dept_id employees.department_id%TYPE;
v_total_salary NUMBER;
BEGIN
-- 1단계: 급여 업데이트
UPDATE employees
SET salary = p_new_salary
WHERE employee_id = p_employee_id;
-- 2단계: 부서 조회 및 유효성 검사 (트리거 없이 안전하게 수행)
SELECT department_id
INTO v_dept_id
FROM employees
WHERE employee_id = p_employee_id;
SELECT SUM(salary)
INTO v_total_salary
FROM employees
WHERE department_id = v_dept_id;
IF v_total_salary > 500000 THEN
ROLLBACK;
RAISE_APPLICATION_ERROR(-20002, '부서 급여 한도 초과');
END IF;
COMMIT;
END proc_update_employee_salary;
/
예방 방법
1. 트리거 설계 원칙 준수 – 행 수준 트리거에서 동일 테이블 참조 금지
트리거를 설계할 때 행 수준 트리거(FOR EACH ROW)에서는 절대로 자기 자신의 테이블(트리거가 걸린 테이블)을 SELECT, INSERT, UPDATE, DELETE하지 않도록 코딩 표준을 수립하세요. Oracle 11g 이상 환경이라면 신규 트리거는 반드시 컴파운드 트리거로 작성하는 것을 팀 표준으로 채택하고, 코드 리뷰 시 이 원칙 위반 여부를 반드시 점검하도록 프로세스를 수립하는 것이 좋습니다.
2. 트리거 로직 최소화 및 정기적 검토
트리거에는 반드시 필요한 최소한의 로직만 담아야 합니다. 복잡한 비즈니스 로직, 특히 다른 테이블을 연쇄적으로 변경하는 로직은 트리거 대신 저장 프로시저나 애플리케이션 레이어에서 처리하도록 아키텍처를 설계하세요. 또한 기존 시스템의 트리거 로직은 정기적으로(분기별 권장) 검토하여 불필요하게 복잡해진 트리거를 단순화하거나 재구성하는 것이 장기적인 운영 안정성을 높입니다.
관련 에러
- ORA-04092: COMMIT 또는 ROLLBACK은 트리거 내에서 수행할 수 없음 (자율 트랜잭션 제외). 트리거에서 트랜잭션 제어를 시도할 때 발생하며 ORA-04091과 함께 자주 등장합니다.
- ORA-04088: 트리거 실행 중 에러 발생. ORA-04091이 내부적으로 발생하면 ORA-04088이 상위 에러로 함께 보고되는 경우가 많습니다.
- ORA-00036: 최대 재귀 SQL 레벨 초과. 중첩 트리거가 너무 깊이 연쇄될 때 ORA-04091 이전에 먼저 발생할 수 있습니다.
- ORA-04098: 트리거가 유효하지 않으며 재검증에 실패함. 트리거 수정 후 컴파일 오류가 있을 때 발생하며, ORA-04091 수정 과정에서 문법 오류가 생기면 나타납니다.
주요 DBMS error code를 정리하는 시리즈입니다.
블로그 홈에서 다른 에러도 확인하세요.
본 포스트는 AI가 생성한 기술 가이드입니다. 운영 환경 적용 전 충분한 검토를 권장합니다.