TIL - 20261001

juni·2일 전

TIL

목록 보기
467/468

1001 운영 자동화/AI 워크플로우 심화 (25/N): Outbox·Inbox, Event Delivery와 Eventual Consistency


✅ 1. DB 저장과 Queue 전송 사이에는 항상 위험한 틈이 있다

예를 들어 주문 상태를 변경한 뒤 알림톡 Job을 만들고 싶다고 하자.

단순 구현은:

1. 주문 상태 DB 변경
2. Queue에 Notification Job 전송

이다.

코드로 보면:

await prisma.order.update(...);

await queue.add('notification', ...);

문제는 두 작업 사이에서 장애가 발생할 수 있다는 것이다.


✅ 2. 가장 대표적인 실패 상황

DB Update
SUCCESS

↓

서버 Crash

↓

Queue Publish
실행 못 함

결과:

주문은 완료 상태

알림톡 Job은 존재하지 않음

이다.

DB만 보면 정상처럼 보이지만 후속 자동화가 영원히 실행되지 않는다.


✅ 3. 반대 순서도 안전하지 않다

그러면 Queue부터 넣으면 될까?

1. Queue Publish
2. DB Update

이렇게 바꿔도 문제가 생긴다.

Queue Publish
SUCCESS

↓

DB Update
FAILED

그러면 Worker는:

주문 상태가 바뀌지도 않았는데
알림톡을 발송

할 수 있다.


✅ 4. 두 시스템을 하나의 DB Transaction으로 묶을 수 없는 이유

PostgreSQL 내부 작업은:

BEGIN

Order Update

Audit Insert

COMMIT

처럼 묶을 수 있다.

하지만:

PostgreSQL
+
Redis Queue

또는:

PostgreSQL
+
External Message Broker

는 서로 다른 시스템이다.

일반적인 DB Transaction 하나로 둘을 동시에 Commit할 수 없다.


✅ 5. Dual Write Problem

이런 문제를 실무에서는 흔히 Dual Write Problem으로 본다.

즉:

DB에 쓰기

+

다른 시스템에도 쓰기

두 작업을 하나의 업무로 처리해야 하는데 둘 중 하나만 성공할 수 있는 문제다.


✅ 6. 단순 Retry만으로 해결하기 어렵다

Queue 전송에 실패하면 Retry할 수 있다.

하지만:

Queue 요청 Timeout

이 발생했다고 하자.

실제로 Queue 등록이:

성공했는지

실패했는지

모를 수 있다.

무작정 Retry하면 중복 Job이 생길 수 있다.


✅ 7. 여기서 Transactional Outbox가 등장한다

핵심 아이디어는 단순하다.

DB 변경과 “나중에 전달해야 할 Event”를 같은 DB Transaction에 저장한다.

즉:

BEGIN

Order Update

OutboxEvent Insert

COMMIT

한다.

Queue에 직접 보내지 않는다.


✅ 8. Outbox 구조

예:

Order Update

↓

OutboxEvent

type:
ORDER_STATUS_CHANGED

status:
PENDING

까지 PostgreSQL에 저장한다.

이 두 작업은 같은 Transaction이다.


✅ 9. Transaction 성공

Order
COMPLETED

Outbox
PENDING

둘 다 존재한다.


✅ 10. Transaction 실패

Order
변경 없음

Outbox
생성 없음

둘 다 Rollback된다.

따라서:

DB는 변경됐는데 Event가 사라짐

상태를 막을 수 있다.


✅ 11. Outbox Worker

별도의 Worker가:

PENDING Outbox

를 찾는다.

그리고:

Queue / Event Bus Publish

를 수행한다.

성공하면:

PUBLISHED

로 바꾼다.


✅ 12. 전체 흐름

Business Transaction

↓

DB 변경
+
Outbox Insert

↓

COMMIT

↓

Outbox Worker

↓

Queue Publish

↓

Consumer

가 된다.


✅ 13. 주문 예시

관리자
주문 상태 변경

↓

Transaction

Order
WAITING → COMPLETED

AuditLog
INSERT

OutboxEvent
ORDER_STATUS_CHANGED

↓

COMMIT

이후:

Outbox Worker

↓

Notification Queue

↓

Notification Worker

로 진행한다.


✅ 14. 기존 Outbox를 이미 일부 사용한다면 확장한다

Outbox는 새로운 거대한 시스템을 만드는 것이 아니다.

기존에:

Order Update
+
Audit
+
후속 Job

같은 구조가 있다면 후속 Job Trigger를 Outbox로 안정화하는 것이다.


✅ 15. OutboxEvent 기본 모델

예:

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])
}

✅ 16. aggregateType / aggregateId

Event가 어떤 Domain 객체에서 발생했는지 나타낸다.

예:

aggregateType
ORDER

aggregateId
order_123

이다.


✅ 17. eventType

예:

ORDER_CREATED

ORDER_STATUS_CHANGED

CONSULT_CREATED

EXPORT_REQUESTED

처럼 명확한 이름을 사용한다.


✅ 18. Payload는 최소화한다

나쁜 예:

{
  "customerName": "...",
  "phone": "...",
  "address": "...",
  "wholeOrder": {}
}

이렇게 전체 객체를 복사하지 않는다.


✅ 19. 필요한 정보만 넣는다

예:

{
  "orderId": "order_123",
  "previousStatus": "WAITING",
  "newStatus": "COMPLETED"
}

정도로 둔다.

필요한 최신 정보는 Consumer가 DB에서 다시 조회할 수 있다.


✅ 20. Payload Snapshot이 필요한 경우도 있다

항상 최신 DB를 조회하는 게 정답은 아니다.

예:

“상태 변경 당시의 가격”

이 필요하다면 Event에 Snapshot 값을 포함할 수 있다.

핵심은:

현재값이 필요한가?

당시값이 필요한가?

를 구분하는 것이다.


✅ 21. Event도 Version을 가진다

시간이 지나면서 Payload 구조가 바뀔 수 있다.

예:

{
  "version": 1,
  "orderId": "order_123"
}

이후:

{
  "version": 2,
  "orderId": "order_123",
  "newStatus": "COMPLETED"
}

처럼 바뀔 수 있다.


✅ 22. Event Version이 없으면 오래된 Consumer가 깨질 수 있다

Queue에 오래된 Event가 남아 있거나 재처리할 경우:

현재 코드가 예상하는 Payload
≠
과거 Payload

가 될 수 있다.

그래서 Event Schema 변경은 신중해야 한다.


✅ 23. Outbox Worker의 Claim

Worker가 여러 개라면 같은 OutboxEvent를 동시에 처리할 수 있다.

따라서:

PENDING
→ PROCESSING

을 Atomic하게 Claim해야 한다.


✅ 24. 예

UPDATE outbox_events
SET status = 'PROCESSING'
WHERE id = $1
  AND status = 'PENDING';

affected rows가 1인 Worker만 실제 Publish를 수행한다.


✅ 25. Outbox Worker도 Crash할 수 있다

예:

PROCESSING

↓

Queue Publish SUCCESS

↓

DB status 업데이트 전에 Worker Crash

하면 DB에는:

PROCESSING

으로 남는다.

하지만 Queue에는 이미 Event가 들어갔다.


✅ 26. 그래서 Consumer도 중복을 견뎌야 한다

