InnoDB는 어떻게 생겼는가 (클러스터드 인덱스, 버퍼 풀, redo와 undo, 그리고 락)

PK를 UUID로 잡으면 왜 나쁜지, 긴 트랜잭션이 왜 서버 전체를 느리게 만드는지, 인덱스가 없으면 왜 테이블이 통째로 잠기는지. 세 질문의 답이 전부 InnoDB의 같은 구조에서 나옵니다.

[배경 - 세 질문이 같은 곳을 가리키고 있었다]

인덱스 이야기를 하다 보면 순서대로 나오는 질문들이 있습니다.

  1. 인덱스는 왜 B+Tree인가요
  2. PK를 UUID로 잡으면 왜 나쁜가요
  3. 인덱스가 없는 컬럼으로 UPDATE 하면 왜 테이블이 통째로 잠기나요

저는 이 셋을 따로 외우고 있었습니다. 1번은 리프가 연결 리스트라 범위 스캔이 되니까, 2번은 랜덤 삽입이라 페이지 분할이 나니까, 3번은 락이 인덱스에 걸리니까. 각각 답은 했는데 왜 이 셋이 같이 나오는지는 몰랐어요.

답은 하나였습니다. InnoDB에서 테이블 자체가 PK로 정렬된 B+Tree이고, 락은 그 트리의 레코드에 걸립니다. 세 질문 모두 이 한 문장의 따름정리예요.

8번 글에서 UUID를 CHAR(36) 으로 저장한 문제를 다뤘는데, 그 프로젝트는 PostgreSQL이었습니다. 글 안에서 “MySQL에는 UUID 타입이 없어서 CHAR(36) 이나 BINARY(16) 을 쓴다”고 짧게 언급하고 넘어갔어요. 그런데 InnoDB를 보고 나니 같은 실수라도 MySQL에서는 결과가 더 나쁩니다. 그 이유를 설명하려면 구조부터 봐야 했습니다.

먼저 밝혀둘게요. 이 글에는 제가 잰 숫자가 없습니다. 8번 글의 측정값은 PostgreSQL에서 나온 것이고, 여기 나오는 MySQL 수치는 전부 InnoDB의 기본 설정값이나 문서에 명시된 상수예요. 제 벤치마크가 아닙니다.

[문제 상황 분석 - MySQL은 두 층으로 나뉜다]

서버 계층과 스토리지 엔진 계층

MySQL의 특이한 점은 스토리지 엔진을 갈아 끼울 수 있다는 겁니다. 이 구조가 성능 특성의 많은 부분을 설명해요.

flowchart TB
  server["서버 계층<br/>커넥션 관리 → 파서 → 옵티마이저 → 실행기<br/>(8.0 부터 쿼리 캐시는 제거됐다)"]:::accent
  engine["스토리지 엔진 계층 (InnoDB)<br/>버퍼 풀 · B+Tree · redo/undo · 락 · MVCC"]:::neutral
  server -->|"핸들러 API — 이 조건으로 다음 행을 줘"| engine

옵티마이저는 엔진에게 통계를 물어보고 실행 계획을 세운 뒤, 핸들러 API로 행을 하나씩 받아옵니다. 이 경계가 중요한 이유는 경계를 넘어오는 행이 많을수록 비싸기 때문이에요.

여기서 두 가지 최적화의 의미가 분명해집니다.

Index Condition Pushdown(ICP). 원래는 엔진이 인덱스로 찾은 행을 전부 서버로 올리고, 서버가 WHERE 의 나머지 조건으로 걸렀습니다. ICP는 인덱스에 들어 있는 컬럼 조건이라면 엔진이 미리 걸러서 올려요. 경계를 넘는 행이 줄어듭니다. EXPLAIN 의 Using index condition 이 이거예요.

커버링 인덱스. 필요한 컬럼이 전부 인덱스 안에 있으면 엔진이 테이블 본체를 읽지 않습니다. Using index 로 표시돼요. 뒤에서 볼 세컨더리 인덱스 구조를 알면 이게 왜 그렇게 큰 차이인지 이해됩니다.

[디스크 구조 - 테이블이 곧 인덱스다]

페이지가 단위입니다

