던플 개발기 (7): 메시징 백본 — 명령은 RabbitMQ, 이벤트는 Kafka, 그 사이 transactional outbox
지난 편에서 데이터 계층을 정리했다면, 이번엔 그 데이터가 흐르는 길입니다. 던플마켓은 "누가 이 아이템을 수집해라"라는 명령과 "이 아이템 시세가 방금 갱신됐다"라는 이벤트가 끊임없이 오갑니다. 처음엔 이 둘을 같은 큐 하나에 태우면 될 줄 알았습니다.
그런데 만들다 보니 둘은 성격이 완전히 달랐습니다. 명령은 한 번만 실행되면 되는 지시고, 이벤트는 여러 소비자가 각자 다시 읽어야 하는 기록입니다. 하나로 뭉개면 둘 다 어정쩡해집니다. 그래서 명령은 RabbitMQ, 이벤트는 Kafka로 나누고, 그 사이의 위험한 틈(DB는 커밋됐는데 이벤트는 못 나가는 경우)을 transactional outbox로 메웠습니다. 이 편은 그 세 조각을 어떻게 붙였는지에 대한 기록입니다.
한눈에 보면
- **명령(command)**은 "이 일을 하라"는 지시입니다. RabbitMQ가 exchange·라우팅·재시도·DLX로 전달합니다.
- **이벤트(event)**는 "이 일이 일어났다"는 사실입니다. Kafka가 topic에 append해 여러 consumer group이 독립적으로 읽습니다.
- 명령 payload에는 API key·provider URL·raw 응답을 절대 싣지 않습니다. id와 reason만 담고, 상세는 워커가 직접 가져옵니다.
- due schedule의 source of truth는 브로커가 아니라 DB의
next_collect_at과 lease입니다. 메시징은 전달 장치일 뿐입니다. - DB 커밋과 Kafka 발행 사이의 유실을 transactional outbox로 막았습니다. 이벤트 row를 스냅샷 write와 같은 트랜잭션에 넣습니다.
- 중복은 세 지점에서 막습니다. 생산 측은
dedupe_keyunique, relay는 at-least-once 재발행, 소비 측은eventIddedupe입니다. - 처음엔 SQS 하나로 시작했지만((4)편), fan-out·replay·다중 consumer가 제품 핵심이 되면서 RabbitMQ+Kafka로 옮겼습니다.
SQS로 시작했는데 왜 옮겼나
(4)편의 인프라 그림에서 수집 경로는 EventBridge → SQS → cron이었습니다. SQS는 완전관리형이라 운영 부담이 작고, visibility timeout과 DLQ로 at-least-once job 처리를 깔끔하게 해 줍니다. due 아이템을 하나씩 수집하는 워커만 필요했다면 SQS로 충분했고, 실제로 그 단계까지는 잘 굴러갔습니다.
방향이 바뀐 건 제품이 "아이템 검색"에서 "게임 경제 시세 터미널"로 재정의되면서입니다. 사용자가 페이지를 열어두고 주식 호가창처럼 시세를 관측하려면, 수집 결과가 실시간 게이트웨이·랭킹 워커·알림 워커로 동시에, 그리고 나중에 다시 읽을 수 있게 흘러야 했습니다. 이건 job 하나를 정확히 한 번 처리하는 문제가 아니라, 하나의 사실을 여러 독립 소비자에게 replay 가능하게 방송하는 문제입니다. SQS 큐 하나로는 개념이 좁았습니다.
그렇다고 SQS를 event log로 억지로 늘리는 대신, 역할을 둘로 갈랐습니다. 명령 전달은 RabbitMQ, 이벤트 스트림은 Kafka. 재설계 문서에 이 구분을 한 줄로 못박아 뒀습니다.
RabbitMQ = do this command/job
Kafka = this market fact happened근거:
docs/architecture/market-terminal-rearchitecture.md§2~3
명령과 이벤트를 나눈 이유
같은 "메시지"지만 명령과 이벤트는 요구하는 성질이 정반대입니다. 이 표가 왜 백본을 둘로 나눴는지의 핵심입니다.
| 성질 | 명령(command) | 이벤트(event) |
|---|---|---|
| 뜻 | "이 일을 하라" | "이 일이 일어났다" |
| 소비자 | 보통 하나가 처리 | 여러 consumer group이 독립적으로 |
| 처리 횟수 | 정확히 한 번 실행이 목표 | 각자 필요할 때 다시 읽어도 됨 |
| 실패 시 | 재시도 → 안 되면 DLX 격리 | 스트림은 그대로, 소비자만 재처리 |
| 보관 | 처리되면 끝(큐에서 사라짐) | retention 동안 log에 남아 replay |
| 적합한 도구 | RabbitMQ (routing·ack·DLX) | Kafka (append·partition·replay) |
전체 흐름을 먼저 그리면 이렇습니다. 왼쪽 절반(명령)은 RabbitMQ, 오른쪽 절반(이벤트)은 Kafka이고, 둘을 잇는 이음매가 가운데 DB의 outbox입니다.
flowchart LR
SCHED[운영 tick / dispatcher] -->|명령| CMDX[RabbitMQ<br/>market.command exchange]
CMDX --> WORK[market.command.work 큐]
WORK --> MW[market-worker]
MW -->|호출| NEO[Neople Open API]
MW -->|같은 트랜잭션| DB[(market PostgreSQL<br/>snapshot + outbox_events)]
DB --> RELAY[outbox relay]
RELAY -->|이벤트| KAFKA[Kafka<br/>market.* topics]
KAFKA --> RT[realtime-gateway]
KAFKA --> RANK[ranking worker]
KAFKA --> ALERT[alert worker]
RT -->|WebSocket/SSE| U[Browser]여기서 한 가지 원칙을 계속 지켰습니다. 메시지는 schedule state를 소유하지 않습니다. "언제 수집할 때가 됐는가"의 정답은 항상 DB의 next_collect_at과 lease입니다. 브로커는 그저 "지금 이 일을 하라"를 실어 나르는 장치이고, 같은 tick을 다시 돌려도 같은 명령이 나오게 만들어 워커가 dedupe할 수 있게 했습니다.
RabbitMQ: 수집 명령 백본
명령 쪽부터 보겠습니다. 명령은 작고 자기서술적인 **봉투(envelope)**에 담고, payload는 타입별로 따로 검증합니다. 핵심은 payload를 의도적으로 아주 작게 유지한다는 점입니다. id와 reason 정도만 담아, 민감한 provider 데이터가 브로커로 새지 않게 합니다.
// libs/shared/messaging-rabbitmq/src/lib/envelope.ts
export const COMMAND_PAYLOAD_SCHEMAS = {
'market.collect-item.requested': z.object({
itemId: z.string().min(1),
// 발행 시점의 수집 tier (도메인 의존을 피하려 free-form)
tier: z.string().min(1),
reason,
// next_collect_at에서 복사한 ISO-8601 due time; advisory only
dueAt: z.string().datetime({ offset: true }),
}),
'provider.probe.requested': z.object({
providerId: z.string().min(1),
reason,
}),
// ...item.catalog.refresh / market.rollup / market.purge ...
} as const satisfies Record<CommandType, z.ZodTypeAny>;봉투에는 commandId, commandType, schemaVersion, correlationId, 그리고 소비자가 dedupe에 쓰는 idempotencyKey가 들어갑니다. 브로커에서 받은 raw 메시지는 parseCommand()로 봉투와 payload를 순차 검증하는데, 여기서 실패하면 poison 메시지로 간주해 DLX로 보내고 절대 무한 재큐하지 않습니다.
토폴로지는 bounded context마다 exchange 하나씩, 그 아래 work 큐·retry 큐·dead-letter(parking) 큐를 붙였습니다. 라우팅 키는 command 타입 문자열 그대로라, 명령을 하나 추가해도 새 exchange가 아니라 바인딩 하나만 늘어납니다.
// libs/shared/messaging-rabbitmq/src/lib/topology.ts
function domainTopology(domain, routingKeys) {
return {
domain,
exchange: `${domain}.command`, // topic exchange
workQueue: `${domain}.command.work`, // consumer가 읽는 durable 큐
retryExchange: `${domain}.command.retry`,
retryQueue: `${domain}.command.retry.delay`, // TTL 후 work로 재전달
deadLetterExchange: `${domain}.command.dlx`,
deadLetterQueue: `${domain}.command.dlq`, // poison·소진 메시지 parking
routingKeys,
};
}한 도메인(market)의 메시지가 어떻게 도는지 그리면 이렇습니다. 발행은 publisher confirm을 써서, 브로커가 durable하게 받았다고 확인해야 publish()가 resolve됩니다.
flowchart LR
P[publisher<br/>confirm 대기] --> EX[market.command<br/>topic exchange]
EX -->|routing key = commandType| WQ[market.command.work]
WQ --> C{consumer<br/>disposition}
C -->|ACK / RESCHEDULE| DONE[channel.ack]
C -->|RETRY · 예산 남음| RX[market.command.retry]
RX -->|x-message-ttl 후| EX
C -->|DROP · 예산 소진 · poison| DLX[market.command.dlx]
DLX --> DLQ[market.command.dlq<br/>parking]소비자는 manual ack를 쓰고, 핸들러가 돌려준 disposition을 브로커 동작으로 번역합니다. 이 번역 규칙은 순수 함수로 떼어내 빠짐없이 테스트했습니다. (2)편에서 다룬 "점검은 사고가 아니라 정상 운영 조건"이라는 원칙이 여기서 RESCHEDULE 하나로 코드가 됩니다.
// libs/shared/messaging-rabbitmq/src/lib/retry-policy.ts
export function decideRetryAction(input: RetryDecisionInput): RetryAction {
switch (input.disposition) {
case 'ACK':
case 'RESCHEDULE':
// 점검(RESCHEDULE)은 핸들러가 이미 next_collect_at을 미뤘으므로
// 그냥 ack. 절대 dead-letter하지 않는다.
return { kind: 'ack' };
case 'DROP':
return { kind: 'park', reason: 'poison' };
case 'RETRY': {
if (input.attempt + 1 >= input.maxAttempts) {
return { kind: 'park', reason: 'attempts-exhausted' };
}
return { kind: 'retry', nextAttempt: input.attempt + 1 };
}
}
}포인트는 점검 실패를 DLX로 보내지 않는다는 것입니다. 점검은 독약 메시지가 아니라 "지금은 잠깐 안 되는 정상 상황"이라, 핸들러가 RESCHEDULE을 반환하면 ack하고 DB에서 다시 스케줄합니다. 이 구분을 흐리면 점검이 끝날 때마다 DLQ가 쌓여 진짜 문제를 가립니다.
Kafka: 시세 이벤트 스트림
명령이 "하라"라면, 수집이 끝난 뒤 남는 건 "일어났다"라는 사실입니다. 이 사실들을 Kafka topic에 append합니다. 이벤트도 순수 계약 라이브러리(market-events)에 봉투와 payload를 정의했고, Kafka 클라이언트에는 의존하지 않습니다(transport는 별도).
topic은 이벤트 종류별로 나누고, 시세와 관련된 topic은 itemId를 partition key로 씁니다. 같은 아이템의 시세 이벤트가 한 partition 안에서 순서를 유지하게 하기 위해서입니다.
// libs/market/events/src/lib/topics.ts
export const MARKET_TOPICS = [
'item.catalog.events', // item.catalog.updated
'market.snapshot.events', // market.snapshot.collected
'market.price.events', // market.price.updated
'market.popularity.events', // market.popularity.recalculated
'provider.status.events', // provider.status.changed
] as const;
// 스트림을 독립적으로 읽는 초기 consumer group들
export const CONSUMER_GROUPS = {
realtimeGateway: 'realtime-gateway',
marketRankingWorker: 'market-ranking-worker',
marketAlertWorker: 'market-alert-worker',
} as const;이벤트 payload에도 명령과 같은 규율을 적용했습니다. raw provider 응답·API key·seller 정보는 넣지 않습니다. 예컨대 market.price.updated는 itemId와 관측 시각, 그리고 counts·min/max·체결 수 같은 compact price summary만 싣습니다. 화면에 그대로 노출하면 안 되는 값(경매장 평균가 등)은 UI 계약이 아니라 내부 이벤트에만 머무릅니다.
producer는 **idempotent producer + acks=-1**로 발행하고, consumer는 at-least-once라 재처리 안전성을 별도로 보장합니다(뒤의 dedupe 절). Kafka를 primary DB로 쓰지 않는다는 규칙도 그대로입니다. 조회 기준은 언제나 PostgreSQL과 Redis read model이고, Kafka는 read model을 재생성하기 위한 replay 가능한 fact log입니다.
transactional outbox로 DB와 브로커를 잇기
여기가 이 편에서 가장 신경 쓴 부분입니다. 수집이 끝나면 두 가지를 해야 합니다. (1) 스냅샷·집계를 DB에 쓰고, (2) "수집됐다" 이벤트를 Kafka에 발행합니다. 그런데 이 둘은 서로 다른 시스템이라 한 트랜잭션으로 묶을 수 없습니다. 순진하게 짜면 이런 이중 쓰기(dual write) 함정에 빠집니다.
- DB는 커밋됐는데 Kafka 발행 직전 프로세스가 죽으면 → 이벤트 유실. 실시간 화면과 랭킹이 갱신을 놓칩니다.
- Kafka는 발행됐는데 DB 커밋이 롤백되면 → 유령 이벤트. 존재하지 않는 시세를 방송합니다.
해법이 transactional outbox입니다. 이벤트를 곧장 Kafka로 보내는 대신, 스냅샷 write와 같은 트랜잭션 안에서 outbox_events 테이블에 row로 적습니다. DB 트랜잭션이 커밋되면 스냅샷과 이벤트 의사(意思)가 원자적으로 함께 남습니다. 그다음 별도 relay가 이 row를 읽어 Kafka로 발행합니다.
-- libs/market/data-access/prisma/migrations/.../create_outbox_events/migration.sql
CREATE TABLE "outbox_events" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"event_id" UUID NOT NULL,
"event_type" VARCHAR(128) NOT NULL,
"topic" VARCHAR(128) NOT NULL,
"key" VARCHAR(128) NOT NULL,
"dedupe_key" VARCHAR(255) NOT NULL,
"payload" JSONB NOT NULL,
"published_at" TIMESTAMPTZ(6), -- 발행 후 도장
"attempts" INTEGER NOT NULL DEFAULT 0,
"last_error" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT now(),
PRIMARY KEY ("id")
);
-- 한 사실당 outbox row 하나: 재시도된 생산 트랜잭션이 이중 발행 못 함
CREATE UNIQUE INDEX "outbox_events_dedupe_key_key" ON "outbox_events" ("dedupe_key");
-- relay는 미발행 row를 오래된 순서로 스캔
CREATE INDEX "idx_outbox_events_unpublished" ON "outbox_events" ("published_at", "created_at");수집 저장소는 스냅샷·집계 write에 outbox insert를 같은 $transaction에 태워 커밋합니다. skipDuplicates와 dedupe_key unique 인덱스가 만나, 트랜잭션이 재시도돼도 사실 하나당 row 하나만 남습니다.
// libs/market/data-access/src/lib/repositories/prisma-auction-collector.repository.ts
// ... 스냅샷 / 매물 / 집계 write를 ops에 push한 뒤 ...
// Transactional outbox: 이벤트를 스냅샷 write와 같은 트랜잭션에 넣어
// DB 커밋과 Kafka 발행 사이 유실을 원천 차단한다.
ops.push(
this.prisma.outboxEvent.createMany({
data: this.buildOutboxEvents(input), // snapshot.collected + price.updated
skipDuplicates: true,
})
);
await this.prisma.$transaction(ops);발행은 별도 relay use case가 담당합니다. 미발행 row를 오래된 것부터 읽어 하나씩 발행하고, 성공한 것만 published_at을 찍습니다. 발행이 실패한 row는 attempts만 올리고 미발행으로 남겨, 다음 run이 다시 시도합니다. row는 절대 사라지지 않습니다.
// libs/market/collection/src/lib/use-cases/relay-outbox.use-case.ts
async execute(input: RelayOutboxInput): Promise<RelayOutboxResult> {
const rows = await this.repo.fetchUnpublished(input.batchSize);
const publishedIds: string[] = [];
let failed = 0;
for (const row of rows) {
try {
await this.publisher.publish(row); // → Kafka
publishedIds.push(row.id);
} catch (error: unknown) {
failed += 1;
await this.repo.markFailed(row.id, /* ... */); // attempts++, 미발행 유지
}
}
if (publishedIds.length > 0) {
await this.repo.markPublished(publishedIds, input.now);
}
return { fetched: rows.length, published: publishedIds.length, failed };
}전체 시퀀스를 그리면 이렇습니다. 왼쪽의 원자적 커밋과 오른쪽의 at-least-once 발행이 outbox row 하나로 이어집니다.
sequenceDiagram
participant W as market-worker
participant DB as PostgreSQL
participant R as outbox relay
participant K as Kafka
participant C as consumer
W->>DB: BEGIN
W->>DB: snapshot / aggregate write
W->>DB: INSERT outbox_events (dedupe_key UNIQUE)
W->>DB: COMMIT
Note over W,DB: 한 트랜잭션 — 둘 다 커밋되거나 둘 다 롤백
loop relay 폴링
R->>DB: SELECT WHERE published_at IS NULL ORDER BY created_at
R->>K: publish(topic, key=itemId, envelope)
R->>DB: UPDATE published_at = now()
end
K->>C: deliver
Note over C: eventId로 dedupe (at-least-once)여기서 감수하는 트레이드오프가 하나 있습니다. relay가 발행에 성공한 직후, published_at을 찍기 전에 죽으면 다음 run이 같은 이벤트를 한 번 더 발행합니다. 즉 이 구조는 exactly-once가 아니라 at-least-once입니다. 이걸 문제가 아니라 전제로 받아들이고, 대신 소비 측에서 중복을 흡수하게 설계했습니다.
dedupe와 순서 보장
at-least-once를 전제로 깔았으니, 중복은 "일어날 수도 있는 사고"가 아니라 "반드시 일어나는 일"입니다. 그래서 중복을 경로의 세 지점에서 각각 막습니다.
| 지점 | 무엇으로 | 막는 것 |
|---|---|---|
| 생산 측 (outbox insert) | dedupe_key unique 인덱스 |
재시도된 트랜잭션의 이중 row |
| relay → Kafka | at-least-once 재발행 허용 | 이벤트 유실(중복은 허용) |
| 소비 측 | eventId 기준 dedupe |
같은 이벤트 두 번 처리 |
소비 측 dedupe는 핸들러를 감싸는 idempotent() wrapper로 처리합니다. 처음엔 이미 본 key를 Set에 무한정 쌓았는데, 오래 도는 컨슈머에서 메모리가 계속 자라는 문제가 있어 FIFO eviction으로 상한을 둔 store로 바꿨습니다(기본 1만 개, 필요하면 durable store 주입).
// libs/shared/messaging-kafka/src/lib/ports.ts
export function idempotent(
handler: EventHandler,
keyOf: (event: ReceivedEvent) => string | undefined,
seen: SeenStore = createBoundedSeenStore() // FIFO, 기본 10_000 keys
): EventHandler {
return {
async handle(event: ReceivedEvent): Promise<void> {
const key = keyOf(event);
if (key !== undefined && seen.has(key)) return; // 이미 본 이벤트면 skip
await handler.handle(event);
if (key !== undefined) seen.add(key);
},
};
}순서는 다른 문제입니다. dedupe는 "같은 걸 두 번 처리하지 않기"고, 순서는 "먼저 일어난 게 먼저 보이기"입니다. Kafka는 partition 안에서만 순서를 보장하므로, 시세 topic을 itemId로 keying해 한 아이템의 이벤트가 항상 같은 partition에 떨어지게 했습니다. 발행 어댑터가 봉투의 key(itemId)를 그대로 Kafka 메시지 key로 넘깁니다.
// apps/market-cron/src/app/relay/kafka-outbox-event.publisher.ts
async publish(record: OutboxEventRecord): Promise<void> {
await this.producer.publish({
topic: record.topic,
key: record.envelope.key, // = itemId → 한 아이템은 한 partition에 고정
value: record.envelope,
});
}마지막 함정 하나. 소비 측에서 파싱조차 안 되는 poison 이벤트가 오면, Kafka는 그 offset을 무한 재시도하며 partition 전체를 막아 버립니다(consumer가 wedge). DB/outbox가 source of truth이므로, 실시간 게이트웨이는 파싱 실패 이벤트를 붙잡고 재시도하는 대신 로그를 남기고 건너뜁니다.
// apps/realtime-gateway/src/app/gateway.ts
let event;
try {
event = parseMarketEvent(received.value);
} catch (error: unknown) {
// poison 이벤트가 partition을 막으면 안 된다. DB/outbox가 진실이니 skip.
log(`skipping unparseable event at ${received.topic}@${received.offset}: ...`);
return Promise.resolve();
}명령 쪽의 DLX와 이벤트 쪽의 skip은 방향이 정반대라 흥미롭습니다. 명령의 poison은 parking 큐에 격리해 나중에 들여다보고, 이벤트의 poison은 그냥 흘려보냅니다. 명령은 반드시 처리돼야 할 지시라 놓치면 안 되고, 이벤트는 스트림이 계속 흐르는 게 더 중요하며 진실은 어차피 DB에 있기 때문입니다. 같은 "처리 불가 메시지"라도 명령이냐 이벤트냐에 따라 옳은 대응이 갈립니다.
정리하면
이 편의 전제는 하나였습니다. 명령과 이벤트는 성격이 다르므로, 하나의 큐로 뭉개지 말고 각자에 맞는 백본을 줘야 한다.
- 명령("하라")은 RabbitMQ로 전달합니다. exchange·라우팅·manual ack·retry·DLX로 "정확히 한 번 실행"에 가깝게 만듭니다.
- 이벤트("일어났다")는 Kafka로 흘립니다. topic에 append해 여러 consumer group이 독립적으로, replay 가능하게 읽습니다.
- DB 커밋과 Kafka 발행 사이의 유실은 transactional outbox로 막습니다. 이벤트 row를 스냅샷 write와 같은 트랜잭션에 넣고, relay가 at-least-once로 발행합니다.
- at-least-once를 전제로 깔았으니 중복은 세 지점(
dedupe_key·relay·eventId)에서 막고, 순서는itemIdpartition key로 지킵니다. - 처리 불가 메시지에 대한 대응은 명령(DLX 격리)과 이벤트(skip)가 정반대입니다. source of truth가 어디냐가 답을 갈랐습니다.
메시지를 명령과 이벤트로 나누는 결정은 결국 "이 메시지의 진실은 어디에 있는가"를 계속 되묻는 일이었습니다. schedule의 진실은 DB에, 시세의 진실도 DB에 있고, 브로커는 그 진실을 실어 나르거나 방송할 뿐입니다. 이 관점을 잡고 나니 재시도할지, 격리할지, 흘려보낼지가 대부분 자명해졌습니다.
다음 편에서는 이 Kafka 이벤트가 실제로 브라우저까지 닿는 마지막 구간, 실시간 가격 게이트웨이 — WebSocket으로 시세를 밀어 넣고, 중복과 poison을 눈에 안 띄게 치우는 이야기를 다루겠습니다.