Outbox만으로:

Event 정확히 한 번 전달

을 완전히 보장하지 않는다.

Worker가 재실행하면 같은 Event가 다시 Publish될 수 있다.


✅ 27. Outbox는 주로 At-Least-Once Delivery 구조가 된다

즉:

Event가 누락되는 것보다

중복 전달될 수 있도록 허용

하고 Consumer가 중복을 방어한다.


✅ 28. 그래서 Outbox와 Inbox가 함께 나온다

Producer는:

Outbox

로 Event 누락을 막고,

Consumer는:

Inbox

로 중복 처리를 막는다.


✅ 29. Inbox Pattern

Consumer가 Event를 받으면 바로 Business Logic부터 실행하지 않는다.

먼저:

이 Event를 이미 처리했는가?

를 확인한다.


✅ 30. InboxEvent

예:

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])
}

✅ 31. 왜 consumer까지 Unique에 넣는가?

같은 Event라도 여러 Consumer가 각각 처리해야 할 수 있다.

예:

ORDER_COMPLETED

를:

NotificationConsumer

AnalyticsConsumer

CRMConsumer

가 각각 처리할 수 있다.

따라서:

eventId + consumer

를 기준으로 중복을 막는다.


✅ 32. Consumer 처리 흐름

Event 수신

↓

Inbox INSERT

↓

이미 존재?
YES
→ Skip

NO
→ Business Logic

↓

Inbox PROCESSED

이다.


✅ 33. Inbox INSERT와 Business 변경도 Transaction으로 묶는다

예:

ORDER_COMPLETED Event

↓

BEGIN

Inbox Insert

NotificationJob Insert

COMMIT

로 처리한다.

그래야:

Business 변경은 됐는데 Inbox는 실패

같은 문제가 줄어든다.


✅ 34. 단 외부 API를 Inbox Transaction 안에서 호출하지 않는다

예:

BEGIN

Inbox Insert

Kakao API 호출
30초 대기

COMMIT

같은 구조는 좋지 않다.

DB Transaction을 너무 오래 잡기 때문이다.


✅ 35. Inbox Consumer는 또 다른 Job을 만드는 형태가 좋다

예:

Event 수신

↓

Transaction

Inbox 기록

NotificationJob 생성

COMMIT

후:

Notification Worker

↓

외부 API

를 호출한다.


✅ 36. Outbox → Inbox 흐름

전체 구조:

Producer DB

Business Change
+
Outbox Event

↓

Outbox Publisher

↓

Queue

↓

Consumer

↓

Inbox

↓

Consumer Transaction

↓

Next Job / State Change

이다.


✅ 37. 이 구조의 장점

다음 문제들을 각각 방어한다.

DB 성공 + Event 누락
→ Outbox

Event 중복 전달
→ Inbox

Consumer Crash
→ Inbox 상태 + Retry

외부 Side Effect 불명확
→ Idempotency + Reconciliation

✅ 38. Outbox와 Idempotency는 다른 역할이다

Outbox:

Event를 잃지 않도록 함

Idempotency:

같은 Event/Action이 여러 번 실행돼도
결과가 중복되지 않도록 함

둘 다 필요하다.


✅ 39. Inbox 역시 Business Idempotency를 완전히 대체하지 않는다

예:

Event ID는 다르지만
실제 업무는 동일

할 수도 있다.

예:

evt_1
ORDER_COMPLETED

evt_2
ORDER_COMPLETED

가 실수로 두 번 생성됐다.

Inbox는 Event ID가 다르므로 둘 다 처리한다.


✅ 40. Business Idempotency Key

따라서 Consumer Side Effect에도:

notification:order_123:completed

같은 업무 기준 Idempotency Key가 필요할 수 있다.


✅ 41. Event 중복과 Business 중복 구분

같은 Event ID 재전달
→ Inbox로 차단
서로 다른 Event지만 같은 업무
→ Business Idempotency로 차단

이다.


✅ 42. Event ID는 Producer가 생성한다

Event를 처음 만들 때:

eventId

를 생성하고 이후 Publish/Retry에서도 유지한다.

Retry마다 새로운 Event ID를 만들면 Inbox 중복 방지가 의미가 없어진다.


✅ 43. Attempt ID와 Event ID를 구분한다

eventId
논리적 Event

attempt
Publish 시도 횟수

이다.


✅ 44. Correlation ID와 Event ID도 다르다

예:

Correlation
cor_123

Request
req_1

Outbox Event
evt_1

Notification Event
evt_2

여러 Event가 하나의 Correlation 안에 포함될 수 있다.


✅ 45. Causation ID

다음 Event가 어떤 Event 때문에 발생했는지도 기록할 수 있다.

예:

ORDER_STATUS_CHANGED
evt_1

↓

NOTIFICATION_REQUESTED
evt_2

이면:

evt_2.causationId = evt_1

이다.


✅ 46. Event Chain을 추적할 수 있다

HTTP Request
req_1

↓

ORDER_STATUS_CHANGED
evt_1

↓

NOTIFICATION_REQUESTED
evt_2

↓

PROVIDER_RESULT
evt_3

전체가:

correlationId = cor_1

을 가진다.


✅ 47. Eventual Consistency란?

이 구조를 사용하면 모든 시스템의 상태가 동시에 바뀌지는 않는다.

예:

10:00:00
Order = COMPLETED

10:00:01
NotificationJob 생성

10:00:03
알림톡 Provider 접수

몇 초 동안 상태 차이가 존재한다.


✅ 48. 이것이 Eventual Consistency다

즉:

모든 시스템이 즉시 같은 상태가 되는 것은 아니지만, 시간이 지나면 최종적으로 일관된 상태에 도달한다.


✅ 49. Strong Consistency와 비교

Strong Consistency:

Transaction 끝나는 순간
모든 관련 상태가 확정

Eventual Consistency:

핵심 상태 먼저 확정

↓

후속 상태가 비동기로 따라옴

이다.


✅ 50. 모든 업무를 Eventual Consistency로 만들 필요는 없다

예:

주문 상태 변경
+
Audit Log

가 같은 DB라면 Transaction으로 즉시 일관성을 맞추는 것이 좋다.


✅ 51. Eventual Consistency가 적합한 영역

예:

알림톡

Analytics

검색 인덱스

Notion Report

외부 CRM

Webhook 후속 처리

처럼 즉시 동기화될 필요가 없는 기능이다.


✅ 52. 주문 생성과 알림톡을 분리하는 이유

주문 성공 여부가:

알림톡 Provider

상태에 의존하면 안 된다.

좋은 구조:

주문 Transaction
COMMIT

↓

Outbox

↓

Notification

이다.

Provider 장애가 발생해도 주문은 정상 저장된다.


✅ 53. 이게 장애 격리와도 연결된다

외부 Provider 장애:

Notification
영향

은 있지만:

Order API
정상

을 유지할 수 있다.

0924의 Bulkhead/Resilience와 연결된다.


✅ 54. Eventual Consistency를 사용자 UI에서 고려해야 한다

예:

주문 상태
완료

인데 바로 아래:

알림톡 상태
대기 중

일 수 있다.

이것은 꼭 오류가 아니다.


✅ 55. UI에서 중간 상태를 표현한다

예:

주문 처리
완료

고객 안내
발송 처리 중

