본문으로 건너뛰기
wooncloud

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

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

·24 min read· views·

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

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

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

여기서 첫 번째 큰 함정이 등장한다. 읽기 시점에 보이지 않던 행을, 다른 트랜잭션이 커밋한 뒤 같은 행을 갱신하려 하면 PostgreSQL 은 갱신 충돌을 감지하고 다음 에러를 던진다.

ERROR:  could not serialize access due to concurrent update

이건 SQLSTATE 40001(serialization_failure)이다. 즉 PostgreSQL 의 Repeatable Read 는 lost update 를 조용히 허용하지 않고, 충돌을 에러로 보고한다. 이 에러는 애플리케이션이 재시도해야 한다(뒤에서 다룬다).

Serializable — SSI

PostgreSQL 의 SERIALIZABLESSI(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 + 재시도가 정석이다.

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