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 Read | Non-repeatable Read | Phantom 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의 차이를 확인한다.
-- 준비
CREATE TABLE account (
id int PRIMARY KEY,
balance int NOT NULL
);
INSERT INTO account VALUES (1, 100), (2, 100);Non-repeatable Read (READ COMMITTED)
-- 세션 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로 실행
-- 세션 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일 때 각 세션이 서로 다른 행을 하나씩 차감한다.
-- 세션 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)
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을 선택적으로 적용한다.