격리 수준 (락, MVCC, 동시성)
같은 데이터를 여러 트랜잭션이 동시에 읽고 쓰면 결과가 어긋날 수 있다. 이 문제를 다루는 영역이 동시성 제어(Concurrency Control)이며, ANSI SQL은 네 가지 격리 수준(Isolation Level)을 표준화해 어떤 종류의 이상 현상을 허용할지 골라 쓸 수 있게 했다. 본 글은 동시성 이상 현상 3종·ANSI 격리 수준 4단계·락 기반 vs MVCC 기반 동시성 제어를 세심하게 정리한다(출처: 위키백과 — Isolation (database systems)). 제가 학교 캡스톤에서 쿠폰 사용 처리를 기본 격리 수준(MySQL의 REPEATABLE READ)으로 두었다가 같은 쿠폰이 두 번 사용된 데이터가 끼어 있던 사고를 겪고 나서야 "기본 격리 수준이 모든 사고를 막아주지는 않는다"는 사실을 손끝으로 받아들였다.

동시성 이상 현상 3종 — Dirty Read·Non-Repeatable Read·Phantom
격리 수준의 의미를 알려면 먼저 "어떤 사고를 막느냐"부터 정확히 알아야 한다. ANSI SQL이 정의한 3대 이상 현상(anomaly)은 다음과 같다.
| 이상 현상 | 시나리오 | 본질 |
|---|---|---|
| Dirty Read (오손 읽기) | T2가 T1의 커밋 전 변경을 읽음 → T1이 롤백되면 T2는 존재하지 않은 값을 본 셈 | 커밋 안 된 값 노출 |
| Non-Repeatable Read (반복 불가능 읽기) | T1이 같은 행을 두 번 읽는 중간에 T2가 UPDATE·COMMIT → 두 번 읽은 값이 다름 | 같은 행의 값 바뀜 |
| Phantom Read (유령 읽기) | T1이 조건 검색을 두 번 하는 중간에 T2가 INSERT·COMMIT → 두 번째에 "유령 행"이 새로 등장 | 조건 결과 집합 바뀜 |
여기서 Phantom과 Non-Repeatable Read의 차이가 시험에서 가장 자주 출제되는 함정이다. Non-Repeatable Read는 같은 행의 값이 바뀐 경우이고, Phantom은 검색 조건에 들어오는 행 자체가 새로 생기거나 사라진 경우이다. "한 행이 바뀌었나 / 집합이 바뀌었나"로 외우면 헷갈리지 않는다.
-- Dirty Read 예시 (READ UNCOMMITTED에서만 발생)
-- T1
START TRANSACTION;
UPDATE account SET balance = 0 WHERE id = 'A';
-- 아직 커밋 안 함
-- T2가 이 시점에 balance를 읽으면 0을 봄
ROLLBACK;
-- T2가 본 0은 존재하지 않은 값 (오손 읽기)
-- Non-Repeatable Read 예시
-- T1
SELECT balance FROM account WHERE id = 'A'; -- 10000
-- 이때 T2가 UPDATE + COMMIT
SELECT balance FROM account WHERE id = 'A'; -- 5000 (같은 행이 바뀜)
-- Phantom Read 예시
-- T1
SELECT * FROM order_item WHERE order_id = 1; -- 3행 반환
-- 이때 T2가 INSERT + COMMIT
SELECT * FROM order_item WHERE order_id = 1; -- 4행 반환 (유령 행 추가)
이 세 가지 이상 현상을 어디까지 허용할지 골라 쓰는 게 격리 수준이다.
ANSI SQL 4대 격리 수준
ANSI SQL이 표준화한 네 가지 격리 수준은 시험에서 매회 출제된다. 어떤 이상 현상을 막는지 정확히 외워 두어야 한다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom | 비고 |
|---|---|---|---|---|
| READ UNCOMMITTED | ❌ 허용 | ❌ 허용 | ❌ 허용 | 가장 낮음·거의 안 씀 |
| READ COMMITTED | ✅ 차단 | ❌ 허용 | ❌ 허용 | PostgreSQL 기본 |
| REPEATABLE READ | ✅ 차단 | ✅ 차단 | ❌ 허용* | MySQL InnoDB 기본 |
| SERIALIZABLE | ✅ 차단 | ✅ 차단 | ✅ 차단 | 가장 엄격·직렬 실행 효과 |
*MySQL InnoDB의 REPEATABLE READ는 갭 락(gap lock) 덕분에 Phantom까지 차단된다. 표준 ANSI와 다른 강점.
여기서 SERIALIZABLE이란 모든 트랜잭션이 마치 직렬로 한 번에 하나씩 실행된 것처럼 보이도록 보장하는 가장 강한 격리 수준을 가리키며, 동시성 사고를 완전히 막는 대신 처리량이 크게 떨어진다. 실무에서 SERIALIZABLE이 필요한 경우는 매우 드물고, 대부분의 OLTP 서비스는 READ COMMITTED 또는 REPEATABLE READ를 채택한다.
-- 격리 수준 변경
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM account WHERE id = 'A';
COMMIT;
-- 한 세션의 기본 격리 수준 변경
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
솔직히 제 경험상 학교에서 처음 ANSI 격리 수준 표를 외울 때 가장 헷갈렸던 게 "MySQL 기본이 REPEATABLE READ인데 왜 Phantom이 안 일어나지?"였고, 갭 락(gap lock)이라는 인덱스 사이 빈 공간을 잠그는 트릭 덕분이라는 사실을 학습한 후로는 "교과서의 표와 실제 DBMS의 동작은 같은 듯 다르다"는 점을 손끝으로 받아들였다.
락 기반 vs MVCC 기반 동시성 제어
격리 수준을 어떻게 구현할지가 동시성 제어 구현의 본질이며, 현대 DBMS는 크게 두 가지 방식을 채택한다.
| 방식 | 메커니즘 | 채택 DBMS | 강점 |
|---|---|---|---|
| 락 기반 (Lock-based) | 공유락(S)·배타락(X)으로 직접 잠금 | MS SQL Server (기본) | 단순·예측 가능 |
| MVCC (Multi-Version Concurrency Control) | 각 트랜잭션이 자기만의 스냅샷을 읽음 | PostgreSQL·Oracle·MySQL InnoDB | 읽기-쓰기 비차단 |
여기서 MVCC(다중 버전 동시성 제어)란 행이 변경될 때마다 새 버전을 만들어 두고, 각 트랜잭션이 자기 시작 시점에 맞는 스냅샷을 읽도록 하는 동시성 제어 기법을 가리키며, 읽기 트랜잭션이 쓰기 트랜잭션을 막지 않고 그 반대도 막지 않는다는 결정적 강점이 있다. 그래서 PostgreSQL·Oracle은 락을 거의 쓰지 않고도 높은 동시성을 보장할 수 있다.
락 기반 방식에서 가장 자주 출제되는 두 가지 락은 다음과 같다.
| 락 종류 | 호환성 | 사용 |
|---|---|---|
| 공유락 (Shared, S) | 다른 S와 호환·X와 충돌 | SELECT (FOR SHARE) |
| 배타락 (Exclusive, X) | 다른 어떤 락과도 충돌 | UPDATE·DELETE·INSERT |
공유락은 여러 트랜잭션이 동시에 같은 데이터를 읽을 수 있게 허용하지만, 배타락은 그 데이터에 다른 어떤 락도 잡지 못하게 막아 단일 쓰기를 보장한다. 락 기반 동시성 제어의 표준 프로토콜이 2PL(Two-Phase Locking, 2단계 잠금)이다(출처: 위키백과 — Two-phase locking).
2PL — 두 단계로 락을 관리
1) 성장 단계 (Growing Phase) : 락 획득만 가능
2) 축소 단계 (Shrinking Phase) : 한 번 풀면 다시 획득 불가
→ COMMIT/ROLLBACK 시점에 모든 락 해제
2PL이 SERIALIZABLE을 구현하는 가장 표준적인 방법이지만, 데드락(deadlock, 교착 상태)이 발생할 위험이 있다. T1이 락 A를 잡고 B를 기다리는데 T2가 락 B를 잡고 A를 기다리면 둘 다 영원히 대기 상태에 빠진다. DBMS는 데드락을 자동 검출해 한쪽 트랜잭션을 강제 롤백(deadlock victim)하는 방식으로 풀어 준다.
-- 데드락 시나리오
-- T1
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 'A'; -- A 락
-- 이때 T2가
-- UPDATE account SET balance = balance - 100 WHERE id = 'B'; -- B 락
UPDATE account SET balance = balance + 100 WHERE id = 'B'; -- B 대기
-- T2가 동시에
-- UPDATE account SET balance = balance + 100 WHERE id = 'A'; -- A 대기 → 데드락!
데드락을 줄이는 가장 표준적인 기법이 자원 접근 순서 일관성이다. 모든 트랜잭션이 "id 작은 순으로 먼저 잠근다" 같은 규칙을 따르면 데드락이 원천적으로 발생하지 않는다. 솔직히 이건 예상 밖이었는데, 학교 캡스톤에서 처음 데드락이 나서 새벽 3시에 콘솔에 찍힌 ERROR 1213 (40001): Deadlock found를 본 후로는 "두 행 이상 동시에 UPDATE할 때는 ID 순으로 정렬"하는 패턴이 자동으로 자리 잡았다.
MVCC 기반 방식은 위 데드락 문제를 상당 부분 회피한다. 읽기는 스냅샷에서 가져오므로 락 자체가 필요 없고, 쓰기끼리만 충돌이 발생한다. 단, 오래된 버전을 정리하는 백그라운드 작업(PostgreSQL의 VACUUM)이 추가로 필요하며, 이 작업이 게을러지면 테이블이 부풀어 오르는 부작용이 있다.
마지막으로 시험 답안에서 자주 쓰이는 정형 표현을 정리하면, "동시성 제어는 ANSI SQL의 4가지 격리 수준(READ UNCOMMITTED·READ COMMITTED·REPEATABLE READ·SERIALIZABLE)으로 표준화되어 있으며, 각 수준은 Dirty Read·Non-Repeatable Read·Phantom Read 3대 이상 현상의 허용 범위로 구분된다. 구현은 락 기반(2PL·공유락·배타락)과 MVCC(다중 버전 스냅샷) 두 방식이 있으며, 현대 주요 DBMS(PostgreSQL·Oracle·MySQL InnoDB)는 MVCC를 표준으로 채택한다"는 두 문장이 표준 답안 표현으로 통한다.
메타 디스크립션: ANSI SQL 4대 격리 수준(READ UNCOMMITTED·READ COMMITTED·REPEATABLE READ·SERIALIZABLE)과 Dirty Read·Non-Repeatable Read·Phantom 3대 이상 현상, 락 기반(2PL·공유락·배타락) vs MVCC 동시성 제어, 그리고 데드락 회피 전략을 DB 입문자 관점에서 세심하게 정리합니다.