아웃박스 패턴 — 커밋되기 전에 이벤트를 보내지 마라
주문을 저장하고 바로 알림 이벤트를 보냈더니, 가끔 알림 서비스가 「그런 주문 없는데요」라고 해요. 취소된 주문 알림이 나가기도 하고요. 저장과 발행을 안전하게 묶는 방법을 정리했어요.
주문이 생성되면 알림 서비스에 이벤트를 보내요. 저장하고 바로 발행하는 순서로 짰어요.
대부분 잘 돌아가요. 그런데 가끔 알림 서비스가 "그런 주문 없는데요"라고 해요. 조회해 보면 정말 없어요. 이벤트는 왔는데 데이터는 없는 상태예요.
반대 사고도 있어요. 트랜잭션이 롤백됐는데 이벤트는 이미 나갔어요. 취소된 주문에 대한 알림이 사용자에게 갑니다.
빈도가 낮아서 오래 방치되기 쉬워요. "가끔 그러네"로 재시도 버튼을 달고 넘어가게 되죠.
그런데 이건 타이밍 문제라 트래픽이 오르면 비례해서 늘어나요. 어느 순간 무시할 수 없는 수가 되고, 그때는 이미 잘못된 알림이 나간 뒤입니다.
왜 그런가
저장과 발행은 서로 다른 시스템에 대한 두 개의 작업이에요. 하나가 성공하고 다른 하나가 실패할 수 있어요.
발행을 먼저 하고 커밋을 나중에 하면, 커밋 전에 이벤트가 나가요. 받은 쪽이 곧바로 우리 DB 를 조회하면 아직 커밋되지 않아 안 보여요. 이게 "이벤트는 왔는데 데이터는 없는" 상황이에요. 몇 밀리초 차이라 대부분은 성공하지만, 소비자가 빠르면 실패해요.
그리고 발행 뒤에 커밋이 실패하면, 이벤트만 남고 데이터는 없어요. 취소된 주문의 알림이 나가는 경우죠.
순서를 뒤집어 커밋을 먼저 하면 다른 문제가 생겨요. 커밋 직후에 서버가 죽으면 이벤트가 영영 안 나가요. 데이터는 있는데 아무도 모르는 상태고, 이건 조용히 유실되기 때문에 더 나빠요.
근본 원인은 두 시스템에 걸친 원자성을 코드 순서로 흉내 낼 수 없다는 데 있어요. 어느 순서로 배치해도 그 사이에 실패 지점이 남아요.
분산 트랜잭션으로 묶는 방법이 있지만 실무에서 거의 안 씁니다. 브로커 대부분이 지원하지 않고, 성능과 운영 복잡도가 크게 오르거든요.
코드로 보면
한 트랜잭션에 기록하고, 나중에 보낸다:
@Transactional
void order() {
save(order);
broker.publish(OrderCreated); // 커밋 전에 나간다
}
→ 소비자가 조회하면 아직 없다
→ 롤백되면 이벤트만 남는다
· 아웃박스 — 이벤트도 같은 DB 에, 같은 트랜잭션에
@Transactional
void order() {
save(order);
outbox.save(OrderCreated); // 같은 트랜잭션 = 원자적
}
· 별도 워커(또는 CDC)가 아웃박스를 읽어 발행한다
SELECT * FROM outbox WHERE published_at IS NULL
ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED;
→ 발행 → published_at 갱신✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
아웃박스 패턴이 이 문제의 표준 해법이에요. 이벤트를 브로커가 아니라 같은 DB 의 테이블에 먼저 씁니다.
주문 저장과 아웃박스 기록이 같은 트랜잭션이니 둘은 함께 성공하거나 함께 실패해요. 여기까지가 원자성이에요. 그다음 별도 워커가 아웃박스를 읽어 실제로 발행하고 발행 표시를 남겨요.
이 구조에서 보장되는 건 "적어도 한 번"이에요. 발행에 성공했는데 표시를 남기기 전에 죽으면 같은 이벤트가 다시 나가요. 그래서 소비자 쪽이 중복을 견뎌야 해요. 멱등 처리가 짝으로 따라온다는 게 이 패턴의 조건입니다.
"정확히 한 번"을 원하는 요구가 자주 나오는데, 분산 환경에서 그건 대체로 환상이에요. 현실적인 답은 "적어도 한 번 보내고 소비자가 멱등하게 처리한다"예요.
발행 방식은 두 갈래예요. 워커가 주기적으로 아웃박스를 읽는 방식(폴링)과, DB 의 변경 로그를 읽어 자동으로 발행하는 방식(변경 데이터 캡처)이요. 폴링은 만들기 쉽고 의존성이 적어요. 변경 데이터 캡처는 지연이 짧고 애플리케이션 코드가 깨끗하지만, 별도 컴포넌트를 운영해야 해요.
순서도 생각해야 해요. 아웃박스를 id 순으로 읽으면 대체로 순서가 지켜지지만, 워커를 여러 대 돌리면 순서가 섞여요. 같은 주문에 대한 이벤트의 순서가 중요하다면 키 단위로 직렬화하는 장치가 필요합니다.
그리고 아웃박스 테이블도 큐예요. 앞 편에서 본 팽창과 정리 문제가 그대로 적용돼요. 발행 완료된 행을 정기적으로 치워야 합니다.
처방
- 이벤트를 브로커가 아니라 같은 DB 의 아웃박스 테이블에 쓰세요. 비즈니스 데이터와 같은 트랜잭션으로요.
- 발행은 별도 워커가 하도록 분리하세요. SKIP LOCKED 로 여러 워커를 돌릴 수 있어요.
- 소비자를 멱등하게 만드세요. 이벤트에 고유 식별자를 넣고, 소비자가 이미 처리한 것인지 확인하게 하세요. 이건 선택이 아니에요.
- 이벤트에 필요한 데이터를 담으세요. "주문 123 생성됨"만 보내면 소비자가 우리 DB 를 조회해야 하고, 그 시점에 아직 안 보일 수 있어요. 필요한 값을 이벤트에 실어 보내면 조회 자체가 사라집니다.
- 아웃박스의 밀린 정도를 지표로 보세요. 미발행 항목 수와 가장 오래된 것의 나이. 워커가 죽으면 이 둘로 즉시 드러나요.
- 발행 완료된 행을 정기적으로 정리하세요. 파티션이나 배치 삭제로요.
- 커밋 후 콜백에 기대지 마세요. 프레임워크가 제공하는 커밋 후 훅은 커밋 시점 문제는 해결하지만, 그 직후 죽으면 이벤트가 유실돼요. 아웃박스는 그 경우에도 기록이 남습니다.
지금 확인해 보세요
아웃박스가 밀리고 있지 않은가:
· 미발행 이벤트의 수와 나이 — 이 둘이 핵심 지표다
SELECT count(*) AS pending,
max(now() - created_at) AS oldest
FROM outbox WHERE published_at IS NULL;
oldest 가 분 단위로 늘면 워커가 죽었거나 브로커가 막혔다
· 중복 발행이 얼마나 나는지 (소비자 쪽 지표)
이미 처리한 이벤트 id 로 들어온 횟수를 센다
0 이 아닌 게 정상이다. 멱등 처리가 일하고 있다는 뜻
· 발행 완료분이 쌓여 있지 않은지
SELECT count(*) FROM outbox WHERE published_at IS NOT NULL;
정리 배치가 도는지 확인한다핵심 한 줄두 시스템에 걸친 원자성은 순서로 못 만든다. 같은 트랜잭션에 기록하라.