18장. MDL이 만드는 큐 — LOCK=NONE은 무중단이 아니다
{/* 삽화 자리 — 문 하나를 붙잡고 선 사람 뒤로 길게 늘어선 줄. 정작 그 사람은 문을 열지도 닫지도 못하고 서 있다 */}
LOCK=NONE을 걸었는데도 평범한 SELECT 하나가 1ms에서 8,880ms가 됐습니다. 그 SELECT는 공유 MDL(메타데이터 락)이면 충분했고, 앞을 막고 있던 트랜잭션도 공유 MDL이었어요. 둘은 원래 공존합니다. 사이에 낀 ALTER의 대기 중인 배타적 요청이 줄을 만든 겁니다. 그리고 8,880ms는 이 현상의 크기가 아닙니다 — 앞의 트랜잭션이 정한 값이에요.
면접 실전 질문: ① 메타데이터 락(MDL)이 뭔가요? ②
LOCK=NONE인데 왜 쿼리가 밀리나요? ③ALTER전에 뭘 확인해야 하나요?
배경 — 17장이 넘긴 세 번째 원인
17장에서 LOCK=NONE이 진짜로 통한다는 걸 봤습니다. ALTER 7.3초 동안 7만 건이 실패 없이 통과했어요.
그리고 지연의 원인을 셋으로 나눠뒀습니다. I/O 경합, redo 압박, 그리고 구간 경계의 MDL이요. 17장 질문 2를 여기서 그대로 받습니다 — 처리량 31% 감소가 셋 중 어느 쪽 몫인지는 안 가렸습니다.
다만 셋째만은 성격이 다릅니다. 앞의 둘은 완만한 저하인데, MDL은 줄을 만듭니다.
인덱스 추가는 세 구간으로 나뉩니다.
| 구간 | 소요 | DML | 지연 원인 |
|---|---|---|---|
| ① 준비 | 순간 | 잠깐 차단 | 배타적 MDL 획득 |
| ② 빌드 | 수 초~수 분 | 허용 | I/O 경합, redo 압박 |
| ③ 적용·커밋 | 수 초 | 잠깐 차단 | 배타적 MDL 재획득 |
LOCK=NONE이 해당하는 건 ②번뿐입니다. ①과 ③은 테이블 정의를 바꾸는 순간이라 배타적 메타데이터 락이 필요해요. 정상이면 밀리초입니다.
문제는 못 잡을 때입니다.
스토리 — 공유끼리는 공존하는데
MDL은 테이블의 정의를 지키는 락입니다. 누가 이 테이블을 쓰는 동안 다른 누가 정의를 못 바꾸게 하는 거예요. 행 단위 락과는 다른 층입니다.
읽고 쓰는 쿼리는 공유(shared) MDL을 잡습니다. 여럿이 동시에 잡을 수 있어요. ALTER는 ①·③구간에서 배타적(exclusive) MDL이 필요하고, 그건 혼자만 잡습니다. 빌드 중에는 승격 가능한 공유 MDL을 쥐고 있고요.
그리고 MDL은 트랜잭션이 끝나야 풀립니다. 쿼리가 끝날 때가 아니라요.
여기서 연쇄가 생깁니다.
- 오래 열려 있는 트랜잭션이 대상 테이블의 공유 MDL을 쥐고 있다
ALTER가 배타적 MDL을 요청하고 대기한다- 그 뒤에 도착한 모든 쿼리가
ALTER뒤에 줄을 선다 — 단순SELECT까지
3번이 이 장의 핵심입니다. 1번의 트랜잭션도 공유고 3번의 SELECT도 공유예요. 둘은 서로 아무 문제가 없습니다. 그런데 사이에 낀 2번의 대기가 3번을 막습니다.
MySQL이 배타적 요청을 무한정 뒤로 미루지 않으려고 그렇게 설계된 건데, 결과적으로 트랜잭션 하나가 테이블 전체를 세웁니다.
조사하겠다고 DataGrip에 쿼리 하나 띄워둔 채 두는 것만으로 걸립니다. 트랜잭션은 그렇게 열립니다.
말로는 잘 안 와닿으니 재봤습니다.
핵심 — 내 SELECT의 소요시간을 남의 트랜잭션이 정합니다
세션 셋을 시간차로 띄웠습니다. A는 테이블을 한 번 읽고 트랜잭션을 열어둔 채 버티고, B는 그 위에 ALTER를 치고, C는 그 뒤에 평범한 SELECT를 던집니다.
| 시나리오 | SELECT | ALTER |
|---|---|---|
| ① 아무도 안 막을 때 | 1 ms | 9 ms |
| ② 오래 열린 트랜잭션이 있을 때 | 8,880 ms | 10,992 ms |
③ ②와 같은데 lock_wait_timeout=3 | 1 ms | 3초 뒤 실패 |
✅ 실측 (MySQL 8.0.46 / Docker / Apple M1 / macOS, 2026-08. 3행짜리 테이블입니다 — 데이터 크기와 무관한 현상이라 일부러 작게 잡았어요. 방해 트랜잭션은 12초를 붙들고(HOLD=12), ALTER는 t+1초, SELECT는 t+3.05초에 도착합니다. 재현: ./ch18/mdlqueue.sh. 전체 소스: docs/book/code/db-index/ch18/mdlqueue.sh)
세 행짜리 테이블에서 SELECT COUNT(*)가 8.9초 걸립니다.
읽을 게 세 줄뿐인데요. 이 테이블 크기가 3행이든 3억 행이든 같습니다 — 읽는 양의 문제가 아니라 줄 서는 문제니까요. 이 책이 열일곱 장에 걸쳐 세워 온 페이지 단위가, 여기서는 아무 상관이 없습니다.
8,880이라는 숫자를 오해하면 안 됩니다
이 값은 MDL 대기의 크기가 아닙니다. 방해 트랜잭션이 살아 있던 나머지 시간입니다.
산수를 맞춰보면 바로 보입니다.
방해 트랜잭션 보유 12,000 ms
− SELECT 도착 3,050 ms
─────────────────────────
예상 8,950 ms ← 실측 8,880
방해 트랜잭션 보유 12,000 ms
− ALTER 도착 1,000 ms
─────────────────────────
예상 11,000 ms ← 실측 10,992둘 다 오차 100ms 안입니다. 제가 12초를 붙들었으니 8.9초가 나온 거예요. 60초를 붙들면 SELECT는 57초 걸립니다.
이 현상에는 상한이 없습니다. 앞의 트랜잭션이 정합니다. 8,880보다 그게 무서운 이야기예요.
락 상태를 직접 보면 명확합니다
ALTER가 대기하는 그 순간에 performance_schema.metadata_locks를 찍었습니다.
EXCLUSIVE / PENDING / thread 598 ← ALTER 가 요청했지만 못 받음
SHARED_READ / GRANTED / thread 592 ← 오래 열린 트랜잭션이 쥐고 있음
SHARED_UPGRADABLE/ GRANTED / thread 598 ← ALTER 가 미리 잡아둔 것✅ 실측 (같은 환경)
PENDING이 하나 있으면 그 테이블은 사실상 멈춘 겁니다. 뒤에 오는 요청이 전부 그 뒤에 섭니다.
그래서 작업 중에 볼 쿼리는 이겁니다.
SELECT * FROM performance_schema.metadata_locks WHERE LOCK_STATUS = 'PENDING';lock_wait_timeout이 바꾸는 것
③번 줄이 처방입니다. 세션에 lock_wait_timeout을 짧게 걸면 ALTER가 기다리는 대신 3초 만에 포기합니다.
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction그리고 SELECT는 1ms로 돌아옵니다.
바꾼 건 하나예요. 테이블 전체가 멈추는 사고를, ALTER 하나가 실패하는 일로 낮춘 겁니다. 실패한 ALTER는 다시 치면 되고요.
기본값은 **31,536,000초(1년)**입니다. 그대로 두면 무한정 기다린다고 보면 됩니다.
SET SESSION lock_wait_timeout = 10; -- ALTER 를 치는 세션에서③구간이 ①구간보다 위험합니다
앞의 실험은 ALTER를 치기 전에 이미 트랜잭션이 열려 있던 경우입니다. 그럼 시작 전에 확인하면 되겠네요.
안 됩니다. 트랜잭션은 빌드 도중에도 열릴 수 있으니까요.
200만 행 테이블로 재봤습니다. ALTER를 시작하고 2초 뒤에 — 빌드 한복판에서 — 트랜잭션을 열었습니다.
ALTER 소요 | |
|---|---|
| 방해 없이 | 5,174 ms |
| 빌드가 시작된 뒤에 트랜잭션이 열리면 | 12,040 ms |
✅ 실측 (같은 환경. scan_demo 200만 행 사본. 방해 트랜잭션은 ALTER 시작 2초 뒤에 열려 10초를 붙듭니다. 재현: ./ch18/mdlqueue.sh)
배수보다 중요한 건 어디서 멈췄느냐입니다. 그리고 그건 타임라인을 맞춰보면 나옵니다.
t+0.0 ALTER 시작 — 빌드 구간 진입
t+2.0 방해 트랜잭션이 열린다 (빌드 한복판)
t+5.2 빌드 끝. ③구간 진입 — 배타적 MDL 을 다시 잡으려는데 못 잡는다
t+12.0 방해 트랜잭션이 SLEEP(10) 을 마치고 풀린다
t+12.04 ALTER 완료 ← 실측 12,040 ms오차 40밀리초입니다. ALTER는 t+5.2에 빌드를 다 끝내놓고, 그 뒤 6.8초를 방해 트랜잭션이 풀리기만 기다렸습니다. ③구간에서요.
①구간에서 막히면 잃는 작업은 없습니다 — 빌드를 시작도 안 했으니 다시 치면 돼요. 대신 그동안 테이블에는 줄이 섭니다. ③구간에서 막히면 줄도 서고, 이미 한 빌드까지 인질이 됩니다. 4.6GB 테이블이면 그 “한 일”이 몇 분이고요.
그 지연이 어디까지 번지는가
여기까지는 DB 안의 이야기입니다. 그 지연이 애플리케이션에서 어디까지 가느냐는 시스템마다 다릅니다.
제가 겪은 시스템에는 번지는 경로가 있었습니다. 로그 적재를 @Async로 감쌌으니 결제 경로와 분리돼 있다고 믿었는데, 스레드풀의 거부 정책이 CallerRunsPolicy였어요 — 큐가 차면 결제 요청 스레드가 그 INSERT를 직접 떠안습니다.
@Async는 완충이지 차단벽이 아닙니다. 왜 그렇게 되는지는 다른 책 소관이라 링크만 둡니다 — 아무도 기다리지 않으면 — 타임아웃과 백프레셔의 부재.
이 장에서 가져갈 건 이겁니다. MDL 대기는 DB에서 끝나지 않을 수 있습니다. 내 시스템에 그 경로가 있는지는 ALTER를 치기 전에 알아둬야 해요.
그래서 실제로 이렇게 합니다
| 시점 | 하는 일 |
|---|---|
| 사전 | 장수명 트랜잭션을 본다. ①구간 직전과 ③구간 직전 둘 다 |
| 세션 설정 | SET SESSION lock_wait_timeout = 10 — 대기 대신 빨리 실패시킨다 |
| 작업 중 | metadata_locks에 PENDING이 뜨는지 본다. 뜨면 즉시 판단한다 |
| 사후 | 실패했으면 원인 트랜잭션을 정리하고 다시 친다. 실패는 정상 경로다 |
마지막 줄이 안 통하는 경우가 있습니다. 장수명 트랜잭션이 상시로 있는 테이블이면 몇 번을 다시 쳐도 계속 타임아웃에 죽어서 ALTER가 영영 안 끝나요. 그때는 타임아웃을 늘릴 게 아니라 원인 트랜잭션을 KILL하거나, 애초에 그 트랜잭션의 수명을 애플리케이션에서 고쳐야 합니다.
-- 장수명 트랜잭션 — 나이 순으로
SELECT trx_id, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
trx_mysql_thread_id, LEFT(trx_query, 80)
FROM information_schema.innodb_trx ORDER BY trx_started;
-- 작업 중 대기 감시
SELECT * FROM performance_schema.metadata_locks WHERE LOCK_STATUS = 'PENDING';ALTER를 치는 창구를 트래픽이 적은 시간으로 잡는 이유가 여기 있습니다. 부하 때문이 아니라 긴 트랜잭션이 열려 있을 확률 때문이에요.
정리
- MDL은 테이블의 정의를 지키는 락입니다. 행 락과 다른 층이고, 트랜잭션이 끝나야 풀려요.
- 인덱스 추가는 ①준비 / ②빌드 / ③적용 세 구간이고,
LOCK=NONE이 지켜주는 건 ②뿐입니다. - 대기 중인 배타적 요청은 뒤따르는 공유 요청을 막습니다. 실측: 3행짜리 테이블의
SELECT COUNT(*)가 1ms → 8,880ms가 됐어요. - 다만 8,880은 이 현상의 크기가 아닙니다. 제가 방해 트랜잭션을 12초 붙들어서 나온 값이에요(12,000 − 도착 3,050 ≈ 8,950, 실측 8,880). 이 현상에는 상한이 없습니다 — 앞의 트랜잭션이 정합니다.
- 막힌
SELECT도 공유, 앞의 트랜잭션도 공유입니다. 둘은 원래 공존해요. 사이에 낀ALTER의 대기가 줄을 만든 겁니다. - MDL 대기는 테이블 크기와 무관합니다. 읽는 양의 문제가 아니라 줄 서는 문제예요. (④의 빌드 5.2초는 물론 크기를 탑니다.)
performance_schema.metadata_locks에PENDING이 하나 있으면 그 테이블은 사실상 멈춘 것입니다.lock_wait_timeout을 짧게 걸면ALTER가 빨리 실패하고SELECT가 1ms로 돌아옵니다. 기본값은 1년이에요. 테이블 전체가 멈추는 사고를ALTER하나 실패로 낮추는 겁니다.- ③구간이 ①구간보다 위험합니다. 빌드가 시작된 뒤에 트랜잭션이 열려도 걸리거든요 — 실측 5,174ms → 12,040ms. 타임라인을 맞추면 방해 트랜잭션 해제 시각(t+12.0)과 오차 40ms라, 빌드를 다 끝내놓고 ③구간에서 기다린 게 확인됩니다. 시작 전 점검만으로는 못 막아요.
- MDL 대기는 DB에서 안 끝날 수 있습니다. 제 경우
@Async가CallerRunsPolicy라 결제 스레드까지 번질 경로가 있었어요(자세한 건 다른 책).
생각해볼 질문
lock_wait_timeout의 최솟값은 1초입니다(0을 넣으면 1로 올라가고, 0.1은ERROR 1232예요). 그럼 1초로 두면 제일 안전할까요? 정상적인 ③구간 대기까지 잘라버리진 않을까요?- ③구간에서 막히면 이미 한 빌드가 인질이 됩니다. 그럼
ALTER를 다시 칠 때 그 빌드를 재사용할 수 있을까요? - MDL 대기는 테이블 크기와 무관했습니다. 반면 17장의 31% 처리량 감소는 빌드가 읽는 양에서 오니 크기를 탈 겁니다(재보진 않았어요). 운영에서 이 둘을 어떻게 구분해 관측할까요?
여기까지가 인덱스를 넣는 이야기입니다. 무엇을 청구하는지 알았고(4부), 어떻게 밀어 넣는지도 알았어요(17·18장).
그런데 넣고 나면 질문이 하나 남습니다.
정말 나아졌나요?
여는 글에서 45.36초짜리 조회를 봤습니다. 인덱스를 넣었으니 빨라졌겠죠. 그런데 “빨라졌다”를 무엇으로 보일 건가요. 한 번 재서 빠르면 된 걸까요.