Claude Code로 이벤트 기반 아키텍처 설계하기: 계약부터 운영까지
Claude Code로 이벤트 기반 설계를 검토하는 방법. 계약, 멱등성, 재시도, DLQ, 관측성과 실패 사례를 다룹니다.
이벤트 기반 아키텍처는 “느슨하게 결합하자”는 말만으로는 안전해지지 않습니다. 이벤트 이름이 모호하고, payload 계약이 없고, 중복 전달이나 재시도 규칙이 없고, dead-letter queue와 로그 기준도 없다면 첫 데모는 성공해도 첫 장애에서 원인을 추적하기 어렵습니다.
이 글에서는 Claude Code를 무조건 믿는 아키텍트가 아니라 리뷰어와 구현 보조자로 사용합니다. 사람은 서비스 경계와 비즈니스 결정을 내리고, Claude Code는 이벤트 명명, schema 호환성, 멱등성, 순서 보장 가정, retry, DLQ, replay, observability의 빈틈을 찾게 합니다. 예시는 SaaS 가입, 결제 Webhook 이후 권한 부여, 감사 로그 이벤트 스트림, 알림 파이프라인을 다룹니다.
이벤트 기반 아키텍처의 핵심 용어
이벤트 기반 아키텍처는 한 서비스에서 이미 발생한 사실을 이벤트로 발행하고, 다른 서비스가 그 사실에 반응하는 구조입니다. 이벤트는 명령이 아닙니다. com.claudecodelab.user.created.v1은 “사용자를 생성하라”가 아니라 “사용자가 생성되었다”라는 기록입니다.
처음에는 네 가지 용어만 잡으면 됩니다. producer는 이벤트를 발행하는 쪽, consumer는 이벤트를 받아 처리하는 쪽, event bus나 queue는 전달 경로, schema는 payload의 계약입니다. payload는 이벤트 안의 실제 데이터이고, schema는 userId가 문자열인지, email이 이메일 형식인지처럼 지켜야 할 약속입니다.
공통 이벤트 형식은 CloudEvents와 CloudEvents spec을 기준으로 볼 수 있습니다. AWS를 쓴다면 이벤트 라우팅 예시로 Amazon EventBridge가 유용합니다. 관측성은 OpenTelemetry docs의 traces, metrics, logs 관점으로 정리하면 도구가 바뀌어도 기준이 흔들리지 않습니다.
Claude Code에게 처음부터 “최적의 이벤트 기반 설계를 해줘”라고 맡기지 마세요. 기존 API, DB, Webhook, 장애 복구 조건을 읽게 한 뒤 “이 이벤트 이름이 충분히 구체적인가”, “payload 변경이 기존 consumer를 깨지 않는가”, “중복 전달에 안전한가”, “replay가 가능한가”를 검토하게 하는 편이 안전합니다.
이벤트 계약을 먼저 고정한다
핸들러 코드를 만들기 전에 계약을 정해야 합니다. 계약이 없으면 각 consumer는 producer가 지금 우연히 보내는 필드에 의존합니다. producer의 작은 변경 하나가 onboarding, billing, audit log, notification을 동시에 깨뜨릴 수 있습니다.
아래 YAML은 SaaS 사용자 생성 이벤트의 시작 템플릿입니다. type에는 도메인, 사실, 버전이 들어갑니다. idempotencykey는 같은 이벤트가 두 번 도착해도 부작용을 한 번만 만들기 위한 키입니다. correlationid는 같은 요청에서 파생된 로그와 trace를 이어 줍니다.
specversion: "1.0"
id: "evt_01JZ0YV8Y9N3A7Z7K6Y1G9X2Q4"
type: "com.claudecodelab.user.created.v1"
source: "/services/identity"
subject: "users/usr_123"
time: "2026-06-02T09:30:00Z"
datacontenttype: "application/json"
dataschema: "https://example.com/schemas/user-created.v1.json"
idempotencykey: "user.created:usr_123:2026-06-02"
correlationid: "req_7fc42b"
data:
userId: "usr_123"
email: "masa@example.com"
plan: "starter"
locale: "ko-KR"
payload의 세부 구조는 JSON Schema로 별도 관리합니다. Claude Code에 구현을 맡길 때는 schema 밖의 필드에 의존하지 말 것, 선택 필드를 필수로 바꾸지 말 것, 삭제나 타입 변경은 v2로 분리할 것을 명시합니다.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/user-created.v1.json",
"title": "UserCreatedV1",
"type": "object",
"additionalProperties": false,
"required": ["userId", "email", "plan", "locale"],
"properties": {
"userId": { "type": "string", "minLength": 1 },
"email": { "type": "string", "format": "email" },
"plan": { "type": "string", "enum": ["free", "starter", "pro"] },
"locale": { "type": "string", "pattern": "^[a-z]{2}-[A-Z]{2}$" }
}
}
이벤트 이름은 과거형 사실로 둡니다. user.create나 sendEmail은 명령처럼 보입니다. user.created, payment.authorized, invoice.finalized처럼 이미 일어난 사실이 좋습니다. user.updated는 편해 보이지만 이메일 변경, 플랜 변경, 프로필 수정이 모두 섞여 consumer가 payload를 들여다보게 됩니다. 중요한 변화는 user.email_changed.v1, subscription.plan_changed.v1처럼 나눕니다.
흐름도를 그려 의존성을 확인한다
글로만 설명하면 어디가 비동기이고 어디서 retry하는지 흐려집니다. 구현 전에 Claude Code에게 Mermaid 흐름도를 만들게 하면 숨은 동기 의존성을 빨리 찾을 수 있습니다.
flowchart LR
A["Identity API<br/>producer"] --> B["Event bus<br/>filter and route"]
B --> C["Onboarding consumer<br/>workspace setup"]
B --> D["Email consumer<br/>welcome message"]
B --> E["Audit consumer<br/>append-only log"]
C --> F["Idempotency store"]
D --> F
C --> G["Dead-letter queue"]
D --> G
B --> H["OpenTelemetry<br/>traces metrics logs"]
검토 포인트는 producer가 모든 consumer의 완료를 기다리지 않는지입니다. 가입 API가 환영 메일 발송 성공까지 기다린다면, 이는 이벤트 기반이 아니라 숨겨진 동기 의존입니다. 동기가 필요하면 API 계약에 드러내고, 비동기면 최종적 일관성을 전제로 화면과 복구 절차를 준비합니다.
복사해서 확인할 수 있는 Node.js consumer
다음 코드는 사용자 생성 이벤트를 처리하고, onboarding 작업공간을 만들고, 환영 메일을 큐에 넣고, 동일 이벤트 중복을 무시하며, 실패 이벤트를 dead-letter queue에 남깁니다. 예시는 Map을 쓰지만 운영에서는 Redis, DynamoDB, PostgreSQL 같은 공유 저장소로 바꿔야 합니다.
const crypto = require("node:crypto");
const processedEvents = new Map();
const deadLetterQueue = [];
function payloadHash(payload) {
return crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
}
function eventKey(event) {
return event.idempotencykey || `${event.type}:${event.id}`;
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function withRetry(operation, options = {}) {
const attempts = options.attempts ?? 3;
const delayMs = options.delayMs ?? 250;
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt === attempts) break;
await wait(delayMs * attempt);
}
}
throw lastError;
}
async function handleUserCreated(event, services) {
if (event.specversion !== "1.0") {
throw new Error(`Unsupported CloudEvents version: ${event.specversion}`);
}
if (event.type !== "com.claudecodelab.user.created.v1") {
throw new Error(`Unexpected event type: ${event.type}`);
}
const key = eventKey(event);
const currentHash = payloadHash(event.data);
const existing = processedEvents.get(key);
if (existing?.status === "succeeded" && existing.payloadHash === currentHash) {
return { status: "duplicate_ignored", key };
}
if (existing && existing.payloadHash !== currentHash) {
throw new Error("Idempotency key reused with a different payload");
}
processedEvents.set(key, {
status: "processing",
payloadHash: currentHash,
updatedAt: new Date().toISOString(),
});
try {
await withRetry(() => services.createOnboardingWorkspace(event.data.userId), {
attempts: 3,
delayMs: 200,
});
await withRetry(
() =>
services.enqueueWelcomeEmail({
userId: event.data.userId,
email: event.data.email,
correlationId: event.correlationid,
}),
{ attempts: 3, delayMs: 200 },
);
processedEvents.set(key, {
status: "succeeded",
payloadHash: currentHash,
updatedAt: new Date().toISOString(),
});
return { status: "processed", key };
} catch (error) {
processedEvents.set(key, {
status: "failed",
payloadHash: currentHash,
updatedAt: new Date().toISOString(),
errorMessage: error.message,
});
deadLetterQueue.push({
key,
event,
failedAt: new Date().toISOString(),
errorMessage: error.message,
});
throw error;
}
}
const services = {
async createOnboardingWorkspace(userId) {
console.log("workspace ready", { userId });
},
async enqueueWelcomeEmail(message) {
console.log("email queued", {
userId: message.userId,
correlationId: message.correlationId,
});
},
};
const exampleEvent = {
specversion: "1.0",
id: "evt_01JZ0YV8Y9N3A7Z7K6Y1G9X2Q4",
type: "com.claudecodelab.user.created.v1",
source: "/services/identity",
time: "2026-06-02T09:30:00Z",
idempotencykey: "user.created:usr_123:2026-06-02",
correlationid: "req_7fc42b",
data: {
userId: "usr_123",
email: "masa@example.com",
plan: "starter",
locale: "ko-KR",
},
};
handleUserCreated(exampleEvent, services)
.then((result) => console.log(result))
.catch((error) => console.error(error));
module.exports = { handleUserCreated, withRetry, deadLetterQueue };
Claude Code에는 조건을 구체적으로 줍니다. 성공한 이벤트는 다시 실행하지 않는다, 같은 idempotency key에 다른 payload가 오면 멈춘다, 일시적 실패는 retry한다, 최종 실패는 DLQ에 남긴다. “retry 넣어줘”만 쓰면 중복 메일이나 중복 권한 부여가 생기기 쉽습니다.
네 가지 실무 유스케이스
| 유스케이스 | 이벤트 | consumer | 주의점 |
|---|---|---|---|
| SaaS 가입과 onboarding | user.created.v1, workspace.created.v1 | 초기 설정, 환영 메일, CRM 동기화 | 가입 API가 모든 consumer를 기다리지 않게 한다 |
| 결제 Webhook에서 제공 시작 | payment.succeeded.v1, subscription.activated.v1 | 권한 부여, invoice, Slack 알림 | Webhook 서명 검증과 멱등성이 필수 |
| 감사 로그와 이벤트 스트림 | role.changed.v1, api_key.revoked.v1 | append-only log, 감사 검색, SIEM | PII를 장기 로그에 넣지 않는다 |
| 알림 파이프라인 | comment.mentioned.v1, report.ready.v1 | 이메일, 앱 내 알림, Push | 알림 설정과 수신 거부를 확인한다 |
결제 Webhook은 이벤트 기반과 잘 맞지만 실수 비용이 큽니다. 입구 구현은 Claude Code Webhook 구현과 함께 확인하세요. API 계약 방식은 Claude Code API 개발이 도움이 됩니다. 이벤트 v1/v2 운용은 API versioning과 같은 원칙으로 다룹니다.
감사 로그는 보안 기준이 중요합니다. 전체 payload를 그대로 장기 보관하지 마세요. Claude Code security audit와 security best practices를 함께 보고, 어떤 필드가 로그에 남아도 되는지 정합니다. 실패 응답과 예외 형식은 error handling patterns와 연결해 설계합니다.
자주 나오는 실패 사례
첫째, 이벤트 이름이 모호합니다. user.updated는 모든 consumer에게 판단을 떠넘깁니다. Claude Code에게 downstream 분기를 강제하는 이름을 찾아 달라고 요청하세요.
둘째, payload를 깨뜨리는 변경입니다. email 삭제, 문자열 ID를 객체로 변경, 선택 필드를 필수로 변경하는 일은 독립 배포된 consumer를 깨뜨립니다. 삭제, 타입 변경, 의미 변경은 새 버전으로 분리합니다.
셋째, 중복 전달을 고려하지 않습니다. 많은 이벤트 시스템은 at-least-once delivery입니다. 적어도 한 번은 도착하지만 두 번 이상 올 수 있다는 뜻입니다. 이메일, 결제, 권한, 포인트 처리에는 멱등성 키와 처리 기록이 필요합니다.
넷째, 숨은 동기 의존입니다. producer가 이벤트를 발행한 뒤 consumer의 DB를 읽고 응답한다면 결합이 남아 있습니다. 동기 API로 인정하거나 최종적 일관성에 맞게 UX를 조정해야 합니다.
다섯째, replay 계획이 없습니다. consumer 버그로 몇 시간치 이벤트가 실패했을 때 보관 기간, replay 범위, 중복 처리, 부작용 억제를 설명할 수 있어야 합니다.
여섯째, 관측성이 없습니다. 로그에는 event id, type, correlation id, consumer 이름, retry 횟수, DLQ 이유가 있어야 합니다. metrics에는 backlog age, 실패율, 중복 수, replay 수가 필요합니다.
일곱째, PII를 로그에 남깁니다. PII는 개인을 식별할 수 있는 정보입니다. 이메일, 이름, 주소, 결제 정보, token을 그대로 로그나 이슈에 붙이지 말고 event id와 userId로 추적합니다.
Claude Code 리뷰 템플릿
구현을 맡기기 전에 먼저 리뷰를 요청하세요.
# Claude Code EDA review checklist
Scope:
- event contract: schemas/user-created.v1.json
- producer: services/identity
- consumers: onboarding, email, audit-log
Please review:
- Is the event name a past-tense fact?
- Is the payload change backward compatible for existing consumers?
- Is there an idempotency key, and does duplicate delivery avoid double side effects?
- Does any consumer call back into the producer synchronously?
- Are retry count, backoff, and dead-letter rules explicit?
- Can replay run without duplicate email, payment, or irreversible effects?
- Do logs avoid PII and secrets?
- Can OpenTelemetry show event id, correlation id, and consumer name?
Output:
- P0/P1/P2 risks
- Files that should change
- Tests that should be added
- Open decisions a human must make
위험한 전제가 나오면 코드를 만들기 전에 경계를 고칩니다. 그 다음 schema, handler, test, runbook 순서로 좁게 구현시키면 리뷰가 쉬워집니다.
운영 runbook
이벤트 기반 시스템은 장애 때 운영할 수 있어야 의미가 있습니다. 첫 consumer를 만들 때 짧은 runbook도 같이 둡니다.
# Runbook: event backlog or DLQ growth
## Symptoms
- Queue age is over 5 minutes
- Dead-letter queue has more than 10 messages
- Consumer error rate is over 2 percent for 10 minutes
## First checks
1. Identify event type, consumer name, and correlation id.
2. Check whether the failure is validation, downstream timeout, or permission.
3. Confirm whether the producer is still publishing new events.
4. Stop replay if the event triggers email, payment, or irreversible side effects.
## Recovery
1. Fix the consumer or downstream dependency.
2. Replay a small batch with idempotency enabled.
3. Compare processed count, duplicate count, and DLQ count.
4. Resume normal processing.
5. Write the incident note with event ids, time range, and customer impact.
## Never do
- Do not edit payloads manually without recording the reason.
- Do not replay payment or email events without suppression rules.
- Do not paste full payloads with PII into chat or issue trackers.
merge 전에 Claude Code에게 “이 runbook으로 복구할 수 없는 실패는 무엇인가”라고 물어보세요. 권한 부족, schema drift, 외부 API 중단 같은 빠진 조건이 드러나는 경우가 많습니다.
정리와 상담 안내
이벤트 기반 아키텍처는 확장성과 회복력을 높일 수 있지만, 이벤트 계약을 공개 API처럼 관리할 때만 효과가 있습니다. 이벤트 이름, schema, versioning, idempotency, ordering, retries, dead-letter handling, replay, observability는 모두 명시적으로 결정해야 합니다. Claude Code는 그 결정을 검토하고 계약에 맞는 작은 구현을 돕는 도구로 쓰는 것이 가장 안전합니다.
ClaudeCodeLab은 Claude Code 교육, 이벤트 기반 설계 리뷰, Webhook/API 계약, 감사 로그 전략, 장애 runbook, 팀 워크플로 정리를 지원합니다. Webhook을 안전하게 처리하거나 알림을 비동기 worker로 옮기거나 Claude Code 리뷰 프롬프트를 팀 표준으로 만들고 싶다면 Claude Code training and consulting에서 시작하세요. 직접 정리할 때는 free cheat sheet와 product templates도 참고할 수 있습니다.
Masa가 작은 SaaS 프로토타입에서 이 흐름을 검증했을 때, event contract와 idempotency key를 먼저 쓴 경우 Claude Code의 변경이 더 작고 검토하기 쉬웠습니다. 반대로 user.updated 하나로 밀어붙인 초기 실험에서는 알림 consumer와 감사 consumer가 payload 내부를 보고 분기하기 시작했고 replay 규칙도 흐려졌습니다. 이벤트 이름을 나누고 DLQ runbook을 먼저 둔 뒤에는 어떤 이벤트를 어느 시간대부터 몇 건 replay할지 설명할 수 있는 상태가 되었습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code Permission Receipt Pattern: 권한, 증거, 롤백을 남기는 운영
Claude Code 작업마다 허용 범위, 승인 경계, 검증 명령, 롤백 메모, Gumroad와 상담 CTA 확인을 남기는 permission receipt 패턴입니다.
Claude Code/Codex 안전 Agent Harness 설계: 권한, 검증, 롤백
Claude Code와 Codex를 안전하게 운영하기 위한 Agent Harness를 권한 정책, 실행 계획, 검증, 복구 계층으로 설계합니다.
Claude Code 서브에이전트 실전 가이드: 기사와 코드 작업을 안전하게 위임하기
Claude Code 서브에이전트로 기사와 코드 작업을 안전하게 나누는 방법. 위임 규칙, 프롬프트, 실패 사례를 정리합니다.