컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
db · 11

트랜잭션 격리 수준

이상현상격리수준트랜잭션

트랜잭션 격리 수준(isolation level)은 동시에 실행되는 트랜잭션들이 서로의 변경을 얼마나 볼 수 있는지를 정하는 설정입니다. ACID의 I(Isolation)를 얼마나 엄격하게 지킬지 고르는 손잡이이며, 엄격할수록 이상 현상은 줄지만 충돌을 다루는 비용이 늘 수 있습니다.

SQL 표준은 Dirty Read, Non-repeatable Read, Phantom Read 세 가지 이상 현상을 기준으로 Read Uncommitted, Read Committed, Repeatable Read, Serializable 네 단계를 정의합니다. 이 토픽에서는 각 이상 현상이 두 트랜잭션의 어떤 순서에서 생기는지와, 격리 수준이 이를 어떻게 막는지를 타임라인으로 확인합니다.

핵심 개념

Dirty Read

다른 트랜잭션이 아직 커밋하지 않은 변경을 읽는 현상입니다. 그 트랜잭션이 롤백되면 읽은 값은 존재한 적 없는 값이 됩니다.

핵심 개념

Non-repeatable Read

한 트랜잭션이 같은 행을 두 번 읽는 사이 다른 트랜잭션이 그 행을 수정하고 커밋해서 두 번째 값이 달라지는 현상입니다.

핵심 개념

Phantom Read

같은 조건으로 두 번 조회하는 사이 다른 트랜잭션이 조건에 맞는 행을 추가하거나 삭제해 결과의 행 집합이 달라지는 현상입니다.

핵심 개념

Read Committed

커밋된 데이터만 읽고, 각 문장이 실행되는 시점의 최신 커밋 상태를 봅니다. PostgreSQL의 기본 격리 수준입니다.

핵심 개념

Repeatable Read

트랜잭션의 첫 쿼리 시점에 잡은 스냅샷을 트랜잭션 내내 봅니다. 같은 행을 다시 읽어도 값이 같고, 충돌하는 수정은 직렬화 실패로 중단될 수 있습니다.

핵심 개념

Serializable

트랜잭션들을 한 번에 하나씩 순서대로 실행한 것과 같은 결과를 보장하는 가장 높은 수준입니다. PostgreSQL에서는 충돌이 감지되면 재시도가 필요합니다.

수준이 높을수록 이상 현상을 더 막습니다 — 표준 기준으로 Read Uncommitted는 세 현상이 모두 가능하고, Read Committed는 Dirty Read를, Repeatable Read는 Non-repeatable Read까지, Serializable은 Phantom Read까지 막습니다.
데이터베이스마다 구현이 다릅니다 — PostgreSQL은 Read Uncommitted를 Read Committed처럼 처리하고 Repeatable Read에서도 팬텀을 막습니다. MySQL InnoDB의 기본 격리 수준은 Repeatable Read입니다.
비용은 충돌 처리에서 나옵니다 — 높은 수준에서는 대기가 늘거나, PostgreSQL처럼 직렬화 실패(SQLSTATE 40001)가 나서 트랜잭션을 처음부터 재시도해야 합니다.
표에 없는 이상 현상도 있습니다 — 갱신 손실(lost update)이나 쓰기 비대칭(write skew)처럼 세 가지 이상 현상만으로는 설명되지 않는 문제가 있어, 필요하면 행 잠금이나 Serializable을 함께 고려합니다.
이 시각화에서 확인할 것 — "Dirty Read", "Non-repeatable Read", "Phantom Read" 탭에서 T1과 T2의 순서에 따라 T1의 읽기 결과가 격리 수준별로 어떻게 갈리는지를, "격리 수준 표" 탭에서 네 단계와 PostgreSQL의 동작을 STEP으로 확인하세요.

격리 수준의 배경

동시성 제어 이론에서 트랜잭션이 하나씩 순서대로 실행된 것과 같은 결과를 내는 성질을 직렬 가능성(serializability)이라 합니다. 이를 항상 지키면 동시성이 떨어지기 때문에 SQL 표준은 더 약한 격리 수준을 두고 어떤 이상 현상을 허용할지로 단계를 나눴습니다.

