예약 시스템에서 재고를 어떻게 관리할까? - 요구사항 분석

숙소 예약 시스템을 구현할 때 동시성 제어를 사용한 가계약 방식을 채택한 이유에 대해 작성해보고자 한다.

숙박 예약은 이커머스처럼 재고를 넉넉히 두고 주문을 처리하는 방식과는 성격이 다르다.

예약의 대상이 되는 객실 별로 실제 재고는 많지 않으며, 동일한 시간대에 다수의 사용자가 동일한 객실을 예약할 수 있으므로 경합이 발생할 확률이 높다. (크리스마스와 같은 기념일을 생각해보자)

사용자 경험 관점에서 재고 차감 시점에 대해 어떻게 처리하는지 다양한 시스템에서의 상황과 현재 진행하고 있는 “숙소 예약 시스템”에서는 어떤 방식으로 구현을 해야되는지 파악해보자.

또한, 구현을 진행하면서 가계약 구조와 락을 사용한 이유에 대해 실제 코드를 기반으로 어떻게 구현했는지 정리해보고자 한다.


사용자 경험 관점에서의 재고 차감

다양한 시스템에서 “재고(stock)”라는 개념은 많이 사용한다. 하지만 각 시스템의 특성과 사용자 경험 측면에서 재고 부족이 얼마나 치명적인지에 따라 설계가 달라진다.

1. 이커머스 시스템에서의 재고

이커머스는 보통 "재고를 결제 후 차감"하는 전략을 많이 사용한다. 실제로 쇼핑몰에서 어떤 물건을 구매한 후 재고가 없어 취소 알림이 온 경험이 종종 있다.

이커머스에서는 재고가 상대적으로 많고 결제건에 대한 주문 취소가 치명적인 비즈니스 리스크로 이어지지 않기 때문에 결제 후 재고 차감 방식을 채택해도 큰 문제가 없다고 판단하는 것 같다.

장점

  • 서버 부하가 적다. (락이 거의 필요 없다)

  • 구현이 비교적 단순하다.

단점

  • 사용자는 결제 후 “품절로 주문이 취소되었습니다” 같은 경험을 겪을 수 있다.

    • CS 문의가 늘어날 수 있고, 신뢰도가 떨어질 수 있다.

  • 하지만 비즈니스 측에서 이를 “치명적인 문제”로 취급하지 않는 경우가 많다.

2. 좌석 시스템에서의 재고 (콘서트, 스포츠)

좌석 시스템은 재고가 좌석별(즉, 1개)로 예약을 진행해야 하기 때문에 별도의 선점(Hold) 구조가 필요하다.

과거에 야구 경기를 보러가기 위해 티켓팅을 했을 때 동시 접속 인원은 제한하는 것 뿐만 아니라 특정 좌석에 대해 먼저 “선점”한 뒤 짧은 시간 안에 결제를 완료해야 하는 구조였다. (중간에 예매 화면을 나가더라도 예약이 가능한 구조)

이러한 시스템에서는 하나의 좌석이 두 명에게 예약될 경우 고객 서비스 측면에서 큰 리스크를 가지게 될 것이다. 즉, 결제 후 “좌석이 없어 취소합니다”라는 문자를 통보받게 될 경우 치명적이라고 판단이 된다.

따라서 실제 결제를 하기 전, 좌석에 대한 가계약 상태를 통해 사용자 입장에서는 “내가 선택한 좌석을 확보” 한 후 중복 예약으로 인한 취소 CS가 줄어들어 서비스 측면에서도 높은 신뢰도를 보장할 수 있게 된다.

장점

  • 치명적인 문제를 방지하여 사용자 경험 측면에서 이커머스 방식보다 낫다.

단점

  • 복잡한 락 기법을 사용해야할 가능성이 높다.

  • 결제까지 이어지지 않은 예약건에 대해 관리가 필요하다 (재고 처리).

그렇다면, 숙소 예약 시스템에서는 어떤 방식이 필요할까?

위의 이미지는 “숙소 예약 문제”로 검색했을 때 흔히 볼 수 있는 결과다. 오버부킹, 당일 취소 등은 고객 서비스(사용자 경험)를 크게 떨어뜨리는 이슈다.

