| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 마이그레이션
- 보안
- React
- 아키텍처
- 도메인설계
- JWT
- 상태관리
- 배포
- 인증
- typescript
- electron
- 온디바이스AI
- Vite
- 백엔드
- playwright
- github actions
- Kotlin
- sqlite
- REST API
- javascript
- Android
- 프론트엔드
- 크롤링
- next.js
- 메일자동화
- 소셜로그인
- python
- php
- contextBridge
- FastAPI
- Today
- Total
HM소프트
시간제 시설 예약 시스템 개발기 (1) — 슬롯 도메인과 동시성, 그리고 자원 배정 본문

시간 슬롯을 예약의 단위로 잡고 유니크 제약과 상태 enum으로 도메인 불변식을 지킨다. 시간표 전개를 DB 모르는 순수 함수로 분리하고, WebSocket 잠금과 DB 트랜잭션 두 층으로 동시 예약을 막는다. 하루 1예약·대리·비회원 정책을 nullable 필드로 흡수하고, 락커는 시간 구간 겹침으로 배정해 재사용되게 한 설계를 다룬다.
시리즈를 시작하며
한 시간제 시설의 온라인 예약 시스템을 처음부터 만들었다. 사용자가 원하는 시간대를 골라 예약하고, 자동으로 사물함(락커)을 배정받고, 확정 안내를 문자로 받는다. 운영자는 예약 현황·요금·시간표·공지 팝업을 관리하고, 현장 결제 단말(POS)은 들어온 예약을 실시간으로 받아 처리한다. 스택은 TypeScript 풀스택 — 서버는 Express + Prisma(PostgreSQL), 클라이언트는 React + Vite, 실시간은 Socket.IO와 plain WebSocket을 함께 쓴다.
세 편으로 나눠 기록한다. 각 편은 서로 다른 코드 영역을 다루지만, 결국 하나의 도메인을 세 각도에서 보는 것이다.
- (이 글) 슬롯 도메인과 동시성, 그리고 자원 배정 — 예약의 단위인 "시간 슬롯", 하루 1예약·대리·비회원 정책, 동시 예약 방지, 락커 자동 배정
- 문자 알림과 현장 단말(POS) 실시간 연동 — 대체 발송, 바이트 단위 길이 분기, 폴링에서 푸시로
- 운영자 도구·요금/이벤트·검색 노출(SEO)·테스트 — 시간표 무중단 전환, 일괄 차단, JSON-LD, 단위·E2E 테스트
특정 시설명·지역·제휴사명은 전부 일반화하고 구현만 다룬다. 도메인은 "시간제 시설 예약"으로 추상화했다.
예약의 단위: 시간 슬롯
이 시스템에서 가장 먼저 정의해야 했던 개념은 슬롯(slot) 이다. 하루를 시작 시각 단위의 블록으로 잘라 놓고, 각 블록 하나가 곧 예약 가능한 한 칸이 된다. 사용자는 결국 "어느 날, 어느 라인의, 몇 시 슬롯"을 고르는 것이고, 예약 레코드는 이 슬롯에 1:1로 붙는다.

슬롯의 스키마를 보면 도메인이 한눈에 보인다. TimeSlot은 라인(courseId)·날짜·시작 시각(teeTime)·세션(오전/오후)·표시 라벨·정원(maxPlayers)·상태를 가진다. 핵심은 유니크 제약과 상태 enum 두 가지다.

@@unique([courseId, date, teeTime]) 가 도메인 불변식의 1차 방어선이다. "같은 라인의 같은 날짜·같은 시각 슬롯은 세상에 하나만 존재한다"를 DB가 보장한다. 슬롯을 코드로 재생성하다 중복이 생겨도 skipDuplicates로 흘려보낼 수 있는 건 이 제약 덕분이다. @@index([date, courseId, status]) 는 "이 날짜에 예약 가능한 슬롯 줘"라는, 이 시스템에서 압도적으로 많이 나가는 쿼리를 위한 복합 인덱스다.
시간표를 슬롯으로 펼치기 — 순수 함수의 가치
슬롯은 손으로 한 줄씩 넣는 게 아니라 시간표 설정(ScheduleConfig)에서 전개(generate) 한다. 설정은 "블록 시작 시각들, 블록당 슬롯 수, 슬롯 간격" 정도의 파라미터를 담고, 전개 함수가 이를 날짜별 슬롯 배열로 펼친다. 이 전개 로직을 DB를 모르는 순수 함수로 떼어 놓은 게 이번 설계에서 가장 잘한 결정이었다.