1995년 Berenson 등은 "A Critique of ANSI SQL Isolation Levels"에서 표준의 이상 현상 정의가 모호하다고 지적하고, 스냅샷 격리(snapshot isolation)라는 수준과 쓰기 비대칭 같은 현상을 설명했습니다. PostgreSQL의 Repeatable Read가 스냅샷 격리에 해당하며, 9.1부터는 직렬화 스냅샷 격리(SSI)로 Serializable을 구현합니다.

숫자로 확인하기

Dirty Read — 잔액 1000을 T2가 500으로 바꾸고 커밋 전에 T1이 읽으면 표준 기준 Read Uncommitted에서는 500, Read Committed 이상에서는 1000입니다(PostgreSQL은 Read Uncommitted도 1000). T2가 롤백하면 500은 확정된 적 없는 값입니다.
Non-repeatable Read — T1이 잔액을 두 번 읽는 사이 T2가 500으로 바꾸고 커밋하면 Read Committed에서는 (1000, 500), Repeatable Read 이상에서는 (1000, 1000)입니다.
Phantom Read — 금액이 100을 넘는 행이 2개일 때 T2가 200인 행을 추가하고 커밋하면 Read Committed의 두 번째 조회는 3, PostgreSQL의 Repeatable Read는 2입니다.
표준 표 — 4단계 × 3현상 12칸 중 발생 가능한 칸은 Read Uncommitted 3개, Read Committed 2개, Repeatable Read 1개, Serializable 0개로 모두 6개입니다.
JDBC 상수 — Connection.TRANSACTION_READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE의 값은 1, 2, 4, 8입니다.
코드 확인 — 파이썬 장난감 모델이 RU 500 (1000, 500), RC 1000 (1000, 500), RR 1000 (1000, 1000)을 출력하고, 이는 시각화의 Dirty Read와 Non-repeatable Read 결과와 같습니다.

자주 하는 오해

PostgreSQL에서 Read Uncommitted를 쓰면 Dirty Read가 난다 — PostgreSQL은 Read Uncommitted를 Read Committed처럼 처리하므로 Dirty Read가 일어나지 않습니다.
Repeatable Read는 팬텀을 항상 허용한다 — SQL 표준에서는 허용될 수 있다는 것이고, PostgreSQL의 Repeatable Read는 스냅샷을 쓰므로 팬텀도 보이지 않습니다.
Serializable은 트랜잭션을 한 줄로 세워 실행한다 — 동시에 실행하되 결과가 순서대로 실행한 것과 같도록 보장하는 것이고, PostgreSQL은 충돌을 감지해 일부 트랜잭션을 중단시키는 방식으로 구현합니다.
높은 격리 수준이 항상 낫다 — 이상 현상은 줄지만 재시도와 대기 비용이 늘고, 업무가 견딜 수 있는 현상까지 막을 필요는 없을 수 있습니다.
Dirty Read만 막으면 안전하다 — 갱신 손실이나 쓰기 비대칭 같은 현상은 Read Committed에서 남을 수 있어, 읽은 값을 근거로 쓰는 로직에는 행 잠금(SELECT ... FOR UPDATE)이나 더 높은 수준이 필요할 수 있습니다.

면접에서 이렇게 물어요

면접 질문

세 가지 이상 현상을 설명해 보세요.

Dirty Read는 미커밋 값을 읽는 것, Non-repeatable Read는 같은 행을 두 번 읽었는데 값이 바뀌는 것, Phantom Read는 같은 조건으로 조회했는데 행이 늘거나 줄어드는 것입니다.

면접 질문

Read Committed와 Repeatable Read의 차이는?

PostgreSQL에서 Read Committed는 문장마다 새 스냅샷을 봐서 같은 행을 다시 읽으면 값이 달라질 수 있고, Repeatable Read는 트랜잭션 동안 같은 스냅샷을 봅니다.

면접 질문

PostgreSQL의 기본 격리 수준은?

Read Committed입니다. 커밋된 데이터만 읽고 문장마다 그 시점의 최신 커밋 상태를 봅니다.

면접 질문

