Loopers

🚨 “그냥 @EventListener면 끝?” — 이벤트, 언제·왜·어떻게 사용할 것인가 ⚙️

그zi운아이 2025. 8. 29. 15:30

TL;DR
“관심사 분리”용으로 가볍게 시작했다가, 도메인 사실을 안전하게 전파하고 추적 가능한 메타데이터를 더해 운영/관측성까지 챙기는 구조로 진화.
핵심 선택은 AFTER_COMMIT + @Async + Envelope + Bridge + Policy/Sender 분리.
이번 과제에서 데이터 플랫폼 싱크 = 알림톡이었고, 도메인 이벤트(PaymentCaptured)가 **봉투(Envelope)**로 표준화되어 알림 정책 → 전송자를 통해 **단일 싱크(알림톡)**로 흘러가게 설계했다.


 

1) 처음 생각: “@EventListener 붙이면 관심사 분리 완료”

처음엔 정말 이렇게 시작했다. 도메인 로직(주문/결제) 뒤에 알림/집계/로그를 리스너로 뽑아내면 끝이라 믿었음.

[Controller/Service]  →  (도메인 상태 변화)
                         ↳ @EventListener들(알림/통계/캐시 등)

그런데 바로 맞은 세 가지 벽

  1. 트랜잭션 영향: 리스너에서 예외 터지면 원 트랜잭션 롤백
  2. 이벤트 순서/시점: 비동기/스레드 풀에 따라 발생 순서 ≠ 처리 순서
  3. 디버깅 불가: “이벤트 발행됐니?”, “어디서 누락?”을 추적할 표준 메타데이터가 없음

여기서 관점이 바뀜:
이벤트 = 콜백 수단이 아니라 **“과거에 일어난 사실(Fact)을 안전하게 전파하는 매체”여야 한다.


2) 이벤트 재정의 — “사실(Fact)이며, 시간(Time)을 가진다”

도메인 이벤트는 이런 성질을 가져야 한다.

  • 과거형 이름: PaymentCaptured, OrderCreated
  • 불변: 이미 일어난 사실
  • 시간: occurredAt이 명확해야 순서를 논할 수 있음
  • 추적: messageId, correlationId, source 같은 표준 메타데이터

📦 빠르게 참조하는 정의 박스 

  • Domain Event: 비즈니스 의미 있는 상태 변화(PaymentCaptured)
  • Integration Event: 시스템 간 통신용(MessageSendRequested)
  • System Event: 기술적 상태 변화(CacheInvalidated)
  • 원칙: 도메인 이벤트는 도메인 언어로, 메타데이터는 봉투(Envelope)가 책임

3) 언제 이벤트를 쓰고, 언제 동기로 부른다? (결정 트리)

✅ 결정 트리 (내가 실제로 쓴 것)

  1. 명령(Do) vs 사실(Happened)
  • “무언가 해라” → 동기 호출
  • “무언가가 일어났다” → 이벤트 후보
  1. 즉시성 필요?
  • 호출자가 지금 결과를 바로 써야 함 → 동기
  • 지연 가능/나중 일관 → 이벤트
  1. 실패 전파/롤백 필요?
  • 실패를 바로 알려야 하거나 원자성 필요 → 동기
  • 실패가 원 트랜잭션에 영향 주면 안 됨이벤트

🌰 사례로 딱 감 잡기

  • 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로 자연스럽게 연결한 게 가장 큰 수확.