숙소 시스템 특징을 먼저 정리해보자.

  • 객실별 실제 재고는 많지 않다.

  • 특정 시점(연휴, 크리스마스 등)에 특정 객실의 경합이 증가한다.

  • 이커머스처럼 “주문 후 재고가 없으니 취소합니다”라고 안내하는 것은 UX/CS 측면에서 치명적이다.

사용자 경험과 고객 서비스 측면에서 이커머스 시스템처럼 대응하기에는 비즈니스적으로 큰 문제가 될 것이라고 판단이 되어, 좌석 예약 시스템처럼 가계약 구조를 도입하는 것으로 구상을 했다.

또한, 가계약을 체결하는 시점에 동일한 시간대에 중복 예약을 막기 위해 동시성 제어가 필요하다고 판단을 했으며, 가계약인 만큼 결제까지 이어지지 않을 경우 재고를 복구하는 구조까지 고려하는 방향을 생각했다.

가계약을 어떻게 구현할까?

여기어때 숙소 상세 페이지 - 객실

위의 사진과 같이 숙박 예약 시스템에서 가계약을 구현하기 위해서는 사용자가 “객실 예약하기” 버튼을 누르는 순간, 단일 요청이라고 해도 내부적으로는 여러 날짜 범위에 대한 객실 재고를 동시에 확인하고 차감해야 한다.

특히 객실 타입(RoomType) 단위로 재고를 관리하는 시스템에서는, 체크인~체크아웃 구간의 각 날짜별 재고를 하나씩 줄이는 구조를 사용한다.

문제는 이 과정이 여러 사용자에 의해 동시에 발생할 때 경합이 매우 쉽게 일어난다는 점이다. 따라서 예약 완료 이전 단계에서 재고를 먼저 확보해두는 가계약(Reservation Hold) 개념이 필요하다.

예약하기 버튼 이후의 기본 흐름

공유 자원에 대해 파악해보자

위에서 설명했듯이 임시 예약하기 API를 호출할 경우 아래와 같은 구조의 데이터를 함께 보내주게 될 것이다.

서버는 이 요청을 기준으로 날짜 범위에 대한 재고를 확인하고, 각 날짜별로 다음 엔티티를 생성하거나 업데이트한다.

객실 타입(RoomType)

  • ID: Auto Increment (채번된 값)

  • 필드: name, totalRoomCount ...

객실 타입은 사용자가 예약을 진행할 때 예약을 거는 "주체"이며, 해당 "객실 타입"에 대해 예약을 진행하는 것이다.

객실 타입이 수량을 가지게 할 경우 "날짜별 수량"을 관리해야하는 숙박 시스템에서는 관리하기가 어렵다고 판단하여 아래와 같은 객실 재고 테이블을 별도로 설계했으며, 날짜별 관리를 위해 [객실 타입 ID, 예약 날짜] 를 ID로 갖는 구조로 설계를 진행했다.

객실 재고(RoomTypeStock)

  • ID: 객실 타입 ID(roomTypeId), 예약 일자(targetDate)

  • 필드: availableQuantity, totalQuantity

특정 날짜에 해당 객실(RoomType)이 몇 개 남아 있는지에 대한 실시간 재고를 관리하기 위한 구조이다. 즉, 이 엔티티 하나가 여러 요청이 동시에 접근하는 공유 자원이다.

체크인 ~ 체크아웃 날짜 범위에 걸쳐 여러 RoomTypeStock(roomTypeId, targetDate) 레코드들에 대해 재고 감소가 일어난다. 따라서 객실 재고에 여러 스레드가 동시에 접근하여 동시성 문제로 인해 객실 재고에 대해 적절하게 처리하지 못할 가능성이 존재한다.

문제가 되는 상황

크리스마스에 동시에 여러 사람이 예약을 진행한다면?..

예시 상황을 정의해보자.

  • 사용자 A: 12/24 ~ 12/26 예약

  • 사용자 B: 12/25 ~ 12/26 예약

⇒ 12/25 ~ 12/26 구간의 남은 재고: 1개 (겹치는 구간 발생)

경합이 발생하면 다음 시나리오가 발생할 수 있다.

  1. A가 12/24, 12/24 재고 차감까지는 성공

  2. 동시에 B가 12/25 ~ 12/26 재고를 차감하며 성공

  3. A는 마지막 날짜(12/26)에서 재고 부족을 발견하고 실패