직렬화 실패가 나면 어떻게 하나요?

데이터가 깨진 것이 아니라 동시 실행 순서를 맞추기 위해 한 트랜잭션이 되돌려진 것이므로, 트랜잭션 전체를 새로 시작해 재시도합니다.

활용 분야

일반 웹 업무 처리 — PostgreSQL 문서는 Read Committed가 많은 애플리케이션에 충분하지만 모든 경우에 충분하지는 않다고 설명합니다. 이체나 재고 차감처럼 읽은 값을 근거로 쓰는 구간에는 행 잠금이나 높은 수준이 필요할 수 있습니다.
긴 읽기 작업 — 여러 쿼리가 같은 시점의 데이터를 봐야 하면 Repeatable Read 이상의 일관된 스냅샷이 필요합니다.
충돌이 잦은 쓰기 — 높은 수준을 쓸 때는 직렬화 실패를 잡아 재시도하는 로직이 애플리케이션에 필요합니다.

함께 보면 좋은 토픽

트랜잭션 — ACID와 커밋, 롤백을 봅니다.
락 — 잠금으로 동시 접근을 제어하는 방법을 봅니다.
MVCC — 스냅샷 읽기를 가능하게 하는 다중 버전 관리를 봅니다.
복제 — 여러 노드가 같은 데이터를 유지할 때의 일관성 문제와 이어집니다.

참고 자료

공식 문서 — Java Connection
논문 — Berenson, H., Bernstein, P., Gray, J., Melton, J., O'Neil, E., O'Neil, P., "A Critique of ANSI SQL Isolation Levels", ACM SIGMOD, 1995
논문 — Ports, D. R. K., Grittner, K., "Serializable Snapshot Isolation in PostgreSQL", Proceedings of the VLDB Endowment 5(12), 2012

SQL — PostgreSQL에서 격리 수준 지정과 확인

SHOW transaction_isolation;                          -- read committed (기본값)

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM account WHERE id = 1;         -- 1000
-- (다른 세션이 balance를 500으로 UPDATE하고 COMMIT)
SELECT balance FROM account WHERE id = 1;         -- 1000 (트랜잭션 동안 같은 스냅샷)
COMMIT;

BEGIN;                                         -- 기본 Read Committed
SELECT balance FROM account WHERE id = 1;         -- 1000
-- (다른 세션이 balance를 500으로 UPDATE하고 COMMIT)
SELECT balance FROM account WHERE id = 1;         -- 500 (문장마다 새 스냅샷)
COMMIT;

BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 충돌이 감지되면 ERROR (SQLSTATE 40001, 직렬화 실패) → 트랜잭션을 처음부터 다시 실행

PostgreSQL은 BEGIN에 ISOLATION LEVEL을 붙이거나 SET TRANSACTION으로 트랜잭션마다 격리 수준을 정합니다. 따로 정하지 않으면 Read Committed입니다. 위 두 번째 예처럼 Read Committed에서는 같은 행을 두 번 읽는 사이에 다른 세션이 커밋하면 두 번째 읽기가 새 값 500을 봅니다. 첫 번째 예의 Repeatable Read는 트랜잭션 동안 같은 스냅샷을 유지하므로 두 번 모두 1000입니다. Repeatable Read와 Serializable에서는 다른 트랜잭션과 충돌하는 수정이 직렬화 실패(SQLSTATE 40001)로 중단될 수 있습니다. 이때 오류를 무시하지 않고 트랜잭션 전체를 다시 실행해야 합니다. 시각화의 Non-repeatable Read 탭과 격리 수준 표 탭이 보여 주는 동작과 같습니다.

Python — 스냅샷 읽기를 흉내 낸 장난감 모델

class Store:
    def __init__(self, value):
        self.committed = [(0, value)]        # (커밋 시각, 값) 버전 목록
        self.pending = None                  # 아직 커밋하지 않은 변경
        self.clock = 0

    def write(self, value): self.pending = value
    def rollback(self): self.pending = None
    def commit(self):
        self.clock += 1
        self.committed.append((self.clock, self.pending))
        self.pending = None

    def read(self, level, snapshot=None):
        if level == 'RU' and self.pending is not None:
            return self.pending              # 커밋되지 않은 값도 읽는다
        ts = snapshot if level == 'RR' and snapshot is not None else self.clock
        return max(v for v in self.committed if v[0] <= ts)[1]

