예약 시스템에서 재고를 어떻게 관리할까? - 구현
1. 들어가며
이전 요구사항 분석 단계의 마지막에서 "가계약이 만료되면 재고를 복구해야 한다" 라는 요구사항에 대해 만료 정산을 배치로 처리하는 설계를 먼저 떠올렸었다.
하지만, 실제 구현을 하는 시점에서 배치를 붙이게 된다면 운영 포인트가 늘어난다는 점에서 고민해볼 필요가 있다고 판단이 되었다.
배치를 사용했을 때 고려해야할 문제
배치 주기가 늦으면 만료 데이터가 캐시(Redis)에 오래 남음
배치가 멈추게 된다면, 정합성을 직접 복구해줘야 함
예외 케이스에 대해 직접 관리를 해줘야 함
위에서 언급한 문제들 뿐 만 아니라 더 많은 문제가 있을 것으로 예상이 된다. 따라서, 실제 구현을 하는 시점에는 가계약의 생명주기를 배치가 아닌 레디스의 TTL을 사용하여 관리하며, 조회 및 결제(가계약 확인)시점에는 유효한 가계약건에 대해서만 계산하도록 요구사항을 재정의했다.
또한, 락을 통해 동시성 문제를 처리하는 방식에서 임계구역이 끝날 때 까지 락이 유지되는 것이 중요하다는 부분도 깊게 고민을 하게 되었다. 숙소 예약은 날짜 범위 예약으로 임계구역에 대한 락의 시간이 입력(현 시스템에서는 날짜 범위)에 따라 달라질 수 밖에 없다는 특성을 생각해봤다.
예시로 생각을 해보자면, 2일 예약과 30일 예약이 같은 시간 안에 처리가 될 것이라고 가정할 수 없다는 것이다. 이러한 "가변성" 때문에 락 전략을 단순히 성능 기준이 아닌 사용자 경험 기준에서 고민을 했다.
2. 가계약 생성의 전체적인 흐름
Controller: 사용자 정보 + 가계약 정보 (예약할 객실 정보가 담긴 DTO)
Facade: 락 획득 및 재고 검증
Service: 레디스에 가계약 및 보조키(요청키, 수량 등) 저장
가계약 생성의 전체적인 흐름
3. Redis에서 Redisson을 사용한 이유
3-1. Named Lock을 주 전략으로 선택하지 않은 이유
가계약의 특성을 생각해보면 결제 전 임시로 선점한다는 것이다. 이러한 데이터의 특성을 가지고 있는 상태에서 MySQL의 Named Lock을 사용하게 될 경우에 어떤 문제가 발생할까?
커넥션 풀 관리의 복잡성: Named Lock은 일반적인 트랜잭션용 DataSource를 사용하면 커넥션 풀이 고갈될 수 있어 별도의 DataSource 구성이 불가피하다.
단일 DB 종속: Named Lock은 MySQL에서 제공하는 기능으로 다른 데이터베이스에서 사용할 수 없다.
반면, Redis는 DB와 독립적으로 동작하여 커넥션 풀에 영향을 주지 않으며, 인메모리 기반으로 빠른 락 처리가 가능하다. 또한, Redis Sentinel 및 Cluster 구성으로 고가용성 구성을 DB에 비해 간단하게 할 수 있다.
이러한 문제점을 가지고 있었기 때문에 임계구역은 Redis를 활용하여 락으로 보호하고 확정 예약(Reservation)만 DB에 반영하는 구조를 선택하게 되었다.
3-2. Lock interface 관점
Redis에서 락을 사용하게 된다면, Lettuce를 그대로 사용할 것인지 Redisson을 도입할 것인지에 대한 글을 많이 보게 된다. 이 두 클라이언트의 큰 차이는 "Redis 드라이버 레벨에서 개별 명령어 조합을 사용할지" vs "분산락을 라이브러리로 사용할지"에 가깝다.
Lettuce만으로 락을 만든다면?
Lettuce는 Redis 명령을 보내는 클라이언트이다. 따라서 락을 만들기 위해서는 SET NX 같은 명령어 조합을 사용해야 한다.
위처럼 구현을 하게 될 경우 "락 획득"은 쉽게 가능하다. 하지만, 락 대기 전략(폴링, 이벤트), TTL 갱신 등 운영 난이도는 여전히 개발자가 떠안게 된다.
RedisLockRegistry?
여기서 흔히 생기는 오해로 Lettuce 클라이언트를 사용하게 될 경우 "무조건 while 문을 통해 스핀 락을 구현해야 한다" 라는 주장이다.
Spring의 RedisLockRegistry 를 쓰게 된다면, Lettuce 기반이어도 Lock 인터페이스 형태로 분산락을 사용할 수 있으며, Spin Lock과 Pub/Sub 기반 락도 제공해준다!
그럼에도 Redisson을 선택한 이유
이러한 방식을 사용할 수 있겠지만, 핵심은 "락의 범위가 날짜 단위로 늘어나는 범위 락" 이라는 점이다. (roomTypeId, date) 를 기반으로 락을 날짜 수만큼 묶어야 하고, 임계구역에 대한 락 시간도 예약 기간에 따라 가변적이어야 한다.
Redisson 라이브러리의 문서를 보며 매력적인 두 가지 기능을 발견하게 되었다.
MultiLock: 여러 날짜 락을 하나로 묶어서 다룰 수 있어 범위 락에 자연스럽다.
Watchdog: 락 해제 시간을 고정으로 예측하기 어려운 현 시스템에서 정상 처리 중 락 TTL 만료로 임계구역 보호가 깨지는 리스크를 낮출 수 있다.
이러한 특성들을 고려하여 더 잘 어울리는 방식이 Redisson을 활용하는 방안이라고 판단하여 채택하게 되었다. 언급한 두 가지 특징에 대해서도 조금 더 살펴보자.
3-3. 멀티 락(MultiLock)을 통한 날짜 범위 락
숙소 예약의 락 단위는 객실 타입 단일 키가 아닌 "객실 타입 + 날짜"다. 즉 (roomTypeId, date) 락을 날짜 수만큼 잡고, 이를 MultiLock으로 묶어서 관리했다.
Redisson의 MultiLock은 여러 락을 순차적으로 획득한다. 이러한 특성으로 인해 락 획득 순서가 달라질 경우 데드락이 발생한다는 점을 생각해야 한다! (식사하는 철학자 문제가 발생할 수 있다는 것)
example

여러 해결하는 방안이 존재하지만, 락을 걸기 전에 날짜를 오름차순으로 정렬하여 락 획득 순서를 통일하는 방식으로 해결했다.
3-4. Redisson Watchdog
처음 구현하는 시점에는 tryLock(waitTime, leaseTime, TimeUnit) 메서드를 통해 락 해제 시점(leaseTime)을 고정하는 방식을 채택했었다. 하지만, 범위 락은 임계구역 시간이 고정적이지 않다는 특징을 생각해보게 되었다.
만약 락 해제 시점을 고정하게 될 경우 어떤 문제가 있을까?
정상 처리 중 임계구역이 락 해제 시점을 초과
락이 자동 해제됨
다른 요청이 같은 범위 락에 진입
결과적으로 동시성 제어가 깨지는 현상 발생
이러한 문제를 해결하기 위해 동적 TTL이 필요하다고 판단을 했다. 이 설정 또한 Redisson에서 watchdog이라는 이름으로 지원을 한다!
Redisson docs - configuration

이를 간단하게 정리해보면 leaseTimeout 파라미터 없이 락을 획득했을 때 해당 방식을 사용되며, 작업이 오래걸리는 경우에는 자동 갱신되어 중복 실행을 방지하고, 서버 장애 상황에서는 갱신되지 않기 때문에 무한 잠금을 방지할 수 있다고 한다.
정리를 해보자면 락을 획득한 경우 살아있는 동안 TTL을 자동으로 연장해주기 때문에 개발자가 직접 락 해제 시점을 예측하지 않아도 된다는 것이다.
적용 방법
필요한 경우 Config.lockWatchdogTimeout 설정을 통해 주기적으로 (설정값의 1/3 마다) 락 만료 시간을 lockWatchdogTimeout으로 설정할 수 있다.
3-5. Redisson watchdog 기능을 사용하는게 맞을까?
watchdog 기능을 고민한 이유는 단순했다. 현재 시스템에서 락은 (roomTypeId, date)를 기간만큼 묶는 범위 락이고, 임계구역 시간도 2 ~ 30일 예약처럼 가변적이다.
따라서 leaseTime을 고정 값으로 두게 될 경우 비즈니스 로직이 끝나기 전에 TTL이 만료되어 락이 풀릴 수 있다. 그 순간부터는 같은 범위에 다른 요청이 들어오면서 동시성을 보장할 수 없게될 수 있다.
그래서 자연스럽게 "TTL을 자동으로 연장해주는 watchdog 기능을 적용하는게 더 안전하지 않나?" 라고 고민하게 되었다. 그런데 더 깊게 생각해보니 leaseTime을 예측할 수 없다는건 사실 watchdog이 필요한 이유라기보다, 먼저 해결 할 문제가 있다라고 느껴졌다.
락 범위는 항상 최소한의 범위가 되어야 한다.
임계구역 시간이 가변적이라는 것은 "어떤 케이스에서 얼마나 걸리는지"를 모니터링해야한다는 뜻이다.
watchdog은 TTL을 늘려주면서 겉으로는 문제를 덮어버릴 수 있다!
느려진 요청이 계속 "성공"해버리면, 병목이나 장애 징후를 늦게 발견하게 되는 최악의 상황으로도 이어질 수 있다.
내가 원하는 것은 락이 절대 안 풀리게 하기가 아닌, 락이 풀릴 정도로 오래 걸리는 요청은 명확히 실패시키고 원인을 추적하는 것이라는 점을 기억해야 한다. 그래서 최종적으로 watchdog 도입을 보류하고, leaseTime을 명시하는 방향으로 되돌렸다.
대신 운영 원칙을 다음과 같이 잡게 되었다.
임계구역 최소화 (락을 잡은 후 로직을 줄이기)
leaseTime은 감이 아닌 측정값(p95, p99)을 기반으로 설정
로직 수행 중 락이 해제되었다고 판단이 된 경우 성공으로 끝내지 않고 실패 처리 (롤백)
이렇게 정리하여 watchdog의 기능은 매력적이긴 하지만, 명확한 실패와 관측 가능한 운영이 더 중요하다고 판단하였으며, 설정 시간의 경우 예측하고 모니터링을 한 후 설정 값을 적절하게 변경하는 방향으로 수정하게 되었다.
4. 가계약 생성 로직
4-1. 실시간 예약 가능 여부 검증
가계약 생성 전에 날짜별로 예약 가능 여부를 계산하는 로직이 필요하다.
숙소 예약 가능 여부 계산식

