← all posts
DEV 2026.05.02 · 12 min read Intermediate

InnoDB Lock은 왜 인덱스 레코드에 걸리는가

S/X Lock의 호환 행렬부터 Gap Lock Deadlock, 데드락 로그 분석, Optimistic vs Pessimistic 선택까지 — InnoDB Lock 설계의 일관된 원리를 추적한다.


InnoDB는 “Row Lock”을 지원한다고 알려져 있다. 그런데 인덱스 없는 컬럼으로 UPDATE를 실행하면 100만 건 테이블이 통째로 잠긴다. 왜 “Row Lock”이 테이블 전체를 막는가? 그 답은 InnoDB Lock의 근본 원리에 있다 — Lock은 Row가 아니라 인덱스 레코드에 걸린다.

Lock의 출발점: 호환 행렬

InnoDB Lock은 두 가지 축으로 구성된다. 하나는 보유자와 요청자 사이의 호환성이고, 다른 하나는 Lock이 걸리는 위치다.

호환 행렬부터 보자.

요청 \ 기존   S Lock    X Lock
S Lock         ✅ 공존   ❌ 차단
X Lock         ❌ 차단   ❌ 차단

S Lock(Shared)끼리는 공존한다. X Lock(Exclusive)은 다른 모든 Lock과 충돌한다. 이 단순한 규칙이 “여러 트랜잭션이 동시에 읽을 수 있지만, 쓰는 동안에는 독점된다”는 동시성 모델을 만든다.

여기에 Intention Lock(IS/IX)이 추가된다. Row에 Lock을 걸기 전에 InnoDB는 테이블 레벨에 먼저 IS 또는 IX를 자동으로 건다. 덕분에 LOCK TABLES가 “어딘가 Row Lock이 있는가?”를 판단할 때 100만 건을 순회하지 않고 테이블 레벨 IX의 존재 여부만 확인하면 된다. O(N) → O(1).

트레이드오프

FOR SHARE(S Lock)는 여러 트랜잭션이 동시에 같은 Row를 읽을 수 있게 허용하지만, 이후 UPDATE로 업그레이드할 때 S Lock 업그레이드 데드락이 발생한다. 두 트랜잭션이 모두 S Lock을 보유한 채 X Lock을 요청하면 서로가 서로를 기다리는 순환이 만들어진다. 수정 의도가 있다면 처음부터 FOR UPDATE를 써야 한다.

인덱스 레코드에 걸리는 Lock

InnoDB에서 Row Lock의 정확한 대상은 “Row 자체”가 아니라 “Row를 가리키는 인덱스 레코드”다. Clustered Index가 데이터 자체이므로, Row를 찾는 경로가 Lock의 경로가 된다.

WHERE user_id = 1 FOR UPDATE를 실행하면:

Secondary Index(idx_user) Leaf:
  [user_id=1, PK=10] ← Lock
  [user_id=1, PK=25] ← Lock

Clustered Index Leaf:
  [PK=10, ...] ← Lock
  [PK=25, ...] ← Lock

인덱스가 있으면 6개 레코드에만 Lock이 걸린다. 인덱스가 없으면 Clustered Index를 처음부터 끝까지 스캔하며 모든 레코드에 Lock을 시도한다. 이것이 “인덱스 없는 DML = 사실상 Table Lock”인 이유다.

READ COMMITTEDSemi-consistent Read는 조건 불일치 Row의 Lock을 즉시 해제해 피해를 줄여주지만, 여전히 전체를 스캔한다. 근본 해결책은 인덱스 추가다.

Gap Lock과 Phantom Read

InnoDB REPEATABLE READ의 기본 Lock 단위는 Next-Key Lock이다. 이는 Record Lock과 그 앞의 Gap Lock을 합친 것이다.

인덱스 값이 [10, 20, 30]일 때 WHERE id = 25 FOR UPDATE를 실행하면 — id=25는 존재하지 않는다 — (20, 30) 사이의 Gap에만 Lock이 걸린다. 이 공간에 새 Row를 삽입하려는 시도는 차단된다.

-∞ | 10 | 20 | 30 | +∞

         Gap Lock
         (20, 30)

이것이 Phantom Read를 막는 원리다. 같은 범위를 다시 읽어도 그 사이에 삽입이 없었으므로 결과가 동일하다.