처럼 별도 상태로 보여준다.

모든 상태가 즉시 완료될 것처럼 UI를 만들면 사용자 혼란이 생긴다.


✅ 56. 관리자 화면에서도 상태를 분리한다

예:

Order Status
COMPLETED

Notification
PENDING

처럼 본다.


✅ 57. 모든 후속 실패 때문에 원본 상태를 FAILED로 되돌리지 않는다

예:

Order COMPLETED

Notification FAILED

라고 해서:

Order를 WAITING으로 되돌림

은 잘못된 경우가 많다.

후속 기능의 상태를 별도로 관리한다.


✅ 58. Business Source of Truth를 정해야 한다

예:

주문 상태
→ orders table

알림톡 발송 상태
→ notification_jobs

Event 전달 상태
→ outbox_events

각 상태의 책임을 명확히 한다.


✅ 59. Event는 Source of Truth가 아닐 수도 있다

현재 구조에서는:

Order Table
= 현재 업무 상태

Event
= 어떤 변화가 발생했는지 전달/기록

정도로 사용하면 충분하다.

Event Sourcing까지 갈 필요는 없다.


✅ 60. Outbox Polling

가장 단순한 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;

이다.


✅ 61. Polling Interval

너무 짧으면:

DB Query 증가

하고,

너무 길면:

Event 전달 지연

이 커진다.

현재 규모라면 초 단위 Polling으로도 충분할 수 있다.


✅ 62. Batch Claim

100개 Event를 한 번에 Claim할 수도 있다.

단:

한 Worker가 너무 오래 잠금

을 잡지 않도록 주의한다.


✅ 63. FOR UPDATE SKIP LOCKED

PostgreSQL에서는 여러 Worker가 Queue처럼 DB Row를 가져갈 때 활용할 수 있다.

개념적으로:

SELECT ...
FOR UPDATE SKIP LOCKED

를 사용하면 다른 Worker가 잡은 Row를 건너뛸 수 있다.


✅ 64. 현재 ORM 구조에 맞춰 구현한다

무조건 Raw SQL을 고집할 필요는 없다.

기존 Prisma 구조에서 Atomic Update 방식이 더 이해하기 쉽다면 그 방식을 써도 된다.

핵심은:

동일 Outbox Event를
두 Worker가 동시에 Publish하지 않는 것

이다.


✅ 65. Publish 성공 후 상태 변경

PROCESSING
→ PUBLISHED

하고:

publishedAt

을 기록한다.


✅ 66. Publish 실패

Retryable이면:

PENDING
또는
RETRY_PENDING

으로 되돌리고:

nextRetryAt

을 지정한다.


✅ 67. 영구 실패

예:

Invalid Event Payload

Unsupported Event Version

처럼 Retry로 해결되지 않는 경우:

DEAD_LETTER

로 보낸다.


✅ 68. Outbox DLQ

Outbox 단계에도 DLQ가 필요할 수 있다.

예:

evt_123

eventType
ORDER_COMPLETED

error
UNSUPPORTED_EVENT_VERSION

attempt
5

status
DEAD_LETTER

이다.


✅ 69. Outbox DLQ는 조용히 묻히면 안 된다

중요 Event가 DLQ에 들어갔다는 것은 후속 업무가 누락됐다는 뜻일 수 있다.

예:

주문은 완료

Notification Event는 DLQ

이므로 운영자에게 보여야 한다.


✅ 70. Inbox 처리 실패

Consumer에서도:

RECEIVED

PROCESSING

PROCESSED

FAILED

DEAD_LETTER

같은 상태가 필요할 수 있다.


✅ 71. Inbox와 WebhookEvent가 비슷하다

0930에서 외부 Event를 Inbox 형태로 저장한다고 했다.

실제로:

WebhookEvent

모델이 이미 있다면 별도 Inbox 모델을 또 만들지 않고 역할을 통합할 수도 있다.


✅ 72. 모델을 무조건 많이 만들지 않는다

예:

OutboxEvent

InboxEvent

WebhookEvent

DomainEvent

EventLog

를 전부 별도 Table로 만들면 과할 수 있다.

현재 실제 필요에 맞게 역할을 합친다.


✅ 73. 최소 구조

현재 프로젝트라면 우선:

OutboxEvent

WebhookEvent / Inbox

Job

정도만 있어도 충분할 수 있다.


✅ 74. Event Consumer Registry

예:

consumerRegistry.register(
  'ORDER_STATUS_CHANGED',
  orderStatusChangedConsumer,
);

처럼 Event Type에 따라 Consumer를 선택한다.


✅ 75. Consumer는 한 가지 책임을 갖게 한다

예:

ORDER_COMPLETED

Consumer A
NotificationJob 생성

Consumer B
Analytics 반영

처럼 분리할 수 있다.


✅ 76. 하나의 Consumer에서 모든 후속 작업을 하지 않는다

나쁜 예:

OrderCompletedConsumer

- 알림톡 발송
- Analytics
- CRM
- Notion
- 파일 생성

하나가 실패하면 전체 처리 상태가 애매해진다.


✅ 77. 후속 Action을 Job으로 분리한다

예:

Event Consumer

↓

NotificationJob 생성

AnalyticsJob 생성

정도로 처리한다.


✅ 78. Consumer Transaction

BEGIN

Inbox 기록

NotificationJob Insert

COMMIT

이면 Consumer 처리 자체는 짧고 안정적이다.


✅ 79. Fan-out

하나의 Event가 여러 후속 작업을 발생시키는 것을 생각할 수 있다.

ORDER_COMPLETED

├─ Notification
├─ Analytics
└─ CRM

이다.


✅ 80. 일부 Consumer가 실패해도 다른 Consumer는 성공 가능

예:

Notification
SUCCESS

Analytics
FAILED

CRM
SUCCESS

처럼 독립적으로 관리할 수 있다.


✅ 81. Event Consumer마다 Retry 정책이 달라도 된다

예:

Analytics
5회 Retry
고객 메시지
중복 위험 때문에 보수적 Retry

처럼 차이가 있다.


✅ 82. Event 순서 문제

Eventual Consistency 시스템에서 또 하나 중요한 문제가 있다.

예:

ORDER_CREATED

ORDER_CANCELLED

순서로 발생했는데 Consumer에는:

ORDER_CANCELLED

ORDER_CREATED

순서로 도착할 수 있다.


✅ 83. Event 순서를 항상 믿지 않는다

특히:

여러 Queue Partition

Retry

Network Delay

등이 있으면 순서가 뒤집힐 수 있다.


✅ 84. Aggregate Version

이를 방어하기 위해 Domain 객체에 Version을 둘 수 있다.

예:

Order version

12
13
14

Event에도:

{
  "orderId": "order_123",
  "aggregateVersion": 14
}

를 넣는다.


✅ 85. 오래된 Event를 감지

현재 Consumer가 알고 있는 Version:

14

인데 Event:

13

이 들어오면:

stale event

로 판단할 수 있다.


✅ 86. 상태 후퇴를 막는다

예:

DELIVERED

상태인데 늦게 도착한:

SENT

Event 때문에 상태가 후퇴해서는 안 된다.

Webhook 순서 역전과 같은 원리다.


✅ 87. 모든 Event에 순서 번호가 필요한 것은 아니다

