운영 중인 서비스에 컬럼 하나 추가하려고 ALTER TABLE을 던졌는데 커서만 깜빡이고 프롬프트가 안 돌아온 경험은 DBA라면 한 번쯤 겪는다. 더 곤란한 건 그 사이에 해당 테이블을 읽으려던 애플리케이션 쿼리까지 줄줄이 대기 상태로 쌓이는 상황이다. 배포 창을 잡고 들어갔다가 이 지점에서 막혀 롤백하는 경우를 현장에서 자주 본다. 이번 글은 그 원인인 메타데이터 락을 어떻게 특정하고 어떻게 푸는지 정리한다.
결론부터 말하자면, ALTER나 DROP은 테이블에 배타적 메타데이터 락(exclusive MDL)이 필요한데, 커밋하지 않은 장기 트랜잭션이나 닫지 않은 커서가 같은 테이블에 공유 락을 잡고 있으면 그 락이 풀릴 때까지 DDL이 대기한다. 범인은 MariaDB의 METADATA_LOCK_INFO 플러그인과 information_schema.innodb_trx로 특정하고, lock_wait_timeout을 짧게 걸어 DDL이 빨리 실패하게 만든 다음 배포 순서로 교착을 예방한다.
1. 메타데이터 락(MDL)이 무엇이고 왜 존재하나
메타데이터 락(metadata lock, MDL)은 트랜잭션이 테이블을 쓰는 동안 그 테이블의 구조(정의)가 바뀌지 못하게 막는 잠금이다. 예전 LOCK TABLES 방식과 달리, 트랜잭션이 시작되면 참조한 테이블에 자동으로 잡히고 트랜잭션이 끝날 때까지 유지된다. 어떤 세션이 SELECT 한 번을 트랜잭션 안에서 실행하면 그 테이블에 공유 MDL이 걸리고, 그 트랜잭션이 커밋 또는 롤백되기 전까지는 아무도 테이블 정의를 바꿀 수 없다.
DDL은 반대로 배타적 MDL을 요구한다. 다른 세션이 공유 MDL을 하나라도 쥐고 있으면 ALTER는 그것들이 다 풀릴 때까지 기다린다. 이 대기 상태가 SHOW PROCESSLIST에서 보이는 Waiting for table metadata lock이다.
여기서 진짜 문제가 시작된다. DDL이 배타적 락을 요청하는 순간, 그 테이블을 새로 읽거나 쓰려는 후속 쿼리들도 DDL 뒤에 줄을 선다. 즉 커밋 안 한 트랜잭션 하나 때문에 DDL이 멈추고, 멈춘 DDL 때문에 신규 트래픽까지 막히는 연쇄가 벌어질 수 있다.
2. 전형적인 증상 재현
세션 A에서 오토커밋을 끄고 조회만 한 뒤 그대로 방치한다. 실무에선 커넥션 풀이 트랜잭션을 열어둔 채 다음 쿼리를 기다리는 상황이 이렇게 된다.
-- 세션 A (범인: 커밋 안 한 트랜잭션) MariaDB> SET autocommit=0; MariaDB> SELECT COUNT(*) FROM articles; +----------+ | COUNT(*) | +----------+ | 1941 | +----------+ 1 row in set (0.001 sec) -- 여기서 COMMIT/ROLLBACK 없이 손을 뗀다
세션 B에서 DDL을 던지면 반환되지 않고 멈춘다.
-- 세션 B (피해자: DDL) MariaDB> ALTER TABLE articles ADD COLUMN summary VARCHAR(255) NULL; -- (응답 없이 대기)
세션 C에서 프로세스 목록을 보면 상태가 드러난다.
MariaDB> SHOW PROCESSLIST; +----+------+-----------+------+---------+------+---------------------------------+-----------------------------------------------+ | Id | User | Host | db | Command | Time | State | Info | +----+------+-----------+------+---------+------+---------------------------------+-----------------------------------------------+ | 12 | app | localhost | sarc | Sleep | 310 | | NULL | | 15 | root | localhost | sarc | Query | 47 | Waiting for table metadata lock | ALTER TABLE articles ADD COLUMN summary ... | | 18 | root | localhost | sarc | Query | 0 | init | SHOW PROCESSLIST | +----+------+-----------+------+---------+------+---------------------------------+-----------------------------------------------+
여기서 흔히 헷갈리는 함정이 하나 있다. 범인인 Id 12는 Command가 Sleep이다. 지금 아무 쿼리도 실행하고 있지 않다는 뜻이라 무해해 보인다. 하지만 트랜잭션은 아직 열려 있고 공유 MDL을 쥔 채다. Sleep 세션이라고 넘어가면 진짜 원인을 놓친다.
3. 환경
- MariaDB 10.6+ (또는 MySQL 8.0). 아래 진단은 MariaDB의
METADATA_LOCK_INFO플러그인을 기준으로 한다. - 스토리지 엔진: InnoDB.
- 버전에 따라 컬럼명이나 기본값이 다를 수 있으니 실제 서버에서
SELECT @@version;과SHOW VARIABLES로 확인한 값을 쓴다.
4. 범인 특정: METADATA_LOCK_INFO와 innodb_trx
SHOW PROCESSLIST만으로는 어느 세션이 문제의 테이블에 MDL을 쥐고 있는지 딱 짚기 어렵다. MariaDB는 METADATA_LOCK_INFO라는 플러그인으로 활성 메타데이터 락을 information_schema 테이블로 노출한다. 라이브러리는 기본 배포되지만 플러그인 자체는 기본 설치가 아니라서 한 번 설치해야 한다.
MariaDB> INSTALL SONAME 'metadata_lock_info'; Query OK, 0 rows affected (0.01 sec)
설치 후 테이블을 조회하면 활성 락이 보인다. 컬럼은 THREAD_ID, LOCK_MODE, LOCK_DURATION, LOCK_TYPE, TABLE_SCHEMA, TABLE_NAME 여섯 개다.
MariaDB> SELECT * FROM information_schema.METADATA_LOCK_INFO; +-----------+---------------------+---------------+---------------------+--------------+------------+ | THREAD_ID | LOCK_MODE | LOCK_DURATION | LOCK_TYPE | TABLE_SCHEMA | TABLE_NAME | +-----------+---------------------+---------------+---------------------+--------------+------------+ | 12 | MDL_SHARED_READ | NULL | Table metadata lock | sarc | articles | | 15 | MDL_INTENTION_EXCL. | NULL | Global read lock | | | +-----------+---------------------+---------------+---------------------+--------------+------------+
THREAD_ID는 프로세스 목록의 Id와 같다. 여기서 Id 12가 articles에 MDL_SHARED_READ(공유 읽기 락)를 쥔 게 확인된다. 바로 이 세션이 DDL을 막고 있다.
범인을 프로세스 정보와 붙여 누구인지, 얼마나 오래됐는지 한 번에 보려면 PROCESSLIST와 조인한다.
MariaDB> SELECT m.THREAD_ID, m.LOCK_MODE, m.LOCK_TYPE,
-> m.TABLE_SCHEMA, m.TABLE_NAME,
-> p.USER, p.HOST, p.TIME AS sec, p.COMMAND, p.STATE
-> FROM information_schema.METADATA_LOCK_INFO m
-> JOIN information_schema.PROCESSLIST p ON p.ID = m.THREAD_ID
-> WHERE m.TABLE_NAME = 'articles';
+-----------+-----------------+---------------------+--------------+------------+------+-----------+-----+---------+-------+
| THREAD_ID | LOCK_MODE | LOCK_TYPE | TABLE_SCHEMA | TABLE_NAME | USER | HOST | sec | COMMAND | STATE |
+-----------+-----------------+---------------------+--------------+------------+------+-----------+-----+---------+-------+
| 12 | MDL_SHARED_READ | Table metadata lock | sarc | articles | app | localhost | 310 | Sleep | |
+-----------+-----------------+---------------------+--------------+------------+------+-----------+-----+---------+-------+
여기에 더해, 락을 쥔 트랜잭션이 정말 오래 열려 있는지는 innodb_trx로 나이를 잰다. Sleep 세션이지만 트랜잭션 시작 시각이 한참 전이면 방치된 커서나 커밋 누락이 확실하다.
MariaDB> SELECT trx_id, trx_state, trx_started,
-> TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
-> trx_mysql_thread_id AS thread_id
-> FROM information_schema.innodb_trx
-> ORDER BY trx_started;
+-----------------+-----------+---------------------+---------+-----------+
| trx_id | trx_state | trx_started | age_sec | thread_id |
+-----------------+-----------+---------------------+---------+-----------+
| 421...F3 | RUNNING | 2026-08-21 10:12:04 | 310 | 12 |
+-----------------+-----------+---------------------+---------+-----------+
trx_mysql_thread_id가 12로, 앞서 락을 쥔 세션과 일치한다. 세 테이블이 같은 스레드를 가리키면 범인 특정은 끝난다.
참고로 MySQL 8.0에는 이 플러그인이 없다. 대신 performance_schema.metadata_locks를 켜서 OBJECT_TYPE='TABLE', LOCK_STATUS='PENDING'/'GRANTED'를 threads, events_statements_current와 조인해 같은 정보를 얻는다.
5. 즉시 해소: 막힌 세션 KILL
범인 스레드를 찾았으면 그 커넥션을 종료해 락을 놓게 한다. DDL 세션(피해자)이 아니라 락을 쥔 세션을 죽여야 한다.
MariaDB> KILL 12; Query OK, 0 rows affected (0.00 sec)
범인이 종료되면 대기하던 ALTER가 배타적 락을 잡고 바로 진행된다.
-- 세션 B에서 Query OK, 0 rows affected (0.42 sec) Records: 0 Duplicates: 0 Warnings: 0
다만 KILL은 응급 처치다. 애플리케이션이 트랜잭션을 방치하는 근본 원인(오토커밋 끔, 예외 처리에서 롤백 누락, 커서 미종료)을 안 고치면 다음 배포에서 또 막힌다.
6. lock_wait_timeout으로 DDL을 빨리 실패시키기
lock_wait_timeout은 메타데이터 락을 얻으려고 기다리는 최대 시간(초)이다. 이 시간을 넘기면 문장이 ERROR 1205로 실패한다. 값을 짧게 잡아두면 DDL이 무한정 대기하며 뒤에 트래픽을 쌓는 대신, 빠르게 포기하고 자리를 비운다.
여기서 기본값을 정확히 알아야 한다. MariaDB의 lock_wait_timeout 기본값은 86400초, 즉 1일이다. 범위는 0부터 31536000까지다. 아무 설정 없이 DDL을 던지면 무한 대기가 아니라 1일 뒤 실패한다. 운영에서 하루를 기다리는 건 사실상 장애이므로, 배포 스크립트에서 세션 단위로 값을 낮춰 던지는 습관이 안전하다.
-- MariaDB: 기본 86400초(1일). 배포 세션에서 짧게 재설정 MariaDB> SET SESSION lock_wait_timeout = 10; MariaDB> ALTER TABLE articles ADD COLUMN summary VARCHAR(255) NULL; ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
10초 안에 락을 못 얻으면 이렇게 실패한다. DDL이 트래픽을 볼모로 잡지 않으니, 이 에러를 신호로 받아 범인 트랜잭션을 정리한 뒤 재시도하면 된다.
MySQL 8.0은 값 체계가 다르다. lock_wait_timeout 기본값과 최대값이 모두 31536000초(1년), 최소값이 1이다. 그래서 MySQL은 기본 설정에서 사실상 무한 대기에 가깝다. 같은 변수라도 MariaDB(기본 1일)와 MySQL(기본 1년)의 기본 동작이 다르다는 점을 배포 표준에 반영해야 한다. 어느 쪽이든 배포 세션에서 명시적으로 낮춰 던지면 이 차이에 휘둘리지 않는다.
7. 배포 순서로 교착을 예방한다
락을 죽이고 다시 던지는 건 결국 사후 대응이다. 실무에선 DDL이 배타적 락을 짧게, 방해 없이 잡도록 순서를 설계하는 편이 낫다.
DDL 직전에 장기 트랜잭션을 비운다
배포 스크립트 첫 단계에서 innodb_trx를 확인해 오래된 트랜잭션이 없는지 게이트를 건다. 아래처럼 임계치를 넘는 트랜잭션이 있으면 DDL을 시작하지 않고 알람을 띄운다.
MariaDB> SELECT COUNT(*) AS long_trx
-> FROM information_schema.innodb_trx
-> WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;
+----------+
| long_trx |
+----------+
| 0 |
+----------+
애플리케이션 쪽 커넥션을 먼저 정리한다
배치 작업이나 리포트성 조회처럼 트랜잭션을 길게 여는 워크로드는 DDL 창 동안 잠시 멈춘다. 커넥션 풀이 오토커밋을 끈 채 아이들 커넥션을 유지하면 그 커넥션이 공유 MDL을 쥘 수 있으니, 배포 직전에 풀을 재기동하거나 max lifetime을 짧게 두는 것도 방법이다.
온라인 DDL과 조합한다
MariaDB/MySQL의 온라인 DDL(ALGORITHM=INPLACE 또는 INSTANT)은 변경 작업 자체는 테이블을 오래 잠그지 않는다. 그래도 시작과 끝 시점에는 짧은 배타적 MDL이 필요하다. 즉 온라인 DDL이라도 그 찰나의 락조차 장기 트랜잭션이 있으면 못 얻어 Waiting for table metadata lock에 걸린다. 온라인 DDL과 '장기 트랜잭션 비우기'는 둘 중 하나가 아니라 함께 가야 하는 짝이다.
MariaDB> ALTER TABLE articles ADD COLUMN summary VARCHAR(255) NULL,
-> ALGORITHM=INSTANT;
Query OK, 0 rows affected (0.03 sec)
8. 검증
DDL이 끝난 뒤에는 락이 남지 않았는지, 구조가 바뀌었는지 확인한다.
-- 남은 메타데이터 락이 없는지 (해당 테이블 기준)
MariaDB> SELECT COUNT(*) FROM information_schema.METADATA_LOCK_INFO
-> WHERE TABLE_NAME = 'articles';
+----------+
| COUNT(*) |
+----------+
| 0 |
+----------+
-- 변경 반영 확인
MariaDB> SHOW CREATE TABLE articles\G
9. 정리
Waiting for table metadata lock은 DDL의 배타적 MDL 요구와, 커밋하지 않은 트랜잭션이 쥔 공유 MDL이 부딪혀 생긴다. 진단은 METADATA_LOCK_INFO로 락을 쥔 THREAD_ID를 찾고, innodb_trx로 그 트랜잭션의 나이를 확인해 범인을 특정하는 순서로 한다. 급하면 그 세션을 KILL하고, 배포 세션에는 lock_wait_timeout을 짧게 걸어 DDL이 트래픽을 볼모로 잡지 않게 한다. 기본값이 MariaDB 1일, MySQL 1년으로 다르다는 점을 잊지 말자. 근본 예방은 DDL 직전에 장기 트랜잭션을 비우고, 온라인 DDL과 배포 순서를 함께 설계하는 것이다.