순수 함수로 분리하니 두 가지가 따라왔다. 첫째, 테스트가 쉬워졌다. DB도 목(mock)도 필요 없이 "이 설정 + 이 날짜 → 정확히 이 슬롯 배열"을 그냥 단언하면 된다(3편에서 이 테스트를 다룬다). 둘째, 재생성·동기화 로직이 이 함수 하나를 공유한다. 신규 슬롯을 처음 만들 때도, 시간표가 바뀌어 다시 채울 때도, 관리자가 강제 재생성할 때도 전부 generateDaySlots를 부른다. 슬롯을 만드는 "진실의 원천"이 한 곳이라, 어디서 만들든 결과가 동일하다.
getEffectiveScheduleParams가 한 겹 더 있는 이유는 운영 중 시간표가 한 번 바뀌었기 때문이다. 어느 시점에 블록 구성이 4블록에서 5블록(점심시간 반영)으로 전환됐는데, 특정 전환일 이후의 날짜는 DB 설정과 무관하게 신규 파라미터를 강제하도록 했다. 이 "날짜 경계로 시간표가 갈리는" 처리가 운영에서 꽤 까다로웠고, 3편의 무중단 전환 이야기로 이어진다.

예약 생성 — 여러 정책이 한 점에서 만난다
예약 생성은 이 시스템에서 가장 많은 규칙이 모이는 지점이다. createReservation은 차례로 이걸 검증한다: 슬롯이 존재하는가 → 아직 비어 있는가(AVAILABLE) → 정원을 넘지 않는가 → 그리고 하루 1예약 제한. 이 마지막 정책이 가장 신경 쓰였다.
정책은 단순하다. "한 사용자는 같은 날짜에 한 번만 예약할 수 있다." 단, 이미 취소(CANCELLED)된 예약은 세지 않는다 — 취소했으면 다시 잡을 수 있어야 하니까. 그래서 중복 검사는 PENDING·CONFIRMED 상태만 본다.

여기서 디테일 두 개. 첫째, 날짜 비교를 gte dayStart ~ lt dayEnd 의 반열린 구간으로 잡았다. date <= 그날 끝 같은 비교는 자정 경계와 타임존에서 자주 사고가 나는데, "그날 0시 이상, 다음날 0시 미만"으로 잡으면 경계가 깔끔하다. 둘째, options.bypassDailyLimit 라는 우회 플래그를 뒀다. 운영자가 전화로 받은 예약을 대신 넣어줄 때는 이 제한을 적용하면 안 되기 때문이다. 정책의 "예외"를 if 문 안에 흩뿌리지 않고 호출자가 명시적으로 켜는 플래그 하나로 모은 셈이다.
동시성 — "비어 있음"을 확인한 순간과 "차지함" 사이
가장 무서운 건 동시 예약이다. 두 사람이 같은 슬롯을 동시에 누르면? "AVAILABLE인지 확인 → RESERVED로 바꿈" 사이에 다른 트랜잭션이 끼어들면 한 슬롯에 두 예약이 생긴다(중복 예약). 이걸 두 층으로 막았다.
1층은 UX 차원의 슬롯 잠금이다. 사용자가 슬롯을 고르는 순간 WebSocket으로 "내가 이거 잡는 중"이라는 잠금을 건다. 다른 사용자 화면에는 즉시 "예약 진행 중"으로 비활성화돼, 애초에 두 사람이 같은 칸으로 들어오는 일을 줄인다. 잠금은 3분이 지나면 자동 해제돼, 결제하다 이탈한 사람이 슬롯을 영원히 점유하는 일을 막는다(잠금 메커니즘 자체는 2편에서 자세히).
2층은 DB 트랜잭션이다. 잠금은 어디까지나 협조적 장치라 최종 방어선이 될 수 없다. 그래서 슬롯 상태 변경·예약 생성·결제 레코드 생성·락커 배정을 하나의 $transaction 으로 묶었다.

