TL;DR
“관심사 분리”용으로 가볍게 시작했다가, 도메인 사실을 안전하게 전파하고 추적 가능한 메타데이터를 더해 운영/관측성까지 챙기는 구조로 진화.
핵심 선택은 AFTER_COMMIT + @Async + Envelope + Bridge + Policy/Sender 분리.
이번 과제에서 데이터 플랫폼 싱크 = 알림톡이었고, 도메인 이벤트(PaymentCaptured)가 **봉투(Envelope)**로 표준화되어 알림 정책 → 전송자를 통해 **단일 싱크(알림톡)**로 흘러가게 설계했다.
1) 처음 생각: “@EventListener 붙이면 관심사 분리 완료”
처음엔 정말 이렇게 시작했다. 도메인 로직(주문/결제) 뒤에 알림/집계/로그를 리스너로 뽑아내면 끝이라 믿었음.
[Controller/Service] → (도메인 상태 변화)
↳ @EventListener들(알림/통계/캐시 등)
그런데 바로 맞은 세 가지 벽
- 트랜잭션 영향: 리스너에서 예외 터지면 원 트랜잭션 롤백
- 이벤트 순서/시점: 비동기/스레드 풀에 따라 발생 순서 ≠ 처리 순서
- 디버깅 불가: “이벤트 발행됐니?”, “어디서 누락?”을 추적할 표준 메타데이터가 없음
여기서 관점이 바뀜:
이벤트 = 콜백 수단이 아니라 **“과거에 일어난 사실(Fact)을 안전하게 전파하는 매체”여야 한다.
2) 이벤트 재정의 — “사실(Fact)이며, 시간(Time)을 가진다”
도메인 이벤트는 이런 성질을 가져야 한다.
- 과거형 이름: PaymentCaptured, OrderCreated
- 불변: 이미 일어난 사실
- 시간: occurredAt이 명확해야 순서를 논할 수 있음
- 추적: messageId, correlationId, source 같은 표준 메타데이터
📦 빠르게 참조하는 정의 박스
- Domain Event: 비즈니스 의미 있는 상태 변화(PaymentCaptured)
- Integration Event: 시스템 간 통신용(MessageSendRequested)
- System Event: 기술적 상태 변화(CacheInvalidated)
- 원칙: 도메인 이벤트는 도메인 언어로, 메타데이터는 봉투(Envelope)가 책임
3) 언제 이벤트를 쓰고, 언제 동기로 부른다? (결정 트리)
✅ 결정 트리 (내가 실제로 쓴 것)
- 명령(Do) vs 사실(Happened)
- “무언가 해라” → 동기 호출
- “무언가가 일어났다” → 이벤트 후보
- 즉시성 필요?
- 호출자가 지금 결과를 바로 써야 함 → 동기
- 지연 가능/나중 일관 → 이벤트
- 실패 전파/롤백 필요?
- 실패를 바로 알려야 하거나 원자성 필요 → 동기
- 실패가 원 트랜잭션에 영향 주면 안 됨 → 이벤트
🌰 사례로 딱 감 잡기
- PG 세션키 발급(결제 요청): 사용자 리다이렉트 즉시 필요 → 동기
- 결제 완료 후 후속 작업(예: 영수증/알림): 실패해도 결제 자체는 유지 → 이벤트
- 좋아요 → 집계 업데이트: 사용자 성공이 우선, 집계는 eventual → 이벤트
[요청]
├─ 즉시 결과/원자성 필요? ── Yes → 동기 호출(명령)
│ (예: PG 세션키)
└─ No ── 사실 전파? ── Yes → 이벤트 발행(사실)
(예: PaymentCaptured → 알림)
└─ No → 그냥 함수 호출
4) 트랜잭션 타이밍 비교 — 왜 AFTER_COMMIT인가
| 옵션 | 언제 실행 | 장점 | 리스크/언제 쓰지 말까 |
| BEFORE_COMMIT | 커밋 직전 | 커밋 전 검증/보조 로직 | 커밋 실패 시 유령 이벤트 가능 |
| AFTER_COMMIT ✅ | 커밋 성공 직후 | DB에 확정된 사실만 전파 | 처리 실패 시 재시도/보상 필요 |
| AFTER_COMPLETION | 성공/실패 모두 이후 | 양쪽 공통 정리 | 롤백된 사실도 흘러감 (도메인 이벤트엔 부적합) |
내 선택: AFTER_COMMIT + @Async
- 이벤트는 커밋된 사실만 흘러야 멘탈 모델이 깔끔
- 비동기로 원 트랜잭션과 장애 격리
5) 진화 타임라인
😵 (1) @EventListener(동기) → 트랜잭션 롤백 유발
😵 (2) @Async 추가 → 커밋 전 조회/순서 꼬임
😬 (3) @TransactionalEventListener(AFTER_COMMIT) → 시점 안정
🙂 (4) DomainEventBridge → 스프링 의존 격리 + 타입 안전 발행
🙂 (5) Envelope(봉투) → 표준 메타데이터 + 추적성 확보
😌 (6) Policy / Sender 분리 → 단일 책임, 테스트/확장성 확보
😎 (7) EventLogger/샘플링 → 운영 관측성 강화 📈
6) Envelope(봉투) — “비즈니스 이벤트는 그대로, 운영 메타데이터는 봉투로”
왜 봉투?
- 이벤트 클래스에 type/occurredAt/correlationId... 섞기 시작하면 침입적이고 변경 파급이 커짐
- 봉투가 메타데이터를 표준화하고, 도메인 페이로드는 그대로 둔다
봉투 필드(우리 기준)
- messageId(ULID) / type(enum 값 문자열) / payload(그대로)
- occurredAt(UTC) / source(서비스명) / correlationId(요청 단위 추적)
{
"messageId": "01J8X2…",
"type": "PAYMENT_CAPTURED",
"occurredAt": "2025-03-08T12:34:56Z",
"source": "payment-service",
"correlationId": "req_abc123",
"payload": { "paymentId": 92134, "orderId": 55120, "amount": 39000 }
}
correlationId 추출 규칙:
HTTP 헤더 X-Correlation-ID가 최우선 → 없으면 세션ID → 백그라운드면 bg_<ULID>
7) Bridge — 스프링에 직접 의존하지 말자
@Slf4j
@Component
@RequiredArgsConstructor
public class DomainEventBridge {
private final ApplicationEventPublisher publisher;
private final EnvelopeFactory envelopeFactory;
// Payment Events
public void publish(PaymentCompletedEvent event) {
Envelope<PaymentCompletedEvent> envelope = envelopeFactory.create(EventType.PAYMENT_COMPLETED, event);
log.debug("Publishing PaymentCompleted event - messageId: {}, paymentId: {}",
envelope.messageId(), event.paymentId());
publisher.publishEvent(envelope);
}
- 도메인 모듈은 ApplicationEventPublisher를 몰라야 함
- DomainEventBridge.publish(PaymentCaptured) 같은 전용 메서드들로 타입 안전 + 로깅 표준화
- 나중에 Kafka로 바꾸면 Bridge만 교체
8) Policy ↔ Sender — 알림을 “비즈니스 결정”과 “전송 I/O”로 분리
[Payment Service]
PaymentCaptured (도메인 이벤트)
│
▼
[Event Layer]
Envelope + Bridge ──(AFTER_COMMIT, @Async)──► NotificationPolicy(순수 결정)
│ └─ template/locale/variables 선택
▼
MessageSendRequested (Integration Event)
│
▼
NotificationSender (I/O) ──► Kakao Alimtalk API
- 왜 분리?
- Policy는 언제/무엇을/어떻게 보낼지 순수 로직 → 단위테스트 쉬움
- Sender는 외부 연동만 → 장애가 도메인/정책을 침식하지 않음
- 새 채널 추가/교체도 Sender에 국한
9) 운영 관측성 — 발행/처리 이중 로그 + 샘플링
- 발행 로그: 📤 type, messageId, correlationId, source, payload(sanitize)
- 처리 로그: ✅ type, messageId, correlationId
- 샘플링: 고빈도(예: Viewed) 10%만
- 효용: 어디서 끊겼나?를 correlationId 한 번으로 추적
10) 📊 이번 주차의 “데이터 플랫폼” 해석 = 알림톡 싱크
“데이터 플랫폼으로 흘린다” = 알림톡으로 내보낸다(이번 주)
[Payment Service] [Event Layer] [Sink = 데이터 플랫폼(알림톡)] PaymentCaptured ──► Envelope + Bridge ──(AFTER_COMMIT)──► NotificationPolicy └─► MessageSendRequested └─► NotificationSender → Kakao API
- 한 갈래 팬아웃: 알림이 곧 데이터 수신처
- 향후에 진짜 분석 싱크(Kafka/S3/DB) 를 붙이면 이때 두 갈래로 확장 가능
11) 스레드 풀/성능/멱등성 — 지금 챙긴 것 & 다음 단계
- 스레드 풀 분리: notificationsExec, likeAggregationExec 등 도메인별로 격리
- 큐 용량/거부 정책: 운영 중 back-pressure에 대비
- 멱등성: messageId 기반 중복 가드(임시) → 다음 주에 Redis/Inbox/Outbox로 영속화 예정
- 순서: 사용자/결제 단위 순서 중요성이 커지면 Kafka 파티션 키로 이관 고려
12) “이벤트를 언제/왜 쓰는가” — 남는 레퍼런스 1장 요약
✅ 이벤트를 쓰는 순간
- 사실(이미 일어난 것)을 전파할 때
- 원 트랜잭션 실패와 격리되어야 할 때
- 즉시성/강결합이 필요하지 않을 때
- 여러 후속 작업(알림/집계/지표)으로 팬아웃할 때
❌ 이벤트를 남발하지 않는 순간
- 호출자가 즉시 결과가 필요할 때 (예: PG 세션키)
- 실패가 원자성을 꼭 깨야 할 때 (예: 쿠폰 사용과 주문 동시 성공/실패)
🧭 타이밍 선택법
- 도메인 사실 전파: AFTER_COMMIT + @Async
- 커밋 실패해도 필요한 정리: AFTER_COMPLETION (도메인 이벤트 X)
13) 자주 물을 것 같은 Q&A (스스로 물었던 것들)
Q. @EventListener면 충분한데 왜 Bridge/Envelope까지?
A. 이벤트는 운영 대상이다. 타입 안전한 발행, 표준 메타데이터, 로깅/추적을 프레임으로 고정해 두면 팀 전체 품질이 일정해진다. 나중에 Kafka 붙일 때도 Bridge만 교체하면 된다.
Q. 왜 꼭 AFTER_COMMIT?
A. “DB에 반영되지 않은 사실”이 흘러나가는 삽질을 두 번 하고 깨달았다. 커밋 확정만 전파해야 디버깅/멘탈 모델이 산다.
Q. 알림 정책을 코드에 하드코딩하면 안 돼?
A. 처음엔 쉬운데, 밤 10시 제한/템플릿 다국어/채널 분기/AB테스트가 붙으면 정책과 전송이 섞여 복잡도가 폭발한다. Policy(결정) ↔ Sender(전송) 분리가 장기적으로 싸게 먹힌다.
14) 마무리 — 내 관점의 변화
- Before: 이벤트 = 옵저버 패턴으로 관심사 분리
- After: 이벤트 = 커밋된 도메인 사실을 표준 메타데이터와 함께 안전하게 전파하는 운영 대상
- 도구 선택보다 중요한 건 “왜 지금 이벤트인가”에 대한 명확한 이유.
- 이번 주차에서 특히, 데이터 플랫폼 싱크 = 알림톡이라는 컨텍스트를 도메인 이벤트 → 봉투 → Policy/Sender로 자연스럽게 연결한 게 가장 큰 수확.
'Loopers' 카테고리의 다른 글
| 🔥 "랭킹 좀 만들어달라는데 Redis 메모리가..." - ZSET 랭킹 시스템 구축기 (1) | 2025.09.11 |
|---|---|
| "같은 주문이 두 번 결제됐습니다" 💸 - Kafka로 배운 분산 시스템의 잔혹한 현실 (1) | 2025.09.05 |
| Resilience와 보상 트랜잭션: 장애에 대응하는 방법 (1) | 2025.08.24 |
| PG가 터져도 우리 서비스는 멀쩡해야 한다 🔥 (3) | 2025.08.22 |
| 인덱스를 걸었는데… 더 느려졌다? 🤯 (4) | 2025.08.15 |