개요
주문을 하면서 결제 API 로 인해 시간 지연이 되면 주문 트랜젝션도 그만큼 오래 가져가야 할까 ?
좋아요 처리를 할 때, 좋아요는 성공 했지만 상품의 좋아요 집계 처리를 하는 데에 실패하면 좋아요도 취소되어야 할까 ?
이런 질문들은 이벤트 처리를 함으로써 해결할 수 있다.
그러면 한번 이벤트를 발행하고, 소비하는 것에 대해 알아보자
이벤트 처리
그렇다면 여기서 이벤트란 무엇일까 ? 이벤트는 한마디로 "사건" 이다.
그렇다면 사건은 무엇을까? 그리고 우리가 자주 말하는 이벤트 프로듀서, 이벤트 브로커, 이벤트 컨슈머는 무엇일까 ?
예시를 들어보자.
나는 식당에 가서 밥을 먹으려고 한다. 그래서 주문을 하고자 한다.
"여기 주문좀 해주세요"
그랬더니 어떤 종업원이 1번 테이블 주문이요 ~ 라고 외친다.
그러자 1번 테이블 담당자가 와서 내 주문을 받아주었다.
위 내용에 말하고자 하는 3개의 개념이 다 들어있다.
내가 "주문좀 해주세요" 라고 외치는 것이 이벤트를 발행한 것이고, (이벤트 프로듀서)
종업원이 "1번 테이블 주문이요 ~" 라고 이벤트를 중계한 것이 이벤트 브로커이다.
마지막으로, 1번 테이블 담당자가 와서 주문을 받아주는 것이 이벤트를 컨슘 해준 것이다.
이런 상황을 한번 코드에 대입해보도록 하자.
주문을 하고 결제를 한다고 해보자.
주문 서비스에서 "결제좀 해주세요" 라고 하는 것이 이벤트를 발행한 것이고,
event publisher가 "id가 xx인 주문 결제요 ~ " 라고 이벤트를 중계한 것이 이벤트 브로커이다.
마지막으로, event listener가 해당 이벤트를 받아 결제 처리를 하는 것이 이벤트를 컨슘 한 것이다.
이렇듯 어렵지만 일상 속 예시를 가지고 이해하면 이벤트를 이해하기 쉽다.
이벤트 처리 분석
그렇다면 서비스의 어느 부분을 이벤트로 가져가야 할까 ?
우선 하나의 트랜잭션에서 모든 로직을 가져가고자 하면 하나의 트랜잭션이 너무 많은 책임을 지니고 있을 것이다.
예를 들어서 주문에 다음과 같은 로직이 있다고 해보자.
- 사용자 정보 확인
- 상품 정보 확인
- 재고 감소
- 쿠폰 정보 확인
- 쿠폰 사용
- 결제
- 사용자 포인트 차감
- 카드 결제(외부 API)
- 데이터 플랫폼에 주문 정보 전송
이러한 과정은 하나라도 실패하면 전체가 롤백이 된다. 또한, 외부 API 에 요청을 보내는 것도 하나의 트랜젝션에 묶이게 되어 트랜젝션이 너무 길어질수도, 또 외부 API의 실패가 주문 생성의 실패로 이어진다는 것이다.
| 문제점 | 설명 |
| 실패 전파 | PG API가 느려지거나 실패하면 주문 전체가 롤백 |
| 높은 결합도 | user, product, coupon, payment 도메인이 모두 한 흐름에 엮임 |
| 재시도 불가 | 롤백은 가능하지만 어디까지 성공했는지 불확실하여 복구가 어려움 |
| 성능 저하 | 트랜잭션이 길어질수록 DB락이 길게 유지되어 TPS가 하락 |
따라서 주 관심사(핵심 트랜잭션)와 부 관심사(후속 트랜잭션)를 나누어 하나의 트랜잭션에서 어느 것까지 가져가야하는지를 결정하는 것이 중요하다.
예를 들어 위의 경우에는 다음과 같이 나눌 수 있다.
| 구분 | 하는일 |
| 핵심 트랜잭션 | 주문 생성, 금액 계산, 유효성 검증 |
| 후속 트랜잭션 | 포인트 사용, pg 호출 |
그렇다면, 이벤트는 어떻게 사용하면 되는 것일까 알아보자
서비스예시
우선 다음과 같이 주문 이벤트 흐름을 그릴 수 있다.

