본문 바로가기
TYLER SONGBlog
← Blog index
CS학습7 min read

Isolation Level: 동시 트랜잭션 사이의 간섭을 어디까지 허용할 것인가

격리 수준(Isolation Level)은 동시에 실행되는 트랜잭션이 서로의 중간 상태를 얼마나 볼 수 있는지 정하는 설정이다. SQL 표준은 Dirty Read, Non-repeatable Read, Phantom Read 세 가지 이상 현상을 기준으로 네 단계를 정의한다. PostgreSQL은 MVCC 기반 스냅샷으로 이를 구현하며, 격리 수준이 높을수

By 송민성

1. 개념

격리 수준(Isolation Level)은 트랜잭션(Transaction)의 ACID 속성 중 격리성(Isolation)을 얼마나 엄격하게 보장할지 정하는 설정이다. 동시에 실행되는 트랜잭션이 서로의 변경 사항을 언제, 어디까지 볼 수 있는지를 결정한다.

SQL 표준은 다음 세 가지 이상 현상(anomaly)을 기준으로 네 단계를 정의한다.

이상 현상설명
더티 리드(Dirty Read)다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽음
반복 불가능 읽기(Non-repeatable Read)같은 행을 두 번 읽었는데 그 사이 다른 트랜잭션의 커밋으로 값이 달라짐
팬텀 리드(Phantom Read)같은 조건으로 두 번 조회했는데 그 사이 커밋된 삽입·삭제로 결과 행 집합이 달라짐

SQL 표준에서 각 수준이 허용하는 현상은 다음과 같다.

격리 수준Dirty ReadNon-repeatable ReadPhantom Read
READ UNCOMMITTED허용허용허용
READ COMMITTED방지허용허용
REPEATABLE READ방지방지허용
SERIALIZABLE방지방지방지

표는 표준이 허용하는 최대 범위이며, 실제 DBMS는 표준보다 더 강하게 구현하기도 한다.

2. 왜 사용하는가

완벽한 격리(직렬 실행)는 동시성(concurrency)을 크게 해친다. 반대로 격리를 느슨하게 하면 처리량(throughput)은 늘지만 데이터 정합성이 깨질 수 있다. 격리 수준은 이 둘 사이의 절충을 애플리케이션 요구에 맞게 고르는 수단이다.

  • 단순 조회나 통계처럼 약간의 불일치를 감수할 수 있는 작업은 낮은 수준으로 충분하다.
  • 잔액 이체, 재고 차감처럼 읽은 값을 근거로 쓰기를 하는 작업은 더 높은 수준이나 명시적 잠금이 필요하다.

3. 동작 원리

구현 방식의 두 갈래

  • 잠금 기반(Lock-based): 읽기에는 공유 잠금(shared lock), 쓰기에는 배타 잠금(exclusive lock)을 건다. 격리 수준이 높을수록 잠금을 오래 유지하거나 범위 잠금까지 건다.
  • 다중 버전 동시성 제어(MVCC, Multi-Version Concurrency Control): 행의 여러 버전을 보관하고, 각 트랜잭션이나 쿼리가 자기 스냅샷(snapshot)에 보이는 버전만 읽는다. 읽기가 쓰기를 막지 않고, 쓰기가 읽기를 막지 않는다.

PostgreSQL의 구현

PostgreSQL은 MVCC를 사용하며 다음과 같이 동작한다.

  • READ UNCOMMITTED: 요청은 가능하지만 READ COMMITTED와 동일하게 동작한다. 즉 PostgreSQL에서는 Dirty Read가 발생하지 않는다.
  • READ COMMITTED (기본값): 트랜잭션이 아니라 각 문장(statement)이 시작될 때 스냅샷을 만든다. 같은 트랜잭션 안에서도 문장마다 다른 커밋 상태를 볼 수 있다.
  • REPEATABLE READ: 트랜잭션의 첫 쿼리 시점에 스냅샷을 고정한다. 표준과 달리 PostgreSQL에서는 팬텀 리드도 발생하지 않는다(스냅샷 격리, Snapshot Isolation). 다만 쓰기 스큐(write skew) 같은 직렬화 이상은 남을 수 있다.
  • SERIALIZABLE: 직렬화 스냅샷 격리(SSI, Serializable Snapshot Isolation)를 사용한다. 트랜잭션 간 위험한 의존 관계를 감지하면 한쪽을 직렬화 실패(SQLSTATE 40001)로 중단시킨다. 애플리케이션은 재시도해야 한다.