InnoDB가 디스크와 메모리 사이에서 주고받는 최소 단위는 페이지이고 기본 크기는 16KB입니다(innodb_page_size). 행 하나를 읽어도 16KB를 통째로 읽어요.

그 위로 익스텐트(연속된 64페이지, 1MB), 세그먼트, 테이블스페이스가 쌓입니다. 익스텐트 단위로 공간을 잡는 이유는 논리적으로 이어진 데이터를 디스크에서도 이어붙여 순차 읽기의 이점을 얻으려는 거예요.

B+Tree, 그리고 왜 B-Tree가 아닌가

인덱스는 B+Tree입니다. B-Tree와의 차이가 두 가지인데, 둘 다 디스크 때문이에요.

첫째, 데이터가 리프에만 있습니다. 내부 노드는 키와 자식 포인터만 담아요. 그러면 노드 하나에 키를 훨씬 많이 넣을 수 있고, 팬아웃이 커지고, 트리 높이가 낮아집니다.

높이가 왜 중요하냐면 높이 = 디스크 접근 횟수이기 때문입니다. 16KB 페이지에 키를 수백 개 넣을 수 있으니, 팬아웃을 500이라고만 잡아도 높이 3이면 500³ = 1억 2천 5백만 행을 커버해요. 어떤 행을 찾든 페이지 세 번이면 닿습니다.

둘째, 리프 노드끼리 이중 연결 리스트로 이어져 있습니다. 범위 스캔이 여기서 나와요. WHERE created_at BETWEEN a AND b 는 시작 지점을 트리로 찾은 뒤 리프를 옆으로 훑습니다. 트리를 다시 타고 내려올 필요가 없어요.

flowchart TB
  root["루트 · 100 | 200"]:::accent
  b1["브랜치 · 10 | 50"]:::neutral
  b2["브랜치 · 120 | 160"]:::neutral
  b3["브랜치 · 250 | 300"]:::neutral
  root --> b1
  root --> b2
  root --> b3
  b1 --> L1
  b1 --> L2
  b2 --> L3
  b2 --> L4
  b3 --> L5
  b3 --> L6
  subgraph leaves ["리프 — 이중 연결 리스트 · 범위 스캔은 이 줄을 따라간다"]
    L1["리프"]:::soft
    L2["리프"]:::soft
    L3["리프"]:::soft
    L4["리프"]:::soft
    L5["리프"]:::soft
    L6["리프"]:::soft
  end

그리고 테이블 자체가 이 트리입니다

InnoDB의 핵심 설계입니다. PK로 정렬된 B+Tree의 리프에 행 전체가 들어 있어요. 별도의 테이블 파일이 있고 인덱스가 그걸 가리키는 게 아니라, 인덱스가 곧 테이블입니다. 이걸 클러스터드 인덱스라고 불러요.

PK를 안 만들면 InnoDB가 대신 만듭니다. 유니크한 NOT NULL 인덱스가 있으면 그걸 쓰고, 없으면 6바이트짜리 숨은 DB_ROW_ID 로 내부 클러스터드 인덱스를 만들어요. PK 없는 테이블은 없습니다. 보이지 않을 뿐이에요.

여기서 세컨더리 인덱스의 구조가 결정됩니다.

세컨더리 인덱스로 조회할 때 일어나는 일 세컨더리 인덱스 (email) 내부 노드 : email 값 리프 : email + PK 값 행의 물리 주소가 아니라 PK 를 담는다 클러스터드 인덱스 (PK) 내부 노드 : PK 값 리프 : 행 전체 두 번째 탐색 여기서 나오는 결론 세 가지 1. 필요한 컬럼이 인덱스 안에 다 있으면 두 번째 탐색이 사라진다 (커버링 인덱스) 2. PK 가 크면 모든 세컨더리 인덱스가 그만큼 같이 커진다 3. 두 번째 탐색은 PK 순서를 따르므로, 조회 순서가 PK 순서와 다르면 랜덤 I/O 가 된다 PostgreSQL 은 힙에 행을 두고 인덱스가 tid 로 가리키는 구조라 이 세 결론이 그대로 성립하지 않는다.

