개발/Spring

MSA 아키텍처로의 전환을 고려한 트랜잭션 처리 및 이벤트 기반 설계

깨굴딱지 2024. 11. 15. 01:38

1. 기존 시스템의 문제점 분석

1.1 강결합된 도메인 구조

@Transactional
public PaymentConcertResult paymentConcert(String token, long reservationId) {
    long userId = Users.extractUserIdFromJwt(token);
    Users user = userRepository.findById(userId);

    Queue queue = queueRepository.findByToken(token);
    queue.tokenReserveCheck();

    Reservation reservation = reservationRepository.findById(reservationId);
    user.checkConcertAmount(reservation.getSeatAmount());

    // 낙관적 락을 사용하여 좌석 조회 및 예약 처리
    ConcertSeat concertSeat = concertSeatRepository.findById(reservation.getSeatId());
    concertSeat.finishSeatReserve();

    queue.finishQueue();
    reservation.finishReserve();

    Payment payment = paymentRepository.findByReservationId(reservation.getId());
    payment.finishPayment();

    PaymentHistory paymentHistory = PaymentHistory.enterPaymentHistory(userId, payment.getPrice(), PaymentType.PAYMENT, payment.getId());
    paymentHistoryRepository.save(paymentHistory);

    (여기에 메시지 전송 로직도 추가되겠죠..?)

    return new PaymentConcertResult(concertSeat.getAmount(), reservation.getStatus(), queue.getStatus());
}
  • 하나의 트랜잭션에서 모든 처리가 이루어집니다.
  • 알림 발송 로직 추가 될 경우 실패 시 결제 트랜잭션도 롤백됩니다.
  • 외부 API 호출로 인한 불필요한 대기 시간이 발생합니다.

 

1.2 확장의 어려움

  • 카프카와 같은 메시지 브로커 도입 시 전체 코드 수정이 필요하게 됩니다.
  • MSA 전환 시 서비스 분리가 복잡합니다.
  • 새로운 기능 추가 시 기존 코드 수정 불가피 합니다.

 

2. 개선된 설계

2.1 이벤트 기반 트랜잭션 분리

// 검증로직 분리
protected PaymentValidationResult validatePayment(String token, long reservationId) {
    long userId = Users.extractUserIdFromJwt(token);
    Users user = userRepository.findById(userId);
    Queue queue = queueRepository.findByToken(token);
    queue.tokenReserveCheck();

    Reservation reservation = reservationRepository.findById(reservationId);
    user.checkConcertAmount(reservation.getSeatAmount());

    return new PaymentValidationResult(userId, user, queue, reservation);
}

// 핵심 결제 로직에만 트랜잭션 적용
@Transactional
public PaymentConcertResult paymentConcert(String token, long reservationId) {
    PaymentHistoryInsertEvent historyEvent = null;

    try {
        // 1. 검증
        PaymentValidationResult validationResult = validatePayment(token, reservationId);

        // 2. 상태 업데이트들
        ConcertSeat concertSeat = concertSeatRepository.findById(validationResult.reservation().getSeatId());
        concertSeat.finishSeatReserve();
        validationResult.queue().finishQueue();
        validationResult.reservation().finishReserve();
        Payment payment = paymentRepository.findByReservationId(reservationId);
        payment.finishPayment();

        // 3. 히스토리 이벤트 생성 (아직 발행하지 않음)
        historyEvent = new PaymentHistoryInsertEvent(
                validationResult.userId(),
                concertSeat.getAmount(),
                PaymentType.PAYMENT,
                payment.getId()
        );

        // 4. 결제 완료 이벤트 발행
        eventPublisher.publishEvent(new PaymentCompletedEvent(
                validationResult.userId(),
                payment.getId(),
                payment.getPrice(),
                PaymentType.PAYMENT
        ));

        // 5. 성공 시에만 히스토리 이벤트 발행
        eventPublisher.publishEvent(historyEvent);

        return new PaymentConcertResult(
                concertSeat.getAmount(),
                validationResult.reservation().getStatus(),
                validationResult.queue().getStatus()
        );

    } catch (Exception e) {
        // 실패 시 보상 트랜잭션 이벤트 발행
        if (historyEvent != null) {
            eventPublisher.publishEvent(new PaymentHistoryCompensationEvent(historyEvent));
        }
        throw e;
    }
}

// 히스토리 저장 로직 분리
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePayHistoryCreated(PaymentHistoryInsertEvent event) {
    PaymentHistory paymentHistory = PaymentHistory.enterPaymentHistory(
            event.userId(),
            event.amount(),
            event.paymentType(),
            event.paymentId()
    );
    paymentHistoryRepository.save(paymentHistory);
}

