트랜잭션2026년 7월 10일6분 읽기

격리 수준을 올려도 안 막히는 것

격리 수준을 4개 이름으로 외우는 대신 무슨 이상 현상을 막느냐로 이해한다. 표준 표가 실제 MySQL·PostgreSQL과 어떻게 다른지, 그리고 격리 수준이 못 막는 Lost Update를 앱에서 어떻게 잡는지.

#트랜잭션#격리수준#MVCC#MySQL#PostgreSQL#동시성#Backend

격리 수준을 올려도 안 막히는 것

격리 수준을 처음 외울 땐 네 개 이름(READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE)을 순서대로 암기했다. 그런데 실무에서 정작 발목을 잡은 건 그 네 레벨이 아니라, 표준 표에는 이름도 안 올라간 현상이었다. 재고를 둘이 동시에 차감했는데 하나가 증발하는 Lost Update 같은 것.

격리 수준은 레벨이 목적이 아니다. 진짜 본체는 "어떤 이상 현상을 허용하고 대신 성능을 얻을 것인가"다. 정합성과 동시성 사이의 트레이드오프 다이얼이라고 보면 된다. 그래서 레벨 이름부터 외우면 길을 잃는다. 현상부터 봐야 한다.

레벨보다 이상 현상이 먼저다

Dirty Read: 커밋 안 한 값을 읽는다

text
T1: 잔액 100 → 200으로 UPDATE (아직 커밋 전)
T2:                              잔액 읽음 → 200   ← 커밋 안 된 값!
T1: ROLLBACK
                                 T2는 존재한 적 없는 값을 본 것

Non-Repeatable Read: 같은 행인데 값이 바뀐다

text
T1: SELECT 잔액 → 100
T2:                    잔액 200으로 UPDATE + COMMIT
T1: SELECT 잔액 → 200   ← 같은 트랜잭션인데 값이 변함

Phantom Read: 같은 조건인데 행 수가 바뀐다

Non-Repeatable Read가 기존 행의 값이 바뀌는 거라면, Phantom은 조건에 걸리는 행이 추가·삭제되는 거다.

text
T1: SELECT COUNT(*) WHERE 나이>=20 → 5건
T2:                                  나이 25 회원 INSERT + COMMIT
T1: 같은 쿼리 → 6건   ← 유령(phantom)이 나타남

Lost Update: 내 갱신이 증발한다

표준 4대 현상엔 없지만 실무에선 1순위로 만나는 현상이다. 둘 다 읽고, 각자 계산하고, 나중에 쓴 쪽이 덮어쓴다.

Lost Update 타임라인. T1과 T2가 둘 다 재고 10을 읽고 각자 계산해 저장하는데, 나중에 커밋한 T2의 7이 T1의 9를 덮어써 결과가 7이 된다. 정답은 6, T1의 차감 -1이 증발한다.

재고 차감, 좌석 예약, 회원권 배치가 전부 이 현상과의 싸움이다. 표준 표에 없다는 건 곧 격리 수준만 올린다고 저절로 막히지 않는다는 뜻인데, 이건 뒤에서 따로 다룬다.

Write Skew: 각자는 정당한데 합치면 위반

아는 사람이 드문 심화 현상이다. 둘이 서로 다른 행을 고쳤는데 합쳐 보면 제약을 위반한다. 고전적인 예가 당직 의사다.

text
규칙: "당직 의사 최소 1명"
현재: 당직 의사 A, B 두 명

T1(A): 나(A) 빼도 B가 있네 → A 퇴근 처리      ← 자기 기준 정당
T2(B): 나(B) 빼도 A가 있네 → B 퇴근 처리      ← 자기 기준 정당
결과: 당직 0명   ← 제약 위반

각 트랜잭션은 자기 스냅샷 기준으로 정당해서 일반 락으로는 안 잡힌다. 뒤에 나오는 SERIALIZABLE에서만 막힌다.

4개 레벨은 현상의 조합이다

레벨은 결국 "어떤 현상을 막느냐"의 조합이다.

