도라의 개발 블로그

소프트웨어 개발/데이터·통계

트랜잭션 격리 수준과 MVCC | ACID, WAL, Dirty Read와 Phantom Read

boradora 2026. 8. 5. 08:51

작업일2026. 07

트랜잭션 ACID 격리 수준 MVCC WAL PostgreSQL

혼자 테스트할 때는 문제없던 코드도 사용자가 동시에 몰리면 예상치 못한 문제가 발생합니다. 같은 데이터를 여러 트랜잭션이 동시에 수정하면서 합계가 달라지거나, 방금 조회한 결과가 다시 조회했을 때 달라지는 현상이 생길 수 있습니다. 이처럼 여러 작업이 동시에 실행되는 환경에서는 데이터의 일관성을 어떻게 보장할지가 중요합니다. 트랜잭션은 이러한 문제를 포함해 데이터의 정확성을 보장하기 위한 기본 단위입니다.

같은 조회를 두 번 했는데 결과가 다르면 버그일까요?
격리 수준을 어디에 두었느냐에 따라 정상 동작일 수 있습니다. 무엇까지 허용할지 정하는 것이 격리 수준입니다.

SQL을 공부할 때는 쿼리를 하나씩 순서대로 실행하므로 동시성 문제를 체감하기 어렵습니다. 그런데 실제 서버에서는 같은 쿼리가 동시에 수십 개씩 들어오고, 그중 일부는 같은 행을 건드립니다. 이때 무엇을 어디까지 허용할지 정해 두지 않으면 계산은 전부 맞는데 결과만 틀리는 상황이 생깁니다.

ACID와 WAL

트랜잭션은 하나로 묶여서 전부 성공하거나 전부 취소되어야 하는 작업 단위입니다. 계좌 이체가 출금과 입금 두 개로 나뉘어 있어도 둘 중 하나만 반영되면 안 되는 것과 같습니다. 트랜잭션이 지켜야 하는 네 가지 성질을 앞글자만 따서 ACID라고 합니다.

속성 내용
Atomicity
원자성
모든 작업이 전부 수행되거나 전혀 수행되지 않습니다. 출금과 입금은 둘 다 성공하거나 둘 다 취소됩니다.
Consistency
일관성
트랜잭션 전후로 데이터베이스가 정해진 규칙을 만족하는 상태를 유지합니다.
Isolation
격리성
동시에 실행되는 트랜잭션끼리 서로 간섭하지 않습니다. 이 글의 대부분이 여기에 해당합니다.
Durability
지속성
커밋이 끝난 결과는 장애가 나도 남아 있습니다.

이 중 지속성을 구현하는 방식이 WAL(Write-Ahead Logging)입니다. 이름 그대로 데이터 파일을 수정하기 전에 변경 내용을 로그에 먼저 기록하는 방식입니다.

BEGIN;
  UPDATE account SET balance = balance - 10000 WHERE id = 'A';
  -- 1. WAL 버퍼에 변경 내용을 먼저 기록
  -- 2. 메모리(Shared Buffer)의 페이지를 변경
  -- 3. 디스크 데이터 파일에는 아직 기록 안 함 (dirty page)
  UPDATE account SET balance = balance + 10000 WHERE id = 'B';
COMMIT;
-- COMMIT 로그를 WAL에 기록한 뒤 fsync 로 디스크에 동기화
-- 데이터 파일 반영은 이후 Checkpoint 시점에

커밋 직후 서버가 꺼져도 데이터 파일에는 아직 반영이 안 되어 있을 수 있습니다. 그래도 WAL에는 기록이 남아 있기 때문에, 재기동할 때 로그를 순서대로 재실행해 복구합니다. 커밋되지 않은 트랜잭션은 되돌립니다.

PostgreSQL의 synchronous_commitoff로 두면 커밋 응답이 빨라지는 대신 장애 시 최대 200ms 분량이 사라질 수 있습니다. 성능과 안전 중 무엇을 택할지 정하는 설정입니다.

동시성에서 생기는 세 가지 현상

