트랜잭션 격리 수준을 공부할 때 많은 사람이 READ COMMITTED, REPEATABLE READ, SERIALIZABLE 이름은 외우지만, 정작 운영에서 어떤 버그가 어느 수준에서 남는지는 흐릿하게 기억합니다. 그래서 실무에서는 “락을 더 세게 걸면 안전하겠지"라고 접근하다가 처리량을 잃거나, 반대로 MVCC만 믿고 갔다가 write skew 같은 미묘한 정합성 버그를 뒤늦게 만나는 일이 자주 생깁니다.

특히 백오피스 승인, 병상/좌석 예약, 교대 근무표, 재고 안전재고, 결제 한도처럼 여러 행을 함께 봐야만 성립하는 불변식에서는 단순 Lost Update보다 write skew가 더 위험합니다. 한 행을 덮어쓰는 문제가 아니라, 서로 다른 행을 각각 정상적으로 갱신했는데도 전체 규칙이 깨지기 때문입니다.

이 글은 Snapshot Isolation과 Serializable을 교과서 정의가 아니라 실무 의사결정 기준으로 연결해 정리합니다. 읽고 나면 “언제 MVCC 기반 스냅샷 읽기로 충분한지”, “언제 abort 비용을 감수하고 Serializable이나 명시적 잠금을 택해야 하는지"를 숫자와 조건으로 판단할 수 있게 하는 것이 목표입니다.

이 글에서 얻는 것

  • write skew가 Lost Update, Phantom Read와 어떻게 다른지 실제 업무 규칙 관점에서 설명할 수 있습니다.
  • Snapshot Isolation이 어디까지 안전하고, 어떤 종류의 불변식에서는 왜 부족한지 판단 기준을 잡을 수 있습니다.
  • Serializable, SELECT ... FOR UPDATE, 제약조건, 재시도 설계를 어떤 순서로 조합해야 하는지 실무 우선순위를 가져갈 수 있습니다.

핵심 개념/이슈

1) write skew는 “같은 행 충돌"이 아니라 “같은 규칙 충돌"이다

Lost Update는 같은 행을 두 트랜잭션이 덮어쓸 때 상대적으로 눈에 잘 띕니다. 반면 write skew는 더 교묘합니다. 두 트랜잭션이 서로 다른 행을 수정하므로 DB 입장에서는 직접 충돌이 없는 것처럼 보이지만, 애플리케이션이 기대한 불변식은 깨집니다.

대표 예시는 아래와 같습니다.

  • 당직 의사는 항상 1명 이상 남아 있어야 한다.
  • 같은 시간대 예약 가능 좌석은 1석 이상 남아야 한다.
  • 특정 고객군의 총 여신 한도는 1억 원을 넘지 않아야 한다.
  • 같은 shard에서 활성 leader 레코드는 정확히 1개만 존재해야 한다.

예를 들어 당직 의사 2명이 모두 “다른 사람이 남아 있으니 나는 빠져도 된다"고 판단하고 자신의 행만 업데이트하면, 각 트랜잭션은 개별적으로는 정상이지만 결과적으로 당직 의사가 0명이 됩니다. 즉 write skew의 본질은 행 단위 동시성 문제가 아니라, 집합 단위 불변식 검증 문제입니다.

이 지점은 MySQL 트랜잭션 격리 수준과 락에서 다룬 행/갭/넥스트키 락 개념, Spring 트랜잭션의 경계 설정, DB Lock Contention 플레이북의 경합 비용과 함께 봐야 감이 잡힙니다.

2) Snapshot Isolation은 읽기-쓰기 충돌을 줄이지만, 모든 불변식을 보장하지는 않는다

Snapshot Isolation의 강점은 분명합니다. 트랜잭션 시작 시점의 스냅샷을 읽기 때문에 읽기 잠금이 크게 줄고, OLTP 서비스에서는 응답시간과 처리량이 좋아집니다. 특히 읽기 비중이 높고, 단일 행 또는 단일 aggregate 단위에서 정합성을 맞추는 서비스에서는 상당히 현실적인 기본값입니다.