세컨더리 인덱스의 리프는 행의 물리 주소가 아니라 PK 값을 담습니다. 이 설계는 이유가 있어요. 행이 페이지 안에서 옮겨지거나 페이지가 분할돼도 PK는 안 변하니, 인덱스를 고칠 필요가 없습니다. 대신 조회할 때 트리를 두 번 타야 해요.

그래서 PK를 UUID로 잡으면

이제 두 번째 질문에 답할 수 있습니다. 이유가 하나가 아니라 넷이에요.

1. 삽입이 트리 중간에 일어납니다. AUTO_INCREMENT면 새 행이 항상 가장 오른쪽 리프에 붙습니다. InnoDB에는 이런 순차 삽입을 위한 최적화도 있어요. 반면 UUIDv4는 값이 무작위라 매번 다른 리프로 갑니다.

2. 페이지 분할이 납니다. 꽉 찬 리프 중간에 끼워 넣으려면 페이지를 둘로 쪼개야 해요. 쪼개면 각각 절반만 차니 공간 효율이 떨어지고, 논리적으로 이웃한 페이지가 디스크에서 흩어집니다. 시간이 지날수록 단편화가 쌓여요.

3. 버퍼 풀이 오염됩니다. 순차 삽입이면 항상 같은 오른쪽 끝 페이지들만 만지니 그것만 메모리에 있으면 됩니다. 무작위 삽입은 매번 다른 페이지를 요구해요. 인덱스 전체가 메모리에 안 들어가면 삽입마다 디스크를 읽습니다.

4. 모든 세컨더리 인덱스가 같이 부풉니다. 위 그림의 결론 2번이에요. PK가 BINARY(16) 이면 세컨더리 인덱스 리프마다 16바이트가 붙고, CHAR(36) 이면 36바이트가 붙습니다. 인덱스가 다섯 개면 다섯 배로 쌓여요.

8번 글에서 PostgreSQL 기준으로 CHAR(36) 과 uuid 를 비교했는데, InnoDB에서는 여기에 1, 2, 3번이 더 얹힙니다. PostgreSQL은 행을 힙에 두고 인덱스가 물리 위치(tid)로 가리키는 구조라, PK 값의 무작위성이 테이블 저장 순서를 흔들지 않아요. InnoDB에서는 PK 순서가 곧 물리 저장 순서입니다.

그래서 MySQL 쪽 처방이 더 강해집니다.

  • BINARY(16) 으로 저장합니다. UUID_TO_BIN(uuid, 1) 은 시간 필드를 앞으로 재배열해서 어느 정도 순차성을 만들어줘요
  • 아예 시간 순서를 갖는 UUIDv7 같은 값을 씁니다
  • 또는 PK를 AUTO_INCREMENT로 두고 UUID는 유니크 세컨더리 인덱스로 둡니다

세 번째가 클래식한 절충안이에요. 내부 조인과 저장은 순차 정수로 하고, 외부에 노출하는 식별자만 UUID로 씁니다.

페이지가 비면 병합합니다

반대 방향도 있습니다. 삭제로 페이지가 비면 InnoDB가 이웃 페이지와 병합해요. 기준은 MERGE_THRESHOLD 이고 기본값은 50%입니다.

삭제와 삽입이 반복되면 분할과 병합이 계속 일어나서 인덱스가 출렁일 수 있어요. 이럴 때 MERGE_THRESHOLD 를 낮춰서 병합을 덜 하게 만드는 튜닝이 있습니다.

[버퍼 풀 - 캐시 하나가 성능의 대부분이다]

그냥 LRU가 아닙니다

버퍼 풀은 페이지를 캐싱하는 메모리 영역이고, InnoDB 성능의 대부분이 여기서 나옵니다. 그런데 교체 정책이 단순 LRU가 아니에요.

문제 상황을 먼저 보면 이해가 됩니다. 새벽에 배치가 돌면서 큰 테이블을 풀 스캔합니다. 그 페이지들이 전부 LRU의 앞쪽에 들어오면, 낮 동안 뜨거웠던 페이지들이 뒤로 밀려 쫓겨나요. 배치가 끝나고 나면 서비스 쿼리가 전부 디스크를 읽습니다.

InnoDB는 LRU 리스트를 두 구역으로 나눠서 이걸 막습니다.

