개발 공부/DB

[MySQL] DDL과 메타데이터 락 1 (원리편)

gmelon 2026. 8. 2. 22:41

계기: 새 테이블을 만들었는데 기존 테이블이 멈췄다

배포에서 새 테이블을 하나 추가했는데, 그 테이블과 무관해 보이는 기존 테이블의 조회가 한동안 함께 멈추는 일을 겪었다. SHOW PROCESSLIST에는 Waiting for table metadata lock이라는 상태가 가득했다.

새 테이블을 만들었을 뿐인데 왜 기존 테이블이 멈출까. 이 의문을 따라가다 보니 결국 MySQL이 DDL과 락을 다루는 방식 전체를 공부하게 됐고, 이 시리즈는 그 정리다.

다만 이 장면을 바로 설명하기는 어려워서, 가장 단순한 DDL인 컬럼 추가부터 살펴보려고 한다.

가장 단순한 재현: 3건짜리 테이블의 SELECT가 24초 걸렸다

row가 3건뿐인 테이블에 컬럼 하나를 추가해본다. 어떤 방식으로 처리되든 순식간에 끝나야 정상일 것이다.

세 개의 세션으로 재현한 상황은 다음과 같다.

세션 A |  BEGIN; SELECT * FROM users;          -- 트랜잭션을 연 채 커밋하지 않고 방치
세션 B |  ALTER TABLE users ADD COLUMN nickname VARCHAR(20);
세션 C |  SELECT * FROM users LIMIT 1;         -- 평범한 조회

결과는 예상과 달랐다. B의 ALTER는 37.3초, C의 SELECT는 24.2초가 걸렸다. 그리고 A를 강제 종료하자 B와 C가 같은 밀리초에 동시에 풀렸다.

이때 performance_schema.metadata_locks를 조회한 스냅샷이다.

object_type  object_name  lock_type            lock_status  session
TABLE        users        SHARED_READ          GRANTED      A      ← A가 쥔 락 (커밋까지 유지)
TABLE        users        SHARED_UPGRADABLE    GRANTED      B
TABLE        users        EXCLUSIVE            PENDING      B      ← B가 기다리는 락
TABLE        users        SHARED_READ          PENDING      C      ← C도 대기
GLOBAL       (NULL)       INTENTION_EXCLUSIVE  GRANTED      B
SCHEMA       lab          INTENTION_EXCLUSIVE  GRANTED      B
BACKUP LOCK  (NULL)       INTENTION_EXCLUSIVE  GRANTED      B
TABLE        #sql-1_c     EXCLUSIVE            GRANTED      B      ← 존재하지 않는 이름에도 락이 걸린다

SHOW PROCESSLIST의 상태 컬럼에는 B와 C 모두 Waiting for table metadata lock이 찍혀 있었다.

이 글은 이 한 장면을 완전히 설명하는 것이 목표다. 계기가 된 CREATE TABLE 사례도 다음 편에서 같은 원리 위에서 풀린다.

이 글에서 다루는 질문

  • 컬럼 추가 시 정확히 어떤 일이 일어나고, 왜 메타데이터 락이 필요한가
  • 락은 언제 획득되고 언제까지 유지되는가, 대기 큐는 왜 SELECT까지 막는가
  • ALGORITHM, LOCK 옵션은 무엇을 제어할 수 있고 무엇은 제어하지 못하는가

메타데이터 락이 어떤 테이블까지 번지는지(FK, CREATE TABLE), 그리고 실제 배포 장애로 이어지는 과정은 다음 편에서 다룬다.

컬럼 추가의 물리적 의미

테이블 정의가 저장되는 곳

컬럼을 추가한다는 것은 우선 테이블 정의(컬럼 목록, 타입, 인덱스 등)를 바꾸는 일이다. 이 정의가 어디에 저장되는지가 출발점이다.

5.7까지는 이중 관리 구조였다.

  • 서버 계층: 테이블마다 .frm이라는 바이너리 파일에 정의를 저장 (CREATE TABLE의 스냅샷, 전용 파서 없이는 해석 불가)
  • InnoDB: 시스템 테이블스페이스(ibdata1) 안에 자체 딕셔너리 테이블(SYS_TABLES, SYS_COLUMNS, SYS_INDEXES 등)을 별도 유지

같은 정의를 두 곳이 각자 관리했기 때문에 DDL 도중 서버가 죽으면 둘이 어긋난 채 남을 수 있었다. 엔진에는 테이블이 있는데 .frm이 없는 orphan 테이블이 대표적인 사고 유형이다. 즉 DDL이 원자적이지 않았다.

8.0에서 이 구조가 통합됐다.

  • .frm 폐지. 정의는 transactional data dictionary라는 InnoDB 테이블들(mysql.tables, mysql.columns 등)에 저장된다. 이 테이블들은 datadir의 mysql.ibd 테이블스페이스에 있고, 직접 SELECT는 막혀 있으며 information_schema 뷰를 통해 조회한다
  • 정의가 일반 데이터와 같은 레코드/redo/undo 인프라로 관리되므로, DDL이 트랜잭션으로 원자적이 됐다 (atomic DDL)
  • .ibd 파일에는 SDI(Serialized Dictionary Information)라는 JSON 직렬화 사본이 내장된다. 딕셔너리가 유실돼도 데이터 파일만으로 정의를 복원하기 위한 중복본이다
  • 딕셔너리 테이블에는 se_private_data라는 키=값 필드가 있어 엔진 전용 정보가 들어간다. 뒤에서 볼 INSTANT의 기본값이 저장되는 곳이다

그래서 8.0에서 컬럼 추가는 우선 딕셔너리 테이블에 "이 테이블에 이런 컬럼이 있다"는 정보 한 건을 추가하는 일이고, 이 변경 역시 일반 데이터처럼 하나의 트랜잭션으로 커밋된다.

레코드에는 컬럼 이름이 없다

그런데 딕셔너리의 정의만 바꿔서는 이미 저장된 데이터를 올바르게 읽을 수 없다. 그 이유는 row가 디스크에 저장되는 방식에 있다.

InnoDB의 row는 PK 순으로 정렬된 16KB 페이지 안에 레코드로 저장된다. 레코드의 구조는 다음과 같다.

┌───────────────────────── 레코드 (COMPACT/DYNAMIC) ─────────────────────────┐
│ 가변길이 목록 │ NULL 비트맵 │ 고정 헤더(5B) │ 값₁ │ 값₂ │ 값₃ │ ...        │
└──────────────────────────────────────────────────────────────────────────┘
                                              └─ 테이블 정의에 적힌 컬럼
                                                 순서 그대로 이어붙는다

핵심은 값마다 "이건 name 컬럼의 값"이라는 태그가 없다는 점이다. 몇 번째 값이 어느 컬럼인지는 오직 테이블 정의를 통해서만 해석된다. 정의가 곧 해석 스키마이고, 레코드는 정의 없이는 해석 불가능한 바이트 나열이다.

태그를 함께 저장하는 설계(MongoDB의 BSON이 이 방식이다)도 가능하지만, 모든 row에 스키마 정보가 중복 저장되어 페이지당 레코드 수가 줄고 I/O가 늘어난다. 읽을 때마다 태그를 탐색하는 CPU 비용도 있다. RDB는 "스키마는 고정"이라는 전제를 공간과 속도로 교환한 설계이고, 그 대가가 스키마 변경의 어려움이다.

과거의 방식: 테이블 재구축

정의만 바꾸면 기존 레코드의 해석이 어긋난다. 3개 컬럼 기준으로 해석해야 할 레코드를 4개 컬럼 정의로 읽을 수는 없다.

전통적인 해법은 단순했다. 모든 레코드를 새 정의에 맞게 처음부터 다시 쓰는 것, 즉 rebuild다. 새 구조를 만들고 전체 row를 재삽입한 뒤 원본과 교체한다. 수백만 row 테이블에서 ADD COLUMN이 무거웠던 근본 원인이다.

rebuild가 필요한 변경은 지금도 많다. 컬럼 타입 변경, 문자셋 변환, PK 변경, OPTIMIZE TABLE 등이다.

INSTANT의 방식: row version과 읽기 시점 합성

8.0.12에서 도입되고 8.0.29에서 개선된 ALGORITHM=INSTANT는 발상을 바꿨다. 레코드를 다시 쓰는 대신, 레코드가 "어느 시점의 정의로 쓰였는지"를 구분할 수단을 만들었다.

8.0.29 기준의 동작(row version 방식)은 다음과 같다.

  • INSTANT ADD/DROP COLUMN을 수행하는 ALTER 문장 1회마다 테이블의 row version이 1씩 올라간다
  • 레코드는 삽입될 때 당시의 버전 번호를 레코드 헤더에 기록한다 (헤더의 미사용 비트 1개를 "버전 있음" 표시로 사용)
  • 딕셔너리는 컬럼마다 어느 버전에서 추가/삭제됐는지를 기록한다
  • 읽을 때 레코드의 버전을 보고 최신 정의로 변환한다. 그 후 추가된 컬럼은 딕셔너리에 저장된 기본값으로 합성하고, 삭제된 컬럼은 물리적으로 남아 있지만 건너뛴다

즉 기존 레코드는 한 바이트도 건드리지 않고, 부족한 부분을 읽는 순간에 메타데이터로 채운다.

실측: .ibd 파일의 실제 바이트

말로만 하면 이해가 어려우니 데이터 파일을 직접 열어봤다. (id, a, b) 테이블에 row 1건을 넣고, ADD COLUMN c INT DEFAULT 99 AFTER id를 INSTANT로 실행한 뒤, row 1건을 더 넣었다.

.ibd 파일에서 값 패턴을 검색한 결과다 (INT는 부호 비트를 뒤집은 4바이트로 저장된다. 10 = 80 00 00 0a).

옛 레코드 (ALTER 전 삽입):  ... 80 00 00 0a  80 00 00 14 │ (다음 레코드)
                                └ a=10       └ b=20        ← c의 흔적이 없다. 3필드 그대로

새 레코드 (ALTER 후 삽입):  ... 80 00 00 0b  80 00 00 15  80 00 00 4d
                                └ a=11       └ b=21       └ c=77
                                                            ← c는 물리적으로 맨 끝

기본값 99 (80 00 00 63):    데이터 파일 전체에서 검색 결과 0건

세 가지가 바이트로 확인된다.

  • 옛 레코드는 정말 한 바이트도 변하지 않았다
  • 논리 순서상 2번째(AFTER id)인 c가 물리적으로는 레코드 맨 끝에 있다. 물리 순서와 논리 순서가 분리되어 있고, 논리 순서는 딕셔너리만 안다
  • 기본값 99는 데이터 파일 어디에도 없다. 딕셔너리(information_schema.INNODB_COLUMNSDEFAULT_VALUE)에만 존재하고, 읽을 때 합성된다

메타데이터 락(MDL)의 정체

여기까지 보면 의문이 생긴다. 레코드는 정의를 기준으로 해석되므로, 어떤 세션이 테이블을 읽거나 쓰는 도중에 다른 세션이 그 정의를 바꿔버리면 안 된다. 이 충돌을 막아주는 장치는 무엇인가.

row lock으로 대체할 수 없는 이유

8.0에서 정의는 결국 mysql.ibd 안의 InnoDB 레코드다. 그렇다면 정의 레코드에 X 락(row lock)을 걸면 되지 않을까. 안 되는 이유가 두 가지 있다.

  • 일반 문장은 정의 레코드에 락을 잡을 일이 없다: 정의는 첫 접근 때 한 번 읽혀 메모리 캐시(TABLE_SHARE)에 올라가고 이후 문장들은 캐시만 쓴다. DML이 잡는 row lock도 자기 테이블의 레코드에 붙는 것이라 정의 레코드와는 접점이 없다. 결국 정의 레코드를 잠가서 막을 수 있는 상대는 다른 DDL뿐이고, 정작 조율이 필요한 일반 트래픽과는 만날 일이 없다
  • MVCC 때문에 일반 SELECT는 락을 잡지 않는다: InnoDB의 일반 읽기는 스냅샷 읽기라서 정의 레코드에 X 락이 걸려 있어도 그냥 지나간다. row lock의 충돌은 잠금 읽기(FOR UPDATE, FOR SHARE)와 쓰기 사이에서만 발생한다

그래서 MySQL은 "테이블이라는 객체의 사용권" 수준에서 잡는 서버 계층의 별도 락을 둔다. 이것이 메타데이터 락(MDL, metadata lock) 이고, 5.5.3에서 도입됐다. 도입 당시에는 정의가 .frm 파일이라 잠글 레코드조차 없었고, row lock이 없는 MyISAM 같은 엔진도 보호해야 했다는 역사적 배경도 있다.

정리하면 하나의 문장은 두 계층의 락을 함께 잡는다.

UPDATE users SET age = 28 WHERE id = 1;

서버 계층 │ users 테이블에 MDL SHARED_WRITE     "내가 쓰는 동안 구조를 바꾸지 마라"
엔진 계층 │ id=1 레코드에 row X 락              "이 레코드는 내가 수정 중이다"

일반 SELECT는 MDL SHARED_READ만 잡고 row lock은 잡지 않는다. DDL은 반대로 MDL이 중심이고 row lock은 관심 밖이다.

MDL의 종류

본격적으로 들어가기 전에 이 글에 등장하는 MDL의 종류를 정리한다. 알고 있는 S/X 락 개념과 같은 원리이고, 종류가 더 세분화됐을 뿐이다.

lock_type 의미 다른 세션에 미치는 영향
SHARED_READ 테이블을 읽는 중 없음 (읽기/쓰기 공존)
SHARED_WRITE 테이블에 DML 중 없음 (읽기/쓰기 공존)
SHARED_UPGRADABLE DDL 진행 중. 나중에 EXCLUSIVE로 승격 가능 없음. 단 다른 DDL은 대기 (테이블당 DDL 1개)
SHARED_NO_WRITE DDL 진행 중 (COPY 계열) 쓰기 차단, 읽기 허용
EXCLUSIVE 딕셔너리의 정의를 실제로 교체하고 캐시를 무효화하는 순간 (DDL의 prepare/commit 단계) 읽기/쓰기 전부 차단
INTENTION_EXCLUSIVE 상위 범위(GLOBAL/SCHEMA)에 "하위에서 배타 작업 예정" 신고 일반 쿼리와 무관 (InnoDB 엔진 계층의 테이블 IX와는 별개의 서버 계층 락)

