· 25분 읽기

결제 흐름 안의 물류 API 호출을 걷어내기 – 같은 트랜잭션 아웃박스 + 배치 릴레이 + Kafka 로 OMS 에 주문 넘기기


MSA

목록 보기숨기기13/13
  1. 카프카(Apache Kafka)란 무엇인가
  2. Kafka 용어 정리 (pub/sub, 토픽, 파티션, 브로커)
  3. API Gateway의 이해
  4. MSA 개발 가이드(1) - MSA란 무엇인가
  5. MSA 개발 가이드(2) - Spring Cloud 기반 마이크로서비스(MSA)
  6. MSA 개발 가이드(3) - 마이크로 서비스 아키텍처 구현해보기
  7. MSA 개발 가이드(4) - Resilience4j(Circuit Breaker)
  8. MSA 개발 가이드(5) - Spring Cloud Eureka(Service Registry)
  9. MSA 개발 가이드(6) - Spring Cloud Gateway(Zuul)
  10. MSA 개발 가이드(7) - Spring Cloud Config Server/Client
  11. MSA 개발 가이드(8) - Spring Cloud Bus(RabbitMQ)
  12. MSA 개발 가이드(9) - 마이크로 서비스 배포(Docker)
  13. 결제 흐름 안의 물류 API 호출을 걷어내기 – 같은 트랜잭션 아웃박스 + 배치 릴레이 + Kafka 로 OMS 에 주문 넘기기

이 글은 설계 글이다. 주문과 아웃박스를 한 트랜잭션으로 저장하는 부분, 조건부 상태 전환, 트랜잭션 재시도, 후속 메시지 릴레이, 아웃박스를 폴링해 Kafka 로 OMS 에 보내는 릴레이, OMS 의 인박스는 이미 운영에서 돌고 있어 코드로 보여준다. 커머스가 자기 아웃박스를 직접 Kafka 로 릴레이하는 부분은 아직 만들기 전이라 "이렇게 만든다"로 적는다.

1. 전에는: 결제 흐름 안에서 바로 보내고 바로 불렀다

운영 결제는 한 달에 1만 건이 넘는다. 결제가 승인되면 커머스 api 는 주문을 저장하고 그 자리에서 후속 일을 처리했다. 먼저 알림톡 메시지를 RabbitMQ 로 발행했다. 이어서 배송 상태를 바꾸면서 크리마 메시지를 보냈다. 결제완료면 마일리지를 적립하고 WMS 에 주문 수집을 요청했다. 이 일들이 전부 주문 저장 블록 하나 안에 순서대로 들어 있었다.

결제 후속 처리: 주문을 저장한 블록 안에서 sendToWorker, sendCremaOrderToWorker, wmsService.collectCommerceOrderAsync 를 차례로 호출한다
스냅샷 1 · 바꾸기 전 결제 후속 처리. 주문 저장 블록 안에서 알림톡·크리마를 바로 발행하고 WMS 수집까지 부른다.

문제는 순서에 묶여 있다는 점이다. 앞의 발행 하나가 예외를 던지면 블록이 거기서 끝난다. 그 뒤에 있던 배송 상태 변경, WMS 수집, 마일리지 적립은 실행되지 않는다. 남는 것은 "중간 실패" Slack 알림 하나였다. 발행 한 번의 실패가 배송·물류·마일리지 후속 처리 전체를 멈추는 장애로 이어졌다.

취소 쪽은 물류 API 를 직접 기다렸다. 주문을 취소하면 WMS 취소 API 를 resilience4j 재시도(최대 4회 시도, 즉 재시도 3회, 10초 간격)로 감싸 runBlocking 으로 호출했다. 실패가 이어지면 대기만 최소 30초다. 호출부는 그 결과를 기다린 다음에야 취소 응답을 만들었다.

cancelCommerceOrder: Retry.decorateRunnable 안에서 runBlocking 으로 wmsClient.cancelCommerceOrder 를 호출하고 runnable.run() 으로 재시도까지 기다린다
스냅샷 2 · 바꾸기 전 취소. WMS 취소 API 를 동기로 부르고 재시도 대기까지 호출자가 떠안는다.

WMS 가 느리거나 죽으면 고객의 취소 응답이 재시도 횟수와 대기 간격만큼 늦어졌다. 예외는 삼켜서 취소 자체가 실패하지는 않았지만 물류가 취소를 받았는지는 로그로만 남았다. 수집 요청은 처음부터 별도 코루틴으로 띄워 결제 응답이 기다리지는 않았다. 그래도 같은 프로세스의 IO 스레드를 잡고 재시도했고 실패는 info 로그로 끝났다.

기존 구조: 결제·취소 흐름 안에서 메시지 발행과 물류 API 호출이 이어져 한 곳이 실패하면 배송·마일리지 후속 처리까지 멈춘다
그림 1 · 기존 구조. 결제·취소 흐름이 메시지 브로커와 물류 API 를 직접 붙잡고 있어 한 곳의 실패가 뒤쪽 후속 처리까지 번진다.

정리하면 두 가지가 섞여 있었다.

  • 결제·취소 응답이 바깥 시스템(브로커, 물류 API)의 상태에 끌려간다.
  • 바깥 시스템으로 보낼 일이 제대로 남지 않는다. 알림톡 같은 메시지는 DB 에 남지 않았다. 물류 이벤트는 공유 아웃박스에 남기긴 했지만 결제 트랜잭션 밖에서 남겼다. 보내다 실패하거나 남기기 전에 파드가 죽으면 무엇을 못 보냈는지 알 길이 없다.

