이전 도구의 한계를 직접 체험한 뒤, 그 한계를 해결하는 다음 도구로 넘어가는 메시징 학습 테스트.
Learn why each messaging tool exists by hitting the limits of the previous one. From in-process events to Kafka, experience the tradeoffs that drive the evolution of event delivery.
- 프로젝트 소개
- 시작하기
- 학습 구조
- 학습 순서 가이드
- 테스트 목록
- 이 lab이 다루지 않는 것
- 설계 원칙 — 6-Layer Framework
- EDA Overview - 큰 그림 보기
- 용어 정리 (Glossary)
각 도구의 "존재 이유" 를 체험하는 lab입니다. 동작 원리는 "다음 step이 왜 필요한지 납득할 수 있는 최소한"만 다룹니다. 특정 도구의 설정, 튜닝, 운영 전략은 다루지 않습니다. (→ kafka-lab)
"잘 되는 것"보다 "안 되는 것"을 먼저 확인합니다. 각 Step은 이전 Step의 한계를 직접 체험한 뒤, 그 한계를 해결하는 다음 도구로 넘어갑니다. 모든 테스트 이름이 곧 증명 명제입니다.
전체 흐름을 먼저 조감하고 싶다면 EDA Overview를, Step 3 완료 후 현재 위치를 확인하고 싶다면 같은 문서의 포지션 맵을 참고하세요.
- Java 21 / Spring Boot 3.4.3
- Spring Kafka — Step 6, 7의 Producer/Consumer
- Spring AMQP — Step 5의 RabbitMQ
- Spring Data Redis — Step 4의 Pub/Sub
- Spring Data JPA (H2) — Step 3의 Event Store
- Testcontainers — Redis, RabbitMQ, Kafka 컨테이너 자동 기동
- Java 21 이상
- Docker 실행 중 (Testcontainers가 Redis, RabbitMQ, Kafka 컨테이너를 자동으로 띄웁니다)
# 전체 테스트 실행
./gradlew test
# 특정 Step만 실행
./gradlew test --tests "com.example.messaging.{step 폴더명}.*"
# 예시: ./gradlew test --tests "com.example.messaging.step1_application_event.*"
# 특정 테스트 클래스만 실행
./gradlew test --tests "CommandEventConceptTest"Step 0 "Event와 Command는 다르다"
Step 1 "직접 호출하면 결합도가 높아진다" → 이벤트로 끊자
한계: 리스너 예외가 발행자 트랜잭션을 롤백시킨다
→ try-catch는 땜빵이다. 근본 원인은 같은 트랜잭션 안에서 실행되기 때문.
Step 2 "트랜잭션 커밋 후에만 실행하면 아예 영향을 줄 수 없다"
이 순간 Eventual Consistency를 수용한 것
함정: TX 없이 발행하면 리스너가 아예 안 불린다
함정: @EnableAsync 없이 @Async 달면 조용히 동기 실행
함정: AFTER_COMMIT에서 DB 저장하려면 별도 빈의 REQUIRES_NEW 필요
Step 3 "메모리 이벤트는 서버 죽으면 사라진다" → DB에 저장하자
분산 환경에서는 트랜잭션보다 상태 전이와 보상 흐름 설계가 중요하다.
── 여기까지: 유실 방지 축 ──
모놀리식이라면 여기까지로 충분할 수 있다. (단, 무거운 작업은 Self-consume도 고려)
하지만 팀/DB가 분리되면 트랜잭션을 묶을 수 없다.
ApplicationEvent는 프로세스 경계를 넘지 못한다.
── 여기서부터: 프로세스 간 전달 축 ──
Step 4 "단일 프로세스 안에서만 전달된다" → 프로세스 밖으로 보내자 (비보존)
Step 5 "보내긴 했는데 저장이 안 돼서 재처리 불가" → 큐에 저장하자 (소비 후 삭제)
Step 6 "소비하면 삭제돼서 과거 이벤트 재처리 불가" → 로그로 보존하자 (소비해도 남음)
Step 3의 Event Store + Kafka Relay = Transactional Outbox 완성
원칙: TX 안에서 외부 I/O(Kafka, 캐시, 외부 API)를 하면 안 된다.
Step 7 "재처리 가능하니까 중복이 온다" → 멱등하게 처리하고, 실패는 격리하자
컨슈머는 프로듀서를 신뢰하면 안 된다. 이벤트를 의심하고 검증해야 한다.
Step 7까지 완주하면, 이 전체를 관통하는 설계 원칙을 6개 레이어로 정리한 PRINCIPLES.md로 복습할 수 있습니다. 내구성 → 원자성 → 전달 보장 → 확인 → 멱등성 → 관측. 아래 레이어가 무너지면 위 레이어는 의미가 없습니다.
처음이라면 반드시 Step 0부터 순서대로 진행하세요. 각 Step은 이전 Step의 한계를 전제로 합니다.
총 71개 학습 테스트. 각 테스트 이름이 곧 증명 명제입니다.
- 각 Step 폴더의 README를 먼저 읽기 (
src/test/.../step1_application_event/README.md) — 스토리를 따라가며 "왜 이것이 문제인지" 맥락을 잡습니다 - 테스트를 실행 — 함정을 직접 체험하고, 해결 방법을 확인합니다
- "스스로 답해보자"에 답하기 — README 끝에 있는 질문에 먼저 답해보고, 막히면 테스트로 검증합니다
| Step | 주제 | 핵심 질문 | 테스트 | 인프라 |
|---|---|---|---|---|
| 0 | Command vs Event | 같은 인프라인데 왜 설계가 달라지는가? | 7개 | 없음 |
| 1 | Application Event | 직접 호출 대신 이벤트를 쓰면 뭐가 좋은가? | 8개 | Spring Event |
| 2 | Transactional Event | 트랜잭션과 이벤트 타이밍은 왜 중요한가? | 16개 | Spring Event |
| 3 | Event Store | 서버가 죽으면 이벤트는 어디로 가는가? | 7개 | Spring Data JPA (H2) |
| 4 | Redis Pub/Sub | 프로세스 밖으로 이벤트를 보내면? | 4개 | Spring Data Redis |
| 5 | RabbitMQ | 큐에 저장하면 유실이 해결되는가? | 6개 | Spring AMQP |
| 6 | Kafka | 메시지를 보존하면서 전달하려면? | 12개 | Spring Kafka |
| 7 | Idempotent Consumer | 같은 메시지가 2번 오면 어떻게 되는가? | 11개 | Spring Kafka |
Command와 Event의 본질적 차이를 구분합니다. 이 구분은 Step 3(DB 저장 대상), Step 6(토픽 설계)에서 계속 돌아옵니다.
- Command: "쿠폰을 발급해라" → 아직 일어나지 않은 일, 실패할 수 있음, 1:1 지시
- Event: "주문이 생성되었다" → 이미 확정된 사실, 발행자는 누가 듣는지 모름, 1:N 통지
- 둘 다 타임스탬프가 있을 수 있지만 의미가 다르다: requestedAt(요청 시각) vs occurredAt(확정 시각)
- 동기/비동기 판단 기준: 도메인 불변식 > 트랜잭션 일관성 > UX (의사결정 트리)
직접 호출 방식의 결합도 문제를 체험하고, ApplicationEventPublisher로 전환 후 의존성이 제거되는 것을 확인합니다.
- 직접 호출: OrderService가 StockService, CouponService, PointService를 모두 알고 있음
- ApplicationEvent 전환 후 의존성 제거 확인
- 한계 발견:
@EventListener내부 예외가 발행자 트랜잭션을 롤백시킨다
@TransactionalEventListener의 4가지 phase를 비교하고, 왜 AFTER_COMMIT이 안전한지 증명합니다.
@Async로 응답 속도를 개선하지만, 거기에 따르는 함정과 한계를 체험합니다.
이 Step에서 인식해야 할 것: AFTER_COMMIT + @Async를 선택한 순간, Eventual Consistency를 수용한 것입니다.
[Phase 비교] — 왜 AFTER_COMMIT인가?
- BEFORE_COMMIT: 커밋 전에 실행. 리스너 예외가 발행자 TX를 롤백시킨다 (Step 1과 같은 위험)
- AFTER_COMMIT: 커밋 후에 실행. 리스너 예외가 발행자 TX에 영향 없다 (안전)
- AFTER_ROLLBACK: 롤백 후에만 실행. 보상 로직/알림에 사용
- AFTER_COMPLETION: 커밋/롤백 무관하게 실행. 리소스 정리에 사용
[함정] — 모르면 밟는 것들
- 트랜잭션 없이 이벤트를 발행하면
@TransactionalEventListener가 아예 불리지 않는다 @EnableAsync없이@Async를 달면 조용히 동기 실행된다- AFTER_COMMIT 리스너에서 DB 저장하려면 별도 빈의 REQUIRES_NEW가 필요 (self-invocation 방지)
@Async기본 스레드풀 설정은 프로덕션에 부적합하다 (커스텀 설정 필요)
[한계 발견]
@Async리스너 예외는 호출자에게 전파되지 않는다 (실패 은닉)- 서버가 재시작되면 메모리의 이벤트는 증발한다 → Step 3으로
도메인 저장과 이벤트 기록을 하나의 트랜잭션으로 묶어 유실을 방지합니다. 스케줄러(릴레이)가 PENDING 이벤트를 처리합니다.
- Event Store 테이블: event_id, event_type, payload, status, created_at
- 서버 재시작 후에도 PENDING 이벤트가 남아있어서 재처리 가능
- 분산 환경에서는 상태 전이(PENDING → SENT → PROCESSED)와 보상 설계가 트랜잭션을 대체한다
- 모놀리식에서도 무거운 작업(대량 처리 등)이면 Kafka Self-produce/Self-consume이 유용할 수 있다
- Step 6에서 Kafka로 릴레이하면 Transactional Outbox Pattern 완성
이벤트가 프로세스 경계를 넘어 전달되는 것을 확인합니다. 구독자가 없으면 메시지는 증발합니다.
- 적합한 케이스: 캐시 무효화 신호, 실시간 알림 브로드캐스트
- 한계 발견: 메시지가 저장되지 않아 재처리 불가
메시지를 큐에 저장해서 구독자가 없어도 보존합니다. Redis Pub/Sub의 유실 문제를 해결합니다. 같은 큐에서 부하를 나누는 Competing Consumers 패턴을 체험합니다.
- Consumer가 없어도 메시지가 큐에 남아있음 (Redis 대비 차이)
- Competing Consumers: 같은 큐에서 메시지를 나눠 처리
- 한계 발견: ACK하면 큐에서 삭제. "어제 이벤트를 다시 처리해야 해"가 불가능
메시지가 로그로 보존되어 소비해도 남아있습니다. 여러 Consumer Group이 독립적으로 소비합니다.
Step 3에서 한 것: 도메인 저장 + Event Store 기록 (같은 TX)
이 Step에서 추가: 릴레이가 Event Store → Kafka로 발행
합치면: Transactional Outbox Pattern
- 원칙: TX 안에서 외부 I/O(Kafka, 캐시, 외부 API)를 하면 정합성이 깨진다 → Outbox로 분리
- Idempotent Producer(브로커 내 중복 방지)와 Outbox(DB↔Kafka 유실 방지)는 보장 범위가 다른 안전 장치
- partition key 하나에 트래픽이 몰리면 Hot Partition — 설계 시 주의
- 한계 발견: 같은 메시지가 2번 오면 포인트가 2번 적립된다
At Least Once 환경에서 중복 소비를 방어하는 멱등 패턴을 구현합니다. 컨슈머는 프로듀서를 신뢰하면 안 된다. 이벤트를 의심하고 검증해야 한다.
| 패턴 | 적합한 상황 | 트레이드오프 |
|---|---|---|
| 상태 머신 (도메인 상태) | 단방향 상태 전이 도메인 | 비용 없음, 단방향에서만 |
| event_handled(event_id PK) | 범용, 어떤 도메인이든 적용 가능 | 별도 테이블 필요, 조회 비용 |
| Upsert | 집계성 데이터 (조회수, 좋아요수) | 도메인 특성에 의존, 범용성 낮음 |
| version / updated_at 비교 | 순서 역전까지 방어해야 하는 경우 | 구현 복잡도 높음 |
- 이벤트 분류: 여러 번 처리해도 OK(상태 단방향) vs 반드시 한 번만(카운터, 포인트) → 멱등 수준 결정
- 처리 불가능한 메시지(poison pill)는 DLQ로 격리
- 장애 대응: Fail-Open(마케팅/그로스) vs Fail-Closed(금전/재고) + Graceful Degradation
각 Step의 README에 상세 설명이 있습니다.
Step 0 — Command vs Event (7개)
| 테스트 | 증명하는 것 |
|---|---|
| Command는 미래시제다 아직 일어나지 않은 일 | requestedAt(요청 시각)이 있다 |
| Event는 과거시제다 이미 확정된 사실 | occurredAt(확정 시각)이 있다 |
| Command는 수신자를 특정한다 1대1 | 발신자가 handler를 직접 호출 |
| Event는 수신자를 모른다 1대N | 리스너 수와 무관 |
| Command는 실패할 수 있고 발신자가 처리해야 한다 | 예외 발생 시 발신자 책임 |
| Event는 이미 일어난 사실이므로 발행 자체는 실패하지 않는다 | 발행은 항상 성공 |
| 같은 도메인에서 Command 실행 결과가 Event가 된다 | Command -> 실행 -> Event |
Step 1 — Application Event (8개)
| 테스트 | 증명하는 것 |
|---|---|
| 직접 호출 방식에서 OrderService는 모든 후속 서비스에 의존한다 | 생성자 의존성 4개 |
| 직접 호출 방식에서 후속 처리 실패시 주문도 롤백된다 | 강한 결합의 부작용 |
| 직접 호출 방식에서 모든 후속 처리가 성공하면 주문이 완료된다 | 정상 흐름 |
| 이벤트 방식에서 OrderService는 EventPublisher에만 의존한다 | 의존성 2개로 감소 |
| 이벤트 발행 후 리스너가 정상 처리하면 모든 데이터가 저장된다 | 정상 흐름 |
| 후속 로직 추가시 OrderService는 수정하지 않아도 된다 | OCP 원칙 |
| 리스너 예외가 발행자 트랜잭션을 롤백시킨다 | 핵심 한계 |
| EventListener는 발행자와 같은 스레드에서 동기적으로 실행된다 | 동일 TX |
Step 2 — Transactional Event (16개)
[Phase 비교]
| 테스트 | 증명하는 것 |
|---|---|
| BEFORE COMMIT 리스너에서 예외 발생 시 발행자 TX가 롤백된다 | Step 1과 같은 위험 |
| TransactionalEventListener는 커밋 후에만 실행된다 | 안전한 타이밍 |
| 트랜잭션이 롤백되면 TransactionalEventListener는 실행되지 않는다 | 롤백 안전 |
| AFTER ROLLBACK 리스너는 롤백 후에만 실행된다 | 보상/알림 용도 |
| AFTER COMPLETION 리스너는 커밋 롤백 무관하게 실행된다 | 리소스 정리 용도 |
[함정]
| 테스트 | 증명하는 것 |
|---|---|
| 트랜잭션 없이 이벤트를 발행하면 TransactionalEventListener가 불리지 않는다 | @Transactional 빠뜨리면 리스너 무시 |
| EnableAsync가 있어야 Async 리스너가 별도 스레드에서 실행된다 | @EnableAsync 없으면 동기 실행 |
| AFTER COMMIT 리스너에서 DB 저장하면 TransactionRequiredException 발생 | 기존 TX는 이미 커밋됨 |
| AFTER COMMIT 리스너에서 별도 빈의 REQUIRES NEW로 새 TX를 열면 DB 저장 가능 | 해결: 별도 빈 + 새 트랜잭션 |
| Async 기본 스레드풀은 큐 제한이 없어 프로덕션에서 튜닝이 필요하다 | 기본 설정의 위험 |
[한계 발견]
| 테스트 | 증명하는 것 |
|---|---|
| EventListener에서 외부 API를 호출한 뒤 트랜잭션이 롤백되면 호출은 되돌릴 수 없다 | 비트랜잭션 부수효과 위험 |
| TransactionalEventListener 예외는 발행자 트랜잭션에 영향을 주지 않는다 | TX 보호 |
| Async 리스너는 별도 스레드에서 실행되어 응답이 빠르다 | 비동기 응답 |
| Async 리스너 예외는 호출자에게 전파되지 않는다 실패가 숨겨진다 | 실패 은닉 |
| 주문 직후 포인트를 조회하면 아직 반영되지 않았을 수 있다 | Eventual Consistency |
| 서버가 재시작되면 Async 리스너가 처리하지 못한 이벤트는 유실된다 | 핵심 한계: 메모리 휘발 |
Step 3 — Event Store (7개)
| 테스트 | 증명하는 것 |
|---|---|
| 주문 저장과 이벤트 기록은 하나의 트랜잭션으로 묶인다 | 원자성 (정상) |
| 주문 저장이 실패하면 이벤트 기록도 함께 롤백된다 | 원자성 (실패) |
| 스케줄러는 PENDING 상태의 이벤트를 조회하여 처리한다 | 릴레이 동작 |
| 처리 완료된 이벤트는 PROCESSED 상태로 변경된다 | 상태 전이 |
| 이미 처리된 이벤트는 다시 처리하지 않는다 | 중복 방지 |
| 서버 재시작 후에도 PENDING 이벤트는 DB에 남아있다 | 내구성 |
| 재시작 후 스케줄러가 PENDING 이벤트를 재처리한다 | 복구 가능성 |
Step 4 — Redis Pub/Sub (4개)
| 테스트 | 증명하는 것 |
|---|---|
| 발행한 메시지를 구독자가 수신한다 | 기본 동작 |
| 여러 구독자가 동일한 메시지를 모두 수신한다 | 브로드캐스트 |
| 구독자가 없으면 발행된 메시지는 유실된다 | 비보존 |
| 구독자가 다운된 동안 발행된 메시지는 수신할 수 없다 | 다운타임 유실 |
Step 5 — RabbitMQ (6개)
| 테스트 | 증명하는 것 |
|---|---|
| Producer가 보낸 메시지를 Consumer가 수신한다 | 기본 파이프라인 |
| Consumer가 없어도 메시지는 큐에 보존된다 | Redis 대비: 보존 |
| Consumer가 다운된 동안 발행된 메시지를 재시작 후 수신한다 | 다운타임 보존 |
| 같은 큐의 Consumer 2개가 메시지를 나눠 처리한다 | Competing Consumers |
| ACK한 메시지는 큐에서 삭제되어 다시 읽을 수 없다 | 소비 후 삭제 |
| 소비 완료된 메시지를 다른 Consumer가 다시 읽을 수 없다 | 재처리 불가 |
Step 6 — Kafka (12개)
| 테스트 | 증명하는 것 |
|---|---|
| Producer가 보낸 메시지를 Consumer가 수신한다 | 기본 파이프라인 |
| 여러 메시지를 순서대로 발행하면 같은 파티션에서 순서대로 소비된다 | 파티션 내 순서 |
| Consumer가 중지된 사이에 발행된 메시지를 재시작 후 이어서 읽는다 | 메시지 보존 |
| 구독자가 없어도 메시지는 Kafka에 보존된다 | Redis 대비 차이 |
| 두 Consumer Group이 같은 토픽의 모든 메시지를 각각 독립적으로 수신한다 | Group 독립성 |
| 한 Consumer Group의 소비 속도가 다른 Group에 영향을 주지 않는다 | 속도 독립성 |
| 같은 key의 메시지는 같은 파티션에 저장된다 | Key-Partition 매핑 |
| 같은 파티션의 메시지는 발행 순서대로 소비된다 | 파티션 내 순서 |
| 다른 key의 메시지는 다른 파티션으로 분배될 수 있다 | 파티션 분배 |
| 주문 저장과 이벤트 기록이 하나의 트랜잭션으로 묶인다 | Outbox 원자성 |
| 릴레이가 PENDING 이벤트를 Kafka로 발행하고 SENT로 변경한다 | Outbox 완성 |
| Kafka 발행 실패 시 이벤트는 여전히 PENDING 상태를 유지한다 | 실패 복구 |
Step 7 — Idempotent Consumer (11개)
| 테스트 | 증명하는 것 |
|---|---|
| 같은 메시지를 2번 소비하면 포인트가 2번 적립된다 | 문제 체험 |
| event_handled 테이블에 이미 처리된 이벤트가 있으면 스킵한다 | 패턴 1: event_handled |
| 서로 다른 event_id의 메시지는 각각 정상 처리된다 | 정상 흐름 |
| 같은 이벤트를 2번 처리해도 upsert로 올바른 결과가 유지된다 | 패턴 2: Upsert |
| upsert는 최신 값으로 덮어쓰므로 최종 상태가 보장된다 | 덮어쓰기 |
| 다른 상품의 이벤트는 각각 독립적으로 upsert된다 | 독립성 |
| version이 현재보다 높은 이벤트만 반영된다 | 패턴 3: version |
| version이 현재보다 낮거나 같은 이벤트는 무시된다 | 역전 방어 |
| 순서가 역전된 이벤트 시퀀스에서 최종 상태가 올바르다 | 종합 검증 |
| 파싱 불가능한 메시지가 Consumer를 막는다 | Poison pill |
| 처리 실패한 메시지를 DLQ 토픽으로 격리할 수 있다 | DLQ 격리 |
| 주제 | 다루는 곳 |
|---|---|
| Kafka 설정 깊이 파기 (acks, commit 전략, rebalancing) | kafka-lab |
| 파티션 수 vs Consumer 수 역학, 리밸런싱 동작 | kafka-lab |
| DLQ 토픽 설계, retry backoff 전략, ErrorHandler 구성 | kafka-lab |
| Exactly-Once Semantics 동작과 한계 | kafka-lab |
| Consumer lag 모니터링, 운영 지표 | kafka-lab |
| CDC (Debezium) 기반 Outbox 릴레이 | 별도 주제 |
| Saga Pattern (Choreography/Orchestration), 보상 트랜잭션 | saga-lab (예정) |