---
title: "PostgreSQL 트랜잭션 격리 수준 실전 정리"
description: "PostgreSQL의 트랜잭션 격리 수준은 SQL 표준과 다르며 MVCC 기반으로 동작합니다. 기본값인 Read Committed를 넘어 데이터 정합성을 위해 Repeatable Read나 Serializable을 선택할 때는 각 수준의 이상 현상 차단 범위와 재시도 로직 구현 등 실무적인 설계 원칙을 반드시 이해해야 합니다"
date: 2026-06-01
updated: 2026-06-01T09:00:00.000Z
tags: [postgresql, database, transaction, backend]
canonical: https://blog.wooncloud.com/posts/postgresql-transaction-isolation
---

![PostgreSQL 트랜잭션 격리 수준 실전 정리](/images/posts/postgresql-transaction-isolation/176ffaa1-9fcc-4e90-b8f8-135519971223.webp)

## 왜 격리 수준을 알아야 하는가

데이터베이스에 트랜잭션이 하나뿐이라면 격리 수준은 고민할 필요가 없다. 문제는 항상 동시성에서 생긴다. 같은 행(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 은 갱신 충돌을 감지하고 다음 에러를 던진다.

```text
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 로 바꾼다.

```sql
-- 트랜잭션 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 를 막을 수 있다.

```sql
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` 을 잡아 트랜잭션 전체를 재시도하는 것**이 계약의 일부다. 재시도하지 않으면 사용자에게 에러가 그대로 노출된다.

재시도는 트랜잭션 **전체**를 처음부터 다시 실행해야 한다. 중단된 트랜잭션의 일부 쿼리만 다시 보내는 건 의미가 없다 — 스냅샷이 무효화됐기 때문이다. 언어 무관 의사코드는 이렇다.

```ts
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` 로 경합을 명시적으로 직렬화하는 게 더 빠를 수 있다.

## 실전 가이드: 무엇을 언제 쓰나

격리 수준 선택은 "가장 안전한 걸 항상 쓴다"가 아니라, **불변식의 성격에 맞춰 최소 비용으로 보장하는 것**이다.

1. **기본은 Read Committed.** 대부분의 읽기, 단건 갱신, CRUD 는 이걸로 충분하다. 트랜잭션을 짧게 유지하는 게 가장 중요하다.

2. **단일 행 카운터/잔액 증감은 원자적 UPDATE.** `SET x = x - n WHERE id = ? AND x >= n` 처럼 한 문장으로 끝낼 수 있으면 락도 격리 수준 상향도 필요 없다. 영향받은 행 수(0이면 잔액 부족)로 성공 여부를 판단한다.

3. **읽은 값으로 분기한 뒤 같은 행을 갱신해야 하면 `SELECT ... FOR UPDATE`.** 계좌 인출(잔액 확인 후 차감), 재고 차감(수량 확인 후 감소)이 전형적이다. Read Committed 에서도 안전하고, 동작이 직관적이라 디버깅이 쉽다. 단, 락 순서를 통일해 데드락을 피하라.

4. **여러 행에 걸친 불변식(write skew 위험)은 Serializable + 재시도.** "최소 한 명은 근무", "회의실 시간대 중복 예약 금지", "특정 합계가 한도를 넘으면 안 됨" 처럼 **서로 다른 행을 읽고 각자 쓰는데 합쳐서 규칙을 깨는** 경우다. 이건 `FOR UPDATE` 로 막기 어렵다(잠글 "그 행"이 명확하지 않거나, 삽입되는 행은 아직 없으니까). Serializable 이 SSI 로 이 위험을 감지해준다. 대신 반드시 재시도 루프를 짝지어라.

5. **장기 실행 읽기 일관성(리포트/일관된 백업)은 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 + 재시도가 정석이다.

격리 수준은 만능 스위치가 아니라, 지키려는 불변식의 모양에 맞춰 고르는 도구다. 단일 행 경합엔 락, 여러 행에 걸친 규칙엔 직렬성, 그 외엔 기본값. 그리고 높은 격리를 골랐다면 재시도는 항상 함께 간다.