취소의 동기 대기는 그 뒤 비동기로 바꿨다. 지금 남은 것은 아웃박스 적재와 함께 나가는 fire-and-forget REST 이중 경로와 취소 가능 여부 판정(동기)이다.

2. 목표 구조

목표는 결제 흐름이 DB 저장에서 끝나고 바깥으로 보낼 일은 DB 에 먼저 남기는 것이다.

목표 구조: 주문 저장과 주문 아웃박스(대기)가 한 트랜잭션, 커밋 후 배치 릴레이가 폴링·CAS 로 선점해 Kafka(키=주문번호)로 발행, OMS 는 eventId 인박스로 중복을 거른다
그림 2 · 목표 구조. 결제 흐름은 주문과 아웃박스를 한 트랜잭션으로 커밋하는 데서 끝난다. 그 뒤는 릴레이와 Kafka, OMS 인박스가 맡는다.
  1. 결제 승인(또는 취소) 처리에서 주문 상태와 "OMS 로 보낼 이벤트"를 주문 DB 의 아웃박스에 한 트랜잭션으로 저장한다.
  2. 커밋하면 결제 응답을 돌려준다. 물류 API 는 부르지 않는다.
  3. 커머스 api 의 배치 릴레이가 아웃박스를 폴링하고 CAS 로 행을 선점한 뒤 Kafka 로 발행한다. 메시지 키는 주문번호다.
  4. OMS 는 이미 같은 토픽을 소비하고 있고 eventId 인박스로 중복을 거른다.

producer 설정 acks=all·enable.idempotence=true 는 지금 WMS 릴레이의 producer 설정이다. 커머스 릴레이도 같은 설정으로 맞춘다.

이 가운데 주문과 아웃박스를 한 트랜잭션으로 저장하는 방식, 폴링·CAS·Kafka 발행, OMS 인박스는 이미 운영에 있다. 다만 각각 다른 자리에 있다. 한 트랜잭션 저장은 알림톡·결제 취소 같은 후속 메시지(RabbitMQ)에 쓰고 있다. 폴링·CAS·Kafka 발행 릴레이는 WMS 쪽에서 돈다. 이 설계는 흩어진 조각을 주문 → OMS 경로 하나로 모은다. 그리고 아웃박스의 주인인 커머스가 발행까지 맡게 한다.

3. 같은 트랜잭션에 아웃박스를 저장한다

3-1. 이미 돌고 있는 것: 주문 + 아웃박스 한 트랜잭션

후속 메시지 쪽은 먼저 바꿨다. 결제 승인 때 주문 저장과 아웃박스(PENDING) 저장을 주문 DB 의 한 트랜잭션으로 커밋하고 발행은 커밋 뒤에 한다.

saveOrderWithOutbox: runInOrderTxWithRetry 안에서 주문과 아웃박스(PENDING)를 저장한다. 커밋 후 sendToWorkerWithMessageId 로 발행하고 성공하면 markSentIfPending
스냅샷 3 · 주문과 아웃박스를 한 트랜잭션으로 커밋한 뒤 발행한다. 발행에 성공한 것만 조건부로 SENT 표시한다.

이 순서에서 지켜지는 것은 세 가지다.

  • 주문이 커밋됐으면 아웃박스도 커밋됐다. 보낼 일이 사라지지 않는다.
  • 커밋 뒤에 발행하므로 롤백된 주문에 대한 메시지는 나가지 않는다.
  • 발행이 실패하거나 SENT 표시가 실패해도 행은 PENDING 으로 남는다. 릴레이가 다시 보낸다.

발행과 SENT 표시는 서로 다른 시스템이라 원자적으로 묶을 수 없다. 그래서 이 구간은 at-least-once 다. 같은 행을 두 번 보낼 수는 있어도 안 보내고 넘어가지는 않는다. 아웃박스 id 를 메시지 id 로 실어서 소비자가 중복을 거른다. 이 소비자 쪽 이야기는 알림 소비자의 처리 후 ack 와 멱등 처리에, 같은 구조를 배치 알림에 적용한 기록은 알림톡 outbox / inbox에 있다.

MongoDB 멀티 도큐먼트 트랜잭션은 동시 쓰기에서 WriteConflict 로 abort 될 수 있다. 이런 abort 에는 서버가 TransientTransactionError 라벨을 붙인다. 이 라벨이 붙은 예외만 짧게 기다렸다가 트랜잭션 전체를 다시 실행한다. 최초 실행을 포함해 최대 3회 시도한다. 롤백된 트랜잭션을 다시 실행하는 것이라 부수효과가 겹치지 않는다.

runInOrderTxWithRetry: ORDER_TX_MAX_ATTEMPTS = 3, executeAndAwait 로 실행하고 isTransientMongoTxError 가 아니면 그대로 던진다
스냅샷 4 · TransientTransactionError 라벨이 붙은 abort 에 한해 최대 3회 시도한다. 그 밖의 예외는 그대로 올린다.

SENT 표시는 "PENDING 일 때만" 바꾸는 조건부 업데이트다. 커밋 직후의 즉시 발행 경로와 릴레이가 같은 행을 동시에 만져도 나중 쓰기가 앞의 결과를 덮지 않는다. 바뀐 문서 수가 0이면 다른 경로가 이미 처리했다는 뜻이다.

markSentIfPending: _id 와 status PENDING 을 조건으로 status SENT, publishedAt 을 set 한다
스냅샷 5 · PENDING 일 때만 SENT 로 바꾼다. 재시도 횟수 증가와 FAILED 전환도 같은 조건을 쓴다.

