예를 들어 주문 상태를 변경한 뒤 알림톡 Job을 만들고 싶다고 하자.
단순 구현은:
1. 주문 상태 DB 변경
2. Queue에 Notification Job 전송
이다.
코드로 보면:
await prisma.order.update(...);
await queue.add('notification', ...);
문제는 두 작업 사이에서 장애가 발생할 수 있다는 것이다.
DB Update
SUCCESS
↓
서버 Crash
↓
Queue Publish
실행 못 함
결과:
주문은 완료 상태
알림톡 Job은 존재하지 않음
이다.
DB만 보면 정상처럼 보이지만 후속 자동화가 영원히 실행되지 않는다.
그러면 Queue부터 넣으면 될까?
1. Queue Publish
2. DB Update
이렇게 바꿔도 문제가 생긴다.
Queue Publish
SUCCESS
↓
DB Update
FAILED
그러면 Worker는:
주문 상태가 바뀌지도 않았는데
알림톡을 발송
할 수 있다.
PostgreSQL 내부 작업은:
BEGIN
Order Update
Audit Insert
COMMIT
처럼 묶을 수 있다.
하지만:
PostgreSQL
+
Redis Queue
또는:
PostgreSQL
+
External Message Broker
는 서로 다른 시스템이다.
일반적인 DB Transaction 하나로 둘을 동시에 Commit할 수 없다.
이런 문제를 실무에서는 흔히 Dual Write Problem으로 본다.
즉:
DB에 쓰기
+
다른 시스템에도 쓰기
두 작업을 하나의 업무로 처리해야 하는데 둘 중 하나만 성공할 수 있는 문제다.
Queue 전송에 실패하면 Retry할 수 있다.
하지만:
Queue 요청 Timeout
이 발생했다고 하자.
실제로 Queue 등록이:
성공했는지
실패했는지
모를 수 있다.
무작정 Retry하면 중복 Job이 생길 수 있다.
핵심 아이디어는 단순하다.
DB 변경과 “나중에 전달해야 할 Event”를 같은 DB Transaction에 저장한다.
즉:
BEGIN
Order Update
OutboxEvent Insert
COMMIT
한다.
Queue에 직접 보내지 않는다.
예:
Order Update
↓
OutboxEvent
type:
ORDER_STATUS_CHANGED
status:
PENDING
까지 PostgreSQL에 저장한다.
이 두 작업은 같은 Transaction이다.
Order
COMPLETED
Outbox
PENDING
둘 다 존재한다.
Order
변경 없음
Outbox
생성 없음
둘 다 Rollback된다.
따라서:
DB는 변경됐는데 Event가 사라짐
상태를 막을 수 있다.
별도의 Worker가:
PENDING Outbox
를 찾는다.
그리고:
Queue / Event Bus Publish
를 수행한다.
성공하면:
PUBLISHED
로 바꾼다.
Business Transaction
↓
DB 변경
+
Outbox Insert
↓
COMMIT
↓
Outbox Worker
↓
Queue Publish
↓
Consumer
가 된다.
관리자
주문 상태 변경
↓
Transaction
Order
WAITING → COMPLETED
AuditLog
INSERT
OutboxEvent
ORDER_STATUS_CHANGED
↓
COMMIT
이후:
Outbox Worker
↓
Notification Queue
↓
Notification Worker
로 진행한다.
Outbox는 새로운 거대한 시스템을 만드는 것이 아니다.
기존에:
Order Update
+
Audit
+
후속 Job
같은 구조가 있다면 후속 Job Trigger를 Outbox로 안정화하는 것이다.
예:
model OutboxEvent {
id String @id @default(cuid())
eventType String
aggregateType String
aggregateId String
payload Json
status String @default("PENDING")
correlationId String?
causationId String?
attemptCount Int @default(0)
nextRetryAt DateTime?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, nextRetryAt])
}
Event가 어떤 Domain 객체에서 발생했는지 나타낸다.
예:
aggregateType
ORDER
aggregateId
order_123
이다.
예:
ORDER_CREATED
ORDER_STATUS_CHANGED
CONSULT_CREATED
EXPORT_REQUESTED
처럼 명확한 이름을 사용한다.
나쁜 예:
{
"customerName": "...",
"phone": "...",
"address": "...",
"wholeOrder": {}
}
이렇게 전체 객체를 복사하지 않는다.
예:
{
"orderId": "order_123",
"previousStatus": "WAITING",
"newStatus": "COMPLETED"
}
정도로 둔다.
필요한 최신 정보는 Consumer가 DB에서 다시 조회할 수 있다.
항상 최신 DB를 조회하는 게 정답은 아니다.
예:
“상태 변경 당시의 가격”
이 필요하다면 Event에 Snapshot 값을 포함할 수 있다.
핵심은:
현재값이 필요한가?
당시값이 필요한가?
를 구분하는 것이다.
시간이 지나면서 Payload 구조가 바뀔 수 있다.
예:
{
"version": 1,
"orderId": "order_123"
}
이후:
{
"version": 2,
"orderId": "order_123",
"newStatus": "COMPLETED"
}
처럼 바뀔 수 있다.
Queue에 오래된 Event가 남아 있거나 재처리할 경우:
현재 코드가 예상하는 Payload
≠
과거 Payload
가 될 수 있다.
그래서 Event Schema 변경은 신중해야 한다.
Worker가 여러 개라면 같은 OutboxEvent를 동시에 처리할 수 있다.
따라서:
PENDING
→ PROCESSING
을 Atomic하게 Claim해야 한다.
UPDATE outbox_events
SET status = 'PROCESSING'
WHERE id = $1
AND status = 'PENDING';
affected rows가 1인 Worker만 실제 Publish를 수행한다.
예:
PROCESSING
↓
Queue Publish SUCCESS
↓
DB status 업데이트 전에 Worker Crash
하면 DB에는:
PROCESSING
으로 남는다.
하지만 Queue에는 이미 Event가 들어갔다.
Outbox만으로:
Event 정확히 한 번 전달
을 완전히 보장하지 않는다.
Worker가 재실행하면 같은 Event가 다시 Publish될 수 있다.
즉:
Event가 누락되는 것보다
중복 전달될 수 있도록 허용
하고 Consumer가 중복을 방어한다.
Producer는:
Outbox
로 Event 누락을 막고,
Consumer는:
Inbox
로 중복 처리를 막는다.
Consumer가 Event를 받으면 바로 Business Logic부터 실행하지 않는다.
먼저:
이 Event를 이미 처리했는가?
를 확인한다.
예:
model InboxEvent {
id String @id @default(cuid())
eventId String
consumer String
eventType String
status String
receivedAt DateTime @default(now())
processedAt DateTime?
errorCode String?
@@unique([eventId, consumer])
}
같은 Event라도 여러 Consumer가 각각 처리해야 할 수 있다.
예:
ORDER_COMPLETED
를:
NotificationConsumer
AnalyticsConsumer
CRMConsumer
가 각각 처리할 수 있다.
따라서:
eventId + consumer
를 기준으로 중복을 막는다.
Event 수신
↓
Inbox INSERT
↓
이미 존재?
YES
→ Skip
NO
→ Business Logic
↓
Inbox PROCESSED
이다.
예:
ORDER_COMPLETED Event
↓
BEGIN
Inbox Insert
NotificationJob Insert
COMMIT
로 처리한다.
그래야:
Business 변경은 됐는데 Inbox는 실패
같은 문제가 줄어든다.
예:
BEGIN
Inbox Insert
Kakao API 호출
30초 대기
COMMIT
같은 구조는 좋지 않다.
DB Transaction을 너무 오래 잡기 때문이다.
예:
Event 수신
↓
Transaction
Inbox 기록
NotificationJob 생성
COMMIT
후:
Notification Worker
↓
외부 API
를 호출한다.
전체 구조:
Producer DB
Business Change
+
Outbox Event
↓
Outbox Publisher
↓
Queue
↓
Consumer
↓
Inbox
↓
Consumer Transaction
↓
Next Job / State Change
이다.
다음 문제들을 각각 방어한다.
DB 성공 + Event 누락
→ Outbox
Event 중복 전달
→ Inbox
Consumer Crash
→ Inbox 상태 + Retry
외부 Side Effect 불명확
→ Idempotency + Reconciliation
Outbox:
Event를 잃지 않도록 함
Idempotency:
같은 Event/Action이 여러 번 실행돼도
결과가 중복되지 않도록 함
둘 다 필요하다.
예:
Event ID는 다르지만
실제 업무는 동일
할 수도 있다.
예:
evt_1
ORDER_COMPLETED
evt_2
ORDER_COMPLETED
가 실수로 두 번 생성됐다.
Inbox는 Event ID가 다르므로 둘 다 처리한다.
따라서 Consumer Side Effect에도:
notification:order_123:completed
같은 업무 기준 Idempotency Key가 필요할 수 있다.
같은 Event ID 재전달
→ Inbox로 차단
서로 다른 Event지만 같은 업무
→ Business Idempotency로 차단
이다.
Event를 처음 만들 때:
eventId
를 생성하고 이후 Publish/Retry에서도 유지한다.
Retry마다 새로운 Event ID를 만들면 Inbox 중복 방지가 의미가 없어진다.
eventId
논리적 Event
attempt
Publish 시도 횟수
이다.
예:
Correlation
cor_123
Request
req_1
Outbox Event
evt_1
Notification Event
evt_2
여러 Event가 하나의 Correlation 안에 포함될 수 있다.
다음 Event가 어떤 Event 때문에 발생했는지도 기록할 수 있다.
예:
ORDER_STATUS_CHANGED
evt_1
↓
NOTIFICATION_REQUESTED
evt_2
이면:
evt_2.causationId = evt_1
이다.
HTTP Request
req_1
↓
ORDER_STATUS_CHANGED
evt_1
↓
NOTIFICATION_REQUESTED
evt_2
↓
PROVIDER_RESULT
evt_3
전체가:
correlationId = cor_1
을 가진다.
이 구조를 사용하면 모든 시스템의 상태가 동시에 바뀌지는 않는다.
예:
10:00:00
Order = COMPLETED
10:00:01
NotificationJob 생성
10:00:03
알림톡 Provider 접수
몇 초 동안 상태 차이가 존재한다.
즉:
모든 시스템이 즉시 같은 상태가 되는 것은 아니지만, 시간이 지나면 최종적으로 일관된 상태에 도달한다.
Strong Consistency:
Transaction 끝나는 순간
모든 관련 상태가 확정
Eventual Consistency:
핵심 상태 먼저 확정
↓
후속 상태가 비동기로 따라옴
이다.
예:
주문 상태 변경
+
Audit Log
가 같은 DB라면 Transaction으로 즉시 일관성을 맞추는 것이 좋다.
예:
알림톡
Analytics
검색 인덱스
Notion Report
외부 CRM
Webhook 후속 처리
처럼 즉시 동기화될 필요가 없는 기능이다.
주문 성공 여부가:
알림톡 Provider
상태에 의존하면 안 된다.
좋은 구조:
주문 Transaction
COMMIT
↓
Outbox
↓
Notification
이다.
Provider 장애가 발생해도 주문은 정상 저장된다.
외부 Provider 장애:
Notification
영향
은 있지만:
Order API
정상
을 유지할 수 있다.
0924의 Bulkhead/Resilience와 연결된다.
예:
주문 상태
완료
인데 바로 아래:
알림톡 상태
대기 중
일 수 있다.
이것은 꼭 오류가 아니다.
예:
주문 처리
완료
고객 안내
발송 처리 중
처럼 별도 상태로 보여준다.
모든 상태가 즉시 완료될 것처럼 UI를 만들면 사용자 혼란이 생긴다.
예:
Order Status
COMPLETED
Notification
PENDING
처럼 본다.
예:
Order COMPLETED
Notification FAILED
라고 해서:
Order를 WAITING으로 되돌림
은 잘못된 경우가 많다.
후속 기능의 상태를 별도로 관리한다.
예:
주문 상태
→ orders table
알림톡 발송 상태
→ notification_jobs
Event 전달 상태
→ outbox_events
각 상태의 책임을 명확히 한다.
현재 구조에서는:
Order Table
= 현재 업무 상태
Event
= 어떤 변화가 발생했는지 전달/기록
정도로 사용하면 충분하다.
Event Sourcing까지 갈 필요는 없다.
가장 단순한 Outbox Publisher는:
1초마다
PENDING Outbox 조회
한다.
예:
SELECT *
FROM outbox_events
WHERE status = 'PENDING'
AND (
next_retry_at IS NULL
OR next_retry_at <= now()
)
ORDER BY created_at
LIMIT 100;
이다.
너무 짧으면:
DB Query 증가
하고,
너무 길면:
Event 전달 지연
이 커진다.
현재 규모라면 초 단위 Polling으로도 충분할 수 있다.
100개 Event를 한 번에 Claim할 수도 있다.
단:
한 Worker가 너무 오래 잠금
을 잡지 않도록 주의한다.
FOR UPDATE SKIP LOCKEDPostgreSQL에서는 여러 Worker가 Queue처럼 DB Row를 가져갈 때 활용할 수 있다.
개념적으로:
SELECT ...
FOR UPDATE SKIP LOCKED
를 사용하면 다른 Worker가 잡은 Row를 건너뛸 수 있다.
무조건 Raw SQL을 고집할 필요는 없다.
기존 Prisma 구조에서 Atomic Update 방식이 더 이해하기 쉽다면 그 방식을 써도 된다.
핵심은:
동일 Outbox Event를
두 Worker가 동시에 Publish하지 않는 것
이다.
PROCESSING
→ PUBLISHED
하고:
publishedAt
을 기록한다.
Retryable이면:
PENDING
또는
RETRY_PENDING
으로 되돌리고:
nextRetryAt
을 지정한다.
예:
Invalid Event Payload
Unsupported Event Version
처럼 Retry로 해결되지 않는 경우:
DEAD_LETTER
로 보낸다.
Outbox 단계에도 DLQ가 필요할 수 있다.
예:
evt_123
eventType
ORDER_COMPLETED
error
UNSUPPORTED_EVENT_VERSION
attempt
5
status
DEAD_LETTER
이다.
중요 Event가 DLQ에 들어갔다는 것은 후속 업무가 누락됐다는 뜻일 수 있다.
예:
주문은 완료
Notification Event는 DLQ
이므로 운영자에게 보여야 한다.
Consumer에서도:
RECEIVED
PROCESSING
PROCESSED
FAILED
DEAD_LETTER
같은 상태가 필요할 수 있다.
0930에서 외부 Event를 Inbox 형태로 저장한다고 했다.
실제로:
WebhookEvent
모델이 이미 있다면 별도 Inbox 모델을 또 만들지 않고 역할을 통합할 수도 있다.
예:
OutboxEvent
InboxEvent
WebhookEvent
DomainEvent
EventLog
를 전부 별도 Table로 만들면 과할 수 있다.
현재 실제 필요에 맞게 역할을 합친다.
현재 프로젝트라면 우선:
OutboxEvent
WebhookEvent / Inbox
Job
정도만 있어도 충분할 수 있다.
예:
consumerRegistry.register(
'ORDER_STATUS_CHANGED',
orderStatusChangedConsumer,
);
처럼 Event Type에 따라 Consumer를 선택한다.
예:
ORDER_COMPLETED
Consumer A
NotificationJob 생성
Consumer B
Analytics 반영
처럼 분리할 수 있다.
나쁜 예:
OrderCompletedConsumer
- 알림톡 발송
- Analytics
- CRM
- Notion
- 파일 생성
하나가 실패하면 전체 처리 상태가 애매해진다.
예:
Event Consumer
↓
NotificationJob 생성
AnalyticsJob 생성
정도로 처리한다.
BEGIN
Inbox 기록
NotificationJob Insert
COMMIT
이면 Consumer 처리 자체는 짧고 안정적이다.
하나의 Event가 여러 후속 작업을 발생시키는 것을 생각할 수 있다.
ORDER_COMPLETED
├─ Notification
├─ Analytics
└─ CRM
이다.
예:
Notification
SUCCESS
Analytics
FAILED
CRM
SUCCESS
처럼 독립적으로 관리할 수 있다.
예:
Analytics
5회 Retry
고객 메시지
중복 위험 때문에 보수적 Retry
처럼 차이가 있다.
Eventual Consistency 시스템에서 또 하나 중요한 문제가 있다.
예:
ORDER_CREATED
ORDER_CANCELLED
순서로 발생했는데 Consumer에는:
ORDER_CANCELLED
ORDER_CREATED
순서로 도착할 수 있다.
특히:
여러 Queue Partition
Retry
Network Delay
등이 있으면 순서가 뒤집힐 수 있다.
이를 방어하기 위해 Domain 객체에 Version을 둘 수 있다.
예:
Order version
12
13
14
Event에도:
{
"orderId": "order_123",
"aggregateVersion": 14
}
를 넣는다.
현재 Consumer가 알고 있는 Version:
14
인데 Event:
13
이 들어오면:
stale event
로 판단할 수 있다.
예:
DELIVERED
상태인데 늦게 도착한:
SENT
Event 때문에 상태가 후퇴해서는 안 된다.
Webhook 순서 역전과 같은 원리다.
예:
analytics.increment
같은 독립 Event는 순서가 중요하지 않을 수 있다.
업무 상태 전이 Event에만 필요할 수 있다.
예:
ORDER_COMPLETED Event 수신
후 바로 처리하기 전에:
현재 Order Status
를 DB에서 확인할 수 있다.
현재 이미 CANCELLED라면 완료 알림 발송 여부를 다시 판단해야 한다.
Event는 보통:
무슨 일이 발생했다
를 의미한다.
예:
ORDER_COMPLETED
반면:
SEND_NOTIFICATION
은 Event라기보다 Command에 가깝다.
이미 일어난 사실
예:
ORDER_COMPLETED
무엇을 실행하라는 요청
예:
SEND_ORDER_COMPLETE_NOTIFICATION
Event:
ORDER_STATUS_CHANGED
Command/Job:
SEND_NOTIFICATION
으로 구분한다.
예:
ORDER_COMPLETED
↓
Consumer
↓
NotificationJob
이다.
예:
관리자 메모 수정
후 다른 시스템이 아무것도 할 필요 없다면 Outbox Event도 필요 없다.
Outbox가 가치 있는 경우:
DB 변경 후
반드시 후속 비동기 작업이 수행되어야 함
이다.
현재 프로젝트라면:
주문 생성
주문 중요 상태 변경
상담 상태 변경
Export 요청
Notification 요청
Workflow 시작
등이 후보가 된다.
먼저:
실패하면 고객 영향이 있거나
업무 누락이 생기는 Event
부터 Outbox를 사용한다.
Analytics처럼 누락 영향이 작은 작업은 나중에 적용해도 된다.
Audit Log:
누가 무엇을 변경했는가?
Outbox:
어떤 후속 시스템에
무슨 Event를 전달해야 하는가?
이다.
목적이 다르다.
예:
BEGIN
Order Update
AuditLog Insert
OutboxEvent Insert
COMMIT
할 수 있다.
이렇게 하면 업무 상태, 변경 이력, 후속 Event가 같이 확정된다.
PUBLISHED Event를 영원히 유지할 필요는 없다.
예:
30일
90일
등 일정 기간 뒤 Archive/Delete할 수 있다.
최근 Incident를 조사하는데 Outbox 기록이 이미 삭제됐다면 추적이 어려울 수 있다.
Retention 정책을 정한다.
0929의 Scheduler와 연결해서:
매일 새벽
PUBLISHED
90일 초과
↓
Batch Delete
같이 처리할 수 있다.
예:
DELETE 1,000,000 rows
보다는:
1,000~10,000 단위 Batch
등으로 처리하는 편이 안전하다.
Publisher가 자주 보는 조건:
status
nextRetryAt
createdAt
에 맞춰 Index를 둔다.
Inbox는 중복 확인을 위해 보관하지만 모든 Event를 영구 유지하면 커진다.
중복 Event가 재전달될 수 있는 기간보다 길게 유지해야 한다.
예를 들어 Provider가 최대 며칠 뒤 재전송할 수 있다면 그보다 짧게 삭제하면 안 된다.
무조건:
7일
같은 고정 숫자를 적용하지 않는다.
중요한 운영 Metric이다.
예:
Oldest PENDING Outbox Age
가 10분이라면 Publisher가 밀리고 있다는 뜻이다.
예:
outbox_pending
outbox_processing
outbox_published_total
outbox_failed_total
outbox_dead_letter
outbox_oldest_pending_age
정도다.
inbox_received_total
inbox_processed_total
inbox_duplicate_total
inbox_failed_total
inbox_dead_letter
등을 볼 수 있다.
평소 Event 중복이 거의 없었는데:
duplicate rate 급증
하면 Producer나 Broker가 이상할 수 있다.
예:
event.createdAt
→
consumer.processedAt
까지 걸린 시간이다.
예:
Order 완료
↓
NotificationJob 생성
↓
Provider 발송
까지의 전체 시간을 측정할 수 있다.
예:
99%의 주문 완료 Event가
30초 이내 NotificationJob으로 변환
같은 목표를 둘 수 있다.
웹 API는 정상인데:
Outbox Publisher
중단
되면 주문은 계속 저장된다.
하지만 후속 알림은 모두 멈춘다.
예:
Oldest Pending > 1분
Warning,
> 5분
Critical
같이 업무에 맞춰 정할 수 있다.
Worker가 살아 있어도 Publish가 멈춰 있을 수 있다.
핵심은:
PENDING Event가 실제 감소하는가?
다.
예:
COMPLETED Order 존재
그런데 관련 OutboxEvent 없음
이면 Transaction 설계나 코드 경로에 문제가 있을 수 있다.
주기적으로:
후속 Event가 있어야 하는데 없는 상태
를 검사할 수 있다.
이미 후속 작업이 다른 경로로 실행됐을 수도 있다.
Business Idempotency Key를 이용한다.
Order
COMPLETED
NotificationJob
이미 존재
OutboxEvent
없음
이라면 새 NotificationJob까지 다시 만들 필요 없다.
Desired:
COMPLETED 주문에는
완료 알림 처리 결과가 존재
Actual:
NotificationJob 있음
이면 이미 일관적이다.
Retryable:
Queue Timeout
Connection Error
Broker 503
Non-Retryable:
지원하지 않는 Event Type
Payload Schema Invalid
등이다.
OutboxEvent를 만들 때 가능한 한 Schema Validation을 한다.
잘못된 Event를 DB에 넣고 Publisher에서 뒤늦게 실패하는 것을 줄인다.
Schema 검증은 가볍게 한다.
외부 API 확인 같은 작업은 넣지 않는다.
프로젝트 수준에서 간단하게:
const eventSchemas = {
ORDER_STATUS_CHANGED: orderStatusChangedSchema,
};
정도로 관리할 수 있다.
대형 Schema Registry 시스템까지는 필요 없다.
Queue/Event는 JSON 형태로 넘어갈 수 있으므로 Runtime Validation이 필요할 수 있다.
예:
Zod
같은 Schema Validator를 사용할 수 있다.
기존 프로젝트 도구가 있다면 재사용한다.
UNKNOWN_EVENT_VERSION
같은 Error Code를 사용한다.
중요 Event인데 Consumer가 조용히 Skip하면 Silent Failure가 된다.
예:
Event ID
Event Type
Aggregate
Error
Attempts
Created
Action
정도를 보여준다.
예:
Retry
Inspect Payload
Mark Ignored
등이다.
예:
Legacy Event
No longer required
처럼 Audit를 남긴다.
DLQ Event Payload를 사람이 임의로 고치면 원본 Event 의미가 달라진다.
가능하면:
원본 보존
새 Corrective Event 생성
이 더 안전하다.
잘못된 Event가 발생했다면:
기존 Event 삭제/수정
보다:
정정 Event
를 만들 수 있다.
중요한 것은 Outbox/Inbox 핵심 원칙이다.
DB + Outbox
한 Transaction
Consumer + Inbox
한 Transaction
부터 적용한다.
0930의 Workflow Step이 성공한 뒤 다음 Step을 직접 Queue에 넣는 대신:
Step SUCCESS
+
OutboxEvent NEXT_STEP_READY
를 같은 Transaction에 저장할 수도 있다.
같은 PostgreSQL 상태와 동일 Queue를 쓰는 간단한 Workflow라면 기존 Reconciliation만으로 충분할 수도 있다.
중요 DB 변경
→ 외부 비동기 시스템
경계다.
BEGIN
Order Update
WorkflowRun Insert
OutboxEvent WORKFLOW_START_REQUESTED
COMMIT
처럼 사용할 수 있다.
0929의:
ScheduleRun 생성
→ Queue 등록
사이에도 같은 Dual Write가 존재한다.
ScheduleRun = PENDING
을 저장하고 Reconciliation이 Queue 누락을 복구한다.
ScheduleRun
+
OutboxEvent
를 Transaction으로 저장한다.
이미 Outbox Infrastructure가 있다면 두 번째가 자연스러울 수 있다.
복잡도와 효과를 비교한다.
가장 중요한 경로부터 적용한다.
주문 상태 변경
→ 알림톡/후속 자동화
처럼 DB 변경 후 반드시 후속 작업이 필요한 곳.
Export 요청
→ Export Worker
이다.
WorkflowRun 생성
→ Workflow Worker
이다.
단순 Analytics
개발용 Report
처럼 누락 영향이 적은 Event다.
개인 로컬 자동화라면 PostgreSQL Outbox까지 붙이는 것은 과할 수 있다.
현재:
Run 상태 파일/DB
+
Resume
만으로 충분할 가능성이 높다.
중요 고객 업무는:
내구성 우선
개인 보고서 자동화는:
단순함 우선
으로 가져간다.
장점만 있는 것은 아니다.
Table 추가
Publisher Worker
Retry/DLQ 관리
Retention
Metric
관리자 조회
가 필요하다.
누락돼도 다시 계산 가능한 내부 통계라면 Outbox 없이 Batch Reconciliation으로 충분할 수 있다.
Outbox:
애초에 후속 Event 누락 가능성을 줄임
Reconciliation:
이미 발생한 상태 불일치를 찾아서 복구
이다.
가장 안전한 구조는:
Outbox로 예방
+
Reconciliation으로 최종 검증
이다.
코드 Bug로 OutboxEvent 생성 자체를 빼먹을 수 있다.
또는 Consumer Logic이 잘못될 수 있다.
완벽한 방어는 없다.
예:
DB Transaction
↓
Outbox
↓
Inbox
↓
Business Idempotency
↓
Reconciliation
여러 층의 방어를 둔다.
업무 중요도에 따라 적용한다.
Outbox:
PENDING
↓
PROCESSING
↓
PUBLISHED
실패:
PROCESSING
↓
RETRY_PENDING
↓
PROCESSING
한도 초과:
DEAD_LETTER
RECEIVED
↓
PROCESSING
↓
PROCESSED
실패:
FAILED
↓
RETRY_PENDING
최종:
DEAD_LETTER
Queue에 Publish했다고 업무가 성공한 것은 아니다.
단계별 성공을 구분한다.
Outbox
PUBLISHED
Consumer
PROCESSED
Business Job
SUCCESS
이다.
예:
Order Event
Published
Notification Consumer
Processed
NotificationJob
SENT
까지 가야 고객 안내 전체 흐름이 성공이다.
예:
10:00:00
order.status.changed
10:00:00
outbox.created
10:00:01
outbox.published
10:00:01
event.received
10:00:01
inbox.processed
10:00:02
notification.job.created
10:00:04
notification.sent
이렇게 전체를 추적할 수 있다.
관리자 UI에서는 기술 용어 대신:
후속 처리 대기
후속 처리 실패
재처리 필요
로 표현할 수도 있다.
OutboxEvent
Event ID
Consumer
Attempt
Error
Correlation ID
를 볼 수 있다.
전체 Retry
이다.
DLQ 수천 건을 한꺼번에 재처리하면 장애가 커질 수 있다.
예:
10개
50개
100개
단위로 제한한다.
대량 Retry에는 Confirmation과 Rate Limit을 둔다.
DLQ 재처리에서도:
Concurrency
Backoff
Provider Rate Limit
을 지켜야 한다.
과거 Event를 다시 처리하는 기능을 만들 수도 있다.
예:
Analytics Consumer 코드 수정
↓
지난 7일 ORDER_COMPLETED Replay
이다.
Notification Consumer까지 같이 Replay하면 고객에게 알림톡이 다시 갈 수 있다.
예:
Analytics
Replay Safe
Customer Notification
Replay Restricted
처럼 둔다.
Consumer에:
LIVE
REPLAY
Context를 전달할 수도 있다.
Replay에서는 외부 Side Effect를 막고 Read Model만 재구성하게 할 수 있다.
Event History가 충분하다면 통계/검색 Read Model을 다시 만들 수 있다.
하지만 현재는 Event Sourcing이 아니므로 모든 상태를 Replay로 재구축할 수 있다고 가정하면 안 된다.
Outbox Retention을 90일만 한다면 전체 시스템 역사를 재생할 수 없다.
목적은:
안전한 Event Delivery
이지:
영구적인 모든 Domain History
가 아니다.
두 패턴의 목적이 다르다.
예:
주문 완료
후 즉시 알림톡이 안 왔다고 해서 주문 실패로 보이면 안 된다.
UI:
신청이 완료되었습니다.
안내 메시지는 순차 발송됩니다.
처럼 처리할 수 있다.
예:
알림톡
30초 이내
Analytics
5분 이내
일일 Report
1시간 이내
처럼 업무마다 다르다.
반드시:
얼마나 늦어도 되는가?
실패를 어떻게 감지하는가?
영원히 안 되면 어떻게 복구하는가?
가 있어야 한다.
예:
Order 완료 후
NotificationJob 생성까지
최대 30초
를 허용한다.
이 시간을 넘으면 Drift로 간주한다.
예:
COMPLETED Order
AND completedAt < now - 5m
AND 완료 NotificationJob 없음
을 찾는다.
Desired:
완료 주문
→ 완료 알림 처리 존재
Actual:
없음
이면 Reconciliation 대상이다.
Business Idempotency Key:
notification:order_123:completed
를 조회한다.
예:
actor
SYSTEM_EVENT_CONSUMER
eventId
evt_123
action
NOTIFICATION_JOB_CREATED
정도로 기록할 수 있다.
Audit는 비즈니스적으로 중요한 변경 중심으로 유지한다.
Application/Event Log와 Audit를 구분한다.
Outbox:
outbox.created
outbox.publish.started
outbox.published
outbox.publish.failed
Inbox:
event.received
event.duplicate
consumer.started
consumer.completed
consumer.failed
같은 이벤트 이름을 정한다.
예:
OUTBOX_PUBLISH_TIMEOUT
OUTBOX_PUBLISH_FAILED
EVENT_SCHEMA_INVALID
EVENT_VERSION_UNSUPPORTED
INBOX_CONSUMER_FAILED
DUPLICATE_EVENT
등이다.
At-Least-Once 시스템에서는 중복 전달이 정상적으로 발생할 수 있다.
따라서:
INFO
event.duplicate
정도로 처리할 수 있다.
평소보다 급격히 늘어난 경우에는 이상 신호다.
Queue, DB, DLQ, 로그 등 여러 위치에 복제되기 때문이다.
고객 전화번호 전체 대신:
customerId
orderId
를 전달한다.
Event 자체가 고객 데이터 저장소가 되지 않게 한다.
현재 내부 시스템 수준에서는 Event Payload 자체를 별도 암호화하는 것보다:
민감 데이터 최소화
접근 제어
DB/전송 암호화
가 우선이다.
불필요하게 복잡하게 만들 필요는 없다.
Publisher는:
Outbox 읽기
상태 변경
Queue Publish
정도만 필요하다.
다른 Production DB 수정 권한을 넓게 줄 필요 없다.
AI Workflow에서도 Event를 활용할 수 있다.
예:
AI_RUN_COMPLETED
AI_RUN_FAILED
REPORT_CREATED
같은 이벤트다.
예:
AI Report 생성
후:
REPORT_CREATED
Event를 만들고 별도 Consumer가 Notion 업로드를 담당하게 할 수도 있다.
Local LLM Report처럼 규모가 작다면 Workflow Step 직접 연결이 더 단순하다.
Event 분리는 독립 Consumer가 실제로 필요할 때 적용한다.
예:
AI Change Approved
↓
OutboxEvent
↓
Deployment Workflow
처럼 중요한 DB 상태와 후속 배포 Trigger를 안전하게 연결할 수 있다.
예:
{
"eventType": "AI_CHANGE_APPROVED",
"workflowRunId": "run_123",
"policyVersion": "v9"
}
정도로 추적할 수 있다.
예:
Release APPROVED
상태 변경과:
DEPLOY_REQUESTED
Event를 같은 Transaction에 저장할 수 있다.
Release APPROVED
그런데 Deploy Job 없음
문제를 줄일 수 있다.
(environment, releaseId)
를 기준으로 같은 Release가 중복 배포되지 않게 한다.
0927 내용과 연결된다.
예:
09:00 Scheduler
↓
ScheduleRun 생성
+
Outbox SCHEDULE_RUN_READY
↓
Publisher
↓
Workflow Worker
↓
WorkflowRun
↓
Step 실행
처럼 연결 가능하다.
현재 프로젝트에서 한꺼번에:
Scheduler
Workflow
Outbox
Inbox
Saga
DLQ
를 모두 추가하면 복잡도가 너무 커질 수 있다.
핵심 DB 변경 + Outbox
Outbox Publisher + Retry
Consumer Inbox + 중복 방지
DLQ + Dashboard
Reconciliation + SLO
순서가 좋다.
예:
UpdateOrderStatusUseCase
에서:
Order Update
Audit Log
OutboxEvent
를 한 Transaction으로 묶는다.
order/
├─ application/
│ └─ update-order-status.use-case.ts
│
event/
├─ outbox/
│ ├─ outbox.repository.ts
│ ├─ outbox.publisher.ts
│ └─ outbox.worker.ts
│
├─ inbox/
│ ├─ inbox.repository.ts
│ └─ event-consumer.ts
│
└─ registry/
└─ event-consumer.registry.ts
await prisma.$transaction(
async (tx) => {
await orderRepository.updateStatus(
tx,
orderId,
status,
);
await auditRepository.create(
tx,
audit,
);
await outboxRepository.create(
tx,
{
eventType:
'ORDER_STATUS_CHANGED',
aggregateId:
orderId,
payload: {
orderId,
status,
},
correlationId,
},
);
},
);
Transaction 안에서:
queue.add()
하지 않는다.
오직 DB 작업만 수행한다.
개념:
async function publishEvent(
event: OutboxEvent,
) {
const claimed =
await claim(event.id);
if (!claimed) {
return;
}
try {
await eventBus.publish({
id: event.id,
type: event.eventType,
payload: event.payload,
correlationId:
event.correlationId,
});
await markPublished(event.id);
} catch (error) {
await handlePublishFailure(
event,
error,
);
}
}
async function consume(event) {
const inserted =
await inboxRepository
.createIfAbsent({
eventId: event.id,
consumer:
'notification-consumer',
});
if (!inserted) {
return;
}
await prisma.$transaction(
async (tx) => {
await notificationJobRepository
.createIdempotently(
tx,
event.payload.orderId,
);
await inboxRepository
.markProcessed(
tx,
event.id,
'notification-consumer',
);
},
);
}
예:
Inbox PROCESSED로 기록
↓
Business Update 실패
같은 상황을 만들면 Event가 다시 와도 Skip해버릴 수 있다.
이것이 핵심이다.
긴 Consumer 작업이라면:
RECEIVED
PROCESSING
을 사용할 수 있지만 외부 Side Effect는 별도 Job으로 보내는 편이 더 단순하다.
강제로 Outbox Insert에서 Exception을 발생시킨다.
Expected:
Order Update
ROLLBACK
Audit
ROLLBACK
Outbox
없음
이다.
Outbox
PENDING
Publish
Timeout
Expected:
Order 상태 유지
Outbox Retry 예정
Event 유실 없음
이다.
Expected:
Event가 중복 Publish될 가능성 있음
Consumer Inbox가 중복 처리 차단
이다.
Expected:
Inbox Logical Record
1개
Business Side Effect
1회
이다.
예:
evt_1
evt_2
둘 다:
order_123 완료 알림
Expected:
NotificationJob idempotency
→ 1개
이다.
version 13
먼저
version 12
나중
Expected:
version 12
stale 처리
이다.
Expected:
Inbox 실패 상태
Retry 가능
Business State 일부 저장 안 됨
이어야 한다.
Expected:
Retry 무한 반복 X
Dead Letter
이다.
0925에서 만든 Reliability Test에:
Outbox Publisher Timeout
Consumer Crash
Duplicate Delivery
Out-of-order Delivery
시나리오를 추가한다.
다음은 Incident 후보가 될 수 있다.
Outbox Lag 급증
Critical Event DLQ 발생
Consumer Dead Letter 증가
Business Consistency Drift
예:
Outbox Lag 증가
1. Publisher Health 확인
2. Queue Health 확인
3. DB Query 지연 확인
4. Oldest PENDING 확인
5. Retry Rate 확인
6. DLQ 확인
7. Publisher 복구
8. Backlog Drain 관찰
Outbox가 1시간 밀렸다가 Publisher가 복구됐다고:
100,000 Event
동시 Publish
하면 안 된다.
Batch Size
Concurrency
Rate Limit
을 이용해 천천히 처리한다.
가능하다면:
Order / Notification
HIGH
Analytics
LOW
같이 나눌 수 있다.
하지만 처음부터 복잡한 Priority Broker까지 만들 필요는 없다.
예:
Outbox
Pending
12
Oldest
3s
Retry
2
DLQ
0
Inbox
Received
1,284
Duplicate
17
Failed
1
정도로 볼 수 있다.
Event ID
Type
Aggregate
Version
Status
Attempts
Created
Published
Correlation
Error
등을 제공한다.
개인정보가 포함될 가능성 때문이다.
안전한 Metadata 중심으로 보여준다.
먼저:
Retry DLQ
정도만 구현하고,
실제 Replay 요구가 생기면 확장한다.
당장은 다음까지 할 필요는 없다.
Kafka Cluster
Schema Registry 서버
Exactly Once Kafka Transaction
Event Sourcing
CQRS Read Model 대규모 분리
복잡한 Stream Processing
이다.
현재 규모에서는 기존 PostgreSQL을 이용하면:
추가 Infrastructure 최소화
Transaction 활용 가능
운영 단순
이라는 장점이 있다.
Outbox Producer 구조를 유지하면서:
현재 Queue
에서:
다른 Broker
로 변경할 수 있다.
Domain Use Case가 Broker에 직접 의존하지 않게 만드는 것이 중요하다.
예:
interface EventPublisher {
publish(
event: IntegrationEvent,
): Promise<void>;
}
Outbox Worker는 이 Interface에 의존한다.
예:
UpdateOrderStatusUseCase
Redis Queue Library
에 직접 의존하지 않는다.
Use Case는 Outbox Repository에 Event를 기록한다.
Domain Event와 외부 전달용 Event를 구분하고 싶다면:
Domain Event
Integration Event
를 나눌 수 있다.
애플리케이션 내부 Domain 관점의 변화.
OrderCompleted
다른 Component/Worker가 소비하도록 안정적인 Payload 형태로 만든 Event.
ORDER_COMPLETED_V1
이다.
구조가 커질 때 필요하면 분리한다.
Event Consumer는 Producer와 다른 시점에 배포될 수 있다.
API보다 더 느슨하게 연결되어 있어 Breaking Change가 눈에 잘 안 띌 수 있다.
기존 필드를 갑자기 삭제하기보다:
새 필드 추가
방식이 안전하다.
예:
{
"orderId": "...",
"status": "...",
"carrier": "LGU"
}
처럼 새 필드를 추가한다.
기존 Consumer는 무시할 수 있다.
예:
ORDER_COMPLETED_V1
ORDER_COMPLETED_V2
처럼 구분한다.
v1 Event가 더 이상 생성되지 않고 Queue/DLQ에도 남아 있지 않은지 확인한다.
신규 Consumer를:
OFF
로 배포하고:
내부 테스트
→ ON
할 수도 있다.
문제 있는 Consumer만 끌 수 있으면 좋다.
예:
analytics_consumer_enabled
을 끈다고 주문 Event 생성까지 막히면 안 된다.
중요 Consumer라면:
처리 일시 중단
만 하고 Event는 Queue/Inbox에 남겨 복구 후 처리한다.
flag OFF
→ ACK하고 Event 삭제
는 위험할 수 있다.
별도 상태:
PAUSED
를 두는 것이 더 명확할 수 있다.
AI에게:
DLQ Event 요약
반복 Error Code 분류
Outbox Lag 원인 후보
Consumer 실패 Timeline
관련 Runbook 추천
을 맡길 수 있다.
특히 Production Event는 업무 기록이다.
AI는 분석/제안 위주로 둔다.
예:
Outbox backlog
1,420 events
Oldest age
18m
Top event
ORDER_STATUS_CHANGED
Top failure
QUEUE_TIMEOUT
Customer Impact
Notification delay possible
같은 요약을 만들 수 있다.
예:
### Event Delivery
- Outbox Pending: 0
- DLQ: 0
- Duplicate Events: 3
- Max Delivery Delay: 2.1s
등이다.
현재 필요성이 있을 때만 추가한다.
OutboxEvent Table
Business Transaction에서
Outbox Insert
Publisher Worker
Retry
Consumer Inbox
중복 방지
DLQ
Metrics
Reconciliation
이다.
UpdateOrderStatusUseCase 같은 중요 Write Use Case 하나에만 적용해본다.
전체 시스템을 한 번에 바꾸지 않는다.
예:
CreateOrder
UpdateConsultStatus
RequestExport
등으로 확장한다.
다음 질문을 먼저 한다.
이 Queue 등록이 누락되면
업무 상태가 영구적으로 잘못되는가?
YES라면 Outbox 가치가 크다.
현재 NestJS + Prisma + PostgreSQL + 기존 Queue/Worker 구조에
Transactional Outbox / Inbox 패턴을 최소 범위로 추가해줘.
목표는 DB 상태 변경과 Queue/Event Publish 사이의
Dual Write 문제를 줄이고,
Event 중복 전달 상황에서도 Consumer Side Effect가
중복 실행되지 않게 만드는 것이다.
Kafka, Event Sourcing, 별도 대형 Event Platform은 도입하지 않는다.
기존 PostgreSQL과 Queue Infrastructure를 최대한 재사용한다.
우선 첫 적용 대상은
중요한 주문 상태 변경 후 후속 자동화가 필요한 Use Case로 한다.
1. OutboxEvent 모델을 만든다.
필드 예:
- id
- eventType
- eventVersion
- aggregateType
- aggregateId
- payload
- status
- correlationId
- causationId
- attemptCount
- nextRetryAt
- publishedAt
- createdAt
- updatedAt
2. Outbox 상태:
- PENDING
- PROCESSING
- RETRY_PENDING
- PUBLISHED
- DEAD_LETTER
3. 중요 Business Write Use Case에서
DB 상태 변경과 OutboxEvent 생성이
반드시 동일 Prisma Transaction 안에서 처리되게 한다.
예:
- Order Update
- Audit Log
- OutboxEvent
4. DB Transaction 안에서
Queue Publish 또는 외부 API 호출을 하지 않는다.
5. Outbox Publisher Worker를 만든다.
역할:
- Publish 대상 조회
- Atomic Claim
- 기존 EventPublisher를 통한 Publish
- 성공 시 PUBLISHED
- 실패 시 Retry 판단
- Retry Limit 초과 시 DEAD_LETTER
6. 동일 OutboxEvent를 여러 Publisher Worker가
동시에 처리하지 못하게 한다.
Atomic status transition 또는
현재 PostgreSQL 구조에 적절한 Lock 방식을 사용한다.
7. Publish Retry에서도 eventId는 변경하지 않는다.
Attempt만 증가한다.
8. Queue Publish 성공 후
Outbox 상태 업데이트 전에 Worker가 종료될 수 있다는 것을 고려한다.
따라서 동일 Event가 여러 번 전달될 수 있다는 전제로 설계한다.
9. Consumer 측 Inbox 구조를 만든다.
필드 예:
- id
- eventId
- consumer
- eventType
- status
- receivedAt
- processedAt
- errorCode
10. 동일 Event가 동일 Consumer에 여러 번 도착해도
Business 처리 횟수는 한 번이어야 한다.
DB Unique Constraint:
eventId + consumer
또는 동등한 구조를 사용한다.
11. Inbox 중복 방지는
동일 eventId 재전달을 방어한다.
서로 다른 eventId이지만 동일 Business Action인 경우를 위해
기존 Job/Domain Idempotency도 유지한다.
12. Consumer 처리에서
Inbox 처리 완료 기록과
Business DB 변경은 가능한 경우
같은 Transaction에서 처리한다.
13. Consumer 안에서 외부 API를
긴 DB Transaction 안에서 직접 호출하지 않는다.
필요하면 별도 Job을 생성한다.
14. Event Payload에는
Secret이나 불필요한 개인정보를 넣지 않는다.
가능하면:
- orderId
- consultId
- status
등의 내부 참조 ID 중심으로 구성한다.
15. Event Version을 지원한다.
지원하지 않는 Version은
무한 Retry하지 않고
명확한 Error Code와 DEAD_LETTER 흐름으로 보낸다.
16. 다음 Error Code를 고려한다.
- OUTBOX_PUBLISH_FAILED
- OUTBOX_PUBLISH_TIMEOUT
- EVENT_SCHEMA_INVALID
- EVENT_VERSION_UNSUPPORTED
- INBOX_CONSUMER_FAILED
17. Event Payload Runtime Validation을 적용한다.
프로젝트에 기존 Zod 등의 Validator가 있다면 재사용한다.
18. Event 이름은 사실형을 기본으로 한다.
예:
ORDER_STATUS_CHANGED
SEND_NOTIFICATION 같은 실행 명령은
Job/Command로 구분한다.
19. 기존 Correlation ID / Causation ID 구조와 연결한다.
Event 로그에서 최소:
- eventId
- eventType
- aggregateId
- correlationId
- causationId
를 추적할 수 있게 한다.
20. Structured Log Event 예:
- outbox.created
- outbox.publish.started
- outbox.published
- outbox.publish.failed
- event.received
- event.duplicate
- consumer.started
- consumer.completed
- consumer.failed
21. 다음 Metric을 수집할 수 있게 한다.
- outbox pending
- outbox published
- outbox failed
- outbox dead letter
- oldest pending age
- inbox received
- inbox duplicate
- inbox failed
22. Outbox / Inbox Record가 무한히 증가하지 않도록
향후 Retention/Cleanup이 가능한 구조를 만든다.
지금 단계에서는 안전한 Cleanup 기준만 설계하고
파괴적인 대량 삭제 자동화는 하지 않는다.
23. 관리자 또는 운영 API에서
다음 정보를 조회할 수 있는 최소 구조를 고려한다.
- PENDING
- RETRY
- DEAD_LETTER
- Event Type
- Attempt
- Error
- Correlation ID
Payload 전체나 Secret은 노출하지 않는다.
24. DLQ 대량 Retry 기능은 만들지 않는다.
필요하다면 소량의 개별 Retry부터 지원한다.
25. 다음 Reliability Test를 작성한다.
- Business Transaction 성공 + Outbox 생성
- Outbox 생성 실패 시 Business Transaction Rollback
- Publisher Timeout 후 Retry
- 동일 Event 중복 Publish
- 동일 Event 3회 Consumer 전달
- Inbox 중복 처리 방지
- Publish 성공 후 Publisher Crash 상황
- 서로 다른 Event ID지만 같은 Business Action Idempotency
- Unsupported Event Version
- Consumer 실패 후 Retry
- Event 순서 역전 시 상태 후퇴 방지
26. 첫 적용 범위를 최소화한다.
기존 전체 Queue 호출을 한번에 Outbox로 마이그레이션하지 말고,
주문 상태 변경처럼 Event 누락 시 고객/운영 영향이 큰
하나의 Use Case부터 적용한다.
현재 코드베이스와 기존 Queue/Audit/Reconciliation 구조를
먼저 분석한 뒤 중복 구현 없이 통합해줘.
현재 Outbox / Inbox 구현을 대상으로
Event Delivery Reliability Test를 작성해줘.
테스트 목적은
Exactly Once Delivery를 가정하는 것이 아니라,
At-Least-Once Delivery 환경에서
최종 Business Side Effect가 Effectively Once로
처리되는지 확인하는 것이다.
필수 Scenario:
1. Order Transaction Commit과 Outbox 생성
2. Outbox Publish 실패 후 Retry
3. Queue Publish는 성공했지만
Publisher가 PUBLISHED 저장 전에 종료
4. 동일 Event ID 3회 Consumer 전달
5. 서로 다른 Event ID지만
동일 orderId + action의 중복 Event
6. Consumer 처리 도중 DB Exception
7. Unsupported Event Version
8. Out-of-order Event
9. Publisher Recovery 후 Backlog 처리
각 Test에서 다음을 확인한다.
- Event 유실 여부
- 중복 Business Side Effect 여부
- 최종 DB 상태
- Outbox 상태
- Inbox 상태
- Retry 횟수
- Audit/Log Context
- Correlation ID 유지
Production 실제 Queue나 고객 데이터를 사용하지 않고
Integration Test 환경에서 재현한다.
0930에서는:
여러 Step으로 이어지는 Workflow가
중간에 실패했을 때 어떻게 Resume할 것인가?
를 다뤘다.
1001에서는 그보다 더 아래 계층에서:
DB 변경은 성공했는데
다음 Queue/Event 자체가 사라지면 어떻게 하는가?
를 다뤘다.
가장 핵심적인 문제는 Dual Write다.
DB Update
+
Queue Publish
를 따로 실행하면 항상 둘 중 하나만 성공할 가능성이 존재한다.
그래서 Transactional Outbox는:
BEGIN
Business Change
Outbox Event
COMMIT
으로 처리한다.
그 뒤 별도 Publisher가:
Outbox
→ Queue
를 담당한다.
이 구조는 Event 누락 가능성을 크게 줄여준다.
하지만 Publisher가:
Queue Publish SUCCESS
↓
DB 상태 변경 전에 Crash
할 수도 있으므로 Event는 중복 전달될 수 있다.
그래서 Consumer는:
Inbox
+
Idempotency
를 사용한다.
전체 흐름은:
Business Transaction
↓
Outbox
↓
Publisher
↓
Queue
↓
Inbox
↓
Consumer
↓
Business Job
이다.
Outbox와 Inbox의 역할은 각각 다르다.
Outbox
= Event 누락 방지
Inbox
= 동일 Event 중복 처리 방지
Business Idempotency
= 서로 다른 Event라도 동일 업무 중복 방지
이다.
즉 Inbox만 있다고:
완벽한 중복 방지
가 되는 것은 아니다.
또 이 구조를 사용하면 모든 상태가 동시에 변경되지 않는다.
Order 완료
↓
잠시 후 Event Publish
↓
잠시 후 NotificationJob
↓
잠시 후 실제 발송
처럼 시간 차이가 생긴다.
이것이 Eventual Consistency다.
중요한 것은 Eventual Consistency를:
언젠가는 맞겠지
로 이해하면 안 된다는 것이다.
반드시:
허용 가능한 지연
Monitoring
SLO
Reconciliation
Dead Letter
이 있어야 한다.
현재 투게더몰 같은 규모에서는 Kafka나 Event Sourcing 같은 복잡한 기술까지 갈 필요는 없다.
우선:
PostgreSQL Outbox
기존 Queue
Inbox Unique Constraint
Business Idempotency
Reconciliation
정도로도 충분히 강한 구조를 만들 수 있다.
그리고 모든 기능을 한 번에 Outbox로 바꾸기보다는:
주문 상태 변경
→ 후속 알림/업무
처럼 후속 Event가 누락됐을 때 실제 고객이나 운영에 문제가 생기는 핵심 Write Use Case 하나부터 적용하는 것이 현실적이다.
지금까지 흐름을 연결하면:
Business Change
↓
Local Transaction
↓
Outbox
↓
At-Least-Once Delivery
↓
Inbox
↓
Idempotent Consumer
↓
Eventual Consistency
↓
Reconciliation
↓
Final Consistency
가 된다.
결국 Outbox·Inbox의 핵심은
“Event를 정확히 한 번 전달하려고 애쓰기보다, Event가 누락되지 않게 만들고 중복 전달될 수 있다는 사실을 받아들인 뒤, 최종 업무 효과가 한 번만 발생하도록 설계하는 것”
이다.