하지만 Snapshot Isolation은 보통 아래 성격의 규칙에 취약합니다.

  1. 여러 행을 합쳐서 참/거짓이 결정되는 규칙
  2. 범위 조회 결과를 보고 다른 행을 갱신하는 규칙
  3. “현재 비어 있으면 생성"처럼 부재(absence)를 조건으로 삼는 규칙
  4. 읽은 집합과 실제로 쓰는 집합이 다른 규칙

문제는 트랜잭션이 스냅샷을 읽을 때는 서로 일관된 과거를 본다는 점입니다. 둘 다 같은 과거를 근거로 “규칙이 아직 안전하다"고 판단한 뒤, 각자 다른 행을 커밋하면 규칙이 깨질 수 있습니다. 이런 패턴에서는 REPEATABLE READ 이름만 보고 안심하면 안 됩니다. 중요한 건 이름이 아니라 DB 엔진이 어떤 충돌을 실제로 감지하는가입니다.

실무에서는 아래 질문으로 먼저 분류하는 편이 좋습니다.

  • 이 규칙은 한 행의 최신값만 맞으면 되는가?
  • 아니면 여러 행의 조합이 동시에 성립해야 하는가?
  • 부재를 확인한 뒤 생성하는가?
  • 실패 시 재시도로 충분한가, 아니면 잘못 커밋되면 복구 비용이 큰가?

이 질문 4개 중 2개 이상이 “집합 불변식” 쪽에 가깝다면 Snapshot Isolation 단독 사용은 위험 신호로 봐야 합니다.

3) Serializable은 만능 버튼이 아니라 “abort를 비용으로 사는 안전장치"에 가깝다

Serializable을 적용하면 많은 팀이 “이제 완전히 안전하겠네"라고 생각합니다. 방향은 맞지만, 비용을 빼고 보면 반쪽 이해입니다. Serializable은 본질적으로 동시에 실행된 트랜잭션들의 결과를 어떤 직렬 실행 순서로도 설명 가능하게 만들기 위해 더 강한 충돌 감지나 잠금을 사용합니다. 그 결과 정합성은 좋아지지만 다음 비용이 따라옵니다.

  • 직렬화 실패(Serialization failure)로 인한 abort 증가
  • 락/검사 오버헤드로 인한 p95, p99 지연시간 증가
  • 장기 트랜잭션에서 경합 반경 확대
  • 애플리케이션 재시도 로직 미비 시 사용자 오류 노출

그래서 실무에서는 Serializable을 전역 기본값으로 두기보다, 정말 보호해야 하는 경로에 한정 적용하는 경우가 많습니다. 예를 들어 주문 생성 전체가 아니라 재고 차감 또는 leader election 테이블 갱신처럼 불변식이 민감한 트랜잭션만 분리하는 식입니다.

즉 의사결정의 핵심은 “Serializable이 더 안전하냐"가 아니라, 이 경로에서 abort 비용을 감수할 만큼 불변식 위반 비용이 큰가입니다. 결제 이중 승인, 초과 판매, 권한 충돌처럼 복구 비용이 큰 경로라면 정답은 대체로 yes입니다.

4) Serializable만 보지 말고, 제약조건과 잠금으로 더 좁게 해결할 수 있는지 먼저 본다

많은 경우 가장 좋은 답은 격리 수준을 무조건 올리는 게 아니라, 불변식을 DB가 직접 이해할 수 있는 형태로 바꾸는 것입니다.

예를 들어 아래 같은 접근이 더 단단합니다.

  • 가능한 경우 UNIQUE, EXCLUDE, CHECK 제약조건으로 규칙을 표현한다.
  • 집합 규칙을 별도 집계 행(counter row, aggregate row)으로 축약해 한 행 충돌로 바꾼다.
  • 필요한 경우에만 SELECT ... FOR UPDATE나 advisory lock으로 충돌 대상을 명시한다.
  • 실패 시 Idempotency와 짧은 backoff 재시도로 사용자 경험을 지킨다.

