동시에 마지막 좌석을 누르면 누가 예약될까?
잠금이 없으면 좌석이 2번 팔립니다. 트랜잭션·비관적/낙관적 잠금이 어떻게 한 명만 성공시키는지 한 단계씩 살펴봅니다.

목차
남은 좌석이 하나인데 두 사람이 같은 순간에 예약을 누르면, 아무 장치가 없을 때는 둘 다 성공합니다. 좌석 하나가 두 명에게 팔리는 것이죠. 이걸 중복 예약, 흔히 오버부킹이라고 합니다.
왜 이런 일이 생기고, 어떻게 막을까요? 데모에서 한 단계씩 눌러 보면 눈에 확 들어옵니다.
문제: 둘 다 "자리 있네!"를 봅니다
예약은 보통 이런 순서로 진행됩니다.
1) 남은 좌석을 읽는다 → 1개
2) 자리가 있으면 예약한다 → 좌석을 0으로, 예약 기록 추가
혼자라면 문제가 없습니다. 하지만 두 사용자 A·B가 거의 동시에 1번을 실행하면, 둘 다 "좌석 1개"를 읽습니다. 아직 아무도 예약을 확정하기 전이니까요. 그다음 둘 다 "자리가 있네"라고 판단하고 2번을 실행해 예약을 기록합니다. 막는 장치가 없으면 두 예약이 모두 확정되고, 좌석은 두 번 팔립니다.
이렇게 실행 순서가 겹쳐 결과가 어긋나는 상황을 경쟁 상태(race condition) 라고 합니다.
트랜잭션만 시작한다고 자동으로 해결되지는 않습니다
먼저 흔한 오해를 짚고 갈게요. "트랜잭션으로 묶으면 되지 않나?"
트랜잭션은 원자성뿐 아니라 격리성도 다룹니다. 다만 BEGIN … COMMIT만 감싼
평범한 읽기→갱신 코드가 모든 격리 수준에서 자동으로 직렬화되는 것은 아닙니다.
PostgreSQL의 흔한 기본값인 Read Committed에서는 두 트랜잭션이 같은 이전 값을
읽을 수 있으므로, 조건부 갱신·명시적 잠금·더 강한 격리 수준·유니크 제약 중
문제에 맞는 장치가 필요합니다.
Serializable 격리 수준은 이런 충돌을 감지해 한 트랜잭션을 실패시킬 수 있지만, 애플리케이션이 그 실패를 재시도해야 합니다. 이 데모는 선택이 눈에 보이는 비관적 잠금과 버전 조건을 쓰는 낙관적 잠금 두 가지를 비교합니다.
방법은 크게 두 가지입니다.
방법 1 · 비관적 잠금 — 먼저 잠그고 한 명씩
"충돌이 날 것 같으니 아예 먼저 잠그자"는 접근입니다. 좌석을 읽을 때 SELECT ... FOR UPDATE로 그 행에 잠금을 겁니다.
-- A가 먼저 실행하면 이 행은 A가 커밋할 때까지 잠긴다
SELECT seats_left FROM show WHERE id = 1 FOR UPDATE;
- A가 행을 잠그고 좌석 1개를 확인합니다.
- B가 같은 행을 잠그려 하지만 A가 쥐고 있어 멈춰서 기다립니다.
- A가 좌석을 0으로 줄이고 커밋하면 잠금이 풀립니다.
- 그제야 B가 좌석을 읽는데, 이제 0개 — 자리가 없어 예약을 포기(롤백)합니다.
결과적으로 A만 성공하고 B는 깔끔하게 거절됩니다. 확실하지만, 잠금을 기다리는 동안 다른 요청이 대기하므로 경쟁이 심하면 느려질 수 있습니다.
방법 2 · 낙관적 잠금 — 일단 하고, 커밋 때 확인
"충돌은 드무니 일단 진행하고, 바꿀 때만 확인하자"는 접근입니다. 좌석 행에 버전 번호를 두고, "내가 읽은 버전일 때만 바꾸기"로 갱신합니다.
-- 내가 읽은 버전(v5)이 그대로일 때만 갱신되고, 버전이 하나 올라간다
UPDATE show SET seats_left = 0, version = version + 1
WHERE id = 1 AND version = 5;
- A·B 둘 다 버전
v5를 읽습니다. - A가 위 문장을 실행 — 조건이 맞아 1행이 바뀌고 버전은
v6이 됩니다. 커밋. - B가 같은 문장을 실행 — 하지만 버전은 이미
v6이라 조건이 안 맞아 0행이 바뀝니다. - "0행 갱신"은 그 사이 누가 먼저 바꿨다는 신호입니다. B는 충돌을 감지하고 예약을 거절하거나 다시 시도합니다.
잠금을 오래 붙들지 않아 대기가 적지만, 충돌이 잦으면 재시도가 늘어납니다.
언제 무엇을 쓸까
- 비관적 잠금: 충돌이 자주 나고, 한 번 실패가 비싼 경우(재고 차감, 좌석 예약처럼 정확성이 중요한 곳).
- 낙관적 잠금: 충돌이 드물고 읽기가 많은 경우. 대기 없이 빠르지만 충돌 시 재시도 처리가 필요합니다.
실무에서는 여기에 더해 "한 좌석에 예약은 하나"라는 유니크 제약을 함께 걸어, 논리에 구멍이 생겨도 DB가 최후의 방어선이 되게 하는 경우가 많습니다.
CREATE UNIQUE INDEX one_booking_per_seat
ON booking (show_id, seat_id);
잠금은 실행 순서를 조정하고 유니크 제약은 최종 결과의 중복을 거부합니다. 역할이 다르므로 둘을 함께 둘 수 있습니다. 사용자가 결제 화면을 새로고침해 같은 요청을 다시 보내는 문제까지 막으려면 예약 요청 ID에도 유니크 제약을 두고, 이미 처리된 요청에는 저장한 결과를 돌려주는 멱등성 규칙이 필요합니다.
데모에 관한 참고
데모는 실제 데이터베이스 실행이 아니라 트랜잭션·잠금 규칙을 따라 만든 교육용 시뮬레이션입니다. 좌석 수(1)와 버전 번호(v5)는 읽기 쉽게 고정한 예시값이고, 두 트랜잭션의 실행 순서도 이해를 돕기 위해 대표적인 한 가지로 고정했습니다. 실제로는 트랜잭션 격리 수준, 인덱스·유니크 제약, 데드락, 재시도 정책에 따라 세부 동작이 달라집니다.