A 입장에서는 일부 날짜만 확보되었다가 마지막에 실패하는 “부분 성공” 상태가 된다.

이런 상황을 막으려면, 날짜 범위 전체에 대한 재고 확보가 하나의 원자적인 작업으로 처리되어야 하고, 동시에 접근하는 스레드를 제어하기 위한 락이 필요하다.

정리해보자면 다음과 같은 조건을 만족해야 하는 것이다.

  1. 날짜 범위 전체에 대해 재고가 확보되는지 확인해야 함.

  2. 확보가 되면 범위 내 모든 날짜의 재고를 한 번에 차감 (원자적으로 처리됨을 보장)

  3. 확보가 되지 않을 경우 전체 작업을 롤백

  4. 여러 요청이 동시에 같은 객실 재고를 수정하지 못하도록 보호

즉, 서로 다른 스레드가 동시에 동일한 객실의 예약 날짜에 대해 재고 변경을 진행할 수 없도록 막아야 한다는 것이다.

어떤 동시성 제어 기법이 이 도메인에 어울릴까?

숙소 예약 시스템에서 동시성 제어의 핵심은 단순히 “어떤 레코드에 락을 걸 것인가”가 아닌, 어떤 단위로 자원을 보호해야 하는가 즉, 락의 주체를 정확히 정의하는 것이 우선이라고 판단했다.

단일 객실 타입의 재고(RoomTypeStock) 행이 아니라, 객실 타입 + 체크인 ~ 체크아웃 전체 날짜 구간이다.

2025-11-25 ~ 2025-11-27 까지 예약을 진행했다면

  • ID, 2025-11-25

  • ID, 2025-11-26

  • ID, 2025-11-27 이 각각에 대해 모두 락을 걸어야만, “이 날짜 범위 전체에 대해 재고를 확보”했다고 할 수 있다.

가능한 동시성 제어 전략들을 하나씩 살펴보자.

1. JPA Lock

낙관적 락(Optimistic Lock)

  • 충돌이 자주 발생하지 않는 상황에 적합하다.

  • 하지만, 재고 관리 측면에서 동일 날짜 범위로 여러 사용자에 대한 충돌이 발생할 가능성이 존재한다.

  • 충돌 발생시 Version 예외 -> 재시도 -> 전체 날짜 범위 재검증 -> 재시도 폭주로 더 큰 병목이 발생할 가능성 존재

결과적으로 “충돌을 예외로 다루는 구조”가 도메인 특성과 맞지 않다.

비관적 락(Pessimistic Lock)

  • 날짜 범위 전체에 대해 SELECT … FOR UPDATE 로 락을 걸어줘야 한다.

  • 행 락을 획득하는 순서가 요청마다 달라질 수 있다.

    • 이는 데드락 위험이 증가한다는 뜻

    • 락을 걸고 해제하는 책임이 트랜잭션 레벨에 강하게 묶임

    • 멀티 인스턴스 환경에서 DB 커넥션 경쟁이 크게 증가

JPA 락의 경우 락을 거는 대상 자체가 테이블의 행(row)가 되어버려 이러한 구조의 락 모델로는 구현하기 어렵다고 판단했다.

2. DB Lock (Named Lock)

다음으로 고려할 수 있는 기법은 MySQL을 사용하는 상황에서의 Named Lock 을 사용하는 것이다.

이 방식은 JPA 락과는 다르게 논리적 자원에 대해 락을 걸 수 있지만 DB 커넥션에 락이 묶이며, 락을 오래 잡을 경우 MySQL 자체에 영향을 미치게 된다. 또한, 락 분포가 DB에 집중되어 스케일 아웃이 된 상황에서는 더욱 치명적인 문제로 이어질 수 있다.

따라서, DB는 락 서버가 되기엔 적합하지 않다고 판단했다.

3. Redis (Lettuce)

Redis는 분산 환경에서 락 서버로 쓰기에 적합하다. 하지만, 기본 클라이언트인 Lettuce로 구현하게 될 경우 문제가 되는 부분이 있다.

스핀 락 방식을 통해 SET NX 반복 호출

