leo.dev
backend

브로커 없이 만든 트랜잭셔널 아웃박스

— SERIES 동시성 Part 03 / 03
  1. Part 01 비관적 락이 못 막은 write skew
  2. Part 02 락 없이 안 겹치는 인보이스 채번
  3. Part 03 브로커 없이 만든 트랜잭셔널 아웃박스

회원 가입에서 웰컴메일을 보내는 코드는 보통 이렇게 생겼다.

await this.companyRepo.save(company); // 가입 커밋
this.emailService.sendWelcomeEmail(company.email).catch(() => {}); // 가는 김에 메일

메일 발송을 await하지 않거나, 하더라도 실패를 catch로 삼킨다. 가입이라는 본 작업을 메일 때문에 실패시킬 순 없으니까. 그런데 이러면 메일이 안 가도 아무도 모른다. SES가 잠깐 죽어 있었든, 네트워크가 끊겼든, 예외는 빈 catch로 사라지고 로그에도 안 남는다. 관측가능성 글에서 말한 그 무음 실패다.

문제가 하나 더 있다. 가입 트랜잭션은 커밋됐는데 메일은 그 밖에서 따로 날아간다. 둘은 원자적이지 않다. 가입은 됐는데 메일은 안 갔거나, 드물게는 그 반대다. 상태를 바꾸는 일과 그에 딸린 외부 발송이 따로 노는 이중 쓰기(dual write) 문제다.

해법은 알려져 있다. 트랜잭셔널 아웃박스. 보통 Kafka·SQS 같은 브로커와 함께 설명되지만, 우리에겐 이미 MySQL이 있었다. 테이블 하나로 충분했다.

보낼 일을 트랜잭션 안에 같이 적는다

핵심 아이디어는 단순하다. 외부로 바로 보내지 말고, “이걸 보내야 한다”는 사실을 본 작업과 같은 트랜잭션 안에서 DB에 적는다.

outbox_messages 테이블(eventType·payload·status·attempts·nextAttemptAt)을 두고, enqueue가 호출자의 트랜잭션 매니저를 받아 그 트랜잭션에 참여한다.

await this.dataSource.transaction(async (em) => {
await em.save(subscription); // 구독 past_due로 전환
await this.outbox.enqueue('payment_failed', payload, em); // 같은 트랜잭션에 적재
});

이제 상태 전환과 발송 예약이 한 묶음이다. 트랜잭션이 커밋되면 둘 다 남고, 롤백되면 둘 다 사라진다. “구독을 past_due로 바꿨다 ⟺ 결제 실패 안내를 보내기로 했다”가 원자적으로 묶인다. 실제 발송은 나중에 별도 워커가 이 테이블을 읽어 처리한다. 본 트랜잭션은 외부 I/O를 기다리지 않고 즉시 끝난다.

워커는 외부 I/O 동안 락을 쥐지 않는다

발송 워커에서 제일 조심한 건 락 보유 범위다. 메시지 행을 잠그고 그 락을 쥔 채 SES 발송(외부 HTTP)을 기다리면, 느린 네트워크 동안 행 락이 묶여 경합·타임아웃이 난다. 그래서 claim과 dispatch를 두 단계로 갈랐다.

1단계 claim (트랜잭션):
미발송 행을 락 → 아직 PROCESSING 아닌지 재확인 → status=PROCESSING → 커밋(락 해제)
2단계 dispatch (락 밖):
실제 발송 → 성공이면 SENT / 실패면 attempts++·nextAttemptAt 미루기(백오프) / 한도 초과면 FAILED

행을 PROCESSING으로 “찜”해 두는 순간 락을 놓는다. 외부 발송은 아무 락도 없이 한다. 대신 워커가 1단계와 2단계 사이에 크래시하면 그 행은 PROCESSING인 채 영영 멈춘다. 그래서 일정 시간 넘게 PROCESSING으로 남은 stale 행을 회수하는 경로를 둔다. 워커 자체는 배치 앱의 @Cron(1분)으로 돌리되, 한 틱이 1분을 넘겨 다음 틱과 겹치지 않게 재진입 가드(running 플래그)를 걸었다.

at-least-once의 대가는 멱등이다

이 구조는 at-least-once다. 정확히 한 번이 아니다. 워커가 발송에 성공하고 SENT로 마킹하기 직전에 죽으면, 다음 틱이 같은 메시지를 또 보낸다. 즉 같은 메시지가 두 번 발송될 수 있다.

웰컴메일이 두 번 가는 건 사소하다. 첫 consumer로 웰컴메일을 고른 이유가 그거다. 저위험으로 메커니즘을 검증했다. 하지만 결제처럼 중복이 치명적인 핸들러는 멱등 키가 필수다. 같은 이벤트를 두 번 받아도 한 번만 효과가 나도록, 핸들러 쪽에서 처리 여부를 키로 막아야 한다. 아웃박스가 “최소 한 번”을 보장하니, “많아야 한 번”은 받는 쪽이 책임진다. 핸들러가 실패하면 반드시 throw해야 재시도 대상이 된다는 규칙도 같이 따라온다. 조용히 삼키면 아웃박스로 옮긴 의미가 없다.

Outbox로 보내면 안 되는 알림이 있다

결제 도메인의 발송을 아웃박스로 옮기다 비자명한 함정을 만났다. 결제 관련 발송에는 두 종류가 섞여 있었다. 고객사 어드민에게 가는 고객 안내(카드 재등록 유도)와, 우리 운영팀에게 가는 운영 알림(“결제는 됐는데 DB 업데이트가 실패했다” 같은 경보).

고객 안내는 아웃박스로 옮겼다. 그런데 운영 알림을 같이 옮기려다 멈췄다. 아웃박스는 outbox_messages 테이블에 적재한다. 즉 DB에 의존한다. 운영 알림의 상당수는 바로 그 “DB 쓰기가 실패했다”를 알리는 경보다. DB가 고장난 상황을 알리려고 같은 DB에 적재하면, 적재 자체가 실패한다. 정작 가장 알려야 할 순간에 안 간다.

그래서 갈랐다. DB 장애를 알리는 경보는 아웃박스로 보내지 않는다. DB와 독립된 채널(직접 SES 발송)로 유지한다. 아웃박스는 “DB가 정상”이라는 전제 위에서만 동작한다. 정상 트랜잭션을 커밋한 뒤 그에 딸린 고객 안내를 안정적으로 보내는 게 아웃박스의 자리고, “DB가 죽었다”는 메시지는 그 전제 자체가 깨진 경우라 다른 길로 가야 한다.

그래서

브로커를 들이지 않고도 “보낼 일을 트랜잭션에 묶고, 따로 보낸다”는 아웃박스의 핵심은 테이블 하나로 선다. 어려운 건 자료구조가 아니라 경계 조건이었다. 외부 I/O 동안 락을 놓기, 크래시한 PROCESSING을 회수하기, at-least-once를 멱등으로 받기, 그리고 DB 장애 알림만은 아웃박스 밖으로 빼기. fire-and-forget을 지우는 일은 발송을 더 똑똑하게 만드는 게 아니라, 안 보내진 걸 DB가 기억하게 만드는 일이었다.

↑↓ 이동 열기esc 닫기