flowchart LR
  read["새로 읽은 페이지"]:::soft
  subgraph LRU["버퍼 풀 LRU 리스트"]
    direction LR
    new["new / young  ·  약 63%<br/>자주 쓰이는 페이지"]:::accent
    old["old  ·  약 37%<br/>새로 읽힌 페이지가 여기 (midpoint)"]:::mute
  end
  read -->|"맨 앞이 아니라 midpoint 로"| old
  old -->|"1초 뒤 다시 접근되면 승격"| new

새로 읽은 페이지는 리스트 맨 앞이 아니라 중간 지점(midpoint)에 들어갑니다. 기본적으로 old 구역이 전체의 약 37%예요(innodb_old_blocks_pct 기본값 37). 그리고 여기서 한 번 더 거릅니다.

innodb_old_blocks_time 기본값이 1000ms입니다. old 구역에 들어온 뒤 1초가 지난 다음에 다시 접근돼야 new 구역으로 승격돼요.

이 1초가 하는 일이 절묘합니다. 풀 스캔은 페이지를 읽고 그 안의 행들을 연속으로 처리하니 접근이 1초 안에 몰려요. 그러니 승격되지 않고 old 구역에 머물다가 쫓겨납니다. 반면 진짜로 자주 쓰이는 페이지는 시간을 두고 반복 접근되니 승격돼요.

한 번 크게 훑고 지나가는 접근과, 꾸준히 반복되는 접근을 시간으로 구분하는 겁니다.

버퍼 풀 주변의 장치들

버퍼 풀 하나로 끝이 아니라 딸린 구조가 몇 개 더 있어요.

Change Buffer. 유니크가 아닌 세컨더리 인덱스를 변경할 때, 해당 페이지가 버퍼 풀에 없으면 디스크에서 읽어와야 합니다. Change Buffer는 그 변경을 일단 모아뒀다가 나중에 페이지가 읽힐 때 합쳐요. 랜덤 읽기를 줄이는 장치입니다.

유니크 인덱스에는 못 씁니다. 중복 여부를 확인하려면 어차피 페이지를 읽어야 하니까요. 유니크 제약이 삽입 성능에 손해라는 말의 근거 중 하나가 이겁니다. 다만 최근 MySQL 버전에서 이 기능은 정리되는 방향이라 기본 동작이 달라졌어요. 쓰는 버전의 문서를 확인하는 게 좋습니다.

Adaptive Hash Index. 특정 인덱스 경로로 접근이 몰리면 InnoDB가 알아서 해시 인덱스를 만들어 B+Tree 탐색을 건너뜁니다. 자동이라 편한데, 워크로드에 따라 이 구조를 관리하는 래치에서 경합이 생겨요. 오히려 끄는 게 나은 경우가 있습니다.

Doublewrite Buffer. 페이지는 16KB인데 디스크나 파일 시스템이 원자적으로 쓰는 단위는 그보다 작을 수 있어요(보통 4KB). 그러니 16KB를 쓰는 도중 전원이 나가면 절반만 쓰인 페이지가 남습니다. 이걸 torn page라고 해요.

redo log로도 이건 못 고칩니다. redo는 “페이지의 이 부분을 이렇게 바꿔라”라는 변경분이라, 원본 페이지가 깨져 있으면 적용할 기준이 없어요.

그래서 InnoDB는 페이지를 제자리에 쓰기 전에 doublewrite 영역에 먼저 순차로 씁니다. 크래시 후 페이지가 깨져 있으면 거기서 온전한 사본을 가져와요. 쓰기가 두 번이지만 doublewrite 쪽은 순차 쓰기라 생각만큼 비싸지 않습니다.

[redo와 undo - 이름은 비슷한데 하는 일이 다르다]

redo log와 WAL

InnoDB는 커밋할 때 데이터 페이지를 디스크에 쓰지 않습니다. 변경 기록만 redo log에 쓰고 커밋합니다. 페이지는 나중에 여유 있을 때 씁니다.

이게 Write-Ahead Logging이에요. 얻는 게 두 가지입니다.

첫째, 랜덤 쓰기가 순차 쓰기로 바뀝니다. 여기저기 흩어진 페이지 열 개를 고쳤어도 redo log에는 순차로 이어서 씁니다.

둘째, 여러 변경을 모아서 한 번에 씁니다. 같은 페이지를 100번 고쳤으면 페이지는 결국 한 번만 쓰면 돼요.

redo log는 고정 크기 공간을 순환하며 씁니다. 8.0.30부터 innodb_redo_log_capacity 로 지정하고 기본값은 100MB예요. 그 전에는 innodb_log_file_size 와 파일 개수로 정했습니다.

여기서 운영상 중요한 게 하나 나옵니다. 순환 구조라 한 바퀴 돌면 오래된 부분을 덮어써야 하는데, 그 부분에 대응하는 페이지가 아직 디스크에 안 쓰였으면 덮어쓸 수 없어요. 그러면 InnoDB가 강제로 페이지를 밀어내기 시작하고, 쓰기 성능이 급격히 떨어집니다.

쓰기가 많은 서비스에서 갑자기 성능이 주저앉는 흔한 원인이에요. redo log 용량을 늘리면 완화되지만, 대신 크래시 복구 시간이 길어집니다.

커밋할 때 어디까지 보장하는가

innodb_flush_log_at_trx_commit 이 이 트레이드오프를 직접 노출합니다.

값동작잃을 수 있는 것
1 (기본)커밋마다 로그 버퍼를 쓰고 fsync없음. ACID 를 만족한다
2커밋마다 OS 로 쓰기만 하고 fsync 는 초당 1회MySQL 이 죽으면 안 잃고, OS 가 죽으면 최대 1초
0초당 1회 쓰고 fsyncMySQL 이 죽어도 최대 1초

여기에 binlog까지 켜져 있으면 비용이 한 번 더 붙습니다. 복제와 시점 복구를 위해 binlog와 redo log가 2단계 커밋으로 묶여요. sync_binlog=1 과 innodb_flush_log_at_trx_commit=1 을 같이 쓰면 커밋마다 fsync 가 두 번 일어납니다.

그래서 그룹 커밋이 있어요. 여러 트랜잭션의 커밋을 모아서 fsync 한 번으로 처리합니다. 커밋이 많은 워크로드에서 효과가 큽니다.

undo log는 되돌리기용이자 MVCC용입니다

redo가 “다시 하기”라면 undo는 “되돌리기”예요. 행을 고치기 전의 값을 undo에 남깁니다. 롤백하면 여기서 복원해요.

그런데 undo의 더 중요한 역할이 따로 있습니다. MVCC의 옛 버전 저장소예요. 다른 트랜잭션이 “내가 시작한 시점의 값”을 봐야 할 때 undo를 따라 올라가서 그때 값을 재구성합니다.

그래서 undo는 아무도 그 버전을 볼 필요가 없어질 때까지 지울 수 없습니다. purge 스레드가 주기적으로 청소하는데, 조건은 “이 버전을 볼 수 있는 트랜잭션이 하나도 없을 것”이에요.

여기서 두 번째 질문의 답이 나옵니다. 긴 트랜잭션이 왜 서버 전체를 느리게 만드는가.

flowchart TB
  A["트랜잭션 A · 09:00 시작<br/>REPEATABLE READ · 아직 안 끝남"]:::accent
  A --> keep["A 는 09:00 시점 데이터를 봐야 한다<br/>→ 09:00 이후의 옛 버전을 지울 수 없다"]:::soft
  upd["그동안 다른 트랜잭션들이 계속 UPDATE"]:::neutral
  upd --> pile["undo 가 계속 쌓인다"]:::warn
  keep --> stuck["purge 가 아무것도 못 지운다"]:::warn
  pile --> stuck
  stuck --> hist["History list length 가 자란다"]:::warn
  hist --> cost["행 하나 읽는 데 undo 체인을 수만 개 거슬러 오른다<br/>디스크 사용량도 계속 는다"]:::warn

A는 아무것도 안 하고 있어도 됩니다. 커넥션을 열어둔 채 트랜잭션만 안 닫으면 이 일이 벌어져요. SHOW ENGINE INNODB STATUS 의 History list length가 이 지표입니다.

10번 글에서 트랜잭션 안에서 외부 API를 부르면 안 되는 이유를 커넥션 풀 관점으로 측정했었어요. 그 글의 결론은 “커넥션을 오래 붙들면 풀이 마른다”였는데, InnoDB 관점의 이유가 하나 더 있었습니다. 그 트랜잭션이 살아 있는 동안 undo가 청소되지 않아요. 외부 API가 5초 걸리면 5초 동안 서버 전체의 purge가 밀립니다.