예를 들어 “활성 leader는 shard당 1개” 규칙은 (shard_id, active=true) 조합을 제약조건으로 밀어 넣을 수 있으면 가장 간단합니다. 반면 “당직 의사 최소 1명 유지"처럼 집합 카운트 규칙은 별도 duty summary row를 두고 그 행을 잠그는 식으로 단순화할 수 있습니다. 불변식이 단일 충돌 지점으로 축약되면 Serializable 전면 적용 없이도 상당수 문제를 해결할 수 있습니다.

5) 재시도 설계가 없으면 Serializable 도입은 오히려 장애를 만든다

직렬화 실패는 버그가 아니라 예상된 운영 이벤트입니다. 그런데 애플리케이션이 이를 일반 500 에러로 흘려보내면, 사용자는 같은 버튼을 다시 누르고 상위 서비스는 또 재시도하면서 오히려 부하가 커집니다.

실무 기준은 보통 아래 정도가 무난합니다.

  • 직렬화 실패 재시도는 최대 2~3회
  • 최초 재시도 대기 20~50ms, 이후 지수 백오프
  • 사용자 요청 전체 deadline의 20~25% 이상을 재시도에 쓰지 않기
  • 멱등 키 없는 쓰기 요청은 자동 재시도 금지

이 기준은 Timeout/Retry/Backoff와 함께 봐야 합니다. DB 안에서 안전해도 API 바깥에서 중복 요청이 생기면 결국 다른 계층에서 사고가 납니다.

실무 적용

1) 언제 Snapshot Isolation으로 충분한가

아래 조건을 대부분 만족하면 Snapshot Isolation 또는 기본 MVCC 격리 수준으로도 충분한 경우가 많습니다.

  • 한 요청이 보통 1개 aggregate 또는 1개 행 묶음만 수정한다.
  • 불변식이 단일 행의 버전 검사나 unique key로 표현 가능하다.
  • 동일 키 경합이 초당 10회 미만이고, 충돌 시 사용자 재시도로 흡수 가능하다.
  • 잘못 커밋돼도 보정 배치나 보상 트랜잭션으로 회복 비용이 낮다.
  • 트랜잭션 p95가 50ms 이하, 외부 API 호출을 포함하지 않는다.

대표적으로 프로필 수정, 상태 플래그 변경, 단일 주문 row 상태 전이 등은 이 범주에 들어갑니다.

2) 언제 Serializable 또는 명시적 잠금을 우선 검토해야 하나

아래 중 1개라도 해당하면 한 번은 강하게 의심해야 합니다.

  • 여러 행의 합/개수/부재 여부가 비즈니스 규칙을 결정한다.
  • 초과 판매, 이중 승인, 권한 중복 부여처럼 잘못 커밋 시 금전/보안 영향이 크다.
  • 동일 조건을 읽고 서로 다른 행을 갱신하는 패턴이 분당 30회 이상 발생한다.
  • 장애 회복보다 사전 차단이 훨씬 싸다.
  • 운영 중 재현이 어렵고, 포스트모템에서 원인 규명이 오래 걸리는 유형이다.

이 경우 권장 우선순위는 보통 아래 순서입니다.

  1. 제약조건으로 표현 가능한지 확인
  2. aggregate row 또는 lock row로 충돌 지점 축약
  3. 그다음 Serializable 또는 FOR UPDATE 적용
  4. 마지막으로 재시도와 서킷 브레이크 조건 추가

즉 격리 수준을 올리는 것은 중요한 도구지만, 늘 첫 번째 카드일 필요는 없습니다.

3) 운영 지표와 알람 기준

도입 후에는 “안전해졌다” 느낌보다 숫자를 봐야 합니다. 최소 아래 5개는 같이 추적하는 편이 좋습니다.

  • serialization_failure_rate: 전체 쓰기 트랜잭션 대비 0.5~2% 이내 유지 목표
  • lock_wait_p95: 핵심 경로 기준 100ms 초과 시 점검
  • transaction_duration_p95: 보호 경로 150ms 초과 시 외부 호출/쿼리 수 재검토
  • retry_success_rate: 재시도 후 성공률 80% 미만이면 구조적 경합 가능성 의심
  • invariant_violation_detected: 0이 목표, 1건이라도 Sev 분류

