기술

공간 예약 마감, 예약과 취소를 같은 선에 묶었습니다

2026.06.258분 읽기

서비스에 공간 예약 기능을 붙이는 일이었습니다. 일반 회의실 예약과 다른 점이 하나 있었습니다. 즉흥적으로 자리만 잡는 공간이 아니라, 며칠 전부터 시간과 인원이 확정되어야 운영이 돌아가는 구조였습니다.

기능을 뜯어보니 마감이 한 종류가 아니었습니다. '언제까지 예약할 수 있는가'를 정하는 예약 마감이 있고, '언제까지 내가 만든 예약을 스스로 취소할 수 있는가'를 정하는 취소 마감이 따로 있었습니다. 처음에는 둘을 별개의 설정으로 두는 게 자연스러워 보였습니다. 예약은 넉넉히 받고, 취소는 조금 더 빡빡하게 막으면 운영이 편하지 않겠냐는 생각이었습니다.

그런데 이 둘을 어떻게 두느냐에 따라 회원이 겪는 경험이 완전히 달라졌습니다. 마감 두 개의 관계를 정하는 게 이 정책의 중심이었습니다.


마감 두 개를 두는 방법은 세 가지였습니다

선택지를 늘어놓고 보니 결국 세 가지 모델로 정리됐습니다. 각 모델은 유연성과 운영 부담을 서로 다르게 가져갔습니다.

모델

예약/취소 마감 관계

유연성

주된 문제

분리

예약 마감 ≠ 취소 마감

높음

회원이 만든 예약을 회원이 자가 취소 못 하는 구간이 생김

취소 개방

예약 마감만 두고 취소는 슬롯 직전까지

중간

막판 취소가 자유로워 노쇼 위험이 커짐

동일(채택)

예약 마감 = 취소 마감

낮음

당일 예약 같은 즉흥 케이스를 받지 못함

분리 모델은 가장 유연했습니다. 예약은 D-1까지 받고 취소는 D-3까지만 허용하는 식으로 자유롭게 조합할 수 있었습니다. 문제는 이 조합이 만드는 빈틈이었습니다. D-2에 예약을 넣은 회원은 이미 취소 마감이 지난 상태라, 자기가 방금 만든 예약을 스스로는 취소할 수 없었습니다. 취소하려면 매장에 전화를 걸어야 했습니다. 시스템이 만들 수 있게 해준 행동을 시스템이 되돌릴 수 없게 막아버린 셈이었습니다.

취소 개방 모델은 반대였습니다. 예약 마감만 두고 취소는 슬롯이 시작하기 직전까지 열어두면 회원 입장에선 편했습니다. 하지만 이미 준비한 뒤 막판에 취소가 들어오면 그 손실은 고스란히 매장이 떠안았습니다.

11.png


예약 마감과 취소 마감을 같은 시각으로 묶었습니다

채택한 모델은 세 번째였습니다. 예약 마감과 취소 마감을 같은 윈도우로 묶었습니다. 두 마감을 하나의 시각으로 통일하자 정책 전체가 한 줄로 정리됐습니다. '예약 마감 = 취소 마감'이라는 등식 하나로, 회원이 만들 수 있는 예약은 항상 자기가 취소할 수 있다는 게 자동으로 보장됐습니다.

불변식 하나를 잡으면 후속 결정이 한꺼번에 단순해집니다. 예약 마감과 취소 마감이 같으면, 만들 수 있는 예약은 언제나 되돌릴 수 있습니다.

이 등식이 왜 빈틈을 없애는지는 시간선 위에 그려보면 분명했습니다. 예약이 가능한 구간과 자가 취소가 가능한 구간이 같은 마감선을 공유하기 때문입니다. 예약을 넣을 수 있었다는 건 아직 마감 전이라는 뜻이고, 마감 전이라는 건 취소도 열려 있다는 뜻이었습니다. 두 구간이 같은 선에서 닫히니, 예약은 됐는데 취소는 안 되는 구간 자체가 존재할 수 없었습니다.

규칙으로 옮기면 이렇게 됐습니다. 예약 가능 여부는 '오늘에 사전 예약 최대 일수를 더한 날이 서비스일 이상이고, 지금이 마감 전인가'로 판정했습니다. 회원의 자가 취소 가능 여부는 '지금이 마감 전인가' 하나로 판정했습니다. 두 판정이 같은 마감 시각을 바라보게 했습니다.

2.png


대신 당일 예약은 받지 않기로 했습니다

마감을 D-1 18시로 두면 당일 예약은 구조적으로 불가능합니다. 오늘 오전에 사용하고 싶어도 어제 저녁 6시에 이미 창이 닫혀 있었습니다. 분리 모델이었다면 당일 예약을 열어둘 여지가 있었지만, 동일 모델은 그 여지를 포기했습니다.

