본문 바로가기
캠프/항해 플러스 4기

콘서트 예약 서비스의 Transaction 범위와 책임 분리 방안 설계

by 핏차 2024. 5. 16.

무분별한 비즈니스 로직과 Transaction의 규모(범위)는 실제 트래픽이 많아지거나, 서비스 규모가 커질수록 성능 상의 문제들을 발생시킬 수 있다.

 

예를 들어 하나의 Transaction이 너무 많은 작업과 넓은 범위를 맡고 있을 경우 후속 작업에 의해 전체 Transaction이 실패하기도 하고, Transaction에서 느린 조회를 처리하는 경우 요청 처리에 많은 시간이 소요될 수 있으며, Transaction 범위 내에서 Lock을 사용하고 있을 경우 이 Transaction의 생명 주기가 길수록 해당 자원에 접근하는 다른 요청이 대기하거나 심할 경우 데드락이 발생될 수도 있다.

또한 Transaction 범위 내에서 DB 작업과 무관한 작업(외부 API 등)을 수행하고 있는 경우, DB 외적인 작업이 오래 걸리면 Transaction이 길어지거나 DB 외적인 작업의 실패가 Transaction의 범위로 전파되어 해당 비즈니스 로직이 전체 rollback되는 문제가 발생할 수 있다.

 

이러한 이유들로, Transaction의 적절한 범위 설정과, 비즈니스 로직의 책임 분리가 중요하다.


만약 서비스의 규모가 확장된다고 가정했을 경우, 서비스들의 책임 분리는 어떻게 이루어져야 할까?

현재 나의 콘서트 예약 서비스는 크게 1. 콘서트 예약 및 취소, 2. 결제 생성, 결제 및 환불, 3. 사용자 잔액 충전 및 사용의 기능으로 이루어져 있다.

콘서트 예약 서비스의 규모가 확장된다고 가정했을 때, 1, 2, 3번 기능이 각각 책임이 분리가 되어 움직이게 되지 않을까 생각하였다.

2번과 결제 및 환불 기능 자체가 3번 사용자 잔액을 조정하는 것에 종속되어 있다고 생각했기 때문에, 서비스가 분리된다면 애로사항이 많을 것으로 예상된다.

 

나뉘어야 한다면 비즈니스 로직이 콘서트 예약과 결제 정보 생성의 트랜잭션이 분리되고, 콘서트 취소 시 환불 로직이 분리되는 식으로 나누어질 것이기 때문에 규모가 확장된다면 1, 2, 3번의 핵심 도메인들의 책임이 얽혀 있지 않도록 분리하면 좋을 것 같다.

 


현재 내 서비스에서의 트랜잭션 범위를 파악해보고,
위 책임 분리에 따른 트랜잭션 처리의 한계를 찾아보자.

여기서 고민 중인 지점

[결제 요청 로직]
예약과 결제(+잔액) 이렇게 큰 부분으로 우선 책임을 분리한다고 가정했는데, 결제 관련 서비스 로직과 실제 사용자 잔액을 처리하는 부분의 책임을 나눌때 트랜잭션이 어려워질 것 같다.
만약 유저 서비스까지 결제 서비스와 나뉘어진다면 서비스 로직에서 결제 처리를 하는 부분과 실제 pg서비스에 결제 및 환불 요청을 하는 부분이 나누어져야 할 것 같은데, 예를 들어 [결제 요청 - 잔액 차감 - 예약 상태 변경] 이럴 때 어떻게 보상 트랜잭션을 구현해야 할지,,?
결제, 사용자, 예약 서비스가 모두 나뉘어졌을 경우
결제 + 잔액 차감 서비스가 묶여 있을 경우

 

 

ConcertService.class

// 좌석 상태 변경 로직
@Transactional
public void patchSeatStatus(Long concertDateId, int seatNum, Seat.Status status) {
	// 1. 좌석 조회
    Seat seat = concertRepository.findSeatByConcertDateIdAndSeatNum(concertDateId, seatNum);
    if (seat != null) {
    	// 2. 좌석 상태 변경
        seat.patchStatus(status);
    }
}
  • 1. 좌석 조회 → 2. 좌석 상태 변경
    • ∴ 해당 트랜잭션은 단일 기능에 대한 책임이며, 하나의 트랜잭션으로 유지된다.

 

 

PaymentService.class

