← 블로그로 돌아가기

PostgreSQL의 트랜잭션 격리 수준

데이터베이스에서 트랜잭션이란 “여러 작업을 하나의 논리적 단위로 묶은 것"이다. 계좌이체를 생각하면 된다. A 계좌에서 빼고, B 계좌에 넣는다. 둘 중 하나만 되면 안 되니까 묶는다.

근데 문제는, 이런 트랜잭션이 동시에 여러 개 돌아갈 때다. 혼자 쓰면 아무 문제 없다. 여럿이 동시에 같은 데이터를 건드리기 시작하면 이상한 일이 벌어진다. 트랜잭션 격리 수준(Isolation Level)은 이 “이상한 일"을 얼마나 허용할 것인가에 대한 설정이다.

먼저, 어떤 “이상한 일"이 벌어지는가?

격리 수준을 이해하려면, 그게 막아주는 문제들을 먼저 알아야 한다. SQL 표준이 정의하는 이상 현상(anomaly)은 크게 세 가지고, PostgreSQL은 여기에 하나를 더 추가한다.

1. Dirty Read (더티 리드)

다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽는 것이다.

-- Transaction A
UPDATE accounts SET balance = 0 WHERE id = 1; -- 아직 COMMIT 안 함

-- Transaction B
SELECT balance FROM accounts WHERE id = 1; -- 0이 읽힌다? 아직 확정도 안 된 건데?

-- Transaction A
ROLLBACK; -- A는 취소됨. B가 읽은 0은 존재한 적 없는 값

B는 “현실에 존재한 적 없는 데이터"를 근거로 뭔가를 했을 수도 있다. 유령을 본 셈이다.

PostgreSQL에서는 이게 절대 발생하지 않는다. 이유는 뒤에서 설명한다.

2. Non-Repeatable Read (반복 불가능한 읽기)

같은 트랜잭션 안에서 같은 쿼리를 두 번 날렸는데, 결과가 달라지는 것이다.

-- Transaction A
SELECT balance FROM accounts WHERE id = 1; -- 1000원

-- Transaction B
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;

-- Transaction A
SELECT balance FROM accounts WHERE id = 1; -- 500원?! 아까 1000원이었는데?

한 트랜잭션 안에서 같은 질문을 했는데 답이 바뀐다. “내가 아까 확인했을 때는 1000원이었는데?“라는 혼란이 생긴다. 이미 읽은 행의 값이 중간에 바뀌는 것이다.

3. Phantom Read (팬텀 리드)

Non-Repeatable Read와 비슷하지만, 행의 이 바뀌는 게 아니라 행 자체가 생기거나 사라지는 것이다.

-- Transaction A:
SELECT count(*) FROM orders WHERE status = 'pending';
-- 5건

-- Transaction B:
INSERT INTO orders (status) VALUES ('pending');
COMMIT;

-- Transaction A:
SELECT count(*) FROM orders WHERE status = 'pending';
-- 6건?! 유령(phantom)이 나타남

Non-Repeatable Read가 “같은 사람인데 옷이 바뀌었네?“라면, Phantom Read는 “아까 없던 사람이 갑자기 나타났는데?“다.

4. Serialization Anomaly (직렬화 이상)

SQL 표준에는 없고, PostgreSQL이 추가로 다루는 문제다. 각 트랜잭션은 개별적으로 보면 올바른데, 동시에 실행한 결과가 어떤 순서로 하나씩 실행해도 나올 수 없는 결과가 되는 것이다.

-- 테이블: 현재 (class = 1, value = 10), (class = 2, value = 20)

-- Transaction A:
INSERT INTO mytab (class, value)
SELECT 1, SUM(value) FROM mytab WHERE class = 2;
             -- class=2의 합(20)을 class=1로 삽입

-- Transaction B:
INSERT INTO mytab (class, value)
SELECT 2, SUM(value) FROM mytab WHERE class = 1;
             -- class=1의 합(10)을 class=2로 삽입

A를 먼저 하고 B를 했다면? A가 (1, 20)을 넣고, B가 class=1의 합(10+20=30)인 (2, 30)을 넣는다. B를 먼저 하고 A를 했다면? 반대로 (2, 10), (1, 30)이 된다. 어느 쪽이든 합계가 30인 행이 하나 생겨야 한다.

그런데 동시에 실행하면? 둘 다 상대의 INSERT를 보지 못하고, (1, 20)과 (2, 10)이 동시에 들어간다. 이건 A 먼저든 B 먼저든, 하나씩 실행했으면 절대 나올 수 없는 결과다.

PostgreSQL의 격리 수준 네 가지

SQL 표준은 네 단계를 정의한다. PostgreSQL은 이 네 가지를 모두 문법적으로 지원하되, 실제 동작은 세 단계다.

격리 수준 Dirty Read Non-Repeatable Read Phantom Read Serialization Anomaly
Read Uncommitted PG에서는 불가 가능 가능 가능
Read Committed 불가 가능 가능 가능
Repeatable Read 불가 불가 PG에서는 불가 가능
Serializable 불가 불가 불가 불가

Read Uncommitted (= 사실상 Read Committed)

PostgreSQL에서 SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED를 하면, 에러 없이 받아들인다. 하지만 실제로는 Read Committed와 동일하게 동작한다. Dirty Read를 허용하지 않는다.

왜냐하면 PostgreSQL은 MVCC(Multi-Version Concurrency Control)를 쓰기 때문이다. 모든 트랜잭션은 데이터의 “스냅샷"을 보는 구조라서, 커밋되지 않은 변경은 구조적으로 보이지 않는다. Dirty Read를 일부러 구현하려면 오히려 추가 작업이 필요한 셈이다.

결론: PostgreSQL에서 이 레벨을 쓸 이유는 없다. Read Committed와 완전히 같다.

Read Committed (PostgreSQL 기본값)

각 쿼리(statement)가 실행되는 시점에 커밋된 데이터만 본다.

“각 쿼리마다"가 핵심이다. 트랜잭션 시작 시점이 아니라, 트랜잭션 안에서 각각의 SELECT, UPDATE 등이 실행되는 그 순간에 스냅샷을 새로 찍는다.

BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 시점 A의 스냅샷
-- 여기서 다른 트랜잭션이 balance를 바꾸고 커밋하면
SELECT balance FROM accounts WHERE id = 1;  -- 시점 B의 스냅샷, 값이 다를 수 있음
COMMIT;

Non-Repeatable Read와 Phantom Read가 발생할 수 있다. 하지만 실무에서 대부분의 경우는 이 정도면 충분하다. 그래서 PostgreSQL의 기본값이다.

Read Committed에서 주의해야 할 동작이 있다. UPDATE나 DELETE가 대상 행을 찾았는데, 그 행이 다른 트랜잭션에 의해 이미 수정 중(아직 커밋 안 됨)이라면, 현재 트랜잭션은 그 트랜잭션이 끝날 때까지 기다린다.

-- 트랜잭션 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 아직 COMMIT 안 함

-- 트랜잭션 B
BEGIN;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
-- A가 끝날 때까지 여기서 블록됨

A가 커밋하면? B는 A의 결과 위에 자기 작업을 적용한다. A가 롤백하면? B는 원래 값에 자기 작업을 적용한다. 이 “기다렸다가 재평가” 동작은 Read Committed에서 데이터 무결성을 지키는 핵심 메커니즘이다.

그런데 더 복잡한 경우가 있다. UPDATE에 WHERE 조건이 있을 때:

-- 트랜잭션 A
UPDATE accounts SET balance = 0 WHERE balance > 500;
COMMIT;

-- 트랜잭션 B (A와 동시에 시작, A를 기다린 후)
UPDATE accounts SET status = 'rich' WHERE balance > 500;
-- A가 balance를 0으로 바꿔버렸으니, WHERE 조건에 맞는 행이 없을 수도!

B는 처음에 WHERE 조건에 맞는 행을 찾았지만, A를 기다린 후 다시 평가하면 조건이 안 맞을 수 있다. 이런 미묘한 동작 때문에, 정합성이 중요한 비즈니스 로직에서는 Read Committed만으로 부족할 수 있다.

Repeatable Read

트랜잭션 시작 시점에 커밋된 데이터만 본다. 트랜잭션 전체가 하나의 스냅샷을 공유한다.

Read Committed가 “쿼리마다 새 스냅샷"이라면, Repeatable Read는 “트랜잭션 전체가 하나의 스냅샷"이다.

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;  -- 1000원 (트랜잭션 시작 시점)
-- 다른 트랜잭션이 balance를 500으로 바꾸고 커밋해도
SELECT balance FROM accounts WHERE id = 1;  -- 여전히 1000원
COMMIT;

같은 쿼리는 항상 같은 결과를 돌려준다. Non-Repeatable Read가 방지된다.

SQL 표준에서는 Repeatable Read에서 Phantom Read가 허용된다. 하지만 PostgreSQL의 MVCC 구현에서는 스냅샷 자체가 트랜잭션 시작 시점에 고정되므로, Phantom Read도 발생하지 않는다. 이건 PostgreSQL이 표준보다 더 강한 보장을 주는 경우다.

대신 다른 문제가 생긴다. 내가 보고 있는 스냅샷의 데이터를 수정하려는데, 그 사이에 다른 트랜잭션이 이미 수정하고 커밋했다면?

-- 트랜잭션 A
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

-- 트랜잭션 B (A와 동시에 시작)
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
-- A가 먼저 커밋했다면, 여기서 에러 발생!
-- ERROR: could not serialize access due to concurrent update

Read Committed에서는 “기다렸다가 재평가"했지만, Repeatable Read에서는 에러를 던진다. “너의 스냅샷은 이미 현실과 달라졌으니, 처음부터 다시 해"라는 뜻이다.

이 말은 애플리케이션 코드에서 이 에러를 잡아서 트랜잭션을 재시도하는 로직이 반드시 필요하다는 뜻이다.

async function transferMoney(fromId, toId, amount) {
    while (true) {
        try {
            await db.query('BEGIN ISOLATION LEVEL REPEATABLE READ');
            // ... 비즈니스 로직 ...
            await db.query('COMMIT');
            return; // 성공
        } catch (err) {
            await db.query('ROLLBACK');
            if (err.code === '40001') { // serialization_failure
                continue; // 재시도
            }
            throw err; // 다른 에러는 진짜 에러
        }
    }
}

Serializable

가장 강력한 격리 수준이다. 동시에 실행한 결과가, 하나씩 순서대로 실행한 것과 반드시 같음을 보장한다. 앞서 본 Serialization Anomaly까지 막아준다.

PostgreSQL은 이걸 SSI(Serializable Snapshot Isolation)라는 기법으로 구현한다. 기본적으로 Repeatable Read와 같은 스냅샷을 쓰되, 트랜잭션들 사이의 읽기/쓰기 의존성을 추적한다. 직렬화 불가능한 패턴이 감지되면 트랜잭션 하나를 강제로 실패시킨다.

-- 아까의 Serialization Anomaly 예시
-- Serializable에서는 둘 중 하나가 이렇게 실패한다:
-- ERROR: could not serialize access due to read/write dependencies
--        among transactions

