기존 주문/결제 로직의 문제점
기존 구현에서는 주문 생성 → 쿠폰 사용 처리 → 결제 요청 → 재고 차감 → 주문 상태 변경까지의 모든 과정을 하나의 트랜잭션 안에서 처리했다.
단일 트랜잭션으로 구현하면 개발은 단순해 보이지만, 실제 운영 환경에서는 다음과 같은 문제점들이 있다.
1. 트랜잭션 점유 시간 증가
결제 로직에는 외부 PG(Payment Gateway) 연동이 포함된다.
PG API 호출은 네트워크 지연과 외부 시스템 응답 시간을 포함하기 때문에, 전체 트랜잭션 점유 시간이 길어진다.
그 결과 동시에 여러 주문을 처리하는 상황에서 DB 커넥션 풀을 잠식하거나 락 경합(lock contention)이 발생해 시스템 전체 성능을 저하시킬 수 있다.
2. 외부 시스템 장애에 따른 트랜잭션 실패
PG 연동 과정에서 응답 지연 또는 장애가 발생하면, 주문 생성부터 결제까지 포함된 모든 로직이 롤백된다.
예를 들어, 주문은 이미 생성되었지만 결제 과정에서 실패했다면 다시 주문부터 재시도해야 하는 문제가 생기고, 사용자는 “결제 실패” 경험을 반복하게 된다.
3. 단일 책임 원칙(SRP) 위반
주문, 결제, 재고 차감, 쿠폰 사용 등은 각각 독립적인 도메인 로직임에도 불구하고 하나의 트랜잭션에서 처리하면 도메인 간 결합도가 높아진다.
이로 인해 일부 기능만 수정해도 전체 트랜잭션 로직을 변경해야 하며, 코드 복잡성이 증가하고 유지보수가 어려워진다.
4. 장애 전파 범위 확대
PG API 호출 실패, 네트워크 타임아웃, 재고 부족 등 일부 단계에서 발생한 예외가 전체 트랜잭션에 영향을 미친다.
한 주문의 결제 실패가 DB 커넥션 점유를 길게 유지하거나, 다른 사용자의 주문 처리 속도를 늦추는 등 장애 범위가 서비스 전체로 확산될 수 있다.
@Transactional
OrderFacade.placeOrder() {
// 1. 주문 생성 OrderService.createOrder()
// 2. 쿠폰 사용 IssuedCouponService.useIssuedCoupon()
// 3. 결제 요청 PaymentService.pay() 결제 타입: [포인트, 카드]
// 4. 주문 완료 요청
// 4-1. 재고 차감 (StockService.deductQuantity())
// 4-2. 주문 완료 상태 변경 (OrderService.completedOrder())
}
이러한 방식을 Command 패턴이라고 할 수 있다. 즉, 특정 작업을 수행하기 위해 명령을 내리고, 그 대상 객체를 직접 지정하는 방식이다.
하지만 Command만으로 모든 로직을 작성하면, 위에서 살펴본 것처럼 하나의 트랜잭션 안에 지나치게 많은 책임이 집중되는 문제가 발생한다.
이를 해결하기 위해 Event를 도입할 수 있다.
Event를 활용하면 트랜잭션을 분리하고, 도메인 간 결합도를 낮추며, 시스템 확장성과 유지보수성을 높일 수 있다.
다만 Event가 무조건적으로 좋다는 것은 아니기 때문에 반드시 Event를 적용하기 전에 Command와 Event가 무엇인지, 어떤 차이점이 있는데 확인해 보는 것이 중요하다.
Command vs Event
| 구분 | Command | Event |
| 정의 | “무엇을 해라”라는 명령을 표현하는 객체 | “무엇이 일어났다”라는 사실을 표현하는 객체 |
| 주체 | 보낸 쪽(클라이언트, 서비스 등)이 수행을 지시 | 발생한 쪽(도메인, 서비스 등)이 결과를 알림 |
| 성격 | 명령적 (Imperative) | 사실적 (Declarative) |
| 시간성 | “지금 이 작업을 수행해라” (현재/미래 지향) | “이미 이런 일이 일어났다” (과거 지향) |
| 예시 문장 | “주문을 생성해라” | “주문이 생성되었다” |
| 응답 | Command는 수행 결과(성공/실패)를 응답 받음 | Event는 발행 후 알림, 응답 없음(비동기 가능) |
| 결합도 | 상대적으로 높음 (명령자는 수신자를 알아야 함) | 상대적으로 낮음 (발행자는 수신자를 몰라도 됨) |
| 실패 처리 | Command는 바로 재시도 가능 | Event는 수신자가 실패 시 별도 보상/재처리 필요 |
Spring에서 Event를 적용하기
Spring은 기본적으로 Event에 대해 두 가지 이벤트 리스너를 제공한다.
1. @EventListener
- 트랜잭션의 범위와 상관없이 이벤트가 발행되는 즉시 리스너를 실행한다.
- 단순 알림, 로그 기록, 메트릭 수집 등 트랜잭션과 무관한 작업에 적합하다.
2. @TransactionalEventListener
- 이벤트를 트랜잭션 상태에 따라 실행할 수 있다.
- 예를 들어, 주문 생성 트랜잭션이 커밋된 이후(AFTER_COMMIT)에만 결제 이벤트를 발행하거나,
롤백 시에는 이벤트를 무시하는 동작을 쉽게 구현할 수 있다.
- 결제, 재고 차감, 쿠폰 발행처럼 트랜잭션 일관성이 중요한 시나리오에서 적합하다.
@EventListener vs @TransactionalEventListener
| 구분 | @EventListener | @TransactionalEventListener |
| 트리거 시점 | 이벤트가 발행되는 즉시 실행 | 트랜잭션의 상태에 따라 커밋/롤백 이후 실행 |
| phase 옵션 | 없음 | BEFORE_COMMIT / AFTER_COMMIT(기본) / AFTER_ROLLBACK / AFTER_COMPLETION |
| 커밋 보장 | X (롤백돼도 실행) | O (AFTER_COMMIT 사용 시) |
| 예외 전파 | 호출자에게 전파 → 원 트랜잭션 롤백 가능 | 본 트랜잭션에는 영향 X |
| 사용 예시 | 캐시 갱신, 로그, 메트릭 | 재고 차감, 쿠폰 사용, 알림 전송 등 커밋 이후 처리 |
| 주의점 | 롤백 시 “유령 이벤트” 가능 | 전파 옵션(REQUIRES_NEW) 필요 |
@EventListener 적용
@EventListener는 아래와 같이 핵심 로직과는 관계 없는(트랜잭션과 무관) 부가 로직에 적용하는 것이 어울릴 수 있다.
또는 부가 로직에서 예외가 발생시 원 트랜잭션과 함께 롤백이 되게끔 하려고 할 때도 사용할 수 있다.
@Component
class DataFlatformEventHandler(
private val dataFlatformRepository: DataFlatformRepository,
) {
@EventListener
fun handle(event: OrderCreatedEvent) {
dataFlatformRepository.send(event)
}
@EventListener
fun handle(event: PaymentCompletedEvent) {
dataFlatformRepository.send(event)
}
@EventListener
fun handle(event: PaymentFailedEvent) {
dataFlatformRepository.send(event)
}
@EventListener
fun handle(event: OrderCompletedEvent) {
dataFlatformRepository.send(event)
}
}
@TransactionalEventListener 적용
| phase | 시점 | 예시 |
| BEFORE_COMMIT | 트랜잭션 커밋 직전 | 커밋 전 데이터 최종 검증 |
| AFTER_COMMIT | 트랜잭션 커밋 성공 후 | 결제 완료 후 재고 차감, 쿠폰 사용 |
| AFTER_ROLLBACK | 트랜잭션 롤백 후 | 결제 실패 시 알림 발송 |
| AFTER_COMPLETION | 트랜잭션 종료 후 (성공/실패 무관) | 트랜잭션 상태 로깅 |
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: OrderCreatedEvent) {
// business logic
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: PaymentCompletedEvent) {
// business logic
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
fun handle(event: OrderCompletedEvent) {
// business logic
}
그렇다면 @EventListener와 @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)은 뭐가 다르지????
앞서 @EventListener는 예외가 발생 시 원 트랜잭션과 함께 롤백이 된다고 했다.
그렇다면 단순하게 롤백이 된다는 측면에서 "@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)도 예외가 발생 시 원 트랜잭션과 함께 롤백이 되기 때문에 동일하게 동작하는 거 아닌가?"라는 의문이 생겼다.
결론부터 말하자면 다르다. (같을리가 없었다..)
핵심은 트리거 시점과 커밋 보장이다.
1) 트리거 시점
- @EventListener
이벤트가 발행되는 즉시 실행된다.
트랜잭션의 커밋/롤백 상태와 독립적으로 동작한다.
따라서 리스너 실행 시점에는 “이 트랜잭션이 커밋될지” 보장하지 않는다.
- @TransactionalEventListener(phase = BEFORE_COMMIT)
커밋 직전 단계에서만 실행된다.
현재 트랜잭션이 커밋 가능한 상태일 때에만 호출된다.
2) 예외 전파와 커밋 보장
둘 다 리스너에서 예외가 던져지면 원 트랜잭션을 롤백시킬 수 있다. 하지만 의미가 다르다.
- @EventListener
리스너에서 예외가 나면 그 즉시 호출 스택으로 전파되어 그 자리에서 롤백이 일어난다.
문제는 예외가 없었다가 이후 서비스 로직의 다른 지점에서 실패해 롤백될 수도 있다는 점이다.
이 경우, 리스너는 이미 실행을 끝낸 상태일 수 있다.
- @TransactionalEventListener(phase = BEFORE_COMMIT)
커밋 직전에 실행되므로 “실행되었다 = 이 트랜잭션은 곧 커밋될 상황”이라는 전제가 깔린다.
여기서 예외가 나면 커밋을 차단하고 원 트랜잭션을 롤백한다.
반대로 예외가 없다면, 직후에 커밋이 진행되므로 “리스너가 성공적으로 끝났는데 나중에 롤백되는” 상황이 구조적으로 줄어든다.
어떤 listener를 사용하느냐에 따라 같은 예외 전파라도 시간축이 다르다.
@EventListener는 즉시 반응하지만 커밋 보장이 없고, @TransactionalEventListener(BEFORE_COMMIT)는 커밋 직전에만 실행되어 예외로 커밋을 차단할 수 있다.
Event를 적용한 주문/결제 로직
기존 주문/결제 로직 vs Event를 적용한 주문/결제 로직 비교
기존 주문/결제 로직