// 결제 로직
@Transactional
public PayResponse pay(Long paymentId, PayRequest request) {
    // validator - 결제 상태 검증
    // 1. 결제 정보 조회
    Payment payment = paymentRepository.findById(paymentId);
    paymentValidator.checkPayStatus(payment.getStatus());

    // validator - 사용자 잔액 검증
    // 2. 유저 정보 조회
    Users users = userReader.findUser(request.userId());
    paymentValidator.checkBalance(payment.getPrice(), users.getBalance());

    boolean isSuccess = false;
    // 3. 사용자 잔액 차감
    BigDecimal previousBalance = users.getBalance();
    BigDecimal usedBalance = users.useBalance(payment.getPrice()).getBalance();
    if (usedBalance.equals(previousBalance.subtract(payment.getPrice()))) {
        // 4-1. 결제 완료 처리
        payment = payment.toPaid();
        // 5. 예약 완료 처리
        payment.getReservation().toComplete();
        isSuccess = true;
    } else {
        // 4-2. 결제 실패 : 잔액 원복
        usedBalance = users.getBalance();
    }

    return PayResponse.from(isSuccess, payment, usedBalance);
}
  • 1. 결제 정보 조회 → 2. 유저 정보 조회 → 3. 사용자 잔액 차감 → 4-1. 결제 완료 처리 or 4-2. 결제 실패 처리 → 5. 예약 완료 처리
    • 결제 과정과 예약 상태 변경 과정의 책임을 분리하는게 좋을 것 같다.
    • 서비스가 나누어진다면 5. 예약 완료 상태 변경 과정이 실패함으로써 보상 트랜잭션으로 사용자 잔액 차감 처리까지 원복해야 할까? 애매하다.
    • 예약 상태가 예약중 > 완료 처리가 되지 않는다면 결제가 롤백 되어야 할 것 같다.
    • ∴ [결제 요청 + 사용자 잔액 차감과 그의 성공/실패]까지의 결제 과정을 하나의 트랜잭션으로 묶는다면 이후 [예약 상태 변경]을 이벤트 처리하여 책임과 트랜잭션이 분리될 것 같다.
      하지만 만약 결제 요청과 사용자 잔액 차감까지 서비스가 나뉜다면 [결제 요청] 후 [사용자 잔액 차감 event], [예약 상태 변경 event]로 트랜잭션이 나누어져야 하는데, 이렇게 된다면 보상 트랜잭션으로 롤백이 필요하다.


// 결제 생성 로직
@Transactional
public CreateResponse create(CreateRequest request) {
    // 1. 예약 조회
    Reservation reservation = reservationReader.findReservation(request.reservationId());
    // 2. 결제 정보 생성
    Payment payment = paymentRepository.save(request.toEntity(reservation));
    if (payment == null) {
        return new CreateResponse(null);
    }
    return new CreateResponse(payment.getPaymentId());
}
  • 1. 예약 조회 → 2. 결제 정보 생성
    • ∴ 해당 트랜잭션은 단일 삽입 기능에 대한 트랜잭션으로, 책임 분리가 되어 있으며 트랜잭션 하나로 유지될 수 있다.
// 결제 취소 로직
@Transactional
public CancelPaymentResultResDto cancel(Long paymentId) {
    // 1. 결제 정보 조회
    Payment payment = paymentRepository.findById(paymentId);

    // validator
    paymentValidator.checkCancelStatus(payment.getStatus());

    // 결제 취소
    Payment updatedPayment = cancelPayment(payment);
    boolean isSuccess = updatedPayment != null;
    if (isSuccess) {
        return new CancelPaymentResultResDto(true, updatedPayment.getPaymentId(), updatedPayment.getStatus());
    } else {
        return new CancelPaymentResultResDto(false, payment.getPaymentId(), payment.getStatus());
    }
}


