Oracle 19c MERGE ORA-30926: 원본 중복을 찾고 안전하게 수정하는 방법

핵심 답변: Oracle 19c의 MERGE에서 ORA-30926이 나면 먼저 원본 집합이 같은 대상 키를 두 번 이상 매칭하는지 확인하세요. MERGE는 한 문장에서 같은 대상 행을 두 번 갱신할 수 없습니다. 원본 중복이 확인되면 중복 행을 삭제하기보다, 업무 규칙으로 “어느 행을 대표로 쓸지”를 정한 뒤 ROW_NUMBER()으로 원본을 한 행으로 축약해 실행합니다.

이 글은 Oracle Database 19c 기준입니다. ORA-30926은 원본 중복 외에도 비결정적인 조건이나 대량 동시 DML과 연관될 수 있으므로, 중복이 없으면 실행 시점의 원본과 조인 조건도 함께 점검해야 합니다. 운영 배치에서는 검증 쿼리와 ROLLBACK 가능한 트랜잭션 경계를 먼저 준비하세요.

핵심 답변
ORA-30926을 “MERGE 문법 오류”로 고치려 하지 마세요. 대상 키 하나에 대해 원본 행이 정확히 하나가 되도록 보장하는 것이 핵심입니다. 중복을 무조건 DISTINCT로 감추면 서로 다른 값 중 어느 값을 반영했는지 알 수 없어 데이터 품질 문제가 남습니다.

문제 상황: 동기화 배치가 ORA-30926으로 멈출 때

주문 상태를 스테이징 테이블에서 운영 테이블로 반영한다고 가정하겠습니다. 같은 고객의 상태가 한 배치에 두 번 들어오면, 두 원본 행이 하나의 customer_status 행을 갱신하려 하므로 실패할 수 있습니다.

항목 이 글의 기준 운영에서 확인할 점
제품·버전 Oracle Database 19c 패치 레벨과 실제 오류 전문을 기록
대상 키 customer_id 1행당 1건 대상 PK/UK와 ON 조건이 같은 업무 키인지 확인
원본 대표 규칙 가장 늦은 수신 시각, 동률이면 가장 큰 적재 ID 업무 소유자가 승인한 순서 규칙인지 확인
권한 대상 INSERT·UPDATE, 원본 SELECT 트리거·VPD 정책 영향도 별도 점검

원리: MERGE는 대상 행을 한 번만 갱신해야 한다

MERGE는 원본(USING)과 대상(INTO)을 ON 조건으로 연결해 갱신 또는 삽입합니다. Oracle 19c 문서는 이를 결정적인 문장으로 설명하며, 같은 대상 행을 한 MERGE 문에서 여러 번 갱신할 수 없다고 명시합니다. 따라서 대상의 키가 유일하더라도 원본 쪽 키가 중복되면 문제가 됩니다.

ORA-30926은 현재 Oracle 오류 도움말에서 “둘 이상의 원본 행이 같은 대상 행과 매칭되어 두 번 갱신하려 한 경우”를 설명합니다. 같은 오류 텍스트에는 대량 DML 또는 비결정적인 조건으로 안정된 원본 집합을 얻지 못한 경우도 있으므로, 아래의 중복 검사가 0건이면 USING 서브쿼리의 비결정 조건과 배치 중 동시 변경을 다음 진단 대상으로 올리세요.

최소 재현 예제

다음은 개인 테스트 스키마에서만 실행하세요. 테이블 생성·삭제 같은 DDL은 암시적 커밋이 발생하므로 DDL 자체를 ROLLBACK할 수 없습니다. 예제의 데이터 변경은 마지막 ROLLBACK으로 되돌릴 수 있습니다.

CREATE TABLE customer_status (
  customer_id NUMBER       CONSTRAINT pk_customer_status PRIMARY KEY,
  status_code VARCHAR2(20) NOT NULL,
  changed_at  TIMESTAMP    NOT NULL
);