핵심은 원자성이다. 네 단계 중 어느 하나라도 실패하면 전부 롤백된다. 락커 배정 도중 예외가 나면 슬롯이 RESERVED인 채로 예약은 없는 "유령 점유"가 생길 텐데, 같은 트랜잭션 안이라 그런 어긋난 상태가 영영 남지 않는다. 그리고 이 트랜잭션 진입 직전에 다시 슬롯 상태를 검사하기 때문에, 1층 잠금을 우회해 동시에 들어온 두 요청 중 뒤늦은 쪽은 "이미 예약된 시간"으로 막힌다. @@unique·상태 검사·트랜잭션이 겹겹이 작동해, 중복 예약이 새어나갈 틈을 좁혔다.
트랜잭션 밖에 두는 일도 분명히 했다. 슬롯 상태 변경 실시간 브로드캐스트, POS 알림, 확정 문자 발송은 전부 커밋 이후에, 그것도 .catch(() => {}) 로 실패해도 예약 자체에는 영향을 주지 않게 했다. "예약이 됐다"는 사실은 DB 커밋으로 끝나야 하고, 문자가 안 갔다고 예약이 취소되면 안 된다 — 이 경계를 흐리지 않은 게 중요했다.
한 사람이 두 얼굴 — 대리 예약과 비회원
운영을 시작하니 곧바로 두 가지 변종이 필요했다. 관리자 대리 예약(전화로 받은 예약을 운영자가 대신 등록)과 비회원 워크인(가입 없이 현장에서 잡는 예약)이다. 둘 다 "로그인한 회원 = 예약자"라는 기본 가정을 벗어난다.
이걸 별도 테이블로 분리하지 않고, Reservation에 nullable 필드 세 개(guestName, guestPhone, createdByAdmin)를 더해 하나의 모델로 흡수했다. null이면 정상 회원 예약, 값이 있으면 대리/비회원 예약이다.

설계 포인트는 userId를 비워두지 않고 관리자 ID로 채웠다는 점이다. 모든 예약이 반드시 owner를 갖게 해서, userId를 nullable로 만들었을 때 따라오는 "주인 없는 예약" 분기 처리를 통째로 없앴다. 실제 이용자가 누구인지는 guestName/guestPhone이 들고, 관리자 화면과 POS는 "guestName이 있으면 그걸, 없으면 회원 이름을" 보여주는 한 줄 규칙으로 통일했다. 확정 문자도 회원 예약은 회원 번호로, 대리 예약은 입력받은 번호로 — 같은 발송 함수에 번호만 바꿔 넣는다. 그리고 대리 예약은 앞서 본 bypassDailyLimit로 하루 1예약 제한을 건너뛴다(현장 사정은 정책보다 우선이니까).
락커 자동 배정 — 시간 겹침으로 푸는 자원 할당
예약이 확정되면 락커가 자동 배정된다. 단순히 "빈 번호 하나 줘"가 아니라, 시간이 겹치는 동안만 점유하는 자원 할당 문제다. 락커는 번호 범위가 정해져 있고, 한 번 쓰면 일정 시간 점유되며, 고장 난 번호는 빼야 한다.