이 트레이드오프를 숨기지 않고 명시적으로 받아들였습니다. 즉흥보다 사전 계획이 더 중요한 공간이었기 때문입니다. 당일 예약을 막는 게 손실이 아니라 컨셉과 맞는 선택이었습니다. 도메인이 다르면 이 판단은 뒤집힙니다. 당일 호출이 가치의 핵심인 택시나 배달이라면 같은 모델을 쓰면 안 됩니다.

한 가지 예외는 남겨뒀습니다. 회원의 자가 취소는 마감에 묶이지만, 관리자와 매장 매니저의 취소는 슬롯이 시작하기 직전까지 가능하게 열어뒀습니다. 정책을 무시할 수 있는 운영 예외 권한입니다. 정책은 회원이 만든 일관성을 보장하는 규칙이고, 운영 예외는 현장에서 어쩔 수 없이 생기는 사정을 처리하는 권한이라, 둘의 책임을 분리했습니다.


정책은 코드가 아니라 DB 컬럼으로 뺐습니다

숫자를 코드에 박지 않았습니다. 매장마다 운영 사정이 다를 수 있어서, 정책을 세 개의 컬럼으로 빼뒀습니다.

사전 예약 최대 일수: 며칠 전부터 예약을 받을지. 기본 30일

예약·취소 공통 마감일: 며칠 전에 창을 닫을지. 기본 D-1

마감 시각: 그날 몇 시에 닫을지. 기본 18시

그리고 이 세 컬럼으로 예약 오픈 시점과 취소 마감 시각을 계산해 주는 정책 서비스를 따로 뒀습니다. 예약 시점과 취소 시점에 같은 마감 가드를 통과시켰고, 취소가 막혔을 때의 에러 메시지에는 마감 시각을 그대로 넣어 회원이 왜 막혔는지 바로 알 수 있게 했습니다. 관리자 화면에는 정책 박스와 함께 '이렇게 설정하면 회원에게 이렇게 보입니다'를 세 줄로 미리 보여주는 프리뷰를 붙였습니다.

회원 앱 캘린더에서도 정책이 그대로 드러났습니다. 날짜를 빠르게 고르는 칩의 시작점을 '지금 선택 가능한 가장 빠른 날'에 맞췄습니다. 당일이 막혀 있으면 시작점이 자동으로 다음 날로, 그날 마감까지 지났으면 이틀 뒤로 밀렸습니다. UI가 정책을 따로 설명하지 않아도, 고를 수 있는 날만 보여주는 것으로 정책 결과가 자연스럽게 반영됐습니다.

마이그레이션에서 한 번 발을 헛디딜 뻔했습니다. 예전 임시 컬럼은 일수를 음수(-30)로 들고 있었는데, 새 컬럼은 양수(30)로 통일했습니다. 부호를 맞추지 않고 그대로 옮겼다면 마감 계산이 통째로 반대 방향으로 뒤집혔을 자리였습니다.


불변식 하나가 후속 결정을 대신 정해줍니다

이 작업에서 챙긴 건 마감 정책 그 자체보다, 정책을 설계하는 방식이었습니다. 핵심 불변식 하나를 먼저 잡으면 그 아래 결정들이 한꺼번에 단순해집니다. '예약 마감 = 취소 마감'을 못 박는 순간, 자가 취소 빈틈을 어떻게 막을지, 어떤 케이스에 예외를 둘지 같은 질문들이 따로 고민할 거리가 아니라 등식에서 따라 나오는 결과가 됐습니다.

한 지점에 규칙 하나를 박아 여러 일관성을 동시에 보장하는 패턴은 전에도 쓴 적이 있습니다. 결제 함수 한 군데에 가드 하나를 넣었더니 흩어져 있던 가정들이 한꺼번에 정리된 경험이었습니다.

정리하면 정책 설계는 다음 순서를 따랐습니다. 첫째, 결정을 묶어줄 불변식 하나를 찾는다. 둘째, 그 불변식이 만드는 트레이드오프가 도메인 컨셉과 맞는지 확인하고 맞으면 명시적으로 수용한다. 셋째, 정책을 깨야 하는 운영 예외는 권한으로 분리한다. 넷째, 정책 값은 코드가 아니라 DB로 빼 매장별로 조정 가능하게 둔다. 다섯째, UI가 정책 결과를 자연스럽게 반영하게 만든다.

반대로 이 모델이 안 맞는 신호도 분명했습니다. 당일 예약 자체가 가치인 도메인, 막판 취소를 감수하더라도 유연성이 더 중요한 운영, 관리자 예외가 너무 자주 발동돼 정책이 사실상 무력해지는 상황. 이 중 하나라도 해당되면 동일 모델 대신 분리 모델을 다시 검토해야 합니다.

#예약시스템#마감정책#불변식#정책설계#트레이드오프#백엔드설계#운영의기술