1. 배경
InnoDB에서는 여러 트랜잭션이 동시에 동일한 데이터를 변경할 때 일관성을 보장하기 위해 Lock을 사용한다
하나의 트랜잭션이 특정 Record를 변경하고 있다면 다른 트랜잭션이 동일한 Record를 동시에 변경할 수 없도록 Lock을 획득한다
-- Transaction A
START TRANSACTION;
UPDATE member
SET name = 'A'
WHERE id = 1;
그렇다면 특정 Record에 어떤 트랜잭션이 Lock을 획득하고 있으며,
다른 트랜잭션이 해당 Lock을 기다리고 있다는 정보는 InnoDB 내부에서 어떻게 관리될까?
단순하게 생각하면 Record 내부에 Lock 상태를 함께 저장하는 방법을 떠올릴 수도 있다
하지만 하나의 Record에는 여러 트랜잭션의 Lock 요청이 존재할 수 있고,
Lock의 종류와 호환 여부에 따라 일부 요청은 허용하고 일부 요청은 대기시켜야 한다
따라서 단순한 상태값만으로는 현재 Lock을 가진 트랜잭션과 대기 중인 트랜잭션의 관계를 관리하기 어렵다
이번 글에서는 InnoDB의 Lock System에 대해 다음 내용을 중심으로 살펴보고자 한다
- Record Lock의 내부 표현
- Lock Hash Table과 Lock Queue
- Lock을 획득하지 못한 트랜잭션의 대기 과정
- Lock 해제와 대기 스레드의 Wake-up
- Lock System의 동시성 제어와 Sharding
- Wait-for Graph를 이용한 Deadlock 탐지
2. InnoDB의 Lock은 내부에서 어떻게 표현될까?
InnoDB는 Record 내부에 Lock 상태를 직접 저장하는 대신,
Lock에 대한 정보를 별도의 객체로 관리한다
MySQL의 InnoDB 구현에서는 이를 lock_t 구조체로 표현한다
struct lock_t {
trx_t* trx;
dict_index_t* index;
lock_t* hash;
uint32_t type_mode;
union {
lock_table_t tab_lock;
lock_rec_t rec_lock;
};
/* 중략 */
}
실제 구조체에는 더 많은 필드가 존재하지만,
Record Lock의 동작을 이해하기 위해서는 우선 다음 정보에 주목할 수 있다
- trx : 해당 Lock을 소유한 트랜잭션
- type_mode : Lock의 종류와 Mode, 대기 여부 등의 상태를 Bit Flag로 표현
- index : Record Lock이 걸린 Index
- rec_lock : Record Lock에 필요한 정보
- page_id : Bitmap이 가리키는 Page의 ID
- n_bits : Bitmap의 길이(8의 배수) - hash : 동일한 Hash Bucket에 속한 다음 lock_t 를 가리키는 링크
이처럼 lock_t 는 단순히 Lock이 걸렸는지에 대한 단일 상태만을 나타내는 것이 아니라,
어떤 트랜잭션이 어떤 Index의 Record에 Lock을 요청했고 어떤 상태인지를 같이 표현한다
특히 type_mode 는 이름 그대로 Lock Type과 Mode를 하나의 정수 값에 Bit Flag 형태로 저장한다
예를 들어 Record Lock 여부, Gap Lock 여부, Insert Intention Lock 여부와 함께
해당 Lock이 대기 중인지 나타내는 LOCK_WAIT 등의 상태가 조합될 수 있다

그렇다면 이러한 정보를 가진 lock_t 객체는 Record 하나마다 생성되는 형태일까?
3. Lock은 어디에서 관리될까?
InnoDB는 Record Lock을 적절히 관리하기 위해 Lock 상태를 관리하기 위한 Lock Hash Table을 사용한다
Record Lock은 Record가 위치한 Page를 기준으로 Hash 값을 계산하고,
해당 값에 대응하는 Hash Bucket에서 관련 Lock 객체를 탐색한다
Hash Table의 각 Bucket에는 하나의 lock_t 만 존재하는 것이 아니라 여러 Lock 객체가 함께 저장될 수 있다
서로 다른 Page가 동일한 Bucket에 매핑되는 Hash Collision이 발생할 수 있고,
동일한 Page에도 여러 트랜잭션의 Lock 요청이 존재할 수 있기 때문이다
InnoDB는 이러한 Lock 객체들을 Singly Linked List로 연결하는 Separate Chaining 방식으로 관리한다