예:

analytics.increment

같은 독립 Event는 순서가 중요하지 않을 수 있다.

업무 상태 전이 Event에만 필요할 수 있다.


✅ 88. Consumer는 가능하면 현재 상태를 검증한다

예:

ORDER_COMPLETED Event 수신

후 바로 처리하기 전에:

현재 Order Status

를 DB에서 확인할 수 있다.

현재 이미 CANCELLED라면 완료 알림 발송 여부를 다시 판단해야 한다.


✅ 89. Event가 “명령”인지 “사실”인지 구분한다

Event는 보통:

무슨 일이 발생했다

를 의미한다.

예:

ORDER_COMPLETED

반면:

SEND_NOTIFICATION

은 Event라기보다 Command에 가깝다.


✅ 90. Event와 Command

Event

이미 일어난 사실

예:

ORDER_COMPLETED

Command

무엇을 실행하라는 요청

예:

SEND_ORDER_COMPLETE_NOTIFICATION

✅ 91. 이름을 구분하면 설계가 이해하기 쉬워진다

Event:

ORDER_STATUS_CHANGED

Command/Job:

SEND_NOTIFICATION

으로 구분한다.


✅ 92. Event Consumer에서 Command/Job을 만든다

예:

ORDER_COMPLETED

↓

Consumer

↓

NotificationJob

이다.


✅ 93. 모든 작업을 Event로 만들 필요는 없다

예:

관리자 메모 수정

후 다른 시스템이 아무것도 할 필요 없다면 Outbox Event도 필요 없다.


✅ 94. Outbox를 남발하면 DB와 운영 복잡도만 증가한다

Outbox가 가치 있는 경우:

DB 변경 후
반드시 후속 비동기 작업이 수행되어야 함

이다.


✅ 95. 대표적인 Outbox 적용 대상

현재 프로젝트라면:

주문 생성

주문 중요 상태 변경

상담 상태 변경

Export 요청

Notification 요청

Workflow 시작

등이 후보가 된다.


✅ 96. 적용 우선순위

먼저:

실패하면 고객 영향이 있거나
업무 누락이 생기는 Event

부터 Outbox를 사용한다.

Analytics처럼 누락 영향이 작은 작업은 나중에 적용해도 된다.


✅ 97. Outbox Event를 Audit Log 대신 사용하지 않는다

Audit Log:

누가 무엇을 변경했는가?

Outbox:

어떤 후속 시스템에
무슨 Event를 전달해야 하는가?

이다.

목적이 다르다.


✅ 98. 같은 Transaction에 Audit + Outbox

예:

BEGIN

Order Update

AuditLog Insert

OutboxEvent Insert

COMMIT

할 수 있다.

이렇게 하면 업무 상태, 변경 이력, 후속 Event가 같이 확정된다.


✅ 99. Outbox 정리

PUBLISHED Event를 영원히 유지할 필요는 없다.

예:

30일

90일

등 일정 기간 뒤 Archive/Delete할 수 있다.


✅ 100. 단 장애 분석 기간은 고려한다

최근 Incident를 조사하는데 Outbox 기록이 이미 삭제됐다면 추적이 어려울 수 있다.

Retention 정책을 정한다.


✅ 101. Cleanup Job

0929의 Scheduler와 연결해서:

매일 새벽

PUBLISHED
90일 초과

↓

Batch Delete

같이 처리할 수 있다.


✅ 102. 한번에 너무 많이 삭제하지 않는다

예:

DELETE 1,000,000 rows

보다는:

1,000~10,000 단위 Batch

등으로 처리하는 편이 안전하다.


✅ 103. Outbox Table도 Index가 중요하다

Publisher가 자주 보는 조건:

status
nextRetryAt
createdAt

에 맞춰 Index를 둔다.


✅ 104. Inbox도 무한 성장한다

Inbox는 중복 확인을 위해 보관하지만 모든 Event를 영구 유지하면 커진다.


✅ 105. Inbox Retention

중복 Event가 재전달될 수 있는 기간보다 길게 유지해야 한다.

예를 들어 Provider가 최대 며칠 뒤 재전송할 수 있다면 그보다 짧게 삭제하면 안 된다.


✅ 106. 정확한 기간은 시스템 특성에 맞춘다

무조건:

7일

같은 고정 숫자를 적용하지 않는다.


✅ 107. Outbox Lag

중요한 운영 Metric이다.

예:

Oldest PENDING Outbox Age

가 10분이라면 Publisher가 밀리고 있다는 뜻이다.


✅ 108. Outbox Metrics

예:

outbox_pending

outbox_processing

outbox_published_total

outbox_failed_total

outbox_dead_letter

outbox_oldest_pending_age

정도다.


✅ 109. Inbox Metrics

inbox_received_total

inbox_processed_total

inbox_duplicate_total

inbox_failed_total

inbox_dead_letter

등을 볼 수 있다.


✅ 110. Duplicate Rate도 유용하다

평소 Event 중복이 거의 없었는데:

duplicate rate 급증

하면 Producer나 Broker가 이상할 수 있다.


✅ 111. Event Delivery Latency

예:

event.createdAt
→
consumer.processedAt

까지 걸린 시간이다.


✅ 112. End-to-End Latency

예:

Order 완료

↓

NotificationJob 생성

↓

Provider 발송

까지의 전체 시간을 측정할 수 있다.


✅ 113. Eventual Consistency SLO

예:

99%의 주문 완료 Event가
30초 이내 NotificationJob으로 변환

같은 목표를 둘 수 있다.


✅ 114. Outbox 장애는 Silent Failure가 될 수 있다

웹 API는 정상인데:

Outbox Publisher
중단

되면 주문은 계속 저장된다.

하지만 후속 알림은 모두 멈춘다.


✅ 115. 그래서 Outbox Lag Alert가 중요하다

예:

Oldest Pending > 1분

Warning,

> 5분

Critical

같이 업무에 맞춰 정할 수 있다.


✅ 116. Outbox Worker Heartbeat만으로는 부족하다

Worker가 살아 있어도 Publish가 멈춰 있을 수 있다.

핵심은:

PENDING Event가 실제 감소하는가?

다.


✅ 117. Business Invariant도 확인

예:

COMPLETED Order 존재

그런데 관련 OutboxEvent 없음

이면 Transaction 설계나 코드 경로에 문제가 있을 수 있다.


✅ 118. Reconciliation과 Outbox 연결

주기적으로:

후속 Event가 있어야 하는데 없는 상태

를 검사할 수 있다.


✅ 119. 단 Reconciler가 무조건 Event를 다시 만들면 안 된다

이미 후속 작업이 다른 경로로 실행됐을 수도 있다.

Business Idempotency Key를 이용한다.


✅ 120. 예

Order
COMPLETED

NotificationJob
이미 존재

OutboxEvent
없음

이라면 새 NotificationJob까지 다시 만들 필요 없다.


✅ 121. Reconciliation은 Desired State를 본다

Desired:

COMPLETED 주문에는
완료 알림 처리 결과가 존재

Actual:

NotificationJob 있음

이면 이미 일관적이다.


✅ 122. Outbox Worker 실패 분류

Retryable:

Queue Timeout

Connection Error

Broker 503

Non-Retryable:

지원하지 않는 Event Type

Payload Schema Invalid

