백엔드 데이터 인프라 38편 — MVCC 격리 수준과 동시성 함정

2026-05-17백엔드 데이터 인프라

백엔드 데이터 인프라 38편. MVCC 격리 수준 4가지와 Dirty Read·Non-Repeatable Read·Phantom Read·Serialization Anomaly 함정 풀어쓴 학습 노트.

📚 백엔드 데이터 인프라 · 38편 — MVCC 격리 수준과 동시성 함정

이 글은 백엔드 데이터 인프라 시리즈 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

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!