이벤트 적용한 주문/결제 로직





Command만 쓸 때의 단점
| 문제점 | 설명 |
| 트랜잭션 점유 시간 증가 | 한 Command에서 쿠폰, 재고, 결제까지 모두 처리하면 트랜잭션이 길어짐 |
| 결합도 증가 | 한 Command에서 여러 서비스 호출 → 각 서비스 간 의존성 ↑ |
| 확장성 부족 | 새로운 처리 로직이 추가될 때 Command 핸들러 수정 필요 |
| 장애 전파 | 하위 서비스 하나 실패 시 상위 Command 전체 롤백 |
이벤트로 해소되는 문제점
| 문제 | Command-only 설계 | Event-driven 설계 |
| 트랜잭션 시간 | 길어짐 | 짧아짐 |
| 락 경합 | 높음 | 완화 |
| 장애 전파 | 전체 롤백 | 부분 실패만 재시도 가능 |
| 결합도 | 높음 | 낮음 |
| 확장성 | 낮음 | 후속 소비자 수평 확장 가능 |
Event를 적용한 주문/결제 로직 테스트하기
이제 하나의 트랜잭션에서 실행되던 주문/결제 로직을 Event를 통해 변경을 해봤는데, 문제는 변경한 이 로직을 어떻게 테스트할 것인가? 였다.
Event에 대해서 테스트를 해야 하는데, 이때 Event가 정상적으로 발행되었는지 여부를 확인하고 이벤트 리스너에 의해 처리되는 로직을 별도로 테스트를 하는 것이 좋다.
왜냐면 Event를 적용하더라도 동기로 실행하게 할 경우 테스트 자체는 크게 문제가 없었지만 @Async를 적용하여 비동기로 실행할 경우 주문 생성 후 이후 로직들이 별도의 트랜잭션으로 동작하기 때문에 즉각적으로 응답을 받기가 어려웠기 때문이다.
그리고 개념적으로도 주문 생성의 경우 이제 주문 생성 후 주문 생성 이벤트 발행까지만 담당했기 때문에 본인의 역할에 대해서만 테스트를 하는 것이 맞다고 생각했다.
Event 발행 & 처리 로직 테스트
// 예시는 ProductLike, ProductLikeCount 테스트로 대체
@DisplayName("동일한 상품에 대해 여러명이 좋아요 등록을 동시에 요청할 때 좋아요 집계 이벤트[ProductLikeEvent]는 정상적으로 발행되어야 한다.")
@Test
fun multipleUsersLikeSameProductWithOptimisticLock() {
// given
val numberOfThreads = 20
val latch = CountDownLatch(numberOfThreads)
val executor = Executors.newFixedThreadPool(numberOfThreads)
val createdProduct = productJpaRepository.save(aProduct().build())
val userIds = mutableListOf<Long>()
repeat(numberOfThreads) {
val createdUser = userJpaRepository.save(aUser().username("user$it").email(Email("shyoon$it@gmail.com")).build())
userIds.add(createdUser.id)
}
productLikeCountJpaRepository.save(ProductLikeCountEntity(createdProduct.id, 0))
// when
repeat(numberOfThreads) {
executor.submit {
try {
productLikeService.likeOptimistic(ProductLikeCommand.Like(userIds[it], createdProduct.id))
} catch (e: OptimisticLockingFailureException) {
println("실패: ${e.message}")
} finally {
latch.countDown()
}
}
}
latch.await()
// then
assertThat(applicationEvents.stream(ProductLikeEvent::class.java).count()).isEqualTo(numberOfThreads.toLong())
}
@DisplayName("동일한 상품에 대해 좋아요 등록을 동시에 요청해도, 상품의 좋아요 개수가 정상 반영되어야 한다.")
@Test
fun multipleIncreaseWithOptimisticLock() {
// arrange
val numberOfThreads = 10
val latch = CountDownLatch(numberOfThreads)
val executor = Executors.newFixedThreadPool(numberOfThreads)
val createdProduct = productJpaRepository.save(aProduct().build())
productLikeCountJpaRepository.save(aProductLikeCount().productId(createdProduct.id).productLikeCount(0).build())
var successCount = 0
var failureCount = 0
// act
repeat(numberOfThreads) {
executor.submit {
try {
productLikeCountService.increase(createdProduct.id)
successCount++
} catch (e: Exception) {
println("실패: ${e.message}")
failureCount++
} finally {
latch.countDown()
}
}
}
latch.await()
// assert
val productLikeCount = productLikeCountJpaRepository.findById(createdProduct.id).get()
assertThat(productLikeCount).isNotNull
assertThat(productLikeCount.productLikeCount).isEqualTo(numberOfThreads - failureCount)
}
하지만 만약 Event 발행 후 실제 처리 결과까지 테스트해보고 싶은 경우에는 어떻게 해야 할까?
가장 간단한 건 Awaitility를 사용하는 것이다. Awaitility는 비동기 동작이 완료될 때까지 polling(폴링) 하며 기다리는 테스트용 라이브러리다.
공식 문서: http://www.awaitility.org/
Event 발행 & 처리 로직 테스트
@DisplayName("포인트로 결제에 성공하면 재고가 감소하며 결제 성공, 주문 완료 처리 된다.")
@Test
fun succeedsToPayWithPoints_whenPaymentIsSuccessful() {
// arrange
val createdUser = userJpaRepository.save(aUser().build())
val createdPoint = pointJpaRepository.save(aPoint().userId(createdUser.id).point(Point(20_000)).build())
val createdProduct = productJpaRepository.save(aProduct().price(Price(1000)).build())
stockJpaRepository.save(aStock().build())
val quantity = Quantity(2)
val criteria = OrderCriteria.Create(
createdUser.username,
"홍길동",
Email("shyoon991@gmail.com"),
Mobile("010-1234-5678"),
Address("12345", "서울시 강남구 역삼동", "역삼로 123"),
listOf(
OrderCriteria.Create.OrderItem(
createdProduct.id,
quantity,
),
),
PaymentMethodType.POINT,
)
// act
val orderId = orderFacade.placeOrder(criteria)
// assert
await().pollDelay(Duration.ofSeconds(2)).pollInterval(Duration.ofSeconds(1)).untilAsserted {
val findOrder = orderJpaRepository.findWithItemsById(orderId)
findOrder?.let { order ->
assertAll(
{ assertThat(order.userId).isEqualTo(createdUser.id) },
{ assertThat(order.orderStatus).isEqualTo(OrderStatusType.COMPLETED) },
{ assertThat(order.orderItems.size()).isEqualTo(2) },
{ assertThat(order.orderItems.amount()).isEqualTo(Price(createdProduct.price.value * quantity.value)) },
)
}
val findPoint = pointJpaRepository.findByUserId(createdUser.id)
assertThat(findPoint?.point).isEqualTo(Point(createdPoint.point.value - (createdProduct.price.value * quantity.value)))
}
}
await().pollDelay(Duration.ofSeconds(2)).pollInterval(Duration.ofSeconds(1)).untilAsserted
=> 테스트 시작 후 2초 기다렸다가, 1초 간격으로 assertion을 재시도하며, 성공하면 바로 종료, 실패하면 계속 반복
이처럼 Awaitility를 사용하면 비동기 처리 로직에 대해서도 처리 결과를 테스트해 볼 수는 있다.
하지만 고민해봐야 할 부분은 Event 발행 이후의 처리 결과에 대해서도 하나의 테스트에서 수행해야 하는가?이다.
Event를 사용한 목적을 토대로 생각해 보면 Event 발행 / 처리 결과 테스트를 각각 나눠서 테스트를 하는 것이 좋다고 생각한다.
Appendix. Event Driven Architecture: Orchestration vs Choreography