SHARED_READSHARED_WRITE는 서로에게 아무 영향이 없는데 왜 나눠놨을까. 뒤에 나올 쓰기 차단형 락(SHARED_NO_WRITE 등)이 "읽기는 허용하고 쓰기만 차단"을 구현하려면, 각 세션이 지금 읽는 중인지 쓰는 중인지가 락 타입으로 구분되어 있어야 하기 때문이다. 즉 서로를 위해서가 아니라 더 강한 락과의 충돌 판정을 위해 나뉜 타입이다.

계층 구조도 함께 봐야 한다. MDL의 대상은 테이블만이 아니다.

GLOBAL (서버 전체)
  └─ SCHEMA (데이터베이스)
       └─ TABLE (개별 테이블, 존재하지 않는 이름 포함)

InnoDB 엔진 계층의 의도 락(IX)은 row X 락을 잡기 전에 테이블에 걸어두는 것으로, 테이블 전체를 잠그려는 작업이 row 락을 전수조사하지 않고도 충돌을 판정하게 해주는 용도다. MDL의 INTENTION_EXCLUSIVE도 용도는 같은 의도 락인데, 걸리는 계층이 다르다. 테이블이 아니라 그 상위인 GLOBAL/SCHEMA에 걸리고, 서버 전체를 잠그려는 작업(FLUSH TABLES WITH READ LOCK 같은 백업용 전역 잠금)과의 충돌 판정에 쓰인다.

동시에 1개만 허용되는 SHARED_UPGRADABLE이 별도 타입으로 존재하는 이유도 짚어둘 만하다. 일반 SHARED를 잡았다가 EXCLUSIVE로 승격하는 설계라면, 보유자 둘이 동시에 승격을 시도할 때 서로가 상대의 해제를 기다리는 데드락이 된다. 승격 가능한 락을 서로 배타로 만들면 승격 후보가 항상 1명이라 이 데드락이 구조적으로 사라진다.

획득과 해제: 테이블별 첫 접근부터 트랜잭션 커밋까지

MDL의 수명이 이 주제 전체에서 가장 중요한 규칙이다.

  • 획득: 트랜잭션이 시작될 때가 아니라, 그 테이블에 처음 접근하는 순간
  • 해제: 문장이 끝날 때가 아니라, 트랜잭션이 커밋/롤백될 때

도입 실험에서 세션 A가 SELECT 한 번 하고 방치했는데도 SHARED_READ를 계속 쥐고 있던 이유다 (스냅샷의 lock_duration 컬럼에 TRANSACTION으로 표시된다). 이 규칙은 격리 수준과 무관하다. READ COMMITTED에서도 동일하다.

도입 실험 스냅샷의 해당 줄을 다시 보면 이렇게 나타나 있었다.

TABLE  users  SHARED_READ  GRANTED  A     ← lock_duration: TRANSACTION (커밋까지 유지)

왜 문장이 아니라 트랜잭션 끝까지 유지해야 할까. 이유가 두 가지다.

유지 이유 1: binlog 직렬화

MySQL 복제는 원본이 남긴 binlog를 리플리카가 순서대로 재생하는 구조다. binlog의 중요한 성질은 트랜잭션이 커밋되는 순간에 한 덩어리로 기록된다는 것이다. 문장 실행 순서가 아니라 커밋 순서로 줄을 선다.

다음은 MDL이 커밋까지 잡아주지 않는다고 가정한 사고실험이다.

원본에서 실제 일어난 순서                binlog에 기록되는 순서
──────────────────────────           ──────────────────────
10:00  T: UPDATE users ...            10:02  ALTER
       (옛 스키마 위에서 실행)          10:05  T 덩어리 (UPDATE 포함)
10:02  다른 세션: ALTER 실행/완료
10:05  T: COMMIT

리플리카는 오른쪽 순서로 재생하므로 ALTER를 먼저 실행하고 T의 UPDATE를 새 스키마 위에서 실행한다. 원본과 실행 환경이 달라지고, 지워진 컬럼을 참조했다면 복제가 에러로 멈춘다. 이를 막으려면 DDL은 진행 중인 트랜잭션의 문장 사이에 끼어들 수 없어야 하고, 그것을 강제하는 장치가 트랜잭션 수명의 MDL이다.

유지 이유 2: 스냅샷과 rebuild의 비양립

