3.3 격리 수준
격리 수준(isolation level)은 동시에 진행되는 트랜잭션이 서로 어디까지 영향을 주고받을 수 있는지를 정의합니다. SQL 표준은 4개를 정의하지만 PostgreSQL은 셋만 의미 있게 구현한다 — Read Committed(기본), Repeatable Read, Serializable.
SQL 표준 vs PostgreSQL 실제
| 표준 격리 수준 | 표준 정의 | PostgreSQL 실제 |
|---|---|---|
| Read Uncommitted | dirty read 허용 | Read Committed처럼 동작 (PostgreSQL은 dirty read 불가) |
| Read Committed | dirty read 차단 | 그대로 (기본) |
| Repeatable Read | non-repeatable read 차단 | 그대로 + phantom read도 차단 |
| Serializable | 모든 이상 현상 차단 | 그대로 (SSI로 구현) |
PostgreSQL은 MVCC 위에 격리 수준을 얹기 때문에 표준의 “Read Uncommitted"는 의미가 없다 — 어차피 dirty read를 못 합니다.
격리 수준 변경
-- 트랜잭션 단위로
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- ... SQL들
COMMIT;
-- 세션 기본값 변경
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 클러스터 기본값
ALTER SYSTEM SET default_transaction_isolation = 'read committed';Read Committed (기본)
각 SQL 명령마다 새 스냅샷을 잡습니다. 즉, 같은 트랜잭션 안에서 같은 SELECT를 두 번 실행해도 다른 결과가 나올 수 있습니다.
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#ede9fe',
'primaryTextColor':'#3b0764',
'primaryBorderColor':'#6d28d9',
'lineColor':'#1d4ed8',
'secondaryColor':'#d1fae5',
'tertiaryColor':'#fef3c7',
'noteBkgColor':'#fef3c7',
'noteBorderColor':'#b45309',
'noteTextColor':'#78350f',
'actorBkg':'#dbeafe',
'actorBorder':'#1d4ed8',
'actorTextColor':'#1e3a8a',
'activationBkgColor':'#d1fae5',
'activationBorderColor':'#047857',
'signalColor':'#1d4ed8',
'signalTextColor':'#1e3a8a',
'labelBoxBkgColor':'#fef3c7',
'labelBoxBorderColor':'#b45309',
'labelTextColor':'#78350f'
}}}%%
sequenceDiagram
participant T1 as Tx A (Read Committed)
participant T2 as Tx B
T1->>T1: BEGIN; SELECT balance — 1000
T2->>T2: BEGIN; UPDATE balance=1500; COMMIT;
T1->>T1: SELECT balance — 1500 (다른 결과!)
T1->>T1: COMMIT
차단되는 것: dirty read (다른 트랜잭션이 아직 커밋 안 한 값은 안 보임)
허용되는 것:
- Non-repeatable read — 같은 row를 다시 읽으면 값이 바뀌었을 수 있음
- Phantom read — 조건에 맞는 row 집합이 달라질 수 있음
UPDATE 같은 쓰기 SQL은 약간 다르게 동작한다 — “가장 최신 commit된 row“를 대상으로 한다(FOR UPDATE 스타일 lock). 그래서 UPDATE는 dirty read는 아니지만 자기가 본 row와 실제로 쓴 row가 다를 수 있다 (lost update 가능성). 운영에서는 이 차이가 자주 사고를 만듭니다.
-- 잔액 차감 — Read Committed에서 race 가능
BEGIN;
SELECT balance FROM accounts WHERE id=1; -- 1000으로 봤음
-- 그사이 다른 트랜잭션이 잔액을 500으로 만들었다고 가정
UPDATE accounts SET balance = balance - 600 WHERE id=1;
-- 결과: -100! "if balance >= 600" 같은 검사가 락 없이는 무용지물
COMMIT;해결: SELECT FOR UPDATE로 row 락을 잡거나 격리 수준을 올립니다.
Repeatable Read
트랜잭션 시작 시점의 스냅샷을 끝까지 유지합니다. 같은 SQL을 여러 번 실행해도 같은 결과입니다.
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#ede9fe',
'primaryTextColor':'#3b0764',
'primaryBorderColor':'#6d28d9',
'lineColor':'#1d4ed8',
'secondaryColor':'#d1fae5',
'tertiaryColor':'#fef3c7',
'noteBkgColor':'#fef3c7',
'noteBorderColor':'#b45309',
'noteTextColor':'#78350f',
'actorBkg':'#dbeafe',
'actorBorder':'#1d4ed8',
'actorTextColor':'#1e3a8a',
'activationBkgColor':'#d1fae5',
'activationBorderColor':'#047857',
'signalColor':'#1d4ed8',
'signalTextColor':'#1e3a8a',
'labelBoxBkgColor':'#fef3c7',
'labelBoxBorderColor':'#b45309',
'labelTextColor':'#78350f'
}}}%%
sequenceDiagram
participant T1 as Tx A (Repeatable Read)
participant T2 as Tx B
T1->>T1: BEGIN; SELECT balance — 1000
T2->>T2: UPDATE balance=1500; COMMIT;
T1->>T1: SELECT balance — 1000 (그대로!)
T1->>T1: COMMIT
PostgreSQL의 Repeatable Read는 표준보다 강하다 — phantom read도 차단합니다.
serialization failure
여러 트랜잭션이 같은 row를 동시에 수정하려 하면 한쪽이 실패합니다.
-- Tx A
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = 900 WHERE id = 1;
-- 동시에 Tx B가 같은 row를 UPDATE
COMMIT;
-- ERROR: could not serialize access due to concurrent update애플리케이션은 이 오류를 잡아서 재시도해야 합니다. Repeatable Read를 운영에서 쓰려면 retry 로직이 필수입니다.
for attempt in range(3):
try:
with conn.transaction(isolation_level="repeatable read"):
cur.execute("UPDATE accounts SET balance = balance - 600 WHERE id = 1")
return
except psycopg.errors.SerializationFailure:
continue
raise RuntimeError("3회 재시도 실패")적합한 경우
- 보고서 — 트랜잭션 동안 일관된 상태를 보고 싶을 때
- 일괄 작업 (ETL) — 중간에 외부에서 변경이 들어와도 영향 없음
- pg_dump — 내부적으로 Repeatable Read를 쓴다
Serializable (SSI)
가장 엄격. 모든 동시 실행이 어떤 직렬 순서와 동등해야 한다 — 즉, “트랜잭션을 한 줄로 줄세워 실행한 것처럼” 보여야 합니다.
PostgreSQL은 SSI(Serializable Snapshot Isolation, 2011 v9.1+)로 구현합니다. 락 없이 MVCC 위에서 충돌을 추적하다가, 직렬화 불가능한 패턴이 감지되면 한쪽을 abort합니다.
%%{init: {'theme':'base','themeVariables':{
'primaryColor':'#ede9fe',
'primaryTextColor':'#3b0764',
'primaryBorderColor':'#6d28d9',
'lineColor':'#1d4ed8',
'secondaryColor':'#d1fae5',
'tertiaryColor':'#fef3c7',
'noteBkgColor':'#fef3c7',
'noteBorderColor':'#b45309',
'noteTextColor':'#78350f',
'actorBkg':'#dbeafe',
'actorBorder':'#1d4ed8',
'actorTextColor':'#1e3a8a',
'activationBkgColor':'#d1fae5',
'activationBorderColor':'#047857',
'signalColor':'#1d4ed8',
'signalTextColor':'#1e3a8a',
'labelBoxBkgColor':'#fef3c7',
'labelBoxBorderColor':'#b45309',
'labelTextColor':'#78350f'
}}}%%
sequenceDiagram
participant T1 as Tx A (Serializable)
participant T2 as Tx B (Serializable)
T1->>T1: BEGIN; SELECT sum(balance) — 100
T2->>T2: BEGIN; SELECT sum(balance) — 100
T1->>T1: INSERT INTO log(total) VALUES (100);
T2->>T2: INSERT INTO log(total) VALUES (100);
T1->>T1: COMMIT (성공)
T2->>T2: COMMIT
Note over T2: ERROR: serialization failure<br/>(직렬 실행이면 두 합계가 달랐을 것)
서로 락 충돌이 없어 보이는 read·write도 SSI가 잡아냅니다.
적합한 경우
- 회계·재무 — 금액 일관성이 절대 요구사항
- 재고 시스템 — race 없이 정확한 카운트 유지
- 단, 모든 트랜잭션이 짧고 retry를 항상 구현할 수 있을 때
비용
- 충돌 감지를 위한 predicate lock(가벼운 가상 락) 메타데이터 — shared memory 소비
- abort/재시도 빈도가 워크로드에 따라 큼
- 운영 환경 대부분은 Read Committed + 명시적 row lock으로 충분
격리 수준 비교
| 현상 | Read Committed | Repeatable Read | Serializable |
|---|---|---|---|
| Dirty read | 차단 | 차단 | 차단 |
| Non-repeatable read | 허용 | 차단 | 차단 |
| Phantom read | 허용 | 차단 (PG 한정) | 차단 |
| Lost update | 위험 (락 없이는) | 자동 감지 → abort | 자동 감지 → abort |
| Write skew | 위험 | 위험 (PG의 RR은 SI라 SSI보다 약함) | 차단 |
Write skew: 두 트랜잭션이 각자 별 row를 봤는데, 직렬 실행이라면 한쪽이 다른 쪽 결과에 영향을 받았어야 하는 경우. SSI만 잡습니다.
어떤 격리 수준을 쓸 것인가
| 시나리오 | 권장 |
|---|---|
| 일반 OLTP — 대부분 | Read Committed + 명시적 SELECT FOR UPDATE로 lost update 방지 |
| 일관된 보고서 | Repeatable Read |
| 회계·재고 | Serializable (retry 로직 필수) |
| ETL·일괄 작업 | Repeatable Read |
| pg_dump 같은 도구 | Repeatable Read (PG가 자동) |
진단
-- 현재 격리 수준
SHOW transaction_isolation;
-- 클러스터 기본
SHOW default_transaction_isolation;
-- 직렬화 실패 통계 (PG 14+)
SELECT datname, deadlocks, conflicts FROM pg_stat_database WHERE datname = current_database();직렬화 실패가 자주 나면 격리 수준을 낮추거나 트랜잭션을 짧게 자르는 걸 검토합니다.
정리
- PostgreSQL은 3가지 격리 수준: Read Committed(기본), Repeatable Read, Serializable
- Read Committed는 각 SQL마다 새 스냅샷 — 빠르지만 lost update 위험
- Repeatable Read는 트랜잭션 시작 시점 스냅샷 유지 — phantom read도 차단
- Serializable은 SSI로 모든 동시성 이상을 차단 — 비용 대신 안전성 최고
- 운영 표준 = Read Committed + 명시 row lock. 필요한 곳만 격리 수준 상향
- 격리 수준 상향 시 serialization failure를 잡고 재시도하는 코드 필수
다음 절(3.4)에서는 격리와 짝을 이루는 락 — heavy-weight, light-weight, predicate — 을 봅니다.