Loopers

락은 왜 느리고, MVCC는 무엇을 바꾸었나

그zi운아이 2025. 8. 10. 22:05

1) 락은 왜 느려질까 → 그래서 MVCC가 나왔다

  • 락의 본질: “같은 리소스를 만지는 트랜잭션을 순서로 묶는다.”
    순서를 강제하면 안전은 올라가지만 대기(Blocking) 가 생긴다.
  • 읽기까지 줄 세우면: 쓰기 충돌이 많을수록 읽기도 연쇄적으로 대기 → TPS 하락, 지연 증가, 데드락 위험 상승.
  • 해결 철학: “읽기는 줄 세우지 말자.” → MVCC (Multi-Version Concurrency Control)
    같은 행의 여러 버전을 보관하고, 각 트랜잭션은 자기 시점의 스냅샷만 읽는다.
    읽기는 락 없이 병렬, 쓰기끼리만 충돌 처리.

2) MVCC, 도대체 뭔데 이렇게 빠르지?

  • Undo Log + TRX_ID: InnoDB는 변경 전 값을 Undo Log에 보관하고, 각 레코드에 내부 트랜잭션 ID를 붙인다.
  • Read View(가시성 경계): “어느 트랜잭션까지 커밋된 걸로 볼지” 기준을 만든다.
  • Consistent Read: SELECT는 Undo를 되짚어 해당 시점의 버전을 재구성해 보여준다. 락이 없다.
  • Locking Read는 예외: SELECT … FOR UPDATE/SHARE는 스냅샷이 아니라 실 레코드에 락을 건다(쓰기 충돌/팬텀 방지 목적).

한 줄 요약: MVCC = 읽기 병렬성 극대화. 다만 쓰기-쓰기 충돌은 여전히 락/버전으로 다룬다.


3) InnoDB 락 종류, 이름만 알면 반은 안다

  • S(Shared) Lock: “읽을게.” 다른 S와는 공존, X와는 충돌.
  • X(Exclusive) Lock: “쓸게.” S/X 모두와 충돌(배타).
  • IS/IX(Intention) Lock: 상위 객체(테이블)에 “아래에 S/X 잡을 거야”라고 의사 표시하는 메타 락. 잠금 호환성 계산에 쓰인다.
  • Record Lock: 인덱스 레코드 하나에 거는 락.
  • Gap Lock: 레코드 사이의 갭에 거는 락(삽입 방지).
  • Next-Key Lock: Record + 앞쪽 Gap 묶음. 팬텀을 막는 핵심.
  • Insert Intention Lock: “여기 삽입하려 해”를 알리는 경합 조정용 잠금. 실제 삽입 시 충돌 조정.

왜 인덱스가 목숨줄이냐?
인덱스가 부정확하면 InnoDB가 더 넓은 범위(불필요한 갭/넥스트키)에 락을 건다 → 동시성 붕괴.


4) 격리수준: 무엇을 허용하고 무엇을 막을지의 계약

  • READ COMMITTED (RC): 매 SELECT마다 최신 커밋본 스냅샷.
    Non-Repeatable/Phantom 허용(대신 “신선한” 읽기).
  • REPEATABLE READ (RR): 트랜잭션 첫 Consistent Read 시점 스냅샷을 끝까지 유지.
    Non-Repeatable은 방지. 팬텀은 락 없는 읽기에선 보일 수 있으나, 락 읽기(FOR UPDATE/SHARE)에선 Next-Key로 삽입을 막아 방지.
  • SERIALIZABLE: 읽기조차 사실상 줄 세움. 가장 안전, 가장 비쌈.

실무 기본값은 RR(MySQL/InnoDB) 또는 RC(Oracle/PG).
전역 격리수준을 올리는 대신, 문제 구간만 Locking Read로 보강하는 게 일반적.


5) 비관적 vs 낙관적: 쓰기-쓰기 충돌을 푸는 두 전략

  • 비관적(Pessimistic): SELECT … FOR UPDATE 등으로 미리 잠그고 진행.
    • 적합: 재고/좌석처럼 경합이 잦고 실패 재시도가 비싼 자원.
    • 단점: 대기/데드락/낮은 TPS. WHERE + 인덱스가 부정확하면 범위락 폭발.
  • 낙관적(Optimistic): @Version으로 커밋 시점에 충돌 감지(UPDATE … WHERE id=? AND version=?).
    • 적합: 유저 단위 데이터(포인트/쿠폰 등), 충돌 확률 낮고 재시도 가능.
    • 단점: 충돌 시 예외 + 백오프/횟수 제한 재시도 필요. Write Skew 주의(조건을 Locking Read로 재검증하거나 DB 제약으로 방지).

6) Spring @Transactional — AOP, 전파, 격리 “진짜로” 뭘 함?

  • AOP 프록시가 메서드 호출을 가로챈다 → 커넥션 획득, 오토커밋 끄기, (필요하면) 격리수준 세팅, 메서드 실행, 예외면 롤백, 아니면 커밋.
  • self-invocation 주의: 같은 클래스 내부에서 자기 메서드 호출은 프록시를 안 거칠 수 있어 트랜잭션 미적용.
  • 전파(Propagation)
    • REQUIRED(기본): 있으면 합류, 없으면 새 시작(대부분 여기에 해당).
    • REQUIRES_NEW: 무조건 새 트랜잭션(외부 보류). 부분 커밋/보상 시 유용.
  • 격리수준 옵션
    • @Transactional(isolation = Isolation.REPEATABLE_READ)처럼 메서드 단위로 DB 격리를 올릴 수 있다.
    • 강제로 전역을 바꾸는 게 아니다. 해당 트랜잭션의 커넥션에 한해 세션 레벨로 적용된다.
    • 현실에서는 기본 격리 + Locking Read 조합이 대부분이고, 메서드 격리 변경은 드물지만 가끔 필요(예: RR→RC로 갭락 회피, 특정 흐름만 Serializable 강제).
  • readOnly=true: JDBC/Hibernate에 “쓰기 안 할 거” 힌트. 실제 쓰기 금지는 아님(드라이버/벤더별 상이).

7) 멱등성(Idempotency) — 락 앞단의 “입구 정리”

  • 왜 먼저? 중복 클릭/네트워크 재전송이 락 경쟁에 들어오면 자원 낭비.
    멱등성으로 유효 요청만 뒤로 보낸다.
  • 패턴
    • DB UNIQUE 키(또는 Redis SETNX+TTL)로 원자적 선삽입: 성공=최초 처리자, 충돌=중복.
    • 상태머신: PENDING → SUCCEEDED/FAILED + 결과/오류 저장.
    • TTL/청소: 유효 기간 지난 키 정리.
  • 도메인 예
    • 주문/결제: idempotency_key 유니크 + 기존 결과 재사용
    • 쿠폰 1회성: 애플리케이션 if문이 아니라 DB 제약(유니크·체크)로 물리 차단
 
 

8) 적용 레시피

  • 재고(Product): RR + SELECT … FOR UPDATE(PK/UK로 정확히 한 행 잠그기)
    → 조건 기반이면 복합 인덱스 필수, 트랜잭션 짧게, 타임아웃 짧게.
  • 포인트(Point): 낙관적(@Version) + 지수 백오프 재시도 + 실패율 모니터링
    → 유저 단위, 충돌 낮고 재시도 가능.
  • 쿠폰(Coupon): 낙관적 + DB 제약(UNIQUE) 로 중복 사용 물리 차단.
  • 주문 생성: 멱등성 선행 → 실제 처리 진입 전 중복 제거, 이후 필요한 구간만 락.

9) 자주 터지는 함정 & 회피법

  • 인덱스 없는 FOR UPDATE: 넥스트키/갭락이 광범위로 걸려 동시성 붕괴. → 반드시 인덱스 정밀 매칭.
  • 낙관적락 재시도 무한루프: 백오프 + 최대 횟수 제한 + 사용자 메시지.
  • Write Skew: 조건을 락 잡고 재검증하거나 DB 제약으로 방지.
  • Self-invocation: 같은 클래스 내부 호출로 @Transactional 미적용. → 경계 분리.
  • 긴 트랜잭션: Undo/History 길어지고 purge 지연 → 짧고 단단하게 커밋.

마무리: 한 줄로 정리

MVCC읽기는 자유롭게, 락/버전으로 쓰기는 안전하게, 멱등성으로 입구를 정리하라.
Spring 트랜잭션은 경계/전파/격리를 다루는 도구고, 격리는 세션(해당 트랜잭션) 범위에서만 바뀐다.