격리 수준을 고르기 전에 반드시 지켜야 할 업무 규칙과 그 규칙이 걸린 데이터 범위를 적어야 한다. PostgreSQL의 Repeatable Read는 안정된 스냅샷을 제공하지만, 두 요청이 다른 행을 바꾸며 전체 규칙을 깨는 쓰기 편향은 허용할 수 있다. 한 행의 조건은 조건부 변경으로, 중복 금지는 제약으로, 여러 행의 판단은 공통 잠금이나 Serializable과 전체 재시도로 설계한다.
“둘 다 성공”이 장애가 되는 경우
서비스 운영팀에 오늘 대응 가능한 담당자가 A와 B 두 명 있다. 팀의 규칙은 대응 가능한 담당자를 최소 한 명 남긴다는 것이다. 각자 자리를 비우는 요청은 “현재 두 명 이상이면 내 상태를 비움으로 변경”하도록 구현했다. 단독으로 실행하면 문제가 없다.
그런데 A와 B가 거의 동시에 요청하면 어떻게 될까. 둘 다 인원을 2명으로 읽고, 서로 다른 자기 행을 변경할 수 있다. 각 요청의 로그에는 정상 커밋이 남지만 최종 인원은 0명이다. 실패한 SQL이 없어도 업무는 실패했다.
이것은 설명을 위해 만든 사례다. 이 글은 PostgreSQL 18 공식 문서를 기준으로 설계를 분석한다. 아래 SQL과 교차 일정은 미실행 재현 절차와 예상 동작이며, 실제 DB 실행 로그나 성능 측정값이 아니다. 다른 DB의 같은 이름을 가진 격리 수준에 그대로 적용하지 않는다.
트랜잭션의 원자성은 내 변경을 함께 확정하거나 취소하는 경계다. 동시 실행 중 무엇을 읽고 어떤 충돌을 허용할지는 격리의 문제다. 여기에 “최소 한 명”이라는 규칙을 코드나 제약으로 표현해야 한다. 변경을 한 트랜잭션으로 감쌌다는 사실만으로 세 가지가 모두 해결되지는 않는다.
같은 장면을 보고 다른 행을 바꾸는 쓰기 편향
다음처럼 단순화한 테이블을 생각하자. 초기 상태는 두 행 모두 on_call=true다.
CREATE TABLE duty (
name text PRIMARY KEY,
on_call boolean NOT NULL
);
INSERT INTO duty VALUES ('A', true), ('B', true);
두 연결에서 각각 BEGIN ISOLATION LEVEL REPEATABLE READ를 실행하고, 아래 번호대로 문장을 교차 실행한다고 가정한다. 1·2번의 조회 결과가 둘 다 2이므로 각 애플리케이션은 자신의 상태를 바꿔도 된다고 판단한다.
| 순서 | 연결 A | 연결 B |
|---|---|---|
| 1 | SELECT count(*) FROM duty WHERE on_call; → 예상 2 |
대기 |
| 2 | 대기 | 같은 조회 → 예상 2 |
| 3 | UPDATE duty SET on_call=false WHERE name='A'; |
대기 |
| 4 | 대기 | UPDATE duty SET on_call=false WHERE name='B'; |
| 5 | COMMIT; |
대기 |
| 6 | 대기 | COMMIT; |
PostgreSQL의 Repeatable Read에서 이 일정은 두 커밋을 허용할 수 있다. A와 B가 덮어쓰는 행이 달라 단순한 같은 행 갱신 충돌이 없기 때문이다. 서로의 판단 근거를 바꾸면서도 자기 판단에는 그 변경이 반영되지 않는 현상을 쓰기 편향(write skew)으로 이해할 수 있다. 이 예의 읽기·쓰기 관계는 공식 문서의 애플리케이션 일관성 설명과 연결된다.
한 요청씩 순서대로 실행해 보면 문제를 더 쉽게 판별할 수 있다. A가 먼저 끝났다면 B는 1명을 보고 비움 요청을 거절해야 한다. B가 먼저 끝나도 A가 거절해야 한다. 어느 순서로도 두 명 모두 빠지는 결과가 나오지 않는다. 따라서 위 결과는 이 업무 로직을 순차 실행한 결과로 설명할 수 없다.
그림의 두 갈래는 실제 쿼리 추적 화면이 아닌 설명용 구성도다. 각자 안정된 과거를 읽는 것과 함께 올바른 현재를 만드는 것은 다른 조건임을 보여 준다.
격리 수준에서 실제로 달라지는 것
PostgreSQL 18의 격리 수준 문서는 기본값인 Read Committed, Repeatable Read, Serializable의 동작을 구분한다. 여기서는 일반 조회와 업무 변경에 필요한 차이만 추린다.
| 수준 | 조회의 기준 | 위 사례에서 남는 책임 |
|---|---|---|
| Read Committed | 일반 SELECT마다 문장 시작 시점의 스냅샷 | 여러 문장 사이에 상태가 달라질 수 있으므로 검사와 변경의 연결을 설계 |
| Repeatable Read | 첫 비제어 문장 시점의 스냅샷을 유지하며 자기 변경은 보임 | 같은 행 갱신 충돌은 실패할 수 있지만 다른 행을 통한 쓰기 편향은 별도 대응 |
| Serializable | 커밋된 결과가 순차 실행과 맞도록 충돌 관계를 감시 | 일부 트랜잭션의 실패를 받아들이고 전체 재시도 |
PostgreSQL의 Repeatable Read는 팬텀 읽기도 막는다. 그러므로 “같은 조회에서 행이 더 보이지 않았으니 전체 규칙도 안전하다”는 결론은 성립하지 않는다. 반대로 Serializable이라고 모든 요청을 실제로 한 줄에 세워 처리하는 것은 아니다. 읽기·쓰기 의존 관계를 감시하고 문제가 될 수 있는 실행을 중단하는 방식이다.
앞의 두 연결을 모두 Serializable로 바꾸면, 두 요청이 함께 빠지는 커밋 결과는 허용되지 않아야 한다. 실패는 문장 실행 또는 커밋에서 나타날 수 있으므로 특정 연결의 마지막 COMMIT에서만 검사하도록 코드를 짜면 안 된다. 이때 오류가 발생했다는 사실은 보호가 작동한 결과일 수 있다.
한 행의 재고는 조건과 변경을 함께 묶는다
업무 규칙이 단일 상품 행의 available >= 0이라면, 여러 행의 담당자 수 문제와 다르게 접근할 수 있다. 애플리케이션에서 잔량을 읽어 계산한 뒤 그 숫자를 다시 덮어쓰는 대신, 조건을 변경 문장에 포함한다.
-- 설계 예: inventory에는 id PK와 아래 잔량 열이 있다고 가정한다.
-- available integer NOT NULL CHECK (available >= 0)
UPDATE inventory
SET available = available - 1
WHERE id = 7 AND available >= 1
RETURNING available;
초기 잔량이 1이고 두 Read Committed 트랜잭션이 이 문장을 실행한다고 가정하자. 먼저 변경한 트랜잭션이 커밋하면, 기다리던 변경은 갱신된 행에서 WHERE 조건을 다시 평가한다. 두 번째 요청은 잔량 0인 행을 차감하지 않고 반환 행이 없게 된다. 이것은 공식 Read Committed의 UPDATE 동작을 이 한 행 사례에 적용한 예상이다.
애플리케이션은 반환된 행의 유무를 반드시 처리해야 한다. 0행이면 해당 문장으로 차감하지 못한 것이며, 상품이 없거나 잔량이 부족한 경우를 업무 응답으로 구분할 수 있다. 행을 반환했어도 명시적 트랜잭션 안이라면 최종 커밋 전에는 주문 확정을 알리지 않는다.
CHECK는 음수 잔량을 막는 방어선이고, 조건부 변경은 재고 부족을 정상적인 분기로 다루게 한다. 다만 두 창고의 재고 합계처럼 규칙이 여러 행에 걸치거나 다른 코드가 잔량을 무조건 덮어쓰면 이 문장 하나로 해결되지 않는다. 구매 요청 자체의 중복 처리도 별도의 업무 식별자로 관리해야 한다.
예약 중복과 여러 행의 규칙에는 다른 장치가 필요하다
고정된 시간 슬롯에 예약 한 건만 허용한다면 (room_id, slot_id)를 NOT NULL 열로 두고 복합 UNIQUE 제약으로 표현할 수 있다. 동일한 키를 동시에 삽입하려는 경우를 DB가 직접 검사하게 하는 설계다. 임의의 시작·종료 시간이 겹치는 예약은 같은 키 중복과 다르므로, 범위와 겹침을 표현하는 exclusion constraint 같은 방법을 별도로 검토해야 한다. 제약 문서는 UNIQUE, CHECK, 배제 제약의 차이를 설명한다.
“아직 예약이 없으면 먼저 SELECT ... FOR UPDATE를 하면 되지 않을까?”라는 대안에는 빈틈이 있다. 이 행 잠금은 조회로 얻은 행에 적용된다. 결과가 0행이면 앞으로 그 키로 생길 행을 예약해 놓은 것이 아니다. SELECT의 잠금 절에서 보장하는 범위를 벗어난 해석이다.
앞의 담당자처럼 여러 행을 함께 판단할 때는, 모든 변경 요청이 반드시 잠그는 공통 상위 행을 두는 방법도 있다. 예를 들어 팀 행 하나를 공통 경계로 삼는다.
-- 설계 예: team(id PK)에 id=3이 이미 있고,
-- duty에는 team_id 열이 추가되어 있다고 가정한다.
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT id FROM team WHERE id = 3 FOR UPDATE;
-- 위 잠금 획득이 끝난 뒤 별도 문장으로 현재 인원을 조회한다.
SELECT count(*) FROM duty WHERE team_id = 3 AND on_call;
-- 애플리케이션: 2명 이상일 때만 해당 팀의 자기 행을 변경한다.
-- 아니면 변경 없이 비움 요청을 거절한다.
COMMIT;
잠금 때문에 기다린 요청은 앞선 요청이 끝난 뒤 다음 조회를 수행한다. 여기서 Read Committed와 별도 조회 문장을 명시한 이유가 있다. Repeatable Read에서 이미 고정된 스냅샷을 나중에 행 잠금 하나로 새롭게 만들 수는 없다. 이 주의점은 명시적 잠금으로 일관성을 지키는 조건에 근거한다.
이것은 완성된 배포용 SQL이 아니라 잠금 규약의 설계 예다. 공통 행이 반드시 존재해야 하고, 담당자 변경·삭제 등 같은 규칙에 영향을 주는 모든 경로가 같은 잠금을 먼저 얻어야 한다. 빠진 배치 작업 하나가 있으면 보호가 깨질 수 있다. 여러 팀을 동시에 처리한다면 잠금 순서도 통일해야 한다. 명시적 잠금 문서는 교착 상태와 일관된 잠금 순서를 다룬다.
업무 규칙의 범위로 고르는 판단표
아래는 공식 보장을 실제 설계 질문으로 옮긴 편집 판단표다. 위쪽 방법이 항상 더 빠르다는 순위표가 아니며, 실제 경합과 재시도 비용은 별도 측정해야 한다.
| 지킬 규칙 | 먼저 검토할 수단 | 설계를 다시 봐야 하는 조건 |
|---|---|---|
| 한 상품의 잔량이 음수가 되지 않음 | 조건부 UPDATE + NOT NULL·CHECK | 여러 행의 합계가 조건이거나 다른 쓰기 경로가 존재 |
| 같은 방·고정 슬롯에 예약 한 건 | NOT NULL + 복합 UNIQUE | 시간 구간 겹침처럼 단순 키 중복으로 표현되지 않음 |
| 같은 팀에 담당자를 최소 한 명 유지 | 공통 팀 행 잠금 후 새 조회 | 모든 변경 경로를 같은 규약으로 묶기 어렵거나 한 팀에 대기 집중 |
| 여러 조회·행의 관계를 함께 판단 | 관련 트랜잭션의 Serializable + 전체 재시도 | 낮은 격리 수준의 다른 쓰기, 잘못된 업무 조건, 과도한 재시도 |
한 행의 CHECK에 다른 행의 총인원 조회를 숨겨 넣는 식으로 마지막 두 문제를 해결하려 해서는 안 된다. PostgreSQL은 다른 행의 데이터에 의존하는 CHECK로 지속적인 일관성을 보장하지 않는다. 이 한계 역시 제약 문서의 CHECK 절에 명시되어 있다.
Serializable도 업무 정책을 발명하지 않는다. 코드가 인원을 확인하지 않고 자기 행을 무조건 비움으로 바꾼다면, 순차 실행해도 모두 빠진다. 따라서 격리 수준을 올리기 전에 요청을 하나씩 실행했을 때 규칙이 지켜지는지부터 확인해야 한다. 관련 변경 경로가 다른 격리 수준으로 동작한다면 보호 범위도 다시 점검한다.
재시도는 같은 UPDATE를 다시 보내는 일이 아니다
직렬화 실패 처리 문서는 40001을 받았을 때 SQL을 결정하는 로직까지 포함해 전체 트랜잭션을 재시도하도록 설명한다. 담당자 사례에서는 다음과 같다.
- 첫 시도에서 “2명이니 빠져도 됨”이라고 판단했지만 직렬화 실패를 받는다.
- 실패한 시도를 종료하고 새 트랜잭션에서 인원을 다시 읽는다.
- 이제 1명이라면 상태를 바꾸지 않고 비움 요청을 거절한다.
- 사용자에게 “다른 담당자가 먼저 자리를 비워 현재는 변경할 수 없음”을 알린다.
즉 재시도의 정상적인 결과가 업무 거절일 수 있다. 처음 계산한 on_call=false를 새 트랜잭션에서 무조건 다시 쓰면 재판단을 생략한 셈이다. 커넥션 풀·ORM의 자동 재시도도 이 범위를 실제로 포함하는지 확인해야 한다.
운영에서는 재시도 횟수·전체 요청 기한을 제한하고 경합이 계속되면 사용자에게 다시 시도할 수 있는 상태를 알려 주는 정책이 필요하다. 이는 이 글의 운영 설계 제안이며 특정 횟수나 지연 시간을 공식 권장값으로 제시하는 것은 아니다. 23505 같은 제약 오류는 요청 자체가 계속 충돌하는 경우도 있어 모든 오류를 같은 루프로 재시도해서는 안 된다.
DB 트랜잭션 안에서 이메일이나 외부 결제를 실행했다면 롤백이 그 효과를 되돌려 주지 않는다. 같은 작업을 재시도하면 외부 효과가 반복될 수 있다. 업무 변경과 전달 의도를 함께 남기는 경계는 트랜잭셔널 아웃박스 글에서 이어서 볼 수 있다. 서버가 커밋한 뒤 응답을 잃은 경우도 명확한 40001과 다르므로, 업무 요청 ID로 처리 상태를 확인하는 경로가 필요하다.
적용 전에 두 연결로 확인할 것
앞의 duty 스키마와 교차 일정은 일회용 테스트 DB에서 검증할 수 있게 구성했다. 이 글에서는 DB 동시 실행을 완료하지 않았으므로 아래 항목은 예상 결과를 대조하는 검증 계획이다. 운영 테이블에서 실험하지 말고, 각 시도 전에 테스트용 상태를 A·B 모두 대응 가능으로 되돌린다.
- Repeatable Read 두 연결에서 1~6번 순서를 지킨 뒤, 새 조회로 최종 인원을 확인한다. 예상은 두 커밋 후 0명이다.
- 같은 초기 상태에서 두 연결을 모두 Serializable로 바꾼다. 직렬화 실패와 최종 인원 1명을 확인하되, 어느 문장에서 실패하는지까지 기록한다.
- 실패한 요청을 처음부터 다시 실행한다. 인원 1명을 읽고 UPDATE 없이 거절하는지 확인한다. 오류가 사라졌다는 사실만으로 성공 처리하지 않는다.
- 한 행 재고 사례에서는 첫 변경을 커밋하기 전 두 번째 변경을 보내 대기를 만든다. 첫 커밋 뒤 두 번째 요청의 반환 행이 없는지, 애플리케이션이 이를 품절로 처리하는지 확인한다.
팀의 실제 쓰기 경로에도 같은 점검을 적용하려면 아래 양식을 저장해 둘 수 있다. 비밀 값이나 고객 원문 대신 규칙과 관측 상태를 기록한다.
규칙: 예) 한 팀에 대응 가능한 담당자 >= 1
데이터 범위: 팀 행 / 담당자 여러 행
관련 쓰기 경로: 화면 변경, 관리자 도구, 배치, 삭제 작업
선택한 제어: 제약 / 조건부 변경 / 공통 행 잠금 / Serializable
교차 실행 순서: 어느 조회 뒤에 다른 연결이 무엇을 바꾸는가
예상: 최종 상태, 허용할 오류, 거절할 업무 요청
관측: DB 버전, 격리 수준, 실제 최종 상태, SQLSTATE
재시도: 다시 읽는 범위, 횟수·기한, 외부 효과 처리 경계
미검증: 다른 쓰기 경로, 실제 경합량, 응답 유실 후 재요청
설계 검토에서는 이 양식의 첫 두 줄을 먼저 합의하는 것이 유용하다. “격리 수준을 높인다”는 변경안이 어떤 규칙을 지키기 위한 것인지 보이고, 검증할 교차 실행도 구체적으로 정할 수 있기 때문이다. 실제 처리량이나 지연을 개선했다는 결론은 그 뒤의 관측이 있어야 내릴 수 있다.
- 안정된 스냅샷을 읽었다는 사실만으로 여러 행에 걸친 업무 규칙이 보존되지는 않는다.
- 한 행의 잔량, 키의 중복, 여러 행의 합계처럼 규칙의 범위를 먼저 나누고 제어 수단을 고른다.
- 40001이면 읽기와 업무 판단까지 포함한 트랜잭션 전체를 다시 수행한다. 다시 판단한 결과는 거절일 수 있다.
- DB 커밋, 외부 알림, 응답을 잃은 클라이언트의 재요청은 각각 다른 실패 경계다.
근거와 원자료
자료의 발행 시점과 이번 글에서 참고한 범위를 함께 기록합니다.
- Transaction Isolation ↗PostgreSQL Global Development Group · PostgreSQL 18 · 상시 갱신 문서
13.2. PostgreSQL 18의 Read Committed·Repeatable Read·Serializable 보장과 UPDATE 재평가. 제품 간 일반화나 실제 실행 결과가 아님.
- Data Consistency Checks at the Application Level ↗PostgreSQL Global Development Group · PostgreSQL 18 · 상시 갱신 문서
13.4. 읽기·쓰기 의존 관계, 관련 트랜잭션의 일관성, 명시적 잠금과 스냅샷 시점의 주의점. 담당자 사례는 자체 설계 분석.
- Serialization Failure Handling ↗PostgreSQL Global Development Group · PostgreSQL 18 · 상시 갱신 문서
13.5. SQLSTATE 40001과 읽기·SQL 결정 로직을 포함한 전체 재시도. 다른 오류의 무조건 재시도를 보장하지 않음.
- Explicit Locking ↗PostgreSQL Global Development Group · PostgreSQL 18 · 상시 갱신 문서
13.3. 행 잠금, 교착 상태, 잠금 순서. 공통 팀 행 잠금은 이 원리를 적용한 미실행 설계 예.
- Constraints ↗PostgreSQL Global Development Group · PostgreSQL 18 · 상시 갱신 문서
5.5. CHECK의 다른 행 참조 한계, NOT NULL·UNIQUE·배제 제약의 역할. 임의 시간 구간 예약의 완성 구현은 제공하지 않음.
- SELECT — Locking Clause ↗PostgreSQL Global Development Group · PostgreSQL 18 · 상시 갱신 문서
PostgreSQL 18 SELECT의 반환된 행 잠금 범위. 결과 0행인 조회를 미래 삽입에 대한 키 예약으로 해석하지 않음.
갱신 기록
첫 발행. PostgreSQL 18 공식 문서 기반 설명과 미실행 교차 일정·SQL 설계 예를 구분한다.
오류를 발견했다면 →