[WIL] - Round7 (1/3). 이벤트 분리 기준
이벤트 기반 아키텍처는 세 축으로 나눠 정리한다.
- 1편 — 이벤트 분리 기준: 어떤 로직을 이벤트로 분리할지, 리스너와 트랜잭션 경계를 어떻게 설계할지
- 2편 — Kafka 파이프라인: Outbox 패턴으로 발행을 보장하고, Consumer에서 멱등 처리·순서 보장·배치·DLQ·2단 구조를 구현하는 방법 (https://kdh0518.tistory.com/186)
- 3편 — 선착순 쿠폰 발급: 비동기 처리 기반 동시성 제어, 단일 파티션 순차 처리, polling 결과 확인 구조 (https://kdh0518.tistory.com/187)
단일 트랜잭션에 모든 로직을 묶는 구조는 단순하지만, 부수효과 하나의 실패가 핵심 로직까지 롤백시키는 문제를 만든다. 이벤트 분리는 이 문제를 해결하는 첫 번째 단계다.
분리 판단 기준
핵심 로직과 부수효과를 나누는 질문
모든 로직을 이벤트로 분리하는 것이 정답은 아니다. 핵심은 '이 로직이 실패했을 때 원래 작업도 실패해야 하는가'를 기준으로 판단하는 일이다.
- 실패 시 원래 작업도 롤백되어야 한다 → 같은 트랜잭션에 묶는다
- 실패해도 원래 작업은 성공이어야 한다 → 이벤트로 분리한다
이 판단을 적용하면 자연스럽게 두 층이 생긴다.
- 핵심 트랜잭션: 데이터 무결성에 직접 영향을 주는 연산
- 부수효과: 핵심 트랜잭션의 결과에 반응하지만, 실패해도 핵심 결과가 바뀌지 않는 연산
주문 생성 플로우에 적용
주문 생성은 여러 연산이 한 트랜잭션에 묶여 있다. 어디서 경계를 나눌지가 핵심이다.
핵심 트랜잭션에 남겨야 하는 연산은 아래와 같다.
- 재고 차감: 재고가 부족하면 주문 자체가 불가능하다
- 쿠폰 적용: 할인 금액이 최종 결제 금액에 직접 영향을 준다 (totalPrice = originalTotalPrice - discountAmount)
- 주문 저장: 주문 데이터 자체가 핵심 결과물이다
이벤트로 분리해야 하는 연산은 아래와 같다.
- 장바구니 정리: 장바구니 삭제에 실패해도 주문은 유효하다. 사용자가 나중에 수동으로 정리할 수 있다
- 유저 행동 로깅: 로그 기록 실패가 주문을 롤백시킬 이유가 없다
쿠폰 적용을 이벤트로 분리하면 안 되는 이유가 중요하다. 쿠폰 적용이 비동기로 처리되면 할인 금액 계산이 주문 저장 시점에 완료되지 않는다. 결제 금액이 확정되지 않은 주문이 저장되는 셈이다.
좋아요 집계 플로우에 적용
좋아요는 두 연산으로 이루어진다.
- 좋아요 저장: 사용자의 좋아요 행위 자체
- 좋아요 수 갱신: 상품의 좋아요 카운터 반영
좋아요 저장은 핵심이다. 좋아요 수 갱신은 부수효과다. 카운터 갱신이 실패해도 좋아요 자체는 성공이어야 한다.
이 판단에서 중요한 것은 '최종 일관성이 허용되는가'다. 좋아요 수는 실시간 정확성보다 대략적인 수치가 중요하다. 수 초에서 수 분 정도의 지연은 사용자 경험에 거의 영향이 없다. 따라서 최종 일관성으로 충분하다.
결제 완료 플로우에 적용
결제 성공 시 발생하는 연산은 아래와 같다.
- 결제 상태 변경 (SUCCESS): 핵심
- 주문 상태 변경 (PAID): 핵심
- 유저 행동 로깅 (PAYMENT): 부수효과
결제와 주문 상태 변경은 같은 트랜잭션에 묶어야 한다. 결제는 성공했는데 주문이 여전히 결제 대기 상태이면 데이터 불일치다. 로깅은 이벤트로 분리한다.
상품 조회 시 VIEW 이벤트
상품 상세 조회는 본래 순수한 읽기 연산이다. 그런데 조회수 집계를 위해 VIEW 이벤트를 기록해야 한다면, 이 조회는 더 이상 순수한 읽기가 아니다.
이런 경우는 '조회와 관측 이벤트 기록이 결합된 유스케이스'로 인식하는 편이 맞다. 조회 트랜잭션에서 이벤트 저장소에 기록을 함께 수행하되, 이벤트 저장이 조회 응답에 영향을 주지 않도록 설계한다.
조회 트랜잭션을 읽기 전용에서 일반 트랜잭션으로 변경해야 하는 이유가 여기에 있다. 이벤트 저장소에 쓰기를 해야 하므로 읽기 전용으로는 동작하지 않는다.
비로그인 사용자도 조회수 집계 대상이 된다. 인증이 선택적이므로 사용자 식별자는 null이 될 수 있다. 이 점을 이벤트 설계에 반영해야 한다.
리스너 설계
트랜잭션 커밋과 리스너 실행 시점
이벤트를 분리한 뒤에는 '원래 트랜잭션이 커밋된 후에 실행할 것인가, 커밋 전에 실행할 것인가'를 결정해야 한다.
커밋 전 실행의 특성은 아래와 같다.
- 이벤트 처리 실패가 원래 트랜잭션을 롤백시킨다
- 이벤트 처리와 원래 작업이 원자적으로 묶인다
- 부수효과를 여기서 처리하면 분리의 의미가 사라진다
커밋 후 실행의 특성은 아래와 같다.
- 원래 트랜잭션은 이미 확정된 상태다
- 이벤트 처리 실패가 원래 작업에 영향을 주지 않는다
- 대신 이벤트 처리 실패 시 복구 수단이 별도로 필요하다
부수효과는 대부분 커밋 후 실행이 맞다.
비동기 실행의 결합
커밋 후 실행만으로는 응답 지연이 남을 수 있다. 이벤트 처리가 동기적으로 실행되면 처리 완료까지 응답이 지연되기 때문이다.
비동기 실행을 결합하면 이벤트 발행 즉시 응답을 반환하고, 이벤트 처리는 별도 스레드에서 진행된다. 이 조합이 가장 일반적인 부수효과 처리 패턴이다.
예시 코드
@TransactionalEventListener(phase = AFTER_COMMIT)
@Async
void handleCartCleanup(OrderCreatedEvent event) {
cartCleaner.deleteCartItems(event.userId(), event.cartItemIds());
}비동기 실행을 위해서는 스레드 풀 설정이 필요하다. 기본 설정 없이 사용하면 무제한 스레드가 생성될 수 있다. 코어 크기, 최대 크기, 큐 용량을 명시적으로 설정하는 편이 안전하다.
이벤트 처리 실패 시 대응
커밋 후 비동기로 실행되는 이벤트는 실패해도 원래 작업에 영향을 주지 않는다. 하지만 실패 자체를 무시하면 안 된다.
장바구니 정리가 실패한 경우를 보면, 사용자의 장바구니에 이미 주문된 항목이 남아 있게 된다. 치명적이지는 않지만 사용자 경험이 좋지 않다.
대응 방식은 두 가지가 있다.
- 리스너 내부에서 예외를 잡고 로그를 남긴다. 운영자가 수동 확인한다
- Kafka 파이프라인으로 전환하여 재시도를 보장한다
단순한 부수효과는 전자로 충분하고, 비즈니스적으로 중요한 부수효과는 후자가 맞다.
이벤트 클래스 설계
이벤트가 담아야 하는 정보
이벤트는 수신 측이 필요한 정보를 자체적으로 보유해야 한다. 수신 측이 추가 조회를 해야 한다면 이벤트 설계가 부족한 것이다.
주문 생성 이벤트를 예로 보면 아래 정보가 필요하다.
- 주문 ID, 사용자 ID: 누가 어떤 주문을 했는지
- 장바구니 항목 ID 목록: 장바구니 정리에 필요
- 주문 항목 스냅샷 (상품 ID, 수량): Kafka 파이프라인에서 판매량 집계에 필요
- 총액, 발생 시각: 로깅에 필요
결제 완료 이벤트는 판매량 집계를 위해 주문 항목 정보가 필수다. 결제 시점에 주문 항목을 조회하는 비용이 발생하지만, 이벤트에 포함하지 않으면 모든 수신 측이 각자 조회해야 하므로 비용이 더 크다. 결제 실패 시에는 주문 항목 조회를 하지 않아 불필요한 비용을 방지한다.
팩토리 메서드 패턴
이벤트 생성 로직이 발행 측에 흩어지면 일관성을 유지하기 어렵다. 이벤트 클래스 자체에 팩토리 메서드를 두면 생성 책임이 한 곳에 모인다.
예시 코드
public static OrderCreatedEvent from(Order order, List<Long> cartItemIds) {
List<OrderItemSnapshot> items = order.getItems().stream()
.map(item -> new OrderItemSnapshot(item.getProductId(), item.getQuantity()))
.toList();
return new OrderCreatedEvent(
order.getId(), order.getUserId(), order.getRequestId(),
cartItemIds, items, order.getTotalPrice(), LocalDateTime.now()
);
}발생 시각은 팩토리 메서드 내부에서 생성한다. 발행 측이 시각을 직접 전달하면 실수로 잘못된 시각이 들어갈 수 있다.
이벤트 Javadoc과 흐름 추적
이벤트 기반 아키텍처의 가장 큰 단점은 흐름이 코드에 명시적으로 드러나지 않는다는 것이다. 메서드 호출은 IDE에서 바로 추적할 수 있지만, 이벤트 발행과 수신은 간접적이다.
이 문제를 완화하기 위해 두 가지 규칙을 적용한다.
- 이벤트 클래스의 Javadoc에 모든 수신자를 명시한다
- 발행 측 코드에 인라인 주석으로 어떤 수신자가 반응하는지 기록한다
이벤트가 Kafka 파이프라인으로 전환되면 in-process 리스너 대신 Kafka Consumer가 수신자가 된다. 이때 Javadoc도 함께 업데이트해야 한다. 삭제된 리스너가 Javadoc에 남아 있으면 코드를 읽는 사람이 혼란스러워진다.
트랜잭션 경계 변화
변경 전후 비교
이벤트 분리 전과 후의 트랜잭션 경계 변화를 정리하면 아래와 같다.
주문 생성의 경우 아래처럼 바뀐다.
- 변경 전: 재고 차감 + 쿠폰 적용 + 주문 저장 + 장바구니 삭제가 하나의 트랜잭션
- 변경 후: 재고 차감 + 쿠폰 적용 + 주문 저장만 하나의 트랜잭션. 장바구니 삭제는 커밋 후 비동기
좋아요의 경우 아래처럼 바뀐다.
- 변경 전: 좋아요 저장 + 좋아요 수 동기 갱신이 하나의 트랜잭션
- 변경 후: 좋아요 저장만 트랜잭션. 좋아요 수는 최종 일관성
점진적 전환 전략
한꺼번에 모든 경로를 바꾸면 실패 범위가 넓어진다. 단계적으로 전환하는 편이 안전하다.
- Step 1에서는 in-process 이벤트로 분리만 한다. 좋아요 수는 여전히 같은 애플리케이션 내에서 이벤트 리스너가 처리한다
- Step 2에서는 Kafka 파이프라인으로 전환한다. in-process 리스너를 제거하고 Kafka Consumer가 처리한다
이 방식의 장점은 Step 1에서 이벤트 분리의 효과를 먼저 확인하고, Step 2에서 시스템 간 전파로 확장할 수 있다는 점이다. Step 1에서 문제가 발생하면 Step 2 진행 전에 수정할 수 있다.
정리
이벤트 분리의 판단 기준은 결국 하나다.
'이 로직이 실패해도 원래 작업은 성공이어야 하는가'
이 질문에 예라고 답할 수 있으면 이벤트로 분리한다. 분리한 뒤에는 커밋 후 비동기 실행이 기본이고, 이벤트 클래스에 수신 측이 필요한 모든 정보를 담는다. 흐름 추적이 어려워지는 문제는 Javadoc과 인라인 주석으로 보완한다.