지난 포스팅에서 MySQL의 최신 표준 콜레이션(utf8mb4_0900_ai_ci, utf8mb4_unicode_ci)이 유니코드 가중치 알고리즘(UCA)에 의해 특수문자 ª를 a로, ²를 2로 동일시하여 데이터 이관 시 중복 오류나 데이터 왜곡을 일으키는 현상을 살펴보았습니다.
이 비교표를 보신 많은 엔지니어분들이 다음과 같은 질문을 던지십니다.
"그렇다면 'ª' = 'a'를 False로 처리하는 utf8mb4_general_ci로 설정하면 데이터 변질이나 충돌을 완벽히 막을 수 있지 않을까요?"
결론부터 말씀드리면 "반은 맞고, 반은 매우 위험한 착각"입니다. 당장 ª와 ²의 충돌은 피할 수 있지만, utf8mb4_general_ci는 또 다른 형태의 심각한 문자 변질(유실 및 왜곡)을 유발하는 레거시(Legacy) 콜레이션이기 때문입니다.
이번 글에서는 왜 utf8mb4_general_ci가 근본적인 해결책이 될 수 없는지 내부 아키텍처와 실행 계획을 통해 낱낱이 파헤쳐 보겠습니다.
1. utf8mb4_general_ci의 내부 아키텍처와 치명적 한계
utf8mb4_general_ci는 과거 CPU 연산 성능이 부족하던 시절, 정렬과 비교 속도를 극대화하기 위해 유니코드 표준 알고리즘(UCA)을 무시하고 만든 단순 1:1 매핑(Simple Mapping) 방식의 콜레이션입니다.
① 왜 ª와 ²는 변질되지 않았을까?
general_ci는 복잡한 호환성 분해(NFKD)를 수행하지 않습니다. 내부 매핑 테이블에 정의된 일부 라틴 문자만 대문자로 치환할 뿐, 매핑 테이블에 없는 ª(U+00AA)나 ²(U+00B2) 같은 문자는 변환을 포기하고 유니코드 코드포인트(바이트 값) 그대로 가중치에 반영합니다. 그 결과 우연히 ª와 a가 구분된 것일 뿐, 문자의 원형을 보호하기 위해 설계된 것이 아닙니다.
② general_ci가 일으키는 또 다른 문자 변질 참사
그렇다면 매핑 테이블에 존재하는 다른 특수문자나 다국어 문자가 들어오면 어떻게 될까요? general_ci 역시 가차 없이 문자를 변질시킵니다.
-- utf8mb4_general_ci 환경에서의 문자 비교 및 내부 가중치(WEIGHT_STRING) 확인
SELECT
'ä' = 'a' COLLATE utf8mb4_general_ci AS umlaut_to_a,
'é' = 'e' COLLATE utf8mb4_general_ci AS acute_to_e,
'ß' = 's' COLLATE utf8mb4_general_ci AS eszett_to_s,
HEX(WEIGHT_STRING('a' COLLATE utf8mb4_general_ci)) AS weight_a,
HEX(WEIGHT_STRING('ä' COLLATE utf8mb4_general_ci)) AS weight_umlaut,
HEX(WEIGHT_STRING('ß' COLLATE utf8mb4_general_ci)) AS weight_eszett,
HEX(WEIGHT_STRING('s' COLLATE utf8mb4_general_ci)) AS weight_s;
| umlaut_to_a | acute_to_e | eszett_to_s | weight_a | weight_umlaut | weight_eszett | weight_s |
| 1 (True) | 1 (True) | 1 (True) | 0041 | 0041 | 0053 | 0053 |
위 결과에서 볼 수 있듯, general_ci는 다음과 같은 심각한 정합성 문제를 야기합니다.
- 악센트 강제 제거: 유럽어권이나 외래어 표기에 흔히 쓰이는 ä, à, á, é, ñ 등의 문자를 모두 일반 알파벳 a(0x0041), e(0x0045), n(0x004E)으로 강제 치환합니다.
- 잘못된 언어 규칙 적용: 독일어 에스체트(ß)는 원래 'ss'(2글자)와 매핑되어야 맞지만, general_ci는 글자 확장을 지원하지 못해 임의로 단일 문자 's'(0x0053)로 뭉개버립니다.
- 보충 문자(4바이트 이모지 및 특수 한자) 처리 버그: BMP(Basic Multilingual Plane) 영역을 벗어나는 4바이트 문자들을 일괄적으로 동일한 가중치(0xFFFD, Replacement Character)로 처리하거나 잘못 정렬하는 역사적 결함을 안고 있습니다.
2. 한눈에 보는 Collation 문자 변질(동등 처리) 매트릭스
데이터 이관 관점에서 각 콜레이션이 어떤 문자를 변질(동일시)시키는지 비교한 아키텍처 매트릭스입니다.
| 비교 대상 문자 쌍 | utf8mb4_0900_ai_ci(MySQL 8.0 기본) | utf8mb4_general_ci(레거시 단순 매핑) | utf8mb4_0900_as_cs(엄격한 유니코드 구분) | utf8mb4_bin(바이너리 바이트 비교) |
| 대소문자 ('A' = 'a') | O (동일 취급) | O (동일 취급) | X (구분함) | X (구분함) |
| 악센트 문자 ('ä' = 'a', 'é' = 'e') | O (동일 취급) | O (동일 취급 - 변질!) | X (구분함) | X (구분함) |
| 위첨자/서수 ('ª' = 'a', '²' = '2') | O (동일 취급) | X (우연히 구분함) | X (구분함) | X (구분함) |
| 전각/반각 문자 ('A' = 'A') | O (동일 취급) | X (우연히 구분함) | X (구분함) | X (구분함) |
| 이모지 구분 ('😀' = '😁') | X (정확히 구분) | 위험 (과거 버전 버그 이력) | X (구분함) | X (구분함) |
| 데이터 원형 보존성 | 낮음 (검색 특화) | 불완전 (예측 불가) | 매우 높음 | 완벽 (100% 바이트 일치) |
즉, utf8mb4_general_ci를 선택하는 것은 ª라는 지뢰 하나를 피하기 위해 ä, é, ß라는 수많은 지뢰밭으로 걸어 들어가는 것과 같습니다.
3. 실무에서 general_ci 도입 시 발생하는 실행 계획(Execution Plan) 참사
MySQL 8.0 환경에서 특정 테이블만 변질을 막겠다며 utf8mb4_general_ci로 설정하면, 기본 콜레이션(utf8mb4_0900_ai_ci)을 사용하는 다른 테이블과 JOIN할 때 심각한 성능 저하와 에러가 발생합니다.
① Collation 충돌 에러 (Illegal mix of collations)
두 테이블의 컬럼 콜레이션 강도(Coercibility)가 동일하기 때문에, 조인 시 MySQL 옵티마이저는 어느 쪽 기준으로 비교할지 결정하지 못하고 즉시 에러를 뱉어냅니다.
-- table_a (utf8mb4_0900_ai_ci) 와 table_b (utf8mb4_general_ci) 조인 시도
SELECT * FROM table_a a JOIN table_b b ON a.code = b.code;
-- ERROR 1267 (HY000): Illegal mix of collations (utf8mb4_0900_ai_ci,IMPLICIT) and (utf8mb4_general_ci,IMPLICIT) for operation '='
② 암시적 형변환으로 인한 인덱스 무력화 (Full Table Scan)
에러를 피하기 위해 쿼리에 COLLATE 절을 명시하거나, 스토어드 프로시저의 세션 콜레이션과 테이블 콜레이션이 어긋날 경우 인덱스 컬럼이 내부적으로 형변환(CONVERT())되어 B-Tree 인덱스를 전혀 타지 못합니다.
-- 실행 계획 확인: b.code의 인덱스가 깨지고 Full Table Scan(ALL) 발생
EXPLAIN SELECT * FROM table_a a
JOIN table_b b ON a.code = b.code COLLATE utf8mb4_0900_ai_ci;
| id | select_type | table | type | possible_keys | key | rows | Extra |
| 1 | SIMPLE | a | ALL | NULL | NULL | 10000 | NULL |
| 1 | SIMPLE | b | ALL | NULL | NULL | 5000000 | Using where; Using join buffer (hash join) |
4. 시니어 아키텍트의 최종 처방: 변질을 완벽히 막는 올바른 설계
데이터 이관 시 특수문자, 악센트, 위첨자 등 모든 문자의 논리적 변질과 중복 충돌을 원천 차단하려면, 어중간한 utf8mb4_general_ci가 아니라 다음 두 가지 표준 전략을 사용해야 합니다.
처방 1: 데이터 원형 100% 보존이 필요한 경우 → utf8mb4_bin (가장 권장)
문자열의 언어적 의미를 완전히 배제하고 오직 UTF-8 바이트 시퀀스(Hex 값) 자체로만 비교합니다.
- 'a'(0x61), 'A'(0x41), 'ª'(0xC2AA), 'ä'(0xC3A4)를 모두 완전히 다른 독립된 데이터로 취급합니다.
복잡한 유니코드 가중치 계산을 생략하고 메모리 바이트만 비교하므로, 비교 및 인덱스 정렬 속도가 모든 콜레이션 중 가장 빠릅니다.
-- 마이그레이션 타겟 테이블 설계 시 완벽한 원형 보존 설정
CREATE TABLE migration_target (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
raw_code VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
description TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
UNIQUE KEY uk_raw_code (raw_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
처방 2: 대소문자만 무시하고 모든 특수/악센트 문자는 구분하고 싶은 경우
만약 비즈니스 요구사항상 'a'와 'A'는 같아야 하지만, 'ª', 'ä', '²' 같은 특수·악센트 문자는 절대 변질되면 안 된다면 어떻게 해야 할까요? 안타깝게도 MySQL은 as_ci (Accent-Sensitive, Case-Insensitive) 콜레이션을 기본 제공하지 않습니다. 이 경우 가상 생성 컬럼(Generated Column)과 함수 기반 인덱스를 조합하는 아키텍처 패턴으로 우아하게 해결할 수 있습니다.
-- 원본은 utf8mb4_bin으로 100% 보존하되, 검색/유니크 제약은 소문자 변환 후 바이트 비교
ALTER TABLE users
ADD COLUMN username_lower VARCHAR(100) GENERATED ALWAYS AS (LOWER(username)) VIRTUAL,
ADD UNIQUE INDEX idx_username_as_ci (username_lower COLLATE utf8mb4_bin);
- 작동 원리: LOWER() 함수는 순수 알파벳 대문자('A')만 소문자('a')로 바꿀 뿐, 'ª'나 'ä'는 건드리지 않습니다. 그 결과를 utf8mb4_bin으로 인덱싱하면 대소문자는 무시하면서 모든 특수문자의 변질은 완벽히 차단하는 커스텀 콜레이션 효과를 얻을 수 있습니다.
5. 아키텍트 요약 (Takeaway)
- utf8mb4_general_ci는 절대 대안이 아닙니다. ª와 ²는 우연히 피해 가지만, ä, é, ß 등 수많은 다국어 및 특수기호를 여전히 일반 알파벳으로 변질시키는 구형 비표준 콜레이션입니다.
- MySQL 8.0 이상에서 general_ci는 공식적으로 Deprecated(사용 중지 권고) 수순을 밟고 있는 레거시 기술이며, 타 테이블과 조인 시 심각한 콜레이션 충돌을 일으킵니다.
- 데이터 이관 시 문자 변질과 중복 키 오류를 막기 위한 유일무이한 정석은 utf8mb4_bin 또는 utf8mb4_0900_as_cs를 적용하는 것입니다.