등이다.


✅ 123. Payload Validation

OutboxEvent를 만들 때 가능한 한 Schema Validation을 한다.

잘못된 Event를 DB에 넣고 Publisher에서 뒤늦게 실패하는 것을 줄인다.


✅ 124. 하지만 DB Transaction 안에서 너무 무거운 일을 하지 않는다

Schema 검증은 가볍게 한다.

외부 API 확인 같은 작업은 넣지 않는다.


✅ 125. Event Schema Registry

프로젝트 수준에서 간단하게:

const eventSchemas = {
  ORDER_STATUS_CHANGED: orderStatusChangedSchema,
};

정도로 관리할 수 있다.

대형 Schema Registry 시스템까지는 필요 없다.


✅ 126. TypeScript 타입만으로는 런타임 검증이 안 된다

Queue/Event는 JSON 형태로 넘어갈 수 있으므로 Runtime Validation이 필요할 수 있다.

예:

Zod

같은 Schema Validator를 사용할 수 있다.

기존 프로젝트 도구가 있다면 재사용한다.


✅ 127. Consumer는 지원하지 않는 Version을 명확하게 실패

UNKNOWN_EVENT_VERSION

같은 Error Code를 사용한다.


✅ 128. 무시해버리면 데이터 누락으로 이어질 수 있다

중요 Event인데 Consumer가 조용히 Skip하면 Silent Failure가 된다.


✅ 129. Event DLQ 관리자 화면

예:

Event ID

Event Type

Aggregate

Error

Attempts

Created

Action

정도를 보여준다.


✅ 130. Action

예:

Retry

Inspect Payload

Mark Ignored

등이다.


✅ 131. Mark Ignored는 이유를 남긴다

예:

Legacy Event
No longer required

처럼 Audit를 남긴다.


✅ 132. Payload 수정 후 Retry는 위험하다

DLQ Event Payload를 사람이 임의로 고치면 원본 Event 의미가 달라진다.

가능하면:

원본 보존

새 Corrective Event 생성

이 더 안전하다.


✅ 133. Corrective Event

잘못된 Event가 발생했다면:

기존 Event 삭제/수정

보다:

정정 Event

를 만들 수 있다.


✅ 134. 다만 현재는 너무 복잡하게 가지 않는다

중요한 것은 Outbox/Inbox 핵심 원칙이다.

DB + Outbox
한 Transaction

Consumer + Inbox
한 Transaction

부터 적용한다.


✅ 135. Workflow와 Outbox 연결

0930의 Workflow Step이 성공한 뒤 다음 Step을 직접 Queue에 넣는 대신:

Step SUCCESS
+
OutboxEvent NEXT_STEP_READY

를 같은 Transaction에 저장할 수도 있다.


✅ 136. 하지만 모든 Workflow 내부 이동에 Outbox를 쓰면 복잡해진다

같은 PostgreSQL 상태와 동일 Queue를 쓰는 간단한 Workflow라면 기존 Reconciliation만으로 충분할 수도 있다.


✅ 137. Outbox가 특히 필요한 경계

중요 DB 변경
→ 외부 비동기 시스템

경계다.


✅ 138. Workflow Run 생성 예

BEGIN

Order Update

WorkflowRun Insert

OutboxEvent WORKFLOW_START_REQUESTED

COMMIT

처럼 사용할 수 있다.


✅ 139. Scheduler와 Outbox

0929의:

ScheduleRun 생성
→ Queue 등록

사이에도 같은 Dual Write가 존재한다.


✅ 140. 해결 방법 1

ScheduleRun = PENDING

을 저장하고 Reconciliation이 Queue 누락을 복구한다.


✅ 141. 해결 방법 2

ScheduleRun
+
OutboxEvent

를 Transaction으로 저장한다.

이미 Outbox Infrastructure가 있다면 두 번째가 자연스러울 수 있다.


✅ 142. 지금 바로 모든 곳을 Outbox로 바꿀 필요는 없다

복잡도와 효과를 비교한다.

가장 중요한 경로부터 적용한다.


✅ 143. 추천 1순위

주문 상태 변경
→ 알림톡/후속 자동화

처럼 DB 변경 후 반드시 후속 작업이 필요한 곳.


✅ 144. 추천 2순위

Export 요청
→ Export Worker

이다.


✅ 145. 추천 3순위

WorkflowRun 생성
→ Workflow Worker

이다.


✅ 146. 낮은 우선순위

단순 Analytics

개발용 Report

처럼 누락 영향이 적은 Event다.


✅ 147. Local LLM Work Report에 적용할 필요는 제한적이다

개인 로컬 자동화라면 PostgreSQL Outbox까지 붙이는 것은 과할 수 있다.

현재:

Run 상태 파일/DB
+
Resume

만으로 충분할 가능성이 높다.


✅ 148. 운영 Production 서비스와 개인 자동화를 구분한다

중요 고객 업무는:

내구성 우선

개인 보고서 자동화는:

단순함 우선

으로 가져간다.


✅ 149. Outbox Pattern의 비용

장점만 있는 것은 아니다.

Table 추가

Publisher Worker

Retry/DLQ 관리

Retention

Metric

관리자 조회

가 필요하다.


✅ 150. 그래서 ‘Event 누락이 실제 문제인가?’를 먼저 묻는다

누락돼도 다시 계산 가능한 내부 통계라면 Outbox 없이 Batch Reconciliation으로 충분할 수 있다.


✅ 151. Outbox vs Reconciliation

Outbox:

애초에 후속 Event 누락 가능성을 줄임

Reconciliation:

이미 발생한 상태 불일치를 찾아서 복구

이다.


✅ 152. 둘은 경쟁 관계가 아니다

가장 안전한 구조는:

Outbox로 예방

+

Reconciliation으로 최종 검증

이다.


✅ 153. Outbox가 있어도 Reconciliation이 필요한 이유

코드 Bug로 OutboxEvent 생성 자체를 빼먹을 수 있다.

또는 Consumer Logic이 잘못될 수 있다.

완벽한 방어는 없다.


✅ 154. Defense in Depth

예:

DB Transaction

↓

Outbox

↓

Inbox

↓

Business Idempotency

↓

Reconciliation

여러 층의 방어를 둔다.


✅ 155. 단 모든 기능에 모든 방어를 적용하지 않는다

업무 중요도에 따라 적용한다.


✅ 156. Event Delivery 상태 머신

Outbox:

PENDING
↓
PROCESSING
↓
PUBLISHED

실패:

PROCESSING
↓
RETRY_PENDING
↓
PROCESSING

한도 초과:

DEAD_LETTER

✅ 157. Inbox 상태 머신

RECEIVED
↓
PROCESSING
↓
PROCESSED

실패:

FAILED
↓
RETRY_PENDING

최종:

DEAD_LETTER

✅ 158. Event 처리의 SUCCESS 정의

Queue에 Publish했다고 업무가 성공한 것은 아니다.

단계별 성공을 구분한다.

Outbox
PUBLISHED

Consumer
PROCESSED

Business Job
SUCCESS

이다.


✅ 159. End-to-End 상태를 별도로 본다

예:

Order Event
Published

Notification Consumer
Processed

NotificationJob
SENT

까지 가야 고객 안내 전체 흐름이 성공이다.


✅ 160. Correlation Timeline

