Published on

MySQL 8.0 MDL 잠금 대기·교착 진단과 해결

Authors

서비스가 멀쩡히 돌아가던 중 갑자기 API 지연이 치솟고, 애플리케이션 스레드는 DB 커넥션을 잡은 채 대기만 하는데 CPU는 한가해 보일 때가 있습니다. 이때 InnoDB row lock만 의심하면 헛다리를 짚기 쉽습니다. MySQL 8.0에서는 MDL(Metadata Lock) 때문에 ALTER TABLE, CREATE INDEX, ANALYZE TABLE, 심지어 어떤 SELECT까지도 줄줄이 막히며 “잠금 대기”가 전파될 수 있습니다.

이 글은 MySQL 8.0에서 MDL 잠금 대기와 MDL 교착을 어떻게 진단하고, 어떤 순서로 풀어야 하는지를 운영 환경 기준으로 정리합니다.

MDL이란 무엇이고 왜 무서운가

MDL은 테이블 메타데이터(스키마) 일관성을 보장하기 위한 잠금입니다. MySQL은 테이블에 접근하는 대부분의 SQL에서 MDL을 잡습니다.

  • 일반 SELECT는 보통 MDL shared(읽기) 계열을 잡습니다.
  • ALTER TABLE, DROP TABLE, TRUNCATE 같은 DDL은 MDL exclusive(쓰기) 계열을 잡습니다.

핵심은 다음입니다.

  1. DDL은 exclusive MDL이 필요합니다.
  2. 이미 누군가가 shared MDL을 잡고 있으면 DDL은 대기합니다.
  3. 그런데 DDL이 대기열에 들어오면, 이후에 들어오는 새로운 shared MDL 요청도 막히는 상황이 생길 수 있습니다(상황과 버전에 따라 체감은 “읽기까지 멈춤”으로 보입니다).

즉, “긴 트랜잭션 하나”가 shared MDL을 오래 잡고 있으면, 그 뒤에 온 DDL 하나가 대기하고, 그 다음부터는 수많은 쿼리가 연쇄 대기하면서 장애처럼 보일 수 있습니다.

MDL 잠금 대기 증상 체크리스트

다음 징후가 보이면 MDL을 1순위로 의심하세요.

  • 배포/마이그레이션 중 ALTER TABLE이 멈춤
  • 애플리케이션은 단순 조회인데도 응답이 지연
  • SHOW PROCESSLIST에서 Waiting for table metadata lock가 다수
  • InnoDB deadlock 로그에는 별 게 없는데 DB는 멈춘 느낌

가장 빠른 1차 진단: PROCESSLIST로 대기 원인 찾기

운영에서 제일 먼저 보는 건 이것입니다.

SHOW FULL PROCESSLIST;

여기서 StateWaiting for table metadata lock인 세션을 찾고, 그 세션이 기다리는 대상 테이블과, 그 테이블에 대해 이미 MDL을 잡고 있는 선행 세션을 찾아야 합니다.

다만 PROCESSLIST만으로는 “누가 MDL을 잡고 있나”가 명확히 안 보일 수 있으니, 다음 단계로 넘어갑니다.

MySQL 8.0에서 MDL을 정밀 추적: performance_schema

MySQL 8.0에서는 performance_schema를 통해 MDL 홀더와 웨이터를 비교적 정확히 볼 수 있습니다.

1) MDL 관련 인스트루먼트가 켜져 있는지 확인

환경에 따라 꺼져 있을 수 있습니다.

SELECT *
FROM performance_schema.setup_instruments
WHERE NAME LIKE 'wait/lock/metadata/sql/%';

ENABLEDYES가 아니면 켜야 하지만, 운영에서 즉시 변경이 어려울 수 있습니다. 그래도 가능하면 아래처럼 활성화합니다.

UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES', TIMED = 'YES'
WHERE NAME LIKE 'wait/lock/metadata/sql/%';

2) MDL 웨이터(기다리는 세션) 조회

SELECT
  t.PROCESSLIST_ID AS conn_id,
  t.PROCESSLIST_USER AS user,
  t.PROCESSLIST_HOST AS host,
  t.PROCESSLIST_DB AS db,
  t.PROCESSLIST_TIME AS time_sec,
  t.PROCESSLIST_STATE AS state,
  t.PROCESSLIST_INFO AS sql_text