트랜잭션이 정말 롤백되는지는 mock 으로 확인할 수 없다. MongoDB 트랜잭션은 replica set 에서만 동작하고 주문과 아웃박스 저장이 같은 클라이언트 세션에 들어가야 한다. 그래서 Testcontainers 로 실제 replica set 을 띄운다. 그 위에서 아웃박스 저장만 일부러 실패시켜 주문 상태 변경도 되돌아가는지 본다.

통합 테스트: 아웃박스 save 만 강제로 실패시킨 뒤 주문이 원래 상태(입금대기)로 남고 아웃박스가 0건인지 확인한다
스냅샷 6 · 실제 replica set 에서 아웃박스 저장이 실패하면 주문 상태 변경도 롤백되는지 확인하는 테스트.

이 경로는 운영 30일 동안 후속 메시지의 즉시 발행·SENT 표시·저장 실패가 0건이었다(운영 로그 기준).

3-2. 남아 있는 틈: OMS 로 갈 이벤트는 아직 트랜잭션 밖이다

주문 → OMS 경로는 사정이 다르다. 지금 OMS 로 보낼 주문 수집 이벤트는 주문 DB 가 아니라 별도 아웃박스 DB 에 쌓인다. 이 저장은 결제 트랜잭션이 커밋된 뒤 별도 코루틴 안에서 일어난다. 트랜잭션은 DB 하나 안에서만 묶이니 지금 구조로는 같은 트랜잭션에 넣을 수 없다.

그래서 커밋 직후에 파드가 죽으면 수집 코루틴이 돌지 않아 아웃박스 행이 아예 생기지 않을 수 있다. 이때 다시 보낼 근거는 커머스에 없고 WMS 관리자 화면의 수동 수집이 유일한 복구 수단이다. 게다가 이 코루틴은 아웃박스 적재와 함께 WMS 수집 REST 도 부른다. 한 주문을 두 경로로 보내는 셈이다.

3-3. 왜 코루틴을 고치지 않고 아웃박스 릴레이로 바꾸나

별도 코루틴(fire-and-forget)은 결제 응답을 물류에서 떼어 내는 데는 성공했다. 응답이 물류를 기다리지 않는다. 하지만 "보낼 일"이 프로세스 메모리에만 있다는 점은 그대로다. 그래서 코루틴 안의 재시도를 늘리거나 로그를 고쳐도 남는 문제가 있다.

  • 보낼 일이 메모리에만 있다. 결제 트랜잭션이 커밋된 뒤 코루틴이 끝나기 전에 파드가 내려가면(배포, 스케일 인, OOM) 그 일은 사라진다. 다시 보낼 근거가 DB 에 없다.
  • 실패가 기록으로 남지 않는다. 재시도를 다 쓰면 로그 한 줄로 끝난다. 어떤 주문이 OMS 에 안 갔는지 상태로 조회할 수 없고 사람이 알아채야 복구가 시작된다.
  • 재시도가 결제를 처리하는 프로세스 안에서 돈다. 물류가 느려지면 같은 프로세스의 IO 스레드가 재시도 대기에 묶인다. 응답은 떼어 냈지만 자원은 아직 같이 쓴다.
  • 저장과 전송이 따로 논다. 아웃박스 적재도 REST 호출도 결제 커밋 뒤의 별도 작업이라 둘 중 하나만 성공하는 경우가 생긴다. 이중 경로가 남은 이유이기도 하다.

아웃박스 릴레이로 바꾸면 이 넷이 한 번에 풀린다. 보낼 일은 주문과 같은 트랜잭션에서 DB 에 행으로 남는다. 커밋됐으면 반드시 행이 있다. 보냈는지는 행의 상태로 조회되고 실패는 다음 폴링이 다시 집는다. 발행은 결제 프로세스의 요청 흐름 밖에서 릴레이가 맡는다.

같은 문제를 푸는 다른 방법도 따져 봤다.

안뺀 이유
코루틴 유지 + 재시도·로그 보강보낼 일이 메모리에만 있다는 근본 문제가 그대로다. 커밋 직후 파드가 내려가면 여전히 사라진다
커밋 직후 Kafka 로 바로 발행하고 실패하면 아웃박스에 남기기커밋과 발행 사이에 죽으면 둘 다 남지 않는다(dual write). 실패를 아웃박스에 쓰는 것 자체가 커밋 뒤의 또 다른 쓰기라 같은 틈이 생긴다. producer 대기(max.block.ms)도 결제 응답 경로에 다시 들어온다
Kafka 트랜잭션으로 DB 쓰기와 발행을 묶기Kafka 트랜잭션은 Kafka 안의 쓰기와 오프셋만 묶는다. MongoDB 쓰기와는 한 트랜잭션이 되지 않는다
두 저장소를 2PC 로 묶기MongoDB 와 Kafka 사이에 쓸 수 있는 코디네이터가 없다. 있더라도 결제 경로에 가장 무거운 프로토콜을 들이게 된다
CDC(Debezium, change stream 직접 구독)로 아웃박스를 읽어 발행아웃박스는 그대로 두고 읽는 방법만 바꾸는 안이다. 얻는 것은 몇 초의 지연 단축인데 운영할 구성요소가 늘어난다. 7장에 따로 적었다
동기 REST + 서킷 브레이커장애 전파는 줄지만 결제·취소가 물류 응답에 묶이는 결합은 그대로다