CREATE TABLE customer_status_stage (
  load_id     NUMBER       CONSTRAINT pk_customer_status_stage PRIMARY KEY,
  batch_id    NUMBER       NOT NULL,
  customer_id NUMBER       NOT NULL,
  status_code VARCHAR2(20) NOT NULL,
  received_at TIMESTAMP    NOT NULL
);

INSERT INTO customer_status (customer_id, status_code, changed_at)
VALUES (1001, 'PENDING', TIMESTAMP '2026-08-10 09:00:00');

INSERT INTO customer_status_stage
  (load_id, batch_id, customer_id, status_code, received_at)
VALUES
  (1, 9001, 1001, 'PAID', TIMESTAMP '2026-08-10 10:00:00');

INSERT INTO customer_status_stage
  (load_id, batch_id, customer_id, status_code, received_at)
VALUES
  (2, 9001, 1001, 'CANCELLED', TIMESTAMP '2026-08-10 10:05:00');

위 예제에서 customer_id = 1001인 원본은 두 건입니다. 아래처럼 그대로 MERGE하면 같은 대상 행에 두 원본 행이 연결됩니다. 이 SQL은 오류 재현용이므로 운영에서 실행하지 마세요.

MERGE INTO customer_status t
USING (
  SELECT customer_id, status_code, received_at
  FROM customer_status_stage
  WHERE batch_id = :batch_id
) s
ON (t.customer_id = s.customer_id)
WHEN MATCHED THEN
  UPDATE SET t.status_code = s.status_code,
             t.changed_at  = s.received_at
WHEN NOT MATCHED THEN
  INSERT (customer_id, status_code, changed_at)
  VALUES (s.customer_id, s.status_code, s.received_at);

목적: 원본 중복을 그대로 둔 실패 조건을 보여 줍니다. 권한: 대상 INSERT·UPDATE, 원본 SELECT. 예상 결과: 같은 대상 행을 두 번 갱신하려는 경우 ORA-30926이 발생할 수 있습니다. 애플리케이션에서는 :batch_id를 바인드 변수로 전달하세요.

실행 전 확인: 중복은 먼저 읽기 전용 SQL로 찾는다

운영 배치의 MERGE를 다시 실행하기 전에, 같은 배치와 같은 대상 키가 몇 번 들어왔는지 확인합니다. 이 쿼리는 DML이 아니며 필요한 컬럼만 읽습니다.

SELECT s.customer_id,
       COUNT(*) AS source_row_count,
       MIN(s.received_at) AS first_received_at,
       MAX(s.received_at) AS last_received_at
FROM customer_status_stage s
WHERE s.batch_id = :batch_id
GROUP BY s.customer_id
HAVING COUNT(*) > 1
ORDER BY source_row_count DESC, s.customer_id;

목적: 대상 키당 원본이 둘 이상인 행을 찾습니다. 권한: 스테이징 테이블 SELECT. 예상 결과: 결과가 없으면 “원본 중복” 원인은 낮아집니다. 결과가 있으면 삭제 전에 적재 재전송인지, 실제 상태 변경 이력인지 구분해야 합니다.

실행 전 확인
중복 두 건의 status_code가 다르면 DISTINCT는 해결책이 아닙니다. 최신 수신 시각, 원본 시스템의 버전 번호, 우선순위 같은 결정 가능한 업무 규칙을 정하고, 동률까지 깨는 유일한 보조 키를 포함하세요.

안전한 작업 SQL: 원본을 한 행으로 축약한 뒤 MERGE

여기서는 “가장 늦게 수신한 행을 사용하고, 수신 시각이 같으면 가장 큰 load_id를 사용한다”는 규칙을 사용합니다. load_id는 스테이징 테이블의 PK이므로 동률을 확실히 끝낼 수 있습니다. 다른 업무 규칙이면 ORDER BY만 임의로 바꾸지 말고 규칙 자체를 검토하세요.