EDA(Event Driven Architecture), 이벤트 기반 아키텍처에서는 크게 Orchestration, Choreography라는 방식이 있다.
Orchestration(오케스트레이션)
중앙 조정자(Orchestrator)가 이벤트의 흐름을 직접 제어하는 방식이다.
특징
중앙 집중 제어: Orchestrator가 각 서비스 호출 순서와 조건을 제어
명시적 흐름 관리: 어떤 서비스가 먼저, 다음에 무엇을 호출할지 중앙에서 결정
단일 진입점: 보통 하나의 서비스(또는 전용 모듈)가 전체 프로세스를 시작하고 끝까지 책임짐
실패 처리 간결: Orchestrator에서 전체 트랜잭션 롤백, 재시도 전략을 통합 관리 가능
장단점
| 장점 | 단점 |
| 전체 비즈니스 흐름을 한눈에 파악할 수 있다. | Orchestrator에 대한 의존성이 증가하여 Orchestrator가 SPOF가 될 수 있다. |
| Orchestrator가 중앙 집중 처리하기 때문에 재시도, 롤백, 예외 관리가 단순해진다. | 서비스가 Orchestrator와 강결합이 될 수 있다. |
| Orchestrator만 보면 되기 때문에 상대적으로 디버깅이 용이하다. | 유연성이 떨어진다. 새로운 로직이 추가될 경우 Orchestrator를 수정해야 한다. |
Orchestration 적용 예시
앞서 작성한 Event가 Orchestration 방식에 해당한다. OrderFacade가 Orchestrator로써 각 서비스 호출 순서와 조건을 제어한다.
@Component
class PaymentEventListener(
private val orderFacade: OrderFacade,
private val paymentService: PaymentService,
) {
private val log = getLogger(this::class.java)
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: OrderCreatedEvent) {
log.info("주문 생성 이벤트 수신: $event")
paymentService.pay(
PaymentCommand.Pay(
event.orderId,
event.userId,
PaymentMethodType.valueOf(event.paymentMethod),
Price(event.totalPrice),
event.cardType?.let { PaymentCardType.valueOf(it) },
event.cardNo,
event.orderKey,
),
)
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: PaymentCompletedEvent) {
log.info("결제 완료 이벤트 수신: $event")
orderFacade.completeOrder(event.orderKey, event.transactionKey)
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: PaymentFailedEvent) {
log.info("결제 실패 이벤트 수신: $event")
orderFacade.failOrder(event.orderKey, event.transactionKey)
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
fun handle(event: OrderCompletedEvent) {
log.info("주문 완료 롤백 이벤트 수신: $event")
orderFacade.recoveryOrder(event.orderKey, event.transactionKey)
}
}
실제 적용해본 결과 Event에 대한 처리를 한 곳(Orchestrator)에서 담당하기 때문에 상대적으로 디버깅이 용이하고 비즈니스 로직 파악이 쉬웠다.
Choreography(코레오그래피)
중앙 조정자 없이, 각 서비스가 이벤트를 구독(subscribe)하고 필요한 이벤트에 반응하는 방식이다.
특징
분산 제어: 중앙 Orchestrator 없이 서비스들이 독립적으로 이벤트를 발행/구독한다.
루즈 커플링(loose coupling): 서비스 간 직접 호출 대신 이벤트를 통해 간접 통신한다.
비동기적 확장성 높음: 새로운 서비스 추가 시 단순히 이벤트만 구독하면 된다.
장단점
| 장점 | 단점 |
| 새로운 서비스가 이벤트를 구독만 하면 되기 때문에 높은 확장성을 가진다. | 전체 비즈니스 프로세스를 한 눈에 보기 어렵다. 흐름 파악이 어렵다. |
| 한 서비스의 변경이 다른 서비스에게 주는 영향을 최소화하기 때문에 서비스 간 결합도가 낮다. | 복잡한 장애 처리: 실패한 이벤트를 어디서 어떻게 재처리할지 서비스별로 설계가 필요하기 때문에 장애 처리가 매우 복잡해진다. |
| 일부 서비스가 일시 중단돼도 전체 시스템은 계속 동작 가능하다. | 이벤트 경로 추적이 매우 어렵다. |
Choreography 적용 예시
Choreography를 적용하게 되면 Orchestrator(OrderFacade)가 더 이상 필요하지 않기 때문에 각 서비스별로 직접 이벤트를 수신하여 본인의 역할을 수행한다.
// OrderEventListener
@Component
class OrderEventListener(
private val orderService: OrderService,
private val issuedCouponService: IssuedCouponService,
private val stockService: StockService,
private val eventPublisher: EventPublisher,
) {
private val log = LoggerFactory.getLogger(this::class.java)
/*
주문 완료
*/
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
fun handleBeforeIssuedCoupon(event: OrderCompletedEvent) {
log.info("주문 완료 이벤트 수신(커밋 전): $event")
val order = orderService.findWithItemsByOrderKey(event.orderKey)
?: throw IllegalStateException("주문 정보를 찾을 수 없습니다. orderKey: ${event.orderKey}")
issuedCouponService.useIssuedCoupon(order.issuedCouponId)
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
fun handleBeforeStock(event: OrderCompletedEvent) {
log.info("주문 완료 이벤트 수신(커밋 전): $event")
val order = orderService.findWithItemsByOrderKey(event.orderKey)
?: throw IllegalStateException("주문 정보를 찾을 수 없습니다. orderKey: ${event.orderKey}")
stockService.deductStockQuantities(
StockCommand.Deduct.from(
order.orderItems.toProductQuantityMap(),
),
)
}
/*
결제 완료
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: PaymentCompletedEvent) {
log.info("결제 완료 이벤트 수신: $event")
// 주문 완료 이벤트 발행 - 마커용
eventPublisher.publish(OrderCompletedEvent(event.orderKey, event.transactionKey))
val order = orderService.findWithItemsByOrderKey(event.orderKey)
?: throw IllegalStateException("주문 정보를 찾을 수 없습니다. orderKey: ${event.orderKey}")
orderService.completeOrder(order.id)
}
/*
주문 완료 롤백
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
fun handle(event: OrderCompletedEvent) {
log.info("주문 완료 롤백 이벤트 수신: $event")
// 주문 복구 완료 이벤트 발행 - 마커용
eventPublisher.publish(OrderFailedSuccessEvent(event.orderKey, event.transactionKey))
// 주문 실패 상태 변경
orderService.failOrderByOrderKey(event.orderKey)
}
/*
결제 실패
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: PaymentFailedEvent) {
log.info("결제 실패 이벤트 수신: $event")
// 결제 실패 완료 이벤트 발행 - 마커용
eventPublisher.publish(PaymentFailedSuccessEvent(event.orderKey, event.transactionKey))
orderService.failOrderByOrderKey(event.orderKey)
}
/*
주문 실패 완료
*/
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
fun handleBeforeFailOrderIssuedCoupon(event: OrderFailedSuccessEvent) {
log.info("주문 실패 완료 이벤트 수신(커밋 전): $event")
val order = orderService.findWithItemsByOrderKey(event.orderKey)
?: throw IllegalStateException("주문 정보를 찾을 수 없습니다. orderKey: ${event.orderKey}")
// 쿠폰 사용 취소
issuedCouponService.unUseIssuedCoupon(order.issuedCouponId)
}
}
// PaymentEventListener
@Component
class PaymentEventListener(
private val paymentService: PaymentService,
) {
private val log = getLogger(this::class.java)
/*
주문 생성
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleAfterPay(event: OrderCreatedEvent) {
log.info("주문 생성 이벤트 수신: $event")
paymentService.pay(
PaymentCommand.Pay(
event.orderId,
event.userId,
PaymentMethodType.valueOf(event.paymentMethod),
Price(event.totalPrice),
event.cardType?.let { PaymentCardType.valueOf(it) },
event.cardNo,
event.orderKey,
),
)
}
/*
결제 실패 완료
*/
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
fun handleBeforeFailIssuedCoupon(event: PaymentFailedSuccessEvent) {
log.info("결제 실패 완료 이벤트 수신(커밋 전): $event")
paymentService.failPayment(event.transactionKey)
}
/*
주문 실패 완료
*/
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
fun handleBeforeFailOrder(event: OrderFailedSuccessEvent) {
log.info("주문 실패 완료 이벤트 수신(커밋 전): $event")
val payment = paymentService.findByTransactionKey(event.transactionKey)
?: throw IllegalStateException("결제 정보를 찾을 수 없습니다. transactionKey: ${event.transactionKey}")
// 재고 감소 오류에 따른 결제 취소
paymentService.cancel(
PaymentCommand.Cancel(
payment.userId,
payment.id,
payment.method,
),
)
}
}
현재는 오케스트레이션 방식으로 구현하여 비즈니스 흐름 파악과 트랜잭션 관리, 장애 처리 측면에서 비교적 용이하다고 느꼈다.
다만, 서비스 간 결합도가 높아 유연하게 서비스를 확장하는 데에는 한계가 있다는 점도 확인했다.
추후 Kafka와 같은 메시지 브로커(MQ)를 도입하여 트랜잭션 관리와 장애 대응을 보다 유연하게 처리할 수 있는 환경을 갖춘다면,
코레오그래피 방식을 적용하여 서비스 간 결합도를 낮추고 확장성을 극대화할 수 있을 것 같다.
결론은 특정 이벤트에 대한 참여 서비스들이 자주 늘어나거나 바뀌지 않게 된다면 오케스트레이션 방식을 사용하는 것이 더 좋고, 자주 늘어나거나 바뀐다면 MQ를 도입하여 코레오그래피 방식을 적용하는 것이 더 좋다고 생각한다.
'Spring > Spring & Spring Boot' 카테고리의 다른 글
| 장애 대응을 위한 Resilience4j 적용 (0) | 2025.08.22 |
|---|---|
| 비관적 락은 정말 느릴까? 실험해봤습니다 (6) | 2025.08.07 |
| @EnableWebMvc 는 조심해서 사용하자 (0) | 2023.05.31 |
| @ControllerAdvice & @RestControllerAdvice (0) | 2023.04.23 |
| Actuator (액츄에이터) (0) | 2023.04.02 |
댓글