워크숍의 마지막 자리 보호하기

PostgreSQLBeginner
지금 연습하기

소개

두 접수 창구가 공예 워크숍의 자리를 판매합니다. 손님은 단순히 음수가 아닌 것처럼 보이는 잔여 수치가 아니라 확정된 자리를 기대합니다. 먼저 정상적인 그룹 예약을 만든 다음, 사용 후 정리할 연습용 워크숍에서 한 자리에 두 예약이 확정되는 상황을 재현합니다. 마지막으로 그 자리를 보호하면서 다른 워크숍은 손님을 계속 받을 수 있게 합니다.

기본적인 터미널 탐색과 PostgreSQL SELECT, WHERE, INSERT, UPDATE 지식이 필요합니다. 트랜잭션, 연결 식별 정보, 행 잠금, RETURNING, 복구는 여기서 설명합니다. PostgreSQL과 합성 데이터는 준비되어 있습니다. Workshop Desk는 셸이며 Desk A와 Desk B는 한 머신의 같은 데이터베이스에 대한 별개의 PostgreSQL 기본 psql 연결입니다. 세 탭을 모두 열어 두세요. 실행 환경에 표시되는 텍스트와 인터페이스 이름은 영어입니다. 저자의 예상 소요 시간에는 읽기와 일반적인 실수가 포함되며, 초보자 시간 측정 연구는 아닙니다.

명시된 시험 예약만 추가하고 해당 워크숍의 places_left를 갱신하세요. 경합 연습에서는 본인의 시험 예약 삭제를 명시적으로 허용합니다. 원래 예약, 워크숍 ID, 제목, 정원, 메모, 테이블 정의, 준비된 도구를 보존하세요. 서버 설정을 변경하거나 테이블을 만들지 마세요. 이 실습은 데이터베이스 하나에서 PostgreSQL의 READ COMMITTED 격리를 사용합니다. 결제, 재시도, 여러 워크숍의 교착 상태는 범위 밖입니다.

정상적인 그룹 예약 만들기

이 단계에서는 실제 그룹 확정과 그 예약이 사용하는 자리 수를 연결합니다.

셸인 Workshop Desk에서 시작하세요. ./는 현재 폴더의 프로그램을 실행합니다. desk status는 실제 테이블을 읽으며 고치지 않습니다. 첫 결과는 workshop_id|title|capacity|places_left|booked_seats를, 다음 결과는 request_code|workshop_id|seats|desk를 나열합니다. 세로 막대가 필드를 구분합니다.

./desk status

워크숍 101은 정원 5명이며 원래 예약된 한 자리와 잔여 네 자리가 있습니다. 워크숍 102는 한 자리가 있고 예약은 없습니다. 워크숍 101의 original-family를 그대로 유지하세요.

Desk A로 전환하세요. Desk A workshops=> 프롬프트는 PostgreSQL의 psql 클라이언트 안에서 데이터베이스 workshops에 연결되어 있다는 뜻입니다. 여기에는 셸 명령이 아니라 SQL을 입력하세요. 세미콜론으로 SQL 문을 끝냅니다. 연결은 데이터베이스 서버와의 대화이며 pg_backend_pid()는 해당 서버 프로세스를 식별합니다. Desk B에서 다른 값이 나오면 독립된 연결임을 알 수 있습니다.

SELECT pg_backend_pid(), current_user;

사용자는 workshop_write이며 프로세스 ID는 달라집니다. BEGIN은 COMMIT에서 함께 보이게 되는 변경 묶음인 트랜잭션을 시작합니다. 그전에는 다른 연결이 새 예약을 볼 수 없습니다. ROLLBACK은 커밋되지 않은 변경을 버립니다. 첫 예제에서는 다른 창구가 워크숍 101을 판매하지 않습니다.

RETURNING은 UPDATE가 실제로 변경한 행을 표시합니다. 두 자리를 빼고 같은 트랜잭션에 그에 맞는 두 자리 예약을 삽입하세요. 텍스트에는 작은따옴표를 사용하며 요청 코드가 예약을 고유하게 식별합니다.

