PostgreSQL MVCC와 쓰기 왜곡: 스냅샷 격리에서 업무 규칙이 깨지는 이유
1장. 둘 다 다른 행을 수정했는데 왜 업무 규칙이 깨졌을까#
병원에 당직 의사가 두 명 있습니다.
| 의사ID | 이름 | 당직 |
|---|---|---|
| 1 | 가람 | true |
| 2 | 나래 | true |
업무 규칙은 간단합니다.
최소 한 명은 반드시 당직으로 남아 있어야 한다.
가람과 나래가 거의 동시에 자신의 당직을 해제하려고 합니다.
가람은 당직자 수를 조회합니다.
2명가람은 생각합니다.
나래가 남아 있으니
나는 빠져도 된다.나래도 거의 같은 시점에 조회합니다.
2명나래 역시 생각합니다.
가람이 남아 있으니
나는 빠져도 된다.가람은 자신의 행만 수정합니다.
가람
true → false나래도 자신의 행만 수정합니다.
나래
true → false두 트랜잭션이 모두 COMMIT됩니다.
최종 상태는:
가람 = false
나래 = false입니다.
당직자는 0명입니다.
두 트랜잭션은 같은 행을 수정하지 않았습니다.
그런데 업무 규칙은 깨졌습니다.
이 현상을 이해하려면 MVCC가 무엇을 보장하고 무엇까지 보장하지 않는지를 구분해야 합니다.
2장. MVCC는 여러 버전으로 동시 읽기를 처리한다#
MVCC는 Multi-Version Concurrency Control의 약자입니다.
직역하면 다중 버전 동시성 제어입니다.
핵심 아이디어는 단순합니다.
하나의 논리적 행이 변경되더라도 이전 상태를 곧바로 모든 트랜잭션에서 없애버리는 대신 여러 버전과 가시성 정보를 이용합니다.
개념적으로 상품 가격이 다음처럼 변경됐다고 하겠습니다.
기존 버전
상품 1 | 1,000원
새 버전
상품 1 | 1,200원트랜잭션마다 자신의 스냅샷과 가시성 규칙에 따라 어느 버전을 볼지 결정할 수 있습니다.
3장. 한 행의 현재값은 사용자마다 다르게 보일 수 있다#
세션 A가 가격을 변경합니다.
BEGIN;
UPDATE product
SET price = 1200
WHERE product_id = 1;아직 COMMIT하지 않았습니다.
세션 B가 같은 상품을 조회합니다.
SELECT price
FROM product
WHERE product_id = 1;세션 B가 미확정 1,200원을 그대로 읽는 것이 아니라 기존에 확정된 1,000원을 볼 수 있습니다.
개념적으로는 다음과 같습니다.
flowchart LR
V1["기존 버전<br/>1,000원"] --> S1["세션 B에 보임"]
V2["새 버전<br/>1,200원<br/>미확정"] --> S2["세션 A에 보임"]MVCC의 중요한 장점은 일반적인 읽기가 항상 쓰기 완료를 기다려야 하는 것은 아니라는 점입니다.
4장. PostgreSQL에서 UPDATE는 새 행 버전을 만든다고 이해할 수 있다#
PostgreSQL의 일반적인 heap 테이블에서는 UPDATE가 기존 튜플 내용을 그 자리에서 단순 덮어쓰기하는 식으로만 처리되지 않습니다.
개념적으로:
기존 행 버전
↓
새로운 행 버전 생성으로 이해할 수 있습니다.
예:
Version A
price = 1000
Version B
price = 1200어떤 트랜잭션에서는 A가 보이고 다른 트랜잭션에서는 B가 보일 수 있습니다.
각 버전에는 가시성을 판단하기 위한 트랜잭션 관련 정보가 연결됩니다.
5장. MVCC의 핵심은 “최신 버전”이 아니라 “내게 보이는 버전”이다#
다음과 같이 생각하기 쉽습니다.
SELECT는 항상 가장 최신 데이터를 읽는다.
MVCC 환경에서는 더 정확하게 다음처럼 생각해야 합니다.
현재 트랜잭션의 스냅샷과 가시성 규칙에서 볼 수 있는 버전을 읽는다.
최신 커밋이 1,200원이라도 오래된 스냅샷을 사용하는 트랜잭션은 1,000원을 계속 볼 수 있습니다.
따라서:
최신 값과:
현재 트랜잭션에서 보이는 값은 같지 않을 수 있습니다.
6장. 먼저 상품 가격으로 READ COMMITTED를 확인해 보자#
테이블을 만듭니다.
CREATE TABLE product (
product_id integer PRIMARY KEY,
price integer NOT NULL
CHECK (price >= 0)
);
INSERT INTO product
VALUES (1, 1000);두 개의 독립된 연결을 준비합니다.
세션 A
세션 B7장. 세션 A가 상품 가격을 바꾸되 아직 COMMIT하지 않는다#
세션 A:
BEGIN;
UPDATE product
SET price = 1200
WHERE product_id = 1;세션 A에서는 변경값을 볼 수 있습니다.
SELECT price
FROM product
WHERE product_id = 1;결과:
1200하지만 아직 COMMIT하지 않았습니다.
8장. 세션 B는 기존에 확정된 가격을 읽는다#
세션 B:
SELECT price
FROM product
WHERE product_id = 1;이 시점에서는 세션 A의 변경이 아직 미확정입니다.
세션 B에서는 기존 확정값:
1000을 볼 수 있습니다.
일반적인 읽기가 세션 A의 미확정 UPDATE 완료를 단순히 기다리는 대신 이전에 보이는 버전을 읽을 수 있다는 점이 MVCC의 중요한 특징입니다.
9장. 세션 A가 COMMIT한 뒤 세션 B가 다시 읽으면#
세션 A:
COMMIT;이제 1,200원이 확정되었습니다.
세션 B가 새로운 SELECT를 실행합니다.
SELECT price
FROM product
WHERE product_id = 1;READ COMMITTED 환경에서는 이번 SELECT가:
1200을 볼 수 있습니다.
시간표로 정리하면 다음과 같습니다.
| 순서 | 세션 A | 세션 B | B의 결과 |
|---|---|---|---|
| 1 | UPDATE 1000 → 1200 | ||
| 2 | 미확정 | SELECT | 1000 |
| 3 | COMMIT | ||
| 4 | SELECT | 1200 |
10장. READ COMMITTED에서는 문장마다 보는 시점이 달라질 수 있다#
세션 B를 명시적인 트랜잭션으로 열어도 PostgreSQL READ COMMITTED에서는 각 문장이 시작할 때 새로운 스냅샷을 사용할 수 있습니다.
BEGIN ISOLATION LEVEL READ COMMITTED;첫 번째 SELECT:
1000그 사이 A가 COMMIT합니다.
두 번째 SELECT:
1200이 될 수 있습니다.
개념적으로:
flowchart TD
Q1["B의 첫 SELECT"] --> S1["Snapshot 1<br/>1000"]
A["A COMMIT<br/>1200"]
Q2["B의 두 번째 SELECT"] --> S2["Snapshot 2<br/>1200"]같은 트랜잭션 안에서도 문장이 달라지면 보이는 확정 상태가 달라질 수 있습니다.
11장. REPEATABLE READ에서는 같은 트랜잭션의 스냅샷을 유지한다#
이번에는 세션 B를 다음과 같이 시작합니다.
BEGIN ISOLATION LEVEL REPEATABLE READ;첫 번째 조회:
SELECT price
FROM product
WHERE product_id = 1;결과가 1,000이라고 하겠습니다.
그 뒤 세션 A가:
UPDATE product
SET price = 1200
WHERE product_id = 1;
COMMIT;합니다.
세션 B가 다시 조회합니다.
SELECT price
FROM product
WHERE product_id = 1;PostgreSQL REPEATABLE READ에서는 기존 스냅샷을 유지하므로 계속:
1000을 볼 수 있습니다.
12장. 오래된 값을 본다고 틀린 값을 보는 것은 아니다#
데이터베이스의 최신 확정 상태는:
1200입니다.
하지만 세션 B의 트랜잭션 스냅샷에서는:
1000입니다.
둘 중 하나가 거짓인 것은 아닙니다.
기준 시점이 다릅니다.
현재 최신 상태
→ 1200
B가 시작한 읽기 시점의 상태
→ 1000MVCC를 이해하려면 데이터 값뿐 아니라 어느 시점의 가시성인지를 함께 봐야 합니다.
13장. 트랜잭션이 끝나면 새로운 상태를 볼 수 있다#
REPEATABLE READ 세션 B가:
COMMIT;합니다.
새로운 트랜잭션을 시작합니다.
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT price
FROM product
WHERE product_id = 1;이번에는 새로운 스냅샷이 만들어지므로 1,200원을 볼 수 있습니다.
따라서 REPEATABLE READ를 다음처럼 이해해서는 안 됩니다.
한 번 1000을 보면
영원히 1000만 본다.트랜잭션 범위 안에서 일관된 시점을 유지하는 것입니다.
14장. 자기 자신의 변경은 볼 수 있다#
세션 B가 REPEATABLE READ 상태라고 하겠습니다.
처음 가격은:
1000입니다.
B가 직접 변경합니다.
UPDATE product
SET price = 1100
WHERE product_id = 1;이후 같은 세션에서:
SELECT price
FROM product
WHERE product_id = 1;을 실행하면 자신의 변경 1,100을 볼 수 있습니다.
즉:
다른 트랜잭션이 이후 COMMIT한 값
→ 기존 스냅샷에서 안 보일 수 있음
내 트랜잭션에서 직접 변경한 값
→ 내게 보임으로 구분해야 합니다.
15장. MVCC가 읽기와 쓰기를 완전히 분리해 준다고 생각하면 안 된다#
다음 설명은 지나치게 단순합니다.
MVCC에서는 읽기와 쓰기가 절대 서로 기다리지 않는다.
일반 SELECT는 많은 상황에서 쓰기 행 잠금을 기다리지 않고 보이는 과거 버전을 읽을 수 있습니다.
하지만 다음과 같은 경우에는 잠금과 충돌이 발생할 수 있습니다.
SELECT ... FOR UPDATE
동일 행 UPDATE
DELETE
고유성 검사
외래키 관련 작업
DDL
명시적 잠금MVCC는 모든 형태의 대기를 없애는 기술이 아닙니다.
16장. PostgreSQL MVCC를 HOT과 같은 개념으로 보면 안 된다#
PostgreSQL에서 HOT, Heap-Only Tuple이라는 용어를 접할 수 있습니다.
HOT은 특정 조건을 만족하는 UPDATE에서 불필요한 인덱스 갱신을 줄이기 위한 최적화입니다.
하지만:
MVCC
=
HOT은 아닙니다.
MVCC는 훨씬 넓은 행 버전과 가시성 제어 개념입니다.
HOT은 일부 UPDATE 경로의 저장·인덱스 최적화입니다.
17장. 행 버전이 계속 만들어지면 이전 버전은 어떻게 될까#
가격이 계속 바뀐다고 하겠습니다.
1000
↓
1100
↓
1200
↓
1300논리적으로 사용자에게 필요한 것은 현재값 1,300일 수 있습니다.
그러나 이전 버전이 즉시 물리적으로 모두 사라지는 것은 아닙니다.
어떤 트랜잭션이 여전히 과거 버전을 볼 가능성이 있다면 해당 버전을 당장 제거하면 안 됩니다.
18장. 더 이상 필요 없는 이전 튜플은 정리 대상이 된다#
PostgreSQL에서는 UPDATE나 DELETE 후 더 이상 어떤 정상 스냅샷에서도 필요하지 않은 이전 튜플이 생길 수 있습니다.
이런 공간을 정리하고 재사용 가능하게 관리하는 핵심 작업이 VACUUM입니다.
개념적으로:
현재 버전
→ 유지
아직 오래된 트랜잭션이 필요로 하는 버전
→ 유지
아무도 필요로 하지 않는 이전 버전
→ VACUUM 정리 대상입니다.
19장. VACUUM은 단순히 파일 크기를 줄이는 명령이 아니다#
VACUUM을 다음처럼 이해하면 부족합니다.
디스크 파일을 작게 만드는 명령.
일반 VACUUM의 중요한 역할은 더 이상 필요하지 않은 튜플이 차지한 공간을 PostgreSQL이 다시 사용할 수 있도록 정리하고 가시성 관련 정보를 관리하는 데 있습니다.
일반 VACUUM을 수행했다고 운영체제에서 보이는 테이블 파일 크기가 즉시 크게 줄어드는 것은 아닐 수 있습니다.
20장. 오래된 트랜잭션은 버전 정리를 방해할 수 있다#
세션 A가 오래된 REPEATABLE READ 트랜잭션을 몇 시간 동안 유지한다고 하겠습니다.
A의 스냅샷
→ 오래된 행 버전 필요그 사이 다른 세션에서 같은 테이블에 UPDATE와 DELETE가 계속 발생합니다.
Version 1
Version 2
Version 3
...A가 과거 버전을 필요로 할 수 있으므로 일부 버전을 정리하지 못할 수 있습니다.
결과적으로 죽은 튜플이 더 오래 남을 수 있습니다.
21장. 오래된 스냅샷은 공간과 조회 비용에 영향을 줄 수 있다#
죽은 튜플이 많이 쌓이면 다음과 같은 문제가 생길 수 있습니다.
테이블 페이지 증가
더 많은 페이지 접근
캐시 효율 저하
VACUUM 부담 증가
인덱스·테이블 팽창따라서 운영에서는 오래 열린 트랜잭션이 없는지도 살펴야 합니다.
MVCC는 동시성을 높이는 장점이 있지만 버전 관리 비용이 무료인 것은 아닙니다.
22장. PostgreSQL과 다른 DBMS의 MVCC 저장 방식은 같지 않다#
MVCC라는 개념은 여러 DBMS에서 사용하지만 내부 방식은 다를 수 있습니다.
개념적으로 비교하면:
| DBMS | 이전 상태를 제공하는 대표적 방식 |
|---|---|
| PostgreSQL | heap에 여러 행 버전을 두고 가시성 판단 |
| Oracle | undo 정보를 이용해 필요한 과거 상태 재구성 |
| MySQL InnoDB | undo 로그와 읽기 뷰를 이용한 일관된 읽기 |
따라서 PostgreSQL의 튜플 버전과 VACUUM 개념을 다른 DBMS에 그대로 적용하면 안 됩니다.
23장. 이제 쓰기 왜곡을 실제로 만들어 보자#
당직 테이블을 만듭니다.
CREATE TABLE doctor_shift (
doctor_id integer PRIMARY KEY,
doctor_name text NOT NULL,
on_call boolean NOT NULL
);데이터를 입력합니다.
INSERT INTO doctor_shift
VALUES
(1, '가람', true),
(2, '나래', true);업무 규칙은:
당직자는 최소 한 명 이상이어야 한다.입니다.
24장. 정상적인 직렬 실행이라면 두 사람이 모두 빠질 수 없다#
가람의 트랜잭션을 먼저 완전히 실행한다고 하겠습니다.
가람이 조회합니다.
당직자 수 = 2자신을 해제합니다.
가람 = false
나래 = trueCOMMIT합니다.
그다음 나래가 트랜잭션을 시작합니다.
조회 결과:
당직자 수 = 1입니다.
규칙상 자신까지 빠지면 0명이 되므로 해제를 거부해야 합니다.
최종 상태는:
가람 = false
나래 = true입니다.
25장. 반대 직렬 순서에서도 한 명은 남는다#
나래를 먼저 처리해도 마찬가지입니다.
나래 해제
↓
가람은 남아 있음그다음 가람이 조회하면 당직자는 한 명뿐입니다.
따라서 가람의 해제는 거부되어야 합니다.
최종 상태:
가람 = true
나래 = false어느 직렬 실행에서도 당직자는 최소 한 명 남습니다.
26장. REPEATABLE READ 두 세션에서는 서로의 이전 상태를 볼 수 있다#
세션 A:
BEGIN ISOLATION LEVEL REPEATABLE READ;세션 B:
BEGIN ISOLATION LEVEL REPEATABLE READ;세션 A가 당직자 수를 조회합니다.
SELECT count(*)
FROM doctor_shift
WHERE on_call;결과:
2세션 B도 같은 조회를 실행합니다.
결과:
2각자 자신의 스냅샷에서 두 명이 당직인 상태를 봅니다.
27장. 두 세션은 서로 다른 행을 변경한다#
세션 A:
UPDATE doctor_shift
SET on_call = false
WHERE doctor_id = 1;세션 B:
UPDATE doctor_shift
SET on_call = false
WHERE doctor_id = 2;A는 의사 1 행을 변경합니다.
B는 의사 2 행을 변경합니다.
같은 행을 동시에 UPDATE한 것이 아닙니다.
따라서 단순한 동일 행 쓰기 충돌이 발생하지 않을 수 있습니다.
28장. 둘 다 COMMIT되면 당직자가 0명이 된다#
세션 A:
COMMIT;세션 B:
COMMIT;최종 데이터를 확인합니다.
SELECT *
FROM doctor_shift
ORDER BY doctor_id;결과:
1 | 가람 | false
2 | 나래 | false당직자 수:
0업무 규칙이 깨졌습니다.
29장. 쓰기 왜곡의 실행 순서를 표로 보면#
| 단계 | 세션 A | 세션 B |
|---|---|---|
| 1 | REPEATABLE READ 시작 | REPEATABLE READ 시작 |
| 2 | 당직자 수 2 확인 | |
| 3 | 당직자 수 2 확인 | |
| 4 | 가람 해제 | |
| 5 | 나래 해제 | |
| 6 | COMMIT | |
| 7 | COMMIT |
결과:
0명각 트랜잭션은 자신의 판단 시점에서는 업무 규칙을 지킨 것처럼 보입니다.
두 결과를 합치자 규칙이 깨졌습니다.
30장. 이것은 갱신 손실과 다른 문제다#
갱신 손실에서는 보통 같은 데이터를 두 트랜잭션이 변경하면서 한 변경이 사라집니다.
예:
잔액 500
A → 600
B → 700
최종 700A의 변경이 사라졌습니다.
쓰기 왜곡에서는 두 변경이 모두 남습니다.
가람 true → false
나래 true → false둘 다 정상적으로 저장되었습니다.
문제는 두 변경을 함께 적용했을 때 업무 조건이 깨진 것입니다.
31장. 같은 행을 수정하지 않았기 때문에 더 찾기 어렵다#
쓰기 왜곡이 까다로운 이유는 다음과 같습니다.
A는 가람 행만 UPDATE
B는 나래 행만 UPDATE각 행만 보면 서로 직접 충돌하지 않습니다.
하지만 두 트랜잭션이 판단할 때 읽은 조건은 같습니다.
현재 당직자가 몇 명인가?즉 실제 충돌 범위는 행 하나가 아니라:
당직자 집합 전체입니다.
32장. 업무 규칙의 범위와 잠금 행의 범위가 다를 수 있다#
저장 구조는:
의사 1 행
의사 2 행으로 나뉘어 있습니다.
하지만 업무 규칙은:
모든 의사 행을 합쳐
on_call = true가 최소 1명입니다.
따라서 다음처럼 생각하는 것이 중요합니다.
업무 불변식은 어느 데이터 범위를 읽어 판단하는가?
한 행보다 넓은 범위를 사용하는 규칙은 단일 행 충돌만으로 보호되지 않을 수 있습니다.
33장. CHECK 제약 하나로는 이 규칙을 단순하게 표현하기 어렵다#
각 행에 다음 CHECK를 둘 수 있습니다.
CHECK (
on_call IN (true, false)
)하지만:
전체 테이블에서
true가 최소 한 개라는 조건은 개별 행 하나만 보고 판단할 수 없습니다.
따라서 단순 행 CHECK만으로는 해결되지 않습니다.
업무 규칙이 여러 행에 걸쳐 있다는 사실을 모델링 단계에서 알아야 합니다.
34장. 스냅샷 일관성과 직렬 가능성은 같은 의미가 아니다#
A는 자신의 스냅샷에서:
가람 = true
나래 = true를 일관되게 봤습니다.
B도 자신의 스냅샷에서:
가람 = true
나래 = true를 봤습니다.
두 트랜잭션 안에서 읽은 데이터 자체는 일관적입니다.
하지만 최종 결과:
가람 = false
나래 = false는 정상적인 직렬 실행으로 설명되지 않습니다.
즉:
스냅샷 안에서 일관되게 읽는다와:
전체 동시 실행이 직렬 가능하다는 같은 말이 아닙니다.
35장. 왜 최종 결과를 직렬 실행으로 설명할 수 없을까#
A를 먼저 직렬 실행하면:
A 해제
↓
나래 한 명 남음
↓
B는 해제 불가입니다.
B를 먼저 실행하면:
B 해제
↓
가람 한 명 남음
↓
A는 해제 불가입니다.
허용되는 직렬 결과는 두 가지입니다.
가람 false / 나래 true
또는
가람 true / 나래 false하지만 동시 실행 결과는:
가람 false / 나래 false입니다.
어떤 직렬 순서와도 같지 않습니다.
36장. PostgreSQL SERIALIZABLE로 같은 실험을 해보자#
초기 상태를 복원합니다.
UPDATE doctor_shift
SET on_call = true;세션 A:
BEGIN ISOLATION LEVEL SERIALIZABLE;세션 B:
BEGIN ISOLATION LEVEL SERIALIZABLE;두 세션 모두 당직자 수를 확인합니다.
SELECT count(*)
FROM doctor_shift
WHERE on_call;각각 2를 볼 수 있습니다.
37장. 각자 다른 행을 수정하는 것까지는 가능할 수 있다#
세션 A:
UPDATE doctor_shift
SET on_call = false
WHERE doctor_id = 1;세션 B:
UPDATE doctor_shift
SET on_call = false
WHERE doctor_id = 2;서로 다른 행입니다.
이 시점까지는 두 UPDATE 문장이 각각 수행될 수 있습니다.
하지만 SERIALIZABLE에서는 최종적으로 이 두 트랜잭션을 모두 성공시켰을 때 직렬 실행과 동등한 결과가 되는지까지 고려합니다.
38장. PostgreSQL은 한쪽을 직렬화 실패로 중단할 수 있다#
두 트랜잭션이 COMMIT을 시도하면 PostgreSQL이 직렬화 이상을 감지해 한쪽을 실패시킬 수 있습니다.
애플리케이션에서는 예를 들어 SQLSTATE:
40001직렬화 실패를 받을 수 있습니다.
결과적으로 두 트랜잭션이 모두 잘못된 판단을 확정하도록 허용하지 않습니다.
39장. SERIALIZABLE은 모든 요청을 성공시키는 모드가 아니다#
SERIALIZABLE을 다음처럼 이해하면 안 됩니다.
가장 높은 격리 수준
=
모든 트랜잭션이 성공오히려 안전한 직렬 순서를 만들 수 없는 동시 실행이라면 한 트랜잭션을 실패시킬 수 있습니다.
위험한 동시 실행
↓
직렬화 실패
↓
ROLLBACK
↓
재시도가 정상적인 설계의 일부입니다.
40장. 재시도할 때 마지막 UPDATE만 다시 보내면 안 된다#
나래의 트랜잭션이 직렬화 실패했다고 하겠습니다.
이미 이전 시도에서:
당직자 수 = 2라는 결과를 읽었습니다.
그 판단만 기억하고 다음 UPDATE만 다시 실행한다면:
UPDATE doctor_shift
SET on_call = false
WHERE doctor_id = 2;새로운 상태를 검사하지 않습니다.
A가 이미 빠져 있다면 나래는 더 이상 빠질 수 없습니다.
41장. 재시도는 업무 판단부터 다시 해야 한다#
재시도는 다음과 같이 해야 합니다.
flowchart TD
F["직렬화 실패"] --> R["ROLLBACK"]
R --> B["새 트랜잭션 시작"]
B --> Q["현재 당직자 다시 조회"]
Q --> C{"내가 빠져도<br/>1명 이상 남는가?"}
C -->|예| U["UPDATE"]
C -->|아니오| D["업무 요청 거부"]
U --> M["COMMIT"]재시도는 이전 SQL을 기계적으로 반복하는 것이 아닙니다.
새로운 상태에서 업무 규칙을 다시 판단하는 과정입니다.
42장. 재시도하면 오히려 정상적으로 “해제 불가”가 나올 수 있다#
첫 동시 실행에서는 두 트랜잭션 모두:
당직자 2명을 봤습니다.
A가 성공하고 B가 직렬화 실패했다고 하겠습니다.
현재 상태:
가람 = false
나래 = trueB가 새 트랜잭션에서 다시 조회하면:
당직자 수 = 1입니다.
따라서 정상적인 업무 결과는:
나래는 당직 해제 불가일 수 있습니다.
재시도의 목적은 반드시 성공시키는 것이 아닙니다.
43장. 명시적인 공통 잠금으로 규칙을 직렬화할 수도 있다#
다른 설계 방법으로 당직표 전체 규칙을 대표하는 공통 행을 둘 수 있습니다.
예:
SHIFT_POLICY
hospital_id = 1당직 상태를 변경하는 모든 트랜잭션이 먼저 이 행을 잠급니다.
SELECT *
FROM shift_policy
WHERE hospital_id = 1
FOR UPDATE;그 뒤 현재 당직자 수를 확인하고 자신의 상태를 변경합니다.
44장. 공통 보호 행을 사용하면 같은 규칙을 검사하는 요청이 순서대로 진행된다#
개념적으로:
flowchart TD
A["세션 A<br/>정책 행 잠금"] --> C["규칙 검사"]
C --> U["상태 변경"]
U --> M["COMMIT"]
M --> B["세션 B가 정책 행 잠금 획득"]
B --> C2["새 상태에서 규칙 검사"]두 요청이 같은 보호 지점을 통과하게 됩니다.
다만 경쟁이 많은 업무라면 해당 행이 병목이 될 수도 있습니다.
45장. 각 의사 행에 FOR UPDATE만 걸면 충분하지 않을 수 있다#
A가 자신의 행만 잠급니다.
의사 1 잠금B는 자신의 행만 잠급니다.
의사 2 잠금서로 다른 행이므로 동시에 진행할 수 있습니다.
그렇다면 여전히:
가람 false
나래 false가 될 수 있습니다.
업무 규칙이 두 행 전체에 걸쳐 있다면 잠금 범위도 그 업무 규칙을 실제로 보호할 수 있어야 합니다.
46장. SERIALIZABLE과 명시적 잠금은 서로 다른 선택지다#
SERIALIZABLE은 DBMS가 읽기·쓰기 의존성을 추적해 직렬 실행과 동등하지 않은 위험한 동시 실행을 중단시키는 방향입니다.
명시적 잠금은 애플리케이션이 특정 자원을 이용해 실행 순서를 직접 제한하는 방식입니다.
각각 장단점이 있습니다.
| 방식 | 특징 |
|---|---|
| SERIALIZABLE | DBMS가 직렬화 이상을 감지, 재시도 필요 |
| 공통 행 잠금 | 업무 충돌 지점을 명시적으로 직렬화 |
| 한 행에 규칙 집중 | 데이터 모델 자체를 변경해 충돌 지점 명확화 |
업무 빈도와 경합 정도에 따라 선택이 달라집니다.
47장. SERIALIZABLE에서도 재시도 폭주를 고려해야 한다#
트래픽이 매우 많고 같은 업무 규칙을 동시에 검사한다면 직렬화 실패가 반복될 수 있습니다.
다음과 같은 구조는 위험합니다.
실패
↓
즉시 재시도
↓
다시 실패
↓
즉시 재시도실무에서는 상황에 따라:
최대 재시도 횟수
짧은 지연
백오프
무작위 지연
실패 로깅등을 고려할 수 있습니다.
48장. 트랜잭션 안에 외부 부작용이 있으면 재시도가 더 어려워진다#
다음 업무라고 하겠습니다.
당직 해제
↓
문자 발송트랜잭션 내부에서 문자 API를 호출했습니다.
문자는 전송됐습니다.
그런데 COMMIT에서 직렬화 실패가 발생합니다.
DB 변경은 ROLLBACK됩니다.
하지만 문자는 취소되지 않습니다.
문자
→ 휴무 승인
DB
→ 휴무 승인 실패상태가 어긋납니다.
49장. 재시도 가능한 트랜잭션에서는 외부 작업도 멱등성을 생각해야 한다#
다음과 같은 구조를 사용할 수 있습니다.
DB 트랜잭션
↓
업무 상태 변경
+
발송할 이벤트 기록
↓
COMMIT
↓
외부 Worker 전송그리고 이벤트에:
event_id를 부여해 중복 전송을 제어할 수 있습니다.
SERIALIZABLE 재시도와 외부 시스템을 함께 사용할 때 특히 중요합니다.
50장. MVCC는 버전이 많아질수록 운영 비용을 만든다#
조회와 UPDATE가 동시에 잘 수행된다는 장점만 보면 MVCC가 공짜처럼 느껴질 수 있습니다.
하지만 행 버전은 저장 공간을 사용합니다.
죽은 튜플을 정리해야 합니다.
가시성 판단도 필요합니다.
긴 트랜잭션은 버전 정리를 지연시킬 수 있습니다.
따라서 다음을 함께 봐야 합니다.
동시 읽기 이점
버전 저장 비용
VACUUM 비용
테이블 팽창
오래된 트랜잭션51장. UPDATE가 많은 테이블에서는 VACUUM 상태를 봐야 한다#
예를 들어 주문 상태가 계속 바뀝니다.
접수
↓
결제
↓
배송준비
↓
배송중
↓
배송완료행 하나가 여러 번 UPDATE됩니다.
이런 테이블에서는 이전 버전이 많이 생성될 수 있습니다.
운영에서는 단순히 현재 행 수만 보는 것이 아니라 죽은 튜플과 VACUUM 동작도 관찰할 필요가 있습니다.
52장. Autovacuum은 중요하지만 아무 문제나 자동으로 해결하는 것은 아니다#
PostgreSQL은 일반적으로 autovacuum 기능을 이용해 정리 작업을 자동으로 수행할 수 있습니다.
하지만 다음과 같은 환경에서는 기본 설정만으로 충분하지 않을 수 있습니다.
UPDATE가 매우 많은 대형 테이블
짧은 시간 대량 DELETE
매우 긴 트랜잭션
높은 변경률
특정 대형 테이블에 집중된 쓰기실제 테이블별 변경 패턴을 기준으로 관찰해야 합니다.
53장. 긴 트랜잭션은 “아무 작업도 안 하는데” 비용을 만들 수도 있다#
세션 A가:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT ...;한 뒤 몇 시간 동안 아무 SQL도 실행하지 않고 연결을 유지한다고 하겠습니다.
겉보기에는 CPU를 거의 쓰지 않을 수 있습니다.
하지만 오래된 스냅샷이 유지되면서 과거 버전 정리에 영향을 줄 수 있습니다.
즉:
CPU 사용량이 낮다
=
DB에 영향이 없다는 아닙니다.
54장. idle in transaction 상태를 오래 두지 않는 것이 중요하다#
애플리케이션이 다음처럼 동작한다고 하겠습니다.
BEGIN
SELECT
사용자 입력 기다림
10분 후 UPDATE
COMMIT트랜잭션이 열려 있는 동안 오래된 스냅샷과 잠금 등의 자원이 유지될 수 있습니다.
가능하면 사용자 대기 시간과 데이터베이스 트랜잭션 범위를 분리하는 것이 좋습니다.
55장. MVCC에서 중요한 것은 버전 개수보다 가시성이다#
행 버전이 세 개 있다고 하겠습니다.
V1
V2
V3모든 트랜잭션이 항상 V3만 보는 것은 아닙니다.
트랜잭션 A
→ V1
트랜잭션 B
→ V2
새 트랜잭션 C
→ V3처럼 각자의 스냅샷에 따라 보이는 버전이 달라질 수 있습니다.
따라서 MVCC를 단순히:
여러 버전을 저장한다.로만 이해하는 것보다:
여러 버전 가운데 어느 버전이 현재 트랜잭션에 보이는지를 판단한다.
라고 이해하는 것이 핵심입니다.
56장. 읽기 일관성만 테스트해서는 동시성 규칙을 검증하기 어렵다#
개발자가 REPEATABLE READ에서 다음을 확인했다고 하겠습니다.
같은 SELECT 두 번
→ 같은 결과그래서:
우리 업무는 안전하다.
라고 결론 내립니다.
하지만 쓰기 왜곡 사례처럼 서로 다른 두 트랜잭션이 각자 같은 스냅샷에서 올바른 값을 보고도 최종 업무 규칙을 깨뜨릴 수 있습니다.
따라서 동시성 테스트는:
한 트랜잭션 안에서 값이 안정적인가?뿐 아니라:
여러 트랜잭션이 동시에 성공했을 때
최종 업무 규칙이 유지되는가?까지 확인해야 합니다.
57장. 단일 행 제약과 다중 행 업무 규칙을 구분하자#
다음은 한 행으로 검사할 수 있습니다.
잔액 >= 0
가격 >= 0
수량 >= 0CHECK 제약과 잘 맞을 수 있습니다.
하지만 다음은 여러 행이나 여러 테이블을 봐야 할 수 있습니다.
당직자 최소 1명
동일 시간대 예약 최대 10건
사용자 전체 하루 이체액 1,000만원 이하
한 좌석은 하나의 확정 예약만 가능이런 업무에서는 동시성 제어까지 설계해야 합니다.
58장. 데이터 모델을 바꿔 불변식을 더 직접적으로 표현할 수도 있다#
어떤 규칙은 데이터 구조를 바꾸면 보호하기 쉬워질 수 있습니다.
예를 들어 분산된 여러 행을 매번 세는 대신 업무에 적합한 하나의 상태 행이나 예약 슬롯 같은 모델을 둘 수 있습니다.
그러면 경쟁하는 트랜잭션이 같은 행을 수정하도록 만들어 DBMS가 직접 쓰기 충돌을 감지하게 할 수도 있습니다.
다만 이런 방식은 경합이 집중될 수 있으므로 성능과 확장성을 함께 판단해야 합니다.
59장. PostgreSQL MVCC와 쓰기 왜곡을 한 그림으로 정리하면#
flowchart TD
S["초기 상태<br/>가람=true<br/>나래=true"] --> A["세션 A 스냅샷<br/>당직 2명"]
S --> B["세션 B 스냅샷<br/>당직 2명"]
A --> UA["가람=false"]
B --> UB["나래=false"]
UA --> C["두 트랜잭션 모두 성공"]
UB --> C
C --> X["최종 상태<br/>당직 0명<br/>업무 규칙 위반"]중요한 것은 각 트랜잭션이 읽은 값이 틀리지 않았다는 점입니다.
문제는 그 두 판단을 동시에 확정한 결과입니다.
60장. 쓰기 왜곡을 발견하는 실전 질문#
동시성 설계에서 다음 질문을 해보면 좋습니다.
- 업무 규칙은 한 행만 보고 판단할 수 있는가?
- 여러 행의 개수·합계·존재 여부를 보고 결정하는가?
- 두 트랜잭션이 서로 다른 행을 수정할 수 있는가?
- 각자 읽은 상태에서는 둘 다 성공해도 된다고 판단할 수 있는가?
- 두 결과를 합치면 업무 규칙이 깨질 수 있는가?
- 한 트랜잭션을 먼저 끝낸 직렬 실행에서도 둘 다 성공할 수 있는가?
마지막 질문의 답이 “아니오”인데 동시 실행에서는 둘 다 성공한다면 직렬화 이상을 의심할 수 있습니다.
61장. MVCC 운영 체크리스트#
스냅샷#
현재 격리 수준에서
스냅샷이 언제 만들어지는가?오래된 트랜잭션#
장시간 열린 트랜잭션이 있는가?버전 정리#
VACUUM이 정상적으로 동작하는가?변경량#
특정 테이블의 UPDATE·DELETE가 지나치게 많은가?업무 규칙#
한 행이 아니라 여러 행에 걸친 불변식이 있는가?직렬화 실패#
40001 오류를 전체 트랜잭션 단위로 재시도할 수 있는가?외부 부작용#
재시도 중 문자·결제·이벤트가 중복되지 않는가?62장. 핵심 정리#
PostgreSQL MVCC를 이해하는 가장 중요한 질문은 이것입니다.
현재 트랜잭션에서는 어느 행 버전이 보이는가?
PostgreSQL에서는 UPDATE와 DELETE 과정에서 여러 행 버전이 생길 수 있고, 각 트랜잭션의 스냅샷과 가시성 규칙에 따라 읽을 버전이 결정됩니다.
그래서 일반적인 읽기는 다른 트랜잭션의 미확정 변경 완료를 기다리지 않고 이전에 보이는 버전을 읽을 수 있습니다.
READ COMMITTED에서는:
문장마다
새로운 확정 상태를 볼 수 있음반면 REPEATABLE READ에서는:
트랜잭션 동안
같은 스냅샷을 유지합니다.
하지만 일관된 스냅샷을 읽는 것과 전체 업무가 직렬 가능하다는 것은 다른 문제입니다.
당직자 예제에서는 두 트랜잭션이 모두:
당직자 2명이라는 올바른 스냅샷을 읽었습니다.
그리고 서로 다른 행을 수정했습니다.
A
→ 의사 1 해제
B
→ 의사 2 해제같은 행에 대한 쓰기 충돌은 없었지만 최종 결과는:
당직자 0명이 되었습니다.
이것이 쓰기 왜곡입니다.
업무 규칙이:
한 행의 값이 아니라:
여러 행의 합계
개수
존재 여부
전체 조건에 걸쳐 있다면 단순한 행 충돌만으로 보호할 수 있는지 반드시 확인해야 합니다.
PostgreSQL SERIALIZABLE에서는 이런 직렬화 이상을 감지해 한 트랜잭션을 실패시킬 수 있습니다.
이때 중요한 것은:
마지막 UPDATE 재실행이 아니라:
ROLLBACK
↓
새 트랜잭션 시작
↓
현재 상태 다시 읽기
↓
업무 규칙 다시 판단
↓
필요하면 변경
↓
COMMIT입니다.
MVCC에는 운영 비용도 있습니다.
행 버전은 정리되어야 하고, 오래된 트랜잭션이 과거 버전을 계속 필요로 하면 VACUUM의 정리가 지연될 수 있습니다.
따라서 MVCC를 단순히 “락 없이 빠르게 읽는 기술”로 이해해서는 부족합니다.
MVCC는 여러 트랜잭션이 서로 다른 시점의 행 버전을 안전하게 관찰할 수 있게 하지만, 여러 행에 걸친 업무 규칙까지 자동으로 보호해 주지는 않는다.
결국 데이터베이스의 동시성 설계에서는 두 가지를 함께 확인해야 합니다.
이 트랜잭션은 어떤 데이터를 보고 있는가?
그리고:
동시에 성공한 여러 트랜잭션의 결과를 합쳐도 업무 규칙이 유지되는가?
이 두 질문이 함께 충족되어야 MVCC의 읽기 일관성과 실제 서비스의 데이터 무결성이 연결됩니다.