// 예약 완료 이벤트 수신 (텔레그램 메시지 전송)
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleReservationCreated(ReservationCreatedEvent event) {
    messageSender.sendMessage(
            String.format("""
            🎫 콘서트 예약이 완료되었습니다!
            예약 ID: %d
            좌석 번호: %d
            좌석 가격: %d원
            시작 시간: %s
            5분 이내에 결제를 진행해주세요.
            """,
                    event.reservationId(),
                    event.seatId(),
                    event.amount(),
                    event.startDt().format(dateFormatter)
            )
    );
}

 

2.2 보상 트랜잭션

// 히스토리 저장 실패시 삭제처리
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void handlePaymentHistoryCompensation(PaymentHistoryCompensationEvent event) {
    PaymentHistory failedPayment = paymentHistoryRepository.findByPaymentId(event.historyInsertEvent().paymentId());

    if (failedPayment != null) {
        failedPayment.isDelete(true);
        paymentHistoryRepository.save(failedPayment);
    }
}

 

 

3. 아키텍처 개선 효과

3.1 트랜잭션 분리

  • 핵심 결제 로직의 트랜잭션 범위를 최소화 합니다.
  • 부가 기능(알림/이력)의 실패가 결제에 영향을 주지 않습니다.
  • AFTER_COMMIT으로 메인 트랜잭션 완료 후 부가 기능을 처리합니다.

3.2 MSA 전환 용이성

  • 이벤트 기반으로 서비스 간 결합도 감소
  • SAGA 패턴 적용을 위한 기반 마련
    • Choreography 방식: 각 서비스가 이벤트를 구독하여 처리합니다.
    • 보상 트랜잭션으로 데이터 일관성을 보장합니다.

3.3 카프카 도입 준비

  • 현재: Spring Event → 향후: Kafka Message
// 현재
eventPublisher.publishEvent(new PaymentCompletedEvent(...));

// 카프카 도입 후
kafkaTemplate.send("payment-completed", new PaymentCompletedEvent(...));

 

4. 실제 구현 및 테스트

4.1 동시성 테스트

@Test
void 동일_유저가_100번_결제요청을_해도_한번만_정상적으로_처리된다() throws InterruptedException {
    // 100개 스레드로 동시 결제 요청
    ExecutorService executorService = Executors.newFixedThreadPool(100);
    CountDownLatch latch = new CountDownLatch(100);
    
    // 결제는 한 번만 성공하고 사용자 잔액도 정상적으로 차감
    assertThat(paymentList.size()).isEqualTo(1);
    assertThat(updatedUser.getUserAmount()).isEqualTo(500L);
}

 

4.2 비동기 알림 확인

테스트 코드는 테스트대로 통과 확인

 

테스트 코드와 별개로 백그라운드에서 메시지 전송 체크

 

 

 

5. 결론과 향후 발전 방향

5.1 MSA로의 전환 준비

[예약 서비스]  →  [결제 서비스]  →  [이력 서비스]  →  [알림 서비스]
      ↑               ↑                ↑                ↑
      └───────────────┴────────────────┴────────────────┘
                   (보상 트랜잭션)
  • Choreography 전략 채택
    • 각 서비스의 자율성 보장
    • 이벤트 기반 통신으로 결합도 최소화
  • 실패 시나리오 대비 보상 트랜잭션 구현

 

5.2 서비스 경계 설정

// AS-IS: 모놀리식
@Transactional
public PaymentResult processPayment() {
    payment.process();
    history.save();
    notification.send();
}

// TO-BE: 마이크로서비스
// Payment Service
@Transactional
public PaymentResult processPayment() {
    payment.process();
    eventPublisher.publish(new PaymentCompletedEvent());
}

// History Service
@KafkaListener(topics = "payment-completed")
public void handlePayment(PaymentCompletedEvent event) {
    history.save();
}

 

 

결론

현재 진행한 이벤트 기반 설계는 MSA로의 전환을 고려한 첫 단계입니다.

 

Spring Event를 통해 도메인 간 결합도를 낮추고 트랜잭션을 분리함으로써, 향후 카프카와 같은 메시지 브로커 도입이나

서비스 분리가 용이한 구조를 마련 해봤습니다. 하지만 실제 MSA 전환 시에는 더 많은 고려사항이 필요할 것으로 판단됩니다......

  • 서비스 경계와 도메인 분리의 명확한 기준 수립
  • SAGA 패턴과 보상 트랜잭션을 통한 데이터 일관성 확보
  • 장애 격리를 위한 서킷브레이커 도입
  • 일시적 장애 대응을 위한 리트라이 전략 수립

이러한 전략들을 바탕으로, 안정적인 MSA 전환을 진행할 수 있을 것 같습니다.