결국 "커밋과 같은 원자성으로 보낼 일을 남긴다"를 만족하는 안은 같은 트랜잭션 아웃박스뿐이었다. 남은 선택은 그 아웃박스를 누가 어떻게 읽느냐였고, 폴링 릴레이로 정했다(읽는 방법은 4장과 7장, 보낼 곳은 5장).

3-4. 이렇게 바꾼다

  • OMS 로 보낼 이벤트도 주문 DB 의 주문 아웃박스에 쓴다. 결제 승인 때 주문 저장과 같은 runInOrderTxWithRetry 안에 넣는다. 취소도 같다. 지금 재고 부족 자동 취소가 "취소완료" 저장과 결제 취소 메시지를 한 트랜잭션으로 묶는 것처럼 OMS 취소 이벤트도 같이 묶는다.
  • 지금은 즉시 발행할 메시지가 없는 결제(카드 결제 등)에서는 트랜잭션을 열지 않고 주문만 저장한다. 결제완료면 OMS 이벤트가 항상 생기니 바꾼 뒤에는 결제완료 저장마다 트랜잭션이 열린다. 트랜잭션 재시도가 여기서 더 자주 쓰일 수 있다.
  • 이벤트 id 는 행을 만들 때 정한다. 이 id 가 Kafka 메시지의 eventId 가 되고 재발행해도 바뀌지 않는다.
  • 결제 흐름에서 물류 REST 호출은 뺀다.

OMS 이벤트는 후속 메시지처럼 커밋 직후에 바로 보내지 않고 릴레이만 보낸다. Kafka producer 는 브로커 메타데이터를 못 받으면 최대 60초(max.block.ms)까지 막힐 수 있는데 이 대기를 결제 응답 경로에 다시 들이고 싶지 않다. 주문 전달은 몇 초 늦어도 업무에 영향이 없다. 지금도 적재부터 OMS 주문 생성까지 설계상 1~7초가 걸리는 구조로 운영하고 있다. 발행 주체가 릴레이 하나면 상태 전이도 단순해진다.

4. 배치 릴레이 설계

릴레이는 지금 WMS 쪽에서 도는 릴레이의 뼈대를 가져온다. 거기서 확인된 약점을 고쳐서 커머스 api 에 만든다.

아웃박스 상태 전이: 대기 → 처리중(CAS) → 완료 / 실패 → 다음 폴링에서 재시도 / 처리중이 10분을 넘기면 회수
그림 3 · 아웃박스 상태 전이. 처리중으로 바꾸는 것은 CAS 한 번이다. 10분 넘게 처리중에 머문 행은 다른 실행이 회수한다. 결과의 조건부 기록과 보류 상태는 설계다.

4-1. 폴링과 CAS 선점

릴레이는 주기적으로 대기·실패·오래된 처리중 행을 생성 순서대로 읽는다. 지금 릴레이는 이전 실행이 끝나고 5초 뒤에 다음 실행을 시작하고(fixedDelay), 한 번에 최대 200건을 읽는다. 백로그가 한꺼번에 몰리면 동기 발행 때문에 한 번의 실행이 길어지니 상한을 둔다. 커머스 릴레이도 같은 주기와 상한에서 시작한다.

지금 공유 아웃박스의 SENT 는 "커머스가 적재만 한 상태(미발행)"라는 뜻이다. 그래서 지금 릴레이에서는 SENT 가 대기 상태다.

읽은 행을 바로 보내지 않는다. 행마다 findAndModify 로 "이 id 이고 상태가 이 중 하나일 때만 처리중으로 바꾼다"를 한 번에 실행한다. 결과가 null 이면 다른 실행이 먼저 가져간 것이니 건너뛴다.

markAsProcessingAtomically: _id 와 상태 조건(SENT·FAILED 또는 updatedAt 이 staleBefore 보다 오래된 PROCESSING)으로 findAndModify 해 PROCESSING 으로 바꾼다. null 이면 선점 실패
스냅샷 7 · 지금 릴레이의 CAS 선점. 대기·실패 행과 10분 넘은 처리중 행만 가져간다. 진 쪽은 null 을 받는다.

선점이 행 단위 CAS 라서 여러 파드가 동시에 폴링해도 한 행은 한 실행만 가져간다. 그래서 ShedLock 같은 분산 락이 필요 없다. 지금 릴레이도 ShedLock 없이 돈다.

락을 일부러 붙일 때는 시간을 따져야 한다. 커머스 api 의 공통 ShedLock 설정은 최소 잠금 시간(lockAtLeastFor)이 30초다. 5초 주기 작업에 이 기본값을 그대로 쓰면 다른 파드가 30초 동안 락을 못 잡아 실제 주기가 30초가 된다. 커머스의 후속 메시지 릴레이는 5분 주기라 ShedLock 기본값으로 문제가 없지만 이 릴레이는 다르다. 커머스 릴레이는 락을 쓰지 않고 CAS 에 맡긴다. 쓰더라도 이 작업만 따로 잠금 시간을 준다.

4-2. 발행과 결과 기록

선점한 행은 {eventId, topic, payload} 봉투로 만들어 주문번호를 키로 Kafka 에 보내고 send().get() 으로 브로커 응답을 기다린다. 성공하면 완료, 예외면 실패로 기록한다. 실패 행은 다음 폴링에서 다시 선점돼 재발행된다.