FROM performance_schema.threads t
WHERE t.PROCESSLIST_STATE = 'Waiting for table metadata lock'
ORDER BY t.PROCESSLIST_TIME DESC;

여기서 오래 기다리는 순으로 상위 몇 개를 잡고, 해당 커넥션이 무엇을 하려는지(sql_text)를 확인합니다. 보통 DDL이거나, DDL을 기다리다가 막힌 DML/SELECT가 보입니다.

3) 어떤 MDL 락이 걸렸는지 metadata_locks에서 확인

SELECT
  ml.OBJECT_TYPE,
  ml.OBJECT_SCHEMA,
  ml.OBJECT_NAME,
  ml.LOCK_TYPE,
  ml.LOCK_DURATION,
  ml.LOCK_STATUS,
  t.PROCESSLIST_ID AS conn_id,
  t.PROCESSLIST_USER AS user,
  t.PROCESSLIST_HOST AS host
FROM performance_schema.metadata_locks ml
JOIN performance_schema.threads t
  ON ml.OWNER_THREAD_ID = t.THREAD_ID
WHERE ml.OBJECT_TYPE = 'TABLE'
ORDER BY ml.OBJECT_SCHEMA, ml.OBJECT_NAME, ml.LOCK_STATUS;
  • LOCK_STATUSGRANTED면 이미 잡힌 락(홀더)
  • LOCK_STATUSPENDING이면 대기 중인 락(웨이터)

같은 OBJECT_SCHEMA, OBJECT_NAME에 대해 GRANTEDPENDING가 섞여 있으면, “누가 잡고 누가 기다리는지”가 그림처럼 드러납니다.

MDL 교착은 어떻게 생기나

InnoDB row lock의 교착은 보통 “A가 row1, B가 row2를 잡고 서로 교차로 기다림” 같은 형태입니다. MDL 교착은 조금 다릅니다.

대표적인 패턴은 다음입니다.

  • 세션 A: 트랜잭션을 연 채로 테이블 t1을 읽거나 쓰며 shared MDL을 오래 유지
  • 세션 B: ALTER TABLE t1 ... 실행, exclusive MDL을 얻기 위해 대기
  • 세션 C: 다른 테이블에 대해 작업하다가 t1을 추가로 접근하려고 하는데, B의 대기 때문에 추가 shared MDL이 막히며 대기
  • 결과적으로 “평소에는 문제 없던 쿼리까지” 줄줄이 멈춥니다

또 다른 교착 패턴은 “서로 다른 테이블에 대한 DDL이 교차”하는 형태입니다.

  • 세션 A: ALTER TABLE t1 ... 실행 중 내부적으로 t2 메타데이터도 필요
  • 세션 B: ALTER TABLE t2 ... 실행 중 내부적으로 t1 메타데이터도 필요
  • 각자 exclusive 또는 강한 MDL을 잡은 상태에서 서로를 기다리며 교착

MySQL이 감지해서 한쪽을 죽여주면 다행이지만, 운영에서는 “교착처럼 보이는 무한 대기”로 체감되는 경우가 많습니다.

재현으로 이해하기: 3세션 시나리오

아래는 로컬에서 MDL 대기를 쉽게 재현하는 예시입니다.

세션 1: 긴 트랜잭션으로 shared MDL 유지

START TRANSACTION;
SELECT * FROM orders WHERE id = 1;
-- 일부러 COMMIT을 하지 않고 대기

세션 2: DDL 실행(대기 상태 진입)

ALTER TABLE orders ADD COLUMN memo VARCHAR(255);

세션 2는 Waiting for table metadata lock로 멈출 가능성이 큽니다.

세션 3: 평범한 쿼리도 영향을 받는지 확인

SELECT COUNT(*) FROM orders;

환경에 따라 세션 3도 대기하거나 지연될 수 있습니다. 이때 운영에서는 “읽기까지 멈췄다”로 관측됩니다.

해결 1순위: 홀더(락을 쥔 세션)부터 끊어라

MDL 상황에서 흔한 실수는 “대기 중인 DDL 세션을 죽이는 것”입니다. 물론 경우에 따라 필요하지만, 근본 원인은 보통 락을 오래 쥐고 있는 홀더 세션입니다.