예:

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

이렇게 전체를 추적할 수 있다.


✅ 161. 운영자가 Outbox를 직접 알 필요는 없을 수도 있다

관리자 UI에서는 기술 용어 대신:

후속 처리 대기

후속 처리 실패

재처리 필요

로 표현할 수도 있다.


✅ 162. 개발자 운영 화면에서는 상세 상태를 제공

OutboxEvent

Event ID

Consumer

Attempt

Error

Correlation ID

를 볼 수 있다.


✅ 163. Event 처리 관리자 기능을 만들 때 가장 위험한 버튼

전체 Retry

이다.

DLQ 수천 건을 한꺼번에 재처리하면 장애가 커질 수 있다.


✅ 164. Batch Retry 제한

예:

10개

50개

100개

단위로 제한한다.

대량 Retry에는 Confirmation과 Rate Limit을 둔다.


✅ 165. Retry Storm 방지

DLQ 재처리에서도:

Concurrency

Backoff

Provider Rate Limit

을 지켜야 한다.


✅ 166. Event Replay

과거 Event를 다시 처리하는 기능을 만들 수도 있다.

예:

Analytics Consumer 코드 수정

↓

지난 7일 ORDER_COMPLETED Replay

이다.


✅ 167. Replay는 강력하지만 위험하다

Notification Consumer까지 같이 Replay하면 고객에게 알림톡이 다시 갈 수 있다.


✅ 168. Replay 가능한 Consumer와 불가능한 Consumer를 구분

예:

Analytics
Replay Safe
Customer Notification
Replay Restricted

처럼 둔다.


✅ 169. Replay Mode

Consumer에:

LIVE

REPLAY

Context를 전달할 수도 있다.

Replay에서는 외부 Side Effect를 막고 Read Model만 재구성하게 할 수 있다.


✅ 170. Projection / Read Model 재구축

Event History가 충분하다면 통계/검색 Read Model을 다시 만들 수 있다.

하지만 현재는 Event Sourcing이 아니므로 모든 상태를 Replay로 재구축할 수 있다고 가정하면 안 된다.


✅ 171. Event Log가 완전한 History인지 구분한다

Outbox Retention을 90일만 한다면 전체 시스템 역사를 재생할 수 없다.


✅ 172. 따라서 Outbox는 Event Store가 아니다

목적은:

안전한 Event Delivery

이지:

영구적인 모든 Domain History

가 아니다.


✅ 173. Outbox를 Event Sourcing처럼 쓰지 않는다

두 패턴의 목적이 다르다.


✅ 174. Eventual Consistency에서 사용자 기대 관리

예:

주문 완료

후 즉시 알림톡이 안 왔다고 해서 주문 실패로 보이면 안 된다.

UI:

신청이 완료되었습니다.

안내 메시지는 순차 발송됩니다.

처럼 처리할 수 있다.


✅ 175. 비즈니스적으로 허용 가능한 지연을 정의한다

예:

알림톡
30초 이내

Analytics
5분 이내

일일 Report
1시간 이내

처럼 업무마다 다르다.


✅ 176. Eventual Consistency는 ‘언젠가는 되겠지’가 아니다

반드시:

얼마나 늦어도 되는가?

실패를 어떻게 감지하는가?

영원히 안 되면 어떻게 복구하는가?

가 있어야 한다.


✅ 177. Consistency Window

예:

Order 완료 후
NotificationJob 생성까지
최대 30초

를 허용한다.

이 시간을 넘으면 Drift로 간주한다.


✅ 178. Reconciliation Query

예:

COMPLETED Order

AND completedAt < now - 5m

AND 완료 NotificationJob 없음

을 찾는다.


✅ 179. Desired vs Actual

Desired:

완료 주문
→ 완료 알림 처리 존재

Actual:

없음

이면 Reconciliation 대상이다.


✅ 180. 자동 복구 전에 중복을 확인한다

Business Idempotency Key:

notification:order_123:completed

를 조회한다.


✅ 181. Event Consumer Side Effect에 Audit

예:

actor
SYSTEM_EVENT_CONSUMER

eventId
evt_123

action
NOTIFICATION_JOB_CREATED

정도로 기록할 수 있다.


✅ 182. 모든 내부 Event마다 Audit Log를 만들 필요는 없다

Audit는 비즈니스적으로 중요한 변경 중심으로 유지한다.

Application/Event Log와 Audit를 구분한다.


✅ 183. Logging

Outbox:

outbox.created

outbox.publish.started

outbox.published

outbox.publish.failed

Inbox:

event.received

event.duplicate

consumer.started

consumer.completed

consumer.failed

같은 이벤트 이름을 정한다.


✅ 184. Error Code

예:

OUTBOX_PUBLISH_TIMEOUT

OUTBOX_PUBLISH_FAILED

EVENT_SCHEMA_INVALID

EVENT_VERSION_UNSUPPORTED

INBOX_CONSUMER_FAILED

DUPLICATE_EVENT

등이다.


✅ 185. Duplicate Event는 반드시 Error일 필요는 없다

At-Least-Once 시스템에서는 중복 전달이 정상적으로 발생할 수 있다.

따라서:

INFO
event.duplicate

정도로 처리할 수 있다.


✅ 186. Duplicate 급증은 Warning

평소보다 급격히 늘어난 경우에는 이상 신호다.


✅ 187. Event Payload에 Secret을 넣지 않는다

Queue, DB, DLQ, 로그 등 여러 위치에 복제되기 때문이다.


✅ 188. 개인정보도 최소화

고객 전화번호 전체 대신:

customerId
orderId

를 전달한다.


✅ 189. Consumer가 필요한 개인정보는 DB에서 권한 있는 경로로 조회

Event 자체가 고객 데이터 저장소가 되지 않게 한다.


✅ 190. Event Encryption까지 필요한가?

현재 내부 시스템 수준에서는 Event Payload 자체를 별도 암호화하는 것보다:

민감 데이터 최소화

접근 제어

DB/전송 암호화

가 우선이다.

불필요하게 복잡하게 만들 필요는 없다.


✅ 191. Outbox Worker 권한도 최소화

Publisher는:

Outbox 읽기

상태 변경

Queue Publish

정도만 필요하다.

다른 Production DB 수정 권한을 넓게 줄 필요 없다.


✅ 192. AI와 Event 시스템

AI Workflow에서도 Event를 활용할 수 있다.

예:

AI_RUN_COMPLETED

AI_RUN_FAILED

REPORT_CREATED

같은 이벤트다.


✅ 193. AI 결과를 바로 외부 시스템에 뿌리지 않는다

예:

AI Report 생성

후:

REPORT_CREATED

Event를 만들고 별도 Consumer가 Notion 업로드를 담당하게 할 수도 있다.


✅ 194. 하지만 개인 자동화에서는 과할 수 있다

Local LLM Report처럼 규모가 작다면 Workflow Step 직접 연결이 더 단순하다.

Event 분리는 독립 Consumer가 실제로 필요할 때 적용한다.


✅ 195. AI Production Automation에는 가치가 커질 수 있다

예:

AI Change Approved

↓

OutboxEvent

↓

Deployment Workflow

처럼 중요한 DB 상태와 후속 배포 Trigger를 안전하게 연결할 수 있다.


✅ 196. AI Action Event에도 Policy Version 기록