Redis는 싱글 스레드 구조인데 짧은 주기로 SET NX 요청이 몰리게 될 경우 부하가 발생하게 된다. 또한, 선점 시점이 요청 순서를 보장하지 못해 락의 공정성(Fairness) 보장이 불가능해진다.

즉, 동시성은 해결할 수 있지만 경합이 많은 시점에 Redis를 병목으로 만드는 구조가 될 수 있다는 점을 고려해야 한다.

4. Redisson + Multi Lock

마지막으로 Redisson 클라이언트를 사용하는 구조를 선택했다.

Pub/Sub 구조

GitHub를 봤을 때 주기적으로 업데이트를 하는 활발하게 업데이트가 진행이 되고 있으며, Lettuce 처럼 스핀 락 방식의 구조를 개선한 pub/sub 구조를 가졌다는 것이 매력적이었다.

락을 획득하지 못했더라도, 락을 가지고 있던 스레드가 해제한 시점에 unlock 이벤트가 발생했을 때만 락을 획득하도록 재시도를 하게 된다. 따라서, 불필요한 Redis 명령 감소는 Redis에 부하를 줄일 수 있다.

멀티 락(MultiLock)

여기에 MultiLock 을 사용할 수 있다는 점 또한 상당히 매력적인 부분이다.

여러 개의 락(RLock)을 묶어서 하나의 단위로 취급하여 모두 성공해야 성공이며, 하나라도 실패하면 전체 롤백하여 “하나의 논리적 락”으로 표현할 수 있다.

배치를 통한 가계약 관리

지금까지 살펴본 가계약 흐름에서는 "가계약 시점에 재고를 차감"하는 것이었기 때문에 "사용자가 가계약까지만 진행하고 결제까지 완료하지 않은 경우, 일정 시간이 지난 후 해당 가계약을 만료 처리하고 재고를 원래대로 복구해야 한다" 라는 추가 요구사항이 존재한다.

  • 가계약 생성 시점에는 재고를 선점하고

  • 가계약 만료 시점에는 재고를 다시 돌려놓는 정산 과정이 필요하다.

이러한 정산을 담당하는 주체로 "배치 작업"을 도입할 수 있다.

전체적인 가계약의 흐름

배치가 맡는 역할

1) 만료 대상 가계약 조회

Redis에 저장된 가계약 중에서 expiredAt (가계약 만료 시점) 을 기준으로 이미 지난 가계약을 조회한다.

2) 재고 복구

만료가 확정된 가계약 건에 대해서는 가계약 생성 시 차감했던 객실 재고(RoomTypeStock)에 대해 체크인 ~ 체크아웃 날짜 구간 전체에 재고 증가 작업을 진행해줘야 한다.

이로써 동일 객실을 다른 사용자가 다시 예약할 수 있는 상태로 만드는 것이다.

3) 가계약 데이터 정리

마지막으로, 만료 처리 및 재고 복구까지 완료된 가계약 데이터들을 Redis 메모리에서 정리를 해줘야 한다.

  • 가계약 생성 시점에는 "락 + 재고 차감"으로 자원을 선점하고

  • 배치 작업은 시간이 지난 뒤 사용되지 않은 가계약을 "만료 + 재고 복구" 하는 역할을 맡게 되는 것이다.


숙소 예약 시스템을 직접 설계해보면서 "재고"라는 단어가 같다고 해서 모든 도메인이 동일하게 처리하지는 않는 다는 점을 느끼게 되었다. 여러가지 케이스들을 분석하고, 어떤 방식이 가장 어울리는지 파악해가는 과정 속에서 요구사항을 명확하게 파악할 수록 설계의 매력에 빠지게 되는 것 같다.

종합해보자면, 단순 가계약이라고 하는 행위 하나에 "락 + 가계약 + 만료 정산"의 구조를 갖춰야하는 이유에 대해 파악할 수 있었다.

이번 글에서는 요구사항 관점에서 정리를 했고, 다음 글에서는 실제로 이 구조를 어떻게 구현했는지, Redis의 어떤 자료구조를 사용했고 Redisson의 멀티 락 방식은 어떻게 동작하고 재고 차감 및 복원 로직은 어떻게 구현했는지 등 실제 코드와 함께 의도를 어떻게 녹여냈는지 정리해보고자 한다.

Last updated

Was this helpful?