PostgreSQL 트랜잭션 격리 수준 실전 정리
PostgreSQL의 트랜잭션 격리 수준은 SQL 표준과 다르며 MVCC 기반으로 동작합니다. 기본값인 Read Committed를 넘어 데이터 정합성을 위해 Repeatable Read나 Serializable을 선택할 때는 각 수준의 이상 현상 차단 범위와 재시도 로직 구현 등 실무적인 설계 원칙을 반드시 이해해야 합니다

왜 격리 수준을 알아야 하는가
데이터베이스에 트랜잭션이 하나뿐이라면 격리 수준은 고민할 필요가 없다. 문제는 항상 동시성에서 생긴다. 같은 행(row), 같은 조건의 행 집합을 여러 트랜잭션이 동시에 읽고 쓸 때, 각 트랜잭션이 "다른 트랜잭션의 작업을 어디까지 볼 수 있는가"에 따라 결과가 달라진다.
이상적으로는 모든 트랜잭션이 한 번에 하나씩 직렬로 실행된 것처럼 보이면 좋다. 그게 가장 안전하다. 하지만 실제로 직렬 실행을 강제하면 동시성이 죽고 처리량이 바닥난다. 그래서 데이터베이스는 정합성과 동시성 사이의 트레이드오프를 단계별로 제공한다. 그 단계를 고르는 손잡이가 바로 트랜잭션 격리 수준(transaction isolation level) 이다.
격리 수준을 높이면 더 많은 동시성 이상현상을 막아주지만, 그만큼 잠금 경합이 늘거나 직렬화 실패로 인한 재시도가 발생한다. 격리 수준을 낮추면 빠르지만, 애플리케이션이 직접 책임져야 할 정합성 구멍이 늘어난다. 이 글은 PostgreSQL 을 기준으로 그 손잡이를 어떻게 돌려야 하는지 정리한다. PostgreSQL 은 SQL 표준의 격리 수준을 그대로 따르지 않고, MVCC 기반으로 독자적으로 구현했기 때문에 표준 문서만 읽고 넘어가면 함정에 빠진다.
동시성 이상현상
격리 수준을 이해하려면 먼저 "무엇을 막으려는가"를 정의해야 한다. 격리 수준은 곧 "어떤 이상현상까지 허용하는가"의 다른 이름이다.
- Dirty read (더티 리드): 다른 트랜잭션이 아직 커밋하지 않은 변경을 읽는다. 그 트랜잭션이 롤백되면, 읽은 값은 애초에 존재한 적 없는 유령 데이터가 된다.
- Non-repeatable read (반복 불가능한 읽기): 한 트랜잭션 안에서 같은 행을 두 번 읽었는데 값이 다르다. 사이에 다른 트랜잭션이 그 행을 커밋해 바꿨기 때문이다.
- Phantom read (팬텀 리드): 한 트랜잭션 안에서 같은 조건(
WHERE)으로 행 집합을 두 번 조회했는데, 두 번째에 새 행이 나타나거나 사라진다. 다른 트랜잭션이 조건에 맞는 행을 삽입/삭제하고 커밋했기 때문이다. non-repeatable read 가 "기존 행의 값"이라면 phantom 은 "행 집합의 멤버십"이 바뀌는 것이다. - Lost update (갱신 손실): 두 트랜잭션이 같은 행을 읽고(read), 각자 계산해서 쓰면(write), 나중에 커밋한 쪽이 먼저 커밋한 쪽의 변경을 덮어쓴다. 카운터 증가, 잔액 차감에서 전형적으로 발생한다.
- Write skew (쓰기 왜곡): 두 트랜잭션이 서로 다른 행을 읽고 각자 갱신하는데, 그 결과가 합쳐지면 둘 다 읽기 시점엔 만족하던 불변식(invariant)을 깨뜨린다. 각 트랜잭션은 자기 입장에서 정당하지만 합치면 규칙 위반이다. 가장 까다롭고, 가장 자주 놓치는 이상현상이다.
마지막 두 개(lost update, write skew)는 SQL 표준의 원래 정의에는 명시적으로 등장하지 않지만, 실무에서는 가장 자주 사고를 일으키는 패턴이다. 표만 외우면 이 둘을 놓친다.
SQL 표준의 4수준과 PostgreSQL 의 실제
SQL 표준은 네 개의 격리 수준을 정의하고, 각 수준이 허용/금지하는 이상현상으로 구분한다. 표준은 dirty read, non-repeatable read, phantom read 세 가지만으로 수준을 정의한다는 점에 유의하라.
| 격리 수준 | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| Read Uncommitted | 허용 | 허용 | 허용 |
| Read Committed | 금지 | 허용 | 허용 |
| Repeatable Read | 금지 | 금지 | 허용 |
| Serializable | 금지 | 금지 | 금지 |
여기까지는 교과서다. 중요한 건 PostgreSQL 이 이 표를 그대로 구현하지 않는다는 것이다. PostgreSQL 은 MVCC(다중 버전 동시성 제어) 기반이라 표준보다 강하게 동작하는 수준이 많다.
Read Uncommitted — 존재하지만 Read Committed 다
PostgreSQL 에서 READ UNCOMMITTED 를 설정할 수는 있다. 하지만 실제 동작은 READ COMMITTED 와 같다. PostgreSQL 에는 더티 리드가 아예 없다. MVCC 구조상 커밋되지 않은 다른 트랜잭션의 변경은 어떤 격리 수준에서도 보이지 않는다. 즉 표준이 허용하는 가장 약한 수준을, PostgreSQL 은 구현하지 않는다(정확히는 한 단계 위로 끌어올린다).
Read Committed — 기본값
별도 설정이 없으면 모든 트랜잭션은 READ COMMITTED 로 동작한다. 핵심 동작은 이렇다.
- 각 문장(statement) 이 시작될 때의 스냅샷을 본다. 트랜잭션 단위가 아니라 문장 단위다.
- 따라서 같은 트랜잭션 안에서도
SELECT를 두 번 하면, 그 사이 다른 트랜잭션이 커밋한 변경이 두 번째SELECT에 보인다 → non-repeatable read 와 phantom 이 모두 발생할 수 있다. - 커밋되지 않은 데이터는 보이지 않으므로 dirty read 는 없다.
대부분의 웹 애플리케이션은 이 수준으로 충분하다. 짧은 트랜잭션, 단건 갱신 위주라면 굳이 올릴 이유가 없다.
Repeatable Read — 사실상 스냅샷 격리
PostgreSQL 의 REPEATABLE READ 는 표준의 정의보다 강하다. 내부적으로 스냅샷 격리(snapshot isolation) 로 구현되어 있다.
- 트랜잭션의 첫 문장이 실행되는 시점에 스냅샷을 한 번 뜨고, 트랜잭션이 끝날 때까지 그 스냅샷만 본다. 문장 단위가 아니라 트랜잭션 단위 스냅샷이다.
- 그래서 같은 트랜잭션 안에서 몇 번을 읽어도 같은 결과가 나온다 → non-repeatable read 가 사라진다.
- 표준에선 Repeatable Read 가 phantom 을 허용하지만, PostgreSQL 의 Repeatable Read 는 phantom 까지 막는다. 스냅샷이 고정되어 있으니 조건에 맞는 새 행도 보이지 않기 때문이다.
여기서 첫 번째 큰 함정이 등장한다. 읽기 시점에 보이지 않던 행을, 다른 트랜잭션이 커밋한 뒤 같은 행을 갱신하려 하면 PostgreSQL 은 갱신 충돌을 감지하고 다음 에러를 던진다.
ERROR: could not serialize access due to concurrent update이건 SQLSTATE 40001(serialization_failure)이다. 즉 PostgreSQL 의 Repeatable Read 는 lost update 를 조용히 허용하지 않고, 충돌을 에러로 보고한다. 이 에러는 애플리케이션이 재시도해야 한다(뒤에서 다룬다).
Serializable — SSI
PostgreSQL 의 SERIALIZABLE 은 SSI(Serializable Snapshot Isolation) 로 구현된다. 스냅샷 격리 위에, 트랜잭션 간 읽기/쓰기 의존성을 추적해 "직렬 실행 순서로 설명할 수 없는" 위험한 패턴을 감지하는 계층을 얹은 것이다.
- 스냅샷 격리의 모든 보장(dirty/non-repeatable/phantom 차단)을 그대로 유지한다.
- 추가로 write skew 까지 막는다. 이게 Repeatable Read 와의 결정적 차이다.
- 위험한 의존성 사이클이 감지되면, 그중 한 트랜잭션을
40001로 중단시킨다. 락을 더 잡아 막는 게 아니라, 충돌을 사후 감지해 한쪽을 희생시키는 방식이다.
SSI 는 비관적 락 없이 높은 동시성을 유지하면서 진짜 직렬성을 보장한다는 점이 강력하다. 대가는 직렬화 실패가 더 자주 날 수 있고, 그때마다 재시도가 강제된다는 것이다.
격리 수준별 이상현상 차단표 (PostgreSQL 실제 동작)
표준 표가 아니라 PostgreSQL 의 실제 동작 기준이다.
| 이상현상 | Read Committed | Repeatable Read | Serializable |
|---|---|---|---|
| Dirty read | 차단 | 차단 | 차단 |
| Non-repeatable read | 허용 | 차단 | 차단 |
| Phantom read | 허용 | 차단 | 차단 |
| Lost update | 허용* | 충돌 시 40001 | 차단 |
| Write skew | 허용 | 허용 | 차단 |
* Read Committed 에서 lost update 는 자동으로 막히지 않는다. 명시적 락(SELECT ... FOR UPDATE)이나 단일 원자적 UPDATE 로 직접 막아야 한다.
이 표에서 가장 중요한 칸은 Repeatable Read 의 Write skew 가 "허용" 이라는 점이다. 많은 개발자가 "Repeatable Read 면 충분히 안전하겠지"라고 생각하지만, write skew 는 통과한다. 진짜 직렬성이 필요하면 Serializable 로 가야 한다.
Write skew 가 정확히 무엇인가
말로만 들으면 추상적이니 전형적인 예를 보자. "최소 한 명의 당직자는 항상 근무 상태여야 한다"는 불변식이 있다. 현재 두 명(A, B)이 근무 중이다. 두 사람이 동시에 "다른 사람이 근무 중이니 나는 빠져도 되겠지"라며 자기 상태를 off 로 바꾼다.
-- 트랜잭션 1 (A 가 빠지려 함)
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM staff WHERE on_call = true; -- 결과: 2, 괜찮네
UPDATE staff SET on_call = false WHERE id = 'A';
COMMIT;
-- 트랜잭션 2 (B 가 빠지려 함) — 거의 동시에
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM staff WHERE on_call = true; -- 결과: 2, 괜찮네
UPDATE staff SET on_call = false WHERE id = 'B';
COMMIT;두 트랜잭션은 서로 다른 행(A, B)을 갱신했다. PostgreSQL 의 Repeatable Read 는 같은 행 갱신 충돌만 감지하므로, 이 경우 둘 다 성공적으로 커밋된다. 결과는 근무자 0명 — 불변식이 깨졌다. 각 트랜잭션은 자기 스냅샷에서 "2명이니까 괜찮다"고 정당하게 판단했지만, 합치면 규칙 위반이다.
같은 시나리오를 SERIALIZABLE 로 올리면, SSI 가 두 트랜잭션의 읽기-쓰기 의존성이 직렬 순서로 설명 불가능함을 감지하고 한쪽을 40001 로 중단시킨다. 애플리케이션은 그 트랜잭션을 재시도하고, 재시도 시점엔 근무자가 1명임을 보고 자기 변경을 거부하게 된다.
비관적 락으로 lost update 막기
Serializable 과 재시도 루프가 부담스러운 흔한 케이스 — 계좌 잔액 차감, 재고 감소 — 는 명시적 행 잠금으로 더 단순하게 해결할 수 있다. SELECT ... FOR UPDATE 는 읽은 행에 쓰기 잠금을 걸어, 다른 트랜잭션이 그 행을 갱신하려면 현재 트랜잭션이 끝날 때까지 기다리게 만든다.
핵심은 이 패턴이 Read Committed(기본 격리 수준)에서도 안전하다는 것이다. 격리 수준을 올리지 않고도 lost update 를 막을 수 있다.
BEGIN; -- Read Committed (기본값)
-- 1. 행을 잠그고 읽는다. 다른 트랜잭션은 여기서 대기.
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- 애플리케이션: balance = 100, 인출 요청 = 30, 충분함을 확인
-- 2. 잠긴 상태에서 안전하게 차감
UPDATE accounts SET balance = balance - 30 WHERE id = 42;
COMMIT; -- 잠금 해제FOR UPDATE 없이 SELECT balance 로 읽고 애플리케이션에서 계산해 UPDATE 하면, 두 트랜잭션이 같은 잔액 100 을 읽고 각자 30 을 빼서 둘 다 70 으로 커밋하는 lost update 가 발생한다(실제로는 40 이 되어야 한다). FOR UPDATE 가 이 경합을 직렬화한다.
한 가지 주의: 단순 증감이라면 UPDATE accounts SET balance = balance - 30 WHERE id = 42 AND balance >= 30 같은 단일 원자적 UPDATE 로 락 없이 처리할 수 있다. UPDATE 문 자체가 행 잠금을 잡고 최신 값 위에서 연산하기 때문이다. FOR UPDATE 가 필요한 건 읽은 값으로 애플리케이션 레벨에서 분기/검증한 뒤 써야 할 때다.
추가로 알아둘 변형:
FOR UPDATE는 다른 트랜잭션이 잠금 해제될 때까지 기다린다. 기다리기 싫으면FOR UPDATE NOWAIT(즉시 에러) 또는FOR UPDATE SKIP LOCKED(잠긴 행 건너뛰기 — 작업 큐 패턴에 유용)를 쓴다.- 여러 행을 여러 트랜잭션이 서로 다른 순서로 잠그면 데드락이 난다. 항상 동일한 순서(예: id 오름차순)로 잠가야 한다. 데드락은 PostgreSQL 이 감지해 한쪽을
40P01로 중단시키므로, 이것도 재시도 대상이다.
40001 재시도 루프
Repeatable Read 의 갱신 충돌이든 Serializable 의 직렬화 실패든, PostgreSQL 은 충돌을 에러로 보고하고 트랜잭션을 중단시킨다. 이건 버그가 아니라 설계다. 높은 격리 수준을 쓰기로 했다면, 애플리케이션이 40001 을 잡아 트랜잭션 전체를 재시도하는 것이 계약의 일부다. 재시도하지 않으면 사용자에게 에러가 그대로 노출된다.
재시도는 트랜잭션 전체를 처음부터 다시 실행해야 한다. 중단된 트랜잭션의 일부 쿼리만 다시 보내는 건 의미가 없다 — 스냅샷이 무효화됐기 때문이다. 언어 무관 의사코드는 이렇다.
async function runSerializable<T>(
work: (tx: Transaction) => Promise<T>,
maxRetries = 5,
): Promise<T> {
for (let attempt = 0; ; attempt++) {
const tx = await db.begin("SERIALIZABLE");
try {
const result = await work(tx); // 트랜잭션 본문 전체
await tx.commit();
return result;
} catch (err) {
await tx.rollback();
// 40001: serialization_failure, 40P01: deadlock_detected
const code = (err as { code?: string }).code;
const retryable = code === "40001" || code === "40P01";
if (!retryable || attempt >= maxRetries) throw err;
// 동시 충돌이 몰릴 때 라이브락 방지: 지수 백오프 + 지터
const backoffMs = Math.min(2 ** attempt * 10, 1000) * Math.random();
await sleep(backoffMs);
}
}
}실전에서 빠뜨리기 쉬운 포인트들:
- 재시도 대상 코드를 정확히 잡아라.
40001(serialization_failure)과40P01(deadlock_detected)은 재시도하면 대개 성공한다. 반면23505(unique_violation) 같은 무결성 위반은 재시도해도 똑같이 실패하므로 잡으면 안 된다. work본문은 멱등하거나, 부수효과가 커밋 안에서만 일어나야 한다. 트랜잭션 중간에 외부 API 호출이나 이메일 발송 같은 비가역 부수효과를 넣으면, 재시도 때 중복 실행된다. 부수효과는 커밋 이후로 미뤄라.- 재시도 횟수에 상한을 둬라. 무한 재시도는 라이브락으로 이어진다. 지수 백오프 + 지터로 동시 충돌을 분산시킨다.
- 재시도는 비용이다. Serializable 의 충돌률이 높으면 전체 처리량이 떨어진다. 충돌이 잦은 핫스팟이라면 Serializable 대신
FOR UPDATE로 경합을 명시적으로 직렬화하는 게 더 빠를 수 있다.
실전 가이드: 무엇을 언제 쓰나
격리 수준 선택은 "가장 안전한 걸 항상 쓴다"가 아니라, 불변식의 성격에 맞춰 최소 비용으로 보장하는 것이다.
-
기본은 Read Committed. 대부분의 읽기, 단건 갱신, CRUD 는 이걸로 충분하다. 트랜잭션을 짧게 유지하는 게 가장 중요하다.
-
단일 행 카운터/잔액 증감은 원자적 UPDATE.
SET x = x - n WHERE id = ? AND x >= n처럼 한 문장으로 끝낼 수 있으면 락도 격리 수준 상향도 필요 없다. 영향받은 행 수(0이면 잔액 부족)로 성공 여부를 판단한다. -
읽은 값으로 분기한 뒤 같은 행을 갱신해야 하면
SELECT ... FOR UPDATE. 계좌 인출(잔액 확인 후 차감), 재고 차감(수량 확인 후 감소)이 전형적이다. Read Committed 에서도 안전하고, 동작이 직관적이라 디버깅이 쉽다. 단, 락 순서를 통일해 데드락을 피하라. -
여러 행에 걸친 불변식(write skew 위험)은 Serializable + 재시도. "최소 한 명은 근무", "회의실 시간대 중복 예약 금지", "특정 합계가 한도를 넘으면 안 됨" 처럼 서로 다른 행을 읽고 각자 쓰는데 합쳐서 규칙을 깨는 경우다. 이건
FOR UPDATE로 막기 어렵다(잠글 "그 행"이 명확하지 않거나, 삽입되는 행은 아직 없으니까). Serializable 이 SSI 로 이 위험을 감지해준다. 대신 반드시 재시도 루프를 짝지어라. -
장기 실행 읽기 일관성(리포트/일관된 백업)은 Repeatable Read. 여러 쿼리에 걸쳐 한 시점의 일관된 스냅샷이 필요한 읽기 전용 작업에 적합하다. 쓰기 충돌 걱정 없이 고정된 뷰를 얻는다.
마지막으로 몇 가지 공통 원칙:
- 트랜잭션은 짧게. 격리 수준이 높을수록 트랜잭션이 길면 충돌 확률과 잠금 보유 시간이 같이 늘어난다.
- 부수효과(외부 호출, 메일, 결제 게이트웨이)는 트랜잭션 밖으로. 재시도와 롤백을 안전하게 만든다.
- 높은 격리 수준을 선택했다면 재시도 루프는 선택이 아니라 의무다.
40001/40P01처리 없이 Serializable 이나 Repeatable Read 쓰기를 운영에 올리면 산발적 에러로 돌아온다.
정리
- PostgreSQL 은 SQL 표준 표를 그대로 따르지 않는다. 더티 리드는 어떤 수준에서도 없고, Read Uncommitted 는 Read Committed 로 동작한다.
- Read Committed(기본): 문장 단위 스냅샷. dirty read 만 차단. non-repeatable/phantom 허용. 대부분의 작업엔 충분.
- Repeatable Read: 트랜잭션 단위 스냅샷(스냅샷 격리). non-repeatable read 와 phantom 까지 차단. 단 write skew 는 못 막고, 갱신 충돌은
40001로 보고. - Serializable(SSI): write skew 포함 진짜 직렬성 보장. 대가로
40001직렬화 실패가 늘고, 재시도가 필수. - lost update 는
FOR UPDATE또는 원자적UPDATE로 Read Committed 에서도 막을 수 있다. 돈·재고처럼 읽고-판단하고-쓰는 불변식의 1순위 도구. - 여러 행에 걸친 불변식(write skew) 은 Serializable + 재시도가 정석이다.
격리 수준은 만능 스위치가 아니라, 지키려는 불변식의 모양에 맞춰 고르는 도구다. 단일 행 경합엔 락, 여러 행에 걸친 규칙엔 직렬성, 그 외엔 기본값. 그리고 높은 격리를 골랐다면 재시도는 항상 함께 간다.