private Payment cancelPayment(Payment payment) {
    Payment updatedPayment = payment;

    if (Payment.Status.READY.equals(payment.getStatus())) {
        // 결제 대기 상태 - 즉시 취소
  	// 2. 결제 상태 변경
        updatedPayment = payment.updateStatus(Payment.Status.CANCEL);
    } else if (Payment.Status.COMPLETE.equals(payment.getStatus())) {
        // 결제 완료 상태 - 환불
        // 2. 결제 상태 변경
        updatedPayment = payment.updateStatus(Payment.Status.REFUND);
        // 3. 유저 정보 조회
        Long userId = payment.getReservation().getUserId();
    	Users users = userReader.findUser(userId);
        // 4. 결제 금액 환불
        users.refundBalance(payment.getPrice());
    }

    return updatedPayment;
}
  • 1. 결제 정보 조회 → 2. 결제 상태 변경 →(결제 완료 상태 시)→ 3. 유저 정보 조회 → 4. 잔액 환불
    • 고민1. cancelPayment 메서드에만 @Transactional 을 붙이는 게 로직상 더 깔끔하지 않을까?
      cancel 작업이 하나의 논리적 단위로서 원자성을 유지하는 것이 더 적절한 것 같아 취소 로직 전체를 트랜잭션 범위로 두기로 하였다.
    • 고민2. 결제 상태를 취소로 변경하는 로직이 성공하고, 잔액 환불이 실패하였을 때, 결제 상태 변경과 결제 금액 환불의 책임을 분리하는 것이 더 좋을까 생각해보았다.
      결제 취소 후 다른 트랜잭션에서 결제 금액 환불이 실패했다면, 다시 결제 금액 환불만 재시도하는 처리를 하면 괜찮지 않을까?
      현재 서비스에서 결제 상태 변경의 서비스 내용 자체가 결제 금액 환불이라는 생각이 들었다. 결제 금액 환불(유저 잔액 조정)의 결과가 곧 결제 상태 변경의 결과라고 생각하여 책임을 함께 가져야 한다고 판단하였다.
    • 서비스 로직 상 예약 취소 결제 취소 책임만 분리되는 것이 깔끔하다.
    • ∴ [결제 상태 변경]과 [유저 잔액 환불]은 기존 로직처럼 하나의 트랜잭션으로 관리되면 좋을 것 같다.
      트랜잭션이 분리되어야 한다면 [결제 상태 변경] 후 [유저 잔액 환불 event]로 트랜잭션을 분리하고, 잔액 환불 event가 실패한다면 보상 트랜잭션으로 롤백되어야 한다.

 

 

ReservationService.class

// 예약 로직
@Transactional
@RedissonLock(key = "'reserveLock'.concat(':').concat(#request.concertDateId()).concat('-').concat(#request.seatNum())")
public ReserveResponse reserve(ReserveRequest request) {
    // validator
    reservationValidator.checkReserved(request.concertDateId(), request.seatNum());

    // 1. 좌석 상태 변경
    concertService.patchSeatStatus(request.concertDateId(), request.seatNum(), Seat.Status.DISABLE);
    // 2. 예약 내역 생성
    Reservation reservation = reservationRepository.save(request.toEntity());

    // 3. 콘서트 정보 조회
    Concert concert = concertReader.findConcert(reservation.getConcertId());
    // 4. 콘서트 날짜 정보 조회
    ConcertDate concertDate = concertReader.findConcertDate(reservation.getConcertDateId());
    
    // 5. 예약 임시 점유 event 발행
    eventPublisher.publishEvent(new ReservationOccupiedEvent(this, reservation.getReservationId()));

    return ReserveResponse.from(reservation, concert, concertDate);
}
  • 1. 좌석 상태 변경 → 2. 예약 내역 생성 → 3. 콘서트 정보 조회 → 4. 콘서트 날짜 정보 조회 → 5. 예약 임시 점유 이벤트 발행
    • 좌석 상태를 변경하고, 예약 내역을 생성하여 반환하는 것이 예약 로직의 책임이라고 생각한다. 둘 중 하나만 처리되면 안 되므로 하나의 트랜잭션 주기 내에서 처리되어야 한다.
    • 좌석 상태 변경 성공이 선행되지 않으면 예약 내역을 생성할 수 없다고 생각하였다.
      (만약 예약을 생성하고 좌석 상태 변경을 비동기 이벤트로 다른 트랜잭션에서 처리하도록 하면, 그 사이에 다른 유저가 해당 좌석의 상태를 조회하고 예약을 요청할 수 있는 상황이 일어날 수도 있지 않을까?)
    • 이미 예약 임시 점유를 체크하는 로직은 후처리로, 관심사 밖에 있기 때문에 event로 처리되어 있다.
      결제 정보를 생성하는 로직은 예약 시에 진행하지 않고 유저가 예약 정보가 반환된 UI에서 [결제하기] 버튼을 눌렀을 때, 결제 정보를 생성하도록 책임을 분리하였다.
    • 해당 트랜잭션의 범위와 책임은 적절히 분리되어 있으며, 더 분리되면 기능 처리가 어려워질 것 같다.