하지만 Hash Bucket을 찾았다고 해서 특정 Record의 Lock을 바로 찾은 것은 아니다
하나의 Bucket에는 여러 Page에 대한 lock_t 가 함께 존재할 수 있기 때문에,
InnoDB는 Bucket 내부의 Lock 객체들을 순회하며 자신이 찾고 있는 Page에 해당하는 lock_t 를 확인해야 한다
그리고 Record Lock의 경우 lock_t 뒤에 위치한 Bitmap을 통해 해당 Page 내부의 어떤 Record가 Lock의 대상인지 판단한다
Bitmap의 각 Bit Index는 Page 내부 Record의 heap_no 에 대응한다
heap_no 는 Page 내부에서 Record를 식별하기 위해 사용되는 번호로,
Lock System에서는 Page와 heap_no 를 함께 이용해 특정 Record를 구분할 수 있다
예를 들어 다음과 같은 Bitmap이 존재한다고 해보자

예를 들어 heap_no = 2 , heap_no = 4 Record를 Lock 대상으로 가진다고 볼 수 있다
이 구조를 통해 InnoDB는 전체 Lock 객체를 순차적으로 판단하지 않고,
Hash Table을 이용해 관련 Lock의 탐색 범위를 줄일 수 있다
또한 동일한 Record에 대한 여러 Lock 요청은 이러한 구조를 기반으로 논리적인 Lock Queue를 형성한다
다만 이를 별도의 Queue 자료구조로 관리하는 것은 아니다
Linked List를 순회하면서 page_id 와 heap_no 를 기준으로 해당 Record에 대한 Lock 요청을 확인하는 방식이다
이 구조는 Page별 별도의 Chain을 추가하지 않고도 Hash Table을 통해 탐색 범위를 먼저 줄이고,
Bucket 내부의 Linked List를 순회하는 비교적 단순한 형태를 유지한다
또한 일반적으로 트랜잭션의 범위를 짧게 유지하면 Lock이 빠르게 해제되어 동시에 유지되는 Lock 객체를 줄일 수 있으므로,
Bucket 내부의 탐색 비용과 Lock System의 경합을 낮추는 데도 도움이 될 수 있다
이는 InnoDB의 명시적인 설계 의도라기보다,
현재 자료구조와 일반적인 OLTP 사용 패턴을 함께 고려했을 때 기대할 수 있는 효과로 보는 편이 적절하다
4. Lock을 획득하지 못한 트랜잭션의 대기 방법
그렇다면 Lock을 획득하지 못해 대기 상태가 된 트랜잭션의 스레드는 실제로 어떤 방식으로 기다리게 될까?
예를 들어 트랜잭션 A, B가 존재하고, 동일한 Record 변경을 시도하고 있다고 해보자
트랜잭션 A가 먼저 Record Lock을 획득하고, 트랜잭션 B도 동일한 Record를 변경하려고 하면 B의 Lock은 대기 상태가 된다
개념적으로 아래와 같은 상태라고 볼 수 있다
Record R
Transaction A
X Lock
GRANTED
Transaction B
X Lock
WAITING
InnoDB에서는 대기 중인 Lock 요청에 LOCK_WAIT 상태를 표시하여 즉시 획득된 Lock과 구분한다
Lock을 획득하지 못한 트랜잭션의 스레드가 Lock이 해제될 때까지 계속 상태를 확인한다면 CPU를 불필요하게 사용하게 된다
따라서 InnoDB는 Lock을 기다리는 스레드를 대기 상태로 전환하여 실행을 중단하고,
해당 스레드가 CPU를 계속 점유하지 않도록 한다
이후 선행 트랜잭션에서 COMMIT 또는 ROLLBACK 이 발생하면 트랜잭션이 보유하고 있던 Lock이 해제된다
Lock이 해제되면 InnoDB는 요청들을 다시 확인하여 현재 상태에서 획득 가능한 Lock이 있는지 판단한다
획득 가능한 Lock은 WAITING 상태에서 획득된 상태로 변경하고 해당 트랜잭션의 스레드도 다시 실행할 수 있게 된다
이때 Lock이 하나 해제되었다고 해서 단순히 다음 Lock 요청 하나를 바로 실행하는 것은 아니다
공식 문서에서는 다음과 같이 설명한다
When we release a lock, locks which conflict with it need to be checked again if they can now be granted.
(출처)
InnoDB는 대기 중인 Lock 요청이 현재 남아 있는 GRANTED Lock들과 충돌하는지 다시 확인하고,
충돌이 없다면 해당 Lock을 GRANTED 상태로 변경한다
충돌이 남아 있다면 WAITING 상태를 유지한다
따라서 하나의 Lock이 해제되었더라도 하나 이상의 Waiting Lock이 새롭게 획득 가능해질 수 있으며,
어떤 요청을 우선적으로 검사할지는 InnoDB의 Lock Scheduling 정책에 따라 결정된다
5. Lock System의 동시성 보장
Lock Hash Table은 여러 트랜잭션의 Lock 요청을 동시에 관리한다
그렇다면 여러 스레드가 동시에 다음과 같은 작업을 수행하면 어떻게 될까?
- 새로운 lock_t 를 추가
- 기존 Lock을 해제
- Waiting Lock의 상태 변경
- Hash Bucket 갱신
- Lock Queue를 순회하며 현재 Lock 상태와 충돌 관계 확인
이러한 작업들이 아무런 동기화 없이 수행된다면 Lock System 내부의 자료구조 자체에서 Race Condition이 발생할 수 있다
따라서 InnoDB는 Lock System 내부 자료구조에 접근할 때 별도의 동기화 장치를 사용한다
과거 InnoDB에서는 Lock System 전체를 보호하기 위해 하나의 전역 Mutex를 사용하는 구조가 중심이었다
이 방식은 구현이 단순하다는 장점이 있지만 동시성이 높아질수록 Lock System 자체가 Critical Section이 될 수 있다
이 문제를 줄이기 위해 MySQL 8.0.21부터 Lock Queue에 대한 동시 접근을 여러 Shard로 분산하는 구조를 도입했다
Lock Hash Table 관점에서는 여러 Hash Bucket을 다시 고정된 수의 Shard로 그룹화하는 형태로 이해할 수 있다
Hash Bucket은 lock_t 를 탐색하기 위한 자료구조이고,
Shard는 해당 Lock Queue에 대한 동시 접근을 어떤 Mutex가 보호할지를 결정하는 동기화 단위다
따라서 Hash Bucket의 갯수와 Shard는 서로 독립적으로 결정될 수 있다
이 구조를 통해 서로 다른 Shard에 대한 Lock 요청은 동시에 처리될 수 있으며,
하나의 전역 Mutex에 모든 요청이 집중되는 문제를 줄일 수 있다
다만 Shard 단위의 Mutex만으로 모든 작업을 처리할 수 있는 것은 아니다
일반적인 Lock 작업은 Lock System에 대한 Global Latch를 Shared 상태로 획득한 뒤,
필요한 Shard의 Mutex만 보호함으로써 서로 다른 Shard 간 병렬성을 유지한다
반면 Lock System 전체에 대해 일관된 상태가 필요한 일부 전역 작업에서는 Global Latch를 Exclusive 상태로 획득한다
이를 통해 다른 Shard 단위 접근을 일시적으로 차단할 수 있다
6. Deadlock의 탐지와 해결
여러 트랜잭션이 각각 Lock을 보유한 상태에서 서로가 가진 Lock이 해제되기를 기다리면 Deadlock이 발생할 수 있다
아래와 같은 상황이 그 예시이다

