예약 시스템을 운영하다 보면 언젠가 이런 문의를 받게 됩니다.
"같은 객실이 두 명한테 예약됐는데요."
확인해보면 두 예약 모두 정상적으로 처리되어 있습니다. 오류 로그도 없습니다. 각각의 요청은 완벽하게 동작했고, 다만 거의 같은 시각에 들어왔을 뿐입니다.
이런 문제는 테스트 환경에서 잘 재현되지 않습니다. 혼자 클릭해서는 발생하지 않기 때문입니다. 운영에 올라가고 트래픽이 붙기 시작하면 그때부터 간헐적으로 나타납니다.
이 글은 예약·좌석·한정 수량 판매처럼 제한된 자원을 여러 사람이 동시에 요청하는 시스템을 설계할 때 결정해야 하는 것들을 정리한 내용입니다.
1. 중복 예약은 어디서 만들어지는가
원인을 정확히 이해하면 해결 방법도 명확해집니다.
재고의 단위부터 정의해야 합니다
동시성을 다루기 전에 확인할 것이 있습니다. 무엇을 하나의 재고로 볼 것인가입니다. 이 정의가 애매하면 이후 설계가 전부 흔들립니다.
숙박을 예로 들면 두 가지 방식이 있습니다.
| 방식 | 재고 단위 | 특징 |
|---|---|---|
| 객실 지정 | 201호, 202호처럼 개별 객실 | 어느 방인지 확정됨. 배정 유연성이 낮음 |
| 유형별 수량 | 스탠다드 더블 10실 중 남은 수 | 배정을 나중에 할 수 있음. 실제 배정 단계가 따로 필요 |
유형별 수량 방식이 일반적입니다. 예약 시점에는 "스탠다드 더블 1실"만 확보하고, 실제 호실 배정은 체크인 시점이나 전날에 하는 방식입니다. 이러면 청소·정비 일정에 맞춰 배정을 조정할 수 있습니다.
다만 이 방식에서는 배정 단계에서 또 한 번 동시성 문제가 생깁니다. 여러 예약에 같은 호실이 배정되지 않도록 해야 합니다.
기간이 겹치는 것도 재고 충돌입니다
날짜 범위가 있는 예약은 단순한 수량 차감보다 복잡합니다.
예약 A: 3월 10일 ~ 3월 13일
예약 B: 3월 12일 ~ 3월 15일 ← 12~13일이 겹침
수량으로 관리한다면 날짜별로 재고를 나눠서 계산해야 합니다. 3박 예약은 3개 날짜의 재고를 각각 하나씩 차지하는 구조입니다.
기간의 끝을 어떻게 볼지도 먼저 정해야 합니다. 숙박에서 3월 10일 체크인, 13일 체크아웃은 10·11·12일 밤의 재고만 차지하고, 13일에는 다른 손님이 체크인할 수 있어야 합니다. 그래서 기간은 시작일 포함, 종료일 제외로 저장하고 비교하는 편이 안전합니다. 이 규칙이 화면, API, 데이터베이스에서 서로 다르면 "퇴실일에 입실이 안 잡힌다"거나 반대로 하루가 겹쳐 팔리는 문제가 생깁니다. PostgreSQL의 daterange 는 기본 표기가 이 방식([))입니다(PostgreSQL 문서: Range Types).
이때 주의할 점이 있습니다. 3박 중 하루라도 재고가 없으면 예약 자체가 불가능합니다. 따라서 여러 날짜의 재고를 한 번에 확보하거나 전부 실패하도록 처리해야 합니다. 일부만 확보된 상태로 남으면 안 됩니다.
확인과 차감 사이에 틈이 있습니다
대부분의 예약 처리는 다음 순서로 진행됩니다.
① 재고 조회 — "남은 객실이 있는가?"
② 판단 — "있다. 진행하자."
③ 차감 — "재고를 줄이고 예약을 생성한다."
문제는 ①과 ③ 사이에 시간이 존재한다는 점입니다. 아무리 짧아도 0은 아닙니다.
두 요청이 이 구간에서 겹치면 이렇게 됩니다.
사용자 A: ① 조회 → 남은 객실 1개
사용자 B: ① 조회 → 남은 객실 1개 ← A가 아직 차감하기 전
사용자 A: ③ 차감 → 예약 생성
사용자 B: ③ 차감 → 예약 생성 ← 이미 없는데 생성됨
코드에는 아무 문제가 없습니다. 각 요청은 조회한 값을 근거로 올바르게 판단했습니다. 판단의 근거가 된 값이 그 사이에 바뀐 것이 문제입니다.
결제가 끼면 틈이 훨씬 길어집니다
결제가 포함되면 이 구간이 초 단위를 넘어 분 단위가 됩니다.
재고 확인 → 예약 정보 입력 → 결제 수단 선택 → 카드사 인증 → 승인 → 예약 확정
사용자가 결제 정보를 입력하는 몇 분 동안 재고는 어떤 상태여야 할까요? 이 질문에 답하지 않으면 두 가지 중 하나가 발생합니다.
- 차감하지 않으면 — 결제까지 마쳤는데 이미 다른 사람이 가져간 상태가 됩니다
- 차감하면 — 결제를 포기한 사용자 때문에 재고가 묶입니다
어느 쪽도 그냥 두면 안 되는 상황입니다.
여러 경로로 예약이 들어옵니다
자사 홈페이지만 있다면 그나마 단순합니다. 실제로는 여러 경로가 동시에 같은 재고를 봅니다.
- 자사 홈페이지·앱
- 외부 예약 플랫폼 여러 곳
- 전화 예약을 받아 직원이 직접 입력
- 현장 워크인
- 기업 단체 예약
각 경로가 재고를 보는 시점과 반영하는 시점이 다르면 어긋납니다. 특히 외부 플랫폼은 우리 시스템을 실시간으로 조회하지 않고 주기적으로 받아간 재고 정보를 가지고 판매하는 경우가 많습니다.
2. 재고를 언제 차감할 것인가
이 결정이 설계 전체의 방향을 정합니다.
세 가지 시점이 있습니다
| 차감 시점 | 동작 | 장점 | 단점 |
|---|---|---|---|
| 예약 시작 시 | 화면 진입 또는 선택 즉시 차감 | 중복 방지가 확실 | 이탈자 때문에 재고가 묶임 |
| 결제 직전 | 결제 요청 직전에 차감 | 묶이는 시간이 짧음 | 결제 도중 실패 시 처리 복잡 |
| 결제 완료 후 | 승인 응답을 받고 차감 | 실제 판매만 반영 | 결제했는데 재고가 없을 수 있음 |
세 번째 방식은 피해야 합니다. 사용자가 돈을 낸 뒤에 "품절입니다"라고 알리는 것은 가장 나쁜 경험이고, 환불 처리 부담도 큽니다.
실무에서는 첫 번째와 두 번째를 조합한 임시 점유 방식을 많이 씁니다.
임시 점유를 씁니다
사용자가 예약 절차에 들어가면 해당 재고를 일정 시간 동안 잡아두고, 시간이 지나면 자동으로 풀어주는 방식입니다.
정해야 할 항목입니다.
- 점유 시간 — 결제를 완료하기에 충분하면서 너무 길지 않은 값
- 연장 여부 — 사용자가 시간 내에 못 끝냈을 때 연장을 허용할지
- 만료 처리 — 시간이 지난 점유를 어떻게 해제할지
- 표시 — 사용자에게 남은 시간을 보여줄지
만료 처리 방식이 설계에서 중요합니다. 두 가지 접근이 있습니다.
- 배치로 정리 — 주기적으로 만료된 점유를 찾아 해제합니다. 구현이 단순하지만 배치 주기만큼 재고가 늦게 풀립니다
- 조회 시점에 판단 — 재고를 조회할 때 만료된 점유는 없는 것으로 계산합니다. 즉시 반영되지만 조회 로직이 복잡해집니다
둘을 함께 쓰는 경우가 많습니다. 조회 시점에 만료된 것을 제외하고, 배치로 실제 데이터를 정리하는 구성입니다.
점유와 확정을 구분해서 저장합니다
임시 점유와 확정 예약을 같은 상태 값으로 관리하면 혼란이 생깁니다. 상태를 명확히 나누는 편이 낫습니다.
점유중(HELD) → 확정(CONFIRMED) → 이용완료(COMPLETED)
↓ ↓
만료(EXPIRED) 취소(CANCELLED)
각 상태에서 재고를 차지하는지 여부를 정의해두면 계산이 명확해집니다. 점유중과 확정은 차지하고, 만료와 취소는 차지하지 않는 식입니다.
만료와 결제 완료가 엇갈리는 경우를 막습니다
임시 점유에서 가장 자주 놓치는 경우는 점유가 만료된 직후에 결제 승인이 도착하는 것입니다. 만료 배치가 재고를 풀어 다른 사람이 가져갔는데, 뒤늦게 온 승인 응답이 예약을 확정해버리면 중복이 됩니다. 상태 전이 자체를 조건부 갱신으로 만들면 둘 중 하나만 성공합니다.
-- 가정: PostgreSQL. reservation 테이블에 status, expires_at 컬럼이 있음. 식별자는 예시 값
-- 결제 승인 후 확정: 아직 점유중이고 만료 전일 때만 성공
UPDATE reservation
SET status = 'CONFIRMED'
WHERE id = 1001
AND status = 'HELD'
AND expires_at > now();
-- 만료 배치: 점유중인 것만 만료 처리. 재고 복원도 같은 트랜잭션에서 수행
UPDATE reservation
SET status = 'EXPIRED'
WHERE status = 'HELD'
AND expires_at <= now()
RETURNING id;
확정 문장의 갱신 행 수가 0이면 이미 만료된 점유입니다. 이때는 예약을 확정하지 말고 결제를 취소하는 보상 처리로 넘어가야 합니다. 이 경로를 만들어두지 않으면 "결제는 됐는데 예약이 없다"는 문의로 돌아옵니다. 결제사 인증 화면에서 머무는 시간까지 고려해, 점유 시간은 화면에 표시하는 제한 시간보다 조금 길게 잡는 편이 이런 경우를 줄입니다.
3. 동시 요청을 어떻게 막을 것인가
차감 시점을 정했다면, 그 차감이 안전하게 이루어지도록 해야 합니다.
데이터베이스 제약으로 막는 것이 가장 확실합니다
애플리케이션 코드로 아무리 잘 검사해도, 여러 서버에서 동시에 실행되면 빈틈이 생깁니다. 마지막 방어선은 데이터베이스에 두는 것이 안전합니다.
- 고유 제약 — "같은 객실, 같은 날짜"에 확정 예약이 하나만 존재하도록 제약을 겁니다. 두 번째 요청은 데이터베이스 수준에서 거부됩니다
- 범위 중복 방지 — 기간이 겹치는 예약을 막아야 한다면, 데이터베이스가 제공하는 범위 제약 기능을 검토합니다
- 조건부 갱신 — "남은 수량이 1 이상일 때만 1 감소"처럼 조건을 포함한 갱신문을 사용합니다. 갱신된 행이 0건이면 실패로 처리합니다
조건부 갱신 방식은 구현이 간단하면서 효과적입니다. 조회와 차감을 하나의 문장으로 합치면 그 사이의 틈이 사라집니다.
수량 재고: 조건부 갱신 예시
유형별 수량을 날짜별 행으로 관리하는 경우입니다. 3박 예약은 3개 행을 한 문장으로 차감하고, 갱신된 행 수가 숙박 일수와 같을 때만 확정합니다.
-- 가정: PostgreSQL, 기본 격리 수준(READ COMMITTED). 식별자와 날짜는 예시 값
CREATE TABLE room_type_inventory (
room_type_id bigint NOT NULL,
stay_date date NOT NULL,
total integer NOT NULL,
sold integer NOT NULL DEFAULT 0,
PRIMARY KEY (room_type_id, stay_date),
CHECK (sold >= 0 AND sold <= total)
);
BEGIN;
UPDATE room_type_inventory
SET sold = sold + 1
WHERE room_type_id = 10
AND stay_date >= DATE '2026-03-10'
AND stay_date < DATE '2026-03-13'
AND sold < total;
-- 갱신 행 수가 3이면 예약 행을 INSERT 하고 COMMIT
-- 3보다 작으면 하루 이상 재고가 없는 것이므로 ROLLBACK
이 문장이 안전한 이유는 PostgreSQL의 READ COMMITTED 동작에 있습니다. 다른 트랜잭션이 같은 행을 먼저 갱신하고 있으면 기다렸다가, 상대가 커밋한 뒤 갱신된 값으로 WHERE 조건을 다시 평가합니다. 그 사이 sold 가 total 에 도달했다면 해당 행은 갱신 대상에서 빠집니다(PostgreSQL 문서: Transaction Isolation).
주의할 점입니다.
- 갱신 행 수를 반드시 확인합니다. 행 수를 보지 않고 다음 단계로 넘어가는 구현이 실제로 가장 흔한 실수입니다. ORM을 쓴다면 갱신 행 수를 돌려주는 API인지 확인합니다
CHECK제약은 애플리케이션의 다른 경로(관리자 화면, 배치)가 조건 없이 수량을 바꾸는 경우를 막는 마지막 장치입니다- 날짜 범위가 서로 엇갈리는 예약이 동시에 들어오면 행을 잠그는 순서가 달라져 교착이 날 수 있습니다. 아래 "자주 나는 오류"의
40P01항목을 참고합니다
객실 지정: 기간 겹침을 제약으로 막기
개별 객실에 기간을 배정하는 경우에는 "같은 객실, 겹치는 기간"을 고유 제약으로 표현할 수 없습니다. PostgreSQL은 범위 타입과 배타 제약(EXCLUDE)으로 이를 직접 표현합니다. 범위 타입은 PostgreSQL 9.2에서 추가되었고(PostgreSQL 9.2 릴리스 노트), 객실 번호 같은 일반 컬럼의 동등 비교를 범위 겹침과 함께 쓰려면 btree_gist 확장이 필요합니다(PostgreSQL 문서: Range Types).
-- 가정: PostgreSQL 9.2 이상, 확장 설치 권한이 있는 계정
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE room_assignment (
reservation_id bigint NOT NULL,
room_id bigint NOT NULL,
stay daterange NOT NULL,
status text NOT NULL,
expires_at timestamptz,
CONSTRAINT room_assignment_no_overlap
EXCLUDE USING gist (room_id WITH =, stay WITH &&)
WHERE (status IN ('HELD', 'CONFIRMED'))
);
INSERT INTO room_assignment (reservation_id, room_id, stay, status)
VALUES (1001, 201, daterange('2026-03-10', '2026-03-13'), 'CONFIRMED');
-- 12~15일은 위 예약과 겹치므로 거부됨 (SQLSTATE 23P01)
INSERT INTO room_assignment (reservation_id, room_id, stay, status)
VALUES (1002, 201, daterange('2026-03-12', '2026-03-15'), 'CONFIRMED');
-- 13일 체크인은 겹치지 않으므로 성공
INSERT INTO room_assignment (reservation_id, room_id, stay, status)
VALUES (1003, 201, daterange('2026-03-13', '2026-03-15'), 'CONFIRMED');
WHERE 절은 제약을 일부 행에만 적용합니다. 취소·만료된 배정은 겹침 검사에서 빠지므로 같은 기간을 다시 팔 수 있습니다(PostgreSQL 문서: CREATE TABLE).
적용할 때 확인할 것입니다.
- 만료 시각은 제약 조건에 넣을 수 없습니다. 제약의 조건식에는 현재 시각처럼 실행할 때마다 값이 달라지는 함수를 쓸 수 없기 때문에, "만료된 점유는 무시"를 제약만으로 표현하지 못합니다. 만료 배치가 상태를
EXPIRED로 바꿔야 재고가 풀립니다. 배치 주기를 기다리지 않으려면 배타 제약 위반이 났을 때 해당 객실의 만료된 점유를 정리하고 한 번만 다시 시도하는 방법을 씁니다 - 위반 오류를 품절로 번역합니다.
23P01은 시스템 오류가 아니라 "이미 팔렸다"는 정상적인 업무 결과입니다. 500 응답이나 장애 알림으로 흘러가지 않도록 분리합니다 - PostgreSQL 18 이상에서는
PRIMARY KEY와UNIQUE에WITHOUT OVERLAPS를 지정해 같은 제약을 표현할 수 있습니다(PostgreSQL 18 릴리스 노트). 다만 이 문법에는 위 예시의WHERE같은 부분 적용 조건이 없으므로, 상태별로 제외해야 한다면EXCLUDE를 그대로 씁니다
MySQL에서는 방법이 달라집니다
MySQL에는 기간 겹침을 직접 막는 제약이 없습니다. 그래서 "겹치는 예약이 있는지 조회한 뒤 없으면 INSERT" 하는 방식으로 구현하게 되는데, 이 방식은 격리 수준에 따라 다르게 실패합니다.
- READ COMMITTED 에서는 검색과 인덱스 스캔에 갭 락이 걸리지 않습니다. 두 트랜잭션이 모두 "겹치는 예약 없음"을 확인하고 각각 INSERT 에 성공합니다
- REPEATABLE READ(InnoDB 기본값) 에서는
SELECT ... FOR UPDATE가 스캔한 범위에 갭 락을 겁니다. 그런데 갭 락은 다른 트랜잭션의 삽입을 막는 용도일 뿐 갭 락끼리는 충돌하지 않아서, 두 트랜잭션이 같은 구간에 동시에 갭 락을 잡을 수 있습니다(MySQL 8.4 매뉴얼: InnoDB Locking). 이 상태에서 양쪽이 INSERT 를 시도하면 서로를 기다리게 되고, 한쪽이 교착으로 롤백됩니다. 중복은 막히지만 정상 요청이 교착 오류로 실패하는 형태가 됩니다
MySQL에서는 다음 두 가지가 더 예측 가능합니다.
- 날짜별 행과 고유 키 — 배정을
(room_id, stay_date)단위 행으로 저장하고 고유 키를 겁니다. 3박이면 3개 행을 한 트랜잭션으로 INSERT 하고, 하나라도 중복 키 오류가 나면 전체를 롤백합니다 - 부모 행 잠금 — 객실 행을 먼저
SELECT ... FOR UPDATE로 잠근 뒤 겹침을 조회하고 INSERT 합니다. 고유 인덱스로 한 행을 찾는 잠금에는 갭 락이 필요 없으므로 같은 객실에 대한 요청만 순서대로 처리됩니다
두 번째 방법은 PostgreSQL에서도 그대로 쓸 수 있습니다. 겹침 조회에 count(*) 같은 집계를 쓰면서 FOR UPDATE 를 붙이려다 막히는 경우가 많은데, PostgreSQL은 집계 결과에 잠금 절을 허용하지 않습니다(PostgreSQL 문서: SELECT). 잠글 대상은 집계 결과가 아니라 그 자원을 대표하는 부모 행입니다.
-- 가정: PostgreSQL 또는 MySQL 8.0 이상(InnoDB). room.id 는 기본 키
BEGIN; -- MySQL은 START TRANSACTION
SELECT id FROM room WHERE id = 201 FOR UPDATE;
-- 이 시점부터 201호에 대한 다른 예약 트랜잭션은 위 문장에서 대기
-- 겹치는 배정을 조회하고, 없으면 INSERT
COMMIT;
잠금 획득은 트랜잭션의 첫 문장으로 둡니다. REPEATABLE READ 이상에서는 잠금을 얻기 전에 이미 조회를 수행했다면, 잠금을 얻은 뒤의 조회도 이전 시점의 데이터를 볼 수 있습니다.
잠금 방식을 선택합니다
| 방식 | 동작 | 적합한 경우 |
|---|---|---|
| 비관적 잠금 | 처리하는 동안 해당 행을 잠가 다른 요청을 대기시킴 | 경합이 잦고 충돌 비용이 큰 경우 |
| 낙관적 잠금 | 버전 값을 확인해 변경되었으면 실패 처리 | 경합이 드문 경우 |
예약처럼 특정 시점에 요청이 몰리는 시스템은 비관적 잠금이 맞는 경우가 많습니다. 낙관적 잠금은 충돌이 나면 재시도해야 하는데, 몰리는 상황에서는 재시도도 계속 실패합니다.
다만 비관적 잠금을 쓸 때는 주의할 점이 있습니다.
- 잠금 범위를 좁게 — 필요한 행만 잠급니다. 테이블 전체가 잠기면 전체 처리량이 떨어집니다
- 잠금 시간을 짧게 — 잠근 상태에서 외부 API를 호출하면 안 됩니다. 응답이 늦어지는 동안 다른 요청이 전부 대기합니다
- 순서를 통일 — 여러 자원을 잠글 때 순서가 다르면 교착 상태가 생깁니다
잠금 상태에서 결제 API를 호출하는 구현은 반드시 피해야 합니다. 카드사 응답이 5초 걸리면 그 5초 동안 모든 예약 요청이 멈춥니다.
기다릴지, 바로 실패할지, 건너뛸지 정합니다
SELECT ... FOR UPDATE 는 기본적으로 상대 트랜잭션이 끝날 때까지 기다립니다. PostgreSQL은 교착이 감지되지 않는 한 무기한 기다리므로(PostgreSQL 문서: Explicit Locking), 대기 방식을 명시하지 않으면 요청이 쌓여 커넥션 풀이 고갈됩니다.
| 방식 | 동작 | 예약에서의 용도 |
|---|---|---|
| 대기 + 시간 제한 | 정해진 시간만 기다리고 오류 | 일반적인 예약 확정 처리 |
NOWAIT |
잠겨 있으면 즉시 오류 | "다른 사용자가 처리 중" 안내를 바로 내보낼 때 |
SKIP LOCKED |
잠긴 행을 결과에서 제외 | 여러 후보 중 아무거나 하나를 잡을 때 |
-- 가정: PostgreSQL 9.5 이상. room 테이블에 room_type_id 컬럼이 있음
BEGIN;
-- 이 트랜잭션 안에서만 잠금 대기를 3초로 제한 (3초는 예시 값)
SET LOCAL lock_timeout = '3s';
SELECT id FROM room WHERE id = 201 FOR UPDATE;
COMMIT;
-- 호실 자동 배정: 다른 트랜잭션이 잡고 있는 객실은 건너뛰고 하나를 잠금
-- (잠금은 트랜잭션이 끝나면 풀리므로 배정 INSERT 와 같은 트랜잭션 안에서 실행)
SELECT id
FROM room
WHERE room_type_id = 10
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;
lock_timeout 은 기본값이 0(제한 없음)이고, 서버 설정 파일에 전역으로 두는 것은 모든 세션에 영향을 주므로 권장되지 않습니다(PostgreSQL 문서: Client Connection Defaults). 트랜잭션이나 역할 단위로 지정합니다.
SKIP LOCKED 는 PostgreSQL 9.5(PostgreSQL 9.5 릴리스 노트), MySQL 8.0.1(MySQL 8.0.1 릴리스 노트)부터 쓸 수 있습니다. 두 DBMS의 문서 모두 잠긴 행을 건너뛴 결과는 일관된 데이터가 아니므로 일반 업무 처리에는 맞지 않고, 대기열 형태의 테이블에서 경합을 피하는 용도라고 설명합니다(MySQL 8.4 매뉴얼: Locking Reads). 예약에 옮기면 이렇게 됩니다.
- 호실 자동 배정, 만료 점유를 처리할 작업 선택처럼 "어느 것이든 하나" 가 필요한 곳에 씁니다
- "남은 재고가 있는가"를 판단하는 조회에는 쓰지 않습니다. 잠겨 있을 뿐 아직 팔리지 않은 객실이 없는 것으로 보여, 재고가 있는데 품절로 응답하게 됩니다
- MySQL에서
NOWAIT·SKIP LOCKED문장은 문장 기반 복제(statement-based replication)에 안전하지 않습니다
격리 수준을 올리는 방법과 재시도
조건부 갱신이나 제약으로 표현하기 어려운 규칙(예: "한 고객은 같은 날 2건까지")은 격리 수준을 올려서 막을 수 있습니다. 다만 수준별로 막는 범위가 다릅니다.
| 격리 수준 (PostgreSQL) | 조회 후 INSERT 방식의 중복 | 실패 시 동작 |
|---|---|---|
| READ COMMITTED (기본값) | 막지 못함 | 오류 없이 둘 다 성공 |
| REPEATABLE READ | 막지 못함. 같은 행을 갱신하는 충돌만 감지 | 40001 |
| SERIALIZABLE | 막음 | 40001, 트랜잭션 전체 재시도 필요 |
PostgreSQL의 REPEATABLE READ는 스냅샷 격리로 구현되어 있어, 서로 다른 행을 INSERT 하는 두 트랜잭션은 충돌로 보지 않습니다. 지금의 SERIALIZABLE 동작은 9.1부터이며, 그 이전 버전의 SERIALIZABLE은 현재의 REPEATABLE READ와 같습니다(PostgreSQL 문서: Transaction Isolation).
SERIALIZABLE을 쓰면 직렬화 실패가 정상 동작의 일부가 됩니다. PostgreSQL은 자동 재시도를 제공하지 않으며, 어떤 SQL을 어떤 값으로 실행할지 결정하는 로직까지 포함해 트랜잭션 전체를 다시 실행하라고 안내합니다(PostgreSQL 문서: Serialization Failure Handling).
// 가정: Node.js, node-postgres(pg). 재시도 횟수와 대기 시간은 예시 값
import { Pool, type PoolClient } from 'pg';
const RETRYABLE_SQLSTATES = new Set(['40001', '40P01']);
const MAX_ATTEMPTS = 3;
function isRetryable(error: unknown): boolean {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return false;
}
return typeof error.code === 'string' && RETRYABLE_SQLSTATES.has(error.code);
}
export async function runSerializable<T>(
pool: Pool,
work: (client: PoolClient) => Promise<T>,
): Promise<T> {
for (let attempt = 1; ; attempt += 1) {
const client = await pool.connect();
try {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
const result = await work(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
if (!isRetryable(error) || attempt >= MAX_ATTEMPTS) {
throw error;
}
const delayMs = Math.random() * 50 * attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
} finally {
client.release();
}
}
}
재시도를 넣을 때 지켜야 할 것입니다.
work안에 되돌릴 수 없는 외부 호출을 넣지 않습니다. 결제 승인, 알림 발송, 외부 채널 통보가 들어 있으면 재시도 때마다 다시 실행됩니다- 직렬화 실패는
COMMIT에서도 발생합니다. 커밋 응답을 확인하기 전에 사용자에게 성공을 알리면 안 됩니다 - 횟수를 제한하고 대기 시간을 무작위로 둡니다. 경합이 심하면 여러 번 재시도해도 실패할 수 있고, 같은 간격으로 재시도하면 다시 부딪칩니다
23505(고유 제약)와23P01(배타 제약)은 일괄 재시도 대상이 아닙니다. 예약에서는 대부분 "이미 팔렸다"는 확정적인 결과입니다
서버가 여러 대라면 잠금 위치가 달라집니다
서버 한 대에서 동작하는 잠금은 여러 대로 늘어나는 순간 의미가 없어집니다. 각 서버가 자기 메모리 안에서만 잠그기 때문에, 다른 서버의 요청은 그대로 통과합니다.
확인할 것들입니다.
- 애플리케이션 메모리 기반 잠금을 쓰고 있지 않은가
- 잠금이 모든 서버가 공유하는 저장소에 있는가
- 잠금을 획득한 서버가 죽으면 그 잠금이 풀리는가
마지막 항목이 중요합니다. 잠금을 잡은 채로 프로세스가 종료되면, 그 잠금이 영원히 남아 해당 자원을 아무도 쓸 수 없게 됩니다. 잠금에는 반드시 유효 시간을 두어야 합니다.
가장 단순하면서 안전한 방법은 데이터베이스 트랜잭션을 잠금으로 쓰는 것입니다. 별도 구성 요소가 늘어나지 않고, 트랜잭션이 끝나면 잠금도 자동으로 해제됩니다. 처리량이 매우 큰 경우가 아니라면 대체로 충분합니다.
잠글 부모 행이 마땅치 않은 자원(예: 아직 재고 행이 만들어지지 않은 날짜)에는 PostgreSQL의 권고 잠금을 쓸 수 있습니다. pg_advisory_xact_lock(key) 는 트랜잭션이 끝나면 자동으로 풀립니다(PostgreSQL 문서: System Administration Functions). 세션 단위 함수(pg_advisory_lock)는 롤백해도 풀리지 않고 세션이 끝나거나 명시적으로 해제할 때까지 유지되므로, 커넥션 풀을 쓰는 환경에서는 트랜잭션 단위 함수를 쓰는 편이 안전합니다(PostgreSQL 문서: Explicit Locking). 권고 잠금은 같은 키를 쓰기로 약속한 코드끼리만 서로를 막는다는 점도 기억해야 합니다.
Redis 분산 락은 보조 수단으로 씁니다
데이터베이스에 가는 요청 자체를 줄이려고 Redis 락을 앞단에 두는 구성도 많이 씁니다. Redis 공식 문서가 안내하는 단일 인스턴스 기준의 방법은 다음과 같습니다(Redis 문서: Distributed Locks with Redis).
# 획득: 키가 없을 때만 설정(NX), 30초 뒤 자동 만료(PX). 값은 요청마다 고유한 무작위 값
SET lock:room:201 <무작위 값> NX PX 30000
# 해제: 저장된 값이 내가 넣은 값과 같을 때만 삭제
# Redis 8.4 이상
DELEX lock:room:201 IFEQ <무작위 값>
-- Redis 8.4 미만에서의 해제 스크립트. KEYS[1] = 락 키, ARGV[1] = 무작위 값
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
값을 비교하지 않고 DEL 로 해제하면, 처리가 길어져 내 락이 만료된 뒤 다른 요청이 잡은 락을 지우게 됩니다.
같은 문서와 그에 대한 분석 글이 밝히는 한계는 예약 설계에서 그대로 문제가 됩니다.
- 만료는 "작업이 끝났다"는 뜻이 아닙니다. 프로세스가 일시 정지되거나 네트워크가 지연되면, 락이 만료되어 다른 요청이 진입했는데도 원래 요청은 자신이 락을 가진 줄 알고 계속 진행합니다(How to do distributed locking)
- 복제는 비동기입니다. 마스터가 락 키를 복제본에 전달하기 전에 죽고 복제본이 승격되면, 두 요청이 같은 락을 갖게 됩니다
- 만료 시각은 단조 시계 기준이 아닙니다. 서버 시계가 조정되면 락이 일찍 풀릴 수 있습니다
- 정확성이 필요하면 펜싱 토큰을 구현하라고 문서가 직접 권고합니다. 락을 얻을 때마다 증가하는 번호를 받고, 저장소가 이미 처리한 것보다 낮은 번호의 쓰기를 거부하는 방식입니다
예약 시스템에서는 이 역할을 앞에서 다룬 조건부 갱신과 제약이 맡습니다. Redis 락이 뚫려 두 요청이 모두 데이터베이스에 도달해도 한쪽은 갱신 행 수 0 또는 제약 위반으로 거부됩니다. 따라서 Redis 락은 경합을 줄이는 용도로만 쓰고, 중복을 막는 책임은 데이터베이스에 둡니다. Redis 락만으로 중복을 막는 구조라면 위 경우 중 하나에서 오버부킹이 발생합니다.
재시도와 사용자 안내를 설계합니다
동시 요청에서 밀린 사용자에게 무엇을 보여줄지도 설계 대상입니다.
- 실패 사유를 명확히 구분합니다 — 품절인지, 일시적 충돌인지
- 일시적 충돌이라면 자동으로 재시도할 수 있습니다
- 품절이라면 대안을 제시합니다 — 다른 날짜, 다른 객실 유형, 대기 등록
"처리에 실패했습니다"만 표시하면 사용자는 같은 버튼을 계속 누릅니다. 그 결과 요청이 더 몰립니다.
4. 여러 채널의 재고를 맞춥니다
자사 시스템 안에서 동시성을 해결해도, 외부 채널이 붙으면 문제가 다시 생깁니다.
재고를 나눌 것인가 공유할 것인가
| 방식 | 동작 | 특징 |
|---|---|---|
| 할당 방식 | 채널별로 재고를 미리 나눠줌 | 초과 판매 위험이 없으나 한쪽이 남고 한쪽이 부족해짐 |
| 공유 방식 | 전체 재고를 모든 채널이 공유 | 효율적이나 동기화 지연 시 초과 판매 위험 |
공유 방식이 일반적이지만, 외부 채널의 재고 반영이 실시간이 아니라면 위험이 있습니다. 그래서 실무에서는 두 방식을 섞습니다. 성수기나 잔여 재고가 적을 때는 할당으로 전환하는 식입니다.
동기화 지연을 전제로 설계합니다
외부 플랫폼이 우리 재고를 가져가는 방식은 대체로 둘 중 하나입니다.
- 우리가 밀어준다 — 재고가 바뀔 때마다 각 채널에 전송합니다. 실시간에 가깝지만 채널이 많으면 부하가 큽니다
- 채널이 가져간다 — 주기적으로 조회해갑니다. 주기만큼 지연이 생깁니다
어느 쪽이든 지연 구간은 존재합니다. 그 사이에 예약이 들어오면 초과가 발생할 수 있습니다.
대응 방법입니다.
- 잔여 재고가 적을 때는 외부 채널에 여유분을 남기고 노출합니다
- 채널에서 들어온 예약도 우리 시스템에서 최종 확인 절차를 거칩니다
- 초과가 발생했을 때의 처리 규칙을 미리 정해둡니다
채널마다 다른 규칙을 흡수합니다
외부 채널을 연동할 때 어려운 부분은 기술이 아니라 규칙의 차이입니다.
- 취소 가능 시점과 수수료 기준이 다름
- 예약 확정 시점이 다름 — 즉시 확정인 곳과 승인이 필요한 곳
- 요금 체계와 할인 적용 방식이 다름
- 투숙객 정보로 받는 항목이 다름
- 변경 요청의 처리 방식이 다름
이 차이를 우리 시스템 안에서 어떻게 표현할지 정해야 합니다. 채널별로 분기 처리를 넣기 시작하면 코드가 빠르게 복잡해집니다.
실무적으로는 공통 모델을 정의하고 채널별 변환 계층을 두는 구조가 유지보수에 유리합니다. 내부에서는 하나의 예약 모델로 다루고, 각 채널의 규격은 연동 지점에서 변환하는 방식입니다. 채널이 추가될 때 변환 계층만 만들면 됩니다. 연동 지점의 타임아웃, 재전송, 중복 수신 처리는 외부 시스템 연동에서 장애가 생기는 자리 — 인터페이스 설계 원칙에서 따로 다뤘습니다.
초과가 발생했을 때의 절차를 정합니다
기술적으로 완전히 막을 수 없다면, 발생했을 때의 처리가 설계에 포함되어야 합니다.
- 누구를 우선할 것인가 — 접수 시각 순, 채널 우선순위, 결제 완료 여부
- 어떻게 알릴 것인가 — 자동 통보인지 담당자가 연락하는지
- 무엇을 제공할 것인가 — 대체 상품, 상위 등급 제공, 보상 기준
- 언제까지 처리할 것인가
이 절차가 없으면 현장에서 그때그때 대응하게 되고, 고객마다 다른 처리를 받게 됩니다.
의도적인 초과 판매는 별개의 이야기입니다
일정 비율의 취소를 전제로 재고보다 많이 판매하는 정책이 있습니다. 이것은 사고가 아니라 사업 판단입니다.
시스템에서 지원하려면 다음이 필요합니다.
- 상품·기간별 초과 허용 비율 설정
- 초과분이 실제로 발생했을 때의 처리 절차
- 예상 취소율과 실제 취소율의 비교 데이터
이 기능을 넣을지는 반드시 사전에 합의해야 합니다. 운영 측에서 당연하게 생각하는 관행이 시스템에는 반영되어 있지 않은 경우가 자주 있습니다.
5. 취소와 변경도 동시성 문제입니다
예약을 만드는 것만큼 되돌리는 것도 조심해야 합니다.
취소가 중복 처리될 수 있습니다
사용자가 취소 버튼을 빠르게 두 번 누르거나, 외부 채널에서 취소 통보가 중복으로 들어오는 경우가 있습니다. 이때 재고가 두 번 복원되면 실제보다 많은 재고가 생깁니다.
- 예약 상태를 확인하고 이미 취소된 건은 다시 처리하지 않습니다
- 취소 요청에 고유 식별자를 두어 중복을 걸러냅니다
- 재고 복원은 상태 변경과 같은 트랜잭션 안에서 처리합니다
"상태를 조회해서 취소 전이면 진행"하는 방식은 예약 생성과 같은 틈을 가집니다. 상태 변경을 조건부 갱신으로 만들고, 갱신된 행이 있을 때만 재고를 복원합니다.
-- 가정: PostgreSQL. 2절·3절 예시의 테이블 기준, 식별자와 날짜는 예시 값
BEGIN;
UPDATE reservation
SET status = 'CANCELLED'
WHERE id = 1001
AND status IN ('HELD', 'CONFIRMED');
-- 갱신 행 수가 1일 때만 아래를 실행. 0이면 이미 처리된 요청이므로 그대로 성공 응답
UPDATE room_type_inventory
SET sold = sold - 1
WHERE room_type_id = 10
AND stay_date >= DATE '2026-03-10'
AND stay_date < DATE '2026-03-13';
COMMIT;
중복 취소 요청에는 오류가 아니라 첫 요청과 같은 성공 응답을 돌려주는 편이 낫습니다. 오류를 돌려주면 외부 채널이 같은 통보를 계속 재전송하는 경우가 있습니다.
변경은 취소와 생성의 조합입니다
날짜나 객실을 바꾸는 요청은 기존 예약을 풀고 새로 잡는 동작입니다. 이 사이에도 틈이 생깁니다.
- 새 재고를 먼저 확보한 뒤 기존 것을 해제합니다
- 새 재고 확보에 실패하면 기존 예약을 유지합니다
- 둘을 하나의 트랜잭션으로 묶습니다
순서를 반대로 하면, 기존 예약을 풀었는데 새 재고를 못 잡아서 아무것도 남지 않는 상황이 생길 수 있습니다.
운영자가 직접 손대는 경로도 동시성 대상입니다
관리자 화면에서 재고를 조정하거나 예약을 수동으로 입력하는 기능은 일반 예약과 같은 자원을 건드립니다. 그런데 이 경로만 검증이 느슨한 경우가 있습니다.
- 관리자가 재고 수량을 직접 수정하는 동안 예약이 들어오면
- 전화 예약을 입력하는 도중 같은 자원에 온라인 예약이 확정되면
- 여러 관리자가 동시에 같은 예약을 수정하면
관리자 기능에도 같은 제약과 잠금을 적용해야 합니다. "관리자니까 강제로 처리할 수 있어야 한다"는 요구가 들어오는 경우가 있는데, 이때도 강제 처리 여부를 명시적으로 선택하게 하고 그 사실을 기록에 남기는 편이 안전합니다.
여러 관리자가 같은 예약을 수정하는 경우에는 나중에 저장한 사람이 앞선 변경을 덮어쓰는 문제가 생깁니다. 수정 시작 시점의 버전을 확인해 그 사이에 변경되었으면 알려주는 방식으로 막을 수 있습니다.
환불 정책과 재고 복원을 구분합니다
취소 수수료가 발생하는 시점이라도 재고는 복원되어야 합니다. 두 가지는 별개의 규칙입니다. 이것을 하나로 묶어 처리하면 "수수료가 발생하는 취소는 재고가 안 돌아온다" 같은 이상한 동작이 나옵니다.
6. 자주 나오는 질문
테스트로 이 문제를 잡을 수 있습니까
일반적인 기능 테스트로는 재현되지 않습니다. 동시 요청을 의도적으로 만들어야 합니다.
- 같은 자원에 대해 여러 요청을 동시에 보내는 테스트를 작성합니다
- 마지막 1개 재고에 여러 요청이 몰리는 상황을 검증합니다
- 결과가 정확히 하나만 성공하는지 확인합니다
이 테스트는 운영 배포 전에 반드시 한 번은 수행하는 편이 좋습니다. 운영에서 처음 발견하면 이미 고객에게 영향이 간 뒤입니다.
데이터베이스 수준의 재현은 PostgreSQL에 포함된 pgbench 로 할 수 있습니다. 3절의 재고 테이블에 재고 5실짜리 행을 하나 넣고, 차감 문장을 50개 세션에서 동시에 실행합니다.
-- hold.sql
UPDATE room_type_inventory
SET sold = sold + 1
WHERE room_type_id = 10
AND stay_date = DATE '2026-03-10'
AND sold < total;
# 가정: 테스트 전용 데이터베이스 booking_test. 운영 데이터베이스에서 실행하지 않음
# -n: pgbench 기본 테이블이 없는 사용자 정의 시나리오에 필요, -c: 동시 세션 수, -t: 세션당 실행 횟수
pgbench -n -c 50 -j 4 -t 20 -f hold.sql booking_test
psql -d booking_test -c "SELECT total, sold FROM room_type_inventory WHERE room_type_id = 10 AND stay_date = DATE '2026-03-10';"
1,000번을 시도했어도 sold 가 5에서 멈추면 정상입니다. 스크립트에서 AND sold < total 조건을 빼고 다시 돌리면 CHECK 제약 위반으로 세션이 중단되는데, 이 제약마저 없는 테이블이라면 sold 가 total 을 넘어갑니다. 격리 수준을 올린 구성을 시험할 때는 --max-tries 로 직렬화 실패·교착의 재시도를 켜고 실패 건수를 확인합니다. 이 옵션의 기본값은 1(재시도 없음)입니다(PostgreSQL 문서: pgbench).
애플리케이션 수준에서는 예약 API를 실제로 동시에 호출하는 통합 테스트를 둡니다. 검증할 것은 응답 코드가 아니라 끝난 뒤의 데이터입니다.
- 재고 1개에 N건을 동시에 요청했을 때 성공 응답이 정확히 1건인가
- 나머지 요청이 500이 아니라 품절 응답을 받았는가
- 날짜별
sold값이 점유중·확정 예약 건수와 일치하는가 - 같은 객실에 기간이 겹치는 유효 배정이 없는가
마지막 항목은 아래 쿼리로 확인합니다. 운영 데이터의 정기 점검과, 제약을 걸기 전 기존 중복을 찾는 데에도 그대로 씁니다.
-- 가정: PostgreSQL. 결과가 0건이어야 정상
SELECT a.room_id, a.reservation_id, b.reservation_id AS conflicting_reservation_id
FROM room_assignment a
JOIN room_assignment b
ON a.room_id = b.room_id
AND a.reservation_id < b.reservation_id
AND a.stay && b.stay
WHERE a.status IN ('HELD', 'CONFIRMED')
AND b.status IN ('HELD', 'CONFIRMED');
동시성 테스트가 통과하는데 운영에서 중복이 나는 경우는 대개 테스트 환경이 실제와 달라서입니다.
- 테스트가 커넥션 하나를 공유하거나 테스트마다 하나의 트랜잭션으로 감싸 롤백하는 구조라면, 요청이 순서대로 실행되어 경합 자체가 생기지 않습니다
- 테스트용 데이터베이스가 운영과 다른 제품이거나 격리 수준 설정이 다르면 잠금 동작이 달라집니다. 운영과 같은 DBMS, 같은 주 버전으로 실행합니다
- 요청을 순차
await로 보내면 동시 요청이 아닙니다. 모두 시작시킨 뒤 한꺼번에 기다립니다
자주 나는 오류와 대응
동시성 제어를 넣으면 이전에는 없던 오류가 생깁니다. 대부분은 장애가 아니라 제어가 동작했다는 신호이므로, 오류 코드별로 처리 방침을 정해둡니다.
| 오류 | 의미 | 대응 |
|---|---|---|
PostgreSQL 23P01 exclusion_violation |
기간이 겹치는 배정이 이미 있음 | 품절로 안내. 만료 점유 정리 후 1회 재시도는 가능 |
PostgreSQL 23505 unique_violation |
같은 키의 행이 이미 있음 | 품절 또는 중복 요청으로 처리 |
PostgreSQL 23514 check_violation |
수량이 범위를 벗어남 | 조건 없이 수량을 바꾸는 경로가 있다는 뜻. 코드 수정 대상 |
PostgreSQL 40001 serialization_failure |
직렬화 실패 | 트랜잭션 전체 재시도 |
PostgreSQL 40P01 deadlock_detected |
교착으로 한쪽이 중단됨 | 재시도하되, 반복되면 잠금 순서를 점검 |
PostgreSQL 55P03 lock_not_available |
NOWAIT 또는 lock_timeout 으로 잠금 획득 실패 |
"처리 중" 안내 후 짧은 간격으로 재시도 |
MySQL 1213 (SQLSTATE 40001) |
교착. 트랜잭션 전체가 롤백됨 | 트랜잭션 전체 재시도 |
| MySQL 1205 | 잠금 대기 시간 초과 | 아래 설명 참고 |
| MySQL 3572 | NOWAIT 로 잠금 획득 실패 |
"처리 중" 안내 후 재시도 |
오류 코드는 PostgreSQL(PostgreSQL 문서: Error Codes)과 MySQL(MySQL 8.4 Error Reference) 공식 문서 기준입니다.
원인을 찾는 데 시간이 걸리는 경우를 따로 적습니다.
교착(40P01, 1213)이 반복될 때. 두 트랜잭션이 같은 행들을 서로 다른 순서로 잠그는 경로가 있습니다. 예약 변경(기존 날짜 해제 후 새 날짜 확보)과 신규 예약이 같은 날짜 행을 반대 순서로 건드리는 경우가 대표적입니다. 여러 행을 잠글 때는 날짜 오름차순처럼 한 가지 기준으로 정렬해 SELECT ... ORDER BY ... FOR UPDATE 로 먼저 잠근 뒤 갱신합니다. PostgreSQL 문서도 일관된 순서로 잠그는 것을 가장 좋은 방어로 들고, 미리 검증하기 어렵다면 교착으로 중단된 트랜잭션을 재시도하라고 안내합니다(PostgreSQL 문서: Explicit Locking). MySQL에서는 SHOW ENGINE INNODB STATUS 로 가장 최근 교착의 내용을 볼 수 있고, innodb_print_all_deadlocks 를 켜면 모든 교착이 오류 로그에 남습니다(MySQL 8.4 매뉴얼: How to Minimize and Handle Deadlocks).
MySQL 1205 이후 데이터가 절반만 반영될 때. InnoDB는 잠금 대기 시간이 초과되면 기본 설정에서 대기하던 문장만 롤백하고 트랜잭션은 유지합니다(MySQL 8.4 매뉴얼: InnoDB Error Handling). 오류를 잡은 뒤 그대로 커밋하면 3박 중 2박만 차감된 상태가 저장됩니다. 이 오류를 받으면 애플리케이션이 명시적으로 롤백해야 합니다.
FOR UPDATE 를 걸었는데 중복이 날 때. 잠금은 트랜잭션이 끝나면 풀립니다. 자동 커밋 상태에서 실행했거나, ORM이 조회와 갱신을 서로 다른 커넥션으로 보냈다면 잠금은 조회 직후 해제된 것입니다. 잠금 조회와 이후 문장이 같은 트랜잭션, 같은 커넥션에서 실행되는지 쿼리 로그로 확인합니다.
특정 객실만 예약이 계속 멈출 때. 잠금을 잡은 트랜잭션이 끝나지 않고 남아 있는 경우입니다. 트랜잭션을 연 채 외부 API 응답이나 사용자 입력을 기다리는 코드가 원인인 경우가 많습니다. PostgreSQL에서는 idle_in_transaction_session_timeout 으로 트랜잭션을 연 채 대기 중인 세션을 일정 시간 뒤 종료시킬 수 있습니다(PostgreSQL 문서: Client Connection Defaults). 이 설정은 안전장치이고, 근본 대책은 트랜잭션 안에서 외부 호출을 빼는 것입니다.
재고를 미리 계산해서 캐시에 두면 안 됩니까
조회 성능을 위해 캐시를 쓰는 것은 일반적입니다. 다만 차감 판단은 캐시로 하면 안 됩니다.
- 조회·목록 표시는 캐시된 값을 써도 무방합니다. 약간의 지연이 허용됩니다
- 실제 차감은 원본 데이터에서 조건부로 처리합니다
- 사용자에게는 "조회 시점 기준"임을 알리고, 확정 단계에서 재확인합니다
목록에 남은 수량이 표시되다가 선택하는 순간 품절로 바뀌는 것은 허용 가능한 동작입니다. 반대로 품절인데 예약이 되는 것은 허용되지 않습니다.
트래픽이 몰리는 오픈 시점은 어떻게 대비합니까
특정 시각에 예약이 열리는 경우 평소의 수십 배 요청이 들어옵니다.
- 대기열을 두어 순서대로 처리합니다
- 오픈 전에는 예약 요청 자체를 차단해 미리 몰리는 것을 막습니다
- 재고 확인 API와 예약 생성 API를 분리해 부하를 나눕니다
- 실패한 요청이 즉시 재시도하지 않도록 안내합니다
대기열을 도입하면 사용자에게 순번과 예상 시간을 보여주는 것이 중요합니다. 아무 안내 없이 기다리게 하면 새로고침을 반복하게 되고 부하가 더 커집니다.
이미 운영 중인 시스템에 적용할 수 있습니까
가능합니다. 순서는 다음과 같습니다.
- 데이터베이스 제약을 먼저 겁니다. 기존 데이터에 이미 중복이 있다면 제약 생성 자체가 실패하므로 정리가 선행되어야 합니다. 중복은 앞의 겹침 조회 쿼리로 찾고, 어느 쪽을 남길지는 운영 담당자와 건별로 정합니다
- 차감 로직을 조건부 갱신 방식으로 바꿉니다
- 임시 점유가 없다면 도입을 검토합니다
- 외부 채널 동기화 방식을 점검합니다
1번만 적용해도 최악의 상황은 막을 수 있습니다. 애플리케이션 로직이 뚫려도 데이터베이스에서 거부되므로, 중복 확정 예약이 저장되지는 않습니다.
7. 착수 전 점검 목록
차감 설계
- 재고를 차감하는 시점이 명확히 정해져 있는가
- 임시 점유를 사용하는가, 점유 시간은 얼마인가
- 만료된 점유를 해제하는 방식이 정의되어 있는가
- 예약 상태별로 재고 차지 여부가 정의되어 있는가
- 기간의 종료일을 포함하지 않는 규칙이 화면·API·데이터베이스에서 같은가
- 점유 만료 후 결제 승인이 도착했을 때의 결제 취소 경로가 있는가
동시성 제어
- 데이터베이스 수준의 고유 제약이 걸려 있는가
- 조회와 차감이 하나의 연산으로 처리되는가
- 잠금 범위가 필요한 행으로 한정되어 있는가
- 잠금 상태에서 외부 API를 호출하지 않는가
- 잠금 대기 시간에 상한이 있는가
- 갱신 행 수를 확인한 뒤 다음 단계로 넘어가는가
- 제약 위반·직렬화 실패·교착 오류별 처리 방침(품절 안내, 재시도)이 정해져 있는가
- 재시도되는 구간 안에 결제·알림 같은 외부 호출이 없는가
- Redis 등 외부 락을 쓴다면, 그 락이 뚫려도 데이터베이스에서 거부되는가
- 운영과 같은 DBMS·버전에서 동시 요청 테스트를 수행했는가
다중 채널
- 재고를 할당할 것인지 공유할 것인지 정했는가
- 채널별 동기화 방식과 지연 시간을 파악했는가
- 잔여 재고가 적을 때의 노출 정책이 있는가
- 초과 판매 발생 시 처리 절차가 정해져 있는가
- 의도적 초과 판매 정책이 있는지 확인했는가
취소·변경
- 중복 취소 요청을 걸러내는가
- 재고 복원이 상태 변경과 같은 트랜잭션에 있는가
- 변경 시 새 재고를 먼저 확보하는가
- 환불 정책과 재고 복원이 분리되어 있는가
마치며
중복 예약 문제의 본질은 판단의 근거가 된 값이 판단하는 사이에 바뀐다는 것입니다. 이 구조를 이해하면 해결 방향도 분명해집니다. 조회와 차감 사이의 틈을 없애거나, 그 틈 동안 다른 요청이 들어오지 못하게 하거나, 마지막에 데이터베이스가 거부하도록 하는 것입니다.
그리고 완전히 막을 수 없는 영역도 있습니다. 외부 채널과의 동기화 지연이 대표적입니다. 이 경우에는 발생했을 때의 처리 절차를 설계에 포함시키는 것이 현실적입니다.
저희는 숙박 예약·운영 시스템을 비롯해 제한된 자원을 여러 경로에서 판매하는 시스템을 구축해왔습니다. 예약 시스템 구축이나 기존 시스템의 동시성 문제를 검토하고 계시다면 편하게 문의해주세요.
함께 보면 좋은 글
- 외부 시스템 연동에서 장애가 생기는 자리 — 인터페이스 설계 원칙
- 레거시 데이터 이관 — 옮기기 전에 확인해야 하는 것들
- SI 프로젝트에서 범위가 불어나는 구조와 이를 막는 변경관리 설계
참고 자료
- PostgreSQL 문서: Range Types
- PostgreSQL 문서: CREATE TABLE
- PostgreSQL 문서: SELECT — The Locking Clause
- PostgreSQL 문서: Transaction Isolation
- PostgreSQL 문서: Explicit Locking
- PostgreSQL 문서: Serialization Failure Handling
- PostgreSQL 문서: Error Codes
- PostgreSQL 문서: Client Connection Defaults
- PostgreSQL 문서: System Administration Functions
- PostgreSQL 문서: pgbench
- PostgreSQL 9.2 릴리스 노트
- PostgreSQL 9.5 릴리스 노트
- PostgreSQL 18 릴리스 노트
- MySQL 8.4 매뉴얼: InnoDB Locking
- MySQL 8.4 매뉴얼: Locking Reads
- MySQL 8.4 매뉴얼: How to Minimize and Handle Deadlocks
- MySQL 8.4 매뉴얼: InnoDB Error Handling
- MySQL 8.4 Error Reference
- MySQL 8.0.1 릴리스 노트
- Redis 문서: Distributed Locks with Redis
- Martin Kleppmann, How to do distributed locking

