1. 상황
티켓 예매를 진행하면서 결제 기능을 도입하면서 결제가 성공하면 Reservation의 Status를 변경해야하는 요구사항이 존재했습니다. 해당 로직을 구현하면 아래와 같습니다.
@Service
@Transactional
public class PaymentService {
private final PaymentClient paymentClient;
private final PaymentRepository paymentRepository;
private final ReservationService reservationService;
public PaymentService(final PaymentClient paymentClient, final PaymentRepository paymentRepository, final ReservationService reservationService) {
this.paymentClient = paymentClient;
this.paymentRepository = paymentRepository;
this.ReservationService reservationService;
}
public PaymentConfirmResponse confirmPayment(final PaymentConfirmRequest paymentConfirmRequest, final Long reservationId) {
//기존 결제 승인 로직~~
...
paymentRepository.save(payment);
//예약 상태 변경 로직
reservationService.updateReservationStatusIsBooked;
return paymentConfirmResponse;
}
}
하지만 해당 로직에는 두가지의 문제가 존재합니다.
- PaymentService는 결제승인 뿐만 아니라 Reservation의 상태를 변경하는 책임까지 갖게 된다.
- Payment, Reservation 두 도메인이 강한 결합도를 가진다.
이 문제를 해결하기 위해 스프링 이벤트를 도입하게 되었습니다.
2. 스프링 이벤트(Spring Event)란?
스프링 이벤트는 애플리케이션 객체 간 결합도를 낮추고, 느슨한 결합을 유지하며 커뮤니케이션을 할 수 있도록 돕습니다.
스프링 이벤트 기능은 이벤트 발생자가 이벤트를 발생시키면(publish) 이벤트를 수신하는 리스너를 통해 특정 동작을 수행하게 됩니다.
@FunctionalInterface
public interface ApplicationEventPublisher {
default void publishEvent(ApplicationEvent event) {
publishEvent((Object) event);
}
void publishEvent(Object event);
}
ApplicationEventPublisher은 ApplicationContext에 이벤트를 발행해주는 인터페이스로 이벤트를 ApplicationContext에 넘겨주고 이 이벤트를 Listener가 받아서 처리하게 됩니다.
그럼 본격적으로 스프링 이벤트를 적용해보도록 하겠습니다.
3. 스프링 이벤트 도입
3-1 @EventListener
스프링 이벤트는 이벤트가 발행되면 @EventListner가 붙은 적합한 메서드를 찾아서 실행하게 됩니다.
(publishEvent(Object event)로 발행한 event를 파라미터로 받는 메서드를 실행하게 된다.)
@Component
public class PaymentConfirmedEventListener {
private final ReservationService reservationService;
public PaymentConfirmedEventListener(final ReservationService reservationService) {
this.reservationService = reservationService;
}
@EventListener
public void handlePaymentConfirmedEvent(PaymentConfirmedEvent event) {
reservationService.updateReservationStatusIsBooked(event.getReservationId());
}
}
3-2 PaymentConfirmedEvent.class
public class PaymentConfirmedEvent {
private final Long reservationId;
public PaymentConfirmedEvent(final Long reservationId) {
this.reservationId = reservationId;
}
public Long getReservationId() {
return reservationId;
}
}
스프링 4.2 이전에는 default 메서드만 존재해서 ApplicationEvent를 상속한 객체가 필요했지만
4.2 이후부터는 Object 타입을 받는 메서드가 추가되어 일반 객체로도 이벤트 생성이 가능하게 되었습니다.
3-3 스프링 이벤트를 통해 기존 코드 리팩토링
public class PaymentService {
private final PaymentClient paymentClient;
private final PaymentRepository paymentRepository;
private final ApplicationEventPublisher eventPublisher;
public PaymentService(final PaymentClient paymentClient, final PaymentRepository paymentRepository, final ApplicationEventPublisher eventPublisher) {
this.paymentClient = paymentClient;
this.paymentRepository = paymentRepository;
this.eventPublisher = eventPublisher;
}
public PaymentConfirmResponse confirmPayment(final PaymentConfirmRequest paymentConfirmRequest, final Long reservationId) {
// 결제 승인 로직
....
paymentRepository.save(payment);
//이벤트 발행
eventPublisher.publishEvent(new PaymentConfirmedEvent(reservationId));
return paymentConfirmResponse;
}
해당 로직을 실행하면 정상적으로 이벤트가 수행되어,결제 승인 내역을 저장하는 쿼리와 Reservation의 상태 필드를 변경하는 업데이트 쿼리가 나가는 것을 볼 수 있습니다.
이로써 PaymentService는 결제 승인 처리에 집중하고, 이벤트를 발행해주는 역할만 수행하게 되어 두 클래스의 결합도를 줄일 수 있었습니다.
하지만 여기서 몇가지 문제가 존재하게 되는데....
4. 트랜잭션과 이벤트 처리 고려할 점
현재 위에서 사용한 방식의 이벤트는 동기 방식으로 결제승인 메서드와, 예약상태 변경 이벤트가 하나의 트랜잭션 하나의 스레드에서 동작하게 됩니다.
그렇기 때문에 만약 결제에는 성공했지만 예약 상태를 변경하는 이벤트에서 예외가 발생하면 결제까지 롤백되는 상황이 발생하게 되고, 또한 이벤트 처리가 늦어지면 결제 완료까지 걸리는 시간이 길어지는 상황이 발생합니다.
이런 문제로 인해서 두가지의 고민이 들게 되었습니다.
1. 트랜잭션을 유지하고 예외 발생시 전체 롤백 (기존 방식 유지)
이 방식은 결제와 예약상태 변경을 하나의 트랜잭션으로 처리해 중간에 실패가 발생하면 모든 작업이 롤백 된다.
장점
- 데이터 일관성 보장. 예약 상태 변경이 실패하면 결제도 실패
- 하나의 트랜잭션 내에서 작업이 진행되어 관리가 쉬워진다
단점
- 예약상태 변경에 문제가 생길 경우 이미 진행된 결제도 롤백되어 사용자에게 혼란을 준다.
- 결제 성공 후에도 예약 상태 변경에 시간이 오래 걸리면 결제 프로세스가 지연된다.
2. 트랜잭션을 분리하여 이벤트 처리를 도입
결제는 성공적으로 처리되며, 예약 상태 변경은 별도의 트랜잭션으로 처리한다. 이 경우 이벤트 처리 실패 시 보상 트랜잭션이나 재시도 매커니즘이 필요하다.
장점
- 결제가 성공적으로 완료된 후 예약 상태 변경 작업이 처리되어 결제 지연이 없다.
- 이벤트 처리 실패에도 결제가 유지되어 사용자의 취소 경험을 겪지 않게할 수 있다.
단점
- 이벤트 처리 실패 시 복구 매커니즘을 추가해야 한다.
- 데이터 일관성 문제가 발생할 수 있으며 이를 해결하기 위한 보상 트랜잭션 등을 도입해야 한다.
2번 상황의 경우 어떻게 해결할 수 있을지 고민해보았습니다.
4-1. 이벤트 발행 시점 설정과, 트랜잭션을 분리하기
@TransactionalEventListener를 사용하면 트랜잭션의 특정 단계에서 이벤트를 발행할 수 있습니다.
특히 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 설정하면,
트랜잭션이 성공적으로 커밋된 후에 이벤트가 발행됩니다. 이 방식을 통해 결제 트랜잭션이 정상적으로 완료된 이후에 예약 상태 변경 이벤트가 발생하도록 할 수 있습니다.
하지만, 주의해야 할 점은 트랜잭션이 커밋된 후에 실행되기 때문에 같은 트랜잭션에서 조회는 가능하지만 쓰기 작업은 불가능하다는 것입니다. (이미 커밋된 트랜잭션에 참여하게 되므로 추가적인 변경 작업을 할 수 없기 때문)
이를 해결하기 위해, 이벤트 처리 시 새로운 트랜잭션을 시작하도록 트랜잭션 전파 속성을 Propagation.REQUIRES_NEW로 설정해줍니다. 이렇게 하면 기존 트랜잭션과는 별개로 새로운 트랜잭션에서 이벤트가 실행되어 정상적으로 상태를 변경할 수 있습니다.
@Component
public class PaymentConfirmedEventListener {
private final ReservationService reservationService;
public PaymentConfirmedEventListener(final ReservationService reservationService) {
this.reservationService = reservationService;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePaymentConfirmedEvent(PaymentConfirmedEvent event) {
reservationService.updateReservationStatusIsBooked(event.getReservationId());
}
}
이번 작업을 통해 두 도메인 간의 의존성을 성공적으로 분리하고, 트랜잭션 관리와 이벤트 처리에 대한 기본적인 구조를 갖출 수 있었습니다. 하지만 이벤트 실패 시 대처 방안에 대한 추가적인 작업이 필요합니다.
- 재시도 메커니즘
- 관리자 알림 및 수동 처리,
- 주기적인 배치 작업
현재 위와 같은 대처 방안들을 생각해보았는데 추가적인 공부 후 포스팅을 통해 돌아오도록 하겠습니다.
'Backend > spring' 카테고리의 다른 글
[Spring] OpenFeign란? (1) | 2024.09.11 |
---|---|
[Spring] 서블릿 필터(Filter), 인터셉터(Interceptor) 개념과 차이점 (0) | 2024.08.05 |
[Spring] Dispatcher Servlet이란?? (1) | 2024.06.08 |
[Spring] 의존성 주입의 3가지 방법 (0) | 2024.05.28 |
[Spring] Rest Docs으로 API 문서화하기 (1) | 2023.06.30 |