Django Transactional Outbox: 발행 성공 직후 워커가 죽으면 어떻게 될까?
주문은 저장됐는데 확인 메일 작업은 큐에 없다. on_commit()으로 작업을 옮겨도 DB 커밋 직후 프로세스가 종료되면 이 간격은 남는다. Transactional Outbox는 그 간격을 DB 안의 재처리 가능한 기록으로 바꾼다. 하지만 메시지를 정확히 한 번만 전달해 주지는 않는다.
기준: Django 5.2 · PostgreSQL 17. 예제는 주문과 Outbox가 같은 DB에 있는 구조다. 모델·브로커 어댑터는 서비스에 맞게 구현해야 한다.
먼저 보장할 것을 한 문장으로 정한다
이 글의 목표는 “성공한 주문마다 발행할 이벤트 기록이 남는다”이다. “브로커에 한 번만 전달된다”는 별도 문제다. 기존 on_commit과 발행 누락 글에서 다룬 선택 기준을 넘어, 여기서는 워커의 장애 구간을 살펴본다.
DB 트랜잭션은 브로커의 저장까지 함께 커밋하지 않는다. 따라서 업무 변경과 발행 의도를 한 트랜잭션에 기록하고, 다른 워커가 그 의도를 전달한다. Django의 커밋 callback은 커밋 이후 실행되며 callback 실패가 이미 끝난 트랜잭션을 되돌리지는 않는다. Django 트랜잭션 문서
주문과 이벤트를 함께 저장한다
아래 코드에서 Order는 customer_id, total을, Outbox는 UUID event_id, 문자열 topic, JSON payload, nullable published_at을 가진다고 가정한다. event_id에는 DB 유일 제약을 둔다.
from uuid import uuid4
from django.db import transaction
def place_order(customer_id, total):
with transaction.atomic():
order = Order.objects.create(customer_id=customer_id, total=total)
event_id = uuid4()
Outbox.objects.create(
event_id=event_id,
topic="order.created.v1",
payload={"order_id": order.pk, "total": str(total)},
)
return order.pk
금액은 JSON 변환 중 부동소수점으로 바꾸지 않는다. 이벤트에는 소비자가 해석할 스키마 버전과 고정된 식별자를 담는다. 재시도할 때 event_id를 새로 만들면 소비자가 중복을 알아볼 수 없다.
또한 이 함수 자체에 같은 HTTP 요청이 두 번 들어오면 주문도 두 개 생긴다. Outbox는 요청 멱등성을 대신하지 않는다. 주문 생성 요청의 중복 제어는 다음 편에서 다룬다.
발행과 완료 표시 사이에는 여전히 틈이 있다
| 장애 시점 | DB 상태 | 복구 방향 |
|---|---|---|
| 주문 트랜잭션 커밋 전 | 주문·이벤트 모두 롤백 | 요청 정책에 따라 재시도 |
| 커밋 후, 발행 전 | 미발행 이벤트 존재 | 워커가 다시 읽음 |
| 브로커 수락 후, 완료 표시 전 | 여전히 미발행 | 같은 이벤트가 다시 전달될 수 있음 |
| 완료 표시 후 | 발행 완료 | 보존 정책에 따라 정리 |
특히 브로커 응답을 받지 못했다는 사실은 브로커가 메시지를 받지 못했다는 뜻이 아니다. 타임아웃은 성공 여부가 불명확한 상태일 수 있다. DB에 먼저 완료 표시를 하고 발행하면 반대로 영구 누락 가능성이 생긴다.
초기 워커가 행 잠금 안에서 네트워크 발행을 수행하면 구현은 단순하지만 브로커 지연 동안 DB 잠금과 연결을 점유한다. 규모가 커지면 짧은 트랜잭션에서 선점한 후 잠금 밖에서 발행하고, 선점 토큰으로 완료를 확인한다. 선점의 구체적인 구조는 skip_locked 편의 리스 모델로 이어진다.
소비자는 이벤트 ID로 업무 효과를 중복 제거한다
같은 DB 안에서 처리되는 소비자라면 아래 경계를 사용할 수 있다. Receipt.event_id는 unique이며, apply_event_to_database()는 같은 DB만 변경하는 함수다.
from django.db import transaction
def consume(event_id, payload):
with transaction.atomic():
receipt, created = Receipt.objects.get_or_create(event_id=event_id)
if not created:
return "duplicate"
apply_event_to_database(payload)
return "applied"
업무 처리가 실패하면 receipt도 함께 롤백된다. 단, 업무 처리가 외부 이메일 발송이나 외부 결제라면 이 DB 경계로 중복 효과를 막을 수 없다. 외부 제공자의 멱등성 키, 별도의 상태 머신, 결과 조회·정산 절차가 필요하다. get_or_create()의 동시성 안전성도 조회 키의 DB 유일성에 의존한다. QuerySet 문서
배포 전에는 실패 지점을 일부러 만든다
- 주문 저장 뒤 예외를 발생시켜 주문과 Outbox가 함께 사라지는지 확인한다.
- 브로커 수락 직후 워커를 중단하고, 재시작 때 동일
event_id가 전달되는지 본다. - 같은 이벤트를 동시에 소비시켜 업무 결과와 receipt가 각각 하나만 남는지 확인한다.
- 영구 실패 이벤트가 계속 재시도되지 않도록 최대 시도 횟수와 수동 재처리 경로를 둔다.
운영에서는 단순 미발행 개수보다 가장 오래된 미발행 이벤트의 나이, 재시도 횟수, 독성 메시지 수를 함께 본다. 이벤트 보관 기간은 소비자의 중복 제거 기록 보관 기간과 맞춰야 한다. 기록을 너무 빨리 지우면 오래된 메시지가 새로운 작업처럼 실행될 수 있다.
Outbox를 도입할 때의 완료 기준은 테이블 생성이 아니다. 장애가 발생해도 이벤트를 찾아 다시 처리할 수 있고, 재전달이 업무를 중복 실행하지 않는지까지 확인하는 것이다.
댓글
댓글 쓰기