백엔드 데이터 인프라 38편. MVCC 격리 수준 4가지와 Dirty Read·Non-Repeatable Read·Phantom Read·Serialization Anomaly 함정 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 70편 중 38편이에요. 37편 MVCC 에서 "기본 원리" 를 다뤘으니, 이번 38편은 그 위에 격리 수준 4개 + 동시성 함정 4종.
4가지 동시성 함정
격리 수준 알기 전 — "왜 격리가 필요한가" 의 함정들.
(1) Dirty Read — 미커밋 읽기
-- 트랜잭션 A
BEGIN;
UPDATE accounts SET balance = 50000 WHERE id = 1;
-- 아직 COMMIT X
-- 트랜잭션 B (낮은 격리)
SELECT balance FROM accounts WHERE id = 1;
-- 50000 (잘못된 미커밋 값)
-- 트랜잭션 A ROLLBACK
ROLLBACK;
B 가 본 50000 = 가짜. PG 는 Dirty Read 발생 X (가장 낮은 READ COMMITTED 도 막음).
(2) Non-Repeatable Read — 반복 읽기 불일치
-- 트랜잭션 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 100000
-- 트랜잭션 B (동시)
BEGIN;
UPDATE accounts SET balance = 80000 WHERE id = 1;
COMMIT;
-- 트랜잭션 A (다시 같은 SELECT)
SELECT balance FROM accounts WHERE id = 1; -- 80000 (변경됨!)
같은 SELECT 가 — 두 번 다른 결과. READ COMMITTED 에서 가능. REPEATABLE READ 이상에서 방지.
(3) Phantom Read — 행 추가 보임
-- 트랜잭션 A
BEGIN;
SELECT COUNT(*) FROM users WHERE city = 'Seoul'; -- 100
-- 트랜잭션 B
INSERT INTO users (city, ...) VALUES ('Seoul', ...);
COMMIT;
-- 트랜잭션 A
SELECT COUNT(*) FROM users WHERE city = 'Seoul'; -- 101 (한 행 추가됨!)
같은 조건의 행 수가 — 두 번 다름. PG REPEATABLE READ 이상에서 방지 (다른 DB 와 다른 점).
(4) Serialization Anomaly — 직렬화 이상
가장 미묘. 동시 트랜잭션들의 결과가 — "어느 순서로 실행해도 안 나올 수 있는 상태".
-- 두 트랜잭션이 동시
-- A: SELECT 합계 → INSERT 새 행
-- B: SELECT 합계 → INSERT 새 행
-- 직렬 실행 시 = 한 행만 추가 가능 (제약)
-- 동시 실행 시 = 두 행 추가됨 (제약 위반)
SERIALIZABLE 만 방지.
격리 수준 4개 비교
| 수준 | Dirty | Non-Repeatable | Phantom | Serialization |
|---|---|---|---|---|
| READ UNCOMMITTED | 발생 | 발생 | 발생 | 발생 |
| READ COMMITTED (PG 기본) | 방지 | 발생 | 발생 | 발생 |
| REPEATABLE READ | 방지 | 방지 | PG는 방지 | 발생 |
| SERIALIZABLE | 방지 | 방지 | 방지 | 방지 |
PG 표 — READ COMMITTED 는 표준, REPEATABLE READ 는 "PG 가 표준보다 강함" (Phantom 방지), SERIALIZABLE 만 완전.
격리 수준 설정
-- 트랜잭션 단위
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ... 작업
COMMIT;
-- 또는 BEGIN 후
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- ...
COMMIT;
-- 세션 기본
SET default_transaction_isolation = 'repeatable read';
PG 기본 = READ COMMITTED. 99% 의 시나리오에 충분.
READ COMMITTED — 기본
각 SELECT 마다 새 스냅샷. 트랜잭션 안에서도 — 다른 트랜잭션 COMMIT 이 보임.
BEGIN;
SELECT balance FROM accounts; -- 100
-- 다른 트랜잭션 UPDATE + COMMIT
SELECT balance FROM accounts; -- 80 (변경 보임)
COMMIT;
장점 — 가장 높은 동시성. 단점 — Non-Repeatable Read 가능.
REPEATABLE READ — 트랜잭션 일관
트랜잭션 시작 시점 스냅샷 유지. 다른 트랜잭션 변경 안 보임.
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts; -- 100
-- 다른 트랜잭션 UPDATE
SELECT balance FROM accounts; -- 여전히 100 (트랜잭션 일관)
COMMIT;
PG REPEATABLE READ = Phantom 도 방지 (표준 SQL 보다 강함).
함정 — 동시 UPDATE 에러
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
-- 다른 트랜잭션이 UPDATE + COMMIT
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
-- ERROR: could not serialize access due to concurrent update
REPEATABLE READ 에서 "동시 변경 감지" = 명시적 에러. 앱에서 재시도 필요.
SERIALIZABLE — 완전한 직렬화
마치 "트랜잭션이 차례로 실행된 것처럼". 완벽한 일관성. 단 — 충돌 시 한쪽 ROLLBACK.
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 복잡한 비즈니스 로직
COMMIT; -- 또는 ERROR: could not serialize ...
PG SERIALIZABLE = SSI (Serializable Snapshot Isolation). 다른 DB 의 "엄격한 락" 보다 효율적. 단 — 충돌 빈도 높으면 재시도 폭주.
Lost Update — 동시 수정 함정
-- 카운터 증가 (계좌 잔액 비슷)
-- 트랜잭션 A: 100 + 10 = 110
-- 트랜잭션 B: 100 + 20 = 120
-- 결과 = 110 또는 120 (둘 중 하나)
-- 의도 = 130 (둘 다 반영)
해결:
(1) 단순 SQL — UPDATE col = col + val
UPDATE counters SET val = val + 10; -- A
UPDATE counters SET val = val + 20; -- B
-- 결과 = 130 (UPDATE 안에서 read-modify-write)
PG 가 자동으로 — UPDATE 시점에 최신 값 읽어 갱신. 락 짧고 안전.
(2) FOR UPDATE — 비관적
BEGIN;
SELECT val FROM counters WHERE id = 1 FOR UPDATE;
-- val = 100
UPDATE counters SET val = 110 WHERE id = 1;
COMMIT;
확실. 락 비용.
(3) @Version — 낙관적
UPDATE counters SET val = 110, version = version + 1
WHERE id = 1 AND version = 5;
-- 행 0 = 다른 트랜잭션이 변경 → 재시도
26편 UPDATE 깊이 의 JPA @Version. 충돌 드문 시스템에 효율.
격리 수준 선택 룰
| 시나리오 | 추천 |
|---|---|
| 일반 웹 API | READ COMMITTED (PG 기본) |
| 일관된 보고서·통계 | REPEATABLE READ |
| 금융·돈·재고 | SERIALIZABLE + 재시도 |
| 단순 카운터 증가 | READ COMMITTED + col = col + val |
99% 한국 백엔드 = READ COMMITTED. 금융 시스템·동시성 핵심만 SERIALIZABLE.
Spring JPA 격리 설정
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void process() { ... }
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transfer() { ... }
자바 백엔드 입문 43편 @Transactional 의 isolation 옵션.
재시도 패턴 — 모든 격리에서
@Transactional(isolation = Isolation.SERIALIZABLE)
@Retryable(retryFor = CannotSerializeException.class, maxAttempts = 3)
public void process() {
// ...
}
SERIALIZABLE·REPEATABLE READ 충돌 시 — 자동 재시도. 운영 표준.
함정 5가지
(1) 격리 수준 모름
READ COMMITTED 기본이라 — Non-Repeatable Read 가능. 의도와 다른 결과 가능.
(2) SERIALIZABLE 남용
모든 트랜잭션 SERIALIZABLE = 재시도 폭주·성능 폭락.
(3) FOR UPDATE 누락
비관적 락 필요한 곳에 안 박으면 — Lost Update.
(4) @Version 충돌 안 처리
OptimisticLockException 무시 = 데이터 손실.
(5) 큰 트랜잭션 + 높은 격리
오랜 시간 + REPEATABLE READ = bloat 증가. autovacuum 영향.
일반 API = READ COMMITTED. 보고서 = REPEATABLE READ. 금융·재고 = SERIALIZABLE + 재시도. 99% 한국 백엔드 = READ COMMITTED 가 정답.
한 줄 정리 — 격리 수준 4개. PG READ COMMITTED 기본 + REPEATABLE READ Phantom 도 방지 + SERIALIZABLE SSI. 동시성 함정 4종 = Dirty·Non-Repeatable·Phantom·Serialization. Lost Update = col = col + val 또는 FOR UPDATE 또는 @Version. 재시도 패턴 표준.
시험 직전 한 번 더 — 격리 수준 입문자가 매번 헷갈리는 것
- 동시성 함정 4종 = Dirty·Non-Repeatable·Phantom·Serialization
- PG Dirty Read 발생 X (READ COMMITTED 도 막음)
- READ COMMITTED (PG 기본) = 각 SELECT 새 스냅샷
- REPEATABLE READ = 트랜잭션 일관 + PG 는 Phantom 도 방지
- SERIALIZABLE = SSI (Serializable Snapshot Isolation)
- BEGIN ISOLATION LEVEL ...
- SET TRANSACTION ISOLATION LEVEL ...
- @Transactional(isolation = ...)
- Lost Update 해결 3방법:
UPDATE col = col + val(안전·단순)- FOR UPDATE (비관적)
- @Version (낙관적)
- REPEATABLE READ + 동시 UPDATE = could not serialize
- SERIALIZABLE 충돌 시 = could not serialize
- @Retryable 표준 패턴
- 일반 API = READ COMMITTED
- 금융 = SERIALIZABLE + 재시도
- SERIALIZABLE 남용 X
- 큰 트랜잭션 + 높은 격리 = bloat
- FOR UPDATE = 확실·비용
- @Version = OptimisticLockException
- 격리 수준 ≠ 락
- MVCC 위에서 동작
- 한국 백엔드 99% = READ COMMITTED
시리즈 다른 편
- Part 2 SQL Language 깊이: 37편 MVCC 소개 · 38편 (현재 글)
시리즈 다음 글
다음 글(39편)에서는 전문 검색 — to_tsvector·to_tsquery·GIN 인덱스.
공식 문서: PostgreSQL 18 — MVCC에서 더 자세한 사양을 확인할 수 있어요.