[MVCC - 읽기가 쓰기를 막지 않는 방법]

행마다 숨은 컬럼이 있습니다

InnoDB는 모든 행에 숨은 컬럼을 붙입니다.

컬럼크기용도
DB_TRX_ID6바이트이 행을 마지막으로 고친 트랜잭션 ID
DB_ROLL_PTR7바이트undo 의 이전 버전을 가리키는 포인터
DB_ROW_ID6바이트PK 가 없을 때만 생기는 내부 행 ID

DB_ROLL_PTR 을 따라가면 그 행의 과거 버전들이 사슬로 이어집니다. 이게 버전 체인이에요.

ReadView가 판정합니다

트랜잭션이 일관된 읽기를 시작할 때 InnoDB는 ReadView라는 스냅샷을 만듭니다. 안에 든 건 이겁니다.

  • 이 순간 활성 상태인 트랜잭션 ID 목록
  • 그중 가장 작은 ID
  • 다음에 할당될 ID
  • 자기 자신의 ID

행을 읽을 때 그 행의 DB_TRX_ID 를 이 정보와 비교해서 판정해요.

  • 내가 고친 것이면 보인다
  • ReadView 생성 전에 커밋된 것이면 보인다
  • ReadView 생성 시점에 아직 안 끝난 트랜잭션이 고친 것이면 안 보인다. DB_ROLL_PTR 을 따라가 이전 버전을 본다
  • ReadView 생성 이후에 시작된 트랜잭션이 고친 것이면 안 보인다

읽는 쪽이 락을 전혀 걸지 않습니다. 쓰는 트랜잭션과 읽는 트랜잭션이 서로를 안 막아요. 이게 MVCC의 이득입니다.

격리 수준의 차이는 ReadView를 언제 만드느냐입니다

격리 수준ReadView 생성 시점
READ COMMITTED쿼리마다 새로 만든다
REPEATABLE READ첫 일관된 읽기에서 한 번 만들고 끝까지 쓴다

MySQL의 기본은 REPEATABLE READ입니다. PostgreSQL이나 Oracle의 기본이 READ COMMITTED인 것과 다르고, 이 차이가 실무에서 종종 사고를 만들어요.

그리고 REPEATABLE READ에서 ReadView는 트랜잭션 시작이 아니라 첫 읽기에서 만들어집니다. BEGIN 만 하고 가만히 있다가 나중에 읽으면, 그 사이 커밋된 변경이 보여요. 시작 시점에 고정하려면 START TRANSACTION WITH CONSISTENT SNAPSHOT 이 필요합니다.

일관된 읽기와 잠금 읽기

여기가 헷갈리기 쉬운 지점입니다. 모든 읽기가 ReadView를 쓰는 게 아니에요.

종류예보는 것
일관된 읽기그냥 SELECTReadView 기준의 옛 버전
잠금 읽기SELECT ... FOR UPDATE, SELECT ... LOCK IN SHARE MODE, UPDATE, DELETE항상 최신 커밋 버전

UPDATE 가 옛 버전을 기준으로 동작하면 이미 지워진 행을 고치는 일이 생기니 당연한 설계입니다. 그런데 이게 한 트랜잭션 안에서 이상한 결과를 만들어요.

-- REPEATABLE READ
BEGIN;
SELECT balance FROM account WHERE id = 1;   -- 1000 을 봤다

-- 이 사이에 다른 트랜잭션이 balance 를 2000 으로 바꾸고 커밋

SELECT balance FROM account WHERE id = 1;   -- 여전히 1000  (일관된 읽기)
UPDATE account SET balance = balance + 100 WHERE id = 1;
SELECT balance FROM account WHERE id = 1;   -- 2100  (최신 2000 기준으로 계산됐다)
COMMIT;

1000을 보고 100을 더했는데 2100이 됩니다. UPDATE 는 최신 버전을 봤기 때문이에요. 애플리케이션에서 읽은 값으로 계산해서 쓰는 코드가 위험한 이유가 여기 있습니다. 조건부 갱신(WHERE version = :v)이나 balance = balance - :n 처럼 DB 안에서 계산하게 만들어야 해요.