지금 릴레이의 send().get() 에는 타임아웃 인자가 없다. 브로커가 응답하지 않으면 행 하나가 최대 약 3분(메타데이터 대기 60초 + 전송 제한 120초)을 붙잡는다. 행을 하나씩 순서대로 보내니 그동안 뒤의 행이 모두 기다리고 다음 틱도 그만큼 밀린다. 커머스 릴레이는 이렇게 한다.

  • get(타임아웃) 으로 기다리는 시간에 상한을 둔다.
  • 브로커 오류가 처음 나면 그 틱을 거기서 멈춘다. 브로커가 아픈데 남은 행을 계속 보내 봐야 같은 오류만 쌓인다.
  • 틱마다 시간 예산을 두고 넘기면 남은 행은 다음 틱으로 넘긴다.
릴레이 루프: staleBefore 계산, CAS 선점 실패 시 건너뜀, eventId·topic·payload 봉투, kafkaTemplate.send(...).get() 동기 대기, 성공이면 markAsSuccess, 예외면 markAsFailed
스냅샷 8 · 지금 릴레이의 루프. 선점 → 동기 발행 → 성공/실패 기록 순서이고 행마다 하나씩 보낸다.

키를 주문번호로 두는 데는 이유가 있다. Kafka 는 파티션 안에서만 순서를 지킨다. 같은 주문의 수집과 취소는 같은 파티션에 들어가야 한다. 지금 릴레이는 예전에 키를 못 뽑으면 아웃박스 id 로 대신했다. 아웃박스 id 는 ObjectId 라 행마다 값이 달라 같은 주문의 이벤트가 여러 파티션으로 흩어졌다. 지금은 고정값인 토픽명으로 대신한다.

그런데 키가 지키는 순서는 브로커에 들어간 뒤의 순서다. 릴레이가 보내는 순서가 어긋나면 키로는 못 막는다. 수집 행이 실패해 다음 폴링으로 밀리거나 처리중에 갇혀 10분 뒤 회수되는 사이에 같은 주문의 취소 행이 먼저 나갈 수 있다. 그래서 커머스 릴레이는 같은 주문번호의 앞선 행이 아직 완료되지 않았으면 뒤 행을 이번 틱에서 건너뛴다. OMS 쪽에도 장치가 하나 있다. 취소가 왔는데 주문이 아직 없으면 이벤트를 보류(PendingEvent)해 두었다가 나중에 다시 처리한다.

키를 payload 에서 뽑지 않는 것은 주문 아웃박스로 옮긴 뒤(9장 4단계)의 이야기다. 주문 아웃박스에는 이미 주문번호 필드(aggregateId)가 있어 그 값을 그대로 키로 쓴다. 키가 없는 행은 만들지 않는다.

4-3. 처리중 회수: 왜 10분인가

선점 후 결과를 기록하기 전에 파드가 죽으면 행이 처리중에 갇힌다. 그래서 처리중 행도 폴링 대상에 넣고 updatedAt 이 일정 시간보다 오래됐을 때만 CAS 로 다시 가져간다.

이 시간은 "죽은 실행"과 "살아 있지만 느린 실행"을 가르는 기준이다. 너무 짧으면 아직 발행을 기다리는 행을 다른 파드가 가져가 같은 메시지를 또 보낸다. 지금 producer 설정에서 살아 있는 발행이 가장 오래 걸리는 경우는 메타데이터 대기(max.block.ms) 60초에 전송 제한(delivery.timeout.ms) 120초를 더한 약 3분이다. 여기에 3배 여유를 둬서 10분으로 잡았다. 커머스 릴레이도 10분보다 짧게 잡지 않는다.

이 비교에는 함정이 하나 있다. 시각 필드가 LocalDateTime 이라 "지금 - 10분"이 JVM 기본 타임존으로 계산된다. 같은 이미지의 파드끼리는 문제가 없다. 그런데 전환 기간에 커머스 릴레이와 WMS 릴레이가 같은 행을 두고 비교하는데 두 JVM 의 타임존이 다르면 처리중 판정이 몇 시간씩 어긋난다. 정상 처리 중인 행을 바로 회수하거나 죽은 행을 몇 시간 방치하게 된다. 그래서 전환 전에 두 쪽 타임존이 같은지 확인한다. 커머스 릴레이는 타임존에 의존하지 않는 절대 시각(Instant)으로 저장·비교한다. 지금 주문 아웃박스도 LocalDateTime 이라 이 필드부터 바꾼다.

4-4. 지금 릴레이의 약점과 그래서 바꾸는 점

지금 릴레이에서 선점은 CAS 인데 결과 기록은 아니다. id 로 문서를 읽고 상태를 바꿔 문서 전체를 저장한다.

markAsSuccess / markAsFailed: findById 로 읽고 success()/failed() 로 바꾼 뒤 save(outbox) 로 문서 전체를 저장한다
스냅샷 9 · 지금 릴레이의 결과 기록. 상태 조건 없이 읽고 저장하므로 다른 실행이 이미 다시 가져간 행도 덮어쓸 수 있다.

이렇게 되면 다음 순서가 가능하다. 파드 A 가 행을 선점하고 발행이 늦어진다. 10분이 지나 파드 B 가 같은 행을 회수해 처리중으로 만들고 다시 보낸다. 그 사이 A 가 늦게 깨어나 실패로 저장하면 B 가 처리 중인 행이 실패로 뒤집힌다. 다음 폴링이 그 행을 또 보낸다. OMS 인박스가 중복을 걸러 주문이 두 번 생기지는 않지만 행 상태가 실제와 달라져 추적이 어려워진다.

커머스 릴레이는 결과 기록도 조건부로 한다.

  • 선점할 때 처리중으로 바꾸면서 찍은 시각을 기억해 둔다.
  • 완료·실패 기록은 {_id, status: 처리중, updatedAt: 내가 찍은 시각} 조건의 findAndModify + $set 으로 한다. 바뀐 문서가 없으면 다른 실행이 이미 가져간 것이니 아무것도 하지 않는다.
  • 읽고 저장하는 방식(findById → save())은 쓰지 않는다.