먼저 스냅샷(read view)의 정확한 정의를 정리할 필요가 있다. 스냅샷은 데이터의 복사본이 아니다.

  • read view: "이 순간까지 커밋된 것만 보겠다"는 기준점. 생성 순간의 미커밋 트랜잭션 목록을 기억해두는 작은 메타데이터로, 세션 메모리에 있다
  • undo log: 과거 버전을 복원할 재료. row의 변경 이력이며 undo 테이블스페이스에 있다
  • MVCC 읽기는 이 둘의 협업이다. 현재 레코드를 읽고, 이 버전의 작성자가 기준점상 보이면 안 되는 트랜잭션이면, 레코드에 붙은 roll pointer를 따라 undo에서 이전 버전을 복원한다
  • REPEATABLE READ는 트랜잭션의 첫 읽기 때 read view를 한 번 만들어 커밋까지 재사용하고, READ COMMITTED는 문장마다 새로 만든다. 격리 수준이란 결국 read view의 수명 정책이다

문제는 undo가 복원하는 대상이 "row의 값"이라는 점이다. rebuild는 테이블 파일 자체를 새로 만들기 때문에, 옛 테이블의 레코드를 가리키던 undo 체인을 새 테이블에 적용할 방법이 없다. undo는 지워지지 않지만 쓸 수 없게 된다.

MySQL의 해법은 이 상황을 복구하는 것이 아니라 금지하는 것이다. 트랜잭션이 이미 사용한 테이블은 MDL로 DDL을 막는다.

보호가 없는 경우: ER_TABLE_DEF_CHANGED (1412)

MDL은 테이블별로 첫 접근 시점부터 발생하므로, 아직 접근하지 않은 테이블은 보호가 없다. 그 틈에 DDL이 지나가면 어떻게 될까. 직접 실험해봤다.

(더 일반적인 상황으로 바꾼 예시)

-- 세션 1 (REPEATABLE READ)
BEGIN;
SELECT * FROM orders LIMIT 1;   -- 다른 테이블을 먼저 읽어 read view 생성. users는 아직 접근 전
-- 이 사이에 세션 2가 users에 ALTER 실행/커밋 (users의 MDL 보유자가 없으므로 즉시 성공)
SELECT * FROM users;            -- users 첫 접근
→ ERROR 1412 (HY000): Table definition has changed, please retry transaction

여러 변형으로 확인한 결과는 다음과 같다. 위쪽은 끼어드는 DDL의 종류를 바꾼 것이고, 아래쪽은 DDL을 고정하고 첫 접근 방식과 격리 수준을 바꾼 것이다.

끼어든 DDL의 종류 (첫 접근은 일반 SELECT, REPEATABLE READ)
────────────────────────────────────────────────────
ALTER ... FORCE (INPLACE rebuild)      ERROR 1412
ALTER ... ALGORITHM=COPY               ERROR 1412
ALTER ... ALGORITHM=INSTANT            ERROR 1412   ← rebuild가 없어도 에러

첫 접근 방식/격리 수준 변형 (DDL은 INSTANT로 고정)
────────────────────────────────────────────────────
SELECT ... FOR UPDATE (잠금 읽기)       역시 ERROR 1412
READ COMMITTED에서 동일 시나리오        일반/잠금 읽기 모두 정상 실행

두 가지가 눈에 띈다.

  • 판정 기준은 rebuild 여부가 아니라 "유지 중인 read view가 테이블의 정의 변경보다 과거인가" 다. INSTANT처럼 데이터 파일을 건드리지 않는 변경도 예외가 아니다
  • REPEATABLE READ에서는 잠금 읽기까지 거부된다. 하나의 트랜잭션이 같은 테이블을 "옛 기준점의 화면"과 "새 정의의 화면"으로 섞어 보는 것을 허용하지 않겠다는 보수적인 선택으로 보인다. READ COMMITTED는 문장마다 새 read view를 만드니 기준점이 정의 변경보다 과거일 수 없어 문제가 없다

정리하면 MDL과 1412는 한 쌍의 방어선이다. 이미 접근한 테이블은 MDL이 DDL을 막고, 접근 전인 테이블은 막는 대신 첫 접근에서 실패시킨다.

대기 큐의 규칙

도입 실험 분석

여기까지 살펴봤으면 도입 실험을 완전히 읽을 수 있다.

시각    사건                                   락 상태
─────  ────────────────────────────────      ─────────────────────────────
t0     A: BEGIN; SELECT * FROM users;         A: SHARED_READ 획득 (커밋까지 유지)
t1     B: ALTER TABLE users ADD COLUMN ...    B: SHARED_UPGRADABLE 획득
                                              B: EXCLUSIVE 요청 (정의 교체용) → A와 충돌 → PENDING