예:

{
  "eventType": "AI_CHANGE_APPROVED",
  "workflowRunId": "run_123",
  "policyVersion": "v9"
}

정도로 추적할 수 있다.


✅ 197. Outbox와 Release Workflow

예:

Release APPROVED

상태 변경과:

DEPLOY_REQUESTED

Event를 같은 Transaction에 저장할 수 있다.


✅ 198. 배포 Trigger 누락 방지

Release APPROVED

그런데 Deploy Job 없음

문제를 줄일 수 있다.


✅ 199. 배포 Consumer는 Idempotency 필요

(environment, releaseId)

를 기준으로 같은 Release가 중복 배포되지 않게 한다.

0927 내용과 연결된다.


✅ 200. Scheduler + Workflow + Outbox 전체 흐름

예:

09:00 Scheduler

↓

ScheduleRun 생성
+
Outbox SCHEDULE_RUN_READY

↓

Publisher

↓

Workflow Worker

↓

WorkflowRun

↓

Step 실행

처럼 연결 가능하다.


✅ 201. 하지만 이것을 한 번에 모두 구현하지 않는다

현재 프로젝트에서 한꺼번에:

Scheduler

Workflow

Outbox

Inbox

Saga

DLQ

를 모두 추가하면 복잡도가 너무 커질 수 있다.


✅ 202. 단계적으로 적용한다

1단계

핵심 DB 변경 + Outbox

2단계

Outbox Publisher + Retry

3단계

Consumer Inbox + 중복 방지

4단계

DLQ + Dashboard

5단계

Reconciliation + SLO

순서가 좋다.


✅ 203. 가장 먼저 적용할 Use Case

예:

UpdateOrderStatusUseCase

에서:

Order Update

Audit Log

OutboxEvent

를 한 Transaction으로 묶는다.


✅ 204. 예시 코드 구조

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

✅ 205. Use Case 의사 코드

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,
      },
    );
  },
);

✅ 206. 중요한 점

Transaction 안에서:

queue.add()

하지 않는다.

오직 DB 작업만 수행한다.


✅ 207. Publisher Worker

개념:

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,
    );
  }
}

✅ 208. Consumer

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',
        );
    },
  );
}

✅ 209. Inbox Insert를 Transaction 밖에서 하면 조심해야 한다

예:

Inbox PROCESSED로 기록

↓

Business Update 실패

같은 상황을 만들면 Event가 다시 와도 Skip해버릴 수 있다.


✅ 210. 따라서 처리 완료 기록은 Business 변경과 함께 Commit

이것이 핵심이다.


✅ 211. 처리 시작 상태가 필요하다면 별도 관리

긴 Consumer 작업이라면:

RECEIVED
PROCESSING

을 사용할 수 있지만 외부 Side Effect는 별도 Job으로 보내는 편이 더 단순하다.


✅ 212. 테스트 1: DB Rollback

강제로 Outbox Insert에서 Exception을 발생시킨다.

Expected:

Order Update
ROLLBACK

Audit
ROLLBACK

Outbox
없음

이다.


✅ 213. 테스트 2: Publisher 실패

Outbox
PENDING

Publish
Timeout

Expected:

Order 상태 유지

Outbox Retry 예정

Event 유실 없음

이다.


✅ 214. 테스트 3: Publish 성공 후 Worker Crash

Expected:

Event가 중복 Publish될 가능성 있음

Consumer Inbox가 중복 처리 차단

이다.


✅ 215. 테스트 4: 동일 Event 3회 전달

Expected:

Inbox Logical Record
1개

Business Side Effect
1회

이다.


✅ 216. 테스트 5: 다른 Event ID지만 동일 Business Action

예:

evt_1
evt_2

둘 다:

order_123 완료 알림

Expected:

NotificationJob idempotency
→ 1개

이다.


✅ 217. 테스트 6: Event 순서 역전

version 13
먼저

version 12
나중

Expected:

version 12
stale 처리

이다.


✅ 218. 테스트 7: Consumer 실패

Expected:

Inbox 실패 상태

Retry 가능

Business State 일부 저장 안 됨

이어야 한다.


✅ 219. 테스트 8: Unsupported Event Version

Expected:

Retry 무한 반복 X

Dead Letter

이다.


✅ 220. Failure Injection과 연결

0925에서 만든 Reliability Test에:

Outbox Publisher Timeout

Consumer Crash

Duplicate Delivery

Out-of-order Delivery

시나리오를 추가한다.


✅ 221. Incident와 연결

다음은 Incident 후보가 될 수 있다.

Outbox Lag 급증

Critical Event DLQ 발생

Consumer Dead Letter 증가

Business Consistency Drift

✅ 222. Runbook

예:

Outbox Lag 증가

1. Publisher Health 확인
2. Queue Health 확인
3. DB Query 지연 확인
4. Oldest PENDING 확인
5. Retry Rate 확인
6. DLQ 확인
7. Publisher 복구
8. Backlog Drain 관찰

✅ 223. Backlog 복구 시 Recovery Storm 주의

Outbox가 1시간 밀렸다가 Publisher가 복구됐다고:

100,000 Event
동시 Publish

하면 안 된다.


✅ 224. Controlled Drain

Batch Size

Concurrency

Rate Limit

을 이용해 천천히 처리한다.


✅ 225. 중요한 Event 우선순위

가능하다면:

Order / Notification
HIGH

Analytics
LOW

같이 나눌 수 있다.

하지만 처음부터 복잡한 Priority Broker까지 만들 필요는 없다.


✅ 226. Event Delivery Dashboard

예:

Outbox

Pending
12

Oldest
3s

Retry
2

DLQ
0


Inbox

Received
1,284

Duplicate
17

Failed
1

정도로 볼 수 있다.


✅ 227. Event Detail

Event ID

Type

Aggregate

Version

Status

Attempts

Created

Published

Correlation

Error

등을 제공한다.


✅ 228. Payload 전체를 관리자 화면에 그대로 보여주지 않는다

개인정보가 포함될 가능성 때문이다.

안전한 Metadata 중심으로 보여준다.


✅ 229. Replay 버튼은 초기에 만들지 않아도 된다

먼저:

Retry DLQ

정도만 구현하고,

실제 Replay 요구가 생기면 확장한다.


✅ 230. 현 프로젝트에서 과한 것

당장은 다음까지 할 필요는 없다.

Kafka Cluster

Schema Registry 서버

Exactly Once Kafka Transaction

Event Sourcing

CQRS Read Model 대규모 분리

복잡한 Stream Processing

이다.


✅ 231. PostgreSQL Outbox만으로도 충분히 강력하다

현재 규모에서는 기존 PostgreSQL을 이용하면:

추가 Infrastructure 최소화

Transaction 활용 가능

운영 단순

이라는 장점이 있다.


✅ 232. 나중에 규모가 커지면 Event Transport만 바꿀 수 있다

Outbox Producer 구조를 유지하면서:

현재 Queue

에서:

다른 Broker

로 변경할 수 있다.

Domain Use Case가 Broker에 직접 의존하지 않게 만드는 것이 중요하다.


✅ 233. Event Publisher Interface

예:

interface EventPublisher {
  publish(
    event: IntegrationEvent,
  ): Promise<void>;
}

Outbox Worker는 이 Interface에 의존한다.


