이벤트를 발행하는 건 쉬웠다 — 어디서 끊을지 판단하는 게 어려웠다
TL;DR: 이커머스에 이벤트 기반 아키텍처를 도입했다. 이벤트를 발행하는 코드는 한 줄이었지만,
@EventListener와@TransactionalEventListener중 뭘 쓸지, DB 커밋과 Kafka 발행 사이의 이벤트 유실을 어떻게 막을지, 선착순 쿠폰에서 중복 발급을 어떻게 막을지 — 진짜 어려운 건 “경계”를 정하는 일이었다.
1. 왜 이벤트가 필요했나
주문 취소 하나에 재고 복구, 쿠폰 복원, PG 환불이 엮여 있었다. 코드로 보면 이랬다.
@Transactional
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId);
order.cancel();
stockService.restore(order); // 재고 복구
couponService.restore(order); // 쿠폰 복원
pgClient.refund(order); // PG 환불 ← 외부 API
}
이 코드의 문제는 PG 환불이 5초 타임아웃을 내면 재고 복구와 쿠폰 복원까지 롤백된다는 것이다. PG가 장애면 주문 취소 자체가 불가능해진다. 외부 시스템 하나가 내부 비즈니스 로직 전체를 인질로 잡는 구조다.
이벤트 기반 아키텍처를 도입한 이유는 간단하다. “실패해도 괜찮은 것”과 “반드시 함께 성공해야 하는 것”을 분리하기 위해서.
2. 첫 번째 판단 — 같은 트랜잭션 vs 커밋 후 분리
Spring에서 이벤트 리스너는 두 가지다.
@EventListener: 같은 트랜잭션에서 동기 실행. 리스너가 실패하면 원래 트랜잭션도 롤백.@TransactionalEventListener(AFTER_COMMIT): 커밋 성공 후 실행. 리스너가 실패해도 원래 트랜잭션은 이미 커밋됨.
어느 것을 쓸지는 “이 로직이 실패하면 원래 작업도 실패해야 하는가?”로 결정했다.
| 로직 | 실패 시 주문 취소도 실패해야 하는가? | 리스너 선택 |
|---|---|---|
| 재고 복구 | 예 — 복구 안 되면 재고 불일치 | @EventListener (같은 TX) |
| 쿠폰 복원 | 예 — 복원 안 되면 쿠폰 유실 | @EventListener (같은 TX) |
| PG 환불 | 아니오 — 환불 실패해도 취소는 성공해야 함 | @TransactionalEventListener (AFTER_COMMIT) |
// 핵심 로직 — 같은 트랜잭션 (데이터 정합성 필수)
@EventListener
public void handle(OrderCancelledEvent event) {
restoreStock(event);
restoreCoupon(event);
}
// 부가 로직 — 커밋 후 별도 처리 (외부 API, 재시도 가능)
@TransactionalEventListener(phase = AFTER_COMMIT)
public void handle(OrderCancelledEvent event) {
refundPaymentUseCase.refundPayment(event.orderId());
}
이렇게 나누니까 PG가 10초간 장애를 내도 주문 취소는 정상적으로 완료되고, 재고와 쿠폰은 정확하게 복구된다. PG 환불은 나중에 재시도하면 된다.
3. 좋아요 집계에서 배운 것 — 집계 실패가 좋아요를 롤백시켰다
비슷한 문제가 좋아요에서도 터졌다.
// 처음 코드
@EventListener // ← 같은 트랜잭션
public void handle(ProductLikedEvent event) {
productRepository.incrementLikeCount(event.productId());
}
incrementLikeCount에서 예외가 발생하면 좋아요 등록 자체가 롤백된다. 사용자 입장에서는 하트를 눌렀는데 아무 반응이 없는 상황이다. 좋아요 수 집계가 1초 늦어도 비즈니스에 문제없지만, 좋아요 자체가 안 되면 UX가 깨진다.
// 수정 후
@Transactional // 독립 트랜잭션 (AFTER_COMMIT이므로 기존 TX 밖)
@TransactionalEventListener(phase = AFTER_COMMIT)
public void handle(ProductLikedEvent event) {
productRepository.incrementLikeCount(event.productId());
}
핵심 변경은 두 가지다.
AFTER_COMMIT→ 좋아요가 커밋된 후에 실행. 집계가 실패해도 좋아요는 성공.@Transactionalon method → AFTER_COMMIT 핸들러는 기존 트랜잭션이 끝난 뒤 실행되므로, 별도 트랜잭션을 열어야 DB에 쓸 수 있다.
이 경험에서 얻은 기준이 하나 생겼다.
“이 부가 로직이 실패해서 사용자의 핵심 행위가 취소되는 게 말이 되는가?”
말이 안 되면 AFTER_COMMIT이다.
4. Outbox Pattern — DB는 커밋됐는데 Kafka는 실패하면?
좋아요가 성공하면 Kafka를 통해 product_metrics의 좋아요 수를 갱신해야 한다. 처음에는 단순하게 했다.
1. BEGIN TRANSACTION
2. INSERT INTO likes (...) ← DB 저장
3. COMMIT ← 성공
4. kafkaTemplate.send(...) ← 네트워크 타임아웃 → 이벤트 유실!
DB 커밋은 됐는데 Kafka 발행이 실패하면, 좋아요는 등록됐지만 메트릭스에는 반영 안 된다. 반대로 Kafka를 트랜잭션 안에 넣으면, Kafka 장애 시 좋아요 자체가 실패한다. 어느 쪽이든 문제다.
이걸 Transactional Outbox Pattern으로 풀었다.
[같은 트랜잭션 — 원자성 보장]
1. BEGIN TRANSACTION
2. INSERT INTO likes (...) ← 비즈니스 데이터
3. INSERT INTO outbox_events (...) ← 이벤트를 DB 테이블에 저장
4. COMMIT ← 둘 다 성공하거나 둘 다 실패
[별도 프로세스 — OutboxEventPublisher @Scheduled(1초)]
5. SELECT * FROM outbox_events WHERE status = 'PENDING'
6. kafkaTemplate.send(topic, key, message)
7. UPDATE outbox_events SET status = 'PUBLISHED'
→ 실패 시 다음 폴링에서 재시도 → At Least Once 보장
비즈니스 데이터와 이벤트가 같은 DB 트랜잭션에 저장되므로 “데이터는 있는데 이벤트는 없는” 상태가 불가능하다. 발행은 별도 프로세스가 폴링으로 처리하고, 실패하면 다음 번에 재시도한다.
대안으로 Debezium CDC를 검토했지만, 학습 목적과 현재 규모에서는 폴링이 적절하다고 판단했다. 1초 지연은 product_metrics 갱신에서 허용 가능한 수준이다.
어떤 이벤트를 Kafka로 보내는가?
이벤트를 무조건 Kafka로 보내는 게 아니다. “다른 시스템(commerce-streamer)이 이 이벤트를 알아야 하는가?”로 판단했다.
| 이벤트 | ApplicationEvent | Kafka | 판단 근거 |
|---|---|---|---|
| ProductLikedEvent | O | O | 좋아요 수 → product_metrics (다른 시스템) |
| OrderCreatedEvent | O | O | 판매량 → product_metrics (다른 시스템) |
| OrderCancelledEvent | O | X | 재고/쿠폰 복구는 같은 시스템 내부 처리 |
| UserActivityEvent | O | X | 로그 기록은 같은 시스템에서 완결 |
5. 선착순 쿠폰 — Redis INCR만 믿으면 중복이 난다
선착순 쿠폰 발급은 Kafka를 통한 비동기 처리로 설계했다.
사용자 → POST /coupons/{id}/issue
→ 202 Accepted (requestId 반환, 즉시 응답)
→ Outbox → Kafka → CouponIssueConsumer → 실제 발급
→ GET /coupons/{id}/issue-status (폴링으로 결과 확인)
문제는 Consumer에서의 중복 방어였다. Kafka는 At Least Once를 보장하므로, 같은 메시지가 두 번 올 수 있다. 게다가 같은 사용자가 버튼을 연타하면 여러 요청이 들어온다.
처음에는 Redis INCR로 수량만 체크했다.
// 처음 코드 — 구멍이 있다
Long count = redisTemplate.opsForValue().increment(key);
if (count > maxIssuance) {
redisTemplate.opsForValue().decrement(key); // 롤백
return;
}
userCouponRepository.save(...); // 발급
이것만으로는 같은 유저가 2개 받는 걸 막지 못한다. Kafka 메시지가 2번 도착하면 INCR이 2번 성공하고, 같은 유저에게 쿠폰이 2장 발급된다.
3중 방어로 해결
// Layer 1: Kafka 메시지 멱등 — 같은 eventId 재처리 방지
if (eventHandledRepository.existsById(eventId)) return;
// Layer 2: 유저 중복 방지 — 이미 발급받은 유저 차단
if (userCouponRepository.existsByCouponIdAndUserId(couponId, userId)) {
request.markRejected("Already issued");
return;
}
// Layer 3: 수량 제한 — Redis INCR 원자적 카운터
Long count = redisTemplate.opsForValue().increment(key);
if (count > maxIssuance) {
redisTemplate.opsForValue().decrement(key);
request.markRejected("Quota exceeded");
return;
}
// 모든 체크 통과 → 발급
userCouponRepository.save(...);
request.markSuccess();
| Layer | 방어 대상 | 수단 |
|---|---|---|
| 1 | Kafka 재전송 (같은 메시지 2회) | event_handled 테이블 (eventId PK) |
| 2 | 같은 유저 연타 (다른 메시지, 같은 유저) | user_coupons 유니크 제약 사전 체크 |
| 3 | 총 발급 수량 초과 | Redis INCR 원자적 카운터 |
동시성 테스트로 검증했다: 200명 동시 요청 + 100개 수량 제한 → 정확히 100개만 발급, 같은 유저 5번 요청 → 1장만 발급.
6. 안티패턴 6개를 고쳤다
PR을 올리고 나서 코드 리뷰에서, 그리고 스스로 돌아보면서 발견한 안티패턴들이 있었다.
6-1. 클래스 레벨 @Transactional
// Before — 읽기 전용 메서드도 트랜잭션이 열린다
@Service
@Transactional
public class OrderService { ... }
// After — 쓰기 메서드에만 명시
@Service
public class OrderService {
@Transactional
public void createOrder(...) { ... }
// 읽기 메서드는 @Transactional 없음
public Order getOrder(...) { ... }
}
13개 서비스에서 클래스 레벨 @Transactional을 제거했다. 불필요한 트랜잭션은 커넥션 점유 시간을 늘린다.
6-2. Consumer의 protected @Transactional이 동작 안 함
// Before — Spring 프록시가 protected 메서드를 가로채지 못함
@Transactional
protected void processRecord(ConsumerRecord<...> record) { ... }
// After — TransactionTemplate으로 프로그래밍 방식 트랜잭션
records.forEach(record ->
transactionTemplate.executeWithoutResult(status -> processRecord(record))
);
@Transactional은 Spring AOP 프록시 기반이라 public 메서드에서만 동작한다. protected에 붙이면 트랜잭션 없이 실행된다. 3개 Consumer에서 모두 TransactionTemplate으로 교체했다.
6-3. Outbox 루프에서 break → continue
// Before — 하나 실패하면 나머지 전부 발행 중단
for (OutboxJpaEntity event : pendingEvents) {
try { send(event); }
catch (Exception e) { break; } // ← 전체 중단!
}
// After — 실패한 건만 건너뛰고 계속 진행
for (OutboxJpaEntity event : pendingEvents) {
try { send(event); }
catch (Exception e) {
log.warn("발행 실패: {}", event.getId());
continue; // ← 나머지는 계속 발행
}
}
이벤트 A의 발행 실패가 이벤트 B, C, D의 발행까지 막으면 안 된다. A는 다음 폴링에서 재시도하면 된다.
7. 이번 주에 가장 많이 바뀐 사고방식
이벤트를 도입하기 전에는 모든 로직이 하나의 @Transactional 안에 있었다. 재고 차감, 쿠폰 검증, 주문 저장, PG 결제, 이벤트 발행 — 전부 한 트랜잭션이다. 하나가 실패하면 전부 롤백된다. 단순하고 안전해 보였다.
하지만 외부 시스템이 하나만 추가되면 이 “단순함”은 “취약함”이 된다. PG 타임아웃 하나가 모든 걸 멈춘다.
이벤트 기반 아키텍처를 도입하면서 배운 건 “모든 걸 한 번에 성공시키려 하지 마라”는 것이다. 핵심은 같은 트랜잭션에서 확실히 성공시키고, 부가적인 것은 커밋 후에 최선을 다해 처리하되 실패하면 재시도한다. 이것이 Eventually Consistent의 본질이라는 걸 코드로 체감했다.
Before: "모든 게 성공하거나, 모든 게 실패하거나"
After: "핵심은 반드시 성공. 부가는 최선을 다하되, 실패하면 나중에"
이게 “이벤트를 발행하는 건 쉬웠다”고 한 이유다. 진짜 어려운 건 어디까지를 “핵심”으로 볼 것인가, 어디서부터 “부가”로 분리할 것인가를 판단하는 일이었다.
참고
- 우아한형제들 - 회원시스템 이벤트기반 아키텍처 구축하기 — 3가지 이벤트 종류와 3가지 구독자 계층 정의
- 우아한형제들 - 잊을만 하면 돌아오는 정산 신병들 — 대규모 정산 배치와 이벤트 흐름
- Spring Docs - Application Events — @EventListener, @TransactionalEventListener 공식 문서