2025. 5. 31. 02:46ㆍ개발
예매의 정의
좌석 선택 + 예매 vs 예매
좌석이 있는 것을 확인하고 결제를 위한 과정(카드 번호 입력, 인증, 혹인 외부 페이먼츠 서비스 이용)을 거쳤는데 오류가 뜨면 이는 사용자에게 매우 부정적인 경험으로 다가옵니다.
따라서 좌석 선택과 좌석 예매 api로 분리를 하여 두가지 과정을 거치도록 하였습니다.
이번 구현의 특별한 점은 기존의 예매시스템에서 찾아볼 수 없었던 이미 예약된 좌석인건지 다른사람이 선택만 하고 예약안 아직 안한 좌석인지를 보여주는 기능이 있다는 점입니다.
요구사항
- 특정 상영시간대의 특정 좌석은 하나의 유저만 가질수 있습니다.
- 동시에 같은 좌석에 요청이 와도 상영시간이 다르면 차례로 수행합니다.
- 동시에 같은 상영시간대의 같은 좌석에 요청이 오면 첫번째 요청만 성공합니다.
- 좌석 선택 후 5분안에 예매를 하지 않으면 선택이 취소됩니다.
- 상영관은 1개이고 좌석은 150개며 상영시간은 매주 35개 존재합니다.
비즈니스 로직
좌석 선택
- 좌석 유효성 검사
- 좌석 선택
예매
- 좌석 유효성 검사
- 예매 객체 생성
- 예매(디비 삽입)
좌석 선택 구현 방식 : 휘발성 데이터라는 특징
구현을 위해서는 특정 상영시간에, 특정 좌석에, 누가 선택을 했는지 저장을 할 필요가 있었습니다.
먼저 DB에 저장을 할지 캐시에 저장을 할지 고민을 했는데 결론은 캐시에 저장을 하였습니다.
좌석 선택은 “예매 확정전의 임시 상태”라는 특징으로 일정 시간뒤 삭제된다는 것, “불필요한 디비 커넥션 소모 방지”가 그 이유입니다.
이 프로젝트에서는 로컬 캐시전략을 선택하였는데 속도가 빠르고, 단일 인스턴스 환경이었기 때문입니다. 다만, 추후의 확장성을 대비해, 캐시 인터페이스를 두어 구현을 분리하였습니다.
Spring에서 사용할 수 있는 캐시 라이브러리로 여러가지가 존재했는데 다음을 고려하였습니다.
- Eviction은 적용하면 안됨! 즉 Eviction에 관한 지원은 장점이 안된다.
- TTL 설정 필요
- Thread safe
결국 빠르고, Spring Cache와 통합이 가능하며 TTL도 지원하는 Caffeine을 선택하였습니다.
https://github.com/ben-manes/caffeine/wiki/Benchmarks
(위의 출처를 보면 성능은 ConcurrentHashMap도 우수하지만 TTL을 직접 구현해야 합니다.)
아래는 구현 코드 입니다.
결과적으로 비즈니스 로직은 아래와 같이 코드를 짰습니다.
비즈니스 로직 전, 요청한 좌석에 대한 검증은 assertSeatsAreAvailable() 메소드로 구현하여 재사용하였고, 유연하게 검증 조건을 추가할 수 있었습니다.
@Transactional
public void selectSeat(Integer userId, RequestSeatIds requestSeatIds, Integer screeningId) throws Exception {
assertSeatsAreAvailable(requestSeatIds,screeningId,userId);
SeatLockInfo seatLockInfo = new SeatLockInfo(userId, System.currentTimeMillis());
selectSeats(requestSeatIds, screeningId, seatLockInfo);
}
@Transactional
public void reserve(Integer userId, RequestSeatIds requestSeatIds, Integer screeningId) throws Exception {
assertSeatsAreAvailable(requestSeatIds,screeningId,userId);
List<Reservation> reservations = buildReservations(userId,requestSeatIds,screeningId);
reservationService.saveReservations(reservations);
}
기존에는 요청한 좌석이 이미 예약된 좌석인지만 확인했으나 추후 변경사항에 대응하기 위해 메서드를 유효성 검사로 extract 하였었는데, 이로 인해 ‘선택했는지’라는 조건을 쉽게 추가할 수 있었습니다.
좌석 충돌 검사 함수 → 좌석 유효성 검사 함수(선택 여부 검사,충돌 검사,.. 추가 가능)
private void assertSeatsAreAvailable(RequestSeatIds requestSeatIds,Integer screeningId,Integer userId) throws Exception {
assertSeatsNotSelectedByOthers(requestSeatIds,screeningId,userId);
assertSeatsNotReserved(requestSeatIds, screeningId);
// 추후 좌석 유효 조건 추가 가능
}
+좌석 선택 데이터에 관한 접근은 아래와 같이 추후 인스턴스 확장시 레디스를 활용에 대비해 @Cacheble을 사용하였습니다.
@Component
public class SeatSelectionCache{
@Override
@CachePut(value = "seatLocks", key = "#screeningId + '-' + #seatId")
public SeatLockInfo lockSeat(Integer screeningId, Integer seatId,SeatLockInfo seatLockInfo) {
return seatLockInfo;
}
@Override
@Cacheable(value = "seatLocks", key = "#screeningId + '-' + #seatId")
public SeatLockInfo getLock(Integer screeningId, Integer seatId) {
return null;
}
추가로 발생한 문제 (동시성 문제)
이전에는 Reservation 테이블에서 unique column(좌석번호,상영번호)를 통해 DB에서 동시 예매 문제를 방지했었습니다만, 이번에는 애플리케이션 레벨에서 좌석 선택 데이터를 갖고 있기 때문에,
동시에 좌석 선택시, 모두 정상 응답이 가는 문제가 생겼습니다. 이러면 맨 처음에 언급한 좌석 선택후 예매를 하려고 하는데 실패를 하는 문제가 발생하죠.
이 문제를 해결하기 위해서는 락을 걸어줘야 하는데 caffeine이 thread-safe하다는 점을 이용해보려고 했습니다.
@Cacheable이 아니라 caffeine의 cache 인스턴스를 사용했어요.
@Component
@RequiredArgsConstructor
public class CaffeineSeatSelectionCache implements SeatSelectionCache{
private final Cache<String, SeatLockInfo> cache;
@Override
public SeatLockInfo lockSeat(Integer screeningId, Integer seatId,SeatLockInfo seatLockInfo) {
String key = screeningId + "-" + seatId;
SeatLockInfo newLock = new SeatLockInfo(seatLockInfo.getUserId(), System.currentTimeMillis());
if(isLockAlreadyExists(key,newLock))
throw new IllegalStateException("이미 선택된 좌석입니다.");
return existing;
}
결국 테스트가 성공했습니다. 동시에 선택하는 테스트 코드를 작성하여 실행해 보았을 때, 하나의 스레드 외 나머지에서 전부 예외가 발생했어요.
하지만 다음 문제가 발생합니다.
첫번째 문제
- client A : 좌석 1,2,3 선택
- client B : 좌석 3,4,1 선택
- 둘다 실패 처리
원하던건 하나의 스레드가 성공, 하지만 모든 스레드가 실패했습니다.
구현은 아래와 같이 2단계입니다.
- 사용자가 요청한 좌석에 대해 락이 잡혀있는지 검증 후 락이 없다면 락을 획득을 시도합니다.
- 하지만 그 사이에 다른 스레드에서 락을 획득할 수 있으므로 락 획득 실패시 예외를 던집니다.
따라서, 위 문제의 발생이유는 첫번째로 좌석 번호가 정렬이 안되어있으며, 두번째로 각 좌석을 독립적인 개체로 락을 관리하기 때문입니다.
만약 정렬이 보장되어있으면 아무런 문제가 없을까요?
두번째 문제
다음 상황도 존재합니다
- client A : 좌석 1,2,3 선택
- client B : 좌석 3,4,5 선택
- client C : 좌석 5,6,7 선택
테스트 결과 client C 만 좌석선택에 성공합니다.
위 상황의 문제는 client A는 client C와 아무런 겹침이 없음에도 꼬리에 꼬리를 물어 실패 응답을 받는다는 것입니다. 어떻게 보면 첫번째 문제와 유사합니다.
해결책
결국 각 좌석을 독립적으로 락을 관리하면 문제는 해결할 수 없습니다. 그렇다는 것은 하나의 요청을 묶어야 되는데 그럼 결국 초당 처리량이 줄어듭니다. 락의 범위가 커지기 때문이예요
예를들어 메소드에 synchronized 키워드를 붙인다면 메소드 자체에 락을 걸어 동시성 문제는 해결 되겠지만, 동시 처리량은 감소할 것입니다.
좌석이 150개이고 하루에 상영을 10번 할때, 1초에 2000명 요청을 가정하고 테스트를 해봤습니다.
원래는 처리량이 1000TPS 까지 나옵니다.
synchronized를 사용한 후에는 동일 요청시, 최대 667TPS로 감소하였습니다. 평균 응답 속도도 664ms 로 느려진것을 확인할 수 있죠
로컬 환경이라 정확한 수치는 측정할 수 없지만 그래도 몇배 차이로 처리량이 감소한다는 것을 알 수 있어요.
이러면 다음 문제점이 생깁니다.
- 요청이 몰리는 상영과 관련없는 상영 예매 요청에 지장이 간다.
- 대기시간이 늘어난다 : 대기시간이 늘어나게 되면 사용자는 새로운 요청을 지속적으로 보낼 것이고 이는 트래픽 폭증을 유발할 것입니다.
그러면 아래와 같은 해결책이 있습니다.
- 대기열 시스템을 도입하여 트래픽을 유지한다. → 대기열 시스템을 도입하면 사용자는 추가적인 요청을 보내지 않고 대기할 것입니다.
- 폭증이 예상되는 영화만 처리하는 서버를 증설. → 트래픽이 몰리는 영화만 분리한다면 다른 영화예매 요청은 영향을 받지 않을 것입니다. 하지만 이 방법은 미리 어떤 상영시간대에 요청이 몰릴지 예측을 해야해요.
위의 해결책들은 모두 추가적인 인프라를 가용하게 됩니다.
소규모 영화관을 전제로 하는 프로젝트이기 때문에 비용을 아껴야 한다는 가정을 세우고 싶었어요. 또한 문제가 생기는 위의 상황들은 발생 확률이 낮습니다.
그래서 더 고민을 해보다 다음 방법으로 해결했습니다.
락의 범위를 줄이기
private ConcurrentHashMap<Integer,ReentrantLock> screeningLock = new ConcurrentHashMap<>();
각 상영시간에 대해 락을 분리하는 방법입니다. 이렇게 되면 모든 요청이 순서대로 대기하는 것이 아니라 서로 다른 상영시간에 대한 요청은 기다리지 않을 수 있습니다.
성과
약 70%의 사용자가 특정 상영시간에 몰릴 때 상황을 가정하여 테스트를 진행해 보았고 결과는 다음과 같이 개선되었습니다.
'개발' 카테고리의 다른 글
Redis의 응답이 느릴때 (메모리 사용량) (0) | 2025.04.30 |
---|---|
인기게시글 도입을 위한 과정 - 추가적인 고민 (0) | 2025.04.25 |
트랜잭션 데드락 - 고민 (0) | 2025.04.06 |
트랜잭션 데드락 - 외래키 제약조건 (0) | 2025.04.06 |
인기 게시글 도입을 위한 과정 - 자료구조의 선택 (0) | 2025.04.06 |