이건 42번 글에서 정리한 원칙과 같은 이야기입니다. 판정을 애플리케이션이 아니라 저장소가 하게 만드는 거예요.

[락 - 인덱스에 걸린다]

락은 행이 아니라 인덱스 레코드에 걸립니다

세 번째 질문의 답입니다. InnoDB의 행 락은 인덱스 레코드에 거는 락이에요. 행이라는 물리적 대상에 거는 게 아닙니다.

그러면 인덱스가 없는 컬럼으로 조건을 주면 어떻게 될까요. 걸 인덱스가 없으니 클러스터드 인덱스를 전부 스캔하면서 지나가는 레코드마다 락을 겁니다.

-- name 에 인덱스가 없다면
UPDATE users SET status = 'X' WHERE name = '김휘래';
-- 결과적으로 테이블의 모든 레코드가 잠긴다

조건에 맞지 않는 행은 나중에 락이 풀리긴 하지만(서버 계층이 걸러낸 뒤), 스캔이 도는 동안은 잡혀 있어요. 인덱스 설계가 곧 락 범위 설계입니다. 이 문장이 이번에 얻은 가장 실용적인 결론이었어요.

갭 락과 넥스트 키 락

REPEATABLE READ에서 InnoDB는 세 종류의 락을 씁니다.

종류잠그는 것
레코드 락인덱스 레코드 하나
갭 락레코드 사이의 빈 구간. 그 구간에 삽입을 막는다
넥스트 키 락레코드 락 + 그 앞쪽 갭. RR 의 기본이다

갭 락이 있는 이유는 팬텀 읽기 때문이에요. WHERE age BETWEEN 20 AND 30 으로 읽고 있는데 그 사이에 25인 행이 삽입되면, 같은 쿼리가 다른 결과를 냅니다. 갭을 잠그면 삽입이 막혀요.

READ COMMITTED로 낮추면 갭 락이 대부분 사라집니다. 동시성은 올라가지만 팬텀이 생겨요. 격리 수준을 낮추라는 조언이 그냥 나오는 게 아니라 이 교환입니다.

갭 락은 데드락의 흔한 원인이기도 합니다. 특히 이 조합이요.

sequenceDiagram
  participant A as 트랜잭션 A
  participant B as 트랜잭션 B
  A->>A: INSERT unique_col='x' — 아직 커밋 안 함
  B->>A: INSERT unique_col='x' (같은 값)
  rect rgba(217,61,66,0.12)
    Note over B: 중복이라 A 가 끝날 때까지 대기
  end
  A->>A: 롤백
  A-->>B: 대기 해제
  Note over B: B 성공
  Note over A,B: 이 대기 중 A 가 다른 락을 기다리면 → 데드락

B는 A가 끝날 때까지 기다립니다. A가 롤백하면 B가 성공해요. 이 대기 중에 A가 다른 락을 기다리면 데드락입니다.

4번 글에서 “아직 없는 행은 잠글 수 없다”는 문제를 PostgreSQL에서 다뤘는데, InnoDB에서는 갭 락이 이 역할을 부분적으로 합니다. 존재하지 않는 값의 구간을 잠글 수 있으니까요. 다만 그 대가로 삽입 동시성이 떨어지고 데드락이 늘어요. 같은 문제를 두 DB가 다른 방식으로 푼 겁니다.

데드락 처리

InnoDB는 락 대기 그래프에서 순환을 찾으면 즉시 한쪽을 죽입니다(innodb_deadlock_detect, 기본 ON). 롤백 비용이 작은 쪽을 고르고, 그 트랜잭션에 ER_LOCK_DEADLOCK 을 돌려줘요.

감지가 안 되는 단순 대기는 innodb_lock_wait_timeout 으로 잘립니다. 기본값이 50초예요. 이 값이 기본 그대로면 웹 요청 하나가 50초를 기다립니다. 대부분의 서비스에서는 너무 길어요.

한 가지 더. 동시성이 아주 높으면 데드락 감지 자체가 비싸집니다. 대기 그래프가 커지면 탐색 비용이 늘어나요. 그래서 극단적인 경우 감지를 끄고 타임아웃에만 맡기는 튜닝도 있습니다.