이러한 식으로 그릴 수 있는데, 각 로직이 연쇄적으로 발생하고 있다.
(물론 대략적으로 그린 것이라 실제와는 다를 수 있다.)
이러한 식으로 핵심 트랜젝션에서 벗어난 것들을 event를 발생하고 이를 컴슘하게하여 처리를 한다면 트랜잭션과 분리하여 트랜잭션을 보다 잘 관리할 수 있다.
특히 위에서도 언급하였듯, 외부 api를 호출하는 경우에는 이러한 식으로 event를 발행하도록 하는 것이 좋아보이기도 한다. (물론 핵심로직인 경우에는 안된다 !!)
그렇다면 이를 한번 코드로 확인해보도록 하자.
코드 예시
@Transactional
public OrderInfo order(String userId, OrderV1Dto.OrderRequest request) {
// 1. 사용자 정보 확인
if (userId == null) {
throw new CoreException(GlobalErrorType.UNAUTHORIZED, "사용자 ID 정보가 없습니다.");
}
// 2. 상품 정보 확인 및 재고 차감
List<OrderCommand.OrderProduct> itemList = new ArrayList<>();
for (OrderV1Dto.ProductOrderRequest productOrderRequest : request.items()) {
ProductEntity productEntity = productService.getProductWithLockAndDecreaseQuantity(productOrderRequest.id(), productOrderRequest.quantity());
itemList.add(new OrderCommand.OrderProduct(productEntity, productOrderRequest.quantity()));
}
// 3. 사용자 확인
UserEntity user = userService.getUserInfoWithLock(userId).orElseThrow(() -> new CoreException(GlobalErrorType.UNAUTHORIZED, "사용자를 찾을 수 없습니다."));
Long totalPrice = orderDomainService.calculateTotalPrice(itemList);
// 4. 쿠폰 확인
UserCouponEntity userCoupon = null;
if (request.couponId() != null) {
userCoupon = userCouponService.getCouponInfoWithLock(request.couponId()).orElseThrow(() -> new CoreException(GlobalErrorType.NOT_FOUND, "쿠폰 ID에 해당하는 객체가 없습니다."));
userCouponDomainService.validateUseCoupon(user, userCoupon, orderDomainService.calculateTotalPrice(itemList));
}
// 5. 상품 가격 확인
Long calculatePrice = userCouponDomainService.calculateUseCouponPrice(totalPrice, userCoupon);
if (!request.totalPrice().equals(calculatePrice)) {
throw new CoreException(GlobalErrorType.BAD_REQUEST, "상품 총 합계와 주문 금액이 일치하지 않습니다.");
}
// 6. 주문
OrderEntity orderEntity = orderService.createOrder(user, itemList, calculatePrice, userCoupon);
paymentService.addPaymentToOrder(orderEntity, request.payment().method(), request.payment().cardId());
orderEntity = orderService.saveOrder(orderEntity);
// 7. 쿠폰 사용
if (orderEntity.getUserCoupon() != null) {
eventPublisher.publishEvent(new UserCouponUseEvent(orderEntity.getPayment().getId(), orderEntity.getUserCoupon().getId(), orderEntity.getTotalPrice(), orderEntity.getId()));
}
// 8. 결제
eventPublisher.publishEvent(new PaymentCreateEvent(
orderEntity.getUser().getId(),
orderEntity.getPayment().getId(),
orderEntity.getUuid(),
orderEntity.getPayment().getMethod(),
orderEntity.getId()
));
eventPublisher.publishEvent(DataPlatformSendEvent.orderComplete(orderEntity.getId(), user.getId(), orderEntity.getUuid(), orderEntity.getTotalPrice()));
return OrderInfo.from(orderEntity);
}
위 7번과 8번을 보면 쿠폰 사용 로직과 결제 로직을 eventPublisher를 이용하여 발행하였다.
모든 코드를 보면 헷갈리니 한번 결제만 확인해보도록 하자
eventPublisher는 내부에 있는 event를 이용하여 어떤 이벤트인지 감지한다.
이런식으로 필요한 데이터들을 모두 넣어준다.
@AllArgsConstructor
@Getter
public class PaymentCreateEvent {
private Long userId;
private Long paymentId;
private String orderUuid;
private PaymentMethod method;
private Long orderId;
}
여기서 주목할 점은 event에 entity 자체를 넣지 않는다는 것이다. entity 자체를 넣으면 다음 문제가 생긴다.
1. 트랜잭션 경계가 애매해져, 이미 끝난 트랜잭션의 entity에 접근하게되면 LazyInitializationException이 발생할 수 있다.
2. 영속성 컨텍스트가 분리가 되어, entity가 수정되어도 db에 반영되지 않는다. (detached 상태가 됨)
3. entity를 전달하면 이벤트 리스너가 도메인 모델에 강하게 결합하게 된다.
이 외에도 메모리를 많이 사용한다거나, 테스트가 복잡해지는 등 많은 문제가 발생하게 된다.
그래서 위에서 만든 이벤트는 다음과 같이 리스너를 만들어서 컴슘해주면 된다.
@Component
@RequiredArgsConstructor
@Slf4j
public class PaymentEventListener {
private final PaymentService paymentService;
private final ApplicationEventPublisher eventPublisher;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handlePayment(PaymentCreateEvent event) {
log.info("payment create event 발생: {}, {}, {}", event.getUserId(), event.getPaymentId(), event.getOrderUuid());
paymentService.payment(event.getUserId(), event.getPaymentId(), event.getOrderUuid(), event.getOrderId());
}
}
아까 위에서 그래프에 그렸듯, 성공하거나 실패한 것들을 eventPublisher를 이용하여 이벤트를 발행해주면 된다.
나는 아래와 같은 결제 로직에서 결제가 성공하였으면, 주문이나 결제 정보를 업데이트 하는 것들은 굳이 같은 트랜잭션에서 하지 않아도 된다고 판단하였다.(핵심 관심사가 아님) 그래서 결제에서도 성공하거나 실패한 이벤트를 발행해줌으로써 후처리를 또다른 이벤트에서 받도록 하였다.
@Transactional
public Boolean payment(Long userId, Long paymentId, String orderUuid, Long orderId) {
PaymentEntity paymentEntity = paymentRepository.findById(paymentId).orElseThrow(() -> new CoreException(GlobalErrorType.NOT_FOUND, "결제 정보가 없습니다."));
switch (paymentEntity.getMethod()) {
case POINT -> {
userService.usePoint(userId, paymentEntity.getOrder().getTotalPrice());
eventPublisher.publishEvent(new PaymentSuccessEvent(paymentId, orderId, userId, orderUuid, paymentEntity.getOrder().getTotalPrice(), paymentEntity.getMethod().name()));
return true;
}
case CARD -> {
PgPaymentInfraV1Dto.PaymentResponse response = pgPayService.pay(userId, orderUuid, paymentEntity.getCard().getUser().getName(), paymentEntity.getCard().getNumber(), paymentEntity.getOrder().getTotalPrice());
if (response.isSuccess()) {
paymentEntity.updateTransactionKey(response.transactionKey());
} else {
eventPublisher.publishEvent(new PaymentFailEvent(paymentId, orderId, userId, response.reason()));
}
return response.isSuccess();
}
}
return true;
}
이런식으로 서비스에서 이벤트가 발행되면 그 이벤트에서 또 다른 이벤트가 발행이 되고, 또 다른 이벤트가 발행되는 등 유기적으로 이벤트들이 이어질 것이다.
결론
이번 주차에서는 하나의 트랜잭션을 분리하는 것에 대해 많은 고민을 하게 만들어 주었다.
원래 이벤트 발행이나 컨슘하는 것에 대해 들어만 보았지 어디서 사용해야하는지나 어떻게 사용해야하는지에 대해 알지 못했다.
또한 기다려주지 않아도 되는 것에 대해 로직을 작성할 때 비동기 처리만 해왔었다.
하지만 그보다 event publisher를 이용하여 이벤트 처리를 하는 것이 더 좋을 것 같다는 생각을 하게 만들어주었다. (더 안전함)
또 로직을 작성할 때 하나의 로직에서 주 관심사가 무엇이고 부 관심사가 무엇인지 더 고민하면서 로직을 작성해야겠다는 생각이 들도록 만들어준 것 같다.
'loop:Pak L2 Backend(2025)' 카테고리의 다른 글
| [loop:Pak] Round7 - WIL 회고 (1) | 2025.08.28 |
|---|---|
| [loop:Pak] Round6 - WIL (0) | 2025.08.22 |
| [Loop:Pak] Round-6 외부 API의 장애에 대처하기 (3) | 2025.08.20 |
| [loop:Pak] Round-5 DB 구조 변경, 인덱스와 캐싱을 이용하여 조회 성능 늘리기 (7) | 2025.08.13 |
| [loop:Pak] Round4 - WIL (2) | 2025.08.09 |