PitNow는 정비소의 비어 있는 리프트와 시간을 예약하는 서비스입니다. 예약 서비스에서 제일 곤란한 버그는 같은 리프트, 같은 시간에 예약이 두 건 잡히는 것입니다. 고객 두 명이 같은 시간에 정비소에 도착하면 어느 한쪽은 돌아가야 하고, 결제까지 끝난 뒤라면 환불과 사과가 뒤따릅니다.
이 글에서는 겹치는 예약을 화면이나 API 코드가 아니라 PostgreSQL 제약 조건으로 막은 이유와, 그 에러를 사용자에게 어떻게 전달했는지 정리합니다. 코드에서는 리프트를 bay라고 부릅니다.
확인하고 넣는 코드에는 틈이 있다
겹침을 막는 가장 쉬운 방법은 저장하기 전에 조회해 보는 것입니다.
const { count } = await db
.from("reservations")
.select("id", { count: "exact", head: true })
.eq("bay_id", bayId)
.lt("start_time", blockedUntil)
.gt("blocked_until", startTime);
if (count) {
return { error: "이미 예약된 시간입니다." };
}
await db.from("reservations").insert(newReservation);요청이 하나씩 들어오면 문제없습니다. 하지만 두 요청이 거의 동시에 들어오면 이런 순서가 가능합니다.
| 순서 | 요청 A | 요청 B |
|---|---|---|
| 1 | 10시 예약이 있는지 조회 → 없음 | |
| 2 | 10시 예약이 있는지 조회 → 없음 | |
| 3 | 예약 저장 | |
| 4 | 예약 저장 → 같은 시간 예약 2건 |
조회와 저장 사이의 짧은 틈에 다른 요청이 끼어드는 것입니다. 서버리스 환경에서는 두 요청이 서로 다른 인스턴스에서 처리될 수 있어서, 서버 메모리에 잠금을 거는 방식으로도 막을 수 없습니다.
리프트 행을 잠가서 예약을 순서대로 처리하는 방법도 있습니다. 다만 Supabase 클라이언트로는 여러 쿼리를 한 트랜잭션으로 묶을 수 없어서 로직을 DB 함수로 옮겨야 하고, 예약을 만드는 모든 경로가 같은 잠금 규칙을 지켜야 합니다. 고객 예약 API, 관리자 도구, 테스트 데이터를 만드는 스크립트까지 전부요.
규칙을 데이터베이스에 두기: 배제 제약 조건
그래서 "같은 리프트의 활성 예약은 시간이 겹칠 수 없다"는 규칙 자체를 테이블에 걸었습니다. PostgreSQL의 배제 제약 조건(exclusion constraint)은 새 행을 저장할 때 기존 행과 지정한 연산자로 비교해서, 조건이 모두 참인 행이 이미 있으면 저장을 거부합니다.
create extension if not exists btree_gist;
alter table reservations
add constraint no_overlap
exclude using gist (
bay_id with =,
tstzrange(start_time, blocked_until, '[)') with &&
) where (status in ('CONFIRMED', 'CHECKED_IN', 'IN_USE'));한 줄씩 보면 이렇습니다.
bay_id with =: 같은 리프트끼리만 비교합니다. uuid의=비교를 GiST 인덱스에서 쓰려면btree_gist확장이 필요합니다.tstzrange(start_time, blocked_until, '[)'): 시작 시각은 포함하고 끝 시각은 포함하지 않는 시간 범위입니다. 그래서 한 예약이 끝나는 시각에 다음 예약이 시작해도 겹침으로 보지 않습니다. 기본값도'[)'이지만 의도를 드러내려고 적었습니다.with &&: 두 범위가 조금이라도 겹치면 참이 되는 연산자입니다.where (status in (...)): 확정, 체크인, 이용 중인 예약만 비교합니다. 취소되거나 끝난 예약이 차지하던 시간은 바로 다시 예약할 수 있습니다.
이제 두 요청이 동시에 들어와도 데이터베이스가 한 건만 저장하고, 나머지 요청에는 23P01(exclusion_violation) 에러를 돌려줍니다. 예약을 어떤 경로로 만들든 같은 규칙이 적용됩니다.
끝나는 시각을 두 개로 나눈 이유
위 제약 조건에서 끝 시각이 end_time이 아니라 blocked_until인 데는 이유가 있습니다. 예약의 "끝"은 어디에 쓰느냐에 따라 의미가 달랐습니다.
end_time: 고객이 예약한 종료 시각입니다. 체크아웃할 때 초과 요금을 계산하는 기준이 됩니다.blocked_until: 리프트를 실제로 비워 둬야 하는 시각입니다. 예약 종료 뒤 1시간의 유예시간을 더한 값입니다.
화면 표시, 정산, 리프트 점유가 각자 다른 종료 기준을 쓰면 초과 요금이나 중복 예약 판단이 어긋날 수 있어서, 두 값의 역할을 결정 기록으로 정하고 둘의 관계를 제약 조건으로 고정했습니다.
alter table reservations
add constraint chk_blocked_until_buffer
check (blocked_until = end_time + interval '1 hour');이렇게 나누고 나니 놓치기 쉬운 경우도 보였습니다. 밤늦게 끝나는 예약의 유예시간은 다음 날로 넘어갑니다. 그래서 하루치 일정을 조회할 때 start_time이 아니라 blocked_until이 그날 시작 시각보다 뒤인 예약까지 함께 가져오도록 했습니다.
에러를 사용자의 말로 바꾸기
데이터베이스 에러를 그대로 보여 줄 수는 없으니, API에서 에러 코드를 보고 사용자에게 보여 줄 메시지로 바꿨습니다.
export function reservationDbErrorToApiError(error: PostgrestError) {
if (error.code === "23P01") {
return apiError(400, "RESERVATION_OVERLAP", "이미 예약된 시간입니다.");
}
if (error.code === "23514") {
if (error.message.includes("chk_blocked_until_buffer")) {
return apiError(
400,
"INVALID_BLOCKED_UNTIL",
"예약 종료 후 유예시간(1시간) 계산이 올바르지 않습니다.",
);
}
// 제약 조건 이름별로 메시지를 나눕니다.
}
// ...
}23P01은 겹침, 23514(check_violation)는 CHECK 제약 조건 위반입니다. CHECK 위반은 어떤 제약 조건에 걸렸는지 이름으로 구분해서, 사용자에게는 무엇이 잘못됐는지 알려 주고 개발자는 로그에서 원인을 바로 찾을 수 있게 했습니다.
결제와 만나는 지점
PitNow는 결제가 승인된 뒤에 예약을 확정합니다. 결제 전에 예약을 먼저 잡아 두면, 결제하지 않고 떠난 예약이 리프트를 차지합니다. 임시로 붙잡아 두는 상태를 따로 만들 수도 있지만, 그러면 만료 처리, 버려진 결제 정리, 임시 예약도 겹침에 포함할지 같은 정책이 한꺼번에 늘어납니다.
대신 결제 승인 직후에 다른 사람이 먼저 그 시간을 잡는 경우가 생길 수 있습니다. 이 경우도 제약 조건이 막아 주기 때문에, 예약 생성이 실패한 이유를 보고 결제 상태를 나눠 기록합니다.
const createResult = await createConfirmedReservation({
db,
body: reservation,
quote,
userId,
// ...
});
if (!createResult.ok) {
const refundStatus =
createResult.error.code === "RESERVATION_OVERLAP"
? "REFUNDED"
: "REFUND_PENDING";
// 결제 상태를 기록하고, 사용자에게 예약 시간이 방금 마감됐다고 안내합니다.
}겹침처럼 원인이 분명한 실패는 REFUNDED로, 원인을 알 수 없는 실패는 운영자가 확인할 수 있게 REFUND_PENDING으로 기록합니다.
검증
- 같은 리프트와 시간에 예약을 다시 만들었을 때
RESERVATION_OVERLAP으로 거부되는지 확인했습니다. - 로컬과 E2E 테스트에서는 가짜 결제 모드로, 실제 결제 없이 결제 준비부터 승인까지의 흐름을 반복해 검증했습니다.
- E2E 테스트용 예약 데이터를 만들 때도 같은 에러 코드를 보고 다음 빈 시간대로 넘어가게 했습니다. 테스트끼리 같은 시간을 잡으려 해도 제약 조건이 막아 주기 때문에, 겹친 테스트 데이터가 조용히 쌓이지 않습니다.
돌아보며
화면에서 이미 찬 시간을 막아 두는 일과, 데이터가 틀린 상태로 저장되지 않게 하는 일은 서로 다른 문제였습니다. 앞의 것은 사용자 경험을 위한 것이고, 뒤의 것은 규칙을 데이터 가까이에 둬야 지킬 수 있었습니다.
아직 남은 과제도 있습니다.
- 결제 승인 직후 마감된 경우의 환불: 지금은 이 경로에서 결제 상태를 기록하는 데서 멈춥니다. 예약 취소 흐름에서 쓰는 결제 취소 API 호출을 이 경로에도 연결해, 기록된 상태와 실제 환불이 항상 같도록 만들어야 합니다.
- 정비소가 막아 둔 시간: 휴무 등으로 막아 둔 시간은 다른 테이블에 있어서, 예약 테이블의 제약 조건 하나로는 함께 막을 수 없습니다. 지금은 예약을 만들기 전에 코드에서 조회하므로 앞에서 말한 조회 후 저장 방식의 틈이 남아 있습니다. 두 확인을 DB 함수 하나로 묶는 것이 다음 과제입니다.
- 결제 중인 시간 붙잡아 두기: 붙잡아 두지 않는 선택은 운영 초기의 단순함을 위한 것입니다. 실제 사용에서 결제 직후 마감되는 경우가 자주 생기면 다시 판단할 계획입니다.