이 함수에서 마음에 드는 부분은 겹침 판정이다. startTime < endTime && endTime > startTime — 두 구간이 겹치는지 보는 고전적 조건으로, "이 예약의 이용 시간에 이미 점유 중인 락커"만 추려낸다. 덕분에 오전 예약이 끝나면 그 락커는 오후 예약에 자연스럽게 재사용된다. 락커를 "영구 소유"가 아니라 "시간 구간 점유"로 본 것이다.
운영에서 진가를 발휘한 건 LOCKER_EXCLUDED 다. 특정 락커가 고장 나면 코드에서 번호만 집합에 넣으면 배정 풀에서 즉시 빠진다. "특정 자원을 일시적으로 풀에서 제외" — 현장에서 의외로 자주 필요했다. 그리고 락커가 모자라도 던지지 않고 경고만 남기고 진행한다. 락커가 1개 부족하다고 예약 자체를 막으면 현장이 더 곤란해지기 때문이다. 자원 배정 실패를 "치명적 오류"가 아니라 "운영 신호(로그)"로 다룬 판단이다. 물론 이 배정도 예약 생성과 같은 트랜잭션 안에서 일어나, 예약이 롤백되면 락커 점유도 함께 사라진다.
취소 — 자원을 되돌리고, 규칙을 지키며
예약 취소는 생성의 거울상이다. 슬롯을 다시 AVAILABLE로, 락커를 회수, 예약을 CANCELLED로 — 역시 한 트랜잭션이다. 취소에는 운영 규칙도 얹혔다. 결제 완료된 예약은 코드로 취소 불가(환불은 별도 절차), 이용일 2일 전까지만 취소 가능.
if (reservation.payment?.status === 'PAID') {
throw new BadRequestError('결제 완료된 예약은 취소가 불가합니다. 고객센터로 문의해 주세요.');
}
// 이용일 2일 전까지만 취소 가능 (당일 기준 정규화 후 일수 비교)
const diffDays = Math.floor((slotMidnight - todayMidnight) / 86_400_000);
if (diffDays < 2) {
throw new BadRequestError('예약 취소는 이용일 2일 전까지만 가능합니다');
}
await prisma.$transaction(async (tx) => {
await tx.timeSlot.update({ where: { id: reservation.timeSlotId }, data: { status: 'AVAILABLE' } });
await releaseLockersForReservation(tx, id); // 락커 회수
return tx.reservation.update({ where: { id }, data: { status: 'CANCELLED', cancelledAt: new Date() } });
});
날짜 차이를 계산할 때 양쪽을 자정으로 정규화한 뒤 일(day) 단위로 비교한 게 포인트다. "2일 전"을 시:분:초가 섞인 채로 빼면 같은 날 오전·오후가 결과를 바꿔버린다. 슬롯 날짜와 오늘을 모두 0시로 맞춰 순수하게 "며칠 차이"만 본다 — 앞의 하루 1예약 검사에서 반열린 구간을 쓴 것과 같은, 날짜를 다룰 때의 일관된 태도다.
정리하며
- 슬롯이 예약의 단위다.
@@unique([courseId, date, teeTime])와 상태 enum이 도메인 불변식의 1차 방어선. - 시간표 전개를 DB를 모르는 순수 함수로 분리해, 생성·재생성·동기화가 한 함수를 공유하고 테스트도 쉬워졌다.
- 동시성은 WebSocket 잠금(협조) + DB 트랜잭션(보증) 두 층으로 막았다. 슬롯 변경·예약·결제·락커를 한
$transaction으로 묶어 원자성을 확보. - 대리/비회원 예약은 별도 테이블 없이 nullable 필드 + owner=관리자로 흡수해 분기를 줄였다. 정책 예외는
bypassDailyLimit플래그 하나로 명시화. - 락커는 시간 구간 겹침으로 배정해 재사용되게 했고, 고장 번호는 집합 하나로 제외. 배정 실패는 예약을 막지 않고 로그로 남겼다.
- 날짜 비교는 반열린 구간·자정 정규화로 경계 사고를 피했다.
다음 2편에서는 이 예약을 사용자에게 통지하고 현장과 잇는 문자 알림(대체 발송·바이트 길이 분기)과 단말 POS 실시간 연동(폴링→푸시) 을 다룬다.
'외주 개발일지' 카테고리의 다른 글
| 시간제 시설 예약 시스템 개발기 (3) — 시간표 무중단 전환·일괄 차단·SEO·테스트 (0) | 2026.06.23 |
|---|---|
| 시간제 시설 예약 시스템 개발기 (2) — 알림톡 대체 발송과 단말 POS 실시간 연동 (0) | 2026.06.23 |
| 과제 채점 플랫폼 개발기 (4) — 랜딩 첫인상 다듬기: 스크롤 리빌, 날짜 피커, 가입 페이지 (0) | 2026.06.23 |
| 교육 플랫폼 개발기 (4) — 멘토링·알림 폴백·서버사이드 소셜 로그인·배포의 함정 (0) | 2026.06.23 |
| 교육 플랫폼 개발기 (3) — 결제 서버 검증, 영수증 PDF, 계약서 AI 검토 (0) | 2026.06.23 |