InnoDB는 이러한 대기 관계를 Wait-for Graph 형태로 관리하고,
Graph에 Cycle이 존재하는지를 확인하여 Deadlock을 탐지한다
Deadlock이 탐지되면 InnoDB는 Cycle에 포함된 트랜잭션 중 하나를 Victim으로 선택해 Rollback한다
이때 단순히 가장 늦게 시작한 트랜잭션을 선택하는 것이 아닌, 상대적으로 Rollback 비용이 작은 트랜잭션을 선택하려고 한다
InnoDB는 트랜잭션의 상대적인 크기를 나타내는 TRX_WEIGHT 를 기준으로 Deadlock Victim을 선택한다
공식 문서에선 TRX_WEIGHT 에 대해 다음과 같이 설명한다
The weight of a transaction, reflecting (but not necessarily the exact count of) the number of rows altered and the number of rows locked by the transaction. To resolve a deadlock, InnoDB selects the transaction with the smallest weight as the “victim” to roll back. (출처)
TRX_WEIGHT 는 트랜잭션이 변경한 Row 수와 Lock을 획득한 Row 수 등을 반영한 값이며,
Deadlock 해결 시 InnoDB는 가장 작은 TRX_WEIGHT 를 가진 트랜잭션을 Victim으로 선택한다
따라서 이를 단순히 Undo Log가 가장 짧은 트랜잭션을 Rollback한다고 표현하기보다는,
변경 및 Lock 범위가 상대적으로 작은 트랜잭션을 선택하여 Rollback 비용을 줄이려는 방식으로 이해하는 편이 적절하다
7. 대기 중인 Lock의 처리 순서
Deadlock이 발생하지 않은 일반적인 Lock 대기 상황에서는 여러 Waiting Lock 중 어떤 요청을 먼저 처리할지도 결정해야 한다
앞서 살펴본 것처럼 InnoDB는 동일한 Record에 대한 요청들을 논리적인 Lock Queue로 관리하며,
GRANTED Lock과 WAITING Lock을 서로 다른 Group으로 구분한다
GRANTED Lock은 Queue의 앞쪽 Group에, WAITING Lock은 뒤쪽 Group에 위치한다

