본문으로 건너뛰기
wooncloud

PostgreSQL 트랜잭션 격리 수준 실전 정리

PostgreSQL 의 Repeatable Read 는 스냅샷 격리로 phantom read 까지 막지만 write skew 는 허용하며, 이를 막으려면 Serializable 로 올리고 40001 재시도 로직을 짝지어야 합니다.

·26 min read·— views··

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

데이터베이스에 트랜잭션이 하나뿐이라면 격리 수준은 고민할 필요가 없습니다. 문제는 항상 동시성에서 생깁니다. 같은 행(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 readNon-repeatable readPhantom 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 까지 막습니다. 스냅샷이 고정되어 있으니 조건에 맞는 새 행도 보이지 않기 때문입니다.

여기서 첫 번째 큰 함정이 등장합니다. 내 스냅샷에는 보이던 행을 그 사이 다른 트랜잭션이 갱신·삭제하고 커밋했다면, 같은 행을 갱신·삭제하거나 SELECT ... FOR UPDATE 로 잠그려는 순간 PostgreSQL 이 충돌을 감지하고 다음 에러를 던집니다. (반대로 내 스냅샷 이후에 삽입된 행은 애초에 보이지 않아 대상으로 잡히지도 않으므로, 에러가 아니라 0행 처리입니다.)

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

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

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

  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 + 재시도가 정석입니다.

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