
MSA 아키텍처로의 전환을 고려한 트랜잭션 처리 및 이벤트 기반 설계개발/Spring2024. 11. 15. 01:38
Table of Contents
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 전환을 진행할 수 있을 것 같습니다.
'개발 > Spring' 카테고리의 다른 글
서킷브레이커는 또 무엇일까... (1) | 2024.11.28 |
---|---|
Spring과 Kafka를 활용한 트랜잭셔널 아웃박스 패턴 구현 (0) | 2024.11.21 |
Junit & Mock 기반 테스트 코드 도입기 (4) | 2024.09.23 |
Spring 애플리케이션에서 로깅 구현하기 (feat. SLF4J) (0) | 2024.07.12 |
스프링 간단한 커스텀 인증 필터 구현하기 (0) | 2024.06.26 |
@깨굴딱지 :: 깨굴딱지의 코드연못
깨굴딱지의 코드연못입니다
올챙이가 개구리로 거듭나듯, 끊임없는 노력으로 진화하는 개발자의 길을 걷습니다. 🐸