데이터베이스에서 트랜잭션이란 “여러 작업을 하나의 논리적 단위로 묶은 것"이다. 계좌이체를 생각하면 된다. 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와 비슷한 성능이 나올 수 있다. 느려지는 건 충돌이 잦을 때다. 충돌이 발생하면 트랜잭션을 실패시키고 재시도해야 하니까.
핵심은 결국 이것이다. 격리 수준은 “동시에 실행되는 트랜잭션들이 서로를 얼마나 모른 척할 것인가"에 대한 선택이다. 더 모른 척할수록(격리 수준이 높을수록) 각 트랜잭션은 편해지지만, 시스템 전체는 더 많은 비용을 지불한다.