home
Real MySQL 8.0

[5장 트랜잭션과 잠금] InnoDB 스토리지 엔진 잠금

InnoDB가 인덱스를 기준으로 레코드를 잠그는 방식과, 레코드 락·갭 락·넥스트 키 락·자동증가 락이 각각 무엇을 어떻게 잠그는지 정리한다.

InnoDB는 스토리지 엔진 차원에서 레코드 기반의 잠금을 제공한다. 덕분에 MyISAM처럼 테이블 전체를 잠그지 않고 필요한 레코드만 잠글 수 있어, 여러 세션이 같은 테이블을 동시에 변경하는 상황에서도 동시성이 크게 높아진다.

잠금은 인덱스에 걸린다

InnoDB의 잠금을 이해하려면 한 가지 전제를 먼저 잡아야 한다. InnoDB는 테이블 데이터를 기본 키(PK) 인덱스 안에 저장한다. 이 인덱스의 잎에 행 데이터가 통째로 들어 있어서, PK 인덱스가 곧 데이터 그 자체다. 이런 구조를 클러스터드 인덱스라고 한다.

그래서 InnoDB에는 행을 직접 잠그는 방법이 따로 없다. 잠금은 항상 인덱스 항목에 붙는 표식으로 구현된다. 어떤 레코드를 잠근다는 것은, 실제로는 그 레코드로 가는 인덱스 항목을 잠근다는 뜻이다.

PK가 아닌 보조 인덱스로 검색하면 두 군데가 잠긴다. 보조 인덱스는 데이터를 갖지 않고 (인덱스 컬럼 값, PK 값)만 담은 이정표이므로, 검색에 사용한 보조 인덱스 항목과 그 항목이 가리키는 클러스터드 인덱스 레코드가 함께 잠긴다.

이 전제 위에서 뒤에 나오는 레코드 락, 갭 락, 넥스트 키 락이 동작한다. 이 사실을 놓치면 "분명히 한 건만 바꿨는데 왜 다른 쿼리가 줄줄이 밀리지?" 같은 현상이 이해되지 않는다.

한 가지 더. 여기서 말하는 잠금은 데이터를 변경하는 쿼리(UPDATE, DELETE)나 잠금을 요구하는 읽기(SELECT ... FOR UPDATE)에서 발생한다. 잠금 없이 실행되는 일반 SELECT는 여기에 해당하지 않는다. 일반 조회가 어떻게 잠금 없이 일관된 결과를 읽는지는 격리 수준과 MVCC를 다루는 다음 글에서 이어간다.

레코드 락

레코드 락은 인덱스의 레코드 하나를 잠그는 잠금이다. 앞서 말했듯 잠그는 대상은 데이터 레코드가 아니라 인덱스의 레코드다. 이 차이가 실제로 어떤 결과를 만드는지 예를 보자.

CREATE TABLE member (
  id   INT PRIMARY KEY,
  name VARCHAR(20),
  age  INT,
  KEY ix_age (age)
);

age 컬럼에는 인덱스가 있고, id는 기본 키다. 이 테이블에서 인덱스를 타는 조건으로 변경하면 해당 레코드만 잠긴다.

UPDATE member SET name = '홍길동' WHERE id = 5;   -- id=5 레코드 하나만 잠금

문제는 인덱스가 없는 컬럼을 조건으로 변경할 때다. 조건에 쓸 인덱스가 없으면 InnoDB는 테이블을 처음부터 끝까지 훑으면서 조건을 검사하는데, 이 과정에서 스캔한 모든 레코드에 잠금을 건다. 결과만 한 건이더라도 잠기는 레코드는 전부다.

UPDATE member SET age = 20 WHERE name = '홍길동';  -- name에 인덱스 없음 → 사실상 전체 레코드 잠금

그래서 InnoDB에서 잠금 범위는 "몇 건을 바꿨느냐"가 아니라 "어떤 인덱스로 몇 건을 훑었느냐"로 결정된다. 변경 쿼리의 조건에 적절한 인덱스가 없으면, 한 건을 바꾸려다 테이블 전체를 잠그는 셈이 된다.

갭 락

갭 락은 레코드와 레코드 "사이의 간격(gap)"을 잠그는 잠금이다. 레코드 자체가 아니라 그 사이의 빈 구간을 잠근다는 점이 특이하다. 갭 락이 하는 일은 하나다. 그 간격에 새로운 레코드가 INSERT되는 것을 막는다.

이게 왜 필요한지는 팬텀 리드(phantom read)를 알면 이해된다. 한 트랜잭션이 같은 조건으로 두 번 조회했는데, 그 사이에 다른 트랜잭션이 조건에 맞는 레코드를 INSERT하면, 첫 번째 조회에는 없던 행이 두 번째 조회에서 나타난다. 이렇게 없던 행이 튀어나오는 것이 팬텀 리드다. 갭 락은 조회 범위의 간격을 잠가서, 그 사이에 새 레코드가 끼어드는 것을 막아 팬텀을 방지한다.

갭 락은 단독으로 쓰이는 경우가 거의 없고, 대부분 다음의 넥스트 키 락을 구성하는 일부로 동작한다.

넥스트 키 락

