오늘은 Ticketing Service 팀프로젝트의 도전 기능을 구현했다!
오늘은 어제에 이어 이번 프로젝트의 도전 기능을 구현했다.
먼저, 오늘은 Redis Lettuce를 통한 분산 락 구현과 Redisson을 통한 분산 락 구현의 차이를 알아보았다.
Redisson은 분산 락을 이미 구현해둔 라이브러리인데, 이걸 사용하면 락 획득을 위해 대기하는 사람들에 대한 내용 처리와 락의 TTL 관리를 간단하게 할 수 있다.
실제로 Redisson을 통해 분산 락을 구현할 때 굉장히 간단하게 구현할 수 있었다.
하지만, Redisson이 제공하는 수많은 서비스에 우리 프로젝트에서는 불필요하게 많다는 생각이 들었다.
우리는 티켓팅 서비스인 만큼 재시도나 대기열이 필요하지 않고, 이전에 분산락과 함께 비관적 락을 구현해두었기에 TTL 만료 시에 해당 쓰레드가 끝나지 않았을 경우도 대비해두었다.
좋은 기능들이 많았지만, 이 좋은 기능들을 실제로는 사용하지 않고 있고, 이런 기능들을 그냥 두는 것은 비용 낭비가 된다.
그래서 우리 프로젝트에서는 Redisson이 아닌 Lettuce를 통해서만 분산 락을 구현하기로 결정했다.
관련하여 자세한 내용은 노션을 통해 정리해두었다.
Notion 확인하기
위 자료들을 정리해둔 뒤에는 결제 시간 초과 시 예매가 자동으로 취소되는 로직을 구현하였다.
우리 프로젝트에서는 예매 후 10분 안에 결제를 하지 않으면, 예매가 자동으로 취소되도록 구현할 예정이었다.
이와 함께 좌석의 예매 가능 여부도 변경되어야 했다.
먼저, 스프링의 스케줄러 기능을 활용했는데, 스케줄러는 일정 시간마다 특정한 작업을 반복할 때 사용하는 것이다.
우선 1분마다 한 번씩 예매한지 10분이 넘었는데, 취소되지 않고, 결제되지 않은 예매 내역을 조회하였다.
그리고 조회된 예매 리스트에 따라 해당 예매를 취소 처리하고, 해당 예매에 있는 좌석의 사용 가능 여부를 바꾸었다.
그런데, 이렇게 구현했을 때 한 번에 bookings 조회, seat 조회, show 조회, booking 수정, seat 수정으로 총 5번의 쿼리가 생성되었다.
프로젝트의 규모가 커진다면, 한 번에 5개의 쿼리가 발생하는 것은 어마어마한 비용을 초래할 수 있게 된다.
그래서 두 번째 리팩토링으로 위 내용들을 한 번에 처리할 수 있도록 쿼리문을 직접 생성하였다.
UPDATE bookings b
JOIN seats s
ON b.show_id = s.show_id
AND s.seat_type = REGEXP_SUBSTR(b.seat, '^[A-Z]+')
AND s.seat_number = CAST(REGEXP_SUBSTR(b.seat, '[0-9]+$') AS UNSIGNED)
SET
b.is_canceled = 1,
s.seat_status = 1
WHERE
b.payment_status = 0
AND b.is_canceled = 0
AND b.created_at <= NOW() - INTERVAL 10 MINUTE
이렇게 진행하니 1분마다 단 하나의 쿼리문만 발생시키기 때문에 훨씬 비용이 줄어들었다.
하지만, 여기에서도 큰 문제가 있었는데, seats를 조회할 때 show_id, seat_type, seat_number로 만들어둔 인덱스를 사용하지 못하고 있다는 것이다.
seats의 내용이 많아지게 될 경우, 이 쿼리를 실행할 때마다 seat 테이블의 모든 행을 다 탐색해야 하기 때문에 시간이 훨씬 많이 소요된다.
미리 만들어둔 인덱스를 사용하지 못하는 이유는 seat_type, seat_number를 비교할 때 함수를 사용하기 때문인데, 이에 대한 내용을 어떻게 해결해야 할지 아직 찾지 못했다.
이 부분에 대해서는 쿼리를 하나만 만들면 되는 것이 우선인지, 인덱스를 통해 성능을 개선하는 것이 우선인지를 더 찾아볼 예정이다.
추가로, 스케줄러는 1분마다 해당 로직을 실행시키는데, 이 부분이 너무 불필요하게 느껴졌다.
그래서 예매가 새로 생성될 때마다 생성된 시간을 Redis를 사용해 캐싱해두었다.
if (lastBookedDateTime == null ||
lastBookedDateTime.isAfter(LocalDateTime.now().minusMinutes(10))) {
return;
}
그리고 스케줄러에서는 캐싱된 시간을 확인하고, 캐싱된 시간이 이미 처리되었어야 할 시간이라면 스케줄러의 로직은 실행되지 않도록 구현하였다.
이러니 필요하지 않은 시간에 대해서는 로직이 실행되지 않게 되었다.
이 부분들을 구현한 후에 팀원들과 이야기를 해보니.. EventListener라는 기능이 있다고 하더라..
왜 내가 찾아볼 때는 이런 내용들이 없었는지 참..
그래서 내일은 이 부분에 대해 더 찾아보고, 수정해야할 부분들을 수정할 예정이다.
우리 팀이 작성한 코드는 깃허브를 통해 업로드해두었다.
GitHub 보러가기
오늘 너무 많은 내용들을 찾아보고, 정리하다보니 뇌 용량이 가득 찼다..
더 이상 머리가 돌아가지 않는 관계로.. 오늘은 여기까지만 진행하고, 내일 새로운 마음으로 새롭게 다시 시작해봐야겠다.