본문으로 건너뛰기

3.3 격리 수준

격리 수준(isolation level)은 동시에 진행되는 트랜잭션이 서로 어디까지 영향을 주고받을 수 있는지를 정의합니다. SQL 표준은 4개를 정의하지만 PostgreSQL은 셋만 의미 있게 구현한다 — Read Committed(기본), Repeatable Read, Serializable.

SQL 표준 vs PostgreSQL 실제

표준 격리 수준표준 정의PostgreSQL 실제
Read Uncommitteddirty read 허용Read Committed처럼 동작 (PostgreSQL은 dirty read 불가)
Read Committeddirty read 차단그대로 (기본)
Repeatable Readnon-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 CommittedRepeatable ReadSerializable
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가 자동)
기본을 바꾸지 말 것: 클러스터 기본을 Serializable로 바꾸면 의도치 않게 retry가 필요한 코드가 폭증합니다. 격리 수준은 트랜잭션별로 명시적으로 올리는 게 정석입니다.

진단

-- 현재 격리 수준
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 — 을 봅니다.