for level in ('RU', 'RC', 'RR'):
    s = Store(1000); s.write(500)
    dirty = s.read(level); s.rollback()           # 롤백될 값을 읽었나
    s = Store(1000); snap = s.clock
    first = s.read(level, snap)
    s.write(500); s.commit()
    second = s.read(level, snap)             # 사이에 커밋된 변경이 보이나
    print(level, dirty, (first, second))
# RU 500 (1000, 500) / RC 1000 (1000, 500) / RR 1000 (1000, 1000)

데이터베이스 엔진이 아니라 개념을 보여 주는 장난감 모델입니다. 값마다 (커밋 시각, 값) 버전을 쌓아 두고, 읽을 때 어느 시각의 커밋까지 볼지가 격리 수준마다 다르다는 점만 흉내 냅니다. RU는 아직 커밋되지 않은 pending 값까지 읽고, RC는 읽는 시점의 최신 커밋을 읽으며, RR은 트랜잭션이 시작할 때 잡은 스냅샷 시각까지의 커밋만 읽습니다. 출력의 첫 값은 롤백될 변경을 읽었는지입니다. RU만 500(Dirty Read)을 읽고 나머지는 1000입니다. 둘째 값은 같은 행을 두 번 읽은 결과로, RC는 (1000, 500)으로 값이 달라지고(Non-repeatable Read) RR은 (1000, 1000)으로 같습니다. 실제 엔진의 잠금과 버전 관리는 훨씬 복잡합니다. 시각화의 Dirty Read, Non-repeatable Read 탭과 같은 결과입니다.

Java — JDBC와 Spring에서 격리 수준 지정

try (Connection conn = dataSource.getConnection()) {
    conn.setAutoCommit(false);
    conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
    // ... 쿼리 실행 ...
    conn.commit();
} catch (SQLException e) {
    if ("40001".equals(e.getSQLState())) {
        // 직렬화 실패: 트랜잭션 전체를 다시 시도한다
    }
}

// Spring: @Transactional(isolation = Isolation.REPEATABLE_READ)
// Connection.TRANSACTION_READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE 상수 값 → 1, 2, 4, 8

JDBC는 java.sql.Connection의 setTransactionIsolation에 TRANSACTION_ 상수를 넘겨 격리 수준을 정합니다. 네 상수의 값은 각각 1, 2, 4, 8입니다. 스프링에서는 @Transactional의 isolation 속성으로 같은 일을 하고, 지정하지 않으면 데이터베이스의 기본값을 따릅니다. 직렬화 실패는 SQLSTATE 40001로 올라옵니다. 이 오류는 데이터가 잘못되었다는 뜻이 아니라 동시 실행 순서를 맞추기 위해 데이터베이스가 한 트랜잭션을 되돌렸다는 신호이므로, 업무 로직 전체를 새 트랜잭션으로 재시도하도록 만들어야 합니다.

Q1

Dirty Read란?

"더러운" 데이터 — 확정되지 않은 값입니다.
Dirty Read는 다른 트랜잭션이 아직 커밋하지 않은(롤백될 수도 있는) 변경 내용을 읽는 현상입니다. Read Uncommitted 격리 수준에서 발생할 수 있습니다.

Q2

Non-repeatable Read란?

"반복해서 읽어도 같지 않다"는 이름 그대로입니다.
한 트랜잭션이 같은 행을 두 번 읽는 사이 다른 트랜잭션이 그 행을 수정하고 커밋하면 두 번째 읽기에서 값이 달라집니다. Read Committed에서는 발생할 수 있고 Repeatable Read부터 막힙니다.

Q3

PostgreSQL의 기본 트랜잭션 격리 수준은?

가장 낮지도, 가장 높지도 않은 중간 수준입니다.
PostgreSQL의 기본 격리 수준은 Read Committed입니다. 커밋된 데이터만 읽으며, 문장마다 그 시점의 커밋된 최신 스냅샷을 봅니다.