이번 프로젝트에서 가장 핵심이 되는 부분을 꼽자면 바로 티켓 예매 메서드라고 할 수 있다.
단순히 티켓 필드를 하나 추가하면 되는 것이 아니라, 동시성 제어, 캐싱, 정합성 검사 등의 과정이 여럿 포함되기 때문이다.
설명에 들어가기 전, 간략히 내용을 요약하자면 아래의 순서로 진행된다.
@Transactional
override fun createTicket(memberId: Long, request: CreateTicketRequest) {
// 속도가 빠른 캐싱체크를 가장 먼저 해야함
request.seatList.map { seat ->
//캐싱 체크
val key = "${request.eventId}_${request.date}_${seat.ticketGrade}_${seat.seatNumber}"
if (redisCacheService.chkCache(CacheTarget.TICKET, key))
throw TicketReservationFailedException("${seat.seatNumber} 번 좌석은 선택할 수 없습니다. ( 이미 예약된 좌석 in Cache )")
}
val event = eventRepository.findByIdOrNull(request.eventId)
?: throw ModelNotFoundException("event", request.eventId)
Hibernate.initialize(event.availableSeats) // 컬렉션을 명시적으로 초기화 ( LAZY 모드 )
// 예약 날짜 체크
if (request.date < event.startDate || request.date > event.endDate || request.date < LocalDate.now()){
throw TicketReservationFailedException("예매일(${request.date})이 올바르지 않습니다.")
}
// 좌석 예약 가능 상태 확인
val availableSeat = event.availableSeats.find {
it.date == request.date && it.bookable == Bookable.OPEN
} ?:let{
throw TicketReservationFailedException("예매일(${request.date}) 의 예약이 불가능한 상태 입니다.")
}
//DB 체크 - 캐싱관리가 제대로 되고 있다면 생략해도 되는 과정
request.seatList.map{ seat ->
val isReserved = ticketRepository.chkTicket(
request.eventId,
request.date,
seat.ticketGrade,
seat.seatNumber
)
if (isReserved != null) {
throw TicketReservationFailedException("${seat.seatNumber} 번 좌석은 선택할 수 없습니다. ( 이미 예약된 좌석 in DB )")
}
}
// 티켓 생성
request.seatList.map{seat ->
val member = memberRepository.findByIdOrNull(memberId)
?: throw ModelNotFoundException("member", memberId)
val ticket = Ticket(
date = request.date,
grade = seat.ticketGrade,
seatNo = seat.seatNumber,
event = event,
member = member,
price = when (seat.ticketGrade) {
TicketGrade.R -> event.price!!.seatRPrice
TicketGrade.S -> event.price!!.seatSPrice
TicketGrade.A -> event.price!!.seatAPrice
},
place = event.place.name
)
ticketRepository.save( ticket )
// 캐시에 등록
redisCacheService.putCache(CacheTarget.TICKET,
"${request.eventId}_${request.date}_${seat.ticketGrade}_${seat.seatNumber}",
TicketResponse.from(ticket))
// 좌석 예약 수 수정
availableSeat.increaseSeat(seat.ticketGrade)
if (availableSeat.isFull()) {
availableSeat.close()
}
availableSeatRepository.save(availableSeat)
}
eventRepository.save(event)
//캐시 내 이벤트 항목 최신화 하지 않아도 됨. Response 에는 변동사항 없음
}
이 메서드를 개발하며 가장 고민을 많이 했던 부분은 '어떻게해야 응답시간을 줄이고 빠르게 락을 해제할 수 있을까' 에 대한 것이었다.
기껏 오래걸리는 확인작업을 다 마쳤는데 중복좌석이거나 예약할 수 없는 좌석이면 몹시 불쾌해질것 같았기 때문이다. 그래서 가능한 빨리 확인할 수 있는 작업부터 수행하고 에러처리를 하도록 구성해보았다. 시간 측정은 logger 를 사용하여 구간별 로그를 남기는 것으로 시간을 측정하였는데 역시 레포지토리를 통한 조회가 가장 시간이 700~1000 사이로 오래 걸려 뒷쪽으로 빼놓기로 했다.
캐싱으로 데이터를 확인하는 시간은 약 8ms 정도. 컨디션에 따라 100배 정도 빠를 수 있는 것이니 캐싱을 사용해야하는 이유를 확실히 느끼게 되었다.
기타 단순 값 비교 과정은 0~3ms 정도 소요되었다.
그리고 [예매하기] 후 [결제하기] 까지의 과정은 별도의 유예시간을 주어 손이 좀 느리더라도 좌석 선택까지만 성공하면 나머지는 조금 천천히 진행할 수 있도록 하였다.
이제 남은 일은 이 과정을 View 로 잘 표현해내고 배포하는 일만 남았다..!