1편에서 메타데이터 락(MDL)의 원리를 다뤘다. 이번 편은 DDL 종류별로 락이 어디까지, 어떤 강도로 걸리는지를 실측으로 분석하고, 그것이 실제 배포 장애로 이어지는 과정을 재구성한다. 모든 실험은 Docker의 MySQL 8.0.46에서 실행했다.
진행 순서는 다음과 같다. 컬럼 추가, 유니크 인덱스, FK, CREATE TABLE 네 케이스를 차례로 분석해 케이스별 락 프로필 표로 모으고, 그 표를 가지고 배포 장애를 재구성한 뒤, 대응 방법으로 마무리한다.
ADD COLUMN: 가장 가벼운 DDL의 조건
컬럼 추가는 1편에서 정리한 세 알고리즘(INSTANT/INPLACE/COPY)이 모두 가능하고, 기본값이 INSTANT다. 락은 자기 테이블에만 걸린다. 209만 row 테이블 실측으로 비교하면 차이가 선명하다.
INSTANT 0.027초 (데이터량과 무관)
COPY 약 3.2초 (row 수에 비례, 쓰기 차단)
INPLACE 약 6.9초 (row 수에 비례, 동시 쓰기 허용)
다만 INSTANT에는 조건이 있다. 실측과 문서로 확인한 거부 조건들이다.
- 64 row version 한도 도달: 아래에서 상세히 다룬다
ROW_FORMAT=COMPRESSED: 페이지를 통째로 압축하는 특수 포맷이라 레코드 헤더 확장이 구현되지 않았다- FULLTEXT 인덱스 보유 테이블: FTS는 숨은 컬럼(
FTS_DOC_ID)과 보조 테이블들이 컬럼 배치에 의존하는 구조라, 정합성이 검증되지 않아 금지됐다 - temporary table: 세션 전용이라 동시성 문제 자체가 없어 INSTANT가 필요 없는 영역이다
- 혼합 문장: 알고리즘은 ALTER 문장 단위로 하나다. INSTANT 가능한 변경과 불가능한 변경을 한 문장에 쓰면 전체가 무거운 쪽을 따른다. INSTANT 가능한 변경은 문장을 분리하는 것이 좋다
- AUTO_INCREMENT 컬럼 추가, STORED generated 컬럼 추가: 기존 row마다 서로 다른 값을 만들어 저장해야 하므로, "없는 컬럼은 상수 하나로 합성한다"는 INSTANT의 원리와 양립할 수 없다. rebuild가 필요하다
- 맨 끝 위치 제약 (8.0.12~8.0.28 한정): 이 구간의 버전에서는 맨 끝 위치에만 추가할 수 있었고, 8.0.29부터 임의 위치가 지원된다. 단 기존 컬럼의 순서 이동은 지금도 INSTANT가 불가능하다
64 row version 한도와 무경고 폴백
INSTANT ADD/DROP은 문장 1회당 테이블의 row version을 1씩 올리고, 상한은 64이다. 실제로 한도를 채우면 무슨 일이 생기는지 실험했다. instant ALTER를 반복해 information_schema.INNODB_TABLES의 TOTAL_ROW_VERSIONS를 64까지 만든 상태에서:
ALTER TABLE users ADD COLUMN overflow1 INT, ALGORITHM=INSTANT;
→ ERROR 4092: Maximum row versions reached for table lab/users.
No more columns can be added or dropped instantly. Please use COPY/INPLACE.
ALTER TABLE users ADD COLUMN overflow1 INT; -- ALGORITHM 미지정
→ 성공. TOTAL_ROW_VERSIONS가 0으로 리셋
두 번째 결과가 주의할 지점이다. ALGORITHM을 지정하지 않으면 에러 대신 조용히 INPLACE(rebuild)로 폴백된다. TOTAL_ROW_VERSIONS를 모니터링 대상으로 두고, 배포 문장에 ALGORITHM=INSTANT를 명시해 폴백을 에러로 감지하는 것이 안전하다.
rebuild는 이 버전 카운터를 0으로 리셋한다. 참고로 8.4.4부터는 빈 테이블에 대한 ADD/DROP COLUMN의 기본이 INSTANT가 아니라 INPLACE로 바뀌었는데, 빈 테이블은 rebuild도 순간이니 row version을 아끼려는 취지로 보인다.
동결되는 기본값
INSTANT로 추가된 컬럼의 기본값이 데이터 파일이 아니라 딕셔너리에 저장된다는 것은 1편에서 확인했다. 여기서 파생되는 동작이 하나 있는데, 실제 데이터 조회로 직접 확인해볼 수 있다.
ALTER TABLE users ADD COLUMN status VARCHAR(10) DEFAULT 'old', ALGORITHM=INSTANT;
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'new';
INSERT INTO users(name, age) VALUES ('after_change', 20);
SELECT id, name, status FROM users;
→ 기존 row 3건: status = 'old' ← ALTER 시점의 기본값으로 고정
→ 새 row 1건: status = 'new' ← 변경된 기본값 적용
기존 row가 합성해 보여주는 값은 INSTANT 실행 시점의 기본값으로 동결되고, 이후 SET DEFAULT를 바꿔도 영향을 받지 않는다. information_schema.INNODB_COLUMNS를 조회하면 동결된 값이 hex로 그대로 보인다 (status의 DEFAULT_VALUE = 6f6c64, 'old'의 hex). 두 개의 기본값이 공존하는 셈이다.
ADD UNIQUE INDEX: 완주가 보장되지 않는 DDL
인덱스 추가는 INSTANT가 원리적으로 불가능하다. 1편에서 본 대로 INSTANT는 정의만 바꾸고 부족한 부분을 읽는 순간 해석으로 채우는 방식인데, 인덱스는 그렇게 대신할 수 없는 실물 자료구조이고 미리 지어놓는 것 자체가 존재 의의이기 때문이다. 대신 rebuild도 아니다. 테이블 본체는 건드리지 않고 옆에 세컨더리 B+tree를 새로 짓는다 (INPLACE를 통한 동시 DML 허용).
빌드는 PK 스캔 → (키, PK) 추출 → 외부 병합 정렬 → 벌크 구축 순서로 진행된다. 벌크 구축은 정렬을 마친 데이터를 B+tree의 리프(말단) 페이지부터 차례로 채워 넣고 그 위의 상위 계층을 만들어 올리는 방식이라, 루트에서 탐색해 내려가며 한 건씩 삽입하는 일반 경로보다 훨씬 빠르다. 실제로 209만 row의 유니크 인덱스 빌드가 1.3초에 끝났다.
문제는 UNIQUE 제약이다. 빌드 중에도 동시 INSERT가 허용되는데, 그중에 중복 값이 들어오면 어떻게 될까.
중복 검출 시점 실측: 3구간
중복이 언제 들어오느냐에 따라 결과가 갈린다. 실험을 통해 실측한 결과는 아래와 같다.
구간 실측 결과
─────────────── ─────────────────────────────────────────────────────
빌드 전부터 존재 ALTER가 60ms 만에 ERROR 1062로 실패
(첫 정렬 버퍼에서 인접 중복이 발견되는 즉시 중단)
빌드 중에 유입 INSERT는 정상 커밋 (아직 제약이 없으므로)
→ ALTER가 마지막 row log 적용 단계(commit 단계)에서 ERROR 1062로 실패
→ 인덱스는 만들어지지 않고, 중복 row 2건은 모두 생존
빌드 완료 후 도착 이때부턴 일반적인 제약 위반으로 INSERT가 거부됨
두 번째 구간이 이 케이스의 핵심이다. 검증을 로그 축적 시점에 미리 하지 않는 이유는 세 가지다.
- 빌드 중에는 완성된 인덱스가 없어서, 들어온 값이 아직 스캔하지 않은 구간의 기존 row와 충돌하는지 알 수 없다
- 온라인 DDL의 원칙은 DML 경로에 대한 간섭 최소화다
- 그 중복 row가 빌드가 끝나기 전에 DELETE로 사라질 수도 있다. 성급한 거부는 오탐이 된다
그래서 충돌이 확정되는 순간은 마지막 로그 적용 때이고, 그 시점에 희생되는 쪽은 이미 커밋된 DML이 아니라 DDL이다. 실무적으로는 온라인 유니크 인덱스 추가는 성공이 보장되지 않는 작업이라는 뜻이 된다. 몇 분 돌던 ALTER를 트래픽 한 건이 무효로 만들 수 있으니, 사전 중복 정리와 실패 시 재시도를 전제로 설계해야 한다.
ADD FOREIGN KEY: 부모 테이블까지 잠긴다
FK부터는 락이 자기 테이블을 벗어난다. row 3건의 parent와 209만 row의 child로 실험했다.
ALTER TABLE child ADD CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parent(id);
두 가지 경로: foreign_key_checks
FK 추가는 foreign_key_checks 변수에 따라 경로가 갈린다.
경로 1: foreign_key_checks=1 (기본값). 기존 child row 전체가 유효한 parent를 가리키는지 검증해야 하고, 이 검증은 COPY 알고리즘 위에서만 지원된다. INPLACE를 지정하면 에러가 규칙을 직접 알려준다.
ERROR 1846: ALGORITHM=INPLACE is not supported.
Reason: Adding foreign keys needs foreign_key_checks=OFF. Try ALGORITHM=COPY.
실측 결과 209만 row에 4.0초가 걸렸고, 그동안의 락 구도는 다음과 같았다.
child SHARED_NO_WRITE GRANTED ← 복사 중 child 쓰기 동결 (읽기 허용)
parent SHARED_UPGRADABLE GRANTED ← DDL 시작 시 관련 테이블 전부에 잡는 예약 락
parent SHARED_READ_ONLY GRANTED ← 부모 쓰기 동결 (읽기 허용)
parent의 SHARED_UPGRADABLE부터 설명하면, DDL은 시작할 때 자기 테이블만이 아니라 FK로 얽히는 관련 테이블 전부에 SHARED_UPGRADABLE을 먼저 잡는다. 그 테이블들에 대한 동시 DDL을 배제하고(테이블당 DDL 1자리), 이후 단계에서 필요한 강도로 승격하기 위한 자리 확보다.
SHARED_READ_ONLY는 1편 끝에서 이름만 예고했던 락 타입이다. "이 테이블이 변하지 않기를 원한다. 다른 세션의 읽기는 허용, 쓰기는 차단"이라는 의미다. 검증 도중 parent의 row가 삭제되면 이미 검증한 결과가 무효가 되므로, 검증 순서와 무관하게 parent 전체를 동결하는 것이다. row 단위 락 대신 테이블 수준 MDL 하나로 해결한다. 다른 세션에 미치는 효과는 SHARED_NO_WRITE와 같지만, SHARED_NO_WRITE가 EXCLUSIVE 승격을 전제로 한 1자리짜리 DDL 계열인 것과 달리 SHARED_READ_ONLY는 승격 없이 여러 세션이 함께 들 수 있다는 차이가 있다. 실제로 ALTER 도중 던진 parent UPDATE는 2초를 대기하다 ALTER 종료 직후에야 완료됐다.
경로 2: foreign_key_checks=0. INPLACE가 허용되고 메타데이터만 수정한다. 같은 209만 row에 3ms였다. 대신 기존 데이터를 검증하지 않는다. 실험으로 확인해보면:
SET foreign_key_checks=0;
INSERT INTO child VALUES (4000000, 999, 'orphan'); -- 존재하지 않는 부모를 참조
ALTER TABLE child ADD CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parent(id), ALGORITHM=INPLACE;
→ 성공 (3ms)
-- 새 세션 (foreign_key_checks=1)
SELECT * FROM child WHERE parent_id=999; → 고아 row가 그대로 존재
INSERT INTO child VALUES (4000001, 998, 'orphan2'); → ERROR 1452 (거부)
제약은 미래의 쓰기부터만 지켜준다. foreign_key_checks를 다시 1로 되돌려도 소급 검증은 일어나지 않으며, 고아를 찾으려면 anti-join 쿼리로 직접 검사해야 한다. PostgreSQL의 NOT VALID + VALIDATE CONSTRAINT 같은 사후 온라인 검증 명령이 MySQL에는 없다.
이렇게 남은 고아 참조는 애플리케이션 계층에서 뒤늦게 드러난다. JPA 기준으로 @ManyToOne 대상이 없으면 EAGER 로딩은 조회 시점에, LAZY 로딩은 프록시를 처음 건드리는 시점에 EntityNotFoundException이 발생한다. 쿼리와 멀리 떨어진 곳에서 터지는 유형의 장애다.
대기 중 역학: 진입 차단 권한이 없는 SHARED_READ_ONLY
경로 1의 SHARED_READ_ONLY에는 1편의 EXCLUSIVE와 결정적으로 다른 성질이 있다. 실험 구성은 다음과 같다.
t=0 A: BEGIN; UPDATE parent ... (커밋 안 함) → parent에 SHARED_WRITE 보유
t=1 B: ADD FK 시작 → child SHARED_NO_WRITE 획득
→ parent SHARED_READ_ONLY 요청 → PENDING
t=4 C: SELECT parent → 2ms 만에 통과
t=4.5 D: UPDATE parent (다른 row) → 3ms 만에 통과 (!)
t=8 A 종료 → 그제야 B가 획득, 복사 4초 진행
D가 주목할 지점이다. B의 SHARED_READ_ONLY가 대기 중인데도, 그와 비호환인 신규 UPDATE가 먼저 처리됐다. 1편에서 정리한 락 허가의 두 조건을 떠올려보면, 두 번째 조건(대기 중인 강한 요청이 뒤 요청의 진입을 막는 것)은 EXCLUSIVE 같은 타입에만 적용된다. SHARED_READ_ONLY의 대기 요청에는 그 권한이 없어서, D의 허가 판정은 이미 허가된 락들과의 호환성만으로 끝났고 전부 호환이라 즉시 통과한 것이다.
결과적으로 두 대기의 모양이 정반대다.
- 대기 중인
EXCLUSIVE: 신규 트래픽 전체를 정지시킨다. 1편에서 본 pile-up이다 - 대기 중인
SHARED_READ_ONLY: 늦게 온 요청들이 계속 먼저 처리된다. 쓰기가 끊이지 않는 부모라면 DDL이 락을 얻을 틈을 계속 놓친다 (기아, starvation)
그리고 이렇게 대기가 길어지는 동안에도 child의 SHARED_NO_WRITE는 이미 쥐고 있다. 즉 부모가 바쁠수록 FK 추가는 하염없이 늘어지고, 그 시간 내내 child 테이블의 쓰기는 계속 막혀 있다. 부모 서비스는 멀쩡한데 child 서비스의 쓰기만 원인 모르게 길게 멈추는 장애 유형이다.
반대 방향도 실험해봤다. child 쪽에 미커밋 쓰기 트랜잭션이 있으면 ALTER는 검증을 시작하기도 전에 child의 SHARED_NO_WRITE 획득에서 대기한다. 이때의 역학은 또 다르다. 대기 중인 SHARED_NO_WRITE는 자신과 비호환인 신규 쓰기의 진입을 막는 권한이 있어서, 실측에서 새로 도착한 child INSERT는 선행 트랜잭션의 잔여 시간과 복사 시간을 전부 합친 6.9초를 기다렸고, 읽기만 통과했다. 즉 자식이 바빠서 시작조차 못 한 ALTER도 child 쓰기 정지는 즉시 일으킨다.
모든 경로의 공통점: 커밋 시점의 부모 EXCLUSIVE
경로 2(3ms)라면 부모에는 영향이 없을까. parent에 커밋 안 한 읽기 트랜잭션을 하나 걸어두고 각 경로를 실행해봤다.
실험 (parent에 커밋 안 한 SELECT 트랜잭션 존재) 결과
────────────────────────────────────────────── ──────────────
foreign_key_checks=0 + ADD FK (작업 자체는 3ms) 4.0초 대기
DROP FOREIGN KEY 4.0초 대기
foreign_key_checks=1 + ADD FK (복사 4초) 11.5초 (복사 후 7.5초 추가 대기)
결론은 간단하다. FK 검증을 생략해도 FK 관계를 커밋하는 단계는 남고, 이 커밋에는 여느 DDL처럼 EXCLUSIVE가 필요하다. 특별한 점은 그 EXCLUSIVE가 child만이 아니라 parent에도 필요하다는 것 하나다. FK가 생기면 parent의 정의도 바뀌기 때문이다. 이 관계가 생기는 순간부터 parent의 DELETE/UPDATE는 자식 검사를 수행해야 하므로 parent의 캐시된 정의 역시 교체 대상이고, 1편의 원칙대로 정의 교체에는 EXCLUSIVE가 필요하다. checks=0 경로가 parent에 막혀 대기하던 순간의 실측 스냅샷이다.
child EXCLUSIVE GRANTED ← 커밋 준비를 마친 child는 이미 전면 동결
parent SHARED_UPGRADABLE GRANTED
parent EXCLUSIVE PENDING ← 부모의 미커밋 읽기 트랜잭션에 막힘
그래서 작업량이 3ms인 경로도 부모의 미커밋 트랜잭션 하나에 막힐 수 있고, 대기 중에는 부모의 신규 트래픽도 함께 정지한다. foreign_key_checks=0도 부모에는 안전하지 않은 이유다. 경로 1 역시 검증 구간이 앞에 붙을 뿐 마지막은 같다 (child 쪽 실험에서 commit 단계의 child EXCLUSIVE PENDING을 직접 확인했다).
CREATE TABLE: FK 유무의 차이
FK가 없는 CREATE TABLE은 기존 객체를 전혀 건드리지 않는다. 잡는 락은 새 테이블 이름에 대한 EXCLUSIVE뿐이다. 아직 존재하지 않는 이름에도 MDL이 걸리는 이유는 동명의 CREATE가 동시에 실행되는 것을 직렬화하기 위해서다. 실측으로도 parent에 커밋 안 한 읽기 트랜잭션이 있는 상태에서 16ms에 끝났다. 이때의 락 구성은 다음이 전부다.
새 테이블 이름 EXCLUSIVE GRANTED ← 이름 예약. 기존 테이블에는 아무 락도 없음
FK가 있는 CREATE TABLE에는 바로 위 ADD FK에서 확인한 결론(커밋 순간의 부모 EXCLUSIVE)이 그대로 적용된다. 시리즈의 계기로 소개했던 바로 그 장면이다. row 3건짜리 parent를 참조하는 새 테이블로 실험한 결과다.
세션 A | BEGIN; SELECT COUNT(*) FROM parent; -- 읽기만 하고 커밋하지 않고 방치
세션 B | CREATE TABLE new_child (id INT PRIMARY KEY, parent_id INT,
| FOREIGN KEY (parent_id) REFERENCES parent(id));
세션 C | SELECT COUNT(*) FROM parent; -- 일반 조회
B의 CREATE TABLE이 5.0초 대기했고, 그 대기 중에 도착한 C의 SELECT도 4.0초를 기다렸다. 세션 A가 풀리는 순간 B와 C가 연달아 완료됐다. 대기 중의 락 스냅샷이다.
object_name lock_type lock_status session
parent SHARED_READ GRANTED A ← 세션 A(방치한 트랜잭션)가 쥔 락
parent EXCLUSIVE PENDING B ← CREATE TABLE이 부모에 EXCLUSIVE를 요청
new_child EXCLUSIVE GRANTED B ← 존재하지 않는 이름에 잡힌 락
ADD FK의 검증 구간에서 보였던 SHARED_READ_ONLY가 여기 없는 이유는 검증의 대상이 다르기 때문이다. CREATE TABLE에도 부모에 대한 확인은 필요하지만, 참조 테이블/컬럼의 존재나 타입 호환 같은 정의 수준의 확인이다 (타입이 다르면 ERROR 3780, 부모가 없으면 ERROR 1824로 즉시 거부된다). 이런 확인은 스캔이 필요 없어 부모 EXCLUSIVE를 쥔 짧은 순간에 함께 끝난다. row를 하나하나 대조하는 데이터 검증, 즉 SHARED_READ_ONLY로 부모를 몇 초씩 동결해야 했던 그 구간이 없는 것이다. 그래서 곧장 커밋의 EXCLUSIVE를 요청한다.
새 테이블을 만드는 문장이 참조 대상인 부모에 EXCLUSIVE를 요청했고, 부모의 미커밋 트랜잭션 하나(읽기 포함)에 막혔으며, 대기 중인 EXCLUSIVE가 늦게 온 요청의 진입을 막는 규칙 때문에 3건짜리 테이블의 SELECT COUNT(*)까지 4초를 기다렸다.
파급만 놓고 보면 역설적인 순위가 나온다. 기존 테이블에 검증하며 FK를 거는 쪽은 부모 트래픽이 허용되는 대신 DDL이 막히는데, FK 있는 새 테이블 만들기는 부모 트래픽 전체를 정지시키는 쪽이다. 이 차이의 원인은 부모에 요청하는 락 타입이다. 검증하며 거는 쪽의 긴 대기는 SHARED_READ_ONLY 획득에서 생기는데 이 타입은 대기 중 진입 차단 권한이 없어 DDL의 대기만 길어지고, CREATE TABLE은 검증 구간 없이 곧장 EXCLUSIVE를 요청하는데 이 타입의 대기는 신규 요청 전체의 진입을 막는다.
케이스별 락 프로필 총정리
지금까지의 실측을 한 표로 모은다. 락 이름은 전부 MDL이다.
| 케이스 | 자기(대상) 테이블 | 부모(참조) 테이블 |
|---|---|---|
| ADD COLUMN (INSTANT/INPLACE) | SHARED_UPGRADABLE → 짧은 EXCLUSIVE |
관여 없음 |
| ADD COLUMN (COPY 변형) | SHARED_NO_WRITE → EXCLUSIVE |
관여 없음 |
| ADD FK (checks=1, COPY) | SHARED_NO_WRITE 전 구간 → EXCLUSIVE |
SHARED_UPGRADABLE + SHARED_READ_ONLY(검증 내내) → EXCLUSIVE(커밋) |
| ADD FK (checks=0) / DROP FK | SHARED_UPGRADABLE → 짧은 EXCLUSIVE |
EXCLUSIVE (커밋 순간) |
| CREATE TABLE + FK | 새 이름에 EXCLUSIVE |
EXCLUSIVE (커밋 순간) |
| CREATE TABLE (FK 없음) | 새 이름에 EXCLUSIVE |
관여 없음 |
공통으로 GLOBAL / SCHEMA / BACKUP LOCK에는 INTENTION_EXCLUSIVE가 함께 잡힌다.
파급을 읽는 규칙은 1편의 두 구간 구분 그대로다.
- 획득 전(PENDING): 진입 차단 권한은 타입마다 다르다. 대기 중인
EXCLUSIVE는 신규 트래픽 전체의 진입을, 대기 중인SHARED_NO_WRITE는 신규 쓰기의 진입을 막는다.SHARED_READ_ONLY는 이 권한이 없어 늦게 온 요청이 계속 먼저 처리된다(기아) - 획득 후(GRANTED):
SHARED_UPGRADABLE은 영향 없음,SHARED_READ_ONLY/SHARED_NO_WRITE는 쓰기 차단,EXCLUSIVE는 전부 차단한다 (대신 보유는 밀리초다)
사용 가능한 알고리즘도 케이스별로 정리해둔다.
ADD COLUMN INSTANT(기본) / INPLACE / COPY
ADD UNIQUE INDEX INPLACE(기본) / COPY INSTANT 불가
ADD FK (checks=1) COPY만
ADD FK (checks=0) INPLACE (메타데이터만)
DROP FK INPLACE (메타데이터만)
CREATE TABLE 알고리즘 개념 없음
배포 장애의 재구성: hibernate ddl-auto
이 시리즈를 시작한 계기였던 실무 장면으로 돌아온다. 스프링 애플리케이션이 spring.jpa.hibernate.ddl-auto=update 상태로 배포되고, FK 연관이 있는 엔티티가 추가된 상황이다.
ddl-auto가 실행하는 문장
새 엔티티 OrderReview가 트래픽 많은 orders를 @ManyToOne으로 참조한다고 하자. hibernate가 기동 시 실행하는 문장은 일반적으로 다음 형태다.
create table order_review (id bigint not null auto_increment,
order_id bigint not null, ..., primary key (id)) engine=InnoDB;
alter table order_review
add constraint FKa1b2c3 foreign key (order_id) references orders (id);
hibernate는 FK를 CREATE TABLE에 인라인하지 않고 별도의 ALTER 문장으로 건다. 첫 문장은 무해하지만, 두 번째 문장이 위 표의 "ADD FK" 프로필을 그대로 밟는다. order_review는 빈 테이블이라 검증 비용은 0에 가깝지만, orders(부모)의 EXCLUSIVE 획득이라는 필수 단계는 데이터량과 무관하게 그대로다.
기존 엔티티에 @ManyToOne 필드를 추가한 배포라면 문장은 이렇게 된다.
alter table orders add column coupon_id bigint; -- INSTANT, 순간
alter table orders add constraint FKd4e5f6 foreign key (coupon_id) references coupons (id);
두 번째 문장이 기본 경로(checks=1)의 COPY다. 수백만 row의 orders 전체가 복사되는 몇 분 동안 orders의 쓰기가 SHARED_NO_WRITE로 동결되고, coupons도 SHARED_READ_ONLY로 묶이며, 마지막에 양쪽 EXCLUSIVE까지 통과해야 한다.
장애 공식
조건을 곱하면 장애가 된다.
[커넥션 풀에 미커밋 트랜잭션 상존] ← 트래픽 있는 시간대의 상수
× [EXCLUSIVE 요청] ← FK ALTER의 필수 단계
× [신규 요청 진입 차단] ← 해당 테이블 신규 쿼리 전면 정지
× [lock_wait_timeout 기본 1년] ← 자연 해소 없음
× [커넥션 풀 고갈] ← 한 테이블의 정지가 전 서비스로 전파
증상은 이렇게 나타난다. SHOW PROCESSLIST에 Waiting for table metadata lock이 도배되고, 해당 테이블을 만지는 API부터 타임아웃이 시작되며, 커넥션 풀이 말라붙으면서 무관한 API까지 번진다.
다만 이 공식이 항상 장애가 되는 것은 아니다. 선행 미커밋 트랜잭션들이 평소처럼 수십 ms 안에 커밋되면 EXCLUSIVE 대기도 그만큼 짧아서, 정지의 파도는 감지되지 않는 수준으로 지나간다. 문제는 오래 유지되는 트랜잭션이 하나라도 있을 때다. 대량 배치 작업, 트랜잭션 안에서 도는 긴 조회, 열어두고 방치된 BEGIN 같은 것들이다. EXCLUSIVE 대기는 그 트랜잭션의 수명만큼 이어지고, 그 시간 내내 해당 테이블의 신규 쿼리 전면 정지도 이어진다.
롤링 배포 루프: 30분짜리 배포 실패의 정체
ECS 같은 환경에서 여러 태스크가 순차 기동하는 롤링 배포라면 특유의 루프가 추가된다.
┌──────────────────────────────────────────────────────┐
│ 구버전 태스크들이 트래픽 처리 중 │
│ (미커밋 트랜잭션 공급이 끊기지 않음) │
└──────────────┬───────────────────────────────────────┘
▼
신규 태스크 기동 → ddl-auto가 DDL 실행
▼
parent EXCLUSIVE 대기 (Waiting for table metadata lock)
▼ ← lock_wait_timeout 1년:
hibernate 초기화에서 기동 정지 DDL은 영원히 기다릴 수 있음
▼ ← 헬스체크 타임아웃은 몇 분:
헬스체크 실패 → 태스크 킬 이 비대칭이 무한 루프의 원인
▼
pending 요청 취소 (큐의 자리도 소멸)
→ 뒤에 쌓였던 트래픽 일괄 통과, 일부는 새 미커밋 트랜잭션이 됨
▼
재시도 태스크가 맨 뒤에서 다시 대기 시작 ────→ (처음으로)
이 루프에는 중요한 특징이 있다. MDL 큐 자체는 순서를 지키므로, 단일 대기의 상한은 "줄 선 시점에 이미 락을 쥐고 있던 트랜잭션 중 가장 오래 가는 것"이다. 그런데 태스크가 죽으면 요청과 함께 큐의 자리도 사라지고, 재시도는 맨 뒤에서 다시 시작한다. 즉 "30분짜리 대기 1번"이 아니라 "몇 분짜리 대기 × N회 반복" 이고, 그때마다 부모 테이블 신규 쿼리 정지의 파도가 주기적으로 반복된다.
배포가 끝나야 구버전 트래픽이 빠지고, 트래픽이 빠져야 락을 얻어 배포가 끝나는 자기 유지 구조라서, 트래픽이 우연히 뜸해지는 순간까지 루프가 계속된다.
대응 방법
- 프로덕션에서 ddl-auto 사용 중지:
validate로 두고, 스키마 변경은 flyway/liquibase 등으로 문장과 실행 시점을 통제한다. 근본 대책이다 - DDL 문장에
ALGORITHM/LOCK명시: "서비스를 멈출 방식이면 실행하지 말고 실패하라"는 보험. 64 한도 폴백 같은 무경고 강등도 에러로 감지된다 - DDL 세션의
lock_wait_timeout축소 (예: 5~30초): "서비스 정지" 대신 "DDL 실패 후 재시도"를 선택하는 설정. 비용 대비 효과가 가장 좋다 - 배포 전 long transaction 점검:
information_schema.innodb_trx,sys.schema_table_lock_waits로 확인하고 필요하면 정리 후 DDL 실행 - 스키마 선배포와 코드 배포의 분리: DDL은 한산한 시간대에 별도로 실행한다
- 물리 FK 정책 재고: 검증하며 걸면 쓰기 동결, 무검증으로 걸어도 부모
EXCLUSIVE. FK 관련 DDL에 공짜 경로가 없다는 사실이, 대규모 서비스들이 물리 FK 대신 논리 FK를 선택하는 기술적 근거다 - 모니터링:
Waiting for table metadata lock상태 감지 알람,TOTAL_ROW_VERSIONS추적
정리
- 락의 범위는 케이스별로 다르다. ADD COLUMN은 자기 테이블만, FK가 얽히면 부모까지, CREATE TABLE은 존재하지 않는 이름까지 잠근다
- FK 관계가 바뀌는 커밋 순간에는 경로와 무관하게 child와 parent 양쪽의
EXCLUSIVE가 필요하다.foreign_key_checks=0도 이 단계를 피하지 못한다 - 유니크 인덱스 추가는 커밋된 트래픽 한 건에 의해 실패할 수 있는, 완주가 보장되지 않는 작업이다
- 배포 장애의 공식은 [미커밋 트랜잭션] × [EXCLUSIVE 대기] × [신규 요청 진입 차단] × [타임아웃 부재]이고, 롤링 배포에서는 헬스체크 킬로 인한 재시도 루프가 이를 30분짜리 배포 실패로 키운다
- 대응의 핵심은
EXCLUSIVE자체를 없애는 것이 아니라(불가능하다) 그 대기를 관리하는 것이다.lock_wait_timeout, 옵션 명시, 마이그레이션 도구 분리, long transaction 위생
DDL 한 줄 뒤에 이렇게 많은 장치가 있는 줄은 직접 실험해보기 전까지 몰랐다. 다음에 Waiting for table metadata lock을 만나면 이제 어느 테이블의 어떤 트랜잭션부터 봐야 할지 알 것 같다. 배포 파이프라인의 DDL 실행 경로도 이 기준으로 다시 점검해봐야겠다.
'개발 공부 > DB' 카테고리의 다른 글
| [MySQL] DDL과 메타데이터 락 1 (원리편) (0) | 2026.08.02 |
|---|---|
| [컬럼형 DB 파헤치기] 3편 - ClickHouse 외 다른 선택지들 (0) | 2026.06.28 |
| MySQL에서 JSON 컬럼 써도 되나요? (0) | 2026.06.21 |
| [컬럼형 DB 파헤치기] 2편 - MySQL vs ClickHouse 성능 측정 (0) | 2026.05.31 |
| [컬럼형 DB 파헤치기] 1편 - Row DB와 뭐가 다른가 (1) | 2026.05.24 |