MERGE INTO customer_status t
USING (
  SELECT x.customer_id,
         x.status_code,
         x.received_at
  FROM (
    SELECT s.customer_id,
           s.status_code,
           s.received_at,
           ROW_NUMBER() OVER (
             PARTITION BY s.customer_id
             ORDER BY s.received_at DESC, s.load_id DESC
           ) AS rn
    FROM customer_status_stage s
    WHERE s.batch_id = :batch_id
  ) x
  WHERE x.rn = 1
) s
ON (t.customer_id = s.customer_id)
WHEN MATCHED THEN
  UPDATE SET t.status_code = s.status_code,
             t.changed_at  = s.received_at
WHEN NOT MATCHED THEN
  INSERT (customer_id, status_code, changed_at)
  VALUES (s.customer_id, s.status_code, s.received_at);

목적: 대상 키별 원본을 정확히 한 행으로 만든 후 동기화합니다. 권한: 대상 INSERT·UPDATE, 원본 SELECT. 예상 결과: 예제에서는 고객 1001의 상태가 CANCELLED와 10:05 시각으로 갱신됩니다. 실제 실행 전에는 실행계획, 대상·원본 행 수, 트리거와 동시 배치를 점검해야 합니다.

실행 후 검증과 트랜잭션 경계

MERGE는 DML이므로 명시적 COMMIT 전에는 일반적으로 되돌릴 수 있습니다. 다만 트리거의 자율 트랜잭션, 외부 호출, 별도 커밋 같은 애플리케이션 설계는 이 경계를 바꿀 수 있습니다.

SELECT t.customer_id,
       t.status_code,
       t.changed_at
FROM customer_status t
WHERE t.customer_id = :customer_id;

ROLLBACK;

목적: 변경된 대상 행을 확인하고, 테스트·검증 단계의 변경을 취소합니다. 권한: 대상 SELECT, DML 실행 권한. 예상 결과: 검증 쿼리는 선택한 고객의 현재 상태를 한 행 반환합니다. 운영에서는 검증 결과와 영향 행 수를 승인한 뒤에만 COMMIT하세요.

운영 환경 주의
ROW_NUMBER()으로 원본을 줄였다고 해서 동시성 문제가 사라지는 것은 아닙니다. 스테이징 적재가 계속되는 중이거나 조인 조건에 비결정 함수가 있으면 실행 시점의 집합이 달라질 수 있습니다. 배치 입력을 닫은 뒤 같은 트랜잭션·같은 조건에서 사전 진단과 MERGE를 실행하고, 오류 시 오류 전문과 SQL ID, 배치 ID를 보존하세요.

흔한 실수

  • DISTINCT로 무조건 중복 제거: 키만 같고 상태나 시각이 다른 행은 하나로 합쳐지지 않습니다. 어떤 값을 채택할지 설명할 수 없으면 수정도 안전하지 않습니다.
  • 대상 PK만 믿기: 대상 키의 유일성은 필요하지만 충분하지 않습니다. USING 결과도 대상 키당 한 행이어야 합니다.
  • 임의의 ROWNUM = 1 사용: 정렬 규칙이 없으면 대표 행이 실행마다 달라질 수 있습니다. 유일한 타이브레이커를 포함한 ROW_NUMBER()을 사용하세요.
  • 오류 후 곧바로 재실행: 중복 적재나 동시 DML이 남아 있으면 같은 실패를 반복하거나, 일부 변경의 원인을 흐릴 수 있습니다. 먼저 읽기 전용 진단 결과를 남기세요.

정리

ORA-30926 대응의 순서는 단순합니다. ① 대상 키를 확인하고 ② USING 결과에서 같은 키의 원본 중복을 읽기 전용으로 찾은 뒤 ③ 업무 규칙으로 대표 행을 결정하고 ④ 트랜잭션 안에서 MERGE와 검증을 실행합니다. 중복이 없다면 비결정 조건과 동시 DML을 별도 원인으로 조사해야 합니다.

공식 출처