# 아웃박스 패턴을 통한 중간 상태 데이터 관리와 복원 가능한 배치 설계

모의 수능 신청 플랫폼을 운영하면서, “신청을 완료하지 않은 사용자”로 인해 중간 상태의 데이터가 지속적으로 남는 문제를 겪었다. 특히 결제 페이지까지 갔다가 이탈한 사용자나, 네트워크 오류로 인해 결제가 실패한 케이스에서 application 테이블에 PENDING 혹은 FAILED 상태의 데이터가 그대로 잔류했다. 이 문제는 시간이 지날수록 누적되었고, 단순히 데이터 정리 문제를 넘어 시스템 정합성좌석 수 동기화에도 영향을 주었다.

# 1️⃣ 문제 인식 — 고아(Orphan) 데이터의 누적

처음에는 단순히 “나중에 쿼리로 정리하면 되지 않을까?”라고 생각했다. 하지만 실제 운영 환경에서는 이 문제를 무시할 수 없었다.

  • 신청만 하고 결제하지 않은 사용자 → PENDING 상태로 남음
  • 결제 도중 오류가 발생한 사용자 → FAILED 상태로 남음
  • 이미 좌석은 Redis Lua를 통해 선점되어 있었음

즉, 결제 실패나 이탈로 인해 DB는 실패 상태를 가지고 있지만 Redis에는 여전히 좌석이 점유된 상태가 되는 불일치가 발생했다. 이 불일치는 Outbox 기반의 보상 트랜잭션 설계로 해결할 수 있었다.

# 2️⃣ 해결 방향 — 상태 기반 Outbox 이벤트 관리

이 문제를 해결하기 위해, Outbox 패턴을 도입하여 중간 상태를 이벤트로 관리하기로 했다. 핵심 아이디어는 “상태 변경을 단순히 DB에만 반영하지 않고, Outbox에 기록하여 비동기로 후속 처리”하는 것이다.

# 주요 설계 포인트

  • PENDING → FAILED → CLEANED

    • 결제 실패 시 application 상태를 FAILED로 변경하고, 동시에 Outbox에 “FAIL_EVENT”를 생성한다.
  • 장기 PENDING 처리

    • 신청만 하고 일정 시간 이상 진행이 없는 경우, 스케줄러가 PENDING 데이터를 조회하여 Outbox에 “CLEAN_PENDING_EVENT”를 추가한다.
  • 보상 로직 연동

    • Lua 스크립트를 통해 Redis의 좌석 정보를 동기화하고, DB와 Redis의 상태를 일치시킨다.

이 구조 덕분에 데이터 정리 로직이 트랜잭션 내부에 포함되지 않고, Outbox를 통해 안전하게 후처리될 수 있었다.

# 3️⃣ Outbox → MQ 흐름 설계

Outbox에 적재된 이벤트는 Poller가 주기적으로 조회하고, 처리할 이벤트를 MQ로 전달한다. 이때 여러 후보 중에서 RabbitMQ를 선택했다.

# 선택 이유

  • DLQ(Dead Letter Queue) 기능이 내장되어 있어, 실패한 이벤트를 따로 보관하고 운영자가 직접 모니터링 가능
  • Persistent Queue 설정을 통해 이벤트 유실 위험을 최소화
  • Batch 처리재시도(backoff) 전략을 유연하게 조정 가능

결국 Outbox → RabbitMQ → Listener 구조로 안정적인 이벤트 흐름이 만들어졌다.

# 4️⃣ Batch Listener에서의 재개(Resume) 설계

이벤트를 수신한 RabbitMQ Listener는 관련된 application 데이터를 Batch 단위로 가져와 JSON 스냅샷을 생성하고, 이를 임시 보존 테이블에 저장한 뒤 삭제한다.

그러나 여기서 새로운 문제가 발생했다. “Batch 처리 중간에 실패하면 어디서부터 다시 시작할 것인가?”

초기 구현에서는 단순히 예외 발생 시 전체 배치를 롤백시켰다. 하지만 이 방식은 이미 처리된 데이터까지 재처리하게 되어 비효율적이었다.

이를 해결하기 위해, Outbox 이벤트 상태를 명시적으로 관리하는 Resume 전략을 도입했다.

# 5️⃣ 상태 기반 Resume 전략 (READY → PROCESSING → DONE)

각 Outbox 이벤트는 세 가지 상태를 가진다.

상태 의미
READY 아직 처리되지 않은 이벤트
PROCESSING 현재 배치 처리 중
DONE 성공적으로 완료된 이벤트

배치 Listener는 Outbox에서 READY 상태의 이벤트만 가져와 처리한다. 처리 도중 예외가 발생하면 해당 이벤트는 PROCESSING 상태로 남게 된다. 이후 Poller가 다시 실행될 때, **“DONE이 아닌 이벤트”**부터 재개하도록 설계했다.

이 구조는 단순하지만 강력하다. 시스템이 중간에 중단되더라도, Outbox의 상태 정보 덕분에 어디서부터 다시 시작해야 하는지 명확히 알 수 있다.

# 6️⃣ 확장 고려 — 단일 인스턴스 → 다중 인스턴스

현재는 단일 인스턴스 기반이기 때문에, 별도의 분산 조정 없이도 안전하게 Resume가 가능하다. 하지만 서비스가 확장되어 여러 Consumer 인스턴스가 동작하게 되면, 중복 처리를 방지하기 위해 분산락(distributed lock) 이 필요하다.

Redis 기반의 분산락 또는 RabbitMQ의 Consumer tag를 이용해 “하나의 이벤트는 오직 하나의 Consumer만 처리하도록” 설계할 예정이다.

# 7️⃣ 회고

이 경험을 통해 배운 점은 명확하다. 이벤트를 삭제하기보다, 상태를 명시적으로 관리하라.

Outbox 패턴을 단순히 “비동기 트랜잭션 전달용”으로만 사용하면 중간 상태 복구나 재시작(resume)이 어렵다. 하지만 Outbox의 상태를 세분화하고, 이를 기반으로 Resume 가능한 설계를 도입하면 예상치 못한 장애 상황에서도 데이터를 안전하게 복원할 수 있다.