격리 수준을 이해하려면 무엇을 막으려는 것인지부터 봐야 합니다. 트랜잭션이 겹칠 때 생기는 대표적인 이상 현상이 세 가지입니다.

Dirty Read
아직 커밋되지 않은 값을 읽었기 때문에, 상대가 롤백하면 존재한 적 없는 값을 읽게 됩니다.
Non-Repeatable Read
같은 행을 두 번 읽었는데 값이 달라집니다. 그 사이 다른 트랜잭션이 UPDATE·DELETE 했기 때문입니다.
Phantom Read
같은 조건으로 두 번 조회했을 때 행의 개수가 달라집니다. 그 사이 조건에 맞는 행이 INSERT 됐기 때문입니다.

Non-Repeatable Read와 Phantom Read를 헷갈리기 쉽습니다. 값이 변한 것이 Non-Repeatable Read, 조건에 맞는 행의 개수가 변한 것이 Phantom Read입니다. 실제로 세션 두 개를 띄워 보면 차이가 분명해집니다.

-- Session 1
BEGIN;
SELECT COUNT(*) FROM orders WHERE amount >= 100;   -- 결과: 5

-- Session 2 (동시에)
BEGIN;
INSERT INTO orders(id, amount) VALUES (999, 150);
COMMIT;

-- Session 1 에서 같은 쿼리를 한 번 더
SELECT COUNT(*) FROM orders WHERE amount >= 100;   -- 결과는?
  • Read Committed: 5에서 6으로 늘어납니다. 팬텀이 발생합니다.
  • Repeatable Read: 5로 유지됩니다. 같은 트랜잭션 안에서는 같은 스냅샷을 보기 때문입니다.
  • Serializable: Session 2의 INSERT가 대기하거나 충돌로 롤백되어 팬텀 자체가 생기지 않습니다.

격리 수준 네 단계

격리 수준은 위의 세 현상 중 무엇까지 허용할지 정하는 설정입니다. 엄격해질수록 정확해지고 느려집니다.

격리 수준 Dirty Read Non-Repeatable Phantom 기본값인 DBMS
Read Uncommitted 허용 허용 허용 거의 쓰지 않음
Read Committed 차단 허용 허용 PostgreSQL, Oracle, SQL Server
Repeatable Read 차단 차단 원칙적 허용 MySQL(InnoDB)
Serializable 차단 차단 차단 성능 저하 주의

표에서 Repeatable Read의 Phantom이 "원칙적 허용"인 이유가 있습니다. ANSI 표준은 허용한다고 정의했지만 실제 DBMS는 구현에 따라 막습니다. 표준만 외우고 있으면 실제 동작과 어긋나기 때문에, 쓰는 DBMS의 구현을 확인해야 합니다.

DBMS 기본값 구현 방식
PostgreSQL Read Committed MVCC. Repeatable Read는 스냅샷 격리라 같은 트랜잭션 안에서 새 행을 보지 않습니다. 완전한 직렬성이 필요하면 Serializable(SSI)을 쓰고, 충돌 시 롤백됩니다.
MySQL
InnoDB
Repeatable Read 일반 SELECT는 같은 스냅샷을 보므로 팬텀이 사실상 없습니다. 다만 FOR UPDATE나 범위 UPDATE·DELETE에서는 next-key lock으로 막아서 대기가 늘어날 수 있습니다.
Oracle Read Committed MVCC로 문장 단위 일관성을 줍니다. Serializable은 충돌 시 ORA-08177이 나므로 재시도가 필요합니다.
SQL Server Read Committed
(락 기반)
READ_COMMITTED_SNAPSHOT을 켜면 행 버전을 읽습니다. Serializable은 범위 잠금으로 팬텀을 막습니다.

MVCC

PostgreSQL과 MySQL InnoDB, Oracle이 공통으로 쓰는 방식이 MVCC(Multi-Version Concurrency Control)입니다. 값을 덮어쓰지 않고 새 버전을 하나 더 만드는 방식이라고 보면 됩니다.