✅ 234. Domain/Application Layer가 Queue Library를 직접 알 필요 없음

예:

UpdateOrderStatusUseCase

Redis Queue Library

에 직접 의존하지 않는다.

Use Case는 Outbox Repository에 Event를 기록한다.


✅ 235. Integration Event라는 이름

Domain Event와 외부 전달용 Event를 구분하고 싶다면:

Domain Event

Integration Event

를 나눌 수 있다.


✅ 236. Domain Event

애플리케이션 내부 Domain 관점의 변화.

OrderCompleted

✅ 237. Integration Event

다른 Component/Worker가 소비하도록 안정적인 Payload 형태로 만든 Event.

ORDER_COMPLETED_V1

이다.


✅ 238. 현재는 둘을 무리하게 분리하지 않아도 된다

구조가 커질 때 필요하면 분리한다.


✅ 239. 이벤트 계약을 쉽게 바꾸지 않는다

Event Consumer는 Producer와 다른 시점에 배포될 수 있다.

API보다 더 느슨하게 연결되어 있어 Breaking Change가 눈에 잘 안 띌 수 있다.


✅ 240. Event Schema 변경 기본 원칙

기존 필드를 갑자기 삭제하기보다:

새 필드 추가

방식이 안전하다.


✅ 241. Optional Additive Change

예:

{
  "orderId": "...",
  "status": "...",
  "carrier": "LGU"
}

처럼 새 필드를 추가한다.

기존 Consumer는 무시할 수 있다.


✅ 242. Breaking Change라면 새 Version

예:

ORDER_COMPLETED_V1

ORDER_COMPLETED_V2

처럼 구분한다.


✅ 243. 오래된 Consumer 제거 전 확인

v1 Event가 더 이상 생성되지 않고 Queue/DLQ에도 남아 있지 않은지 확인한다.


✅ 244. Feature Flag와 Event Consumer

신규 Consumer를:

OFF

로 배포하고:

내부 테스트
→ ON

할 수도 있다.


✅ 245. Event Consumer Kill Switch

문제 있는 Consumer만 끌 수 있으면 좋다.

예:

analytics_consumer_enabled

을 끈다고 주문 Event 생성까지 막히면 안 된다.


✅ 246. Kill Switch OFF 시 Event를 버리지 않는다

중요 Consumer라면:

처리 일시 중단

만 하고 Event는 Queue/Inbox에 남겨 복구 후 처리한다.


✅ 247. Feature Flag가 Event 유실을 만들지 않게 한다

flag OFF
→ ACK하고 Event 삭제

는 위험할 수 있다.


✅ 248. Consumer Pause

별도 상태:

PAUSED

를 두는 것이 더 명확할 수 있다.


✅ 249. AI가 Event 운영을 도울 수 있는 것

AI에게:

DLQ Event 요약

반복 Error Code 분류

Outbox Lag 원인 후보

Consumer 실패 Timeline

관련 Runbook 추천

을 맡길 수 있다.


✅ 250. AI가 Event Payload를 직접 수정하게 하지 않는다

특히 Production Event는 업무 기록이다.

AI는 분석/제안 위주로 둔다.


✅ 251. AI Event Incident Report

예:

Outbox backlog
1,420 events

Oldest age
18m

Top event
ORDER_STATUS_CHANGED

Top failure
QUEUE_TIMEOUT

Customer Impact
Notification delay possible

같은 요약을 만들 수 있다.


✅ 252. Local LLM 작업 보고서에도 운영 지표 추가 가능

예:

### Event Delivery

- Outbox Pending: 0
- DLQ: 0
- Duplicate Events: 3
- Max Delivery Delay: 2.1s

등이다.

현재 필요성이 있을 때만 추가한다.


✅ 253. 구현 우선순위

1단계

OutboxEvent Table

2단계

Business Transaction에서
Outbox Insert

3단계

Publisher Worker
Retry

4단계

Consumer Inbox
중복 방지

5단계

DLQ
Metrics
Reconciliation

이다.


✅ 254. 가장 ROI 높은 시작점

UpdateOrderStatusUseCase 같은 중요 Write Use Case 하나에만 적용해본다.

전체 시스템을 한 번에 바꾸지 않는다.


✅ 255. 성공적으로 동작하면 반복 적용

예:

CreateOrder

UpdateConsultStatus

RequestExport

등으로 확장한다.


✅ 256. 기존 Queue 호출을 무조건 전부 Outbox로 교체하지 않는다

다음 질문을 먼저 한다.

이 Queue 등록이 누락되면
업무 상태가 영구적으로 잘못되는가?

YES라면 Outbox 가치가 크다.


✅ 257. Codex 구현 프롬프트

현재 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 구조를
먼저 분석한 뒤 중복 구현 없이 통합해줘.

✅ 258. Event Delivery Reliability Test 프롬프트

현재 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 환경에서 재현한다.

✅ 259. 실무 체크리스트

Producer

  • DB 변경과 Outbox 생성이 같은 Transaction인가?
  • Transaction 안에서 외부 Queue를 직접 호출하지 않는가?
  • Event ID가 Retry마다 유지되는가?
  • Payload가 최소화되어 있는가?
  • Event Version이 있는가?

Outbox

  • Atomic Claim이 있는가?
  • Retry 정책이 있는가?
  • Retry Limit이 있는가?
  • DEAD_LETTER가 있는가?
  • Oldest Pending Age를 확인할 수 있는가?

Consumer

  • 동일 Event 중복을 감지하는가?
  • Inbox Unique Constraint가 있는가?
  • Business Idempotency도 별도로 있는가?
  • Business 변경과 처리 완료 기록이 일관적인가?
  • 외부 API를 긴 DB Transaction 안에서 호출하지 않는가?

Event

  • 사실형 이름인가?
  • Event와 Command를 구분하는가?
  • Correlation ID가 있는가?
  • Causation ID가 필요한가?
  • Aggregate ID를 추적할 수 있는가?
  • 순서가 중요한 Event에 Version 전략이 있는가?

Eventual Consistency

  • 즉시 일관성이 필요한 상태와 구분했는가?
  • 허용 가능한 지연 시간이 있는가?
  • 중간 상태를 UI에서 표현할 수 있는가?
  • 후속 실패가 원본 성공 상태를 잘못 되돌리지 않는가?

Reconciliation

  • 후속 Job 누락을 찾을 수 있는가?
  • Desired vs Actual State를 비교하는가?
  • 복구 전 Business Idempotency를 확인하는가?
  • Outbox 자체 Bug까지 최종적으로 탐지 가능한가?

Operations

  • Outbox Lag Alert가 있는가?
  • DLQ를 확인할 수 있는가?
  • 대량 Retry를 제한하는가?
  • Backlog Recovery에 Concurrency Limit이 있는가?
  • Retention 정책이 있는가?

Security

  • Payload에 Secret이 없는가?
  • 개인정보를 최소화했는가?
  • 관리자 Event 화면에서 Payload를 과도하게 노출하지 않는가?
  • DLQ에도 민감정보가 남지 않는가?

📌 요약

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가 누락되지 않게 만들고 중복 전달될 수 있다는 사실을 받아들인 뒤, 최종 업무 효과가 한 번만 발생하도록 설계하는 것”

이다.

0개의 댓글