그런데 Gap Lock은 데드락의 새로운 원인이 된다. Gap Lock끼리는 공존할 수 있지만, Gap Lock + Insert Intention Lock은 충돌한다. 두 트랜잭션이 같은 Gap에 Gap Lock을 보유한 채 각각 INSERT를 시도하면 서로의 Gap Lock 때문에 Insert Intention Lock을 얻지 못하고 순환 대기에 빠진다.

READ COMMITTED로 격리 수준을 낮추면 Gap Lock이 비활성화되어 이 종류의 데드락은 사라진다. 대신 Phantom Read가 허용되고, Statement-based Replication이 안전하지 않게 된다. Row-based Replication을 사용하는 MySQL 8.0 환경이라면 허용되는 트레이드오프다.

데드락: 순환 의존의 해결

데드락은 무작위가 아니다. Coffman의 4가지 조건 — 상호 배제, 보유하며 대기, 선점 불가, 순환 대기 — 이 모두 충족될 때 발생한다. InnoDB는 Wait-for Graph의 DFS로 사이클을 감지하고, trx_weight(수정 행 수 + Lock 행 수)가 가장 낮은 트랜잭션을 Victim으로 ROLLBACK한다. Victim은 ERROR 1213을 받는다.

가장 실용적인 예방책은 순환 대기 조건을 제거하는 것이다. 항상 오름차순으로 Lock을 획득하면 사이클이 만들어지지 않는다.

Long first  = Math.min(fromId, toId);
Long second = Math.max(fromId, toId);
accountRepo.findByIdWithLock(first);   // 항상 작은 id 먼저
accountRepo.findByIdWithLock(second);

데드락이 발생했을 때는 SHOW ENGINE INNODB STATUS\GLATEST DETECTED DEADLOCK 섹션을 읽는다. 구조는 단순하다: (N) HOLDS THE LOCK(S)는 이 트랜잭션이 보유한 Lock, (N) WAITING FOR THIS LOCK은 기다리는 Lock. hex 값을 SELECT CONV('hex값', 16, 10)으로 변환하면 PK를 확인할 수 있다. 모든 데드락을 파일에 남기려면 innodb_print_all_deadlocks = ON을 설정한다.

Optimistic vs Pessimistic — 충돌 빈도가 결정한다

Optimistic Lock(@Version)은 읽을 때 Lock 없이, 저장할 때 버전 CAS로 충돌을 감지한다.

-- Hibernate가 생성하는 SQL
UPDATE products
SET stock = 7, version = 6          -- version + 1
WHERE id = 1 AND version = 5        -- 내가 읽은 버전
-- affected = 0이면 ObjectOptimisticLockingFailureException

충돌이 없을 때는 Lock 오버헤드가 없어 유리하다. 하지만 충돌이 빈번하면 재시도가 폭발한다. 10,000명이 같은 좌석을 동시에 예약하려 할 때, Optimistic은 9,999번의 예외와 수만 번의 재시도를 만든다. Pessimistic(FOR UPDATE)은 9,999개가 순서대로 대기했다가 빠르게 “이미 예약됨”을 반환한다.

트레이드오프

충돌률 < 10%: Optimistic 유리. 충돌률 > 30%: Pessimistic 유리. 외부 시스템 호출이 포함된 트랜잭션에 Optimistic을 쓰면, 재시도 시 외부 호출이 중복 실행된다. 결제 API가 두 번 호출되는 상황이 만들어질 수 있다.

정리

  • InnoDB Lock은 인덱스 레코드에 걸린다. 인덱스 없는 DML은 Clustered Index Full Scan → 사실상 Table Lock.
  • Next-Key Lock = Record Lock + Gap Lock. REPEATABLE READ에서 Phantom Read를 막는 대신 Gap Lock Deadlock 위험이 생긴다.
  • 데드락은 순환 의존이 원인이다. Lock 획득 순서를 오름차순으로 통일하면 원천 차단할 수 있다.
  • SHOW ENGINE INNODB STATUSLATEST DETECTED DEADLOCK에서 HOLDS/WAITING과 hex→int 변환으로 원인을 특정할 수 있다.
  • Optimistic(@Version)은 충돌이 드문 경우, Pessimistic(FOR UPDATE)은 충돌이 빈번하거나 외부 호출이 포함될 때 선택한다.

다음 글에서는 이 Lock들이 MVCC와 어떻게 공존하는지, Read View가 Consistent Read를 어떻게 구현하는지 추적한다.