마지막 좌석을 동시에 누르면 둘 다 예약됩니다

잠금이 없으면 좌석이 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개를 보고 둘 다 예약합니다.

준비

‘예약 경쟁 시작’을 누르면 두 사용자가 동시에 마지막 좌석을 예약하는 과정을 한 단계씩 보여드려요.

대기 중
사용자 A대기

아직 시작 전

본 좌석
본 버전

공연 좌석 (DB 행)

빈 좌석
seats_left
1
version
v5
잠금
없음
예약 기록
없음
사용자 B대기

아직 시작 전

본 좌석
본 버전
아직 아무 일도 일어나지 않았어요.버튼을 누르면 두 트랜잭션이 주고받는 명령이 여기에 순서대로 쌓입니다.

한눈에 비교

세 방식은 마지막 좌석을 이렇게 처리합니다

잠금 없음

두 트랜잭션이 같은 자리 1개를 보고 둘 다 예약합니다.

좌석 하나가 두 명에게 팔렸습니다 — 중복 예약(오버부킹).

비관적 잠금

SELECT … FOR UPDATE로 먼저 잠그고 한 명씩 순서대로 처리합니다.

A만 성공, B는 잠금이 풀린 뒤 '자리 없음'을 보고 거절됩니다.

낙관적 잠금

일단 진행하고, 커밋할 때 버전 번호로 충돌을 감지합니다.

A만 성공, B는 버전이 바뀌어 0행 갱신 → 충돌로 거절됩니다.

교육용 시뮬레이션입니다.

이 페이지는 실제 데이터베이스 실행이 아니라, 트랜잭션·잠금 규칙을 따라 만든 교육용 시뮬레이션입니다. 좌석 수(1)와 버전 번호(v5)는 읽기 쉽게 고정한 예시값이며, 두 트랜잭션의 실행 순서도 이해를 돕기 위해 대표적인 한 가지로 고정했습니다. 실제로는 트랜잭션 격리 수준, 인덱스·유니크 제약, 데드락, 재시도 정책에 따라 세부 동작이 달라집니다.

참고 개념: 트랜잭션 원자성 · SELECT … FOR UPDATE(비관적) · 버전 컬럼(낙관적)

데모만 따로 보려면 전체 화면으로 열기.

격리 수준을 올리면 해결될까

"트랜잭션 격리 수준을 올리면 되는 거 아니야?"라는 질문이 여기서 자주 나옵니다. 반은 맞고 반은 틀립니다. 어느 수준이냐에 따라 결과가 완전히 다릅니다.

PostgreSQL을 기준으로 세 가지를 비교하면 이렇습니다.

격리 수준이 문제에서 벌어지는 일
READ COMMITTED (기본값)두 트랜잭션이 각각 "자리 있음"을 읽습니다. 막지 못합니다.
REPEATABLE READ나중 트랜잭션이 갱신 충돌을 만나 오류로 실패합니다.
SERIALIZABLE순서대로 실행한 것과 다른 결과가 되면 오류로 실패합니다.

여기서 중요한 지점이 있습니다. 위 두 수준은 막아 주지만 "성공"시켜 주지는 않습니다. 데이터가 깨지는 대신 트랜잭션 하나가 오류로 끝납니다.

ERROR: could not serialize access due to concurrent update

그래서 격리 수준을 올리는 선택은 "애플리케이션이 이 오류를 받아 재시도하도록 만들겠다"는 결정과 한 묶음입니다. 재시도 코드가 없으면 사용자에게는 그냥 알 수 없는 오류로 보입니다. 앞에서 본 낙관적 잠금과 사실상 같은 구조이고, 충돌 판정을 애플리케이션이 아니라 DB가 대신 해 주는 셈입니다.

정리하면 선택지는 셋입니다.

  • 비관적 잠금 — 먼저 잠그고 줄 세운다. 재시도 코드가 필요 없다. 대신 기다린다.
  • 낙관적 잠금 — 조건부 갱신으로 내가 판정한다. 실패를 직접 처리한다.
  • 높은 격리 수준 — DB가 판정한다. 실패 재시도는 여전히 내 몫이다.

어느 쪽을 골라도 공통점이 하나 있습니다. "확인하고 나서 쓴다"를 두 단계로 나눠 두면 그 사이에 남이 끼어듭니다. 확인과 쓰기를 하나의 원자적 동작으로 묶거나, 끼어들었다는 사실을 알아채고 되돌릴 수 있어야 합니다.

데모가 줄인 것

데모는 실제 데이터베이스 실행이 아니라 트랜잭션·잠금 규칙을 따라 만든 교육용 시뮬레이션입니다. 좌석 수(1)와 버전 번호(v5)는 읽기 쉽게 고정한 예시값이고, 두 트랜잭션의 실행 순서도 이해를 돕기 위해 대표적인 한 가지로 고정했습니다. 실제로는 트랜잭션 격리 수준, 인덱스·유니크 제약, 데드락, 재시도 정책에 따라 세부 동작이 달라집니다.

한 문장으로 줄이면 문제가 사라집니다

세 방식을 비교했지만, 이 예제에 한해서는 더 간단한 답이 있습니다. 읽고 계산해서 쓰는 대신 조건을 붙인 갱신 한 문장으로 처리하는 것입니다.

UPDATE seats SET left = left - 1
 WHERE id = 1 AND left > 0;

이 문장은 남은 좌석을 읽어 프로그램으로 가져오지 않습니다. 데이터베이스가 그 행을 갱신하는 동안 다른 요청은 그 행을 건드릴 수 없으므로, 읽기와 쓰기 사이의 틈 자체가 생기지 않습니다. 갱신된 행 수를 보고 성공 여부를 판단하면 됩니다.

그런데도 앞의 세 방식을 익혀 두어야 하는 이유가 있습니다. 실제 예약은 좌석 수만 줄이는 것으로 끝나지 않습니다. 예약 기록을 남기고, 결제를 걸고, 알림을 보내는 여러 단계가 같은 트랜잭션 안에 있습니다. 여러 행을 함께 다루는 순간 한 문장으로 줄이는 방법은 통하지 않고, 그때부터 잠금 방식을 골라야 합니다.

정리하면 순서는 이렇습니다. 한 문장으로 줄일 수 있으면 그렇게 하고, 그럴 수 없을 때 충돌 빈도를 보고 두 잠금 중 하나를 고릅니다.

잠금 방식의 원문 문서

확인한 자료

#데이터베이스#트랜잭션#동시성#잠금#학습도구

관련 글