넥스트 키 락은 레코드 락과 갭 락을 합친 잠금이다. 인덱스 레코드 하나와, 그 레코드 바로 앞의 간격을 함께 잠근다. InnoDB가 REPEATABLE READ 격리 수준에서 범위를 잠글 때 기본으로 사용하는 방식이 바로 이 넥스트 키 락이다.

-- age 10 ~ 20 범위를 잠그면
SELECT * FROM member WHERE age BETWEEN 10 AND 20 FOR UPDATE;

이 경우 범위에 걸린 레코드뿐 아니라 그 사이의 간격까지 잠기므로, 다른 세션은 age = 15인 레코드를 새로 INSERT하지 못하고 대기한다. 범위 조회 결과가 트랜잭션 내내 바뀌지 않도록 보장하는 것이다.

넥스트 키 락이 필요한 또 다른 이유는 복제다. 바이너리 로그를 STATEMENT 포맷으로 복제할 때, 소스 서버와 레플리카에서 쿼리가 같은 결과를 내려면 실행 도중 범위 안으로 레코드가 끼어들지 않아야 한다. 넥스트 키 락이 이 조건을 보장한다.

다만 넥스트 키 락은 개발자가 의도한 것보다 넓은 범위를 잠그기 쉽고, 그만큼 잠금 대기나 데드락을 유발하기 쉽다. 그래서 운영에서는 바이너리 로그를 ROW 포맷으로 두어 넥스트 키 락이 잡히는 범위를 줄이는 방향을 권장한다.

자동증가 락

자동증가 락은 AUTO_INCREMENT 컬럼이 있는 테이블에서 새 레코드를 저장할 때 걸리는 테이블 수준의 잠금이다. 여러 커넥션이 동시에 INSERT하더라도 자동증가 값이 중복되거나 꼬이지 않고 하나씩 순차적으로 부여되도록 보장하기 위한 장치다. 사용자가 명시적으로 획득하거나 해제할 수는 없다.

이 잠금은 새 레코드를 저장하는 INSERT나 REPLACE 같은 쿼리에서만 걸리고, UPDATE나 DELETE에서는 걸리지 않는다. 또한 트랜잭션과 무관하게 동작한다. 자동증가 값을 부여하는 아주 짧은 순간에만 걸렸다가 즉시 해제되며, 트랜잭션의 커밋이나 롤백을 기다리지 않는다.

여기서 운영상 알아둘 점이 하나 있다. 자동증가 값은 한 번 부여되면 트랜잭션이 롤백되어도 되돌려지지 않는다. INSERT가 번호를 받은 뒤 롤백되면 그 번호는 그냥 건너뛰어지고, 다음 INSERT는 그다음 번호를 받는다. 그래서 자동증가 컬럼 값에는 중간중간 빈 번호가 생길 수 있고, 이 값을 "빠짐없이 이어지는 일련번호"로 가정하고 설계하면 안 된다.

자동증가 락의 구체적인 동작은 innodb_autoinc_lock_mode 설정으로 조절한다. 이 값에 따라 테이블 락을 잡을지, 더 가벼운 방식으로 값만 채번할지가 달라지며, MySQL 8.0의 기본값은 잠금 경합을 최소화하는 모드다. 대신 이 모드에서는 동시에 실행된 INSERT들 사이에서 자동증가 값이 연속적이지 않게 부여될 수 있다.

보충 개념

바이너리 로그 포맷과 잠금 범위

넥스트 키 락이 범위를 넓게 잠그는 근본 이유는 복제에 있다. MySQL은 데이터를 변경하는 모든 작업을 바이너리 로그에 기록하고, 레플리카는 이 로그를 받아 자신에게 똑같이 적용해 소스 서버와 같은 데이터를 유지한다.

이 로그를 기록하는 방식은 두 가지다. STATEMENT 포맷은 실행한 SQL 문장을 그대로 남기고, 레플리카는 그 문장을 다시 실행한다. ROW 포맷은 그 문장으로 실제 바뀐 행의 결과를 남기고, 레플리카는 SQL을 재실행하지 않고 바뀐 행을 그대로 반영한다.

STATEMENT 포맷에서는 레플리카가 문장을 재실행하므로, 소스 서버와 레플리카가 반드시 같은 결과를 내야 한다. 범위를 다루는 쿼리가 실행되는 도중에 다른 트랜잭션이 그 범위 안으로 레코드를 INSERT하면, 소스 서버와 레플리카에서 결과가 달라질 수 있다. 이를 막기 위해 InnoDB는 범위 자체를 잠가 그 사이에 새 레코드가 끼어들지 못하게 한다. 넥스트 키 락이 개별 레코드를 넘어 간격까지 잠그는 이유가 여기에 있다.

ROW 포맷에서는 레플리카가 SQL을 재실행하지 않고 바뀐 행의 결과만 반영한다. 재실행의 결과를 맞출 필요가 없으므로, 그 사이에 다른 트랜잭션이 무엇을 INSERT하든 복제 정합성에는 영향을 주지 않는다. 그래서 범위를 미리 잠글 이유가 줄고, 넥스트 키 락이 잡히는 범위도 함께 줄어든다.

포맷로그에 남기는 것레플리카 동작범위 잠금
STATEMENT실행한 SQL 문장SQL 재실행넓게 필요
ROW바뀐 행의 결과바뀐 행 그대로 반영줄어듦

댓글

아직 댓글이 없습니다.