덮어쓰지 않기 때문에 이전 버전이 그대로 남아 있고, 읽는 쪽은 자기 트랜잭션이 시작될 때의 버전을 봅니다. 그래서 읽기가 쓰기를 막지 않고, 쓰기가 읽기를 막지 않습니다. 조회 쿼리가 UPDATE를 기다리며 멈추는 일이 없는 이유가 여기 있습니다.

UPDATE 는 값을 고치지 않고 새 버전을 추가합니다
구버전 (dead tuple 이 됨)
stock = 10
xmin=100   xmax=205
신버전 (지금 보이는 값)
stock = 9
xmin=205   xmax=
xmin 은 이 행을 만든 트랜잭션 번호, xmax 는 이 행을 지우거나 고친 트랜잭션 번호입니다. 트랜잭션 번호 150번이 지금 조회하면 xmax=205 가 아직 자기보다 뒤이므로 구버전인 stock = 10 을 봅니다.

남은 구버전을 dead tuple이라고 하고, VACUUM이 주기적으로 회수합니다. 여기서 실무에서 자주 겪는 문제가 나옵니다.

오래 열려 있는 트랜잭션 하나가 테이블을 부풀립니다. VACUUM은 어떤 트랜잭션도 더 이상 보지 않는 구버전만 지울 수 있습니다. 트랜잭션 하나가 몇 시간째 열려 있으면 그동안 쌓인 구버전을 전부 지우지 못하고 테이블이 계속 커집니다. 트랜잭션을 열어 둔 채로 외부 API를 호출하거나 파일을 읽지 않아야 하는 이유가 여기 있습니다.

MySQL InnoDB는 구버전을 테이블이 아니라 별도의 Undo Log에 두고 Purge Thread가 정리합니다. 구현은 다르지만 버전을 저장하는 공간이 늘어나고, 긴 트랜잭션이 정리를 막는다는 비용은 같습니다.

여기까지가 읽기와 쓰기 사이의 충돌입니다. MVCC가 이 부분을 없애 줍니다. 남는 것은 쓰기끼리의 충돌인데, 같은 행을 두 트랜잭션이 동시에 고치려는 상황은 버전을 늘리는 것으로 해결되지 않습니다. 이쪽은 Lock으로 다루고, 분량이 있어서 Lock과 데드락에서 따로 정리했습니다.

정리하면

작업 성격별로 어떤 격리 수준을 쓰는지 정리해보겠습니다.

작업 권장 격리 수준 확인할 것
단순 조회, 캐시 워밍업 Read Committed 읽는 값이 조금 달라져도 되는지
보고서, 반복 집계 Repeatable Read Phantom이 결과를 바꾸는지
계좌 이체, 재고 차감 Serializable 성능 저하와 충돌 재시도를 감당할 수 있는지
장바구니, 좌석 예약 Serializable 충돌 시 재시도할 준비가 되어 있는지
  • 격리 수준을 높이면 정확해지고 처리량이 떨어집니다. 전부 Serializable로 두면 값은 안 틀리지만 충돌과 재시도가 늘어납니다.
  • 표준과 실제 구현이 다릅니다. ANSI 표준은 Repeatable Read에서 Phantom을 허용하는데, PostgreSQL과 InnoDB는 스냅샷으로 실질적으로 막습니다.
  • MVCC 덕분에 읽기와 쓰기가 서로를 막지 않습니다. 대신 구버전이 쌓이므로 긴 트랜잭션이 테이블을 부풀립니다.
  • WAL은 로그를 데이터 파일보다 먼저 씁니다. 커밋 직후 서버가 꺼져도 로그를 재실행해 복구할 수 있는 이유입니다.
한 줄 정리: 트랜잭션이 겹치면 Dirty · Non-Repeatable · Phantom 세 가지가 생기고 → 격리 수준으로 어디까지 허용할지 정하며 → MVCC가 버전을 여러 개 두어 읽기 충돌을 없앱니다. 남은 쓰기 충돌은 Lock의 몫입니다.

참고