1) 어떤 세션이 홀더인지 찾기

앞서 metadata_locks에서 같은 테이블에 대해 GRANTED를 가진 커넥션이 홀더입니다.

2) 홀더가 애플리케이션 트랜잭션이라면

  • 가능한 경우: 애플리케이션에서 해당 요청을 취소하거나 타임아웃으로 롤백 유도
  • 긴 트랜잭션이 자주 생기면 트랜잭션 경계를 재점검

Spring 계열이라면 트랜잭션이 의도대로 끝나지 않는 원인을 먼저 제거해야 재발을 막을 수 있습니다. 관련해서는 Spring Boot 3에서 @Transactional 무시되는 7가지 원인 글이 실전 체크리스트로 유용합니다.

3) 즉시 복구가 필요하면 KILL

운영에서 장애를 끄는 게 우선이면, 홀더 세션을 끊는 게 빠릅니다.

KILL 12345;
  • KILL은 커넥션을 끊고 트랜잭션을 롤백합니다.
  • 롤백 비용이 큰 대용량 트랜잭션이면, 끊는 순간부터 I/O가 튈 수 있으니 주의합니다.

해결 2: DDL 자체를 안전하게 바꾸기(온라인 스키마 변경)

MDL 대기의 “방아쇠”는 대개 DDL입니다. MySQL 8.0에서도 ALTER TABLE은 경우에 따라 강한 MDL을 필요로 하고, 완전한 무중단이 아닐 수 있습니다.

1) 가능한 알고리즘/락 옵션을 명시

테이블/변경 종류에 따라 다르지만, 의도를 드러내고 실패를 빨리 감지하는 데 도움이 됩니다.

ALTER TABLE orders
  ADD COLUMN memo VARCHAR(255),
  ALGORITHM = INPLACE,
  LOCK = NONE;
  • 지원되지 않으면 에러가 나며, 그때 운영자는 “이 변경은 무중단이 아니다”를 즉시 알 수 있습니다.
  • 무작정 실행해서 길게 대기하는 것보다 낫습니다.

2) DDL 대기 시간을 제한

MySQL에는 세션 단위로 lock_wait_timeout을 줄 수 있습니다(주로 InnoDB 락에도 영향). MDL만 딱 분리해 제어하긴 어렵지만, “무한 대기”를 피하는 데는 도움이 됩니다.

SET SESSION lock_wait_timeout = 10;
ALTER TABLE orders ADD COLUMN memo VARCHAR(255);

이렇게 하면 실패를 빨리 감지하고 재시도/롤백/점검으로 전환할 수 있습니다.

해결 3: 운영 프로세스 개선(재발 방지)

기술적으로 한 번 풀어도, 프로세스가 그대로면 재발합니다.

1) 배포 시간대와 트래픽 패턴 분리

DDL은 트래픽이 낮을 때 수행하고, 장시간 트랜잭션이 생기기 쉬운 배치 시간대와 겹치지 않게 조정합니다.

2) 애플리케이션에서 “긴 트랜잭션” 금지 규칙

  • 사용자 요청에서 파일 업로드, 외부 API 호출, 대기성 작업을 트랜잭션 안에 넣지 않기
  • 페이지네이션 없는 대량 조회를 트랜잭션으로 감싸지 않기
  • 커넥션 풀에서 빌린 커넥션을 오래 쥐지 않기

락 경합이 도메인 모델 설계에서 기인하는 경우도 많습니다. Aggregate 경계가 커지면 “한 요청이 잡는 락의 범위와 시간”이 커져 MDL/row lock 모두 악화될 수 있습니다. 이 관점은 DDD Aggregate 경계 실수로 락 폭증한 장애 해결기를 함께 참고하면 좋습니다.

3) 마이그레이션 자동화에서 “대기 감지 후 중단”

CI나 배포 파이프라인에서 DDL을 실행할 때, 대기가 길어지면 자동 중단하고 알람을 보내도록 구성합니다.

