호라이즌앱스 로고
← 기술 노트

회의실 예약 중복, 화면에서 확인해도 겹치는 이유와 막는 법

임균영조회 —

오전 9시 58분, 예약 화면의 회의실 A 10시 칸이 비어 있습니다.

두 사람이 각자 자리에서 그 칸을 거의 동시에 누릅니다. 프로그램은 두 사람 모두에게 비어 있다고 확인해 주고, 두 건을 다 저장합니다.

10시가 되면 회의실 문 앞에서 두 팀이 마주칩니다. 예약 프로그램을 쓰는데도 겹치는 이유는 확인하는 자리가 틀려서입니다.

직원 수십 명이 쓰는 사내 그룹웨어를 만들면서 저도 회의실 예약을 직접 짰습니다. 겹침 판정을 어디에 두었는지, 그 주변에서 만난 함정까지 적어 둡니다.

화면 확인이 뚫리는 틈

흔한 예약 프로그램은 저장하기 전에 이 회의실, 이 시간에 겹치는 예약이 있는지 한 번 묻습니다.

없으면 저장합니다. 순서만 보면 빈틈이 없어 보입니다.

문제는 조회와 저장 사이의 아주 짧은 시간입니다. 그 사이에 다른 사람의 요청이 끼어들 수 있습니다.

순서사용자 1사용자 2
110시 겹침 조회, 결과 없음
210시 겹침 조회, 결과 없음
3저장
4저장

두 사람 다 확인을 통과했고 두 건 다 정상 저장입니다. 프로그램 쪽에서 보면 규칙을 어긴 단계가 하나도 없습니다.

개발에서는 이런 상황을 경쟁 상태(race condition)라고 부릅니다. 쓰는 사람이 적을 때는 잘 안 보이고, 예약이 몰리는 시간일수록 확률이 올라갑니다.

도입 전 시연에서 이 구멍이 드러나는 일은 드뭅니다. 담당자 한 명이 차례로 눌러 보면 조회가 매번 정확하게 겹침을 잡아내기 때문입니다.

화면에 빨간 경고가 뜨는 걸 보고 안심하기 쉽습니다. 그 경고가 혼자 누를 때만 맞는 판정이라는 점은 시연에서 보이지 않습니다.

엑셀·단톡방 예약의 같은 구멍

공유 엑셀에 이름을 적는 방식도 틀은 같습니다. 빈칸을 확인하는 순간과 이름을 적는 순간이 떨어져 있습니다.

단톡방은 더 느슨합니다. 메시지가 위로 밀리면 누가 먼저 말했는지 스크롤을 올려 따져야 합니다.

결국 마지막 판정은 총무 담당자의 몫이 됩니다. 겹침을 막는 장치가 없으니 겹친 뒤에 조율하는 일이 업무로 남습니다.

제가 만든 예약 화면은 회의실을 가로로, 시간을 세로로 놓은 하루짜리 격자입니다. 빈칸을 눌러 제목만 적으면 예약이 되고, 이미 찬 칸은 블록으로 보입니다.

엑셀 시트와 생김새는 비슷합니다. 다른 점은 칸에 이름을 적는 순간을 누가 심판하느냐입니다.

판정을 저장하는 순간으로

제가 고른 방법은 겹침 판정을 데이터베이스에 맡기는 것입니다. PostgreSQL에는 배제제약(EXCLUDE)이라는 규칙이 있습니다.

같은 회의실이면서 시간 범위가 겹치는 두 행은 함께 있을 수 없다는 규칙을 테이블 정의에 적어 둡니다.

reservations(id, room_id, title, booker_id, booker_name, starts_at, ends_at)
  EXCLUDE USING gist (
    room_id WITH =,
    tstzrange(starts_at, ends_at) WITH &&
  )

room_id WITH =는 같은 회의실, WITH &&는 시간 범위가 겹친다는 뜻입니다. 회의실 번호 같은 일반 값을 이 인덱스에 함께 넣으려면 btree_gist 확장을 켜 둬야 합니다.

이 규칙은 저장하는 그 순간에 DB가 한 번에 판정합니다. 조회와 저장 사이에 틈이 생길 자리가 처음부터 없습니다.

그래서 두 요청이 같은 10시를 동시에 노려도 하나만 성공합니다. 늦게 닿은 쪽은 저장 자체가 거부됩니다.

같은 10시 칸을 두 사람이 동시에 누릅니다. 앱이 먼저 조회하는 방식에서는 두 건이 한 칸에 쌓이고, DB가 저장 순간에 판정하면 한 건만 앉고 늦은 쪽은 409와 안내 문구를 받습니다.

늦게 누른 사람에게 보이는 것

DB가 거부하면 오류코드 23P01을 돌려줍니다. 배제제약 위반이라는 뜻인데, 이 코드를 그대로 띄우면 사용자는 고장으로 받아들입니다.

그래서 API가 23P01만 골라 HTTP 409(Conflict)로 바꿉니다. 화면은 409를 받으면 "이미 예약된 시간입니다"라고 한 줄 띄웁니다.

화면에서 미리 찬 칸을 막아 주는 표시는 그대로 두셔도 됩니다. 그건 편의를 위한 안내이고, 최종 심판은 DB가 맡는다는 역할 구분만 분명하면 됩니다.

요점은 다른 DB 오류와 섞지 않는 데 있습니다. 409는 한발 늦었다는 뜻으로만 쓰고, 서버 문제는 다른 안내로 보냅니다.

