ORA-02291은 외래키(FK) 제약 조건을 위반할 때 발생하는 에러다. 자식 테이블에 INSERT·UPDATE를 시도했는데 참조하는 부모 키가 부모 테이블에 존재하지 않으면 즉시 던져진다. 데이터 정합성을 보장하기 위해 오라클이 가장 먼저 막아 주는 방어선이지만, 대량 적재나 ETL 환경에서는 골치 아픈 장애 원인이 되기도 한다. 본 글은 ORA-02291의 정확한 의미, 일곱 가지 발생 원인, 진단·디버깅 흐름, 그리고 일반 INSERT부터 대량 적재까지 시나리오별 해결 패턴을 정리한다.

주요 원인 / 로드 순서 뒤바뀜, 잘못된 FK 값, 트레일링 공백, 타입 불일치, 마이그레이션 잔재, 대량 적재 후 제약 활성화
근본 해법 / 부모 먼저 적재, MERGE로 보장,
DEFERRABLE 제약, DISABLE/ENABLE NOVALIDATE목차
오라클 공식 설명은 "외래 키 값이 매칭되는 기본 키 값을 갖지 못함"이다. 즉 자식 행이 가리키는 부모 키가 부모 테이블에 실제로 존재해야 한다. 메시지에 표시되는
SCHEMA.CONSTRAINT_NAME이 곧 위반된 외래키 제약의 이름이다. 디버깅의 첫 단추는 이 이름으로 정확히 어떤 컬럼이 무엇을 참조하는지 확인하는 것이다.1. ORA-02291이 던져지는 경로
ORA-02291은 다음 두 시점에서만 발생한다.
- 자식 테이블 INSERT ━ 새 자식 행의 FK 값이 부모에 없음
- 자식 테이블 UPDATE ━ 자식 행의 FK 값을 부모에 없는 값으로 변경
부모 테이블의 INSERT·DELETE는 이 에러를 일으키지 않는다. 부모 DELETE 시 자식이 참조 중이면 ORA-02292(child record found)가 발생한다. 두 에러는 검사 방향이 반대일 뿐 같은 무결성 메커니즘의 양면이다.
2. 발생 원인 일곱 가지
원인 1 / 부모-자식 로드 순서 뒤바뀜
가장 흔한 원인이다. ETL 또는 데이터 마이그레이션 스크립트가 자식 테이블을 먼저 적재하면 그 시점에 부모 행이 없어 즉시 위반한다.
-- 잘못된 순서
INSERT INTO emp(emp_id, dept_id) VALUES (1001, 10);
-- ORA-02291: dept(10)이 아직 없음
-- 올바른 순서
INSERT INTO dept(dept_id, name) VALUES (10, 'SALES');
INSERT INTO emp(emp_id, dept_id) VALUES (1001, 10);
원인 2 / 잘못된 FK 값
애플리케이션 버그·오타·하드코딩된 값으로 인해 부모에 없는 ID가 자식 FK로 들어오는 경우다. 사용자가 화면에서 선택한 값과 실제 DB의 값이 어긋날 때도 자주 발생한다.
원인 3 / 트레일링 공백
VARCHAR2 FK에서 흔하다. 부모는 'A'로 저장돼 있는데 자식이 'A '(공백 포함)을 넣으면 두 값은 다르게 인식돼 ORA-02291이 발생한다. CHAR 타입과 VARCHAR2 타입을 혼용할 때도 패딩 차이로 같은 문제가 생긴다.
-- 트레일링 공백 함정
INSERT INTO p(k) VALUES ('A');
INSERT INTO c(k) VALUES ('A ');
-- ORA-02291: 'A '와 'A'는 다른 값
원인 4 / 데이터 타입 불일치
FK와 PK의 데이터 타입이 미세하게 다른 경우다. NUMBER vs VARCHAR2 같은 명백한 차이는 테이블 생성 시점에 막히지만, CHAR(8) vs VARCHAR2(8) 또는 NLS 정렬이 다른 컬럼 같은 경우는 런타임에 비교 결과가 어긋날 수 있다.
원인 5 / 케이스(대소문자) 차이
VARCHAR2 FK에서 부모는 'A001'인데 자식이 'a001'을 넣으면 위반된다. 오라클 기본 비교는 케이스 민감이므로 두 값은 서로 다른 키로 인식된다.
원인 6 / 마이그레이션 잔재
테이블 구조 변경이나 시스템 마이그레이션 후 자식 테이블이 잘못된 부모 테이블을 참조하도록 남아 있는 경우다. 자식 코드는 새 부모를 가정하고 동작하는데 제약은 옛 부모를 가리키면 무한히 ORA-02291이 발생한다.
원인 7 / 대량 적재 후 제약 활성화
DISABLE NOVALIDATE로 제약을 꺼 둔 채 데이터를 로드한 뒤, 고아 행이 남아 있는 상태에서 ENABLE VALIDATE를 시도하면 전체 행 검증 단계에서 위반된 행을 발견하고 에러를 던진다. 이 경우 한 행만이 아니라 모든 고아 행을 미리 찾아 처리해야 한다.
3. 재현 시나리오
전체 흐름을 한 번에 보여주는 예시는 다음과 같다.
-- 부모·자식 테이블 생성
CREATE TABLE dept (
dept_id NUMBER PRIMARY KEY,
name VARCHAR2(50)
);
CREATE TABLE emp (
emp_id NUMBER PRIMARY KEY,
dept_id NUMBER,
CONSTRAINT fk_emp_dept
FOREIGN KEY (dept_id) REFERENCES dept(dept_id)
);
-- 부모 행 적재
INSERT INTO dept VALUES (10, 'SALES');
-- 위반: 99는 dept에 없음
INSERT INTO emp VALUES (1001, 99);
-- ORA-02291: integrity constraint (SCOTT.FK_EMP_DEPT) violated
-- 위반: UPDATE로 잘못된 FK 변경
INSERT INTO emp VALUES (1002, 10);
UPDATE emp SET dept_id = 99 WHERE emp_id = 1002;
-- ORA-02291
4. 제약 조사 쿼리
ORA-02291 메시지의 제약 이름으로부터 어떤 컬럼이 어떤 부모를 참조하는지 즉시 확인한다.
-- 자식 테이블의 FK와 참조 부모 정보 한번에 조회
SELECT c.constraint_name,
c.table_name AS child_table,
cc.column_name AS child_col,
r.table_name AS parent_table,
rc.column_name AS parent_col,
c.delete_rule, c.status, c.deferrable, c.deferred
FROM user_constraints c
JOIN user_cons_columns cc ON cc.constraint_name = c.constraint_name
JOIN user_constraints r ON r.constraint_name = c.r_constraint_name
JOIN user_cons_columns rc ON rc.constraint_name = c.r_constraint_name
AND rc.position = cc.position
WHERE c.constraint_type = 'R'
AND c.table_name = 'EMP';
-- 고아 자식 행 탐색 (대량 적재 후 검증 전 필수)
SELECT e.*
FROM emp e
LEFT JOIN dept d ON d.dept_id = e.dept_id
WHERE e.dept_id IS NOT NULL
AND d.dept_id IS NULL;
5. 해결 방법
패턴 A / 사전 존재 확인 또는 MERGE로 보장
가장 직관적인 해법이다. 자식 INSERT 전에 부모가 있는지 확인하거나, 없으면 자동 생성하는 패턴이다.
-- PL/SQL: 사전 존재 확인
DECLARE
v_cnt NUMBER;
BEGIN
SELECT COUNT(*) INTO v_cnt
FROM dept WHERE dept_id = :id;
IF v_cnt = 0 THEN
INSERT INTO dept(dept_id, name) VALUES (:id, :nm);
END IF;
INSERT INTO emp(emp_id, dept_id) VALUES (:eid, :id);
END;
-- MERGE로 부모 자동 보장 후 자식 적재
MERGE INTO dept d
USING (SELECT :id AS dept_id, :nm AS name FROM dual) s
ON (d.dept_id = s.dept_id)
WHEN NOT MATCHED THEN
INSERT (dept_id, name) VALUES (s.dept_id, s.name);
INSERT INTO emp VALUES (1001, :id);
패턴 B / DEFERRABLE 제약으로 트랜잭션 내 자유 순서
부모와 자식 적재 순서를 자유롭게 두고 싶다면 제약을 DEFERRABLE로 정의한다. 트랜잭션 내에서는 순서 무관하게 작업하고 COMMIT 시점에 한꺼번에 검증된다.
-- 제약을 DEFERRABLE로 재정의
ALTER TABLE emp DROP CONSTRAINT fk_emp_dept;
ALTER TABLE emp ADD CONSTRAINT fk_emp_dept
FOREIGN KEY (dept_id) REFERENCES dept(dept_id)
DEFERRABLE INITIALLY IMMEDIATE;
-- 트랜잭션 내에서 검증을 COMMIT으로 지연
SET CONSTRAINTS fk_emp_dept DEFERRED;
INSERT INTO emp VALUES (1001, 10);
INSERT INTO dept VALUES (10, 'SALES');
COMMIT; -- 여기서 한꺼번에 검증
패턴 C / ON DELETE CASCADE / SET NULL
부모 삭제 시 자식을 어떻게 처리할지 미리 정의해 두면 운영 시 ORA-02292·02291 양쪽 모두를 줄일 수 있다.
-- 부모 삭제 시 자식도 함께 삭제
ALTER TABLE emp ADD CONSTRAINT fk_emp_dept
FOREIGN KEY (dept_id) REFERENCES dept(dept_id)
ON DELETE CASCADE;
-- 부모 삭제 시 자식 FK를 NULL로
ALTER TABLE emp ADD CONSTRAINT fk_emp_dept
FOREIGN KEY (dept_id) REFERENCES dept(dept_id)
ON DELETE SET NULL;
패턴 D / 대량 적재 시 DISABLE/ENABLE NOVALIDATE
마이그레이션·초기 적재처럼 일시적으로 무결성 검증을 끄고 대량 적재해야 한다면 NOVALIDATE를 사용한다. 기존 데이터를 재검사하지 않으므로 매우 빠르다.
-- 적재 전: 제약 비활성
ALTER TABLE emp MODIFY CONSTRAINT fk_emp_dept DISABLE NOVALIDATE;
-- 대량 적재 수행 (SQL*Loader, INSERT /*+ APPEND */ 등)
-- 적재 후: 기존 행은 검증 스킵, 신규 DML만 제약 적용
ALTER TABLE emp MODIFY CONSTRAINT fk_emp_dept ENABLE NOVALIDATE;
-- 백그라운드 전체 검증 (DML 락 없이 진행)
ALTER TABLE emp MODIFY CONSTRAINT fk_emp_dept ENABLE VALIDATE;
-- 검증 중 위반 행을 별도 테이블로 분리
ALTER TABLE emp MODIFY CONSTRAINT fk_emp_dept
ENABLE VALIDATE EXCEPTIONS INTO exceptions;
NOVALIDATE는 "기존 행은 따지지 말고 신규 DML만 막아라"이고, VALIDATE는 "기존 행까지 모두 검증하라"이다. 마이그레이션 직후엔 NOVALIDATE로 운영을 시작하고, 데이터 정리 후 VALIDATE로 전환하는 두 단계 패턴이 안전하다.
6. 인접 에러와의 구분
| 에러 코드 | 검사 대상 | 전형적 시나리오 |
|---|---|---|
| ORA-02291 | 참조 무결성 (자식 → 부모) | 자식 INSERT·UPDATE 시 부모 없음 |
| ORA-02292 | 참조 무결성 (부모 → 자식) | 부모 DELETE 시 자식이 참조 중 |
| ORA-00001 | 유일성 제약 | PK·UK 중복 값 INSERT |
| ORA-02290 | CHECK 제약 | CHECK 조건 위반 (예: 음수 가격) |
| ORA-01400 | NOT NULL 제약 | NULL 불가 컬럼에 NULL INSERT |
NULL은 검증 대상에서 제외된다. FK 컬럼이 NOT NULL 제약이 별도로 없는 한 자식 행의 FK가 NULL이면 부모 매칭을 요구하지 않는다. "이 자식은 아직 부모와 연결되지 않았다"는 상태로 허용된다.
Q2. DEFERRABLE 제약은 항상 켜둬도 되나요?
대량 적재나 순환 참조가 필요한 시나리오가 아니라면 굳이 사용할 필요는 없다. INITIALLY IMMEDIATE 상태에서는 일반 즉시 검증 제약과 동일하게 동작하므로 정의해 두는 것만으로 성능 손실은 없다. 다만 DBA가 의도를 파악하기 어렵게 만들 수 있어 팀 컨벤션과 일치시키는 게 좋다.
Q3. ON DELETE CASCADE는 위험하지 않나요?
잘못 사용하면 한 번의 DELETE로 연쇄 삭제가 일어나 대량 데이터가 사라질 수 있다. 트랜잭션·롤백을 신중히 다뤄야 하는 환경에서는 SET NULL 또는 명시적 자식 삭제 후 부모 삭제 패턴이 더 안전하다. 대시보드·리포트성 통계 테이블에서는 CASCADE가 자연스럽다.
Q4. NOVALIDATE와 VALIDATE 중 무엇이 더 빠른가요?
ENABLE NOVALIDATE는 기존 행을 스캔하지 않으므로 대형 테이블에서도 즉시 완료된다. ENABLE VALIDATE는 전체 테이블 스캔이 필요해 시간이 오래 걸린다. 둘 다 결과적으로는 신규 DML에 제약을 강제하지만, 기존 행의 일관성 보증 여부에서 갈린다.
Q5. 트레일링 공백이나 케이스 차이를 자동으로 정규화할 방법이 있나요?
부모·자식 양쪽에 동일한 정규화 함수(TRIM, UPPER)를 적용한 함수 기반 인덱스를 만들거나, INSERT/UPDATE 트리거에서 통일된 형태로 변환할 수 있다. 다만 무결성 보증보다는 입력 단계 검증이 본질적인 해법이다. 데이터 모델링 시점에 CHAR/VARCHAR2 혼용을 피하는 것이 가장 깔끔하다.
7. 예방 체크리스트와 결론
ORA-02291은 데이터 모델 설계와 적재 순서를 신중히 다루면 거의 100% 차단할 수 있다. 새 테이블·새 ETL·새 마이그레이션을 준비할 때 다음 항목을 점검한다.
- ✓적재 순서: 부모 먼저, 자식 나중으로 표준화
- ✓자식 INSERT 전 MERGE로 부모 자동 보장 또는 사전 SELECT 확인
- ✓VARCHAR2 FK는 TRIM·UPPER 정규화로 트레일링 공백·케이스 차이 제거
- ✓FK와 PK는 동일한 데이터 타입·길이·NLS 정렬 사용
- ✓순환 참조·복잡한 의존 그래프는
DEFERRABLE INITIALLY IMMEDIATE로 설계 - ✓대량 적재:
DISABLE NOVALIDATE→ 적재 → 고아 정리 →ENABLE VALIDATE - ✓부모 삭제 정책 사전 결정: CASCADE·SET NULL·명시 삭제 중 선택
- ✓USER_CONSTRAINTS·USER_CONS_COLUMNS 조회 쿼리를 운영 매뉴얼에 포함
ORA-02291은 단순한 INSERT 실패가 아니라 데이터 무결성의 근간을 지키는 신호다. 자식이 가리킬 부모가 반드시 존재해야 한다는 규칙은 너무 자명해 보이지만, 적재 순서·정규화·마이그레이션·대량 처리가 얽히는 실제 운영에서는 의외로 자주 깨진다. 답은 두 가지로 요약된다 ━ 적재 순서를 표준화하고, 정상 경로(즉시 검증)와 예외 경로(DEFERRABLE·NOVALIDATE)를 명시적으로 분리한다. 이 두 가지만 일관되게 적용하면 ORA-02291은 운영 중 만나는 일이 거의 사라지는 에러가 된다.
#ORA02291 #parentkeynotfound #오라클에러 #외래키 #FK #INTEGRITY #USERCONSTRAINTS #DEFERRABLE #MERGE #CASCADE #NOVALIDATE #ORA02292 #데이터무결성 #대량적재 #ETL
댓글