t2     C: SELECT * FROM users LIMIT 1;        C: SHARED_READ 요청 → PENDING (!)
...
t3     A 종료 (락 해제)                        B EXCLUSIVE 획득 → 즉시 완료
                                              C 실행 → 즉시 완료 (B와 같은 밀리초)

t1에서 B가 EXCLUSIVE를 요청하는 이유는 이렇다. 컬럼 추가의 마지막 단계는 딕셔너리의 정의를 새것으로 교체하고 캐시된 정의를 무효화하는 일인데(락 타입 표에서 본 EXCLUSIVE의 용도이고, 뒤의 "온라인 DDL의 구조"에서 볼 commit 단계에 해당한다), 이 교체 순간에 옛 정의로 테이블을 사용 중인 세션이 남아 있으면 안 된다. 그래서 모든 사용을 차단하는 EXCLUSIVE가 필요하고, SHARED_READ를 커밋까지 쥐고 있는 A가 바로 그 "옛 정의의 사용자"라서 요청이 PENDING으로 걸렸다.

의문은 t2다. C의 SHARED_READ는 A의 SHARED_READ와 완전히 호환인데 왜 막혔을까.

락 요청이 즉시 허가되려면 두 조건을 모두 만족해야 하기 때문이다.

  • 이미 허가된(GRANTED) 락들과 호환될 것
  • 먼저 대기 중(PENDING)인 요청 가운데, 뒤에 오는 요청의 진입을 막는 강한 타입(EXCLUSIVE 등)이 없을 것

C는 첫 번째 조건은 만족했지만 두 번째 조건에서 B의 대기 중인 EXCLUSIVE에 걸렸다. 강한 락의 대기 요청에 이런 진입 차단 권한을 주지 않으면, 읽기가 끊이지 않는 서비스에서 EXCLUSIVE는 영원히 차례가 오지 않기 때문이다 (기아, starvation). 쓰기 락의 기아를 막는 대신, ALTER 하나가 대기하는 동안 그 테이블의 모든 신규 쿼리가 함께 멈추는 결과가 생긴다.

이것이 "ALTER 하나가 서비스 전체 SELECT를 세운다"는 현상의 전체 메커니즘이다.

  • 도입 실험의 ALTER는 별도 지정이 없어 자동으로 INSTANT 방식으로 처리됐다. 즉 실제 작업은 밀리초짜리였다. INSTANT는 실행할 작업을 없애줄 뿐, EXCLUSIVE가 필요하다는 사실 자체는 바꾸지 못한다
  • EXCLUSIVE 획득을 막는 것은 커밋하지 않은 트랜잭션 하나면 충분하다
  • 대기가 시작되는 순간부터 신규 트래픽이 전부 그 뒤에 쌓인다
  • 기본 lock_wait_timeout은 31536000초, 즉 1년이다. 아무도 개입하지 않으면 이 상태가 해소되지 않는다

획득 전과 획득 후

락의 파급을 정리할 때는 두 구간을 구분하는 것이 정확하다.

  • 획득 전 (PENDING): 파급은 락 타입의 대기 규칙이 결정한다. 대기 중인 EXCLUSIVE는 늦게 도착한 요청의 진입을 (호환 여부와 무관하게) 막으므로 신규 트래픽 전체가 정지한다. 반면 이 권한이 없는 공유 계열 락(다음 편에서 다룰 SHARED_READ_ONLY 등)은 대기 중에 늦게 온 요청이 먼저 처리되는 것을 막지 못한다
  • 획득 후 (GRANTED): 파급은 호환표가 결정한다. SHARED_UPGRADABLE은 아무도 막지 않고, SHARED_NO_WRITE는 쓰기만 막고, EXCLUSIVE는 전부 막는다

위험의 본질은 대부분 획득 후가 아니라 획득 전 대기에 있다. 보유는 밀리초지만, 대기는 무한정이다.

온라인 DDL의 구조

여기서 잠시 ALTER의 수행 방식(ALGORITHM)을 정리하고 가자. 방식은 세 가지다.

  • INSTANT: 딕셔너리의 정의만 바꾸고 끝낸다. 위에서 본 방식이다
  • INPLACE: InnoDB 엔진 내부에서 처리한다. 대부분 작업 중 동시 DML을 허용한다
  • COPY: 서버 계층이 임시 테이블을 만들어 전체를 복사한 뒤 교체한다

모든 변경이 INSTANT로 가능한 것은 아니다. INSTANT는 "정의만 바꾸고, 부족한 부분은 읽는 순간 해석으로 채우는" 방식이라, 인덱스 추가처럼 실물 자료구조(B+tree)를 실제로 만들어야 하는 변경이나, 컬럼 타입 변경처럼 레코드 바이트를 다시 써야 하는 변경(rebuild)은 표현할 수 없다. 이런 변경은 INPLACE나 COPY로 수행되고 시간이 걸린다. 그 시간 동안 서비스가 살아 있게 하는 것이 온라인 DDL이다. 여기서 "온라인"은 작업 중에도 테이블이 계속 읽기/쓰기 가능하다는 뜻이다.

