운영 심화: 안전한 스키마 변경
기존 행이 있는 테이블에 새 필수 열을 추가할 때 NULL 허용·백필·검증·강화 순서로 변경합니다.
이 문서는 INSERT·UPDATE·DELETE와 제약 오류를 먼저 익힌 뒤 읽는 운영 심화입니다.
CREATE TABLE은 빈 구조를 만들지만 운영 스키마는 이미 데이터와 쓰기 주체를 가집니다.
새 필수 열을 한 번에 추가하면 기존 행에 넣을 값이 없거나 DB가 의도하지 않은 암묵적 기본값을 채울 수 있습니다.
DDL의 최종 모양뿐 아니라 기존 데이터가 그 모양으로 이동하는 순서를 설계해야 합니다.
회원 게시판의 재시도 중복을 막는 request_key를 추가합니다.
기존 게시글에는 요청 키가 없으므로 NULL 허용으로 열을 열고 legacy-{id}를 백필한 뒤 NULL·중복이 0건임을 확인하고 NOT NULL·UNIQUE를 적용합니다.
작은 표에서는 과정을 세 단계로 읽으면 됩니다.
기존 한 행에 빈 request_key 열을 먼저 추가하고, 해당 행에 충돌하지 않는 과거 키를 채운 다음, 빈 값과 중복이 0건일 때만 필수 제약을 적용합니다.
백필(backfill)은 새 열의 과거 행에 업무 근거가 있는 값을 채우는 작업입니다.
확장·이관·강화는 이 세 단계를 운영 중인 여러 쓰기 버전까지 넓힌 표현입니다.
한 번에 끝낸 스키마 변경의 위험
값이 있는 posts에 호환용 NULL 허용 열을 추가한 직후 작업을 끝냅니다.
DDL은 성공하지만 모든 기존 행의 request_key가 NULL이고 새 쓰기 주체도 값을 보내지 않을 수 있습니다.
확장은 안전한 시작일 뿐, 데이터 이동과 최종 제약이 없으면 업무상 필수 열이 아닙니다.
ALTER TABLE posts
ADD COLUMN request_key VARCHAR(64) NULL AFTER version;
SELECT
COUNT(*) AS total_rows,
SUM(request_key IS NULL) AS null_rows
FROM posts;열은 정상 생성되지만 null_rows가 total_rows와 같아 같은 등록 요청의 재실행을 구별할 수 없습니다.
total_rows | null_rows
1 | 1스키마 변경은 새 열 선언, 기존 행 변환, 새 쓰기 주체 전환, 최종 제약의 네 문제입니다.
NULL 허용 확장만으로는 구버전 호환은 얻지만 새 규칙은 얻지 못합니다.
MySQL DDL은 트랜잭션 DML처럼 마음대로 롤백할 수 있다고 가정하지 않고 단계마다 사전 조건과 완료 증거를 둡니다.
확장·이관·축소 전략
확장 단계는 구버전 쓰기 주체도 계속 쓸 수 있는 NULL 허용 열을 추가합니다.
이관에서 기존 행을 작은 배치로 백필하고 NULL·허용값 분포를 검증합니다.
새 쓰기 주체가 request_key를 쓰도록 전환한 뒤 규칙 단계에서 NOT NULL·복합 UNIQUE를 강화합니다.
여러 애플리케이션 버전이 동시에 실행될 수 있으면 각 단계가 구·신버전 모두와 호환되는지 확인합니다.
ALTER TABLE의 기본 개념은 표준이지만 온라인 DDL 알고리즘·잠금 옵션·암묵적 커밋은 MySQL 제품 동작입니다.
MySQL 8.4에서 ALGORITHM=INSTANT, INPLACE, COPY 가능 여부는 변경 종류에 따라 다르며 요청한 알고리즘을 지원하지 않으면 오류가 발생할 수 있습니다.
작은 학습 테이블의 성공 시간을 대형 운영 테이블에 그대로 적용하지 않습니다.
열 추가와 데이터 이관
먼저 NULL 허용 열을 추가하고 기존 행의 PK를 이용해 충돌하지 않는 과거 키를 채웁니다.
NULL과 (author_id, request_key) 중복이 0인지 확인한 뒤 제약을 적용합니다.
UPDATE posts
SET request_key = CONCAT('legacy-', id)
WHERE request_key IS NULL;
SELECT
COUNT(*) AS total_rows,
SUM(request_key IS NULL OR TRIM(request_key) = '') AS invalid_rows,
COUNT(*) - COUNT(DISTINCT author_id, request_key) AS duplicate_rows
FROM posts;
ALTER TABLE posts
MODIFY request_key VARCHAR(64) NOT NULL,
ADD CONSTRAINT ck_posts_request_key
CHECK (CHAR_LENGTH(TRIM(request_key)) BETWEEN 1 AND 64),
ADD CONSTRAINT uq_posts_author_request
UNIQUE (author_id, request_key);기존 행은 서로 다른 과거 키를 갖고 앞으로 모든 등록 요청은 키를 명시해야 합니다.
같은 회원이 같은 키를 다시 쓰면 UNIQUE가 중복 저장을 거절합니다.
개선 실행 결과stage | evidence
expand | request_key exists and allows NULL
backfill | invalid_rows = 0, duplicate_rows = 0
contract | NOT NULL + CHECK + uq_posts_author_request
new writes | every create request sends a stable keylegacy-{id}는 과거 네트워크 요청을 복원하지 않고 기존 행에 충돌 없는 전환 키만 부여합니다.
앞으로의 키는 HTTP 요청 ID가 아니라 클라이언트가 재시도에도 유지하는 업무 멱등성 키여야 합니다.
백필 UPDATE는 대량 테이블에서 언두 로그·복제 지연·잠금을 만들 수 있어 PK 범위 배치와 관측 지표를 둡니다.
변경 결과와 값 분포 확인
SHOW CREATE TABLE만 보지 않고 information_schema의 NULL 허용/기본값과 실제 분포를 함께 기록합니다.
새 값이 검사에 의해 거절되는지도 트랜잭션에서 확인합니다.
SELECT
column_name,
column_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_schema = 'board_lab'
AND table_name = 'posts'
AND column_name = 'request_key';
SELECT author_id, COUNT(*) AS posts, COUNT(DISTINCT request_key) AS request_keys
FROM posts
GROUP BY author_id
ORDER BY author_id;
SELECT check_clause
FROM information_schema.check_constraints
WHERE constraint_schema = 'board_lab'
AND constraint_name = 'ck_posts_request_key';request_key는 NOT NULL이고 회원별 게시글 수와 서로 다른 키 수가 같아야 합니다.
스키마와 데이터 중 하나만 맞아서는 마이그레이션 완료가 아닙니다.
DROP TABLE은 구조와 데이터를 제거하고 TRUNCATE는 구조를 남긴 채 전체 데이터를 빠르게 비우지만 둘 다 일반 DELETE와 같은 롤백·트리거·FK 의미라고 보지 않습니다.
FOREIGN_KEY_CHECKS=0은 안전한 삭제 해결책이 아니라 무결성 검사를 우회합니다.
참조 순서와 수명주기를 설계하고, 실습 초기화는 별도 일회성 스키마에서만 수행합니다.
열 DROP도 소비 쿼리가 모두 전환된 뒤 규칙 단계에서 합니다.
별도 임시 테이블에 행 3개를 넣고 NULL 허용 추가→일부만 백필→NULL 진단→나머지 백필→NOT NULL 순서를 반복하세요.
중간에 새 쓰기 주체가 NULL을 삽입해 제약 추가가 실패하는 경쟁 상황도 재현합니다.
공백 키로 검사 3819를 확인하고 같은 회원의 기존 키를 다시 넣어 오류 1062를 관찰합니다.
DDL 전에는 테이블 크기, 장기 트랜잭션, 복제본 지연, 메타데이터 잠금 대기와 예상 알고리즘을 확인합니다.
마이그레이션 도구가 오류가 난 단계와 재실행 가능성을 기록해야 합니다.
MySQL의 원자적 DDL이 개별 구문 장애 안전성을 높여도 여러 구문으로 이루어진 업무 마이그레이션 전체를 자동 롤백하지 않습니다.
각 단계는 중복 실행 또는 상태 점검 뒤 안전하게 재개할 수 있게 작성합니다.
기본값 변경은 기존 행을 다시 쓰지 않고 앞으로 생략된 INSERT에만 영향을 줍니다.
NULL 허용 열에 검사만 추가하면 NULL은 알 수 없음으로 통과할 수 있습니다.
문자열 열거형은 확장 시 DDL이 필요하지만 의미가 명확하고, 별도 코드 테이블은 메타데이터·다국어·수명주기에 유리할 수 있습니다.
값 종류와 변경 빈도에 따라 선택합니다.
열 이름 변경은 쿼리·뷰·ETL(Extract·Transform·Load, 추출·변환·적재)·대시보드 의존을 모두 조사합니다.
이 단계적 마이그레이션은 ch5에서 post_tags 관계를 기존 게시글에 추가할 때 그대로 확장됩니다.
ch7 인덱스 추가와 ch11 물리 변경에서도 알고리즘·잠금·백필·일치 검사 기준을 반복합니다.
request_key는 ch6과 이후 트랜잭션 장에서 재시도 중복을 구분하는 누적 열로 남습니다.
스키마 변경 배포 기준
| 판단 축 | 확인할 질문 |
|---|---|
| 기존 행 | 새 열에 넣을 실제 업무값을 결정했는가? |
| 호환 | 구·신 쓰기 주체가 마이그레이션 중 함께 동작하는가? |
| 비용 | 알고리즘·잠금·백필 양을 측정했는가? |
| 일치 검사 | NULL·잘못된·행 건수 완료 조건이 있는가? |
| 복구 | 중간 오류 발생 뒤 어느 단계부터 재개하는가? |
최종 CREATE 문만 검토하지 않습니다.
변경 전 상태, 각 전이 SQL, 단계별 증거, 오류 발생 시 재개 절차를 한 마이그레이션 단위로 승인합니다.
연습 문제
posts에 선택 입력인 legacy_slug VARCHAR(100)을 추가한다고 가정하세요.
기존 행은 NULL을 유지하고 값이 있을 때만 빈 문자열이 아니어야 합니다.
확장과 검사를 나눠 작성하고 메타데이터에서 NULL 허용을 확인하세요.
해설과 예시 답안
업무상 선택값이므로 백필·NOT NULL은 필요 없습니다.
검사는 NULL을 허용하고 값이 있을 때 길이를 검사합니다.
ALTER TABLE posts
ADD COLUMN legacy_slug VARCHAR(100) NULL AFTER request_key;
ALTER TABLE posts
ADD CONSTRAINT ck_posts_legacy_slug
CHECK (legacy_slug IS NULL OR CHAR_LENGTH(TRIM(legacy_slug)) > 0);
SELECT column_name, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'board_lab'
AND table_name = 'posts'
AND column_name = 'legacy_slug';NULL은 허용되고 빈 문자열·공백만 있는 값은 오류가 발생하며 일반 참조는 성공하는지 확인합니다.
이 연습 열을 누적 스키마에 남기지 않으려면 의존성 확인 뒤 DROP합니다.
핵심 정리
- 기존 데이터가 있는 스키마는 확장·이관·축소 단계로 변경합니다.
- NULL 허용·백필·일치 검사·NOT NULL 순서가 호환 창을 만듭니다.
- DDL의 알고리즘·잠금·암묵적 커밋을 운영 비용으로 봅니다.
- DROP·TRUNCATE·FK 검사 해제를 일반적인 수정 도구처럼 사용하지 않습니다.
다음 장에서는 완성된 누적 데이터를 필요한 열과 행으로 조회합니다.
온라인 DDL 알고리즘·복제 지연·배치 백필은 운영 환경에서만 추가로 측정합니다.