REPEATABLE READ에서 같은 행을 다른 트랜잭션이 먼저 수정·커밋했다면, 뒤따라 그 행을 수정하려는 트랜잭션은 could not serialize access due to concurrent update 오류로 실패한다.

4. 코드 예제

두 개의 psql 세션으로 READ COMMITTED와 REPEATABLE READ의 차이를 확인한다.

sql
-- 준비 CREATE TABLE account ( id int PRIMARY KEY, balance int NOT NULL ); INSERT INTO account VALUES (1, 100), (2, 100);

Non-repeatable Read (READ COMMITTED)

sql
-- 세션 A BEGIN ISOLATION LEVEL READ COMMITTED; SELECT balance FROM account WHERE id = 1; -- 100 -- 세션 B BEGIN; UPDATE account SET balance = 50 WHERE id = 1; COMMIT; -- 세션 A SELECT balance FROM account WHERE id = 1; -- 50 (값이 바뀜) COMMIT;

같은 시나리오를 REPEATABLE READ로 실행

sql
-- 세션 A BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT balance FROM account WHERE id = 1; -- 100 -- 세션 B UPDATE account SET balance = 50 WHERE id = 1; -- 자동 커밋 -- 세션 A SELECT balance FROM account WHERE id = 1; -- 여전히 100 COMMIT; SELECT balance FROM account WHERE id = 1; -- 50

쓰기 스큐(Write Skew)와 SERIALIZABLE

규칙: 두 계좌 잔액의 합은 0 이상이어야 한다. 합이 200일 때 각 세션이 서로 다른 행을 하나씩 차감한다.

sql
-- 세션 A BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT sum(balance) FROM account; -- 200 UPDATE account SET balance = balance - 150 WHERE id = 1; -- 세션 B BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT sum(balance) FROM account; -- 200 UPDATE account SET balance = balance - 150 WHERE id = 2; COMMIT; -- 세션 A COMMIT; -- 성공: 합계가 -100이 되어 규칙 위반

서로 다른 행을 수정하므로 REPEATABLE READ는 충돌을 감지하지 못한다. 두 세션을 모두 ISOLATION LEVEL SERIALIZABLE로 실행하면 나중에 커밋하는 쪽이 40001 오류로 실패한다.

애플리케이션 재시도 (TypeScript, node-postgres)

typescript
import { Pool } from "pg"; const pool = new Pool(); // PG* 환경 변수로 접속 정보 설정 async function transfer(from: number, to: number, amount: number): Promise<void> { const maxRetries = 3; for (let attempt = 1; attempt <= maxRetries; attempt++) { const client = await pool.connect(); try { await client.query("BEGIN ISOLATION LEVEL SERIALIZABLE"); const { rows } = await client.query( "SELECT sum(balance)::int AS total FROM account" ); if (rows[0].total < amount) throw new Error("잔액 부족"); await client.query( "UPDATE account SET balance = balance - $1 WHERE id = $2", [amount, from] ); await client.query( "UPDATE account SET balance = balance + $1 WHERE id = $2", [amount, to] ); await client.query("COMMIT"); return; } catch (err: any) { await client.query("ROLLBACK"); // 40001: serialization_failure, 40P01: deadlock_detected if ((err.code === "40001" || err.code === "40P01") && attempt < maxRetries) { continue; } throw err; } finally { client.release(); } } }

5. 시간 복잡도 또는 성능 특성