레벨Dirty ReadNon-RepeatablePhantom한 줄 정의
READ UNCOMMITTED허용허용허용커밋 안 된 것도 읽음 (실무 거의 안 씀)
READ COMMITTED차단허용허용커밋된 것만 읽음. 문장마다 스냅샷 갱신
REPEATABLE READ차단차단허용*트랜잭션 시작 시점 스냅샷 고정
SERIALIZABLE차단차단차단순차 실행과 동등함을 보장

외우는 요령은 하나다. 아래로 갈수록 "읽기의 기준 시점"이 넓어진다. 문장 단위 최신(RC)에서, 트랜잭션 단위 고정(RR)으로, 아예 순차 실행 등가(S)로. Phantom 칸의 별표는 다음 섹션 얘기다. 실제 DB는 이 표대로 안 움직인다.

표준 표는 1992년 문서다: MVCC와 실제 DB

위 표는 락 기반 구현을 가정한 1992년 표준 문서고, 현대 DB는 대부분 MVCC(다중 버전 동시성 제어)로 구현한다. MVCC는 데이터를 덮어쓰는 대신 버전을 여러 개 유지하고, 각 트랜잭션이 자기 스냅샷 시점에 맞는 버전을 읽는다. 그래서 읽기가 쓰기를 막지 않고, 쓰기가 읽기를 막지 않는다. 락 기반이면 읽는 동안 쓰기가 기다려야 하는데, 그 대기가 사라진 게 MVCC의 존재 이유다.

구현 방식은 두 DB가 다르다. PostgreSQL은 UPDATE할 때 새 튜플을 삽입하고 구 튜플엔 죽음 표시를 한다. 구 버전이 테이블 안에 그대로 쌓여서 VACUUM으로 청소해야 하고 bloat가 생기는 대신, 롤백은 공짜다. 새 튜플만 무시하면 끝이니까. MySQL InnoDB는 행을 제자리에서 갱신하고 이전 버전을 undo log에 따로 둔다. 롤백하려면 undo를 재적용해야 해서 비용이 있는 대신, 테이블이 부풀지 않는다. "청소 비용이냐 롤백 비용이냐"의 트레이드오프다.

레벨별 동작도 갈린다. READ COMMITTED는 문장 하나가 시작될 때마다 새 스냅샷을 뜬다. 그래서 같은 트랜잭션에서 두 번 읽으면 값이 달라질 수 있다(non-repeatable 허용). PostgreSQL과 Oracle의 기본값이다. REPEATABLE READ는 트랜잭션의 첫 읽기 시점 스냅샷을 끝까지 고정한다. MySQL InnoDB의 기본값인데, 같은 RR이라도 두 DB의 성격이 여기서 확 갈린다.

MySQL RRPostgreSQL RR
정체스냅샷 + 갭 락/넥스트키 락사실상 Snapshot Isolation
팬텀잠금 읽기(FOR UPDATE)에서 조건 범위의 "틈"까지 잠가 상당 부분 차단 (표준보다 강함)스냅샷 고정이라 일반 SELECT에선 안 보임
충돌 시락 대기로 풂"could not serialize access" 에러 → 앱이 재시도

핵심은 마지막 줄이다. 충돌을 MySQL은 락 대기로 풀고, PostgreSQL은 에러를 던져 앱이 재시도하게 한다. 하나는 기다리게 하고, 하나는 실패시키고 다시 시도하게 한다. 이 감각 차이가 실무에서 꽤 크다.

SERIALIZABLE도 마찬가지로 갈린다. MySQL은 모든 SELECT를 잠금 읽기로 바꾸는 락 기반이라 동시성이 뚝 떨어진다. PostgreSQL은 SSI(Serializable Snapshot Isolation)로, 낙관적으로 돌리다 직렬화 불가능한 의존이 감지되면 한쪽을 abort시킨다. 그래서 PG의 SERIALIZABLE은 "느려지는" 모델이 아니라 "실패하고 재시도하는" 모델이고, 앞에서 본 Write Skew를 잡을 수 있는 유일한 레벨이다.