지금 릴레이에는 최대 재시도 횟수와 알림도 없다. 직렬화가 안 되는 행처럼 영원히 실패하는 행은 5초마다 끝없이 다시 시도되고 실패는 error 로그로만 남는다. 조회가 생성 순서 상위 200건이라 이런 행이 200건 넘게 쌓이면 새 행이 조회 창에 들어오지 못할 수도 있다(코드로 추론한 것이고 실제로 본 적은 없다). 커머스 릴레이에는 후속 메시지 릴레이처럼 재시도 상한과 알림을 붙인다. 다만 기준을 그대로 가져오지는 않는다. 후속 메시지 릴레이는 5분 주기라 10회가 약 50분이지만 5초 주기에서 10회는 50초다. 브로커가 1분만 흔들려도 멀쩡한 행이 끝난 것으로 처리된다. 그래서 상한은 횟수가 아니라 시간으로 잡고 재시도 간격은 백오프로 늘린다.

  • 상한을 넘긴 행은 다시 시도하는 실패(FAILED)와 다른 끝 상태(예: 보류)로 옮기고 Slack 으로 알린다. 보류 행은 선점 대상에서 빠지니 조회 창을 차지하지 않는다.
  • 브로커 전체 장애는 행 하나의 실패로 세지 않는다. 틱을 멈춘 원인이 브로커 오류면 그 틱의 행들은 재시도 기록을 올리지 않는다. 행 자체의 문제(직렬화 실패, 메시지 과대)만 그 행의 실패로 센다.

5. 왜 Kafka 인가

후보는 넷이었다.

안뺀 이유 / 고른 이유
물류 API 를 계속 REST 로 호출결제·취소가 물류 응답에 다시 묶인다. 이 설계가 없애려는 결합이다
지금처럼 WMS 가 커머스 아웃박스를 읽어 발행결제와 물류는 떨어지지만 아웃박스 주인이 아닌 쪽이 발행한다. WMS 가 커머스 DB 접속을 계속 쥐고 있어야 하고 발행 실패 복구 책임이 커머스 밖에 있다
RabbitMQ 로 OMS 에 발행커머스에는 이미 있다. 하지만 OMS 는 RabbitMQ 를 소비하지 않고 소비한 메시지는 큐에서 사라져 다시 재생할 수 없다
Kafka 로 OMS 에 발행고름

Kafka 를 고른 이유는 OMS 와 이미 맺은 계약이다.

  • OMS 는 지금 이 이벤트를 Kafka 로 받고 있다. 토픽, 봉투({eventId, topic, payload}), 키를 그대로 쓰면 OMS 는 아무것도 고치지 않는다. 바뀌는 것은 발행자뿐이다.
  • 키 단위로 순서가 지켜진다. 주문번호를 키로 두면 브로커에 들어간 한 주문의 이벤트는 순서대로 소비된다(보내는 순서는 4-2 의 장치가 맞춘다).
  • 오프셋과 보존 기간이 있어 소비 쪽 장애 뒤에 구간을 다시 읽을 수 있다. 발행자를 바꾸는 전환 기간에 무엇이 나갔는지 토픽에서 직접 확인할 수 있다.

결제 후속 메시지(알림톡·결제 취소·마일리지)는 RabbitMQ 에 그대로 둔다. 소비자가 커머스 워커이고 거기에는 이미 durable 큐와 멱등 소비가 갖춰져 있다. 바꿀 이유가 없다.

6. exactly-once 대신 at-least-once + 멱등 소비

"정확히 한 번"을 브로커에 맡길 수 있으면 좋겠지만 이 경로에서는 안 된다.

Kafka 의 exactly-once 는 트랜잭션 producer 로 소비 오프셋 커밋과 출력 토픽 쓰기를 한 트랜잭션으로 묶는 방식이다. 읽기와 쓰기가 둘 다 Kafka 일 때(Kafka → Kafka)만 원자적이다. 이 경로는 양 끝이 DB 다. 앞쪽은 MongoDB 아웃박스에서 읽어 Kafka 에 쓰고 뒤쪽은 Kafka 에서 읽어 OMS 의 DB 에 쓴다.

  • 앞쪽에서 Kafka 발행과 아웃박스 완료 표시를 묶으려면 2PC 가 필요하다. MongoDB 와 Kafka 사이에 그런 코디네이터는 없다.
  • 뒤쪽에서 정확히 한 번을 만들려면 OMS 가 처리 결과와 오프셋을 같은 DB 트랜잭션에 저장해야 한다. 그렇게 해도 막히는 것은 "같은 오프셋을 두 번 처리"까지다. 릴레이가 같은 행을 다시 보내면 그 메시지는 새 오프셋을 받는다. 오프셋으로 보면 다른 메시지라 걸러지지 않는다.

producer 의 enable.idempotence 도 같은 한계가 있다. producer 세션 안에서 네트워크 재전송으로 생긴 중복만 막는다. 릴레이가 새로 send 를 부르면 브로커에게는 새 메시지다.

그래서 중복을 없애는 대신 중복이 생겨도 결과가 한 번이게 한다. 발행 쪽은 at-least-once 로 둔다. 확인을 못 받았으면 다시 보낸다. 소비 쪽은 메시지에 실린 eventId 로 거른다. eventId 는 아웃박스 행의 id 라 몇 번을 다시 보내도 같다. OMS 는 이미 이 방식으로 소비한다.