객실 총 수량(totalQuantity) 및 확정 예약 수(reservedCount)는 DB에서 조회
가계약 수(holdCount)는 Redis에서 조회
4-2. 가계약 데이터 모델링
가계약은 레디스에 가계약 정보 + 보조키 + 카운트로 저장하는 구조로 구성을 했다.
가계약 정보 = 가계약 ID(hold ID) :: 가계약 정보(JSON)
가계약 인덱스 = (userId, roomTypeId, checkIn, checkOut) :: 가계약 ID(hold ID)
가계약 요청키 = (holdRequestKey + 조건) :: 가계약 ID(hold ID)
가계약 수 = (roomTypeId, day) :: holdCount
인덱스, 요청키, 수량을 가계약 정보(JSON) 안에 같이 넣어서 끝내는 방안도 존재했지만 키를 없애고 JSON 하나로만 운영하는 것은 어렵다고 판단했다.
1. 가계약 인덱스가 필요한 이유
같은 사용자가 같은 조건으로 가계약(hold)을 여러 개 만드는 케이스를 막기 위해서 요청이 들어올 경우 기존 가계약을 찾아야 한다. 즉, 가계약 정보안에 존재하게 된다면, 전체 스캔이 필요하게 되어 불필요한 데이터까지 긁어와야 하는 문제가 발생하게 된다.
2. 가계약 요청키가 필요한 이유
요청키는 중복 요청을 원자적으로 차단하기 위한 별도 인덱스 키다. hold 내부에 저장만 하게 된다면, 요청키로 즉시 조회할 수 없어 추가적인 조회가 필요하게 되기 때문에 별도의 전용 매핑 키로 O(1) 시간으로 검증 및 등록을 보장할 수 있다.
3. 가계약 수(count)가 필요한 이유
예약 가능 여부 계산은 날짜별 예약 + 가계약 수의 합산을 기반으로 계산을 하게 된다. 하지만, 가계약 정보에 가계약 수가 존재할 경우 해당 날짜에 걸린 가계약들을 모두 모아서 합산해야 한다. 이 방식의 경우 트래픽이 몰리게 될 경우 병목 지점이 될 가능성이 크다.
가계약 수는 가계약을 만들 때 미리 집계해둔 값(aggregation cache)로써의 역할을 하여 예약 가능 여부를 판단할 때 날짜 수만큼 GET으로 끝내는게 더 효과적이라고 판단했다.
4. 가계약 정보를 JSON으로 한 이유
가계약 정보는 “단건 조회”가 목적이다. 확정 및 취소 단계에서 가계약 ID(holdId) 하나로 필요한 정보를 한 번에 가져오려면 JSON 형태로 저장하여 네트워크 왕복 비용을 줄이는 것이 좋다고 판단했다.
5. 정리

멀티 락(MultiLock)을 통해 날짜 범위에 대한 락을 통해 동시성을 제어
실시간 예약 현황은 데이터베이스의 예약 확정 정보 + 가계약 정보를 기반으로 계산
레디스 캐시를 통해 가계약 정보와 조회 및 검증을 위한 보조 구조(index, request-key, count)를 분리해 저장
watchdog 방식을 고려했지만 보류하고, leaseTime을 측정값(p95/p99) 기반으로 설정하고, 초과 시 실패 처리 후 롤백하는 로직을 추가 및 모니터링을 통해 시간 조정
6. 남은 개선 포인트
Lua Script를 통해 Redis rount-trip 비용을 줄이는 방안 고려해보기
명령어 처리 중 장애가 발생한다면 Eventual Consistency 측면에서 괜찮은지?
예약 가능 여부를 조회할 때 각각의 날짜 예약 정보를 가져올 때 holdCount 조회 최적화
날짜별 GET을 MGET 명령어로 변경하여 한 번에 가져오는 방식 고려
락 파라미터 운영 기준 정리 필요
waitTime, leaseTime을 어떻게 모니터링해서 조정할 것인가?
Last updated
Was this helpful?