기본값은 외워두면 편하다. MySQL은 REPEATABLE READ, PostgreSQL과 Oracle, SQL Server는 READ COMMITTED. MySQL이 RR인 건 statement 기반 복제(binlog) 시절의 일관성 역사 때문이고, PG가 RC인 건 RC와 MVCC로 대부분 충분하다는 철학 때문이다.

격리 수준이 못 막는 것: Lost Update

여기가 실무에서 제일 자주 데는 지점이다. MVCC는 "읽기 일관성"을 준다. 그런데 "내가 읽은 값이 내가 쓸 때까지 안 바뀐다"는 보장은 안 한다. read-modify-write 사이에 남이 커밋하면 내 갱신이 덮어써진다. 재고를 둘이 10으로 읽고 각자 차감해 저장하면 나중에 쓴 쪽이 이긴다. READ COMMITTED는 물론이고 스냅샷 기반 REPEATABLE READ에서도 방심하면 뚫린다. PostgreSQL RR은 에러로 알려주기라도 하지만, MySQL RR의 일반 SELECT는 못 잡는다. 격리 수준을 올린다고 자동으로 막히는 게 아니라는 뜻이다. 그래서 애플리케이션이 개입한다.

제일 저렴한 건 원자적 UPDATE다. 읽기-계산-쓰기를 한 문장으로 합쳐버리면 격리 수준과 상관없이 안전하다.

sql
UPDATE stock SET qty = qty - 1 WHERE id = ? AND qty > 0

DB가 행 락을 문장 실행 동안만 잡아 원자성을 보장한다. 단일 행 카운터 차감(재고, 좌석 수)이면 이걸로 끝이다. 의외로 제일 안 언급되는데 제일 싸다.

한 문장으로 못 접는 경우엔 락으로 간다. 낙관적 락은 @Version 컬럼을 두고 커밋 시점에 버전을 비교해서, 다르면 실패시키고 재시도한다. 락을 안 잡으니 가볍지만 재시도 로직이 필수고, 충돌이 드물 때 유리하다. 비관적 락은 SELECT ... FOR UPDATE로 읽는 순간 행을 잠가 경쟁 자체를 차단한다. 충돌이 잦을 때 확실하지만, 락 대기가 커넥션과 스레드를 점유하는 대가가 있다. 이 락 대기가 폭발하면 스레드풀 고갈로 번지는데, 그 메커니즘은 쓰레드 풀 글에 정리해뒀다.

경합이 DB 커넥션 밖이거나 여러 인스턴스·여러 리소스에 걸치면 Redis 같은 분산 락으로 나간다. 예전에 회원권 배치에서 DB에 UNIQUE 제약을 못 거는 환경이라 이 길로 간 적이 있는데, 그 과정은 배치 멱등성 글에 따로 정리해뒀다.

정리하면 선택은 상황이 정한다.

상황해법
단일 행 카운터 차감(재고·좌석 수)원자적 UPDATE (제일 쌈)
충돌 드묾, 락 점유 싫음낙관적 락 (@Version)
충돌 잦음, 확실히 순서 보장비관적 락 (FOR UPDATE), 단 락 대기 비용 주의
경합이 DB 밖 / 여러 리소스분산 락 (Redis)

마치며

격리 수준 네 개를 외우는 것보다 중요한 건 하나다. 내가 지금 하는 게 read-modify-write인지, 그렇다면 그게 무엇에 뚫리는지를 아는 것. 격리 수준을 SERIALIZABLE까지 올려도 동시 갱신이 저절로 막히는 건 아니고(그건 격리가 아니라 동시성 제어의 영역이다), 반대로 원자적 UPDATE 한 줄이면 격리 수준과 무관하게 안전한 경우도 많다. 레벨 이름은 지도의 눈금이지 목적지가 아니다.

#트랜잭션#격리수준#MVCC#MySQL#PostgreSQL#동시성#Backend

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js