지난 포스팅을 통해 기존 utf8mb4_unicode_ci 환경에서 첨자(ª, ²)나 특수문자가 일반 문자/숫자와 동일시되어 발생하는 정합성 문제를 해결하려면, 콜레이션을 utf8mb4_bin 또는 utf8mb4_0900_as_cs로 전환해야 한다는 점을 다루었습니다.
또한 일부 테이블만 콜레이션을 변경했을 때 JOIN 시 발생하는 Illegal mix of collations (ERROR 1267)와 인덱스 무력화 현상도 살펴보았습니다.
이론적인 해결책은 명확하지만, 이미 수백 개의 테이블이 얽혀 돌아가는 라이브 운영 환경에서 콜레이션을 일괄 변경하는 것은 마치 달리는 자동차의 바퀴를 갈아 끼우는 것과 같습니다. 섣불리 ALTER TABLE을 실행했다가는 외래키(Foreign Key) 제약조건 에러로 작업이 중단되거나, 변경 도중 메타데이터 락(MDL)과 조인 실행 계획 붕괴로 인해 서비스 전체가 마비될 수 있습니다.
이번 포스팅에서는 운영 중인 데이터베이스의 콜레이션을 외래키 충돌이나 조인 성능 저하 없이 안전하게 전환하는 내부 아키텍처 원리와 4단계 실무 마이그레이션 절차를 완벽하게 정리해 드립니다.
1. 왜 외래키(FK)가 걸린 컬럼의 Collation은 변경이 거부될까?
부모 테이블(users)과 자식 테이블(orders)이 user_code라는 문자열 컬럼으로 외래키(Foreign Key) 관계를 맺고 있다고 가정해 보겠습니다. 두 테이블 모두 utf8mb4_unicode_ci를 사용 중일 때, 부모 테이블의 콜레이션부터 utf8mb4_0900_as_cs로 변경을 시도하면 MySQL은 즉시 아래와 같은 에러를 뱉어냅니다.
-- 부모 테이블의 Collation 단독 변경 시도
ALTER TABLE users
MODIFY COLUMN user_code VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL;
ERROR 3780 (HY000): Referencing column 'user_code' and referenced column 'user_code' in foreign key constraint 'fk_orders_users' are incompatible.
(MySQL 5.7 이하에서는 ERROR 1025 또는 ERROR 1215: Cannot add foreign key constraint 발생)
내부 작동 원리: InnoDB B-Tree 인덱스와 가중치(Weight) 비교 메커니즘
InnoDB 스토리지 엔진에서 외래키 제약조건은 단순한 논리적 규칙이 아니라, 부모 테이블의 인덱스 트리(B-Tree)와 자식 테이블의 인덱스 트리를 물리적으로 직접 탐색(Index Lookup)하여 정합성을 검증하는 저수준(Low-level) 메커니즘입니다.
| 구분 | utf8mb4_unicode_ci (자식 테이블) | utf8mb4_0900_as_cs (부모 테이블) | InnoDB 엔진의 판단 |
| 'user_a'의 인덱스 Key Weight | 0x0E33... (대소문자/첨자 무시 가중치) | 0x1C47...0008 (Level 1~3 통합 가중치) | 가중치 포맷 불일치 |
| 'user_ª'의 인덱스 Key Weight | 0x0E33... ('user_a'와 완전히 동일) | 0x1C47...0014 ('user_a'와 다른 고유값) | 1:1 매핑 관계 붕괴 |
| B-Tree 정렬 순서 | 'A' = 'a' = 'ª' (동일 위치) | 'a' < 'A' < 'ª' (엄격히 분리 정렬) | 직접 인덱스 탐색 불가! |
- 인덱스 탐색 알고리즘 불일치: 자식 테이블에 데이터가 INSERT 될 때, InnoDB는 자식 컬럼의 값을 가지고 부모 테이블의 B-Tree 인덱스를 직접 검색합니다. 만약 두 컬럼의 콜레이션이 다르면 문자열을 바이트 가중치(Weight)로 변환하는 규칙과 B-Tree 정렬 순서 자체가 달라지므로, InnoDB는 인덱스 루프업(Lookup)을 수행할 수 없습니다.
- 참조 무결성(Referential Integrity) 붕괴: 부모 테이블(0900_as_cs)에서는 'a'와 'ª'가 서로 다른 2개의 Row로 존재할 수 있지만, 자식 테이블(unicode_ci)에서는 두 값을 구분할 수 없습니다. 만약 부모 테이블의 'ª'가 삭제(ON DELETE CASCADE)될 때, 자식 테이블에서는 'a'까지 함께 삭제되는 대참사가 벌어지므로 MySQL은 데이터 딕셔너리(Data Dictionary) 레벨에서 스키마 변경을 원천 차단합니다.
2. 한 번에 바꾸려다 발생하는 2가지 치명적 운영 장애
외래키 에러를 피하기 위해 SET foreign_key_checks = 0;을 선언하고 테이블들을 순차적으로 ALTER 하는 방식을 흔히 떠올립니다. 하지만 대용량 실무 환경에서는 다음 두 가지 장애 포인트 때문에 이 방식을 그대로 쓰면 위험합니다.
① Collation 변경은 INPLACE가 불가능한 테이블 리빌드(COPY) 작업이다
인덱스가 걸린 문자열 컬럼의 콜레이션을 변경하면 B-Tree 인덱스의 정렬 순서가 완전히 바뀌므로 인덱스를 처음부터 다시 써야 합니다. MySQL에서 이 작업은 ALGORITHM=INSTANT나 INPLACE가 지원되지 않으며, 테이블 전체를 임시 테이블로 복사하는 ALGORITHM=COPY로 동작합니다.
-- ALGORITHM=INPLACE 강제 지정 시 발생하는 에러 확인
ALTER TABLE users
MODIFY COLUMN user_code VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL,
ALGORITHM=INPLACE;
-- ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported. Reason: Cannot change column type INPLACE. Try ALGORITHM=COPY.
- 장애 영향도: ALGORITHM=COPY가 수행되는 동안 해당 테이블에는 쓰기 작업(DML)이 모두 차단(LOCK=SHARED)되며, 끝나는 시점에 배타적 메타데이터 락(Exclusive MDL)을 획득하면서 후속 쿼리들이 도미노처럼 적체되어 커넥션 풀이 고갈됩니다.
② 변경 과도기(Transition Window) 동안의 JOIN 에러 및 Full Scan 폭발
10개의 테이블을 순차적으로 변경하는 데 총 30분이 걸린다고 가정해 보겠습니다. 1번 테이블(users)의 변경이 끝나고 2번 테이블(orders)이 변경되기 전까지의 수 분 동안, 두 테이블의 콜레이션이 서로 달라지는 공백기(Inconsistency Window)가 발생합니다. 이 짧은 순간에 애플리케이션에서 두 테이블을 조인하는 쿼리가 유입되면:
- 일반 콜레이션 간 조인 시: Illegal mix of collations (ERROR 1267) 에러가 쏟아지며 API 장애 발생
- utf8mb4_bin으로 변경 시: 에러는 안 나지만 묵시적 형변환으로 인해 후행 테이블 인덱스가 깨져 DB CPU가 100%로 폭주
3. 실무 4단계 무중단 Collation 마이그레이션 아키텍처
서비스 중단이나 조인 장애 없이 기존 utf8mb4_unicode_ci 환경을 utf8mb4_0900_as_cs(또는 utf8mb4_bin)로 안전하게 전환하는 표준 엔지니어링 절차입니다.
[Step 1] 영향도 전수 조사: 변경 대상 컬럼 및 외래키(FK) 토폴로지 추출
가장 먼저 데이터베이스 내에서 콜레이션 변경이 필요한 모든 문자열 컬럼과, 그에 얽혀 있는 외래키 제약조건의 생성/삭제 스크립트를 자동으로 추출합니다.
-- 1. 외래키(FK)로 묶여 있는 문자열 컬럼 목록 및 현재 Collation 불일치/현황 조회
SELECT
kcu.TABLE_NAME AS child_table,
kcu.COLUMN_NAME AS child_column,
c1.COLLATION_NAME AS child_collation,
kcu.CONSTRAINT_NAME AS fk_name,
kcu.referenced_table_name AS parent_table,
kcu.referenced_column_name AS parent_column,
c2.COLLATION_NAME AS parent_collation
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
JOIN INFORMATION_SCHEMA.COLUMNS c1
ON kcu.TABLE_SCHEMA = c1.TABLE_SCHEMA AND kcu.TABLE_NAME = c1.TABLE_NAME AND kcu.COLUMN_NAME = c1.COLUMN_NAME
JOIN INFORMATION_SCHEMA.COLUMNS c2
ON kcu.REFERENCED_TABLE_SCHEMA = c2.TABLE_SCHEMA AND kcu.REFERENCED_TABLE_NAME = c2.TABLE_NAME AND kcu.REFERENCED_COLUMN_NAME = c2.COLUMN_NAME
WHERE kcu.TABLE_SCHEMA = 'your_db_name'
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
AND c1.DATA_TYPE IN ('varchar', 'char', 'text');
[Step 2] 사전 정합성 검증: Collation 변경 시 깨질 잠재 데이터 클렌징
기존 utf8mb4_unicode_ci에서는 'a'와 'ª'가 같은 값이라 부모 테이블에 'a'만 있어도 자식 테이블에 'ª'가 들어갈 수 있었습니다. 하지만 콜레이션을 0900_as_cs나 bin으로 바꾸는 순간, 자식 테이블의 'ª'는 부모 테이블에 존재하지 않는 고아 레코드(Orphaned Record)가 되어 외래키 재결합 시 에러를 유발합니다.
-- 부모-자식 간 unicode_ci로는 매칭되지만, 변경될 엄격한 Collation(bin/as_cs) 기준으로는 깨지는 고아 데이터 색출
SELECT
c.order_id,
c.user_code AS child_val, HEX(c.user_code) AS child_hex,
p.user_code AS parent_val, HEX(p.user_code) AS parent_hex
FROM orders c
JOIN users p ON c.user_code = p.user_code -- 기존 unicode_ci 인덱스 활용 조인
WHERE c.user_code COLLATE utf8mb4_bin <> p.user_code COLLATE utf8mb4_bin;
- 이 쿼리로 검출된 데이터가 있다면, 마이그레이션 전에 부모 테이블에 누락된 특수문자 키를 추가하거나 자식 데이터를 보정해야 합니다.
[Step 3] 실행 계획 붕괴를 막는 브릿지 인덱스(Bridge Index) 배포 (무중단 핵심 팁!)
테이블들을 순차적으로 변경하는 동안 조인 에러와 Full Table Scan을 막기 위해, 대용량 핵심 테이블에 변경될 콜레이션 기준의 가상 컬럼 인덱스를 미리 추가하고 애플리케이션 쿼리가 과도기 동안 안전하게 동작하도록 완충 지대를 만듭니다. (소규모 점검 창구(Maintenance Window) 확보가 가능하다면 Step 4로 바로 이동해도 무방합니다.)
-- [MySQL 8.0] 메타데이터만 변경되는 빠른 작업이거나 Online DDL(INPLACE)이 가능한 가상 컬럼 인덱스 추가
ALTER TABLE orders
ADD COLUMN user_code_new VARCHAR(50)
GENERATED ALWAYS AS (user_code COLLATE utf8mb4_0900_as_cs) VIRTUAL,
ADD INDEX idx_orders_user_code_new (user_code_new);
[Step 4] FK 제어 및 안전한 일괄 스키마 전환 실행
소규모 테이블이나 트래픽이 적은 시간대에는 foreign_key_checks = 0을 활용한 트랜잭션/배치 스크립트로 변경하고, 수천만 건 이상의 대용량 테이블은 gh-ost(또는 pt-online-schema-change)를 사용하여 무중단으로 스키마를 교체합니다.
방법 A: 점검 시간(Maintenance Window) 내 일괄 DDL 스크립트 실행
테이블 전체의 기본 콜레이션과 하위 모든 문자열 컬럼을 한 번에 바꾸려면 MODIFY COLUMN 대신 CONVERT TO CHARACTER SET 구문을 사용하는 것이 누락을 막는 정석입니다.
-- 1. 현재 세션에서만 외래키 검사 비활성화 (글로벌 설정 변경 절대 금지!)
SET SESSION foreign_key_checks = 0;
-- 2. 부모 테이블 및 하위 모든 컬럼 Collation 일괄 변환
ALTER TABLE users
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs;
-- 3. 자식 테이블 및 하위 모든 컬럼 Collation 일괄 변환
ALTER TABLE orders
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs;
-- 4. 외래키 검사 재활성화
SET SESSION foreign_key_checks = 1;
-- 5. 데이터베이스 기본 Collation 변경 (향후 생성될 신규 테이블을 위해 필수)
ALTER DATABASE your_db_name CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_as_cs;
⚠️ 주의사항: ALTER DATABASE ... COLLATE 명령어는 앞으로 새로 만들어질 테이블(CREATE TABLE)의 기본값만 바꿀 뿐, 이미 존재하는 테이블들의 콜레이션은 전혀 바꾸지 않습니다. 반드시 기존 테이블들에 대해 개별적으로 CONVERT TO CHARACTER SET을 수행해야 합니다.
방법 B: 24x365 무중단 대용량 환경 — Shadow Table 스왑 전략 (gh-ost 활용)
수억 건의 테이블이라 ALTER TABLE 실행 시 수십 분간 테이블이 잠기는 것을 허용할 수 없다면, 외래키 제약조건을 잠시 제거(Drop)한 뒤 gh-ost로 무중단 마이그레이션을 수행해야 합니다.
| 단계 | 작업 내용 | 실행 명령어 / 아키텍처 동작 | 서비스 영향도 |
| 1단계 | 자식 테이블의 물리적 FK 제약조건만 빠르게 Drop | ALTER TABLE orders DROP FOREIGN KEY fk_orders_users, ALGORITHM=INPLACE, LOCK=NONE; | 무중단 (0초) |
| 2단계 | gh-ost를 이용해 복제본(Ghost) 테이블 생성 및 Binlog 동기화 | gh-ost --alter="CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs" ... (컷오버는 보류 --postpone-cut-over-flag-file) | 무중단 (백그라운드 복사) |
| 3단계 | 부모·자식 테이블 동시 Cut-over (원자적 테이블 이름교체 RENAME) | 플래그 파일 삭제 시 부모와 자식 테이블이 1초 이내에 신규 콜레이션 테이블로 동시 스왑됨 | 찰나의 지연 (< 100ms) |
| 4단계 | 외래키 제약조건 재연결 (데이터 검증 생략으로 즉시 반영) | SET SESSION foreign_key_checks=0; 후 ALTER TABLE orders ADD CONSTRAINT fk_... 실행 | 무중단 |
4. 마이그레이션 직후 반드시 확인해야 할 실행 계획(Execution Plan) 검증
모든 테이블의 콜레이션을 utf8mb4_0900_as_cs(또는 utf8mb4_bin)로 성공적으로 변경했더라도, 애플리케이션 커넥션(JDBC, Python, Node.js 등)의 설정이나 스토어드 프로시저가 여전히 구형 utf8mb4_unicode_ci로 남아 있으면 또 다른 문제를 일으킵니다.
검증 포인트: 커넥션 콜레이션 불일치 여부 확인
마이그레이션 직후 핵심 조인 쿼리와 조건절 쿼리에 대해 EXPLAIN과 SHOW WARNINGS를 실행하여 옵티마이저가 내부적으로 불필요한 형변환을 수행하고 있지 않은지 반드시 점검해야 합니다.
-- 1. 핵심 조인 및 첨자('ª', '²') 검색 쿼리의 실행 계획 검증
EXPLAIN SELECT o.order_id, u.user_code
FROM users u
JOIN orders o ON u.user_code = o.user_code
WHERE u.user_code = 'TYPE_²';
-- 2. 옵티마이저가 재작성한 내부 쿼리에 CONVERT() 함수가 끼어있는지 확인!
SHOW WARNINGS\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `db`.`o`.`order_id` AS `order_id`,`db`.`u`.`user_code` AS `user_code`
from `db`.`users` `u` join `db`.`orders` `o`
where ((`db`.`o`.`user_code` = 'TYPE_²') and (`db`.`u`.`user_code` = 'TYPE_²'))
- 정상 판정 기준: SHOW WARNINGS 결과의 where 절에 convert( 또는 COLLATE라는 단어가 보이지 않고 깔끔하게 (db.o.user_code = 'TYPE_²') 형태로 등치 비교가 이루어진다면, 두 테이블의 인덱스와 콜레이션이 완벽하게 일치하여 최고 성능으로 동작하고 있는 것입니다!
5. 요약
- 외래키(FK)가 걸린 테이블의 콜레이션 변경 시 발생하는 ERROR 3780은 InnoDB가 B-Tree 인덱스의 가중치(Weight)를 직접 비교하여 참조 무결성을 검증하기 때문에 발생하는 아키텍처적 제약입니다.
- 콜레이션 변경(CONVERT TO CHARACTER SET)은 인덱스 전체를 재정렬해야 하므로 ALGORITHM=COPY로 동작하며, 순차 변경 시 과도기 동안 조인 에러(ERROR 1267)와 Full Scan을 유발할 수 있습니다.
- 안전한 전환을 위해서는
① INFORMATION_SCHEMA를 통한 영향도 파악 $\rightarrow$
② 첨자/대소문자로 인한 고아 데이터 사전 정합성 검증 $\rightarrow$
③ SET SESSION foreign_key_checks = 0 또는 gh-ost 동시 컷오버를 통한 일괄 전환 $\rightarrow$
④ SHOW WARNINGS를 통한 실행 계획 내 암시적 형변환 검증의 4단계 절차를 준수해야 합니다.
