본문 바로가기
Back-end/SpringBoot

Oracle + Quartz Batch 환경에서 PK Duplicate가 발생하는 이유

by backend 개발자 지망생 2025. 12. 23.

— 그리고 왜 이것을 “문제”가 아니라 “받아들여야 할 결과”로 봐야 하는가


1. 문제 상황

현재 운영 중인 시스템은 Quartz Scheduler를 사용하고 있으며,
2개의 애플리케이션 인스턴스가 클러스터링된 환경에서
cron trigger 기반으로 배치 작업이 실행되고 있다.

quartz.properties 설정을 살펴보면 다음과 같은 클러스터링 관련 설정이 존재했다.

  • org.quartz.jobStore.isClustered = true
  • misfire 관련 설정 등

이로 인해 처음에는 다음과 같이 인식하고 있었다.

“Quartz가 클러스터링 환경에서
동일한 Job이 동시에 실행되지 않도록 제어해주고 있을 것이다.”

그러나 운영 로그를 분석한 결과,
동일한 배치 Job이 두 개의 인스턴스에서 동시에 실행된 흔적이 발견되었다.

  • 동일한 실행 시각
  • 동일한 입력 데이터
  • 동일한 비즈니스 로직
  • 거의 동일한 실행 시간

즉, 명백히 동시 실행이 발생하고 있었다.


2. Quartz의 isClustered 설정에 대한 오해

문제를 파고들면서 가장 먼저 짚고 넘어가야 했던 부분은
isClustered = true 설정의 실제 의미였다.

결론부터 말하면:

Quartz의 isClustered 설정은
“Trigger 관리”를 위한 설정이지,
“Job 실행 자체를 단독으로 보장하는 설정이 아니다.”

isClustered의 실제 역할

  • 여러 인스턴스가 하나의 Quartz DB를 공유할 수 있도록 함
  • Trigger 상태, misfire 처리 등을 DB 기준으로 조율
  • 장애 발생 시 다른 인스턴스가 Trigger를 이어받을 수 있게 함

하지만 중요한 사실은:

Job 실행에 대해 강제적인 Lock을 걸어주지는 않는다.

즉,

  • 같은 시점에
  • 같은 Job이
  • 다른 인스턴스에서

**동시에 실행되는 것은 Quartz 관점에서는 “허용된 동작”**이다.


3. 동시 실행에 대한 해결책으로 @DisallowConcurrentExecution 검토

Quartz 공식 문서를 살펴보면
Job의 동시 실행을 제한하기 위한 방법으로
@DisallowConcurrentExecution 어노테이션이 제공된다.

 
@DisallowConcurrentExecution
public class SampleBatchJob implements Job { ... }

 

이 어노테이션의 의미는 다음과 같다.

동일한 JobDetail (jobKey 기준)에 대해
동시에 두 개 이상의 실행을 허용하지 않는다.

 

구현 방식은 Quartz 내부적으로:

  • Job 실행 시
  • Quartz DB의 JobDetail row에 대해
  • Lock을 획득하여
  • 단독 실행을 보장하는 방식이다.

따라서 이 어노테이션을 적용하면
클러스터 전체에서 해당 Job은 동시에 실행되지 않는다.


4. 그러나, 운영 환경에서는 적용이 어려웠다

문제는 여기서 끝이 아니었다.

기존 운영 시스템에서는:

  • @DisallowConcurrentExecution을 사용하지 않고 있었고
  • 새로운 동시성 제어 어노테이션 도입에 대해
    • 기존 배치 전체 영향도 파악이 어렵다
    • 예상치 못한 스케줄 지연 가능성
    • 운영 안정성 우려

등의 이유로 도입이 반려되었다.

결과적으로 다음과 같은 상황에 놓이게 되었다.

  • 로그 상으로는 동시 실행이 명확함
  • 하나의 트랜잭션은 성공
  • 다른 하나는 실패
  • 하지만 “동시성 문제”라는 것을
    기술적으로 입증하지 못하면
    단순 로직 오류로 치부될 수 있는 상황

5. 로직 레벨에서의 보완 시도 — MERGE 사용

억울함(?)을 차치하고,
현실적으로 로직으로 커버할 수 있는지를 검토하기로 했다.

기존에는 단순 INSERT 로직이었기 때문에,
“이미 데이터가 존재하면 Insert를 하지 않도록”
Oracle의 MERGE 문을 사용하기로 했다.

 
MERGE INTO target_table t
USING source_data s
ON (t.pk1 = s.pk1 AND t.pk2 = s.pk2)
WHEN NOT MATCHED THEN INSERT (...) VALUES (...);

 

의도는 명확했다.

“이미 존재하면 최소한 INSERT는 안 되겠지.”

하지만 결과는 예상과 달랐다.

👉 MERGE를 사용했음에도 동일하게 PK Duplicate 에러 발생