// 예약 취소 로직
@Transactional
public void cancel(Long reservationId, CancelRequest request) {
    // 1. 예약 정보 조회
    Reservation reservation = reservationRepository.findByIdAndUserId(reservationId, request.userId());

    // validator
    reservationValidator.isNull(reservation);

    // 2. 결제 내역 조회
    Payment payment = paymentReader.findPaymentByReservationId(reservation.getReservationId());
    if (payment != null) {
        // 3. 결제 내역 존재 시 환불 처리
        paymentService.cancel(payment.getPaymentId());
    }
    // 4. 예약 취소
    reservationRepository.delete(reservation);
    // 5. 좌석 상태 변경
    concertService.patchSeatStatus(reservation.getConcertDateId(), reservation.getSeatNum(), Seat.Status.AVAILABLE);
}
  • 1. 예약 정보 조회 → 2. 결제 내역 조회 → 3. 결제 내역 존재 시 환불 처리 → 4. 예약 취소 → 5. 좌석 상태 변경
    • 예약 취소 로직에서는 예약을 취소하는 로직과 결제 내역을 처리하는 로직의 책임을 분리하면 좋을 것 같다. 결제 내역 환불 처리에서 실패한다면 예약 취소 요청 전체가 롤백되어, 만약 외부 API로 결제 처리를 한다면 외부 API에 장애가 생길 시에 예약 취소 요청의 트랜잭션이 너무 길어질 수 있다.
    • 서비스가 나뉘어진다면 좌석 상태 변경도 이벤트 처리로 트랜잭션을 분리할 수 있다. 이 때, 좌석 상태 변경 이벤트 처리가 적절히 진행되지 못한다면, 문제가 발생할 수 있다.
    • ∴ 해당 트랜잭션은 서비스가 분리될 경우, 책임이 다 다르기 때문에 트랜잭션을 많이 분리해야 할 것이다. 이벤트로 처리한 후 보상 트랜잭션이 필요하다.

 

 

UserService.class

// 잔액 충전 로직
@Transactional
@RedissonLock(key = "'userLock'.concat(':').concat(#userId)")
public GetBalanceResponse charge(Long userId, ChargeRequest request) {
    // 1. 유저 정보 조회
    Users users = userRepository.findById(userId);
    // 2. 잔액 충전
    users = users.chargeBalance(BigDecimal.valueOf(request.amount()));
    return GetBalanceResponse.from(users);
}
  • 1. 유저 정보 조회 → 2. 잔액 충전
    •  해당 트랜잭션의 범위는 단일 서비스로 유지된다.
// 잔액 사용 로직
@Transactional
@RedissonLock(key = "'userLock'.concat(':').concat(#userId)")
public GetBalanceResponse use(Long userId, UseRequest request) {
    // 1. 유저 정보 조회
    Users users = userRepository.findById(userId);

    // validate
    userValidator.insufficientBalance(users.getBalance(), BigDecimal.valueOf(request.amount()));

    // 2. 잔액 사용
    users = users.useBalance(BigDecimal.valueOf(request.amount()));
    return GetBalanceResponse.from(users);
}
  • 1. 유저 정보 조회 → 2. 잔액 사용
    •  해당 트랜잭션의 범위와 책임은 단일 서비스로 유지된다.

 


서비스가 나뉘어졌을 경우의 해결방안

여기서 고민 중인 지점

[예약 취소 - 결제 취소 로직]
현재 이벤트 발행 로직

사용자 잔액 환불 event 실패 시에는 예약 취소 요청을 롤백하지 않고 재시도만 시도하도록 하였다. (후처리는 고객센터로..)
그런데 좌석 상태 변경 event 실패(재시도 후 최종 실패) 시에는 예약 취소, 잔액 환불 요청을 모두 롤백해야 할 것이다.
각각 별도의 트랜잭션을 유지하면서 각 이벤트의 성공/실패에 따라 롤백 등의 후처리를 진행하려면 Saga 패턴을 사용하는 방법밖에 없을까?
  1. 예약 취소 로직 수정
    1. 해결 방안 설계 
      1. 예약 취소와 결제 내역 처리의 책임을 분리하여 결제 내역 처리에 실패 시에는 재시도 등의 로직을 추가하면 좋을 것 같다.
        고민. 이 때, '결제 내역 처리'가 결국 실패했을 경우 원본 작업인 '예약 취소'도 실패 처리되어야 할까? 
        '결제 내역 처리' == '결제 금액 환불' 인데, 환불이 정상적으로 이루어지지 않아도 예약 취소 처리는 유지되어야 할 것 같다. 미환불에 대한 로그를 남기고 이후 미환불에 대한 처리를 진행해야 한다.
      2. 그러려면 예약을 삭제하는 처리가 아닌, 예약 상태를 취소로 변경하는 로직으로 수정하여 취소 상태의 예약 정보를 남기는 코드 수정이 필요하다.
      3. 좌석 상태 변경과 결제 내역 처리의 트랜잭션과 책임을 분리한다.
      4. 이후 좌석 상태 변경도 책임을 분리하여 이벤트로 처리한다.
    2. 해결 방안 구현
      1. 예약 취소 로직: 예약 데이터 삭제에서 예약 상태값 변경으로 로직 수정
      2. 결제 내역 처리 event, 좌석 상태 변경 event 발행 
      3. 결제 내역 처리, 좌석 상태 변경 실패 시 재시도 로직 추가
    3. 수정된 로직
