Todak-Todag

[Troubleshooting] Outbox Relay 다중 인스턴스 환경의 중복 발행 방지

HS0601 2026. 9. 20. 20:42

문제 정의

Care Plan Service는 Outbox Relay Scheduler가 주기적으로 PostgreSQL의 Outbox 테이블에서 PENDING 상태의 이벤트를 조회한 뒤 RabbitMQ로 발행하는 구조였습니다.

단일 인스턴스에서는 하나의 Scheduler만 동작하지만, 서비스가 여러 인스턴스로 확장되면 각 인스턴스에서 동일한 Scheduler가 독립적으로 실행됩니다.

따라서 동일한 PENDING 이벤트를 여러 인스턴스가 거의 동시에 조회할 수 있었습니다.

CarePlan Service A → PENDING 이벤트 조회
CarePlan Service B → 동일 PENDING 이벤트 조회

A → RabbitMQ 발행
B → RabbitMQ 발행

기존 구조에는 이벤트의 처리 권한을 선점하거나 동시 수정 충돌을 감지하는 장치가 없었기 때문에, Scale-out 환경에서는 동일한 Outbox 이벤트가 중복 발행될 가능성이 있었습니다.

원인 분석

기존 Relay 흐름은 다음과 같았습니다.

PENDING 조회
→ RabbitMQ 발행
→ 성공 시 SENT
→ 실패 시 retryCount 증가

문제는 PENDING 이벤트를 조회하는 시점과 실제 SENT 상태로 변경하는 시점이 분리되어 있다는 점이었습니다.

예를 들어 A 인스턴스가 이벤트를 조회했더라도 아직 상태가 PENDING이라면, B 인스턴스 역시 같은 이벤트를 조회할 수 있습니다.

A → PENDING 조회
B → PENDING 조회

A → 발행
B → 발행

즉 단순 조회만으로는 어떤 인스턴스가 해당 이벤트를 처리할지 결정되지 않았으며, 동일 이벤트에 대한 동시 처리 경쟁도 제어할 수 없었습니다.

해결 방안 검토

중복 발행을 방지하기 위해 다음 두 가지 방안을 비교했습니다.

방안  장점  트레이드오프
FOR UPDATE SKIP LOCKED DB 수준에서 동일 이벤트의 동시 처리를 직접 차단할 수 있음 RabbitMQ 발행까지 같은 트랜잭션에 포함하면 Row Lock과 Connection을 오래 점유할 수 있음
PENDING → PROCESSING + @Version 외부 I/O 동안 DB Lock을 유지하지 않으면서 하나의 인스턴스만 처리 권한을 획득할 수 있음 선점 후 인스턴스가 종료되면 PROCESSING 상태가 고착될 수 있어 복구 정책이 필요함

선택 및 적용

PENDING → PROCESSING 상태 선점과 @Version 기반 낙관적 락을 조합하는 방식을 선택했습니다.

가장 큰 이유는 RabbitMQ 발행과 같은 외부 네트워크 작업 동안 DB Row Lock을 유지하지 않으면서도, 다중 인스턴스 환경에서 동일 이벤트의 처리 주체를 하나로 제한하기 위해서입니다.

이벤트를 실제 발행하기 전에 먼저 PENDING → PROCESSING으로 상태를 변경해 처리 권한을 선점하도록 했습니다.

또한 두 인스턴스가 같은 PENDING 이벤트를 동시에 조회한 경우에도 둘 다 선점에 성공하지 않도록 Outbox 엔티티에 @Version 기반 낙관적 락을 적용했습니다.

초기 상태

status  = PENDING
version = 0

A → PENDING 조회(version=0)
B → PENDING 조회(version=0)

A → PROCESSING 변경 성공
     version = 1

B → version=0 기준 변경 시도
     현재 DB version=1
     ↓
Optimistic Lock 충돌
     ↓
선점 실패

PROCESSING은 해당 이벤트가 이미 다른 인스턴스에서 처리 중임을 나타내고, @Version은 동시에 선점을 시도한 여러 인스턴스 중 하나만 상태 변경에 성공하도록 충돌을 감지하는 역할을 합니다.

기존 구조:

PENDING 조회
        ↓
RabbitMQ 발행
        ↓
SENT 변경

개선 구조:

PENDING 조회
        ↓
PROCESSING 선점
        ↓
짧은 DB Transaction 종료
        ↓
RabbitMQ 발행
        ↓
성공 → SENT
실패 → 재시도 정책 적용

선점 과정만 짧은 DB 트랜잭션으로 처리하고, 실제 RabbitMQ 발행은 DB Lock을 장시간 유지하지 않는 상태에서 수행하도록 책임을 분리했습니다.

추가 장애 상황 고려

상태 선점 방식을 적용하면 선점 이후 인스턴스가 비정상 종료되는 상황도 함께 고려해야 했습니다.

PENDING
   ↓
PROCESSING 선점
   ↓
RabbitMQ 발행 전 인스턴스 종료

이 경우 이벤트가 계속 PROCESSING 상태로 남아 있으면 다른 인스턴스가 해당 이벤트를 다시 처리할 수 없습니다.

따라서 일정 시간 이상 PROCESSING 상태에 머문 이벤트를 비정상 처리 건으로 판단하고 다시 PENDING 상태로 복구할 수 있는 정책을 함께 두었습니다.

PROCESSING
   ↓
일정 시간 초과
   ↓
Stale 상태 판단
   ↓
PENDING 복구
   ↓
재처리

이를 통해 이벤트를 선점한 인스턴스가 비정상 종료되더라도 해당 이벤트가 영구적으로 정체되지 않도록 했습니다.

성과

기존 Outbox Relay는 단일 인스턴스에서는 정상적으로 동작했지만, Scale-out 시 여러 Scheduler가 동일한 PENDING 이벤트를 동시에 조회할 수 있어 중복 발행 가능성이 존재했습니다.

이를 PENDING → PROCESSING 상태 선점과 @Version 기반 낙관적 락을 적용하는 구조로 변경하여 동일 이벤트에 대해 하나의 인스턴스만 처리 권한을 획득하도록 개선했습니다.

또한 RabbitMQ 발행 과정에서 DB Row Lock을 장시간 유지하지 않도록 선점 트랜잭션과 외부 I/O를 분리했고, 선점 이후 인스턴스 장애로 PROCESSING 상태가 고착되는 경우를 대비해 복구 정책까지 함께 고려했습니다.

결과적으로 다중 인스턴스 환경에서의 중복 처리, DB Lock 장기 점유, 선점 이후 장애 복구까지 고려한 Outbox Relay 구조로 보완했습니다.