= 상황 =
이전 게시글에서 유효성 검증과 예약을 DB에 저장하는 예약 추가 로직을 RabbitMQ 를 도입하며 이를 아래와 같이 분리하게 되었다.
- 유효성 검증 및 메세지 큐 전송 (Sender)
- 메세지 큐 수신 후 DB에 저장 (Listener)
( 이해를 위해 임의로 두 로직을 Sender, Listener 로 명하였음 )
이러한 구조 변경을 통해 예약 순서 보장 , 처리량 증가, 데이터 유실 방지라는 이점을 얻었다.
하지만 이후 서버가 증설되며 동시성 문제와, RabbitMQ의 메세지 처리 방식으로 인한 문제가 발생하였다.
= 문제 =
해당 서비스는 동일 시간대의 예약 가능 좌석이 제한되어 있기에, 메세지를 큐에 전송하기 전 DB 에서 해당 시간대에 예약이 가능한지 확인하는 과정을 거쳤다.
그러나 이 과정에서 아래와 같은 문제가 발생하였다.
- 동일 시간대에 여러 예약이 요청될 경우, Listener 에서 예약을 처리하기 전까지 DB에는 예약 정보가 존재하지 않아 유효성 검증이 불가함
- RabbitMQ 에 쌓여 있는 메세지에 대한 유효성 검증이 불가함
= 고민 =
이러한 문제는 아래와 같은 이유로 인해 발생하게 되었고, 이를 해결하기 위해 각 서버가 동일하게 바라보는 DB에 락을 거는 방법을 우선적으로 고민하게 되었다.
- 서버가 증설 되며 동일한 요청을 동시에 처리하는 점
- 기존 하나의 트랙젝션에서 처리되던 로직이 둘로 나뉘게 된 점.
결국 앞선 요청이 먼저 DB 에 접근해 작업을 완료하고, 그 다음 요청에게 락을 넘겨주면 해결 되는 문제였기에 현재 두 개로 분리된 로직을 하나로 묶어 하나의 트랜젝션에서 처리할 수 있는 방안을 생각하게 되었다.
첫 번째 방안은 아래와 같았다.
- Sender 에서 데이터를 조회하기 전 DB에 락을 검
- Sender 에서 검증 후 Listener 로 메세지를 전달
- Listener 은 요청을 처리하고 락을 해제함
위와 같은 방안은 문제가 발생하는 원인 자체를 없애는 방식이기에 문제에 대한 해결은 보장이 되었지만, 요청 처리 중 문제가 발생할시 DB 락이 정상적으로 해제가 되지 않아 치명적인 문제로 이어질 수 있었다.
이를 보안하기 위해 보상 트렉잭션을 활용한 방안을 생각하게 되었다.
- 요청을 처리하기 전 DB에 트랙잭션을 검
- 전달받은 요청을 Sender와 Listener 에게 비동기적으로 요청을 처리하도록 한다.
- Sender 는 요청에 대한 검증을 처리한 후, Listener 의 요청에 대한 응답을 기다린다.
- Listener 는 예약을 즉시 추가하고 Sender 로 처리 결과를 전송한다.
- Sender 는 Listener 에게 전달 받은 결과를 확인하고 정상적으로 처리 됐을 경우 커밋, 비정상적으로 처리 됐을 경우 해당 요청과 관련된 모든 작업을 롤백한다.
해당 방안은 첫 번째 방안에서 문제가 되었던 실패 이후에 대한 처리가 보안되고, 두 개의 서버를 활용함으로 처리 시간이 단축됐지만
실패시 이를 처리하기 위해 많은 비용이 소모된다는 문제점이 존재했다.
또한 위의 두 개의 방안은 하나의 메소드가 너무 많은 역할과 책임을 갖게 되어 실패지점이 많다는 문제가 있었고, RabbitMQ 를 도입해 얻게 된 많은 이점을 버리게 되었다.
이러한 이유로 인해 각각의 역할을 확실히 구분하는 방향을 고민하기 시작하였고, 최종적으로 캐시와 Redssion 을 활용해 문제를 하결하게 되었다.
= 해결 =
우선 목표로 하는 사항은 아래와 같다.
- Sender 는 예약에 대한 검증만, Listener 는 예약에 대한 저장만 진행하며 독립적으로 동작해야한다.
- Sender 로 동일 시간대의 요청이 동시에 전송이 되어도 예약 순서를 보장해야만 한다.
- Listener 로 전달된 예약은 무결한 요청이어야 한다.
이를 위해 우선, Redis 를 활용한 캐시를 활용하기로 하였다.
Sender 에서 예약을 검증하고 큐로 메세지를 전송하기 전, Redis 에 해당 예약 정보를 저장해두고 이후 DB 데이터와 캐시 데이터를 함께 조회해 예약을 검증하는 방식을 채택함으로 RabbitMQ 에서 아직 처리되지 않은 메세지에 대한 검증이 가능하게 되었다.
다만, 위와 같은 방식만으로는 다중 서버에서 동일한 요청을 처리하였을 때 발생하는 동시성 이슈를 해결할 수 없었기에 Redis 의 분산 락을 활용하기로 하였다.
Redis 의 분산 락은 Lettuce 과 Redisson 으로 크게 2가지가 존재했다.
이 둘은 락을 획득하지 못 해 대기하고 있는 과정에서 큰 차이가 있었는데,
Lettuce 는 락을 얻기 위해 반복적으로 요청을 보내는 반면, Redssion은 Pub/Sub 방식으로 효율적으로 락을 얻을 수 있었다.
또한, Lettuce 은 락 획득/해제 를 별도의 스레드에서 관리할 수 있는 반면,
Redssion은 동일한 스레드에서만 관리가 가능하다는 점이었다.
별도의 스레드에서 락 획득/해제 를 진행하게 되면 예상치 못 한 문제가 발생할 수 있다는 단점은 있지만 그만큼 활용성이 높다는 의미기도 하기에 분명 활용에 따라 큰 이점으로 다가올 수 있었겠지만,
현재는 각 로직이 독립적으로 동작하기에 여러 스레드에서 락을 관리할 일이 없을 뿐더러, Lettuce 동작 방식은 요청이 많으면 많을 수록 락을 획득하는 과정에서 서버에 큰 부담을 줄 수 있을 것이라 예상이 되어 최종적으로 Redssion 을 활용하는 방안으로 코드를 작성하게 되었다.
또한, 락을 획득하고 있는 과정이 길어질 수록 처리량이 감소하기에
락은 DB에 접근하고, 캐시를 저장하는 과정에만 획득하도록 하였다.
= 최종 코드 =
Sender
public void sendAddReservationRequest(...) {
....
// 영업 시간 내의 예약인지 검사
instituteValidator.validateOperatingHours(...);
// 매장 범위 내 좌석인지 검사
instituteValidator.isValidSeatNumber(...);
// 락 획득 -> 예약이 가능한 시간인지 검사 -> 캐시 저장
reservationValidator.checkStartTimeBeforeEndTime(...);
// 예약이 가능한 시간인지 검사
PendingReservationDto pendingReservation = reservationValidator.checkAndReserveTimeSlot(...);
// 메세지 큐 전송
reservationSender.sendAddReservation(...);
}
Listener
@Transactional
public void addReservations(...) {
...
// 임시 저장 데이터 제거
pendingReservationDeleter.delete(...);
// 데이터 저장
reservationCreator.save(...);
}
'기술적 고민' 카테고리의 다른 글
(MSA) 주문 및 결제에 이벤트 기반 SAGA 패턴을 활용한 이유 (0) | 2025.04.26 |
---|---|
예약 관리 서비스에 RabbitMQ 를 도입하게 된 이유 (0) | 2025.03.26 |
데이터 유실 방지 - 외부 저장소(S3) 장애 대응 (0) | 2025.03.04 |
무한스크롤에서 Offset 방식의 문제와 Keyset Pagination 사용 이유 (1) | 2025.02.20 |
메타데이터를 이용한 객체 값 자동 생성 ( DatabaseMetaData ) (0) | 2024.10.08 |