// ReservationService.class
@Transactional
public void cancel(Long reservationId, CancelRequest request) {
    Reservation reservation = reservationRepository.findByIdAndUserId(reservationId, request.userId());

    // validator
    reservationValidator.isNull(reservation);

    reservation.toCancel();

    // 결제 내역 환불 처리 event
    eventPublisher.publishEvent(new ReservationCancelledEvent(this, reservationId));
    // 좌석 상태 변경 event
    reservationEventPublisher.updateSeatStatus(new SeatStatusEvent(this, reservation.getConcertDateId(), reservation.getSeatNum(), Seat.Status.AVAILABLE));
}

// 이벤트 발행
@Getter
public class ReservationCancelledEvent extends ApplicationEvent {

    private final Long reservationId;

    public ReservationCancelledEvent(Object source, Long reservationId) {
        super(source);
        this.reservationId = reservationId;
    }
}

@Getter
public class SeatStatusEvent extends ApplicationEvent {

    private final Long concertDateId;
    private final int seatNum;
    private final Seat.Status status;

    public SeatStatusEvent(Object source, Long concertDateId, int seatNum, Seat.Status status) {
        super(source);
        this.concertDateId = concertDateId;
        this.seatNum = seatNum;
        this.status = status;
    }
}

// 이벤트 리스너
@Component
@RequiredArgsConstructor
public class PaymentEventListener {

    private final PaymentService paymentService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onReservationCancelledEvent(ReservationCancelledEvent event) {
        paymentService.refundReservationCancelled(event.getReservationId());
    }
}

@Component
@RequiredArgsConstructor
public class ConcertEventListener {

    private final ConcertService concertService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onSeatStatusEvent(SeatStatusEvent event) {
        concertService.patchSeatStatus(event.getConcertDateId(), event.getSeatNum(), event.getStatus());
    }
}

// PaymentService.class
@Transactional
@Retryable(value = RuntimeException.class, maxAttempts = 3, backoff = @Backoff(delay = 2000))
public void refundReservationCancelled(Long reservationId) {
    Payment payment = paymentRepository.findByReservationId(reservationId);
    if (payment == null) {
        return;
    }
    // 결제 내역 존재 시 환불 처리
    cancel(payment.getPaymentId());
}

@Recover
public void refundRecover(RuntimeException e, Long reservationId) {
    log.error("All the retries failed. reservationId: {}, error: {}", reservationId, e.getMessage());
}

// ConcertService.class
@Transactional
@Retryable(value = RuntimeException.class, maxAttempts = 3, backoff = @Backoff(delay = 2000))
public void patchSeatStatus(Long concertDateId, int seatNum, Seat.Status status) {
    Seat seat = concertRepository.findSeatByConcertDateIdAndSeatNum(concertDateId, seatNum);
    if (seat != null) {
        seat.patchStatus(status);
    }
}

@Recover
public void recoverSeatStatusEvent(RuntimeException e, Long concertDateId, int seatNum, Seat.Status status) {
    log.error("All the seatStatusEvent retries failed. concertDateId: {}, seatNum: {}, status: {} error: {}", concertDateId, seatNum, status, e.getMessage());
}

 

728x90

'캠프 > 항해 플러스 4기' 카테고리의 다른 글

[9주차] WIL  (0) 2024.05.18
콘서트 좌석 예약 정보를 데이터 플랫폼으로 전달한다면?  (2) 2024.05.16
[8주차] WIL  (0) 2024.05.15
쿼리 성능 개선을 위한 DB index 처리  (0) 2024.05.08
[7주차] WIL  (0) 2024.05.04