
TL;DR
- 견적 요청은 저장됐는데 알림톡은 안 나가는 사고의 원인은 dual-write였습니다. DB 커밋과 이벤트 발행이 별개라, 하나만 성공하면 데이터가 어긋납니다.
- 해결책은 아웃박스 테이블입니다. 이벤트를 비즈니스 데이터 옆에
PENDING으로 적어두고, 별도 릴레이가 15초마다 폴링해 발행합니다.- 실패하면 지수 백오프(2·4·8·16·32분)로 재시도하고, 5회를 넘기면 그때만
FAILED로 데드레터 격리합니다. 재시도 대상은 절대 죽지 않는다는 규칙을 TDD로 못 박았습니다.- 전송 보장은 at-least-once라 컨슈머는 반드시 멱등해야 합니다.
견적 요청(quote request)이 DB에는 멀쩡히 들어와 있는데, 공급사에게 나가야 할 카카오 알림톡이 발송되지 않은 케이스가 있었습니다. 코드는 이렇게 생겼었습니다.
typescriptconst saved = await this.repository.save(entity); // DB 커밋 this.eventEmitter.emit('quote_request.created', { ... }); // 알림 발행
문제가 보이시나요? 이 두 줄은 서로 다른 트랜잭션입니다. save()는 커밋됐는데 그다음 emit() 시점에 리스너가 던진 예외, 앱 재시작, 프로세스 크래시 중 하나만 끼면 이벤트는 증발합니다. 반대로 이벤트를 먼저 처리하고 DB 저장이 실패하면 "존재하지 않는 견적"에 대한 알림이 나갑니다.
이게 dual-write 문제입니다. 하나의 논리적 작업(견적 생성 + 알림 예약)을 물리적으로 분리된 두 시스템에 나눠 쓰는데, 둘을 하나로 묶을 트랜잭션이 없습니다. 게다가 @nestjs/event-emitter(3.0.1)의 이벤트는 인메모리라 프로세스가 죽으면 큐에 남은 것까지 통째로 사라집니다. 알림은 "가면 좋은 것"이 아니라 견적 프로세스의 일부라, 유실을 그냥 두고 볼 수 없었습니다.
핵심 아이디어는 단순합니다. 이벤트 발행을 비즈니스 데이터와 같은 저장소에 기록하는 겁니다. 알림을 바로 쏘는 대신, "이런 이벤트를 발행해야 한다"는 사실을 outbox_events 테이블에 한 행으로 적어둡니다. 그리고 별도의 릴레이(relay) 프로세스가 그 행을 읽어 실제 발행을 담당합니다.
이렇게 하면 발행 책임이 두 단계로 쪼개집니다.
PENDING으로 저장한다. 이건 DB 작업이라 비즈니스 데이터 저장과 같은 신뢰 수준을 갖습니다.PENDING 행을 주기적으로 집어서 실제로 발행한다. 실패하면 재시도하고, 성공하면 COMPLETED로 넘긴다.발행이 실패해도 이벤트는 테이블에 그대로 남아 있으니, 다음 폴링에서 다시 시도됩니다. 유실 구간이 사라지는 거죠.
테이블 스키마는 이렇게 잡았습니다.
sqlCREATE TABLE public.outbox_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_type VARCHAR(255) NOT NULL, payload JSONB NOT NULL, metadata JSONB DEFAULT '{}'::jsonb, status VARCHAR(50) DEFAULT 'PENDING', -- PENDING | PROCESSING | COMPLETED | FAILED retry_count INTEGER DEFAULT 0, last_error TEXT, created_at TIMESTAMPTZ DEFAULT now(), processed_at TIMESTAMPTZ, scheduled_at TIMESTAMPTZ DEFAULT now() ); -- PENDING 행만 골라 scheduled_at 순으로 폴링하므로, 부분 인덱스로 좁힙니다 CREATE INDEX idx_outbox_events_status_scheduled ON public.outbox_events (status, scheduled_at) WHERE status = 'PENDING';
scheduled_at이 재시도의 핵심입니다. 실패한 이벤트는 이 값을 미래로 밀어두고, 릴레이는 scheduled_at <= now()인 것만 집습니다. 백오프가 여기서 자연스럽게 표현됩니다. WHERE status = 'PENDING' 부분 인덱스를 둔 이유는, 시간이 지나면 COMPLETED 행이 테이블의 대부분을 차지하기 때문입니다. 폴링이 매번 전체를 훑지 않도록 처리 대상만 인덱스에 남깁니다.
이제 호출부는 emit 대신 enqueue를 씁니다.
typescript// 견적 요청 저장 후, 알림 이벤트를 아웃박스에 넣습니다 await this.outboxService.enqueue(NotificationEvents.QUOTE_REQUEST_CREATED, { quoteRequestId: saved.id, buyerUserId: userId, projectName: saved.project_name.getValue(), items, });
enqueue는 이벤트를 PENDING 상태의 도메인 엔티티로 만들어 저장할 뿐, 실제 발행은 하지 않습니다.
typescriptasync enqueue(eventType, payload, metadata = {}) { try { const event = OutboxEvent.create({ eventType, payload, metadata }); await this.outboxRepository.save(event); } catch (error) { // 아웃박스 저장마저 실패하면 최후의 수단으로 직접 발행(베스트 에포트) this.eventEmitter.emit(eventType, { ...payload, _outboxFailed: true }); } }
catch 블록의 폴백에 주목해 주세요. 아웃박스 저장 자체가 실패하는 극단적 상황에서는 유실을 막기 위해 그냥 인메모리로라도 쏘고, _outboxFailed: true 플래그를 붙여 "이건 보장되지 않은 경로로 나갔다"는 걸 컨슈머가 알 수 있게 했습니다.
한 가지 정직하게 짚을 점. 교과서적인 아웃박스는 비즈니스 데이터 저장과 아웃박스 삽입을 하나의 DB 트랜잭션으로 묶습니다. 이 구현은 Supabase 클라이언트로
repository.save(entity)와enqueue()를 연달아 호출하는 형태라, 엄밀히는 두 호출이 같은 트랜잭션으로 감싸여 있지 않습니다. 견적 저장은 됐는데 아웃박스 삽입이 실패하는 좁은 창이 여전히 남아 있고, 그 구간을 위 폴백이 메꾸는 구조입니다. 진짜 트랜잭셔널 아웃박스로 완성하려면 이 둘을 하나의 트랜잭션 경계 안으로 넣어야 합니다. 지금은 그 직전 단계라고 보는 게 정확합니다.
릴레이는 pg-boss(12.13.0)를 스케줄러로 씁니다. 크론식으로 15초마다 process-outbox 잡을 깨워 PENDING을 처리합니다.
typescriptprivate readonly CRON_EXPRESSION = '*/15 * * * * *'; // 15초마다 await boss.work(this.QUEUE_NAME, { batchSize: 1 }, async () => { const processed = await this.outboxService.processPending(20); // 여기서 throw하지 않습니다 — 다음 스케줄이 다시 시도하도록 둡니다 });
batchSize: 1로 둔 건 의도적입니다. 여러 인스턴스가 동시에 폴링하며 같은 행을 두고 경합하면 DB 부하만 늘어납니다. 아웃박스 폴링은 팀 사이즈 1로 직렬화하는 편이 안전했습니다. 워커가 예외를 삼키고 throw하지 않는 것도 마찬가지 이유입니다. 한 사이클이 실패해도 15초 뒤 다음 사이클이 어차피 다시 집어 갑니다.
실제 처리 루프는 상태 전이를 그대로 밟습니다.
typescriptprivate async processEvent(event: OutboxEvent) { event.markAsProcessing(); await this.outboxRepository.save(event); try { await this.eventEmitter.emitAsync(event.getEventType(), payloadWithContext); event.markAsCompleted(); // status = COMPLETED, processed_at 기록 await this.outboxRepository.save(event); } catch (error) { event.markAsFailed(error.message); // 재시도 or 데드레터 판정 await this.outboxRepository.save(event); } }
발행할 때 payload에 _outbox 컨텍스트(id, retryCount, scheduledAt 등)를 얹어 보냅니다. 컨슈머가 "이건 몇 번째 재시도인지"를 알 수 있게 하기 위해서입니다.
가장 신경 쓴 부분이 실패 처리입니다. 규칙은 두 가지입니다. 재시도 여력이 남은 이벤트는 반드시 다시 시도할 수 있어야 하고, 여력이 소진된 것만 데드레터로 격리한다. 이 규칙을 도메인 엔티티 안에 넣었습니다.
typescriptmarkAsFailed(error: string): void { this.props.retryCount = (this.props.retryCount || 0) + 1; this.props.lastError = error; // 카운트를 먼저 올린 뒤 판정합니다: 4→5가 되면 isRetryable(5) = 5<5 = false if (this.isRetryable()) { // 지수 백오프: 2, 4, 8, 16, 32분 const delayMin = Math.pow(2, this.props.retryCount); this.props.scheduledAt = new Date(Date.now() + delayMin * 60000); this.props.status = 'PENDING'; // 백오프 뒤 findPending이 다시 집어가도록 } else { this.props.status = 'FAILED'; // 재시도 소진 — 데드레터(종단 상태) } } isRetryable(maxRetries = 5): boolean { return (this.props.retryCount || 0) < maxRetries; }
여기서 가장 미묘한 건 카운트를 올린 뒤에 재시도 여부를 판정한다는 점입니다. 순서가 바뀌면 마지막 한 번을 더 재시도하거나 덜 재시도하는 off-by-one이 생깁니다. 그래서 이 경계를 테스트로 먼저 고정하고 구현했습니다.
typescriptit('재시도가 남아 있으면 상태를 PENDING으로 유지한다', () => { const event = OutboxEvent.create({ eventType: 'test.event', payload: { x: 1 } }); event.markAsFailed('transient error'); // retryCount 0 → 1 expect(event.getStatus()).toBe('PENDING'); }); it('재시도가 소진되면 그때 FAILED(데드레터)로 넘긴다', () => { // 이미 4번 실패한 이벤트를 재구성 const event = OutboxEvent.reconstitute({ /* ... */ retryCount: 4, status: 'PENDING' }); event.markAsFailed('final error'); // 4 → 5, isRetryable(5) = false expect(event.getStatus()).toBe('FAILED'); expect(event.getRetryCount()).toBe(5); });
데드레터로 격리된 이벤트(status = FAILED)는 findPending의 WHERE status = 'PENDING' 조건에 걸리지 않으니 자동으로 재처리 대상에서 빠집니다. 조용히 삭제되지 않고 last_error와 함께 테이블에 남아 있어, 나중에 원인을 조사하거나 수동으로 재투입할 수 있습니다. 5회까지 매달리다 실패한 이벤트를 무한 재시도로 큐를 막지 않으면서도 흔적을 남기는 게 목적입니다.
정리(deleteProcessed)는 COMPLETED이거나 retry_count >= 5인 행 중 오래된 것만 지웁니다. 성공한 것과 완전히 포기한 것만 청소하고, 아직 재시도 중인 것은 절대 건드리지 않습니다.
이 구조는 at-least-once 전송입니다. 발행 뒤 markAsCompleted 저장이 실패하거나 프로세스가 그 사이에 죽으면, 다음 폴링이 같은 이벤트를 한 번 더 발행할 수 있습니다. 즉 같은 이벤트가 두 번 이상 도착할 수 있다는 뜻입니다.
그래서 컨슈머는 반드시 멱등해야 합니다. 알림톡이라면 "이 견적에 대한 알림을 이미 보냈는지"를 확인하고 중복이면 건너뛰는 식입니다. 여기서 정직하게 남는 엣지 케이스가 하나 있습니다. markAsProcessing으로 PROCESSING까지 갔는데 그 직후 크래시가 나면, 그 행은 PENDING이 아니라 PROCESSING에 머물러 findPending이 다시 집지 못합니다. 발행이 실제로 나갔는지 모호한 채 멈추는 구간이죠. 운영에서는 이런 PROCESSING 좀비 행을 일정 시간 뒤 PENDING으로 되돌리는 리퍼(reaper)가 별도로 필요합니다.
COMPLETED가 쌓이면 폴링 쿼리가 급격히 느려집니다. 처리 대상만 인덱스에 남기는 것만으로 폴링 비용이 일정하게 유지됩니다.last_error를 남겨야 "왜 5번 다 실패했는지"를 사후에 볼 수 있습니다. 대시보드에서 status = 'FAILED' 건수를 알람으로 걸어두면 문제를 놓치지 않습니다.// Comments