← Posts

Event-Driven · Outbox · Messaging

저장과 발행을 한 묶음으로 - 아웃박스

DB에 저장하고 이벤트를 발행하는 사이에 프로세스가 죽으면, 저장은 됐는데 발행은 안 된 어중간한 상태가 남는다. 두 시스템을 한 트랜잭션으로 못 묶는 문제를, 발행을 DB에 먼저 적어 푼다.

목차
  1. 저장과 발행 사이에서 죽는다
  2. 순서를 바꿔도 안 된다
  3. 왜 둘을 한 트랜잭션으로 못 묶나
  4. 아웃박스: 발행을 DB에 먼저 적는다
  5. 릴레이가 아웃박스를 읽어 보낸다
  6. 그래서 결국 “적어도 한 번”이 된다
  7. 릴레이는 어떻게 아웃박스를 보나
  8. 실무에서: 순서와 정리
  9. 정리

주문을 저장하고 “주문 생성됨” 이벤트를 발행한다. 두 줄짜리 코드지만, 그 사이에 프로세스가 죽으면 데이터가 어긋난다. DB와 메시지 브로커라는 두 시스템을 어떻게 한 묶음으로 다루느냐가 이 글이다.

저장과 발행 사이에서 죽는다

흔한 코드다.

plaintext
db.save(order)          // 1. 주문을 DB에 저장
broker.publish(event)   // 2. "주문 생성됨" 이벤트 발행

1번과 2번 사이에서 프로세스가 죽으면? DB엔 주문이 있는데 이벤트는 안 나갔다. 결제·배송 서비스는 이 주문을 영영 모른다.넣었는데 안 넣어진 경우가 조용한 데이터 유실이다 - 에러도 안 나고, 주문은 멀쩡히 저장돼 있으니 한참 뒤에야 “왜 배송이 안 됐지”로 발견된다.

순서를 바꿔도 안 된다

“그럼 발행을 먼저 하면?” 똑같이 깨진다.

plaintext
broker.publish(event)   // 1. 이벤트 먼저 발행
db.save(order)          // 2. 그다음 저장

이번엔 반대로, 이벤트는 나갔는데 저장 직전에 죽으면 주문이 없는데 결제·배송이 시작된다. 유령 주문이다.

순서를 어떻게 놓아도 구멍이 있다. 근본 원인은 저장(DB)과 발행(브로커)이 서로 다른 두 시스템이라는 데 있다. 하나의 트랜잭션으로 둘을 함께 묶을 수 없으니, 그 사이엔 항상 “하나만 된” 순간이 생긴다.

왜 둘을 한 트랜잭션으로 못 묶나

DB 트랜잭션은 그 DB 안의 일만 “다 되거나 다 안 되거나”로 보장한다. 브로커는 그 트랜잭션 밖의 남이다. db.save가 롤백돼도 이미 나간 broker.publish는 주워 담을 수 없다.

두 시스템에 걸친 진짜 분산 트랜잭션(2단계 커밋)이 이론상 있지만, 느리고 복잡하고 한쪽이 죽으면 전체가 묶여 실무에선 대개 피한다. 그래서 다른 길을 쓴다 - 발행을 브로커가 아니라 DB에 먼저 적는 것.

아웃박스: 발행을 DB에 먼저 적는다

핵심 아이디어는 이렇다. 이벤트를 브로커로 곧장 보내지 말고, 같은 DB의 outbox 테이블에 한 줄로 적는다. 그러면 주문 저장과 이벤트 적기가 같은 DB 안의 일이 되어, 하나의 트랜잭션으로 묶인다.

diagramdiagram

이제 “주문은 저장됐는데 이벤트는 없는” 상태가 원천적으로 불가능하다. 둘은 한 트랜잭션이라 함께 커밋되거나 함께 사라진다. 멱등 소비자 글에서 인박스를 처리와 같은 트랜잭션에 넣은 것과 정확히 같은 발상 - 이번엔 보내는 쪽 버전이다.