OMS 소비자: omsEventInbox 에서 eventId 를 찾아 있으면 건너뛴다. 처리에 성공하면 eventId 와 topic 으로 인박스를 기록한다
스냅샷 10 · OMS 의 인박스. 같은 eventId 가 다시 오면 처리하지 않는다. 성공한 뒤에 eventId 를 기록한다.

OMS 인박스는 "확인 → 처리 → 기록" 순서이고 한 트랜잭션은 아니다. 처리와 기록 사이에 죽으면 같은 이벤트를 한 번 더 처리할 수 있다. 수집 이벤트에는 두 번째 방어선이 있다. 수집 처리 자체가 이미 있는 배송을 빼고 만든다. 전부 이미 있으면 "이미 수집됨"으로 성공을 돌려준다. 같은 키는 같은 파티션에서 순서대로 처리되니 같은 이벤트가 동시에 두 번 처리될 일은 드물다. 예외는 리밸런스로 파티션 주인이 바뀌는 순간이다. 그래서 커머스 릴레이 쪽은 eventId 를 바꾸지 않고 키를 주문번호로 지키는 것만 계약으로 고정하면 된다.

7. CDC 를 쓰지 않은 이유

아웃박스를 읽는 방법은 폴링 말고 CDC(Debezium 이 MongoDB change stream 을 읽어 Kafka 로 내보내는 방식)도 있다. 비교해 보고 폴링을 골랐다.

CDC 가 주는 것은 지연 단축이다. 폴링 주기만큼의 대기가 사라지고 DB 에 폴링 쿼리가 돌지 않는다. 그런데 이 경로에는 그 이점이 크지 않다.

  • 주문 전달과 배송 처리는 초 단위 실시간성이 필요 없다. 몇 초 늦게 OMS 에 들어가도 출고 일정이 바뀌지 않는다. 지금도 설계상 1~7초 걸리는 구조로 운영한다.
  • 폴링 부하는 이미 줄여 두었다. 지금 릴레이는 토픽·상태별로 따로 하던 조회를 한 번의 쿼리로 합쳤다. 월 1만 건 규모에서 5초마다 쿼리 한 번은 부담이 아니다.

반대로 CDC 가 새로 요구하는 것은 많다. Kafka Connect 클러스터와 MongoDB 커넥터를 띄우고 운영해야 한다. change stream 권한이 필요하고 resume token 관리, 커넥터 장애 복구, 커넥터 모니터링이 새로 생긴다. 이벤트 모양을 지금 계약과 맞추려면 결국 아웃박스 컬렉션은 그대로 둬야 한다. 아웃박스를 없애 주지도 않는다.

얻는 것은 몇 초의 지연이고 늘어나는 것은 운영할 인프라 하나다. 이 규모에 CDC 는 오버 엔지니어링이라고 판단했다. 실시간성이 업무 요구가 되면 그때 다시 검토한다. 아웃박스 구조는 그대로라 릴레이만 커넥터로 바꾸면 된다.

8. 장애 지점별 동작

장애 지점별 결과 표: 커밋 전 실패, 커밋 후·발행 전 크래시, 발행 성공·완료 표시 전 크래시, Kafka 일시 장애, OMS 중복 수신
그림 4 · 장애 지점별 결과. 잃는 칸은 없다. 중복이 생기는 칸은 모두 OMS 인박스가 거른다.
  • ① 커밋 전 실패: 주문 저장도 같이 롤백된다. 결제 응답은 실패이고 보낼 이벤트도 없다. 일시 충돌이면 트랜잭션을 최대 3회 시도한다.
  • ② 커밋 후·발행 전 크래시: 행이 대기로 남는다. 다른 파드의 다음 폴링이 선점해 보낸다.
  • ③ 발행 성공·완료 표시 전 크래시: 행이 처리중에 갇힌다. 10분 뒤 다른 파드가 회수해 같은 eventId 로 다시 보낸다. 중복은 인박스가 거른다.
  • ④ Kafka 일시 장애: 기다리는 시간 상한 안에 응답이 없거나 예외가 나면 틱을 멈추고 다음 폴링에서 재시도한다. 브로커가 실제로는 받았는데 응답만 못 받은 경우도 여기에 들어가고 중복은 인박스가 거른다.
  • ⑤ OMS 중복 수신: 같은 eventId 는 인박스에서 건너뛴다. 수집 이벤트는 인박스 기록 전에 죽었더라도 수집 처리의 기존 배송 제외가 막는다.

②는 지금 OMS 경로에서는 잃는 칸이다. 아웃박스 행이 트랜잭션 밖에서 만들어지니 커밋 직후 크래시면 행이 없다. 사람이 수동 수집으로 채워야 한다. 아웃박스를 주문 트랜잭션 안으로 옮기는 것이 이 칸을 닫는다.

다섯 경우 모두 잃는 것은 없다. 중복이 생기는 곳(③·④)은 모두 같은 eventId 를 달고 다시 나가는 경우라 소비자가 거른다. 결과 기록을 조건부로 바꾸는 것(4-4)은 이 표의 결과를 바꾸지는 않는다. 행 상태가 실제와 어긋나지 않게 해서 재시도 기록과 알림이 정확해지게 한다.

9. 전환 순서와 남은 일

발행자를 한 번에 바꾸지 않는다. 발행자가 둘이 되는 순간이 있고 그 기간을 짧게 두는 것이 목표다.

  1. 커머스 api 에 Kafka producer 와 발행 권한을 준비한다. producer 빈은 이미 있고 아직 쓰는 곳이 없다.
  2. 커머스 릴레이가 지금 쓰는 공유 아웃박스를 그대로 읽어 발행한다. 토픽·봉투·키는 바꾸지 않는다. 이 동안 WMS 릴레이와 같은 행을 두고 CAS 로 경쟁한다. 한 행은 한쪽만 가져가지만 WMS 쪽 결과 기록이 읽고 저장하는 방식이라 중복 발행이 0은 아니다. OMS 가 eventId 로 거르므로 영향은 제한적이다. 처리중 회수 기준은 양쪽 모두 10분 이상으로, 타임존은 같게 확인하고 시작한다.
  3. WMS 릴레이를 끈다. 이 시점부터 아웃박스 주인이 발행까지 맡는다.
  4. OMS 로 갈 이벤트를 주문 DB 의 주문 아웃박스에 주문과 같은 트랜잭션으로 쓰고 릴레이가 그쪽을 읽게 한다.
  5. 결제·취소 흐름의 물류 REST 호출을 걷어낸다.

순서마다 확인할 것이 있다.

  • WMS 에는 OMS 가 주문을 만들지 WMS 가 만들지 정하는 토글이 있다. WMS 수집 REST 가 살아 있고 이 토글이 꺼져 있는 동안 커머스 릴레이가 너무 빨리 발행하면 주문을 만드는 주체가 바뀐다. 운영 토글 값을 확인하기 전에는 릴레이에 유예를 둔다. 물류 REST 를 걷어내는 5단계도 이 확인 뒤에 한다.
  • 커머스의 취소 로직은 "아직 발행되지 않은 수집 행이면 지우고 물류에 취소를 통보하지 않는다"는 규칙에 기댄다. 지금 공유 아웃박스에서는 SENT 가 "적재만 하고 아직 안 보냄"을 뜻한다. 커머스 아웃박스의 SENT 는 "보냄"이다. 같은 단어가 반대 뜻이라 4단계에서 행을 옮길 때 이 규칙을 테스트로 먼저 고정하고 옮긴다.
  • 결과 기록 조건부화, 재시도 상한과 보류 전환, 처리중 회수, 같은 주문의 발행 순서는 실제 replica set 통합 테스트로 확인한다. 특히 "회수당한 실행의 늦은 결과 기록이 0건 반영되는지"를 테스트로 남긴다.

이 설계로도 남는 것이 있다.

  • 취소 가능 여부 판정은 지금도 물류 API 를 동기로 부르고 실패하면 취소를 막는다. 이 판정은 응답이 즉시 필요해서 이번 범위에 넣지 않았다. 판정에 필요한 상태를 커머스가 이벤트로 받아 들고 있는 방향을 따로 검토한다.
  • 장애 표의 결과는 코드와 설계로 따진 것이다. 운영에서 실제로 ③·④가 얼마나 생기는지는 릴레이를 띄운 뒤 회수 로그와 실패 횟수로 본다.

결제 쪽의 다른 정합성 장치(승인과 주문 처리 사이의 실패, 승인 후 취소)는 토스페이먼츠 전환 기록에 정리했다.

요약

  • 전에는 결제·취소 흐름 안에서 메시지를 바로 발행하고 물류 API 를 불렀다. 발행 하나가 실패하면 배송·물류·마일리지 후속 처리가 멈췄다. 취소는 물류 API 의 재시도 대기만큼 늦어졌다.
  • 목표 구조에서는 주문과 주문 아웃박스를 한 MongoDB 트랜잭션으로 커밋하는 데서 결제 흐름이 끝난다. 그 뒤는 커머스 api 의 배치 릴레이가 아웃박스를 폴링·CAS 선점해 주문번호를 키로 Kafka 에 발행한다. OMS 는 eventId 인박스로 중복을 거른다.
  • 별도 코루틴은 응답을 물류에서 떼어 냈지만 보낼 일이 메모리에만 있어 커밋 직후 파드가 내려가면 사라지고 실패가 상태로 남지 않았다. 코루틴 보강, 커밋 직후 바로 발행, Kafka 트랜잭션, 2PC, CDC, 동기 REST + 서킷 브레이커를 따져 봤고 커밋과 같은 원자성으로 보낼 일을 남기는 안은 같은 트랜잭션 아웃박스뿐이었다.
  • 한 트랜잭션 아웃박스, 조건부 상태 전환, TransientTransactionError 한정 재시도, 폴링·CAS·Kafka 릴레이, OMS 인박스는 이미 운영 중인 조각이다. 커머스가 자기 아웃박스를 직접 Kafka 로 릴레이하는 부분과 OMS 이벤트를 주문 트랜잭션 안으로 옮기는 부분이 새로 만들 부분이다.
  • 선점이 행 단위 CAS 라 분산 락이 필요 없다. 처리중 회수 기준 10분은 살아 있는 발행이 최대 약 3분 걸리는 것의 3배다. 결과 기록도 선점 시각을 조건으로 하는 조건부 업데이트로 바꾸고 시간 기준 재시도 상한과 알림을 붙인다. 발행 대기에는 타임아웃을 둔다.
  • Kafka 는 OMS 가 이미 쓰는 계약이라 골랐다. exactly-once 는 Kafka → Kafka 에서만 원자적이고 재발행 중복은 새 오프셋으로 오므로, 발행은 at-least-once 로 두고 소비는 eventId 로 멱등하게 한다.
  • CDC 는 실시간성이 필요 없는 경로에 운영할 인프라를 늘리는 오버 엔지니어링이라 쓰지 않았다.

댓글

댓글을 불러오는 중…