예를 들어 애플리케이션 코드나 스크립트에서 DDL 수행 전후로 다음을 자동 수집합니다.

  • SHOW FULL PROCESSLIST 결과
  • performance_schema.metadata_locks에서 대상 테이블의 PENDING 유무
  • 대기 시간이 임계치를 넘으면 즉시 실패 처리

실전 진단 쿼리 묶음(운영 체크용)

아래는 “지금 멈췄다” 상황에서 복붙해서 쓰기 좋은 묶음입니다.

1) MDL 대기 세션 목록

SELECT
  t.PROCESSLIST_ID AS conn_id,
  t.PROCESSLIST_TIME AS time_sec,
  t.PROCESSLIST_USER AS user,
  t.PROCESSLIST_HOST AS host,
  t.PROCESSLIST_DB AS db,
  t.PROCESSLIST_INFO AS sql_text
FROM performance_schema.threads t
WHERE t.PROCESSLIST_STATE = 'Waiting for table metadata lock'
ORDER BY t.PROCESSLIST_TIME DESC;

2) 특정 테이블에 대한 MDL 홀더/웨이터 같이 보기

OBJECT_SCHEMAOBJECT_NAME만 바꿔서 사용합니다.

SELECT
  ml.LOCK_STATUS,
  ml.LOCK_TYPE,
  t.PROCESSLIST_ID AS conn_id,
  t.PROCESSLIST_USER AS user,
  t.PROCESSLIST_HOST AS host,
  t.PROCESSLIST_INFO AS sql_text
FROM performance_schema.metadata_locks ml
JOIN performance_schema.threads t
  ON ml.OWNER_THREAD_ID = t.THREAD_ID
WHERE ml.OBJECT_TYPE = 'TABLE'
  AND ml.OBJECT_SCHEMA = 'app'
  AND ml.OBJECT_NAME = 'orders'
ORDER BY FIELD(ml.LOCK_STATUS, 'PENDING', 'GRANTED'), ml.LOCK_TYPE;

3) 홀더 세션이 어떤 트랜잭션인지(가능하면)

트랜잭션 테이블은 환경에 따라 값이 다를 수 있지만, 다음을 함께 보면 실마리가 나옵니다.

SELECT
  t.PROCESSLIST_ID AS conn_id,
  t.PROCESSLIST_TIME AS time_sec,
  esh.EVENT_NAME,
  esh.TIMER_WAIT,
  esh.SQL_TEXT
FROM performance_schema.events_statements_history_long esh
JOIN performance_schema.threads t
  ON esh.THREAD_ID = t.THREAD_ID
WHERE t.PROCESSLIST_ID = 12345
ORDER BY esh.EVENT_ID DESC
LIMIT 20;

자주 하는 오해 3가지

1) “InnoDB deadlock 로그가 없으니 DB 락이 아니다”

MDL은 InnoDB row lock 교착 로그와 별개로 움직입니다. InnoDB 로그가 조용해도 MDL로 전체가 멈출 수 있습니다.

2) “DDL만 죽이면 된다”

대기 중인 DDL은 결과일 뿐 원인이 아닐 때가 많습니다. 홀더(긴 트랜잭션)부터 찾아야 합니다.

3) “SELECT는 안전하다”

SELECT도 MDL shared를 잡습니다. 특히 트랜잭션을 길게 열어두면 shared MDL이 오래 유지되어 DDL을 막습니다.

정리: MDL 대응의 우선순위

  1. PROCESSLIST에서 Waiting for table metadata lock 확인
  2. performance_schema.metadata_locks로 홀더와 웨이터를 같은 테이블 기준으로 매칭
  3. 홀더 세션의 트랜잭션을 정상 종료(가능하면)하거나, 긴급 시 KILL
  4. DDL은 ALGORITHMLOCK 옵션을 명시하고, 대기 시간 제한과 실행 창을 분리
  5. 재발 방지를 위해 긴 트랜잭션을 만드는 코드/설계를 제거

MDL은 “스키마 변경 때만 생기는 특수한 락”이 아니라, 트랜잭션과 운영 습관이 만나면 언제든 장애로 커질 수 있는 기본 메커니즘입니다. 위의 진단 쿼리와 우선순위만 팀 런북에 넣어도, 다음 번에는 ‘DB가 멈춘 것 같은데 뭐부터 보지’라는 시간을 크게 줄일 수 있습니다.