제가 재직하고 있는 아임웹은 노코드 웹빌더 솔루션을 제공하는 기업으로,
디자인모드라는 기능을 통해 사용자가 자신만의 웹 페이지를 직접 제작할 수 있습니다.
디자인모드는 아임웹의 근간이자, 지금의 아임웹이라는 이름을 만들어온 핵심 기능입니다.
그러나 그만큼 오래된 기능이기도 하고, 소위 말하는 레거시 코드와 구성으로 구현되어 있습니다.
최근 이 기능을 더욱 안정적으로 서비스하기 위해 모던 스택으로 전환하는 과정에서,
각 디자인 데이터를 버전별로 관리하고 제어할 수 있도록 아래와 같은 DDL 작업이 필요했습니다.
▶ 신규 컬럼 추가
▶ 기존 유니크 제약 삭제
▶ 신규 유니크 제약 생성
문제는 디자인모드를 구성하는 수많은 테이블 중에는
수억 건의 데이터가 존재하는 테이블부터 수천만 건 규모의 테이블까지 다양하게 분포해 있다는 점이었습니다.
대규모 테이블의 스키마를 변경하는 일은,
수백만 명이 건너고 있는 다리의 교각을 교체하는 것과 같습니다.
다리를 닫을 수 없고, 사람들은 계속 건너야 하며, 공사는 어떻게든 완료해야 합니다.
이번 글에서는 이 고민의 출발점이 된 두 가지 선택지를 소개합니다.
▶ MySQL 8이 지원하는 Online DDL 알고리즘 (INSTANT / INPLACE / COPY) 과 각 알고리즘의 동작 방식
▶ pt-osc(pt-online-schema-change) 가 무엇인지, 그리고 어떤 원리로 동작하는지
동작 원리부터 실무 판단 기준까지, 하나의 글로 정리합니다. 🚀
MySQL은 테이블 구조를 변경하는 DDL 작업을 수행할 때, 해당 테이블에 MDL(Metadata Lock) 을 획득합니다.
MDL은 테이블의 메타데이터(스키마 정보)를 보호하기 위한 잠금으로,
DDL 작업 중 다른 트랜잭션이 테이블 구조를 동시에 변경하거나 읽는 것을 제어합니다.
MDL은 크게 두 가지 모드로 동작합니다.
▶ Shared MDL : 일반적인 DML(SELECT, INSERT, UPDATE, DELETE) 수행 시 획득
▶ Exclusive MDL : DDL 수행 시 획득, 다른 모든 접근을 차단
여기서 핵심은 MDL의 획득 순서와 대기 방식입니다.
아래 시나리오를 살펴보겠습니다.
시나리오: 대용량 테이블에 ALTER TABLE 실행
① 트랜잭션 A (기존 DML): SELECT * FROM big_table WHERE ... → Shared MDL 보유 중
② ALTER TABLE big_table ADD COLUMN ... → Exclusive MDL 대기 중
③ 트랜잭션 C (신규 DML): SELECT * FROM big_table WHERE ... → Shared MDL 대기 중 (②번 뒤에서 줄 서기)
④ 트랜잭션 D, E, F ... → 동일하게 대기 적체
① 트랜잭션 A가 끝나지 않으면 ALTER는 Exclusive MDL을 획득하지 못합니다.
② ALTER가 Exclusive MDL을 기다리는 동안, 이후 모든 DML 요청은 그 뒤에서 대기합니다.
③ 결과적으로 커넥션이 빠르게 고갈되어 서비스 장애로 이어집니다.
즉, ALTER TABLE 하나가 테이블 전체의 트래픽을 막는 댐이 되어버리는 것입니다.
대규모 트래픽 환경에서 이 문제가 발생하면 아래와 같은 연쇄 반응이 일어납니다.
▶ 쿼리 적체: 대기 중인 쿼리가 급격히 쌓임
▶ 커넥션 고갈: DB 커넥션 풀이 가득 차 신규 요청 처리 불가
▶ 애플리케이션 타임아웃: 응답 지연 → 사용자 오류 화면
위와 같은 문제를 해결하기 위해 두 가지 접근 방식이 존재합니다.
▶ MySQL Online DDL: MySQL 엔진 자체에서 잠금을 최소화하며 DDL을 수행하는 방식
▶ pt-osc: Percona에서 개발한 툴로, 잠금 없이 Shadow Table을 이용해 스키마를 변경하는 방식
두 방식 모두 "서비스를 멈추지 않고 스키마를 변경한다" 는 목표를 가지고 있지만,
그 내부 동작 방식과 각각의 한계점은 분명히 다릅니다.
지금부터 하나씩 살펴보겠습니다.
MySQL은 DDL 작업 시 어떻게 변경할 것인가(ALGORITHM) 와 어느 수준으로 잠글 것인가(LOCK) 를 옵션으로 제어할 수 있습니다.
ALTER TABLE big_table
ADD COLUMN version INT NOT NULL DEFAULT 0,
ALGORITHM = INPLACE,
LOCK = NONE;
MySQL 8.0에서 새롭게 도입된 알고리즘으로, 테이블 데이터를 건드리지 않고 메타데이터만 변경합니다.
▶ 실제 데이터 파일을 수정하지 않음
▶ 작업 시간이 데이터 양과 무관하게 거의 즉시 완료
▶ DDL 수행 중 DML 차단 없음
-- INSTANT 지원 예시
ALTER TABLE big_table ADD COLUMN memo TEXT, ALGORITHM = INSTANT;
⚠️ 단, 지원되는 작업이 제한적입니다.
컬럼 추가(8.0.29 이전은 마지막 위치만 가능), 기본값 변경 등 일부 작업만 해당됩니다.
테이블을 통째로 복사하지 않고 제자리에서 변경하는 방식입니다.
MySQL Online DDL의 핵심 알고리즘이며, 작업 중에도 DML이 허용됩니다.
▶ 데이터 파일을 직접 수정하여 작업 완료
▶ 작업 중 발생한 DML 변경사항은 온라인 로그(Online Log)에 임시 기록 후 반영
▶ DDL 시작/완료 시점에 짧은 Exclusive MDL 구간 존재
ALTER TABLE big_table
ADD INDEX idx_created_at (created_at),
ALGORITHM = INPLACE,
LOCK = NONE;
가장 오래된 방식으로, 임시 테이블을 생성 후 데이터를 전체 복사합니다.
▶ 원본 테이블의 모든 데이터를 새 임시 테이블에 복사
▶ 작업 중 DML 불가 (테이블 전체 잠금)
▶ 디스크 여유 공간이 원본 테이블 크기만큼 추가 필요
▶ INSTANT, INPLACE가 지원되지 않는 작업에 fallback으로 사용됨
⚠️ 수억 건 테이블에 COPY가 선택된다면 그 시간만큼 서비스가 멈출 수 있습니다.
| 옵션 | 설명 | DML 허용 여부 |
|---|---|---|
NONE | 잠금 없음, 읽기/쓰기 모두 허용 | ✅ 읽기 + 쓰기 |
SHARED | 읽기는 허용, 쓰기는 차단 | ⚠️ 읽기만 |
EXCLUSIVE | 모든 접근 차단 | ❌ 불가 |
DEFAULT | 알고리즘에 따라 자동 선택 | - |
▶ ALGORITHM과 LOCK은 조합이 가능한 모든 경우가 허용되지는 않습니다.
▶ 예: ALGORITHM=INSTANT는 LOCK=DEFAULT만 허용되며, 다른 값 지정 시 에러 발생
▶ 예: ALGORITHM=COPY는 LOCK=NONE 지정 불가 (에러 발생)
INPLACE 알고리즘은 내부적으로 3단계 페이즈로 동작합니다.
Phase 1. Prepare
▶ Exclusive MDL 획득 (짧은 순간)
▶ 내부 임시 구조 및 온라인 로그 공간 초기화
▶ Exclusive MDL 해제 → DML 재개
Phase 2. Execute
▶ 실제 DDL 작업 수행 (인덱스 재구성, 컬럼 추가 등)
▶ 이 구간 중 발생한 DML은 온라인 로그에 임시 기록
▶ DML 허용 상태 유지 (LOCK=NONE 기준)
Phase 3. Commit
▶ Exclusive MDL 재획득 (짧은 순간)
▶ 온라인 로그에 쌓인 DML 변경사항 반영
▶ 메타데이터 최종 업데이트 후 MDL 해제
핵심은 Phase 1과 Phase 3의 Exclusive MDL 구간이 매우 짧다는 점입니다.
하지만 동시성이 높은 환경에서는 이 짧은 순간도 문제가 될 수 있습니다. (2편에서 다룰 예정 🚨)
INSTANT가 지원되지 않는 케이스
▶ 컬럼을 중간 위치에 추가하는 경우 (8.0.29 이전)
▶ 컬럼 타입 변경
▶ PK 변경 또는 재정의
▶ 외래 키(FK) 추가/삭제 (일부 케이스)
INPLACE 중 발생하는 잠금 구간
▶ Prepare / Commit 페이즈에서 짧지만 Exclusive MDL이 존재
▶ 높은 동시성 환경에서는 이 구간이 MDL 대기 적체로 이어질 수 있음
▶ 온라인 로그 크기 초과 시(innodb_online_alter_log_max_size) 작업 실패
작업 중단 시 재시작 불가
▶ 작업이 중단되면 처음부터 다시 시작해야 함
▶ 수억 건 테이블에서 중단 발생 시 매우 치명적
pt-osc는 Percona에서 개발한 오픈소스 툴로,
MySQL의 Online DDL이 가진 한계를 보완하기 위해 만들어진 무중단 스키마 변경 도구입니다.
핵심 아이디어는 단순합니다.
"원본 테이블을 직접 건드리지 않고, Shadow Table(그림자 테이블)을 만들어 데이터를 옮긴 뒤 테이블을 교체한다"
Step 1. Shadow Table 생성
▶ 원본 테이블과 동일한 구조의 _테이블명_new 테이블 생성
▶ 이 시점에 변경하고자 하는 DDL도 함께 적용
-- 원본: big_table
-- 생성: _big_table_new (+ 변경된 스키마 적용)
Step 2. Trigger 3종 세트 생성
▶ 원본 테이블에 INSERT / UPDATE / DELETE Trigger를 각각 생성
▶ 데이터 복사가 진행되는 동안 원본에 발생하는 DML 변경사항을 _new 테이블에 실시간으로 반영하기 위함
-- 원본 테이블에 INSERT 발생 시 → _new 테이블에도 INSERT
-- 원본 테이블에 UPDATE 발생 시 → _new 테이블에도 UPDATE
-- 원본 테이블에 DELETE 발생 시 → _new 테이블에도 DELETE
Step 3. Chunk 단위 데이터 복사
▶ 원본 테이블의 데이터를 chunk 단위로 나누어 _new 테이블에 복사
▶ 한 번에 전체를 복사하지 않기 때문에 서비스 부하를 분산할 수 있음
▶ --chunk-size 옵션으로 한 번에 복사할 행 수 조절 가능
▶ --max-load, --critical-load 옵션으로 DB 부하 수준에 따라 작업 속도 자동 조절 또는 중단 가능
-- chunk 단위 복사 예시 (내부 동작)
INSERT INTO _big_table_new
SELECT *
FROM big_table
WHERE id BETWEEN 1 AND 1000; -- chunk-size: 1000
Step 4. 원자적 테이블 교체 (RENAME)
▶ 데이터 복사가 완료되면 단 하나의 쿼리로 원본과 신규 테이블을 교체
▶ RENAME TABLE은 원자적(Atomic)으로 동작하기 때문에 순간적인 잠금만 발생
RENAME TABLE big_table TO _big_table_old,
_big_table_new TO big_table;
Step 5. Trigger 및 구 테이블 정리
▶ 작업에 사용된 Trigger 3종 제거
▶ _big_table_old (구 원본 테이블) 삭제
▶ 작업 완료 🎉
| 파라미터 | 기본값 | 설명 |
|---|---|---|
--chunk-size | 1000 | 한 번에 복사할 행 수 |
--chunk-time | 0.45 | chunk 복사에 소요될 목표 시간 (초 단위) |
--max-load | - | 지정 임계값 초과 시 작업 일시 중단 (ex. Threads_running=25) |
--critical-load | - | 지정 임계값 초과 시 작업 즉시 강제 종료 |
--sleep | 0 | chunk 복사 후 대기 시간 (DB 부하 조절용, 초 단위) |
--pause-file | - | 해당 파일이 존재하면 작업 일시 중단 (수동 제어) |
Trigger 오버헤드
▶ 데이터 복사 중 원본 테이블에 발생하는 모든 DML이 Trigger를 통해 이중으로 처리됨
▶ 트래픽이 높은 환경에서는 Trigger 자체가 부하 요인이 될 수 있음
FK(Foreign Key) 제약 처리 이슈
▶ FK가 걸린 테이블은 별도의 옵션(--alter-foreign-keys-method) 처리가 필요
▶ 잘못 설정 시 FK 정합성 문제 발생 가능
Trigger를 지원하지 않는 환경
▶ 이미 해당 테이블에 동일한 이벤트의 Trigger가 존재하면 pt-osc 사용 불가
▶ MySQL은 동일 이벤트에 대해 여러 Trigger를 지원하지 않기 때문
작업 중단 시 수동 정리 필요
▶ 작업이 비정상 종료되면 _new 테이블, Trigger가 남아있을 수 있음
▶ 재시작 전 수동으로 잔여물 정리 필요
두 방식을 한눈에 비교하면 아래와 같습니다.
| 항목 | MySQL 8 Online DDL | pt-osc |
|---|---|---|
| 잠금 방식 | Prepare / Commit 구간 Exclusive MDL | RENAME 시 순간 잠금 |
| 작업 중 DML | INPLACE 기준 허용 | Trigger로 실시간 반영 |
| 작업 진행 방식 | 엔진 내부에서 직접 처리 | Shadow Table 복사 후 교체 |
| 부하 제어 | 불가 (엔진이 자체 처리) | --max-load, --sleep 등으로 세밀하게 제어 가능 |
| 작업 중단 / 재시작 | 불가 (처음부터 재시작) | --pause-file로 일시 중단 가능 |
| 진행률 모니터링 | 제한적 | chunk 단위 로그로 진행 상황 확인 가능 |
| FK 처리 | 네이티브 지원 | --alter-foreign-keys-method 별도 설정 필요 |
| Trigger 존재 시 | 영향 없음 | 동일 이벤트 Trigger 존재 시 사용 불가 |
| 디스크 추가 사용 | INPLACE 기준 최소 | Shadow Table 생성으로 원본 크기만큼 추가 필요 |
| Aurora 호환성 | 완전 지원 | Trigger 기반 제약 있음 |
핵심 요약
▶ Online DDL — MySQL이 직접 처리하므로 간편하지만, 높은 동시성 환경에서 MDL 대기 적체 위험 존재
▶ pt-osc — 부하 제어와 모니터링이 유연하지만, Trigger 오버헤드와 추가 디스크 공간이 필요
모든 상황에 맞는 정답은 없습니다. 아래 기준을 순서대로 따라가면 대부분의 케이스에서 올바른 선택을 할 수 있습니다.
Step 1. INSTANT DDL이 가능한가?
▶ 가능하다면 고민 없이 선택합니다.
▶ 메타데이터만 변경하므로 데이터 양과 무관하게 즉시 완료됩니다.
▶ 단, 지원 여부를 반드시 사전에 확인해야 합니다.
-- 사전 확인: INSTANT 지원 여부 체크
ALTER TABLE target_table
ADD COLUMN new_col INT NOT NULL DEFAULT 0,
ALGORITHM = INSTANT;
-- ERROR 발생 시 → INSTANT 미지원, 다음 단계로
MySQL 공식 문서(dev.mysql.com)를 기준으로, 아래 케이스에서는 INSTANT 알고리즘을 사용할 수 없습니다.
① ROW_FORMAT=COMPRESSED 테이블
▶ 압축 테이블은 INSTANT 알고리즘 적용 불가
▶ INPLACE 또는 COPY 알고리즘 사용 필요
② FULLTEXT 인덱스가 존재하는 테이블
▶ FULLTEXT 인덱스가 있는 테이블에는 INSTANT로 컬럼 추가 불가
③ 임시 테이블 (TEMPORARY TABLE)
▶ 임시 테이블은 ALGORITHM=COPY만 지원
④ 컬럼 타입 변경
▶ 컬럼의 데이터 타입을 변경하는 작업은 INSTANT 미지원
▶ INPLACE (일부) 또는 COPY 필요
⑤ PK(Primary Key) 추가 / 삭제 / 변경
▶ 클러스터드 인덱스 재구성이 수반되므로 INSTANT 미지원
▶ 반드시 INPLACE 또는 COPY 사용
⑥ FK(Foreign Key) 추가 / 삭제
▶ 외래 키 관련 작업은 INSTANT 미지원
▶ foreign_key_checks = OFF 상태에서 INPLACE 가능
⑦ 컬럼 중간 위치 삽입 (8.0.29 이전)
▶ MySQL 8.0.29 이전 버전에서는 INSTANT로 컬럼을 추가할 때 반드시 테이블의 맨 끝에만 추가 가능
▶ MySQL 8.0.29부터는 임의의 위치에 컬럼 추가 가능
-- 8.0.29 이전: 중간 위치 컬럼 추가 시 INSTANT 불가 → INPLACE 또는 COPY 필요
ALTER TABLE target_table
ADD COLUMN new_col INT NOT NULL DEFAULT 0 AFTER existing_col,
ALGORITHM = INSTANT;
-- ERROR: ALGORITHM=INSTANT is not supported (8.0.29 이전 버전)
-- 8.0.29 이후: 임의의 위치에도 INSTANT 가능
ALTER TABLE target_table
ADD COLUMN new_col INT NOT NULL DEFAULT 0 AFTER existing_col,
ALGORITHM = INSTANT;
-- OK
⑧ INSTANT 변경 횟수 64회 초과
▶ INSTANT로 ADD COLUMN / DROP COLUMN을 수행할 수 있는 횟수는 테이블당 최대 64회로 제한
▶ 64회 초과 시 아래 에러 발생, 테이블 rebuild(INPLACE 또는 COPY) 필요
ERROR 4092 (HY000): Maximum row versions reached for table test/t1.
No more columns can be added or dropped instantly.
Please use COPY/INPLACE.
▶ 현재 남은 횟수는 INFORMATION_SCHEMA로 확인 가능
SELECT NAME, TOTAL_ROW_VERSIONS
FROM information_schema.INNODB_TABLES
WHERE NAME = 'your_database/your_table';
⑨ 컬럼명 변경 (8.0.28 이전)
▶ 컬럼명 변경(RENAME COLUMN)은 MySQL 8.0.28부터 INSTANT 지원
▶ 8.0.28 이전 버전에서는 INPLACE 또는 COPY 필요
⑩ 행 크기(Row Size) 제한 초과 가능성
▶ INSTANT로 컬럼 추가 시 최대 행 크기를 초과하면 INSTANT 거부
ERROR 4092 (HY000): Column can't be added with ALGORITHM=INSTANT
as after this max possible row size crosses max permissible row size.
Step 2. 테이블 규모와 트래픽을 확인한다
| 테이블 규모 | 트래픽 | 권장 방식 |
|---|---|---|
| 소규모 (수백만 건 이하) | 낮음 | Online DDL (INPLACE) |
| 소규모 (수백만 건 이하) | 높음 | Online DDL + 트래픽 낮은 시간대 |
| 대규모 (수천만 건 이상) | 낮음 | Online DDL (INPLACE) 또는 pt-osc |
| 대규모 (수천만 건 이상) | 높음 | pt-osc 강력 권장 |
Step 3. FK 또는 기존 Trigger 존재 여부를 확인한다
▶ FK 존재 → Online DDL은 네이티브 지원, pt-osc는 --alter-foreign-keys-method 별도 설정 필요
▶ 동일 이벤트 Trigger 존재 → pt-osc 사용 불가, Online DDL만 선택 가능
Step 4. 작업 중단 가능성을 고려한다
▶ 작업 도중 중단 가능성이 있는 환경이라면 pt-osc 선택
▶ Online DDL은 중단 시 처음부터 재시작해야 하므로, 수억 건 테이블에서는 치명적
아임웹 환경(Aurora MySQL, 높은 QPS)에서는 아래 기준을 따르고 있습니다.
▶ INSTANT 가능 → 무조건 INSTANT
▶ 수천만 건 이상 + 피크 트래픽 테이블 → pt-osc
▶ 수천만 건 이상 + 트래픽 낮은 테이블 → INPLACE, 단 MDL 대기 모니터링 병행
▶ 수백만 건 이하 → INPLACE (트래픽이 몰리는 13~17시는 되도록 피함)
⚠️ Online DDL을 선택했더라도 작업 전 반드시
SHOW PROCESSLIST와 롱 트랜잭션 여부를 확인해야 합니다.
롱 트랜잭션이 존재하는 상태에서 DDL을 실행하면 MDL 대기가 발생하고, 이것이 서비스 장애로 이어질 수 있습니다.
서버에 직접 접속해 수동으로 확인하는 것은 번거롭고 놓치기 쉽습니다.
Dolphin(돌핀) 또는 innotop 과 같은 모니터링 솔루션을 활용하면 실시간으로 프로세스와 트랜잭션 상태를 한눈에 파악할 수 있어, 작업 자체에 더욱 집중할 수 있습니다.
이 이야기는 다음 편에서 자세히 다룹니다. 🚨
-- ① INSTANT: 컬럼 추가 (가장 빠름)
ALTER TABLE design_data
ADD COLUMN version INT NOT NULL DEFAULT 0,
ALGORITHM = INSTANT;
-- ② INPLACE: 인덱스 추가 (DML 허용)
ALTER TABLE design_data
ADD INDEX ix_version (version),
ALGORITHM = INPLACE,
LOCK = NONE;
-- ③ INPLACE: 유니크 제약 삭제 + 신규 유니크 제약 생성
ALTER TABLE design_data
DROP INDEX uk_old_key,
ADD UNIQUE INDEX uk_code_version (code, version),
ALGORITHM = INPLACE,
LOCK = NONE;
⚠️ ALGORITHM을 명시하지 않으면 MySQL이 자동으로 선택합니다.
운영 환경에서는 반드시 명시하여 예상치 못한 COPY 알고리즘 선택을 방지해야 합니다.
pt-online-schema-change \
--host=your-aurora-endpoint \
--port=3306 \
--user=dba_user \
--ask-pass \
--database=your_database \
--table=design_data \
--alter="ADD COLUMN version INT NOT NULL DEFAULT 0,
DROP INDEX uq_old_key,
ADD UNIQUE INDEX uk_code_version (code, version)" \
--chunk-size=1000 \
--chunk-time=0.45 \
--max-load="Threads_running=25" \
--critical-load="Threads_running=50" \
--pause-file=/tmp/pt-osc.pause \
--no-drop-old-table \
--print \
--execute
주요 옵션 설명
▶ --no-drop-old-table : 작업 완료 후 구 테이블을 바로 삭제하지 않고 보존 (롤백 대비)
▶ --print : 실행 중 상세 로그 출력
▶ --ask-pass : 비밀번호를 커맨드에 노출하지 않고 입력 프롬프트로 처리
💡
--no-drop-old-table옵션은 작업 완료 후_design_data_old형태로 구 테이블이 남습니다.
서비스 이상이 없음을 확인한 후 직접 삭제하는 것을 권장합니다.
대규모 테이블의 스키마를 변경하는 일은 단순히 ALTER TABLE 한 줄로 끝나는 작업이 아닙니다.
테이블의 크기, 트래픽 패턴, 작업의 종류, 환경의 특성까지 종합적으로 고려해야 하는 의사결정의 연속입니다.
이번 글에서 다룬 내용을 한 줄로 요약하면 아래와 같습니다.
▶ INSTANT가 되면 → 고민 없이 INSTANT
▶ INSTANT가 안 되면 → 테이블 규모와 트래픽을 보고 INPLACE와 pt-osc 중 선택
▶ 어떤 방식이든 → 작업 전 롱 트랜잭션 확인은 필수
이론은 언제나 깔끔합니다. 하지만 실제 운영 환경에서는 이론대로 흘러가지 않는 순간이 반드시 찾아옵니다.
다음 편에서는 Online DDL을 믿었다가 서비스 장애로 이어진 실제 경험담과, 그 수습 과정에서 pt-osc와 약 3일을 싸웠던 이야기를 솔직하게 기록할 예정입니다. 🚨