11_트랜잭션분리_&_이벤트기반설계 - loveAlakazam/hh-08-concert GitHub Wiki
- 단일 트랜잭션의 무거움을 회피합니다.
- 성능을 향상시킵니다.
- 서비스간 결합도를 낮춥니다.
- 장애전파를 방지합니다.
여러분이 점심에 스타벅스 커피집에 갔다고 가정합니다. 점심시간대에는 커피를 마시려고 온 사람들이 많아서 커피를 주문하려고하는데 대기줄에는 사람들이 20명이 있습니다! 여러분이 어떻게 기다리느냐에 따라 동기와 비동기의 차이를 답할 수 있습니다.
동기(synchronize)
- 내차례가 올때까지 내 앞사람의 주문이 끝날때까지 계속 기다리는 것입니다.
- 작업이 순차적으로 진행됩니다.
- 이전 작업이 끝나야 다음작업을 시작합니다.
- 긴작업은 대기시간이 증가할 수 밖에 없습니다.
비동기(asynchronize)
- 내차례가 오면 나를 불러오게하고, 줄서는 것은 다른사람에게 맡깁니다. 내차례가 오기전까지 나는 줄을 서지않고 다른일을 합니다. (혹은 진동벨을 받고 진동벨이 울려서 내차례가 오기전까지는 다른일을 합니다.)
- 작업이 병렬적으로 진행합니다.
- 이전 작업 완료여부 상관없이 다음 작업 시작이 가능합니다.
- 긴작업도 대기없이 처리가 가능합니다.
명령(command)
예: 결제완료 했으니, 예약상태를 CONFIRMED 로 변경해주세요.
-
여기서는
결제완료
->예약상태를 CONFIRMED 로 변경
으로 순서가 있습니다. -
강결합 : 의존도가 있습니다.
- 결제가 실패되면, 다음단계인 예약상태 CONFIRMED 변경은 진행하지 않고 실패됩니다.
-
작업에 순서가 있고, 앞으로 일어날 일에 대해서 이미 알고있는 상태를 의미합니다.
-
단계별로 하고자하는 작업만 수행해야될 뿐만아니라 연결되어있는 다른 작업들도 수행해야합니다.
이벤트(event)
예: 결제완료 했습니다
-
결제완료
라는 사실을 알리는 것이고, 이벤트의 구독자(핸들러)들은 이 이벤트가 발생했음을 수신하고 반응할 뿐입니다. -
이미 일어난 일(사건) 을 의미하며, 앞으로 일어날 일에 대해서는 모르는 상태를 의미합니다.
-
느슨한 결합: 의존도를 분리시키는 것을 의미합니다.
-
관심사 분리: 하고자하는 작업만을 수행합니다.
- 하고자하는 작업 외의 작업의 실패를 하더라도, 해당작업의 결과에 영향을 미치지 않습니다.
-
분산트랜잭션
-
그동안은 데이터베이스 하나로 묶여있던 트랜잭션이 분리되었으므로, 전체흐름이 정상적으로 처리되거나 롤백될 수 있는 원자성을 보장을 받았습니다.
-
하지만 하나로 묶여진 트랜잭션작업이 분리되었으니 내부로직 각각에 트랜잭션이 생기므로 전체흐름에 대한 원자성을 보장하지 못하게 되었습니다.
-
트랜잭션작업이 분리되어 작업이 나눠진 것을 분산트랜잭션 이라고 합니다.
-
- 이벤트 발행자(event publisher)는 이벤트 수신자(event listener)를 알 필요 없습니다.
- 이벤트 수신자가 관심을 갖는건 이벤트 발행자가 아니라, 이벤트 입니다.
- 이벤트에 의해 기능의 결합의 끊어내어 독립적인 리스너를 만들 수 있습니다. 이 리스너들은 이벤트 발행자의 작업에 영향을 주지않습니다.
@TransactionalEventListener
와@EventListener
비교
항목 | @EventListener |
@TransactionalEventListener |
---|---|---|
트랜잭션 연동 | 트랜잭션과 무관하게 즉시 실행 | 트랜잭션의 상태에 따라서 실행시점을 제어 |
사용 목적 | 일반적인 이벤트 처리에 사용 | 트랜잭션 결과에 따라서 이벤트를 처리 |
실행 타이밍 | 이벤트 발생시 바로 처리 | 트랜잭션의 조건에 따라 처리 가능 |
지연실행 | 불가능 | phase 속성으로 제어하므로 가능 |
@TransactionalEventListener
의 주요 phase 정리
Phase | 설명 |
---|---|
AFTER_COMMIT |
트랜잭션 커밋 후에 실행 (기본값) |
AFTER_ROLLBACK |
트랜잭션 롤백 후에 실행 |
AFTER_COMPLETION |
커밋/롤백 상관없이 트랜잭션 완료 후에 실행 |
BEFORE_COMMIT |
트랜잭션 커밋 직전에 실행 |
@TransactionalEventListener
와@EventListener
사용 예
사용 어노테이션 | 설명 |
---|---|
@EventListener |
트랜잭션 상태와 상관없이 즉시 처리하고 싶을 경우 |
@TransactionalEventListener(phase = AFTER_COMMIT) |
트랜잭션 커밋(성공)이후에 후속이벤트를 처리하고 싶을 경우 |
@TransactionalEventListener(phase= AFTER_ROLLBACK) |
트랜잭션이 실패했을 때 후속작업이 필요할 경우 |
(AS-IS) 이전 트랜잭션 코드
payAndConfirm
함수내에 결제처리 로직뿐만 아니라 부가로직인 매진확인 및 일간랭킹 추가 까지 고려를 해야했습니다.
이 코드의 문제점은 핵심로직이 아닌 부가로직에서 예외가 발생하게되면 결제처리가 실패가 되버립니다.
package io.hhplus.concert.application.usecase.payment;
@Service
@RequiredArgsConstructor
public class PaymentUsecase {
@Transactional
public PaymentResult.PayAndConfirm payAndConfirm(PaymentCriteria.PayAndConfirm criteria) {
// 유저포인트 조회
UserInfo.GetUserPoint userPointInfo = userService.getUserPoint(UserPointCommand.GetUserPoint.of(criteria.userId()));
UserPoint userPoint = userPointInfo.userPoint();
// 예약데이터 조회
ReservationInfo.Get reservationInfo = reservationService.get(ReservationCommand.Get.of(criteria.reservationId()));
Reservation reservation = reservationInfo.reservation();
long concertSeatPrice = reservation.getConcertSeat().getPrice();
long concertId = reservation.getConcert().getId();
long concertDateId = reservation.getConcertDate().getId();
// 임시예약상태일 경우에 결제 가능
if(reservation.isTemporary()) {
// 포인트 사용
userService.usePoint(UserPointCommand.UsePoint.of(criteria.userId(), concertSeatPrice));
// 예약 확정 변경
reservation.confirm();
// 결제처리 및 결제정보 반환
PaymentInfo.CreatePayment paymentInfo = paymentService.create(
PaymentCommand.CreatePayment.of(reservation)
);
// 매진 확인 및 매진처리
// 전체 좌석의 개수를 구한다
long totalSeats = concertService.countTotalSeats(ConcertCommand.CountTotalSeats.of(concertId, concertDateId));
// 확정상태의 예약 개수를 구한다
long confirmedSeatsCount = reservationService.countConfirmedSeats(ReservationCommand.CountConfirmedSeats.of(concertId, concertDateId));
// 전좌석이 모두 예약확정 상태라면
if( totalSeats == confirmedSeatsCount) {
// 전좌석 예약확정이므로 해당콘서트일정은 매진상태이므로 예약불가능한 상태로 변경한다.
ConcertDate soldOutConcertDate = concertService.soldOut(concertDateId);
// 매진됐다면, 매진시점에 일간 인기콘서트 에 넣는다.
concertRankingRepository.recordDailyFamousConcertRanking(String.valueOf(concertId), soldOutConcertDate.getProgressDate().toString());
}
return PaymentResult.PayAndConfirm.of(paymentInfo);
}
throw new BusinessException(NOT_VALID_STATUS_FOR_PAYMENT);
}
}
(TO-BE) 이벤트 도입으로 트랜잭션 분리 시킨 코드
핵심로직은 성공했지만, 부가로직의 실패로인해서 전체로직이 실패가 일어나는 불편함을 해결하기 위해서 결제처리의 핵심로직 트랜잭션이 성공하면 부가로직인 매진확인 및 일간랭킹 추가가 실행되도록 이벤트를 추가하여 결합도를 낮췄습니다.
package io.hhplus.concert.application.usecase.payment;
@Service
@RequiredArgsConstructor
public class PaymentUsecase {
@Transactional
public PaymentResult.PayAndConfirm payAndConfirm(PaymentCriteria.PayAndConfirm criteria) {
// 유저포인트 조회
UserInfo.GetUserPoint userPointInfo = userService.getUserPoint(UserPointCommand.GetUserPoint.of(criteria.userId()));
UserPoint userPoint = userPointInfo.userPoint();
// 예약데이터 조회
ReservationInfo.Get reservationInfo = reservationService.get(ReservationCommand.Get.of(criteria.reservationId()));
Reservation reservation = reservationInfo.reservation();
long concertSeatPrice = reservation.getConcertSeat().getPrice();
long concertId = reservation.getConcert().getId();
long concertDateId = reservation.getConcertDate().getId();
// 임시예약상태일 경우에 결제 가능
if(reservation.isTemporary()) {
// 포인트 사용
userService.usePoint(
UserPointCommand.UsePoint.of(criteria.userId(), concertSeatPrice)
);
// 예약 확정 변경
reservation.confirm();
// 결제처리 및 결제정보 반환
PaymentInfo.CreatePayment paymentInfo = paymentService.create(PaymentCommand.CreatePayment.of(reservation));
// ✨ 결제성공 이벤트 발행
paymentSuccessEventPublisher.publishEvent(reservation.getId(), concertId,concertDateId);
return PaymentResult.PayAndConfirm.of(paymentInfo);
}
throw new BusinessException(NOT_VALID_STATUS_FOR_PAYMENT);
}
}
결제성공 이벤트 - PaymentSuccessEvent
package io.hhplus.concert.domain.payment;
public record PaymentSuccessEvent(
Long reservationId,
Long concertId,
Long concertDateId
) { }
결제성공 이벤트 발행자 - PaymentSuccessEventPublisher
- 인터페이스
package io.hhplus.concert.domain.payment;
public interface PaymentSuccessEventPublisher {
void publishEvent(long reservationId, long concertId, long concertDateId);
}
- 구현체
package io.hhplus.concert.infrastructure.events;
@Component
@RequiredArgsConstructor
public class PaymentSuccessEventPublisherImpl implements PaymentSuccessEventPublisher {
private final ApplicationEventPublisher eventPublisher;
@Override
public void publishEvent(long reservationId, long concertId, long concertDateId) {
// Spring이 제공하는 내장 이벤트 퍼블리셔를 사용하여 이벤트 발행
eventPublisher.publishEvent(new PaymentSuccessEvent(reservationId, concertId, concertDateId));
}
}
결제성공 이벤트 리스너 - PaymentSuccessEventListener
- 결제성공이벤트 리스너
handleSoldOutConcertDate
핸들러 실행중 발생 예외가 상위 결제처리 결과에 영향 받지 않게끔하기 위하여 try-catch 블록으로 묶었습니다. - 예외가 발생시 실패이벤트
SoldOutConcertDateFailEvent
를 발행하도록 하였습니다. - 실패이벤트를 수신하게되면 슬랙이나 메신저에 바로 에러를 바로 리포트할 수 있도록 구현했습니다.
package io.hhplus.concert.interfaces.events;
@Component
@RequiredArgsConstructor
public class PaymentSuccessEventListener {
// 결제 성공후 매진확인 및 콘서트날짜 매진 처리 로직
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleSoldOutConcertDate(PaymentSuccessEvent event) {
long reservationId = event.reservationId();
long concertId = event.concertId();
long concertDateId = event.concertDateId();
log.info("[PaymentSuccessEventListener] 예약 ID: {} 결제완료", reservationId);
try {
// 전체 좌석의 개수를 구한다
long totalSeats = concertService.countTotalSeats(
ConcertCommand.CountTotalSeats.of(concertId, concertDateId)
);
// 확정상태의 예약 개수를 구한다
long confirmedSeatsCount = reservationService.countConfirmedSeats(
ReservationCommand.CountConfirmedSeats.of(concertId, concertDateId));
// 전좌석이 모두 예약확정 상태라면
if( totalSeats == confirmedSeatsCount) {
// 전좌석 예약확정이므로 해당콘서트일정은 매진상태이므로 예약불가능한 상태로 변경한다.
ConcertDate soldOutConcertDate = concertService.soldOut(concertDateId);
// 매진됐다면, 매진시점에 일간 인기콘서트에 추가한다
concertRankingRepository.recordDailyFamousConcertRanking(
String.valueOf(concertId),
soldOutConcertDate.getProgressDate().toString()
);
}
} catch(BusinessException e) {
log.error("[매진 처리 실패] concertId={}, concertDateId={}, reason: {}", concertId, concertDateId, e.getMessage(), e);
// ✏️ 예상가능한 예외가 발생시 해당예외 리포트
soldOutConcertDateFailEventPublisher.publishEvent(concertId, concertDateId, e.getMessage(), e);
} catch(Exception e) {
log.error("[예상치 못한 매진 처리 실패] concertId={}, concertDateId={}", concertId, concertDateId, e);
// ✏️ 정의되지않은 예외가 발생시 해당예외 리포트
soldOutConcertDateFailEventPublisher.publishEvent(concertId, concertDateId, "Unexpected", e);
}
}
}
매진 확인 및 매진 처리 실패 이벤트 - SoldOutConcertDateFailEvent
package io.hhplus.concert.domain.payment;
public record SoldOutConcertDateFailEvent(
long concertId,
long concertDateId,
String reason,
Throwable cause
) { }
매진 확인 및 매진 처리 실패 이벤트 발행자 - SoldOutConcertDateFailEventPublisher
- 인터페이스
package io.hhplus.concert.domain.payment;
public interface SoldOutConcertDateFailEventPublisher {
void publishEvent(long concertId, long concertDateId, String reason, Throwable cause);
}
- 구현체
package io.hhplus.concert.infrastructure.events;
@Component
@RequiredArgsConstructor
public class SoldOutConcertDateFailEventPublisherImpl implements SoldOutConcertDateFailEventPublisher {
private final ApplicationEventPublisher eventPublisher;
@Override
public void publishEvent(long concertId, long concertDateId, String reason, Throwable cause) {
// Spring이 제공하는 내장 이벤트 퍼블리셔를 사용하여 이벤트를 발행한다
eventPublisher.publishEvent(new SoldOutConcertDateFailEvent(concertId, concertDateId, reason, cause));
}
}
매진 확인 및 매진 처리 실패 이벤트 리스너 - SoldOutConcertDateFailEventListener
package io.hhplus.concert.interfaces.events;
@Component
@RequiredArgsConstructor
public class SoldOutConcertDateFailEventListener {
private static final Logger log = LoggerFactory.getLogger(SoldOutConcertDateFailEventListener.class);
@Async
@EventListener
public void handleReportSlackOfSoldOutConcertDateFailEvent(SoldOutConcertDateFailEvent event) {
log.error("[슬랙 리포팅 대상] 매진처리 실패: concertId={}, concertDateId={}, reason={}",
event.concertId(),
event.concertDateId(),
event.reason(),
event.cause()
);
}
}
현재 시스템은 모놀리식 구조 기반으로 대부분의 비즈니스 로직이 하나의 트랜잭션 내에서 동기적으로 처리되고 있습니다. 이를 개선하고자 이벤트 기반 아키텍쳐를 도입하여 트랜잭션을 분리하였지만 여전히 몇가지 기술적 한계와 확장성 문제를 안고 있습니다.
서비스가 점차 커지고 조직과 도메인도 복잡해짐에 따라 기술적/조직적/비즈니스 적 요구사항을 효과적으로 대응하기 위해서 MSA(Microservices Architecture) 전환이 필요합니다. 이 보고서는 이러한 전환 과정에서 발생할 수 있는 분산 트랜잭션 처리의 한계와 고려사항을 정리하고 이를 해결하기 위한 전략적 접근방안을 제시하는 것을 목적으로 합니다.
- 이벤트는 비동기이기 때문에 순서보장이 어려워서 일관성 보장이 어려움
- 에러발생시 복구 책임이 불분명하여 실패시 어떻게 보상해야될지 명확하지 않음
- 동일 이벤트가 2번이상 처리되는 중복이벤트가 발생하여 상태에 민감한 작업에서는 문제발생
- 명시적인 호출이 어려워서 추적이 어렵고 로컬개발환경에서도 테스트와 디버깅이 어려움
- 이벤트기반 트랜잭션은 롤백이 어려우므로 보상트랜잭션 작성 비용이 증가
현재 결제 유즈케이스 분산트랜잭션 설계안
문제점
유형 | 설명 | 영향 |
---|---|---|
1. 이벤트 전파 실패 |
PaymentSuccessEvent 또는 SoldOutConcertDateFailEvent 가 발행되었으나 리스너에서 처리되지 않을 수 있음 (예: 예외, 앱 재시작, 서버 중단 등) |
부가 로직이 아예 빠지는 현상 |
2. 이벤트 중복 처리 | 장애 복구 상황에서 이벤트가 중복 발행되거나 재처리될 수 있음 | 매진 중복 처리, 랭킹 중복 기록 등 사이드이펙트 |
3. 장애 진단 어려움 | 이벤트 기반 시스템은 호출 트레이스를 따라가기가 어렵고, 흐름 추적이 불명확할 수 있음 | 운영 중 이슈 분석이 까다로움 |
4. 롤백 불가능성 | 매진 처리/랭킹 등록이 완료된 이후라도 결제 취소 등 롤백 시 동기화가 어려움 | 데이터 정합성 이슈 발생 가능 |
5. 트랜잭션 경계 이해 어려움 | 이벤트 간 연결 관계가 많아질수록 도메인 경계와 트랜잭션 경계 혼동 발생 가능 | 유지보수 복잡도 증가 |
6. 메시지 브로커 미사용 시 신뢰성 낮음 | ApplicationEvent 기반은 메모리 내 발행 → 서버 재시작 시 이벤트 손실 위험 | 신뢰성 보장 어려움 |
현재 설계의 문제점을 해결하기 위한 전략
문제 | 개선 방안 |
---|---|
이벤트 손실 | 메시지 브로커 도입 |
중복 처리 | Idempotency 키 사용 |
장애 추적 어려움 | 이벤트 로그 저장 + 트레이싱 연동 |
핸들러 실패 시 리포트 | 실패 이벤트 발행 + Slack 알림 리스너 유지 |
개선된 설계안
sequenceDiagram
autoNumber
participant Client
participant PaymentService
participant KafkaProducer
participant Kafka
participant SoldOutHandler
participant RankingHandler
participant SlackReporter
%% Step 1
Client->>PaymentService: 결제 요청
activate PaymentService
Note right of PaymentService: ▶️ 결제 트랜잭션 시작
%% Step 2
PaymentService->>PaymentService: 예약조회, 포인트차감, 예약확정, 결제처리
%% Step 3
PaymentService->>KafkaProducer: PaymentSuccessEvent 전송
KafkaProducer-->>Kafka: Kafka에 publish (durable)
deactivate PaymentService
Note right of Kafka: ▶️ 트랜잭션 커밋 후 이벤트 발행
%% Step 4
Kafka-->>SoldOutHandler: PaymentSuccessEvent 수신
activate SoldOutHandler
SoldOutHandler->>SoldOutHandler: 중복처리 체크 (idempotency)
%% Step 5
alt 매진 판단 성공
SoldOutHandler->>Kafka: SoldOutConfirmedEvent 발행
Note right of SoldOutHandler: ▶️ 매진 처리 완료
else 매진 판단 실패
SoldOutHandler->>Kafka: SoldOutConcertDateFailEvent 발행
Note right of SoldOutHandler: ⚠️ 매진 실패 → 후속 이벤트 발행
end
deactivate SoldOutHandler
%% Step 6
Kafka-->>RankingHandler: SoldOutConfirmedEvent 수신
RankingHandler->>RankingHandler: 일간 랭킹 추가
%% Step 7
Kafka-->>SlackReporter: SoldOutConcertDateFailEvent 수신
SlackReporter->>SlackReporter: Slack 알림 전송
SAGA 패턴은 분산트랜잭션을 처리하기 위한 전체적인 아키텍쳐 패턴입니다. 긴 비즈니스 트랜잭션을 여러개의 작은 트랜잭션으로 분해하여 순차적으로 실행하는 방식을 말합니다. SAGA패턴이 더 큰 개념 범위에 속하며, 보상트랜잭션은 SAGA 패턴을 이루는 핵심 구성요소 입니다. SAGA패턴에는 대표적으로 Orchestration 와 Choreography 으로 2가지 방법이 존재합니다.
보상트랜잭션 (실패처리 전략)
하나의 전체작업을 여러 서비스가 순차적으로 처리하다가 중간에 실패하면, 이전에 성공한 작업을 실패 이전상태로 되돌리는 작업(보상) 을 실행하는 것을 의미합니다.
보상트랜잭션 의 장점
- 데이터 일관성 : 분산환경에서도 데이터의 일관성을 유지
- 원자성: 전체 작업을 하나의 트랜잭션처럼 처리가 가능하며, 롤백이 어려운환경에서 실용적
보상트랜잭션이 적합한 경우
- SAGA 패턴을 쓸 수 없는 환경
- 각 단계가 이미 외부 API로 구현되어있고, 이전상태로 복구가 가능할 경우
- 즉시 일관성이 아니라 최종 일관성을 허용할 경우
Orchestration 패턴
중앙에서 흐름을 제어하는 오케스트레이터 서비스(중앙 컨트롤러서비스)가 각 서비스에 명령(Command)를 보내고 결과를 받아서 처리하는 구조입니다. 서비스들은 오케스트레이터의 명령에 따라서 동작을합니다. 흐름을 명확하게 순서를 나타낼 수 있지만, 결합도가 증가합니다.
- 중앙 컨트롤러 서비스(ochestrator)가 서비스 흐름제어, 서비스 상호작용 조정하여 프로세스를 관리합니다.
- 단일 마이크로서비스의 분산에 따른 한계가 존재할때 사용됩니다. 여러 마이크로 서비스에 분산되어있는 상호작용을 중앙의 단일 서비스를 통해서 비즈니스 로직을 구현합니다.
- 실패로직을 구현하거나 추적할 수 있습니다.
Orchestration 패턴 장점
- 추적/디버깅 용이 : 전체 흐름을 한곳에 제어하여 추적과 디버깅이 쉬움
- 실패대응 : 실패대응 예측이 가능
Orchestration 패턴이 적합한 경우
- 트랜잭션 플로우가 복잡하고 명확한 제어가 필요할 경우
- 단방향 호출보다는 흐름 통제가 중요한 경우
Choreography 패턴
중앙 조절자 없이 각 서비스가 이벤트를 발행하고, 다른서비스가 이벤트를 구독하여 처리하는 구조입니다. 각 서비스는 이벤트 발생과 구독을 담당하며, 서비스간의 느슨한 결합을 가집니다. 각 서비스가 독립적이지만, 전체 서비스의 흐름을 파악하기가 어렵습니다.
- 여러 마이크로서비스를 조합하는 비즈니스 기능의 구현을 이벤트 기반의 비동기 통신으로 합성하는 패턴.
- 유연성과 확장성, 변경 비용을 고려해서 서비스들간의 낮은 결합도(decoupled)와 비동기 통신이 필요할 때 사용합니다. 서비스 추가와 삭제가 유연합니다.
- 애플리케이션 구조 이해에는 어렵습니다. 다른 마이크로 서비스를 능동적으로 직접 호출하지 않고 이벤트와 메시지 기반으로 반응 모드로 작동합니다.
Choreography 패턴 장점
- 낮은 결합도 : 서비스간 결합이 낮아서 비동기흐름에 최적화
- 높은 확장성과 유연성: 서비스가 추가해도 간섭이 없음
Choreography 패턴이 적합한 경우
- 이벤트 기반 시스템을 사용할 경우
- 서비스가 독립적이면서 자율성이 중요한 도메인인 경우
데이터베이스의 변경과 메시지 발행을 하나의 트랜잭션 내에서 원자적으로 처리하기 위한 패턴입니다. 비즈니스 데이터와 함께 발행할 이벤트를 동일한 데이터베이스에 저장후, 별도의 프로세스가 이를 메시지 브로커로 전송합니다. 그러므로 Outbox패턴은 메시지손실과 데이터 불일치문제를 해결해줍니다.
sequenceDiagram
autoNumber
participant API
participant PaymentService
participant PaymentRepository
participant OutboxRepository
participant OutboxRelay
API->>PaymentService: POST /payments
activate PaymentService
PaymentService->>PaymentRepository: 결제 정보 저장
activate PaymentRepository
deactivate PaymentRepository
PaymentService->>OutboxRepository: "PaymentCompleted" 이벤트 저장
activate OutboxRepository
deactivate OutboxRepository
deactivate PaymentService
PaymentService-->>API: 200 OK (커밋)
Note right of OutboxRelay: 트랜잭션 커밋 이후 polling or Debezium
OutboxRelay->>OutboxRepository: 미전송 이벤트 조회
activate OutboxRelay
OutboxRelay->>Kafka: PaymentCompleted 발행
OutboxRelay->>OutboxRepository: 전송 상태 업데이트
deactivate OutboxRelay
Outbox 패턴 장점
- 원자성 보장 : DB변경과 메시지 발행이 함께 성공하거나 실패합니다.
- 데이터 일관성 : 비즈니스 데이터와 이벤트 발행의 불일치를 방지합니다.
- 신뢰성 : 메시지 손실을 방지합니다.
- 순서 보장: 이벤트 발생 순서대로 처리가 가능합니다.
OutBox 패턴이 적합한 경우
- 데이터의 변경과 이벤트의 발행이 원자적으로 처리되어야할 경우
- 마이크로서비스간 데이터 일관성이 중요한 경우
- 이벤트 기반 아키텍쳐를 구현할 경우
메시지 처리에 반복적으로 실패한 메시지들을 별도의 Queue 로 이동시켜서 관리하는 패턴입니다. 실패가 발생하면 즉시 알려주며, 재시도 로직으로 해결되지 않는 메시지들을 격리하여 시스템의 안정성을 보장합니다. 실생활의 예로는 이메일 발송서비스에 해당됩니다.
sequenceDiagram
participant API
participant PaymentService
participant PaymentRepository
participant Kafka
participant PaymentConsumer
participant SlackNotifier
participant DLQ
API->>PaymentService: POST /payments
activate PaymentService
PaymentService->>PaymentRepository: 결제 정보 저장
activate PaymentRepository
deactivate PaymentRepository
PaymentService->>Kafka: PaymentCompleted 이벤트 발행
deactivate PaymentService
API-->>API: 200 OK (결제 성공 응답)
Note over Kafka: Kafka 브로커에서 비동기 전달
Kafka->>PaymentConsumer: PaymentCompleted 이벤트 수신
activate PaymentConsumer
PaymentConsumer->>PaymentConsumer: 후처리 로직 실행 (e.g. 랭킹 집계)
Note right of PaymentConsumer: 💥 예외 발생
deactivate PaymentConsumer
PaymentConsumer->>SlackNotifier: 실패 알림 전송 (Slack 등)
activate SlackNotifier
deactivate SlackNotifier
PaymentConsumer->>DLQ: 메시지 이동 (Dead Letter)
activate DLQ
deactivate DLQ
비동기처리 + DLQ 패턴 장점
- 시스템 안정성 : 문제가 발생한 메시지가 전체시스템을 마비시키는 것을 방지합니다.
- 가시성 : 실패한 메시지들을 명시적으로 관리합니다.
- 복구 가능성 : 문제 해결후 재처리가 가능합니다.
- 모니터링 : 시스템 문제 패턴 파악이 가능합니다.
비동기처리 + DLQ 패턴이 필요한 경우
- 외부 시스템 연동에서 일시적 장애가 빈번할 경우
- 메시지 형식이나 데이터 품질문제가 발생할 수 있을 경우
- 시스템 장애시에도 메시지 손실을 방지해야하는 경우
- 왼쪽그림은 현재 프로젝트의 구조이자 모놀로식으로 되어있습니다.
- 오른쪽그림은 MSA로 전환하게된다면 도메인 엔티티들을 도메인서비스단위로 그룹을시켜서 도메인별 배포단위를 설계하였습니다.
MSA 설계를 바탕으로 도메인간의 결합의 분리시켰다고 가정할때의 향후 흐름도를 나타냈습니다.
서비스 | 트랜잭션 패턴 | 핵심 처리 전략 |
---|---|---|
Reservation | Outbox + Redis TTL | 좌석 점유 TTL 관리 및 임시 상태 |
Payment | Choreography + 보상트랜잭션 | 결제 성공 시 좌석 확정, 실패 시 취소 |
Ranking | Choreography + 이벤트 기반 | 매진 시점 기준 인기 랭킹 반영 |
Token | Redis + Scheduler | 대기열 순서 관리, 토큰활성화 |
전체 서비스 | 비동기 + DLQ | 모든 비동기 이벤트핸들러에서 발생하는 장애 대응 |
MSA 구조는 도메인 단위로 서비스가 분리되고 각각 독립적으로 배포 및 운영되므로 서비스간 트랜잭션 경계가 명확히 분리되어있어서 전체흐름을 파악하기가 어려우며, 개발환경에서 디버깅이 어렵습니다.
MSA 구조로 전환을 한다고 가정했을 때 트랜잭션 분리 문제를 분석하고, 이 문제를 해결하기 위해서 향후 트랜잭션 처리 전략으로 계획을 세웠습니다. MSA로 전환하게되면 다음과 같은 효과를 얻을 수 있습니다.
- 서비스간 결합도 감소와 유연한 확장 : 새로운 서비스 추가해도 타서비스에 강하게 의존하지 않음
- 확장성과 성능 향상 : 이벤트 처리구조로 병렬처리 가능
- 장애격리 및 복원력 향상 : 하나의 서비스가 장애가 나더라도 전체시스템의 영향을 최소화 시킬 수 있음
- 비동기처리로 인한 사용자 응답속도 개선 : 요청-응답 흐름이 아니라 작업 분산처리가 가능