새롭게 획득한 Lock은 GRANTED Group의 앞쪽에 추가되기 때문에,
GRANTED Group은 최신 Lock부터 오래 된 Lock 순서로 유지된다
반면 WAITING Lock은 Queue의 뒤쪽에 추가된다
다만 Waiting Group의 물리적인 순서가 곧 실제 실행 순서를 의미하는 것은 아니다
Lock이 해제된 이후 어떤 Waiting Lock을 우선적으로 검사할지는 InnoDB의 Lock Scheduling 정책에 따라 결정되며,
현재 InnoDB는 CATS(Contention-Aware Transaction Scheduling)을 사용한다
CATS는 Wait-for Graph의 대기 관계를 기반으로 각 트랜잭션의 Weight를 계산하고,
더 많은 트랜잭션의 진행을 막고 있는 Lock 요청을 우선적으로 처리하려 한다
여기서 CATS에서 사용하는 Weight는 앞서 Victim 선정에 사용한 TRX_WEIGHT 와는 다른 개념이다
CATS는 Wait-for Graph에서 해당 트랜잭션에 종속된 대기 관계를 바탕으로 Scheduling 우선순위를 계산한다
이처럼 InnoDB는 Lock Hash Table을 이용해 Lock을 관리하는 것에 그치지 않고,
Wait-for Graph를 이용해 트랜잭션 간 대기 관계를 추적하고 Deadlock이 발생하면 Victim을 선택해 Cycle을 해소한다
8. 마무리하며
InnoDB의 Lock System은 단순한 Lock 여부보다는,
여러 트랜잭션의 충돌과 대기 관계를 효율적으로 관리할 것인가가 더 중요한 문제임을 알 수 있다
애플리케이션에서도 이러한 내부 동작을 고려해 불필요하게 긴 트랜잭션과 Lock 경합을 줄이는 것이 중요하다
'탐구일지 > Database' 카테고리의 다른 글
| InnoDB에서 Covering Index를 사용해도 테이블 접근이 발생하는 이유 (0) | 2026.07.17 |
|---|