용어를 정확히 하면, 온라인 DDL은 특정 알고리즘의 이름이 아니라 InnoDB가 DDL을 이런 방식으로 처리하는 기능 전반의 이름이다. INSTANT와 INPLACE가 여기에 속하고, 쓰기를 차단하는 COPY는 아니다. 이 중 오래 걸리는 작업을 온라인으로 처리하는 경로가 INPLACE라서, 아래 단계 설명은 INPLACE 기준이다.

3단계 타임라인

INPLACE 경로는 세 단계로 진행되고, 단계마다 MDL 강도가 다르다.

단계       MDL                   하는 일
────────  ───────────────────   ─────────────────────────────────────────
prepare   짧은 EXCLUSIVE         새 구조(임시) 준비, row log 생성
execute   SHARED_UPGRADABLE      복사/인덱스 빌드 (몇 분이든)
          (다운그레이드)          + 동시 DML은 원본에 그대로 + row log 축적/수시 적용
commit    짧은 EXCLUSIVE         잔여 row log 적용, 딕셔너리에서 정의 교체

표에서 MDL 강도가 바뀌는 것은 락을 풀었다가 새로 잡는 것이 아니다. 처음 획득한 락을 쥔 채로 수준만 승격/강등하는 것이라, 중간에 다른 DDL이 끼어들 틈은 생기지 않는다. 대신 EXCLUSIVE로 승격하는 순간마다 획득 대기가 발생할 수 있다.

INSTANT는 execute가 사실상 없어서 prepare/commit만 남은 형태다. "즉시"인 이유와 "그래도 EXCLUSIVE는 필요한" 이유가 이 그림 하나로 설명된다.

row log: 동시 쓰기를 반영하는 장치

execute 단계에서 rebuild는 모든 row를 새 구조로 복사한다. 그 도중에 들어온 INSERT/UPDATE/DELETE는 어떻게 결과에 반영될까.

InnoDB의 선택은 변경 로그다.

  • 동시 DML은 원본 테이블에 평소와 완전히 동일하게 반영된다 (DML 경로에 간섭 최소화)
  • 그 변경 내역이 row log라는 버퍼에도 순서대로 축적된다
  • 복사가 끝나면 로그를 새 구조에 따라잡기(catch-up) 적용한다
  • 따라잡는 도중에도 새 변경이 계속 들어오므로, 마지막 잔여분은 commit 단계의 짧은 EXCLUSIVE 아래에서 적용한다. EXCLUSIVE가 새 쓰기를 잠시 막는 순간 로그는 유한해지고, 그것을 다 적용하면 완료다

즉 마지막 EXCLUSIVE는 정의 교체뿐 아니라 로그를 유한하게 만들기 위해서도 필요하다.

row log에는 상한이 있다 (innodb_online_alter_log_max_size, 기본 128MB). 쓰기가 몰리는 테이블에서 로그가 상한을 넘으면 몇 시간 돌던 ALTER가 그 시점에 실패한다. 온라인 DDL의 대표적인 실패 유형이다.

실측: INPLACE와 COPY의 동시성 차이

row log 같은 변경 반영 장치가 없는 COPY와의 차이를 실측으로 비교했다. 209만 row 테이블에 ADD COLUMN을 실행하면서 2초 뒤 UPDATE를 던진 결과다.

                INPLACE (6.9초 진행)          COPY (3.2초 진행)
─────────────  ──────────────────────       ─────────────────────────────
동시 UPDATE     1ms 만에 통과                 ALTER가 끝날 때까지 대기 후 완료
락 스냅샷       SHARED_UPGRADABLE GRANTED    SHARED_NO_WRITE GRANTED
                                             + UPDATE의 SHARED_WRITE PENDING

COPY가 동시 쓰기를 허용하지 못하는 이유는 구조에 있다. COPY는 서버 계층이 엔진 공용 인터페이스로 "읽어서 새 테이블에 삽입"을 반복하는 범용 경로라서, InnoDB 내부의 변경 포착 장치(row log)를 끼울 자리가 없다. 장치가 없으면 남는 정합성 수단은 락뿐이고, 그래서 SHARED_NO_WRITE로 쓰기를 차단한다.

부수적으로, 이 실험에서는 COPY(3.2초)가 INPLACE(6.9초)보다 빨랐다. 차이의 본질은 속도가 아니라 작업이 도는 동안 서비스가 사는가다.

ALGORITHM과 LOCK 옵션

두 옵션이 지정하는 것