AUTO_INCREMENT의 락

innodb_autoinc_lock_mode 기본값은 8.0에서 2(interleaved)입니다. 값을 예약하는 방식이라 삽입 동시성이 좋아요. 대신 여러 행을 넣는 INSERT 에서 번호가 연속임을 보장하지 않습니다. 그리고 statement 기반 복제와는 안전하지 않아서, row 기반 복제를 쓰는 게 전제예요.

[실무 적용 - 이 구조에서 나오는 규칙]

1. PK는 짧고 순차적으로. 클러스터드 인덱스라서 물리 순서와 모든 세컨더리 인덱스 크기가 여기 달려 있습니다. UUID가 필요하면 BINARY(16) 이나 UUIDv7, 또는 내부 PK와 외부 식별자를 분리합니다.

2. 트랜잭션을 짧게. 커넥션 점유만의 문제가 아니라 undo 청소가 막힙니다. 트랜잭션 안에서 외부 호출을 하지 않는 규칙에 근거가 하나 더 생겼어요.

3. 인덱스로 락 범위를 좁힙니다. UPDATE 와 DELETE 의 WHERE 절이 인덱스를 타는지 반드시 확인합니다.

4. 커버링 인덱스를 노립니다. 두 번째 트리 탐색이 통째로 사라져요. EXPLAIN 에 Using index 가 뜨는지 봅니다.

5. 읽은 값으로 계산해서 쓰지 않습니다. 조건부 갱신이나 DB 안에서의 산술로 바꿉니다.

6. innodb_lock_wait_timeout 을 서비스에 맞게 낮춥니다. 기본 50초는 대부분 너무 깁니다.

7. redo log 용량과 버퍼 풀 크기를 봅니다. 쓰기가 몰릴 때 성능이 주저앉으면 redo 용량, 읽기가 느리면 버퍼 풀입니다.

[결론]

세 질문이 하나의 구조에서 나왔습니다. InnoDB에서 테이블은 PK로 정렬된 B+Tree이고, 세컨더리 인덱스는 PK를 담고, 락은 인덱스 레코드에 걸립니다. 이 세 문장을 알고 나니 나머지가 따라 나왔어요.

특히 두 가지가 제 기존 글을 다시 읽게 만들었습니다.

8번 글의 UUID 문제는 MySQL에서 더 나쁩니다. PostgreSQL에서는 공간과 비교 비용의 문제였는데, InnoDB에서는 물리 저장 순서와 페이지 분할, 버퍼 풀 효율까지 걸립니다. 같은 실수인데 엔진 구조에 따라 대가가 달라요.

10번 글의 긴 트랜잭션 문제도 이유가 하나 더 있었습니다. 커넥션 풀만 마르는 게 아니라 undo가 쌓입니다. 그리고 이건 커넥션 풀을 키운다고 해결되지 않아요.

한계를 적어둘게요.

첫째, 측정이 없습니다. 위의 모든 이야기는 구조에서 나오는 추론이고 제가 잰 값이 아니에요. UUID PK와 순차 PK의 삽입 성능 차이, 버퍼 풀 히트율, undo 누적의 실제 영향은 전부 재봐야 합니다. 8번 글처럼 하네스를 짜서 확인하는 게 다음 할 일이에요.

둘째, 제 실무 저장소는 대부분 PostgreSQL입니다. InnoDB를 운영 규모로 다뤄본 경험이 없어요. 그래서 이 글은 문서와 구조 이해에 기대고 있고, 운영에서만 보이는 것들은 못 담았습니다.

셋째, 복제와 클러스터를 다루지 않았습니다. binlog 형식, 반동기 복제, InnoDB Cluster와 Group Replication은 각각 따로 볼 주제라 뺐어요.

넷째, 옵티마이저를 거의 안 다뤘습니다. 비용 모델, 통계 갱신, 조인 순서 결정, 히스토그램은 이 글에 넣기엔 너무 커서 스토리지 엔진 쪽만 봤습니다.

“인덱스를 타게 하세요”는 많이 들었는데, 인덱스를 안 타면 왜 테이블이 잠기는지를 알고 나서야 그 조언이 실행 가능한 규칙이 됐습니다.