실시간 좌석 예약 트래픽이 많이 몰린다면,
보다 원활한 제어와 요청 처리를 위해 각 서비스 로직이 본인의 관심사를 온전히 처리할 수 있어야 한다.
콘서트 좌석 예약 정보를 데이터 플랫폼으로 전달하는 요구사항이 추가되는 상황을 가정해보면,
서비스의 규모가 매우 확장되고 대량의 트래픽이 몰릴 경우 메시지 전송 호출로 인해 문제가 생길 수 있다.
이 때, 전달할 수 있는 방법으로 이벤트 처리나 메세지 큐를 사용한 비동기 전송(RabbitMQ, Kafka)을 고려해볼 수 있다.
먼저, 기존 로직으로 데이터 전달을 구현했을 때 발생할 수 있는 문제에 대해 알아보자.
기존 로직의 예약 과정
기존 로직의 예약 과정은 크게 3가지로 나뉜다.
1. 좌석 예약 요청 ▶ 예약중(ING)으로 예약 정보 생성 + 좌석 상태 변경(DISABLE)
2. 결제 요청 & 완료 ▶ 유저 잔액 차감 + 예약완료(COMPLETED)로 예약 정보 수정
3. 예약 취소 ▶ 예약취소(CANCEL)로 예약 정보 수정 + 좌석 상태 변경(AVAILABLE) + 결제 환불 요청 event
본 글에서는 가장 간단하게 1, 2, 3번 로직 후 예약 상태 정보와 함께 예약 정보를 전달하는 방식을 고려해보았다.
예약 정보를 전달하는 로직을 추가하기
기존 로직에 예약 정보를 전달하는 로직을 추가한 로직은 다음과 같다.
1. 좌석 예약
@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());
ConcertDate concertDate = concertReader.findConcertDate(reservation.getConcertDateId());
// 4. 예약 임시 점유 확인 event 발행
eventPublisher.publishEvent(new ReservationOccupiedEvent(this, reservation.getReservationId()));
// :: 추가 ::
// 5. 데이터플랫폼(외부 API)로 예약 정보(예약중) 전송
externalApiService.sendReservationInfo(reservation, ING);
return ReserveResponse.from(reservation, concert, concertDate);
}
2. 결제 완료
@Transactional
public PayResponse pay(Long paymentId, PayRequest request) {
// validator - 결제 상태 검증
Payment payment = paymentRepository.findById(paymentId);
paymentValidator.checkPayStatus(payment.getStatus());
// validator - 사용자 잔액 검증
Users users = userReader.findUser(request.userId());
paymentValidator.checkBalance(payment.getPrice(), users.getBalance());
boolean isSuccess = false;
// 1. 사용자 잔액 차감
BigDecimal previousBalance = users.getBalance();
BigDecimal usedBalance = users.useBalance(payment.getPrice()).getBalance();
if (usedBalance.equals(previousBalance.subtract(payment.getPrice()))) {
// 2-1. 결제 완료 처리
payment = payment.toPaid();
payment.getReservation().toComplete();
isSuccess = true;
} else {
// 2-2. 결제 실패 : 잔액 원복
usedBalance = users.getBalance();
}
// :: 추가 ::
if (isSuccess) {
// 3. 데이터플랫폼(외부 API)로 예약 정보(예약완료) 전송
externalApiService.sendReservationInfo(reservation, COMPLETED);
}
return PayResponse.from(isSuccess, payment, usedBalance);
}
3. 예약 취소
@Transactional
public void cancel(Long reservationId, CancelRequest request) {
// 1. 예약 정보 조회
Reservation reservation = reservationRepository.findByIdAndUserId(reservationId, request.userId());
// validator
reservationValidator.isNull(reservation);
// 2. 결제 상태 변경
reservation.toCancel();
concertService.patchSeatStatus(reservation.getConcertDateId(), reservation.getSeatNum(), Seat.Status.AVAILABLE);
// 3. 결제 내역 환불 처리 event
eventPublisher.publishEvent(new ReservationCancelledEvent(this, reservationId));
// :: 추가 ::
// 4. 데이터플랫폼(외부 API)로 예약 정보(예약취소) 전송
externalApiService.sendReservationInfo(reservation, CANCEL);
}
호출 메서드를 함께 호출했을 때의 문제점
이렇게 기존 로직에 호출 메서드를 붙여 구현했을 때,
좌석 예약 정보를 데이터 플랫폼에 전달하는 위 로직의 문제는 다음과 같다.
- 예약 처리 서비스의 책임과 별개인 데이터 전달 로직이 예약 처리에 영향을 끼치게 된다.
- 추가된 예약 정보 전달 로직이 어떤 이유로 인해 오래 걸릴 경우, 해당 예약 처리 과정의 전체 트랜잭션에 영향을 주어 요청 처리 과정이 오래 걸리게 됨
- 추가된 예약 정보 전달 로직이 실패할 경우, 해당 예약 처리 과정이 모두 rollback되어 실패하게 됨
>> 이렇게 되면 만약 데이터 플랫폼에 장애가 생겼을 경우, 모든 예약 처리 로직이 먹통이 되는 참사가 일어남
이러한 문제를 해결하려면?
위와 같은 문제를 해결하려면 어떻게 해야 할까.
- 우선, 예약 정보 전달 로직이 예약 처리의 트랜잭션에서 분리되어 사용자 요청 처리 시간에 영향을 미치지 않게 한다.
- >> 비동기 처리: 외부 API 호출을 비동기적으로 처리한다. 예를 들어, 이벤트로 발행하거나 메시지 큐 등을 사용하여 외부 API 호출 작업을 별도의 스레드나 프로세스에서 처리할 수 있다.
- 외부 API 호출이 실패할 경우를 대비한다.
- >> 타임아웃 및 오류 처리: 네트워크 문제나 외부 시스템의 오류로 인해 API 호출이 실패할 경우를 대비하여 적절한 타임아웃 설정과 오류 처리 로직을 구현한다. 실패할 경우의 대비책으로 재시도 로직, 보상 로직 등도 마련해야 한다.
- 예약 정보 이벤트와 데이터베이스 작업이 분리되어 있기 때문에, 최종 일관성(Eventual Consistency)을 유지할 수 있도록 처리한다.
- >> Outbox 테이블 저장 방식:
1. 하나의 트랜잭션 내에서 서비스 비즈니스 로직을 수행한 후 결과를 로컬 데이터베이스에 저장할 때, 'Outbox' 테이블에 이벤트 또는 메시지를 저장한다.
2. 이벤트를 발행하거나, 별도의 프로세스에서 Outbox 테이블을 주기적으로 폴링하여 새로운 이벤트를 확인하여 이를 전달한다.
3. 이벤트를 성공적으로 발행한 후, 해당 이벤트를 Outbox 테이블에서 발행 완료로 업데이트한다.
- >> Outbox 테이블 저장 방식:
문제 해결하기
문제 해결하기
현재 로직에서 가장 간단한 해결 방법으로는 event로 비동기 처리하는 방법이 있다.
- 위 1, 2, 3번의 예약 생성, 수정 로직의 트랜잭션이 커밋된 후 데이터 플랫폼에 메시지를 전송하는 event를 발행하여 트랜잭션과 책임을 분리할 수 있다.
- 이 때, event 발행하여 리스너에서 커밋 전 Outbox 테이블에 예약 정보 전송 이벤트 정보를 저장하여 관리하고, 커밋 후 예약 완료 정보를 kafka로 발행한다.
- kafkaListener인 ReserveConsumer로 수신하여 Outbox 데이터를 발행 완료 처리하고, 예약 정보를 전송한다.
- event publisher에서 발행에 실패했을 경우를 대비하여, 시간 간격을 두고 일정 횟수의 재시도 로직을 추가하고 모두 실패했을 경우 로그를 남긴다.
문제 해결 코드 (event 처리)
1. 예약 생성 후 event 호출
@Transactional
@RedissonLock(key = "'reserveLock'.concat(':').concat(#request.concertDateId()).concat('-').concat(#request.seatNum())")
public ReserveResponse reserve(ReserveRequest request) {
// (예약 로직)
// 예약완료 이벤트 발행
eventPublisher.publishEvent(new ReserveEvent(this, null, reservation.getReservationId()));
return ReserveResponse.from(reservation, concert, concertDate);
}
2. 이벤트 수신
// 이벤트 클래스
@Getter
@Setter
@ToString
public class ReserveEvent extends ApplicationEvent {
private Long outboxId;
private final Long reservationId;
public ReserveEvent(Object source, Long outboxId, Long reservationId) {
super(source);
this.outboxId = outboxId;
this.reservationId = reservationId;
}
}
// 이벤트 리스너
@Component
@RequiredArgsConstructor
public class ReservationEventListener {
private final OutboxService outboxService;
private final KafkaProducer kafkaProducer;
private final ReservationMonitor reservationMonitor;
private final ReservationReader reservationReader;
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void saveOutboxReserve(ReserveEvent event) {
// Outbox 데이터 등록
Outbox outbox = outboxService.save(Outbox.toEntity(Outbox.Type.RESERVE, Outbox.Status.INIT, String.valueOf(event.getReservationId())));
// set outboxId
event.setOutboxId(outbox.getOutboxId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onReservedEvent(ReserveEvent event) {
// 예약 완료 kafka 발행
kafkaProducer.publish(KafkaConstants.RESERVATION_TOPIC, event.getOutboxId(), String.valueOf(event.getReservationId()));
}
}
3. kafka 메시지 수신
@Slf4j
@Component
@RequiredArgsConstructor
public class ReserveConsumer {
private final SendService sendService;
private final OutboxService outboxService;
private final ReservationReader reservationReader;
@KafkaListener(topics = KafkaConstants.RESERVATION_TOPIC, groupId = "hhplus-01")
public void reserved(String outboxId, String message) {
try {
log.info("Received RESERVATION_TOPIC: {}", outboxId);
outboxService.toPublished(Long.valueOf(outboxId));
Reservation reservation = reservationReader.findReservation(Long.valueOf(message));
// 예약 정보 전송
sendService.send(new SendCommReqDto(SendCommReqDto.DataType.RESERVATION, JsonUtil.toJson(reservation)));
log.info("RESERVATION_TOPIC: Message processed successfully");
} catch (Exception e) {
log.error("Failed to process message: {}", e.getMessage());
}
}
}'캠프 > 항해 플러스 4기' 카테고리의 다른 글
| 장애 대응 - 부하 테스트 준비(nGrinder → k6) (1) | 2024.05.21 |
|---|---|
| [9주차] WIL (0) | 2024.05.18 |
| 콘서트 예약 서비스의 Transaction 범위와 책임 분리 방안 설계 (0) | 2024.05.16 |
| [8주차] WIL (0) | 2024.05.15 |
| 쿼리 성능 개선을 위한 DB index 처리 (0) | 2024.05.08 |