6. “왜 MERGE인데도 PK DUP이 발생하는가?”

이 지점이 이 문제의 핵심이다.

많은 개발자가 MERGE를 이렇게 인식한다.

“SELECT로 한 번 보고,
없으면 INSERT, 있으면 UPDATE”

 

하지만 Oracle의 MERGE는 그렇게 동작하지 않는다.

Oracle MERGE의 분기 기준

MERGE의 MATCH / NOT MATCHED 판단은
‘쿼리 시작 시점의 일관된 읽기(Consistent Read)’ 기준이다.

즉,

  • 두 트랜잭션이 거의 동시에 MERGE를 시작하면
  • 둘 다 “해당 PK가 없다”고 판단할 수 있다
  • 이후 둘 다 INSERT 경로로 진입한다

그리고 이 시점부터는:

DB 제약 조건이 최종 판정자가 된다.


7. 정확한 입증을 위한 실험 — 두 개의 세션에서 MERGE 동시 실행

정확한 원인을 확인하기 위해
DB에서 두 개의 세션을 열고,
동일한 MERGE 문을 동시에 실행해보았다.

실험 과정은 다음과 같다.

  1. Session A
    • MERGE 실행
    • COMMIT 하지 않음
  2. Session B
    • 동일한 MERGE 실행
    • 대기 상태 발생 (lock 걸린 것처럼 보임)
  3. Session A
    • COMMIT 수행
  4. Session B
    • 대기 해제
    • ORA-00001 (PK DUP) 발생

8. 이 Lock의 정체는 무엇이었는가?

여기서 중요한 결론이 나온다.

이 대기는 row lock이 아니었다.

  • 존재하지 않는 row에는 row lock을 걸 수 없다
  • MERGE의 NOT MATCHED 경로에서는 UPDATE row lock도 없다

그럼 무엇이었을까?

정답

유니크 인덱스 키에 대한 트랜잭션 락 (TX enqueue) 경합

즉,

  • Session A가 INSERT를 시도하면서
  • PK(유니크 인덱스)에 대한 키를 먼저 점유
  • COMMIT 전까지 해당 인덱스 키는 트랜잭션 락 상태
  • Session B는 같은 인덱스 키를 필요로 하므로 대기
  • COMMIT 이후:
    • 존재가 확정되면 → PK DUP
    • 롤백이면 → INSERT 성공

👉 Oracle이 의도한, 정상적인 동작


9. 실행 시간 차이(203ms vs 210ms)의 의미

실제 로그를 보면:

  • 성공한 트랜잭션: 203ms
  • 실패한 트랜잭션: 210ms

이 차이는 다음이 포함된 결과다.

  • 유니크 인덱스 키 락 대기 시간 (짧게)
  • PK DUP 예외 생성 비용
  • 트랜잭션 롤백 처리 비용

즉,

“실패한 쪽이 조금 더 느린 것”은
동시성 경합 상황에서 매우 자연스러운 결과
다.


10. 결론 — 이 문제를 어떻게 바라봐야 하는가

핵심 정리

  • Quartz 클러스터링은 Job 단독 실행을 보장하지 않는다
  • @DisallowConcurrentExecution은 효과적인 해결책이지만
    • 운영 환경 제약으로 도입이 어려울 수 있다
  • MERGE는 동시성 해결책이 아니다
  • PK Duplicate는:
    • 버그 ❌
    • 로직 오류 ❌
    • DB 이상 ❌
    • 동시성 경합의 정상적인 결과 ⭕

11. 그래서 PK Duplicate를 “받아들여야” 하는 이유

이 배치는 다음 특성을 가진다.

  • 동일 입력 → 동일 결과
  • 이미 처리된 데이터는 다시 처리할 필요 없음
  • 멱등(Idempotent)한 성격

이런 배치에서는:

 
catch (DuplicateKeyException e) 
{ log.warn("이미 처리된 데이터 (동시성 경합): {}", e.getMessage()); return; }
 

이 코드는 임시방편이 아니라 설계적 선택이다.

“동시성은 막을 수 없을 수 있지만,
그 결과를 시스템이 받아들일 수 있도록 설계하는 것”

이것이 실무에서의 현실적인 해법이다.


12. 맺으며(LLM의 첨언..)

이번 이슈를 통해 얻은 가장 큰 교훈은 이것이다.

Quartz는 실행을 조율할 뿐이고,
DB는 진실을 결정하며,
애플리케이션은 그 결과를 해석해야 한다.

PK Duplicate를 “없애야 할 에러”로만 보면
끝없이 같은 문제를 반복하게 된다.

하지만 이를:

  • 동시성
  • Race Condition
  • 유니크 인덱스 키 락

이라는 구조로 이해하는 순간,
문제는 해결 대상이 아니라 설계 선택의 영역으로 이동한다.