BEGIN;
UPDATE workshops
SET places_left = places_left - 2
WHERE workshop_id = 101
RETURNING workshop_id, places_left;
INSERT INTO bookings (request_code, workshop_id, seats, desk)
VALUES ('normal-group', 101, 2, 'A');

갱신은 101 | 2를 반환합니다. 프롬프트의 *는 열린 트랜잭션을 뜻합니다. Workshop Desk로 전환해 커밋된 원장을 확인하세요:

./desk status

워크숍 101은 여전히 잔여 네 자리와 원래 예약만 보여 줍니다. Desk A로 돌아가 마무리하세요:

COMMIT;

Workshop Desk로 전환하세요:

./desk status

이제 101에는 잔여 두 자리와 예약된 세 자리가 있습니다. 원래 한 자리와 두 명 그룹입니다. 실제 예약은 normal-group|101|2|A입니다. 성공적인 트랜잭션은 capacity = places_left + booked_seats를 유지합니다. 첫 검사는 이 업무 사실을 읽으며 어떻게 입력했는지 추론하지 않습니다.

트랜잭션 안에서 SQL을 잘못 입력하면 프롬프트가 !로 바뀔 수 있습니다. 트랜잭션이 중단된 상태이므로 ROLLBACK; 전까지 추가 SQL은 실패합니다. 같은 창구에서 롤백하고 Workshop Desk의 ./desk status를 확인한 뒤 커밋되지 않은 작업만 재시도하세요. 이미 커밋된 요청을 무작정 반복하지 마세요. 요청 코드 충돌은 두 번째 확정이 아니라 오류입니다.

오래된 가용성 판단 재현하기

이 단계에서는 의도적으로 잘못된 연습 원장을 만들고, 잔여 수치가 음수가 아닌 것만으로는 충분하지 않은 이유를 관찰하며, 영향을 워크숍 102에 한정합니다.

Desk A로 전환하세요. 이 실험은 조건부 자리 확보를 계산하는 대신 오래된 숫자를 의도적으로 씁니다. 최종 예약 방법으로 사용하지 마세요. READ COMMITTED 격리 수준에서는 각 문장이 시작될 때 커밋된 데이터의 모습을 봅니다. 앞선 SELECT가 그 결과를 예약해 두지는 않습니다.

BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT workshop_id, places_left FROM workshops WHERE workshop_id = 102;

Desk B로 전환하세요. 프로세스 ID가 Desk A와 다른지 확인한 뒤 자체 트랜잭션을 시작하고 같은 자리를 읽으세요:

SELECT pg_backend_pid(), current_user;
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT workshop_id, places_left FROM workshops WHERE workshop_id = 102;

두 SELECT 모두 한 자리를 반환합니다. 어느 SELECT도 그 자리를 확보하지 않았습니다. Desk A로 전환해 앞서 읽은 숫자에 따른 결정을 저장하세요:

UPDATE workshops SET places_left = 0 WHERE workshop_id = 102;
INSERT INTO bookings (request_code, workshop_id, seats, desk)
VALUES ('unsafe-A', 102, 1, 'A');

A의 트랜잭션을 열린 채로 두세요. Desk B로 전환하고 이 UPDATE만 입력하세요:

UPDATE workshops SET places_left = 0 WHERE workshop_id = 102;

아직 프롬프트가 돌아오지 않습니다. 예상된 잠금 대기이며 연결 고장이 아닙니다. A가 이 행을 변경했고 트랜잭션이 끝날 때까지 행 잠금을 보유합니다. Workshop Desk로 전환하세요:

./desk waits

실제 Desk B 백엔드의 wait_event_type은 Lock이며 blocked_by에는 A의 프로세스 ID가 들어 있습니다. ID는 달라집니다. 관찰 도구는 PostgreSQL 활동만 읽으며 애니메이션이나 타이머로 이 상태를 만들지 않습니다.

Desk A로 전환하세요:

COMMIT;

Desk B로 전환하세요. 이제 UPDATE가 끝납니다. WHERE 조건은 워크숍만 지정하므로 이미 0인 잔여 값을 다시 0으로 설정할 수 있습니다. 확정을 추가하고 커밋하세요:

