Loading the catalog…
Loading the catalog…
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 같은 상태가
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
TIL - 20261001. 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 ↓…
Open source