트랜잭션을 디커플링 하는 것은 단순히 성능 때문이 아니라, 장애 격리와 시스템 안정성을 위해서이다. 쿠폰 서비스가 느려진다고 주문 생성까지 느려지면 안된다. 격리를 위해 이벤트 기반으로 분리했다.
처음에는 이렇게 구현했다. 로컬에서 돌려보니 잘 작동했다. 주문도 생성되고, 쿠폰도 사용되고, 할인도 잘 적용됐다.
fun createOrder(userId: Long, request: OrderCreateRequest): OrderCreateInfo {
val totalAmount = calculateTotalAmount(orderItems)
// 쿠폰 사용 (동기)
val discountAmount = applyCoupon(userId, request.couponId, totalAmount)
val finalAmount = totalAmount - discountAmount
val order = orderService.createOrder(userId, orderItems)
return OrderCreateInfo.from(order)
}
private fun applyCoupon(userId: Long, couponId: Long?, totalAmount: Money): Money {
if (couponId == null) return Money.ZERO
// 쿠폰 사용 처리 (비관적 락 사용)
val userCoupon = couponService.useUserCoupon(userId, couponId)
return userCoupon.coupon.calculateDiscount(totalAmount)
}
구현 후 부하테스트를 해보니 타임아웃 문제를 겪기 시작했다.
# 동시 100명이 주문 생성
ab -n 100 -c 100 http://localhost:8080/api/v1/orders
결과:
"쿠폰 서비스가 느려지니 주문까지 느려지잖아?"
로그를 확인하니 패턴이 보였다:
14:30:10 [http-nio-200] 쿠폰 사용 시작: userId=1, couponId=100
14:30:11 [http-nio-200] 비관적 락 대기 중... (1초)
14:30:12 [http-nio-200] 비관적 락 대기 중... (2초)
14:30:13 [http-nio-200] 쿠폰 사용 완료
14:30:13 [http-nio-200] 주문 생성 완료 (총 소요 시간: 3초)
문제 분석:
| 시점 | 상황 | 영향 |
|---|---|---|
| 동시 주문 100개 | 같은 쿠폰 사용 시도 | 비관적 락 경합 |
| 락 대기 시간 증가 | 쿠폰 처리 지연 | 주문 생성도 지연 |
| 트랜잭션 길어짐 | DB 커넥션 점유 시간 증가 | 전체 시스템 성능 저하 |
결론: "하나의 트랜잭션에 다 넣으면 안된다."
현재 흐름을 보니 모든 것이 동기적으로 처리되고 있었다:
현재 주문 생성 흐름:
createOrder()
├── 재고 차감 (필수)
├── 쿠폰 사용 (필수?)
├── 포인트 차감 (필수)
└── 주문 저장 (필수)
"쿠폰 사용이 지금 당장 필요할까?"
다시 생각해보니:
| 항목 | 지금 필요한가? | 이유 |
|---|---|---|
| 재고 차감 | ✅ 필수 | 재고가 없으면 주문 불가 |
| 할인 계산 | ✅ 필수 | 최종 금액 결정 |
| 쿠폰 사용 | ⚠️ 나중에 가능 | 할인 계산만 하면 됨 |
| 포인트 차감 | ✅ 필수 | 결제 금액 확정 |
"쿠폰은 검증만 하고, 실제 사용은 나중에 하면 되겠네!"
결정했다.
"지금 꼭 해야 하는 것"과 "조금 나중에 해도 되는 것"을 분리한다.
트랜잭션을 나누는 도구로 이벤트를 사용하기로 했다.
Command (명령):
Event (이벤트):
| 항목 | Command | Event |
|---|---|---|
| 의미 | "~을 해라" (명령) | "~이 발생했다" (통지) |
| 흐름 제어 | 호출자가 제어 | 호출자는 모름 |
| 실패 영향 | 전체 롤백 | 격리됨 |
Spring은 이벤트 기반 구조를 제공한다:
| 구성 요소 | 역할 |
|---|---|
ApplicationEventPublisher | 이벤트 발행 |
@EventListener | 이벤트 수신 |
@TransactionalEventListener | 트랜잭션 커밋 후 실행 |
@Async | 비동기 실행 |
핵심: @TransactionalEventListener(phase = AFTER_COMMIT)
이 어노테이션은 트랜잭션이 성공적으로 커밋된 후에만 이벤트를 처리한다.
// 주문 생성 (메인 트랜잭션)
@Transactional
fun createOrder(...) {
val order = orderService.createOrder(...)
eventPublisher.publishEvent(OrderCreatedEvent.from(order, couponId))
// 여기서 커밋되면...
}
// 쿠폰 사용 (별도 트랜잭션)
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
fun handleOrderCreatedForCoupon(event: OrderCreatedEvent) {
event.couponId?.let { couponId ->
couponService.useUserCoupon(event.userId, couponId)
}
}
"쿠폰 사용은 주문이 커밋된 후에 처리한다"
이 문장이 얼마나 중요한지 처음엔 몰랐다.
시나리오: 일반 @EventListener 사용
// 주문 생성
@Transactional
fun createOrder(...) {
val order = orderService.createOrder(...)
eventPublisher.publishEvent(OrderCreatedEvent.from(order, couponId))
// 여기서 이벤트가 즉시 발행됨
// 만약 여기서 예외 발생?
throw RuntimeException("재고 부족!")
}
// 쿠폰 사용 (일반 EventListener)
@EventListener
fun handleOrderCreatedForCoupon(event: OrderCreatedEvent) {
couponService.useUserCoupon(event.userId, event.couponId)
// 이미 쿠폰 사용됨!
}
문제:
1. 주문 생성 시작
↓
2. 이벤트 발행 (OrderCreatedEvent)
↓
3. 쿠폰 즉시 사용 (별도 트랜잭션에서)
↓
4. 주문 생성 실패 (재고 부족으로 롤백)
↓
❌ 결과: 주문은 없는데 쿠폰만 사용됨
사용자 관점:
"이건 심각한 데이터 정합성 문제다..."
// 주문 생성
@Transactional
fun createOrder(...) {
val order = orderService.createOrder(...)
eventPublisher.publishEvent(OrderCreatedEvent.from(order, couponId))
// 이벤트는 발행되지만, 핸들러는 아직 실행 안 됨
// 만약 여기서 예외 발생?
throw RuntimeException("재고 부족!")
// → 롤백되면서 이벤트도 발행 취소!
}
// 쿠폰 사용 (AFTER_COMMIT)
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
fun handleOrderCreatedForCoupon(event: OrderCreatedEvent) {
// 주문이 성공적으로 커밋된 후에만 실행됨
couponService.useUserCoupon(event.userId, event.couponId)
}
정상 흐름:
1. 주문 생성 시작
↓
2. 이벤트 발행 예약 (OrderCreatedEvent)
↓
3. 주문 생성 성공
↓
4. 트랜잭션 커밋 ✅
↓
5. 커밋 후 쿠폰 사용 실행
↓
✅ 결과: 주문도 있고, 쿠폰도 사용됨
실패 흐름:
1. 주문 생성 시작
↓
2. 이벤트 발행 예약
↓
3. 재고 부족 예외 발생
↓
4. 트랜잭션 롤백 ❌
↓
5. 이벤트 발행 취소 (쿠폰 사용 안 됨)
↓
✅ 결과: 주문도 없고, 쿠폰도 안 사용됨 (정합성 유지!)
AFTER_COMMIT이 보장하는 것들:
| 보장 항목 | 설명 |
|---|---|
| 원자성 | 주문이 롤백되면 쿠폰 사용도 안 됨 |
| 데이터 정합성 | 실제로 존재하는 주문에 대해서만 쿠폰 사용 |
| 사용자 신뢰 | "주문 실패했는데 쿠폰만 빠졌어요" 문제 없음 |
| 재시도 안전성 | 커밋 전 실패는 이벤트 미발행 |
"주문이 성공했을 때만 후속 처리한다"
이게 AFTER_COMMIT의 핵심이다.
시나리오 1: DB 커넥션 타임아웃
@Transactional
fun createOrder(...) {
val order = orderService.createOrder(...) // 성공
eventPublisher.publishEvent(OrderCreatedEvent.from(order, couponId))
// DB 커넥션 타임아웃 발생
throw QueryTimeoutException("Connection timeout")
}
시나리오 2: 동시성 문제로 롤백
@Transactional
fun createOrder(...) {
val order = orderService.createOrder(...)
eventPublisher.publishEvent(OrderCreatedEvent.from(order, couponId))
// 낙관적 락 예외
throw OptimisticLockException("Version mismatch")
}
시나리오 3: 비즈니스 검증 실패
@Transactional
fun createOrder(...) {
val order = orderService.createOrder(...)
eventPublisher.publishEvent(OrderCreatedEvent.from(order, couponId))
// 주문 금액 검증
if (order.totalAmount < 0) {
throw IllegalArgumentException("Invalid amount")
}
}
현재 OrderEventHandler 구현:
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleOrderCreatedForCoupon(event: OrderCreatedEvent) {
event.couponId?.let { couponId ->
try {
couponService.useUserCoupon(event.userId, couponId)
logger.info("쿠폰 사용 완료: orderId=${event.orderId}")
} catch (e: Exception) {
// 이 시점에는 주문은 이미 커밋된 상태
// 쿠폰 사용 실패는 주문에 영향 없음
logger.error("쿠폰 사용 실패: orderId=${event.orderId}", e)
}
}
}
핵심 포인트:
phase = TransactionPhase.AFTER_COMMIT
@Async와의 조합
try-catch 위치의 의미
"주문의 완결성"이란:
주문 트랜잭션의 성공/실패에 따라
후속 처리도 함께 결정되는 것
AFTER_COMMIT 없이는 이 완결성을 보장할 수 없다.
처음 이벤트를 사용할 때 헷갈렸던 부분이 있다.
"@EventListener와 @TransactionalEventListener, 뭐가 다른 거지?"
둘 다 이벤트를 처리하는데, 왜 두 개나 있을까?
@EventListener
fun handleUserAction(event: UserActionEvent) {
logger.info("[USER_ACTION] ${event.actionType}")
// 바로 실행됨
}
특징:
언제 사용?
@TransactionalEventListener(phase = AFTER_COMMIT)
fun handleOrderCreatedForCoupon(event: OrderCreatedEvent) {
couponService.useUserCoupon(event.userId, event.couponId)
// 주문 트랜잭션이 커밋된 후에만 실행됨
}
특징:
언제 사용?
"결제가 완료되면 주문 상태를 업데이트한다"
이건 어떤 리스너를 써야 할까?
첫 번째 시도: @Async + @TransactionalEventListener
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
fun handlePaymentCompleted(event: PaymentCompletedEvent) {
val order = orderRepository.findById(event.orderId)
order.confirm()
orderRepository.save(order)
}
문제:
두 번째 시도: @EventListener + @Transactional(REQUIRES_NEW)
@EventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun handlePaymentCompleted(event: PaymentCompletedEvent) {
val order = orderRepository.findById(event.orderId)
order.confirm()
orderRepository.save(order)
}
해결:
| 항목 | @EventListener | @TransactionalEventListener |
|---|---|---|
| 실행 시점 | 즉시 | 커밋 후 |
| 트랜잭션 | 같은 트랜잭션 | 별도 가능 |
| 롤백 시 | 같이 롤백 | 이벤트 발행 안 됨 |
| 용도 | 로깅, 즉시 처리 | 후속 처리 |
"언제 뭘 써야 하지?"
| 시나리오 | 선택 | 이유 |
|---|---|---|
| 사용자 행동 로깅 | @EventListener | 트랜잭션 성공/실패 무관 |
| 쿠폰 사용 | @TransactionalEventListener + @Async | 주문 커밋 후, 비동기 처리 |
| 주문 상태 업데이트 | @EventListener + REQUIRES_NEW | 즉시 반영, 별도 트랜잭션 |
| 외부 API 호출 | @TransactionalEventListener + @Async | 커밋 후, 비동기 처리 |
쿠폰 사용은 분리했다. 그런데 또 다른 문제가 있었다.
"결제가 완료되면 주문 상태를 어떻게 업데이트하지?"
현재 흐름:
1. 주문 생성 (OrderFacade)
└─> 주문 저장 (status: PENDING)
2. 결제 요청 (PaymentFacade)
└─> PG 결제 시작
3. PG 콜백 (PaymentService)
└─> 결제 상태 업데이트 (status: COMPLETED)
4. ??? → 주문 상태를 CONFIRMED로 어떻게?
가장 간단한 방법:
@Transactional
fun handlePaymentCallback(transactionKey: String, status: TransactionStatusDto) {
val payment = paymentRepository.findByTransactionKey(transactionKey)
when (status) {
SUCCESS -> {
payment.complete()
// 주문 상태 직접 업데이트
val order = orderRepository.findById(payment.orderId)
order.confirm()
orderRepository.save(order)
}
}
}
문제:
"결제와 주문은 별개의 도메인인데, 왜 같은 트랜잭션에?"
PaymentService:
@Transactional
fun handlePaymentCallback(transactionKey: String, status: TransactionStatusDto, reason: String?) {
val payment = paymentRepository.findByTransactionKey(transactionKey)
when (status) {
SUCCESS -> {
payment.complete(reason)
// 이벤트 발행
eventPublisher.publishEvent(PaymentCompletedEvent.from(payment))
}
FAILED -> {
payment.fail(reason ?: "결제 실패")
// 실패 이벤트도 발행
eventPublisher.publishEvent(PaymentFailedEvent.from(payment))
}
}
paymentRepository.save(payment)
}
OrderEventHandler:
@Component
class OrderEventHandler(
private val orderRepository: OrderRepository,
) {
@EventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun handlePaymentCompleted(event: PaymentCompletedEvent) {
try {
logger.info("주문 상태 업데이트 시작: orderId=${event.orderId}")
val order = orderRepository.findById(event.orderId)
?: throw CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다")
order.confirm()
orderRepository.save(order)
logger.info("주문 상태 업데이트 완료: orderId=${event.orderId}, status=${order.status}")
} catch (e: Exception) {
logger.error("주문 상태 업데이트 실패: orderId=${event.orderId}", e)
throw e
}
}
}
핵심 포인트:
@EventListener (not @TransactionalEventListener)
@Transactional(propagation = REQUIRES_NEW)
예외를 throw
Before (직접 호출):
[결제 콜백 트랜잭션]
├── 결제 상태 업데이트
├── 주문 상태 업데이트 (같은 트랜잭션)
└── 커밋
문제:
After (이벤트 기반):
[결제 콜백 트랜잭션]
├── 결제 상태 업데이트
├── 이벤트 발행
└── 커밋
↓
[주문 업데이트 트랜잭션] (REQUIRES_NEW)
├── 주문 조회
├── 주문 확정
└── 커밋
개선:
"주문 상태 업데이트도 비동기로 하면 안 되나?"
처음엔 @Async를 붙일까 고민했다. 하지만:
| 시나리오 | 문제 |
|---|---|
| 결제 완료 | 즉시 주문 상태 확인 가능해야 함 |
| 비동기 처리 시 | 수십 ms 지연 → 사용자가 "결제 완료"인데 주문은 "대기 중"? |
결론: 주문 상태는 즉시 반영되어야 한다
쿠폰 사용은 나중에 처리해도 되지만, 주문 상태는 결제와 거의 동시에 업데이트되어야 한다.
결제가 실패했을 때에 관해 두 가지 정책을 고민했다.
정책 결정:
현재 구현은 후자를 선택했다.
@EventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun handlePaymentFailed(event: PaymentFailedEvent) {
try {
logger.warn("결제 실패로 인한 주문 처리: orderId=${event.orderId}, reason=${event.reason}")
val order = orderRepository.findById(event.orderId)
?: throw CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다")
// 주문 상태를 PENDING으로 유지하여 재시도 가능하도록
// 또는 완전히 취소 처리 (비즈니스 정책에 따라)
logger.info("결제 실패한 주문 유지: orderId=${event.orderId}, status=${order.status}")
} catch (e: Exception) {
logger.error("결제 실패 처리 중 오류: orderId=${event.orderId}", e)
}
}
주문 확정 로직을 구현하고 나서 안심했다. 별도 트랜잭션으로 분리했고, 동기적으로 실행되어 즉시 반영된다. 완벽해 보였다.
그런데 코드를 다시 보니 문제가 있었다:
초기 구현:
@Transactional(propagation = Propagation.REQUIRES_NEW)
@EventListener
fun handlePaymentCompleted(event: PaymentCompletedEvent) {
try {
val order = orderRepository.findById(event.orderId)
order.confirm()
orderRepository.save(order)
logger.info("주문 상태 업데이트 완료: orderId=${event.orderId}")
// 데이터 플랫폼에 결제 완료 정보 전송
dataPlatformClient.sendPaymentCompleted(event) // ⚠️ 위험!
// 유저 행동 로깅
eventPublisher.publishEvent(UserActionEvent(...))
} catch (e: Exception) {
logger.error("주문 상태 업데이트 실패: orderId=${event.orderId}", e)
throw e
}
}
"잠깐, 저 외부 API 호출이 트랜잭션 안에 있잖아?"
만약 데이터 플랫폼 전송이 실패하면 어떻게 될까?
시나리오:
1. 결제 완료 이벤트 발행
↓
2. [주문 확정 트랜잭션 시작]
↓
3. order.confirm() 성공
↓
4. orderRepository.save(order) 성공
↓
5. dataPlatformClient.sendPaymentCompleted() 호출
↓
6. ❌ 네트워크 타임아웃 발생!
↓
7. Exception throw
↓
8. ❌ 트랜잭션 롤백!
↓
결과: 주문 상태가 다시 PENDING으로...
문제:
| 상황 | 결과 | 영향 |
|---|---|---|
| 데이터 플랫폼 응답 느림 | 트랜잭션 길어짐 | DB 커넥션 점유 시간 증가 |
| 네트워크 타임아웃 | Exception 발생 | 주문 확정 롤백 |
| 데이터 플랫폼 장애 | 전송 실패 | 결제 완료인데 주문은 PENDING |
사용자 관점:
"결제는 완료되었는데 주문 상태가 계속 '대기 중'이에요!"
"돈은 빠져나갔는데 주문이 확정이 안 돼요!"
"결제는 성공했는데, 외부 API 장애 때문에 주문이 확정 안 되는 건 말이 안 된다..."
문제의 본질은 트랜잭션 경계였다.
트랜잭션에 포함되어야 하는 것:
트랜잭션에 포함되면 안 되는 것:
"외부 호출은 트랜잭션 밖으로!"
주문 확정과 데이터 플랫폼 전송을 별도 메서드로 분리했다.
개선된 구현:
// 1. 주문 확정 (트랜잭션)
@Transactional(propagation = Propagation.REQUIRES_NEW)
@EventListener
fun handlePaymentCompleted(event: PaymentCompletedEvent) {
try {
val order = orderRepository.findById(event.orderId)
order.confirm()
orderRepository.save(order)
logger.info("주문 상태 업데이트 완료: orderId=${event.orderId}")
// 유저 행동 로깅 (같은 트랜잭션)
eventPublisher.publishEvent(UserActionEvent(...))
} catch (e: Exception) {
logger.error("주문 상태 업데이트 실패", e)
throw e
}
}
// 2. 데이터 플랫폼 전송 (별도 처리)
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handlePaymentCompletedForDataPlatform(event: PaymentCompletedEvent) {
try {
logger.info("결제 완료 데이터 플랫폼 전송 시작: orderId=${event.orderId}")
dataPlatformClient.sendPaymentCompleted(event)
logger.info("결제 완료 데이터 플랫폼 전송 완료")
} catch (e: Exception) {
// 전송 실패해도 주문 확정에는 영향 없음
logger.error("결제 완료 데이터 플랫폼 전송 실패: orderId=${event.orderId}", e)
}
}
Before (외부 호출이 트랜잭션 내부):
[주문 확정 트랜잭션]
├── DB: order.confirm()
├── DB: save(order)
├── 외부 API: dataPlatformClient.send() ⚠️
│ └─> 실패 시 전체 롤백!
└── 커밋
After (외부 호출을 트랜잭션 밖으로):
[주문 확정 트랜잭션]
├── DB: order.confirm()
├── DB: save(order)
└── 커밋 ✅
↓
[데이터 플랫폼 전송] (AFTER_COMMIT, Async)
└── 외부 API: dataPlatformClient.send()
└─> 실패해도 주문은 이미 확정됨 ✅
개선 효과:
| 항목 | Before | After |
|---|---|---|
| 트랜잭션 시간 | 외부 API 응답 시간 포함 (길어짐) | DB 작업만 (짧아짐) |
| 외부 장애 영향 | 주문 확정 실패 🔴 | 주문 확정 성공 ✅ |
| DB 커넥션 점유 | 외부 API 대기 중에도 점유 | 최소 시간만 점유 |
| 데이터 정합성 | 결제 완료인데 주문 PENDING 가능 | 항상 일관성 유지 ✅ |
"외부 시스템이 죽어도 우리 핵심 로직은 성공한다!"
이 경험을 통해 배운 트랜잭션 경계 설정 원칙:
1. 트랜잭션에 포함할 것 (필수):
✅ 같은 DB의 데이터 변경
✅ 원자성이 필요한 작업
✅ 롤백되어야 하는 작업
2. 트랜잭션에서 제외할 것 (위험):
❌ 외부 API 호출 (HTTP, gRPC 등)
❌ 메시징 시스템 전송 (Kafka, RabbitMQ)
❌ 파일 I/O
❌ 느린 작업
3. 판단 기준:
"이 작업이 실패했을 때,
메인 로직도 롤백되어야 하는가?"
→ YES: 트랜잭션 내부
→ NO: 트랜잭션 외부 (@TransactionalEventListener + AFTER_COMMIT)
"주문 확정은 성공해야 하고, 데이터 플랫폼 전송은 실패해도 된다"
이 명확한 정책이 트랜잭션 경계를 결정했다.
결제와 주문 정보를 외부 데이터 플랫폼에 전송하는 것도 이벤트로 분리했다.
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
fun handleOrderCreatedForDataPlatform(event: OrderCreatedEvent) {
try {
logger.info("데이터 플랫폼 전송 시작: orderId=${event.orderId}")
sendToDataPlatform(event)
logger.info("데이터 플랫폼 전송 완료: orderId=${event.orderId}")
} catch (e: Exception) {
// 전송 실패해도 주문은 정상 생성
logger.error("데이터 플랫폼 전송 실패: orderId=${event.orderId}", e)
}
}
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
fun handlePaymentCompletedForDataPlatform(event: PaymentCompletedEvent) {
try {
sendPaymentCompletedToDataPlatform(event)
} catch (e: Exception) {
logger.error("결제 완료 데이터 플랫폼 전송 실패", e)
}
}
특징:
결과:
좋아요 추가할 때도 비슷한 문제가 있었다:
기존:
fun addLike(userId: Long, productId: Long) {
val like = Like(userId, productId)
likeRepository.save(like)
// 동기 처리
productLikeCountService.increment(productId) // Redis 업데이트
evictProductCache(productId) // 캐시 무효화
}
문제:
"좋아요는 추가됐는데, 집계가 실패하면 어떻게 하지?"
LikeService 변경:
After:
fun addLike(userId: Long, productId: Long) {
val like = Like(userId, productId)
likeRepository.save(like)
// 이벤트 발행 (집계는 핸들러에서)
eventPublisher.publishEvent(LikeAddedEvent(userId, productId))
}
LikeEventHandler:
@Component
class LikeEventHandler(
private val productLikeCountService: ProductLikeCountService,
private val productCacheRepository: ProductCacheRepository,
) {
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
fun handleLikeAdded(event: LikeAddedEvent) {
try {
// Redis 카운트 증가
productLikeCountService.increment(event.productId)
// 캐시 무효화
evictProductCache(event.productId)
logger.info("좋아요 집계 완료: productId=${event.productId}")
} catch (e: Exception) {
// 집계 실패해도 좋아요는 추가됨
logger.error("좋아요 집계 실패: productId=${event.productId}", e)
}
}
}
결과:
"좋아요 누르자마자 바로 반영 안 되는 거 아니야?"
맞다. 하지만:
| 시점 | 상태 | 사용자 경험 |
|---|---|---|
| t0 | 좋아요 클릭 | - |
| t1 | Like 저장 완료 | "좋아요 추가됨" 응답 ✅ |
| t2 | 이벤트 발행 | - |
| t3 | Redis 카운트 증가 | 수십 ms 지연 |
| t4 | 다른 사용자 조회 | 최신 카운트 확인 ✅ |
지연: 수십 밀리초
사용자는 느끼지 못한다. 하지만 시스템은 훨씬 안정적이다.
동일한 부하 테스트를 다시 실행했다:
ab -n 100 -c 100 http://localhost:8080/api/v1/orders
AS-IS (동기 처리):
평균 응답 시간: ~2000ms
최대 응답 시간: ~5000ms
실패율: 10% (타임아웃)
TO-BE (이벤트 기반):
평균 응답 시간: ~200ms
최대 응답 시간: ~500ms
실패율: 0%
| 항목 | AS-IS | TO-BE | 개선율 |
|---|---|---|---|
| 평균 응답 시간 | ~2000ms | ~200ms | 90% ↑ |
| 최대 응답 시간 | ~5000ms | ~500ms | 90% ↑ |
| 실패율 | 10% | 0% | 100% ↑ |
90% 성능 향상!
더 중요한 건 장애 상황이었다.
시나리오: 쿠폰 서비스 장애
// 쿠폰 서비스를 강제로 느리게 만듦
fun useUserCoupon(...) {
Thread.sleep(10000) // 10초 지연
throw Exception("쿠폰 서비스 장애")
}
AS-IS (동기 처리):
주문 생성 요청
→ 쿠폰 사용 시도 (10초 대기)
→ 쿠폰 사용 실패
→ 전체 롤백
→ 주문 생성 실패 ❌
TO-BE (이벤트 기반):
주문 생성 요청
→ 쿠폰 검증만 (즉시)
→ 주문 저장 (성공)
→ 이벤트 발행
→ 주문 생성 성공 ✅
[별도 스레드]
→ 쿠폰 사용 시도 (10초 대기)
→ 쿠폰 사용 실패 (로그만)
→ 주문은 그대로 유지 ✅
| 상황 | AS-IS | TO-BE |
|---|---|---|
| 쿠폰 서비스 느림 | 주문 생성 느림 🔴 | 주문 생성 빠름 ✅ |
| 쿠폰 서비스 장애 | 주문 생성 실패 🔴 | 주문 생성 성공 ✅ |
| 사용자 경험 | 나쁨 | 좋음 |
"쿠폰 서비스가 죽어도 주문은 생성된다!"
처음 알았다. 이벤트 기반은 단순히 성능 문제가 아니라 "장애 격리와 시스템 안정성" 문제라는 것을.
이벤트 기반은 강력하지만, 새로운 문제도 생긴다:
| 리스크 | 설명 | 대응 |
|---|---|---|
| ❌ 예외 은닉 | 이벤트 핸들러 실패는 사용자에게 안 보임 | 로그 적재, 모니터링 |
| ❌ 순서 보장 어려움 | 이벤트는 병렬 실행될 수 있음 | 순서 의존 없는 흐름만 분리 |
| ❌ 중복 실행 | 트랜잭션 재시도 시 이벤트 중복 발행 | Idempotency 처리 |
| ❌ 데이터 불일치 | 최종적으로는 일관성 보장, 즉시는 아님 | 비즈니스 요구사항 확인 |
"주문은 생성됐는데, 쿠폰 사용이 실패하면?"
시나리오:
[사용자 A] 쿠폰 100번으로 주문 생성
→ 주문 생성 성공 (orderId=1)
→ 쿠폰 사용 이벤트 발행
[사용자 B] 같은 쿠폰 100번으로 주문 생성 (동시)
→ 주문 생성 성공 (orderId=2) ← 문제!
→ 쿠폰 사용 이벤트 발행
[이벤트 핸들러]
→ 쿠폰 100번 사용 (orderId=1) 성공
→ 쿠폰 100번 사용 (orderId=2) 실패 (이미 사용됨)
결과:
이게 맞나?
사실 이건 비즈니스 정책의 문제다:
| 정책 | 선택 |
|---|---|
| 쿠폰 사용 필수 | 주문 생성 전 쿠폰 사용 처리 (동기) |
| 쿠폰 사용 선택 | 주문 생성 후 쿠폰 사용 처리 (비동기) |
현재 구현은 후자를 선택했다. 쿠폰 사용이 실패해도 주문은 유지되고, 사용자는 나중에 다른 쿠폰으로 재시도할 수 있다.
처음엔 "하나의 트랜잭션에 다 넣으면 간단하겠지"라고 생각했다.
하지만:
트랜잭션이 길어질수록:
"꼭 지금 해야 하는가?"를 항상 묻자.
Command (명령):
Event (통지):
이벤트는 "무엇을 하라"가 아니라 "무슨 일이 일어났다"를 알리는 것이다.
@TransactionalEventListener(phase = AFTER_COMMIT)
이 한 줄이 주는 가치:
"커밋된 후에 처리하면 안전하다"
"즉시 반영 안 되면 문제 아니야?"
아니다. 이건 전략적 선택이다:
즉시 일관성 (Immediate Consistency):
최종적 일관성 (Eventual Consistency):
"사용자가 느끼지 못하는 지연은 문제가 아니다"
좋아요 수가 1-2개 차이 나는 건 사용자가 신경 쓰지 않는다. 하지만 페이지가 1초 느린 건 바로 느낀다.
이벤트 기반은 흐름이 눈에 보이지 않는다.
Before:
orderService.createOrder()
→ couponService.useCoupon() // 여기서 실패하면 바로 알 수 있음
After:
orderService.createOrder()
→ eventPublisher.publishEvent() // 이벤트 핸들러가 어디서 실패했는지 모름
필요한 것:
현재는 쿠폰 사용이 실패해도 로그만 남긴다.
catch (e: Exception) {
logger.error("쿠폰 사용 실패: orderId=${event.orderId}", e)
}
개선 방향:
현재는 이벤트가 병렬로 실행된다.
@Async // 별도 스레드
@TransactionalEventListener(phase = AFTER_COMMIT)
fun handleOrderCreatedForCoupon(...) { ... }
@Async // 별도 스레드
@TransactionalEventListener(phase = AFTER_COMMIT)
fun handleOrderCreatedForDataPlatform(...) { ... }
만약 순서가 중요하다면?
@Async 제거 (동기 실행)중요한 이벤트는 이벤트 저장소에 적재 후 처리:
[주문 생성]
↓
[이벤트 저장소에 저장]
├─ OrderCreatedEvent
├─ status: PENDING
└─ timestamp
↓
[별도 스케줄러가 처리]
├─ PENDING 이벤트 조회
├─ 이벤트 핸들러 실행
└─ status: PROCESSED
장점:
"트랜잭션 분리의 핵심은 성능 최적화가 아니라, 장애 격리다"
이벤트 기반 아키텍처의 진짜 가치는: