<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Snapshot Isolation on jyukki's Blog</title><link>https://jyukki.com/tags/snapshot-isolation/</link><description>Recent content in Snapshot Isolation on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-kr</language><lastBuildDate>Sat, 11 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jyukki.com/tags/snapshot-isolation/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: Snapshot Isolation, Serializable, Write Skew 실무 판단 플레이북</title><link>https://jyukki.com/learning/deep-dive/deep-dive-snapshot-isolation-serializable-write-skew-playbook/</link><pubDate>Sat, 11 Apr 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-snapshot-isolation-serializable-write-skew-playbook/</guid><description>트랜잭션 격리 수준을 이론으로만 외우지 않고, write skew가 실제로 언제 터지는지, Snapshot Isolation과 Serializable을 어떤 기준으로 고를지 운영 숫자와 함께 정리합니다.</description><content:encoded><![CDATA[<p>트랜잭션 격리 수준을 공부할 때 많은 사람이 <code>READ COMMITTED</code>, <code>REPEATABLE READ</code>, <code>SERIALIZABLE</code> 이름은 외우지만, <strong>정작 운영에서 어떤 버그가 어느 수준에서 남는지</strong>는 흐릿하게 기억합니다. 그래서 실무에서는 &ldquo;락을 더 세게 걸면 안전하겠지&quot;라고 접근하다가 처리량을 잃거나, 반대로 MVCC만 믿고 갔다가 <strong>write skew</strong> 같은 미묘한 정합성 버그를 뒤늦게 만나는 일이 자주 생깁니다.</p>
<p>특히 백오피스 승인, 병상/좌석 예약, 교대 근무표, 재고 안전재고, 결제 한도처럼 <strong>여러 행을 함께 봐야만 성립하는 불변식</strong>에서는 단순 Lost Update보다 write skew가 더 위험합니다. 한 행을 덮어쓰는 문제가 아니라, 서로 다른 행을 각각 정상적으로 갱신했는데도 전체 규칙이 깨지기 때문입니다.</p>
<p>이 글은 Snapshot Isolation과 Serializable을 교과서 정의가 아니라 <strong>실무 의사결정 기준</strong>으로 연결해 정리합니다. 읽고 나면 &ldquo;언제 MVCC 기반 스냅샷 읽기로 충분한지&rdquo;, &ldquo;언제 abort 비용을 감수하고 Serializable이나 명시적 잠금을 택해야 하는지&quot;를 숫자와 조건으로 판단할 수 있게 하는 것이 목표입니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>write skew가 Lost Update, Phantom Read와 어떻게 다른지 실제 업무 규칙 관점에서 설명할 수 있습니다.</li>
<li>Snapshot Isolation이 어디까지 안전하고, 어떤 종류의 불변식에서는 왜 부족한지 판단 기준을 잡을 수 있습니다.</li>
<li>Serializable, <code>SELECT ... FOR UPDATE</code>, 제약조건, 재시도 설계를 어떤 순서로 조합해야 하는지 실무 우선순위를 가져갈 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-write-skew는-같은-행-충돌이-아니라-같은-규칙-충돌이다">1) write skew는 &ldquo;같은 행 충돌&quot;이 아니라 &ldquo;같은 규칙 충돌&quot;이다</h3>
<p>Lost Update는 같은 행을 두 트랜잭션이 덮어쓸 때 상대적으로 눈에 잘 띕니다. 반면 write skew는 더 교묘합니다. 두 트랜잭션이 서로 <strong>다른 행</strong>을 수정하므로 DB 입장에서는 직접 충돌이 없는 것처럼 보이지만, 애플리케이션이 기대한 불변식은 깨집니다.</p>
<p>대표 예시는 아래와 같습니다.</p>
<ul>
<li>당직 의사는 항상 1명 이상 남아 있어야 한다.</li>
<li>같은 시간대 예약 가능 좌석은 1석 이상 남아야 한다.</li>
<li>특정 고객군의 총 여신 한도는 1억 원을 넘지 않아야 한다.</li>
<li>같은 shard에서 활성 leader 레코드는 정확히 1개만 존재해야 한다.</li>
</ul>
<p>예를 들어 당직 의사 2명이 모두 &ldquo;다른 사람이 남아 있으니 나는 빠져도 된다&quot;고 판단하고 자신의 행만 업데이트하면, 각 트랜잭션은 개별적으로는 정상이지만 결과적으로 당직 의사가 0명이 됩니다. 즉 write skew의 본질은 <strong>행 단위 동시성 문제가 아니라, 집합 단위 불변식 검증 문제</strong>입니다.</p>
<p>이 지점은 <a href="/learning/deep-dive/deep-dive-mysql-isolation-locks/">MySQL 트랜잭션 격리 수준과 락</a>에서 다룬 행/갭/넥스트키 락 개념, <a href="/learning/deep-dive/deep-dive-spring-transaction/">Spring 트랜잭션</a>의 경계 설정, <a href="/learning/deep-dive/deep-dive-database-locking-contention-playbook/">DB Lock Contention 플레이북</a>의 경합 비용과 함께 봐야 감이 잡힙니다.</p>
<h3 id="2-snapshot-isolation은-읽기-쓰기-충돌을-줄이지만-모든-불변식을-보장하지는-않는다">2) Snapshot Isolation은 읽기-쓰기 충돌을 줄이지만, 모든 불변식을 보장하지는 않는다</h3>
<p>Snapshot Isolation의 강점은 분명합니다. 트랜잭션 시작 시점의 스냅샷을 읽기 때문에 읽기 잠금이 크게 줄고, OLTP 서비스에서는 응답시간과 처리량이 좋아집니다. 특히 읽기 비중이 높고, 단일 행 또는 단일 aggregate 단위에서 정합성을 맞추는 서비스에서는 상당히 현실적인 기본값입니다.</p>
<p>하지만 Snapshot Isolation은 보통 아래 성격의 규칙에 취약합니다.</p>
<ol>
<li>여러 행을 합쳐서 참/거짓이 결정되는 규칙</li>
<li>범위 조회 결과를 보고 다른 행을 갱신하는 규칙</li>
<li>&ldquo;현재 비어 있으면 생성&quot;처럼 부재(absence)를 조건으로 삼는 규칙</li>
<li>읽은 집합과 실제로 쓰는 집합이 다른 규칙</li>
</ol>
<p>문제는 트랜잭션이 스냅샷을 읽을 때는 서로 일관된 과거를 본다는 점입니다. 둘 다 같은 과거를 근거로 &ldquo;규칙이 아직 안전하다&quot;고 판단한 뒤, 각자 다른 행을 커밋하면 규칙이 깨질 수 있습니다. 이런 패턴에서는 <code>REPEATABLE READ</code> 이름만 보고 안심하면 안 됩니다. 중요한 건 이름이 아니라 <strong>DB 엔진이 어떤 충돌을 실제로 감지하는가</strong>입니다.</p>
<p>실무에서는 아래 질문으로 먼저 분류하는 편이 좋습니다.</p>
<ul>
<li>이 규칙은 한 행의 최신값만 맞으면 되는가?</li>
<li>아니면 여러 행의 조합이 동시에 성립해야 하는가?</li>
<li>부재를 확인한 뒤 생성하는가?</li>
<li>실패 시 재시도로 충분한가, 아니면 잘못 커밋되면 복구 비용이 큰가?</li>
</ul>
<p>이 질문 4개 중 2개 이상이 &ldquo;집합 불변식&rdquo; 쪽에 가깝다면 Snapshot Isolation 단독 사용은 위험 신호로 봐야 합니다.</p>
<h3 id="3-serializable은-만능-버튼이-아니라-abort를-비용으로-사는-안전장치에-가깝다">3) Serializable은 만능 버튼이 아니라 &ldquo;abort를 비용으로 사는 안전장치&quot;에 가깝다</h3>
<p>Serializable을 적용하면 많은 팀이 &ldquo;이제 완전히 안전하겠네&quot;라고 생각합니다. 방향은 맞지만, 비용을 빼고 보면 반쪽 이해입니다. Serializable은 본질적으로 <strong>동시에 실행된 트랜잭션들의 결과를 어떤 직렬 실행 순서로도 설명 가능하게 만들기 위해</strong> 더 강한 충돌 감지나 잠금을 사용합니다. 그 결과 정합성은 좋아지지만 다음 비용이 따라옵니다.</p>
<ul>
<li>직렬화 실패(Serialization failure)로 인한 abort 증가</li>
<li>락/검사 오버헤드로 인한 p95, p99 지연시간 증가</li>
<li>장기 트랜잭션에서 경합 반경 확대</li>
<li>애플리케이션 재시도 로직 미비 시 사용자 오류 노출</li>
</ul>
<p>그래서 실무에서는 Serializable을 전역 기본값으로 두기보다, <strong>정말 보호해야 하는 경로에 한정 적용</strong>하는 경우가 많습니다. 예를 들어 주문 생성 전체가 아니라 재고 차감 또는 leader election 테이블 갱신처럼 불변식이 민감한 트랜잭션만 분리하는 식입니다.</p>
<p>즉 의사결정의 핵심은 &ldquo;Serializable이 더 안전하냐&quot;가 아니라, <strong>이 경로에서 abort 비용을 감수할 만큼 불변식 위반 비용이 큰가</strong>입니다. 결제 이중 승인, 초과 판매, 권한 충돌처럼 복구 비용이 큰 경로라면 정답은 대체로 yes입니다.</p>
<h3 id="4-serializable만-보지-말고-제약조건과-잠금으로-더-좁게-해결할-수-있는지-먼저-본다">4) Serializable만 보지 말고, 제약조건과 잠금으로 더 좁게 해결할 수 있는지 먼저 본다</h3>
<p>많은 경우 가장 좋은 답은 격리 수준을 무조건 올리는 게 아니라, <strong>불변식을 DB가 직접 이해할 수 있는 형태로 바꾸는 것</strong>입니다.</p>
<p>예를 들어 아래 같은 접근이 더 단단합니다.</p>
<ul>
<li>가능한 경우 <code>UNIQUE</code>, <code>EXCLUDE</code>, <code>CHECK</code> 제약조건으로 규칙을 표현한다.</li>
<li>집합 규칙을 별도 집계 행(counter row, aggregate row)으로 축약해 한 행 충돌로 바꾼다.</li>
<li>필요한 경우에만 <code>SELECT ... FOR UPDATE</code>나 advisory lock으로 충돌 대상을 명시한다.</li>
<li>실패 시 <a href="/learning/deep-dive/deep-dive-idempotency/">Idempotency</a>와 짧은 backoff 재시도로 사용자 경험을 지킨다.</li>
</ul>
<p>예를 들어 &ldquo;활성 leader는 shard당 1개&rdquo; 규칙은 <code>(shard_id, active=true)</code> 조합을 제약조건으로 밀어 넣을 수 있으면 가장 간단합니다. 반면 &ldquo;당직 의사 최소 1명 유지&quot;처럼 집합 카운트 규칙은 별도 duty summary row를 두고 그 행을 잠그는 식으로 단순화할 수 있습니다. 불변식이 단일 충돌 지점으로 축약되면 Serializable 전면 적용 없이도 상당수 문제를 해결할 수 있습니다.</p>
<h3 id="5-재시도-설계가-없으면-serializable-도입은-오히려-장애를-만든다">5) 재시도 설계가 없으면 Serializable 도입은 오히려 장애를 만든다</h3>
<p>직렬화 실패는 버그가 아니라 예상된 운영 이벤트입니다. 그런데 애플리케이션이 이를 일반 500 에러로 흘려보내면, 사용자는 같은 버튼을 다시 누르고 상위 서비스는 또 재시도하면서 오히려 부하가 커집니다.</p>
<p>실무 기준은 보통 아래 정도가 무난합니다.</p>
<ul>
<li>직렬화 실패 재시도는 최대 2~3회</li>
<li>최초 재시도 대기 20~50ms, 이후 지수 백오프</li>
<li>사용자 요청 전체 deadline의 20~25% 이상을 재시도에 쓰지 않기</li>
<li>멱등 키 없는 쓰기 요청은 자동 재시도 금지</li>
</ul>
<p>이 기준은 <a href="/learning/deep-dive/deep-dive-timeout-retry-backoff/">Timeout/Retry/Backoff</a>와 함께 봐야 합니다. DB 안에서 안전해도 API 바깥에서 중복 요청이 생기면 결국 다른 계층에서 사고가 납니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-언제-snapshot-isolation으로-충분한가">1) 언제 Snapshot Isolation으로 충분한가</h3>
<p>아래 조건을 대부분 만족하면 Snapshot Isolation 또는 기본 MVCC 격리 수준으로도 충분한 경우가 많습니다.</p>
<ul>
<li>한 요청이 보통 1개 aggregate 또는 1개 행 묶음만 수정한다.</li>
<li>불변식이 단일 행의 버전 검사나 unique key로 표현 가능하다.</li>
<li>동일 키 경합이 초당 10회 미만이고, 충돌 시 사용자 재시도로 흡수 가능하다.</li>
<li>잘못 커밋돼도 보정 배치나 보상 트랜잭션으로 회복 비용이 낮다.</li>
<li>트랜잭션 p95가 50ms 이하, 외부 API 호출을 포함하지 않는다.</li>
</ul>
<p>대표적으로 프로필 수정, 상태 플래그 변경, 단일 주문 row 상태 전이 등은 이 범주에 들어갑니다.</p>
<h3 id="2-언제-serializable-또는-명시적-잠금을-우선-검토해야-하나">2) 언제 Serializable 또는 명시적 잠금을 우선 검토해야 하나</h3>
<p>아래 중 1개라도 해당하면 한 번은 강하게 의심해야 합니다.</p>
<ul>
<li>여러 행의 합/개수/부재 여부가 비즈니스 규칙을 결정한다.</li>
<li>초과 판매, 이중 승인, 권한 중복 부여처럼 잘못 커밋 시 금전/보안 영향이 크다.</li>
<li>동일 조건을 읽고 서로 다른 행을 갱신하는 패턴이 분당 30회 이상 발생한다.</li>
<li>장애 회복보다 사전 차단이 훨씬 싸다.</li>
<li>운영 중 재현이 어렵고, 포스트모템에서 원인 규명이 오래 걸리는 유형이다.</li>
</ul>
<p>이 경우 권장 우선순위는 보통 아래 순서입니다.</p>
<ol>
<li><strong>제약조건으로 표현 가능한지 확인</strong></li>
<li><strong>aggregate row 또는 lock row로 충돌 지점 축약</strong></li>
<li><strong>그다음 Serializable 또는 <code>FOR UPDATE</code> 적용</strong></li>
<li><strong>마지막으로 재시도와 서킷 브레이크 조건 추가</strong></li>
</ol>
<p>즉 격리 수준을 올리는 것은 중요한 도구지만, 늘 첫 번째 카드일 필요는 없습니다.</p>
<h3 id="3-운영-지표와-알람-기준">3) 운영 지표와 알람 기준</h3>
<p>도입 후에는 &ldquo;안전해졌다&rdquo; 느낌보다 숫자를 봐야 합니다. 최소 아래 5개는 같이 추적하는 편이 좋습니다.</p>
<ul>
<li><code>serialization_failure_rate</code>: 전체 쓰기 트랜잭션 대비 0.5~2% 이내 유지 목표</li>
<li><code>lock_wait_p95</code>: 핵심 경로 기준 100ms 초과 시 점검</li>
<li><code>transaction_duration_p95</code>: 보호 경로 150ms 초과 시 외부 호출/쿼리 수 재검토</li>
<li><code>retry_success_rate</code>: 재시도 후 성공률 80% 미만이면 구조적 경합 가능성 의심</li>
<li><code>invariant_violation_detected</code>: 0이 목표, 1건이라도 Sev 분류</li>
</ul>
<p>실무적으로는 <code>serialization_failure_rate</code>가 3%를 넘는데도 비즈니스 이득이 뚜렷하지 않다면, 전역 Serializable보다 설계 축약이 더 맞을 가능성이 큽니다.</p>
<h3 id="4-2주-도입-플레이북">4) 2주 도입 플레이북</h3>
<p><strong>1주차</strong></p>
<ul>
<li>집합 불변식이 있는 트랜잭션 5개를 선정합니다.</li>
<li>각 트랜잭션에 대해 읽는 집합과 쓰는 집합이 같은지 표로 정리합니다.</li>
<li>제약조건, lock row, Serializable 중 어떤 수단이 맞는지 1차 분류합니다.</li>
</ul>
<p><strong>2주차</strong></p>
<ul>
<li>가장 위험한 1개 경로에만 보호 장치를 적용합니다.</li>
<li>직렬화 실패 재시도와 멱등 키를 같이 넣습니다.</li>
<li>부하 테스트에서 경합 비율 5%, 10%, 20% 시 abort율과 p95를 비교합니다.</li>
<li>운영 알람 기준을 고정하고 포스트모템 템플릿에 invariant 항목을 추가합니다.</li>
</ul>
<h3 id="5-의사결정-기준-요약">5) 의사결정 기준 요약</h3>
<ul>
<li><strong>정합성 우선 경로</strong>: 금전, 권한, 재고, leader election, 예약</li>
<li><strong>처리량 우선 경로</strong>: 통계 집계, 비핵심 상태 동기화, 보정 가능한 비동기 작업</li>
<li><strong>먼저 볼 것</strong>: 규칙을 제약조건으로 내릴 수 있는가</li>
<li><strong>그다음 볼 것</strong>: 충돌 지점을 한 행으로 줄일 수 있는가</li>
<li><strong>마지막 카드</strong>: 넓은 범위 Serializable 전면 적용</li>
</ul>
<p>이 우선순위를 지키면 &ldquo;락을 세게 걸어서 안전&quot;과 &ldquo;대충 MVCC라 괜찮음&rdquo; 사이에서 흔들리는 시간을 많이 줄일 수 있습니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<ol>
<li>
<p><strong>Serializable은 안전하지만, 긴 트랜잭션과 만나면 비용이 빠르게 커집니다.</strong><br>
조회 후 외부 API 호출, 대량 배치 처리, 사용자 입력 대기 같은 흐름과 섞으면 abort와 락 대기가 급증합니다.</p>
</li>
<li>
<p><strong>명시적 잠금은 이해하기 쉽지만, 잠금 순서가 어긋나면 데드락 비용을 다시 떠안습니다.</strong><br>
따라서 락 기반 접근을 택했다면 접근 순서와 재시도 정책을 같이 문서화해야 합니다.</p>
</li>
<li>
<p><strong>애플리케이션 레벨 검증만으로는 늦을 수 있습니다.</strong><br>
두 요청이 거의 동시에 들어오면 둘 다 검증을 통과한 뒤 커밋 단계에서야 문제가 드러납니다. 가능한 규칙은 DB 제약으로 내리는 편이 낫습니다.</p>
</li>
<li>
<p><strong>재시도는 복구 수단이지 면죄부가 아닙니다.</strong><br>
abort가 잦다는 건 경합 설계가 거칠다는 뜻일 수 있습니다. 재시도율이 높아지면 구조를 다시 봐야 합니다.</p>
</li>
</ol>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="체크리스트">체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 이 트랜잭션의 규칙이 단일 행인지, 집합 불변식인지 분류돼 있다.</li>
<li><input disabled="" type="checkbox"> 집합 불변식이면 제약조건 또는 lock row로 축약 가능한지 검토했다.</li>
<li><input disabled="" type="checkbox"> Serializable/잠금 적용 경로에는 멱등 키와 재시도 상한이 정의돼 있다.</li>
<li><input disabled="" type="checkbox"> <code>serialization_failure_rate</code>, <code>lock_wait_p95</code>, <code>retry_success_rate</code>를 대시보드에서 본다.</li>
<li><input disabled="" type="checkbox"> 불변식 위반 발생 시 Sev 기준과 즉시 완화 절차가 정해져 있다.</li>
</ul>
<h3 id="연습-과제">연습 과제</h3>
<ol>
<li>현재 서비스에서 &ldquo;여러 행을 함께 봐야 하는 규칙&rdquo; 3개를 골라 write skew 가능성을 점검해 보세요.</li>
<li>그중 1개를 골라 제약조건, lock row, Serializable 세 가지 해법의 장단점을 표로 비교해 보세요.</li>
<li>직렬화 실패를 강제로 발생시키는 부하 테스트를 만든 뒤, 재시도 0회/1회/2회에서 성공률과 p95 차이를 측정해 보세요.</li>
</ol>
<h2 id="관련-글">관련 글</h2>
<ul>
<li><a href="/learning/deep-dive/deep-dive-mysql-isolation-locks/">MySQL 트랜잭션 격리 수준과 락</a></li>
<li><a href="/learning/deep-dive/deep-dive-spring-transaction/">Spring 트랜잭션 핵심 정리</a></li>
<li><a href="/learning/deep-dive/deep-dive-database-locking-contention-playbook/">DB Lock Contention 대응 플레이북</a></li>
<li><a href="/learning/deep-dive/deep-dive-idempotency/">Idempotency 설계</a></li>
<li><a href="/learning/deep-dive/deep-dive-timeout-retry-backoff/">Timeout, Retry, Backoff 실전 기준</a></li>
</ul>
]]></content:encoded></item></channel></rss>