릴레이가 아웃박스를 읽어 보낸다

DB에 적기만 하면 브로커엔 아직 안 갔다. 그래서 릴레이(relay) 라는 별도 일꾼이 아웃박스를 지켜본다.

diagramdiagram

릴레이는 아웃박스에서 아직 안 보낸 줄을 꺼내 브로커로 발행하고, 성공하면 그 줄을 “보냄”으로 표시(하거나 지운다). 애플리케이션은 DB에 적는 것까지만 책임지고, 실제 발행은 릴레이가 끈질기게 대신한다 - 브로커가 잠깐 죽어 있어도 아웃박스에 남아 있으니 살아나면 마저 보낸다.

그래서 결국 “적어도 한 번”이 된다

여기서 중요한 성질이 하나 나온다. 릴레이가 이벤트를 발행하고 “보냄” 표시를 하기 직전에 죽으면, 살아난 뒤 그 줄을 다시 보낸다. 즉 아웃박스는 중복 발행을 만든다.

이건 결함이 아니라 의도된 타협이다. 아웃박스는 “이벤트를 잃지 않는다(적어도 한 번)“를 보장하는 대신 “중복은 있을 수 있다”를 받아들인다. 그 중복은 받는 쪽이 멱등 소비자로 삼킨다. 그래서 이 둘은 이다 - 아웃박스가 안 잃게 보내고, 멱등 소비자가 두 번을 한 번으로 만든다.

릴레이는 어떻게 아웃박스를 보나

릴레이가 아웃박스를 읽는 방식은 크게 둘이다.

  • 폴링(polling) - 릴레이가 주기적으로 “안 보낸 줄 있니?”를 쿼리한다. 단순하고 어디서나 되지만, 너무 자주 물으면 DB에 부담이고 너무 뜸하면 이벤트가 늦는다.
  • 로그 따라가기(CDC) - DB가 남기는 변경 로그를 릴레이가 따라 읽어, 아웃박스에 줄이 들어오는 즉시 감지한다. 폴링보다 빠르고 가볍지만, DB의 로그를 읽는 장치를 붙여야 한다.

초중급 단계에선 폴링으로 시작해도 충분하다. 트래픽이 커져 지연·부하가 문제될 때 로그 방식으로 옮긴다.

실무에서: 순서와 정리

  • 순서를 지키려면 아웃박스에 순번을 둔다 - 릴레이가 그 순서대로 보내면 발행 순서는 지킨다(도착 순서까지는 브로커·파티셔닝의 몫). 순번 없이 병렬로 보내면 뒤섞인다.
  • 보낸 줄은 쌓인다 - “보냄” 표시만 하고 안 지우면 테이블이 무한히 큰다. 주기적으로 오래된 줄을 지우거나, 아예 발행 후 삭제한다.
  • 릴레이는 하나만(또는 조율해서) - 릴레이 여러 대가 같은 아웃박스를 동시에 긁으면 같은 줄을 여럿이 보내 중복이 폭증한다. 한 대가 맡거나, 줄을 잠가 나눠 갖는다.

정리

  • 저장과 발행은 서로 다른 두 시스템이라 한 트랜잭션으로 못 묶는다 - 순서를 어떻게 놓아도 “하나만 된” 구멍이 남는다.
  • 아웃박스는 이벤트를 브로커 대신 같은 DB에 먼저 적어, 저장과 발행 기록을 한 트랜잭션으로 묶는다.
  • 릴레이가 아웃박스를 읽어 끈질기게 브로커로 보낸다 - 브로커가 잠깐 죽어도 안 잃는다.
  • 대신 릴레이는 중복을 만든다(“적어도 한 번”) - 그래서 멱등 소비자으로 쓴다.

다음 글은 이렇게 안전하게 오가는 이벤트로, 여러 서비스에 걸친 하나의 일을 잇는 사가로 간다.

관련 글