트랜잭션 격리 수준
트랜잭션 격리 수준(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에서는 충돌이 감지되면 재시도가 필요합니다.
격리 수준의 배경
동시성 제어 이론에서 트랜잭션이 하나씩 순서대로 실행된 것과 같은 결과를 내는 성질을 직렬 가능성(serializability)이라 합니다. 이를 항상 지키면 동시성이 떨어지기 때문에 SQL 표준은 더 약한 격리 수준을 두고 어떤 이상 현상을 허용할지로 단계를 나눴습니다.
1995년 Berenson 등은 "A Critique of ANSI SQL Isolation Levels"에서 표준의 이상 현상 정의가 모호하다고 지적하고, 스냅샷 격리(snapshot isolation)라는 수준과 쓰기 비대칭 같은 현상을 설명했습니다. PostgreSQL의 Repeatable Read가 스냅샷 격리에 해당하며, 9.1부터는 직렬화 스냅샷 격리(SSI)로 Serializable을 구현합니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
세 가지 이상 현상을 설명해 보세요.
Dirty Read는 미커밋 값을 읽는 것, Non-repeatable Read는 같은 행을 두 번 읽었는데 값이 바뀌는 것, Phantom Read는 같은 조건으로 조회했는데 행이 늘거나 줄어드는 것입니다.
면접 질문
Read Committed와 Repeatable Read의 차이는?
PostgreSQL에서 Read Committed는 문장마다 새 스냅샷을 봐서 같은 행을 다시 읽으면 값이 달라질 수 있고, Repeatable Read는 트랜잭션 동안 같은 스냅샷을 봅니다.
면접 질문
PostgreSQL의 기본 격리 수준은?
Read Committed입니다. 커밋된 데이터만 읽고 문장마다 그 시점의 최신 커밋 상태를 봅니다.
면접 질문
직렬화 실패가 나면 어떻게 하나요?
데이터가 깨진 것이 아니라 동시 실행 순서를 맞추기 위해 한 트랜잭션이 되돌려진 것이므로, 트랜잭션 전체를 새로 시작해 재시도합니다.
활용 분야
함께 보면 좋은 토픽
참고 자료
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란?
Q2
Non-repeatable Read란?
Q3
PostgreSQL의 기본 트랜잭션 격리 수준은?