Dirty Read부터 Phantom Read까지 — 이상현상 네 가지를 SQL로 재현하기
이상현상은 격리수준을 이해하는 유일한 언어
격리수준을 설명하는 표는 대부분 이렇게 생겼습니다 — "Read Committed는 Non-repeatable Read를 허용하고, Repeatable Read는 막는다." 이 표를 외워봤자 실무에서 쓸모가 없습니다. 각 이상현상이 실제로 어떤 버그로 나타나는지 몸으로 겪어봐야 표가 의미를 가집니다. 그래서 이 장은 설명보다 재현이 먼저입니다. 세션 두 개(A, B)를 열어서 순서대로 따라가 보세요. 예제는 PostgreSQL 기준입니다.
Dirty Read — 커밋되지 않은 데이터를 읽는다
-- 세션 A
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 1;
-- 아직 COMMIT 안 함
-- 세션 B (Read Uncommitted를 지원하는 DB라면)
SELECT balance FROM accounts WHERE id = 1; -- 0을 읽는다
-- 세션 A
ROLLBACK; -- A는 취소했다. 잔고는 원래대로 돌아간다
-- 그러나 B는 이미 "0"이라는, 존재한 적 없는 값을 사용해 버렸다다행히 PostgreSQL은 Read Uncommitted 자체를 지원하지 않습니다 — 설정해도 내부적으로
Read Committed로 동작합니다. 반면 SQL Server는 READ UNCOMMITTED를 실제로 지원하므로
이 예제를 그대로 재현할 수 있습니다. Dirty Read는 네 이상현상 중 가장 위험하고, 그래서
사실상 모든 실무 DB의 기본 설정에서 이미 차단되어 있습니다.
Non-repeatable Read — 같은 쿼리가 다른 값을 준다
-- 세션 A (Read Committed)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 500000
-- 세션 B
BEGIN;
UPDATE accounts SET balance = 600000 WHERE id = 1;
COMMIT;
-- 세션 A, 같은 트랜잭션 안에서 다시 조회
SELECT balance FROM accounts WHERE id = 1; -- 600000. 방금 전과 다르다
COMMIT;Dirty Read와 다른 점은 B가 커밋했다는 것입니다. A 입장에서는 커밋된 값을 읽었을 뿐이니 잘못한 게 없어 보이지만, 같은 트랜잭션 안에서 같은 쿼리의 결과가 바뀐다는 건 "한 트랜잭션은 하나의 일관된 스냅샷을 본다"는 직관을 깹니다. 정산 로직에서 같은 값을 두 번 읽어 계산에 쓰는 코드가 있다면 정확히 이 문제로 무너집니다.
Phantom Read — 없던 행이 생긴다
Non-repeatable Read가 "값"의 문제라면 Phantom Read는 "행의 존재 여부" 문제입니다.
-- 세션 A
BEGIN;
SELECT count(*) FROM orders WHERE status = 'pending'; -- 10건
-- 세션 B
BEGIN;
INSERT INTO orders (status) VALUES ('pending');
COMMIT;
-- 세션 A, 같은 조건으로 다시 집계
SELECT count(*) FROM orders WHERE status = 'pending'; -- 11건
COMMIT;"이미 읽은 행의 값이 바뀐 것"과 "조건에 맞는 새 행이 나타난 것"을 굳이 구분하는 이유는, 이 둘을 막는 메커니즘이 실제로 다르기 때문입니다. 값의 변경은 행 락으로 막을 수 있지만, 아직 존재하지 않는 행에는 락을 걸 수 없습니다. 그래서 Phantom Read를 막으려면 갭 자체에 락을 걸어야 하고(6장의 갭 락), 이게 Repeatable Read와 Serializable을 실무에서 가장 헷갈리게 만드는 지점입니다.
Lost Update — 이미 1장에서 본 그 문제
1장의 계좌 이체 버그가 바로 Lost Update입니다. 두 트랜잭션이 같은 값을 읽고, 각자의
계산 결과로 덮어써서 한쪽의 변경이 통째로 사라지는 현상입니다. 흥미로운 점은 SQL
표준의 원래 이상현상 목록(Dirty/Non-repeatable/Phantom)에는 Lost Update가 없다는
것입니다. 표준이 Lost Update를 별도로 명시하지 않은 이유는 "Read + Write를 하나의
원자적 연산으로 만들면 되는 문제"로 취급했기 때문인데, 실무에서는 애플리케이션 코드가
SELECT 후 UPDATE를 별도 문장으로 짜는 경우가 압도적으로 많아서 오히려 가장 흔하게
만나는 이상현상입니다.
-- Lost Update를 막는 가장 간단한 방법: 계산을 DB에 맡긴다
UPDATE accounts SET balance = balance + 1000000 WHERE id = 1;
-- SELECT로 값을 가져와 애플리케이션에서 더하지 않는다이 한 줄이 왜 안전한지는 6장에서 행 락과 함께 설명합니다. 지금은 "애플리케이션에서 읽고-계산하고-쓰는 패턴은 Lost Update의 근본 원인"이라는 것만 기억하면 됩니다.
이상현상 네 가지 요약
| 이상현상 | 보는 것 | Read Committed | Repeatable Read | Serializable |
|---|---|---|---|---|
| Dirty Read | 커밋 안 된 데이터 | 차단 | 차단 | 차단 |
| Non-repeatable Read | 같은 행의 다른 값 | 발생 가능 | 차단 | 차단 |
| Phantom Read | 조건에 맞는 새 행 | 발생 가능 | PostgreSQL은 차단* | 차단 |
| Lost Update | 갱신 덮어쓰기 | 발생 가능 | 발생 가능** | 차단 |
* SQL 표준상 Repeatable Read는 Phantom을 허용하지만, PostgreSQL은 Repeatable Read를 Snapshot Isolation으로 구현해 표준보다 더 엄격하게 막습니다. MySQL InnoDB도 갭 락으로 막습니다. "표준의 이름"과 "실제 구현"이 어긋나는 대표 사례이고, 4장에서 이 어긋남을 정리합니다.
** 명시적 락(SELECT ... FOR UPDATE)이나 원자적 갱신 문(UPDATE ... SET x = x + 1)
없이는 Repeatable Read에서도 Lost Update가 일어날 수 있습니다.