사용자 입장에서는 다른 칸을 고르면 끝나는 일입니다.

예약을 취소하면 그 행이 사라지면서 제약도 풀리기 때문에, 비워진 시간은 다른 사람이 곧바로 잡을 수 있습니다.

시각은 어떤 기준으로 저장하나

겹침 판정은 결국 시각 비교입니다. DB는 두 예약의 시작·끝 시각을 같은 기준으로 놓고 겹치는지 봅니다.

입력과 표시는 한국 시간(KST, +09:00)으로 합니다. 저장은 타임존을 반영하는 시각형(TIMESTAMPTZ)에 합니다. 이 형식은 입력한 시각을 하나의 절대시각으로 바꿔 둡니다.

DB는 이 절대시각끼리 범위를 비교합니다. 화면이 어떤 시간대로 그리든 판정은 같은 기준선 위에서 이뤄집니다.

tstzrange(시작, 끝)은 기본값이 시작 포함, 끝 제외입니다. 그래서 9시10시 예약 바로 뒤에 10시11시 예약을 붙여도 겹침으로 잡히지 않습니다.

이 기준 근처에서 저도 한 번 헛디뎠습니다. 시연용 시드 데이터가 현재 시각에 9시간을 더한 값으로 예약을 만들었고, 화면에서는 예약이 오후로 밀려 보였습니다.

저장 값에 한국 시간을 한 번 더 얹으면 이렇게 됩니다. 변환은 화면에 그릴 때 한 번만 하세요.

회의실을 지우면 예약은?

나중에 관리자가 회의실을 추가하고 이름을 바꾸고 삭제할 수 있게 기능을 넓혔습니다. 설계 전에 스키마를 점검하다가 함정이 나왔습니다.

예약 테이블이 회의실을 참조하면서 "회의실이 지워지면 예약도 같이 지운다(ON DELETE CASCADE)"로 걸려 있었습니다.

회의실 하나를 지우면 그 방 예약이 알림 없이 전부 사라지는 구조입니다.

그래서 삭제 버튼을 누르면 회의실이 비활성 상태로 바뀌게 했습니다. 예약 화면에는 활성 회의실만 나오고, 지난 예약 이력은 그대로 남습니다.

비활성으로 돌린 회의실은 활성 표시를 되돌리면 다시 예약 화면에 나옵니다.

막는 방식별 장단점

방식장점남는 위험
앱에서 조회 후 저장구현이 가장 쉬움조회와 저장 사이 틈으로 중복
앱에서 잠금을 건 뒤 조회·저장DB 종류를 덜 탐예약이 들어오는 모든 경로가 잠금을 지켜야 함
DB 배제제약어느 경로로 들어와도 저장 순간 판정PostgreSQL에 묶임, 오류 변환을 따로 짜야 함

저는 마지막을 골랐습니다. 화면이 늘거나 관리자 화면에서 직접 넣는 경로가 생겨도 규칙은 테이블 한곳에만 있습니다.

주간 정기회의 같은 반복 예약을 나중에 붙이더라도 각 회차가 같은 제약을 통과하게 됩니다.

회의실 말고 차량, 장비, 상담실도 구조가 같습니다. 무엇을 언제부터 언제까지 쓰느냐의 문제라서 같은 제약 하나로 막을 수 있습니다.

회의실 × 시간 격자의 일간 화면은 그룹웨어 데모의 회의실 예약 탭에 그대로 옮겨 두었습니다.

가상 조직과 더미 데이터로 만든 시연판입니다.

예약 대상이 여러 종류이거나 지금 쓰는 사내 시스템에 붙여야 한다면 제약을 어느 단위에 걸지부터 정해야 합니다. 지금 예약을 받는 방식을 업무 시스템 상담에 남기시면 그 단위를 먼저 짚어 드립니다.

엑셀을 유지할지 예약 화면으로 옮길지부터 같이 따져 보겠습니다.

예약 도구 점검표

지금 쓰는 도구든 새로 고르는 도구든 이 네 줄을 확인해 보세요.

  • 두 명이 동시에 같은 칸을 눌러 봤는가. PC 두 대나 PC와 휴대폰으로 같은 회의실, 같은 시간을 띄워 두고 신호에 맞춰 저장해 보세요. 두 건이 다 들어가면 판정이 저장 순간에 있지 않습니다.
  • 겹침 판정이 화면에 있는가, DB에 있는가. 업체에 겹침 검사를 DB 제약으로 하는지, 앱에서 조회한 뒤 저장하는지 물어보세요. 답이 뒤쪽이면 몰리는 시간의 중복은 남습니다.
  • 시각을 어떤 기준으로 저장하는가. 9시10시 예약 뒤에 10시11시가 들어가는지, 시드나 연동으로 들어온 예약이 제 칸에 그려지는지 보세요. 늦은 쪽으로 밀려 있으면 변환을 두 번, 이른 쪽으로 밀려 있으면 변환을 빠뜨린 경우가 많습니다.
  • 거절될 때 사용자에게 무엇이 보이는가. 늦게 누른 사람에게 "이미 예약된 시간입니다"처럼 이유가 보이는지, "오류가 발생했습니다"만 뜨는지 확인하세요. 이유가 안 보이면 같은 칸을 몇 번이고 다시 누르게 됩니다.

이어서 읽기