실무적으로는 serialization_failure_rate가 3%를 넘는데도 비즈니스 이득이 뚜렷하지 않다면, 전역 Serializable보다 설계 축약이 더 맞을 가능성이 큽니다.

4) 2주 도입 플레이북

1주차

  • 집합 불변식이 있는 트랜잭션 5개를 선정합니다.
  • 각 트랜잭션에 대해 읽는 집합과 쓰는 집합이 같은지 표로 정리합니다.
  • 제약조건, lock row, Serializable 중 어떤 수단이 맞는지 1차 분류합니다.

2주차

  • 가장 위험한 1개 경로에만 보호 장치를 적용합니다.
  • 직렬화 실패 재시도와 멱등 키를 같이 넣습니다.
  • 부하 테스트에서 경합 비율 5%, 10%, 20% 시 abort율과 p95를 비교합니다.
  • 운영 알람 기준을 고정하고 포스트모템 템플릿에 invariant 항목을 추가합니다.

5) 의사결정 기준 요약

  • 정합성 우선 경로: 금전, 권한, 재고, leader election, 예약
  • 처리량 우선 경로: 통계 집계, 비핵심 상태 동기화, 보정 가능한 비동기 작업
  • 먼저 볼 것: 규칙을 제약조건으로 내릴 수 있는가
  • 그다음 볼 것: 충돌 지점을 한 행으로 줄일 수 있는가
  • 마지막 카드: 넓은 범위 Serializable 전면 적용

이 우선순위를 지키면 “락을 세게 걸어서 안전"과 “대충 MVCC라 괜찮음” 사이에서 흔들리는 시간을 많이 줄일 수 있습니다.

트레이드오프/주의점

  1. Serializable은 안전하지만, 긴 트랜잭션과 만나면 비용이 빠르게 커집니다.
    조회 후 외부 API 호출, 대량 배치 처리, 사용자 입력 대기 같은 흐름과 섞으면 abort와 락 대기가 급증합니다.

  2. 명시적 잠금은 이해하기 쉽지만, 잠금 순서가 어긋나면 데드락 비용을 다시 떠안습니다.
    따라서 락 기반 접근을 택했다면 접근 순서와 재시도 정책을 같이 문서화해야 합니다.

  3. 애플리케이션 레벨 검증만으로는 늦을 수 있습니다.
    두 요청이 거의 동시에 들어오면 둘 다 검증을 통과한 뒤 커밋 단계에서야 문제가 드러납니다. 가능한 규칙은 DB 제약으로 내리는 편이 낫습니다.

  4. 재시도는 복구 수단이지 면죄부가 아닙니다.
    abort가 잦다는 건 경합 설계가 거칠다는 뜻일 수 있습니다. 재시도율이 높아지면 구조를 다시 봐야 합니다.

체크리스트 또는 연습

체크리스트

  • 이 트랜잭션의 규칙이 단일 행인지, 집합 불변식인지 분류돼 있다.
  • 집합 불변식이면 제약조건 또는 lock row로 축약 가능한지 검토했다.
  • Serializable/잠금 적용 경로에는 멱등 키와 재시도 상한이 정의돼 있다.
  • serialization_failure_rate, lock_wait_p95, retry_success_rate를 대시보드에서 본다.
  • 불변식 위반 발생 시 Sev 기준과 즉시 완화 절차가 정해져 있다.

연습 과제

  1. 현재 서비스에서 “여러 행을 함께 봐야 하는 규칙” 3개를 골라 write skew 가능성을 점검해 보세요.
  2. 그중 1개를 골라 제약조건, lock row, Serializable 세 가지 해법의 장단점을 표로 비교해 보세요.
  3. 직렬화 실패를 강제로 발생시키는 부하 테스트를 만든 뒤, 재시도 0회/1회/2회에서 성공률과 p95 차이를 측정해 보세요.

관련 글