Serializable은 “정확성"에 대해 생각할 필요가 없어진다는 점에서 매력적이다. 각 트랜잭션을 혼자 돌아가는 것처럼 짜면 된다. 하지만 대가가 있다:

  • 재시도 로직이 필수다. Repeatable Read보다 더 자주 직렬화 실패가 발생한다.
  • 성능 오버헤드가 있다. 의존성 추적에 메모리와 CPU를 쓴다.
  • 모든 동시 트랜잭션이 Serializable이어야 의미가 있다. 하나라도 Read Committed면, 그 트랜잭션과의 상호작용에서는 Serializable 보장이 깨진다.

MVCC: PostgreSQL이 이 모든 걸 구현하는 방법

격리 수준의 동작을 이해하려면, PostgreSQL의 MVCC를 최소한 개념적으로는 알아야 한다.

핵심 아이디어는 간단하다. 행을 수정할 때 원본을 덮어쓰지 않고, 새 버전을 만든다. 각 행에는 “이 버전을 만든 트랜잭션 ID”(xmin)와 “이 버전을 삭제/교체한 트랜잭션 ID”(xmax)가 붙는다.

xmin=100, xmax=∞    →  (id=1, balance=1000)   -- 트랜잭션 100이 만듦, 아직 유효
xmin=100, xmax=105  →  (id=1, balance=1000)   -- 트랜잭션 105가 교체함, 더 이상 최신 아님
xmin=105, xmax=∞    →  (id=1, balance=500)    -- 트랜잭션 105가 만든 새 버전

각 트랜잭션은 자신의 스냅샷에 따라 “어떤 버전이 보이는지"를 결정한다. Read Committed는 쿼리마다 “지금 커밋된 것 중 최신"을, Repeatable Read는 트랜잭션 시작 시점의 “그때 커밋된 것 중 최신"을 본다.

이 구조 덕분에 읽기는 쓰기를 블록하지 않고, 쓰기는 읽기를 블록하지 않는다. 각자 자기 버전을 보면 되니까. 락(lock) 기반 동시성 제어와 비교하면 이게 MVCC의 가장 큰 장점이다.

대신 오래된 버전(dead tuple)이 쌓이므로, VACUUM으로 정리해줘야 한다. 이건 MVCC의 대가다.

실무에서 어떻게 고르나?

대부분의 경우 Read Committed(기본값)로 충분하다. 단순한 CRUD, 독립적인 쿼리들, 한 트랜잭션 안에서 이전 읽기 결과에 의존하지 않는 경우.

Repeatable Read는 한 트랜잭션 안에서 여러 쿼리의 일관성이 중요할 때 쓴다. 리포트 생성이 대표적이다. “주문 총액"과 “주문 건수"를 따로 쿼리하는데, 그 사이에 주문이 추가되면 총액/건수가 안 맞는다. Repeatable Read면 둘 다 같은 시점의 데이터를 보니까 일관성이 보장된다.

Serializable은 정확성이 극도로 중요한 금융 트랜잭션이나, 복잡한 비즈니스 규칙에서 동시성 버그를 원천 차단하고 싶을 때 쓴다. 다만 재시도 로직 구현과 성능 비용을 감수해야 한다.

정리하면 이런 트레이드오프다:

정확성 보장     Read Committed < Repeatable Read < Serializable
성능 / 처리량   Read Committed > Repeatable Read > Serializable
구현 복잡도     Read Committed < Repeatable Read < Serializable
                (재시도 로직 필요 여부)

격리 수준을 올린다고 무조건 느려지는 건 아니다. 충돌이 적은 워크로드에서는 Serializable도 Read Committed와 비슷한 성능이 나올 수 있다. 느려지는 건 충돌이 잦을 때다. 충돌이 발생하면 트랜잭션을 실패시키고 재시도해야 하니까.

핵심은 결국 이것이다. 격리 수준은 “동시에 실행되는 트랜잭션들이 서로를 얼마나 모른 척할 것인가"에 대한 선택이다. 더 모른 척할수록(격리 수준이 높을수록) 각 트랜잭션은 편해지지만, 시스템 전체는 더 많은 비용을 지불한다.