INSERT INTO bookings (request_code, workshop_id, seats, desk)
VALUES ('unsafe-B', 102, 1, 'B');
COMMIT;

Workshop Desk로 전환하세요:

./desk status

이제 워크숍 102는 정원 1명, 잔여 0자리, 예약된 2자리입니다. unsafe-A와 unsafe-B가 모두 있습니다. 잔여 값은 데이터베이스의 음수 금지 검사를 만족했지만 한 손님에게는 자리가 없습니다. 대기만으로 오래된 업무 판단이 고쳐지지는 않았습니다. 다음 단계에서는 명시적으로 폐기 가능한 이 시험 예약만 제거하고 판단 방식을 바꿉니다. 단계 검사는 이 연습 상태나 올바르게 복구된 최종 상태를 허용하며 터미널 이력을 조사 과정의 증거로 사용하지 않습니다.

확정 전에 자리 확보하기

이 단계에서는 확정을 쓰기 전에 실제 조건부 자리 확보를 요구하고 취소된 대기 요청을 복구합니다.

Desk A로 전환하세요. DELETE는 WHERE 조건이 선택한 행만 제거합니다. IN은 폐기 가능한 요청 코드 두 개를 지정합니다. 원래 예약이나 정상 예약은 삭제하지 마세요. 같은 트랜잭션에서 연습용 워크숍 102만 복원하세요:

BEGIN;
DELETE FROM bookings WHERE request_code IN ('unsafe-A', 'unsafe-B');
UPDATE workshops SET places_left = 1 WHERE workshop_id = 102;
COMMIT;

먼저 별도 워크숍 104에서 잠금 대기 취소를 연습하세요. A는 ROLLBACK으로 변경을 해제하므로 이 시험은 예약을 만들지 않습니다:

BEGIN;
UPDATE workshops SET places_left = 0 WHERE workshop_id = 104;

Desk B로 전환하세요:

BEGIN;
UPDATE workshops SET places_left = places_left - 1
WHERE workshop_id = 104 AND places_left >= 1
RETURNING workshop_id;

B가 기다리는 동안 Desk B에서 Ctrl+C를 누르세요. PostgreSQL은 SQLSTATE 57014와 함께 canceling statement due to user request를 보고합니다. SQLSTATE는 PostgreSQL의 다섯 문자 오류 범주입니다. 취소된 문장은 이 명시적 트랜잭션을 중단 상태로 남깁니다. 일반적인 빈 결과와의 차이를 확인하세요:

SELECT 1;

이는 예상된 오류입니다. current transaction is aborted, SQLSTATE 25P02입니다. Desk B에서 복구하세요:

ROLLBACK;

Desk A로 전환해 연습 변경을 해제하세요:

ROLLBACK;

이제 워크숍 102의 실제 마지막 자리를 보호합니다. WHERE 조건은 UPDATE 자체의 일부로 충분한 자리를 검사합니다. 경쟁 갱신을 기다린 후 READ COMMITTED는 갱신된 행에 대해 이 조건을 다시 검사합니다. RETURNING은 이 트랜잭션이 실제로 자리를 확보했는지 알려 줍니다.

Desk A에서 실행하세요:

BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE workshops SET places_left = places_left - 1
WHERE workshop_id = 102 AND places_left >= 1
RETURNING workshop_id, places_left;

A는 102 | 0을 받으며 자리를 확보했습니다. 실제 자리 확보를 나타내는 행이 반환된 경우에만 그에 맞는 확정을 만드세요:

INSERT INTO bookings (request_code, workshop_id, seats, desk)
VALUES ('safe-last-A', 102, 1, 'A');

커밋하지 않은 채 두세요. Desk B로 전환하세요:

BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE workshops SET places_left = places_left - 1
WHERE workshop_id = 102 AND places_left >= 1
RETURNING workshop_id, places_left;

B가 기다립니다. Workshop Desk로 전환해 실제 대기를 읽으세요:

./desk waits