ALTER TABLE에는 두 개의 절을 붙일 수 있고, 축이 서로 다르다.

  • ALGORITHM = 무슨 방식으로 작업하나: DEFAULT / INSTANT / INPLACE / COPY
  • LOCK = 작업하는 동안(execute 단계) 다른 세션에 무엇을 허용하나: DEFAULT / NONE(읽기+쓰기 허용) / SHARED(읽기만) / EXCLUSIVE(모두 차단)

LOCK 값의 실체는 지금까지 본 MDL이다. NONE이면 SHARED_UPGRADABLE 수준을 유지하고, SHAREDSHARED_NO_WRITE, EXCLUSIVE면 EXCLUSIVE다.

fail-fast: 지정을 만족하지 못하면 실행하지 않는다

두 옵션의 공통 성격은 최적화 힌트가 아니라 단언이라는 점이다. 지정한 조건을 만족할 수 없으면 타협해서 실행하는 것이 아니라 시작 전에 에러를 낸다. 실측한 거부 사례 세 가지다.

-- 1) 이 연산에 그 ALGORITHM이 불가능한 경우 (컬럼 순서 이동은 INSTANT 불가)
ALTER TABLE users MODIFY age TINYINT NOT NULL AFTER fz, ALGORITHM=INSTANT;
→ ERROR 1845: ALGORITHM=INSTANT is not supported for this operation. Try ALGORITHM=COPY/INPLACE.

-- 2) 그 ALGORITHM이 그 LOCK 수준을 지킬 수 없는 경우
ALTER TABLE users ADD COLUMN c INT, ALGORITHM=COPY, LOCK=NONE;
→ ERROR 1846: LOCK=NONE is not supported. Reason: COPY algorithm requires a lock. Try LOCK=SHARED.

-- 3) 조합 자체가 무의미한 경우 (INSTANT에는 제어할 execute 단계가 없다)
ALTER TABLE users ADD COLUMN c2 INT, ALGORITHM=INSTANT, LOCK=NONE;
→ ERROR 1221: Incorrect usage of ALGORITHM=INSTANT and LOCK=NONE/SHARED/EXCLUSIVE

아무것도 지정하지 않으면 MySQL이 가능한 것 중 최선을 자동 선택한다. 알고리즘은 INSTANT → INPLACE → COPY 순으로, 락은 가장 느슨한 수준으로.

이 fail-fast 성격이 실무에서는 안전장치가 된다. 배포 스크립트에 ALGORITHM=INSTANT, LOCK=NONE을 명시해두면 "서비스를 멈출 방식으로만 가능한 변경이라면 실행하지 말고 실패하라"는 보험이 된다.

옵션이 제어하지 못하는 것

LOCK=NONE을 붙여도 사라지지 않는 것이 있다.

  • prepare/commit 단계의 짧은 EXCLUSIVE. LOCK이 다스리는 것은 execute 단계의 동시성뿐이다
  • EXCLUSIVE 획득 전의 대기와, 대기 중 발생하는 신규 트래픽 정지. 도입 실험이 LOCK 옵션과 무관하게 재현되는 이유다

정리

  • 레코드는 테이블 정의 없이는 해석할 수 없는 바이트 나열이고, 그래서 정의의 안정성이 곧 데이터의 안정성이다
  • INSTANT는 레코드를 건드리지 않고 row version과 읽기 시점 합성으로 정의 변경을 흡수한다. 실제 바이트 확인 결과 옛 레코드는 불변이고, 기본값은 데이터 파일 밖(딕셔너리)에만 있다
  • MDL은 캐시와 MVCC 때문에 row lock으로 대체할 수 없는 "테이블 사용권" 락이며, 테이블별 첫 접근부터 트랜잭션 커밋까지 유지된다. 이유는 binlog의 트랜잭션 단위 직렬화, 그리고 스냅샷이 rebuild를 넘을 수 없다는 물리적 한계다
  • 대기 중인 EXCLUSIVE는 늦게 도착한 요청의 진입을 막는다. 그래서 커밋 안 한 트랜잭션 하나가 INSTANT ALTER를 세우고, 그 ALTER가 서비스의 SELECT 전체를 세운다
  • ALGORITHM/LOCK 옵션은 fail-fast 단언이며, execute 단계의 동시성까지만 제어한다. EXCLUSIVE 획득 전 대기는 어떤 옵션으로도 제거할 수 없다

다음 편에서는 이 원리가 DDL 종류별로 어떻게 다르게 나타나는지를 다룬다. 특히 FK가 얽히는 순간 락이 부모 테이블까지 번지는 과정과, 새 테이블 하나를 만들었을 뿐인데 기존 테이블의 조회가 멈추는 사례, 그리고 그것이 실제 배포 장애로 이어지는 공식을 실측으로 정리할 예정이다.