- Published on
슬롯 기반 예약 시스템 백엔드 설계 - 가상 슬롯, 점유 계산, advisory lock
- Authors

- Name
- hongreat
- ✉️hongreat95@gmail.com
최근 시설 예약 시스템의 백엔드를 처음부터 설계하고 구축할 기회가 있었습니다.
헬스장의 사우나 같은 부대시설을 떠올리면 됩니다. 수업 예약(정해진 세션에 신청)과 달리, 운영시간 안에서 20분 단위 슬롯을 골라 연속으로 예약하는 유형입니다.
- 슬롯 단위(예: 20분)로 시간을 쪼개고, 1회에 연속 1~3슬롯 예약
- 슬롯마다 정원 존재
- 요일별 운영시간이 다르고, 휴무일·대관·점검·정원 조정 같은 예외가 수시로 발생
- 체크인/체크아웃, 초과 이용시 추가요금
만들고 보니 설계 단계에서 내린 결정 몇개가 이후의 개발 난이도를 좌우했다는 생각이 들어서, 그 결정들과 이유를 기록합니다.
- 1. 첫번째 결정: 슬롯을 미리 만들지 않는다
- 2. 예약은 슬롯 참조가 아니라 시간 범위
- 3. 슬롯 row가 없으면 select_for_update도 없다
- 4. 자격 검증은 eligibility API로 분리
- 5. 정책 값은 스냅샷으로 저장
- 6. 상태 머신과 "왜"의 기록
- 참고 자료
1. 첫번째 결정: 슬롯을 미리 만들지 않는다
가장 먼저 부딪히는 갈림길입니다. 슬롯을 DB row로 미리 생성해둘 것인가?
슬롯을 사전 생성(materialize)하는 방식은 직관적입니다. Slot(date, time, capacity) 테이블을 만들고 예약이 슬롯을 FK로 참조하면, 점유 계산도 조인 한번이면 됩니다.
하지만 운영을 상상해보면 단점이 계속 나옵니다.
- 슬롯을 언제까지 생성해둘 것인가? 매일 배치로 미래 슬롯을 만들어야 하고, 배치가 죽으면 예약이 막힙니다.
- 운영시간이나 슬롯 단위가 바뀌면? 이미 생성된 미래 슬롯들을 재생성해야 하고, 기존 예약과의 정합성 처리가 따라옵니다.
- 하루 51개 슬롯(06:00~23:00, 20분 단위) × 지점 수 × 365일... 대부분 빈 row 입니다.
그래서 반대로 결정했습니다. 슬롯은 DB에 존재하지 않고, 운영 규칙으로부터 코드로 계산합니다.
class FacilityResource(models.Model):
"""시설 설정. 슬롯 생성의 '규칙'만 가진다."""
place = models.OneToOneField("Place", on_delete=models.PROTECT)
capacity = models.PositiveIntegerField("기본 정원")
slot_minutes = models.PositiveIntegerField("슬롯 단위(분)", default=20)
max_slots_per_reservation = models.PositiveIntegerField(default=3)
advance_booking_days = models.PositiveIntegerField("예약 가능 일수", default=14)
# 요일별 운영시간은 별도 WeeklySchedule 모델 (7 row)
슬롯 시각 리스트는 규칙에서 즉석으로 만듭니다.
def build_time_grid(open_time, close_time, slot_minutes):
"""[open, open+slot, ...] 슬롯 시각 리스트. close를 넘는 슬롯은 제외."""
grid = []
cursor = datetime.combine(date.min, open_time)
end = datetime.combine(date.min, close_time)
delta = timedelta(minutes=slot_minutes)
while cursor + delta <= end:
grid.append(cursor.time())
cursor += delta
return grid
build_time_grid(time(6, 0), time(23, 0), 20)
# [06:00, 06:20, ..., 22:40] (51개)
그럼 대관이나 정원 조정같은 예외는 어떻게 하느냐. 기본 규칙에서 벗어나는 경우에만 row를 만드는 예외 모델을 하나 둡니다.
class FacilitySlotException(models.Model):
"""기본 규칙에서 벗어나는 슬롯만 row 생성. 평상시 이 테이블은 거의 비어있다."""
resource = models.ForeignKey(FacilityResource, on_delete=models.CASCADE)
date = models.DateField()
slot_time = models.TimeField()
is_closed = models.BooleanField(default=False) # True면 예약 불가 (대관·점검)
capacity_override = models.PositiveIntegerField( # null이면 기본 정원 사용
null=True, blank=True, default=None
)
note = models.CharField(max_length=200, blank=True, default="") # "단체 대관" 등
class Meta:
constraints = [
models.UniqueConstraint(
fields=["resource", "date", "slot_time"],
name="uniq_slot_exception",
),
]
"평상시 이 테이블은 거의 비어있다"가 이 설계의 핵심 문장입니다. 데이터는 규칙 + 예외만 저장하고, 화면에 뿌릴 슬롯 그리드는 매번 계산합니다.
계산 비용이 걱정될 수 있는데, 하루치 그리드는 슬롯 51개 × 예약 목록 순회 수준이라 밀리초 단위입니다. 사전 생성이 필요해지는 규모(슬롯 수만개, 조회 폭주)가 오면 그때 캐시를 붙이면 되는 문제라고 판단했습니다.
2. 예약은 슬롯 참조가 아니라 시간 범위
슬롯 row가 없으므로 예약이 슬롯을 FK로 가리킬 수 없습니다. 예약은 시작 시각과 슬롯 개수만 저장합니다.
class FacilityReservation(models.Model):
resource = models.ForeignKey(FacilityResource, on_delete=models.PROTECT)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
membership = models.ForeignKey("Membership", on_delete=models.PROTECT)
date = models.DateField("예약 날짜")
start_time = models.TimeField("시작 시각")
slot_count = models.PositiveIntegerField("배정된 슬롯 수") # 1~3
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="RESERVED")
class Meta:
indexes = [
models.Index(fields=["user", "date"]),
models.Index(fields=["resource", "date", "status"]),
models.Index(fields=["resource", "date", "start_time"]),
]
특정 슬롯의 점유 인원은 시간 범위 겹침으로 계산합니다. 구간 겹침의 고전적인 조건 그대로입니다.
def slot_occupancy(slot_start, slot_end, reservations, slot_minutes):
"""겹침 조건: r.start < slot_end AND r.end > slot_start"""
count = 0
for r in reservations:
r_end = add_minutes(r.start_time, r.slot_count * slot_minutes)
if r.start_time < slot_end and r_end > slot_start:
count += 1
return count
하루치 그리드를 만들때는 해당 날짜의 유효한 예약(취소 제외)을 한번에 조회해서 메모리에서 슬롯별로 겹침을 세면 됩니다. 슬롯마다 쿼리를 날리는게 아니라, 쿼리 한번 + 파이썬 순회입니다.
경계 조건은 테스트로 고정해두는 것이 좋습니다. 10:00에 시작하는 슬롯과 10:00에 끝나는 예약은 겹치지 않아야 하는데(r_end > slot_start에서 >=가 아니라 >), 이런 off-by-one은 코드리뷰로 잡기보다 테스트 케이스로 박아두는게 확실합니다.
3. 슬롯 row가 없으면 select_for_update도 없다
이 설계의 대가는 동시성 제어에서 치릅니다.
잔여 확인 → 예약 생성 사이의 race condition을 막는 정석은 슬롯 row를 select_for_update로 잠그는 것인데, 잠글 row 자체가 없습니다.
대안으로 PostgreSQL의 advisory lock을 사용했습니다. row가 아니라 임의의 정수 키에 거는 락이라서, "가상의 자원"을 잠그는 이런 상황에 정확히 맞습니다.
def make_resource_date_lock_id(resource_id: int, target_date: date) -> int:
"""(자원, 날짜) 조합을 bigint 락 키로 변환"""
return (resource_id << 32) | int(target_date.strftime("%Y%m%d"))
def pg_advisory_xact_lock(lock_id: int):
"""트랜잭션 종료시 자동 해제되는 advisory lock"""
if not transaction.get_connection().in_atomic_block:
raise RuntimeError("advisory lock은 transaction.atomic 안에서 호출해야 합니다.")
with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_id])
예약 생성 흐름은 이렇게 됩니다.
with transaction.atomic():
# 같은 (시설, 날짜)에 대한 예약 생성을 직렬화
pg_advisory_xact_lock(make_resource_date_lock_id(resource.id, target_date))
# 이 안에서의 잔여 계산은 동시 요청으로부터 안전하다
validate_capacity(resource, target_date, start_time, slot_count)
reservation = FacilityReservation.objects.create(...)
pg_advisory_xact_lock은 트랜잭션이 끝나면(커밋이든 롤백이든) 자동으로 풀리기 때문에, 락 해제를 잊는 유형의 사고가 원천적으로 없습니다.
락 범위를 (자원, 날짜)로 잡은 것도 의도적인 선택입니다. 자원 전체를 잠그면 다른 날짜 예약까지 줄을 서게 되고, 슬롯 단위로 잠그면 연속 슬롯 예약에서 락을 여러개 잡아야해서 복잡해집니다. 같은 날짜의 동시 예약만 직렬화하면 충분했습니다.
한가지 주의점은 advisory lock이 커넥션(트랜잭션) 기반이라는 것 입니다. pgbouncer 같은 커넥션 풀러를 transaction 모드로 쓸때는 pg_advisory_lock(세션 락)이 아니라 반드시 pg_advisory_xact_lock(트랜잭션 락)을 써야 안전합니다.
4. 자격 검증은 eligibility API로 분리
예약을 만들때 검증할 것이 많습니다. 유효한 멤버십이 있는지, 잔여 이용횟수가 있는지, 미납 추가요금이 있는지, 예약 가능 일수(D+14) 안인지.
처음에는 예약 생성 API 안에서만 검증했는데, UX 문제가 바로 나왔습니다. 유저가 날짜·시간을 다 고르고 나서야 "미납 요금이 있어 예약할 수 없습니다"를 만나는 흐름이 됩니다.
그래서 예약 화면 진입 시점에 호출하는 eligibility API를 따로 뒀습니다.
GET /api/facility/eligibility/
{
"is_eligible": false,
"reason_code": "FACILITY_0004",
"reason": "미납된 추가요금이 있습니다.",
"remaining_credits": 2
}
포인트는 검증 로직 자체를 함수로 분리해서 eligibility API와 예약 생성 API가 같은 코드를 쓰게 하는 것 입니다. 진입 시점 검증은 UX용이고, 생성 시점 검증이 최종 방어선입니다. 둘이 다른 코드면 언젠가 반드시 어긋납니다.
5. 정책 값은 스냅샷으로 저장
초과요금 단가(슬롯당 5,000원 같은)는 운영 정책이라 언제든 바뀔 수 있습니다.
여기서 문제가 생깁니다. 체크인 후에 단가가 변경되면, 이 사람의 초과요금은 옛 단가일까 새 단가일까.
정답이 뭐가 됐든 "계산할때마다 결과가 달라질 수 있는 구조"가 최악이므로, 체크인 시점에 당시 정책 값을 예약 row에 스냅샷으로 저장했습니다.
class FacilityReservation(models.Model):
...
# 체크인 시점의 정책 값을 고정
price_per_extra_slot_snapshot = models.PositiveIntegerField(default=0)
extra_charge_grace_minutes_snapshot = models.PositiveIntegerField(default=0)
이후의 모든 초과요금 계산은 스냅샷 값만 사용합니다. 결제·정산이 얽히는 값은 참조가 아니라 복사라는 원칙을 여기서도 그대로 따랐습니다. (주문 시점의 상품 가격을 주문 row에 저장하는 것과 같은 이유입니다)
6. 상태 머신과 "왜"의 기록
예약 상태는 다섯개로 정리했습니다.
RESERVED ──> CHECKED_IN ──> CHECKED_OUT
│
├──> NO_SHOW (이용 종료 시각까지 체크인 없음, 배치 전환)
└──> CANCELLED (유저/관리자 취소)
여기에 하나 더, 체크아웃에는 유형 필드를 뒀습니다.
CHECKOUT_TYPE_CHOICES = (
("manual_member", "수동(회원)"),
("manual_admin", "수동(관리자)"),
("forced_closing", "운영 종료 강제"),
("forced_max_slot", "최대 슬롯 도달 강제"),
)
같은 CHECKED_OUT이라도 본인이 나간 것과 마감 배치가 강제로 내보낸 것은 CS 대응에서 완전히 다른 상황입니다. 상태만 저장하면 "어떻게 이 상태가 됐는지"가 사라지는데, 운영 문의는 대부분 그 "어떻게"를 묻습니다.
NO_SHOW 전환과 마감 강제 체크아웃은 Celery Beat 주기 배치가 담당합니다. 사람이 아닌 배치가 상태를 바꾸는 경로가 있다면, 위처럼 전이 사유를 남기는 것이 더더욱 중요해집니다.
설계를 요약하면 세 문장입니다.
슬롯은 규칙에서 계산하고 예외만 저장한다. 점유는 시간 범위 겹침으로 세고, 동시성은 (자원, 날짜) advisory lock으로 직렬화한다. 정책 값은 스냅샷으로 고정하고, 상태 전이에는 사유를 남긴다.
정답이라기보다는 트레이드오프의 기록에 가깝습니다. 슬롯 사전 생성이 맞는 규모의 서비스도 분명 있습니다. 다만 어느 쪽을 고르든 "운영시간이 바뀌면 어떻게 되는가", "동시에 마지막 자리를 잡으면 어떻게 되는가"에 대한 답은 설계 단계에서 정해두어야, 나중에 코드가 아니라 데이터로 고생하지 않습니다.