격리 수준은 알고리즘 복잡도보다 동시성과 재시도 비용의 문제다.

  • READ COMMITTED: 문장마다 스냅샷을 만든다. 장기 트랜잭션이 오래된 스냅샷을 붙잡지 않아 정리(VACUUM) 부담이 상대적으로 적다.
  • REPEATABLE READ / SERIALIZABLE: 트랜잭션이 끝날 때까지 스냅샷을 유지하므로, 오래 열린 트랜잭션은 죽은 튜플(dead tuple) 정리를 막아 테이블 비대화(bloat)를 유발할 수 있다.
  • SERIALIZABLE: 읽기 의존성을 추적하기 위한 서술 잠금(predicate lock, SIREAD)을 관리하는 오버헤드가 있고, 충돌 시 트랜잭션이 중단되어 재실행 비용이 든다. 이 잠금은 다른 트랜잭션을 블로킹하지 않는다.
  • 잠금 기반 DBMS에서는 격리 수준이 높을수록 잠금 대기와 교착 상태(deadlock) 가능성이 늘어난다.

구체적인 처리량 차이는 워크로드에 따라 크게 달라지므로 실제 부하로 측정해야 한다.

6. 실무 사용 사례

  • 일반 웹 API (READ COMMITTED): PostgreSQL 기본값이며 대부분의 CRUD에 충분하다. 읽고 나서 판단해 쓰는 로직은 SELECT ... FOR UPDATE로 행 잠금을 명시한다.
  • 리포트·배치 집계 (REPEATABLE READ): 여러 쿼리가 하나의 일관된 시점을 봐야 할 때 쓴다. READ ONLY 트랜잭션과 함께 쓰면 의도가 분명하다.
  • 금융·재고처럼 불변식(invariant)이 여러 행에 걸친 경우 (SERIALIZABLE): 모든 쓰기 경로가 재시도 로직을 갖춘다는 전제로 사용한다.
  • MySQL InnoDB: 기본값이 REPEATABLE READ이며, 넥스트 키 락(next-key lock)으로 팬텀을 상당 부분 막는다. 같은 이름이라도 DBMS마다 동작이 다르다.

7. 주의할 점

  • 이름이 같아도 구현이 다르다. PostgreSQL의 REPEATABLE READ는 팬텀을 막지만 InnoDB와 잠금 동작이 다르다. 사용하는 DBMS 문서를 기준으로 판단한다.
  • SERIALIZABLE을 쓰면 재시도가 필수다. 40001 오류를 처리하지 않으면 정상 요청이 실패로 이어진다. 트랜잭션 안에서는 외부 API 호출 같은 부수 효과를 넣지 않아야 재시도가 안전하다.
  • READ COMMITTED의 갱신 손실. SELECT로 읽은 값을 애플리케이션에서 계산해 UPDATE하면 덮어쓰기가 일어날 수 있다. SET balance = balance - 10처럼 원자적 갱신을 쓰거나 FOR UPDATE를 사용한다.
  • 격리 수준은 트랜잭션 시작 시 정해진다. PostgreSQL에서 첫 쿼리 실행 후에는 SET TRANSACTION ISOLATION LEVEL로 바꿀 수 없다.
  • 긴 트랜잭션을 피한다. 스냅샷이 오래 유지되면 bloat와 잠금 경합이 커진다.

8. 핵심 정리

  • 격리 수준은 Dirty Read, Non-repeatable Read, Phantom Read 허용 범위로 정의되는 정합성과 동시성의 절충 설정이다.
  • PostgreSQL은 MVCC를 쓰며, READ UNCOMMITTED는 READ COMMITTED와 같고, REPEATABLE READ는 팬텀도 막는 스냅샷 격리다.
  • 쓰기 스큐는 REPEATABLE READ에서도 발생할 수 있고, SERIALIZABLE(SSI)에서만 감지되어 40001로 중단된다.
  • 높은 격리 수준을 쓰면 애플리케이션에 재시도 로직이 필요하다.
  • 기본값은 READ COMMITTED로 시작하고, 불변식이 걸린 구간에만 행 잠금이나 SERIALIZABLE을 선택적으로 적용한다.