백엔드 데이터 인프라 38편. MVCC 격리 수준 4가지와 Dirty Read·Non-Repeatable Read·Phantom Read·Serialization Anomaly 함정 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 70편 중 38편이에요. 37편 MVCC 에서 MVCC(다중 버전 동시성 제어)의 기본 원리를 다뤘으니, 이번 38편은 그 위에 격리 수준 4개와 동시성 함정 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(PostgreSQL)는 Dirty Read가 발생하지 않습니다. 가장 낮은 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는 표준보다 강해서 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(자바 ORM 표준) @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
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 33편 — JSON과 JSONB 깊이
- 34편 — 인덱스 소개와 큰 그림
- 35편 — 인덱스 종류 6가지 B-Tree·Hash·GIN·GiST·BRIN·SP-GiST
- 36편 — 인덱스 종합 운영 전략과 튜닝
- 37편 — MVCC 소개
다음 글: