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 트랜잭션은 경계/전파/격리를 다루는 도구고, 격리는 세션(해당 트랜잭션) 범위에서만 바뀐다.
'Loopers' 카테고리의 다른 글
| PG가 터져도 우리 서비스는 멀쩡해야 한다 🔥 (3) | 2025.08.22 |
|---|---|
| 인덱스를 걸었는데… 더 느려졌다? 🤯 (4) | 2025.08.15 |
| 동시성 제어, 도메인 분리를 삼킨 괴물 (4) | 2025.08.08 |
| 설계는 정답이 없다고 했지만, 그래도 너무 어렵잖아 (3) | 2025.08.01 |
| Loopers WIL – 2주차 (0) | 2025.07.24 |