행 잠금이 무관한 워크숍까지 막아서는 안 됩니다. 계속 Workshop Desk에서 짧은 세 번째 연결을 여세요. ./db --desk A는 같은 준비된 데이터베이스 로그인을 사용합니다. 이 임시 셸 호출은 이미 열린 Desk A 탭과 독립적입니다. 따옴표로 지정한 here-document는 닫는 SQL까지 모든 줄을 이 클라이언트로 보낸 뒤 연결을 닫습니다. 닫는 표시는 해당 줄에 단독으로 두세요. 예약에 저장되는 C는 이 세 번째 요청을 나타냅니다.

./db --desk A <<'SQL'
BEGIN;
UPDATE workshops SET places_left = places_left - 1
WHERE workshop_id = 103 AND places_left >= 1
RETURNING workshop_id, places_left;
INSERT INTO bookings (request_code, workshop_id, seats, desk)
VALUES ('other-workshop', 103, 1, 'C');
COMMIT;
SQL

워크숍 102가 여전히 잠겨 있는 동안 103 | 2를 반환하고 커밋합니다. Desk A로 전환하세요:

COMMIT;

Desk B로 전환하세요. 이제 UPDATE는 0행과 UPDATE 0을 반환합니다. 새 잔여 값 0은 places_left >= 1을 만족하지 않습니다. 이는 자리 확보가 없는 성공한 SQL 문이며 SQL 오류가 아닙니다. 확정을 삽입하지 마세요. B의 트랜잭션을 끝내세요:

ROLLBACK;

Workshop Desk로 전환하세요:

./desk status

워크숍 102에는 예약 safe-last-A|102|1|A 하나와 잔여 0자리가 있습니다. 워크숍 103에는 other-workshop|103|1|C와 잔여 두 자리가 있습니다. 워크숍 104에는 여전히 원래 한 자리가 있고 예약은 없습니다. 101은 정상 그룹과 원래 가족 예약을 유지합니다. 이제 모든 워크숍이 잔여·예약 균형식을 만족합니다.

일반적인 새 시험 확정 실수는 먼저 해당 창구의 열린 트랜잭션을 ROLLBACK하세요. 이 연습 환경에서는 본인의 잘못된 코드만 제거하고 한 트랜잭션에서 그 워크숍의 잔여 값만 다시 계산할 수 있습니다. 예를 들어 워크숍 103에 typo-trial을 실수로 커밋했다면 Desk A에서 다음을 실행하고 상태를 확인한 뒤 원래 의도한 요청을 재시도하세요. 하위 쿼리는 실제 예약 좌석을 합산하며 COALESCE(..., 0)은 예약이 없으면 0을 사용합니다. 이는 명시적인 연습 복구이며 예약 알고리즘의 일부가 아닙니다:

BEGIN;
DELETE FROM bookings WHERE request_code = 'typo-trial' AND workshop_id = 103;
UPDATE workshops
SET places_left = capacity -
  (SELECT COALESCE(SUM(seats), 0) FROM bookings WHERE workshop_id = 103)
WHERE workshop_id = 103;
COMMIT;

해당 실수를 만들지 않았다면 이 예제는 아무것도 바꾸지 않습니다. 원래 예약이나 적법하게 요청된 예약을 삭제하는 데 절대 사용하지 마세요. 다음 실습에서는 자리 확보와 확정을 재사용 가능한 문장 하나로 연결하므로 호출자가 빈 확보 결과 뒤에 실수로 삽입할 수 없습니다.

요약

실제 정상 예약을 만들고, 오래된 읽기에 따른 초과 예약을 재현한 뒤, 같은 트랜잭션의 조건부 UPDATE와 확정으로 마지막 자리를 보호했습니다. PostgreSQL 자체 잠금 관찰에서 경쟁 창구가 대기하는 동안 다른 워크숍은 계속 진행됨을 확인했습니다. 대기 중인 문장도 취소하고 ROLLBACK으로 중단된 트랜잭션을 복구했습니다. 최종 원장은 원래 예약을 보존하며 확정된 좌석 수와 남은 자리 수가 정원에 맞게 균형을 이룹니다.