지난 포스팅에서 데이터 이관 시 첨자(ª, ²)나 특수문자 변질을 막기 위해 특정 테이블이나 컬럼의 콜레이션을 utf8mb4_unicode_ci에서 utf8mb4_bin이나 utf8mb4_0900_as_cs로 변경하는 전략을 다루었습니다.

 

하지만 실무에서 일부 테이블의 콜레이션만 부분적으로 변경하거나, 레거시 테이블(utf8mb4_unicode_ci)과 신규 테이블(utf8mb4_0900_ai_ci)을 함께 운영하다 보면 필연적으로 마주치는 치명적인 장애가 있습니다. 바로 두 테이블을 JOIN 하거나 서브쿼리로 비교할 때 발생하는 ERROR 1267 (HY000): Illegal mix of collations 에러입니다.

 

더 무서운 것은 에러가 나지 않고 조인이 수행되더라도, 옵티마이저가 내부적으로 인덱스를 포기하고 Full Table Scan을 선택해 DB CPU를 100%로 치솟게 만드는 현상입니다. 이번 글에서는 MySQL이 서로 다른 콜레이션을 어떻게 중재하는지 그 내부 아키텍처인 Coercibility(콜레이션 강제성) 원리를 파헤치고, 인덱스를 살리는 실무 쿼리 튜닝 및 아키텍처 해법을 정리해 드립니다.

 


1. 문제 재현: 왜 서로 다른 Collation을 JOIN 하면 오류가 날까?

기존 레거시 테이블(orders_legacy, utf8mb4_unicode_ci)과 신규 이관 테이블(users_new, utf8mb4_0900_ai_ci)을 사용자 코드(user_code) 기준으로 조인하는 상황을 가정해 보겠습니다.

-- 1. 레거시 테이블 (MySQL 5.7 표준이었던 utf8mb4_unicode_ci)
CREATE TABLE orders_legacy (
    order_id BIGINT PRIMARY KEY,
    user_code VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
    INDEX idx_user_code (user_code)
);

-- 2. 신규 테이블 (MySQL 8.0 기본값인 utf8mb4_0900_ai_ci)
CREATE TABLE users_new (
    user_id BIGINT PRIMARY KEY,
    user_code VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
    INDEX idx_user_code (user_code)
);

-- 3. 두 테이블 JOIN 실행
SELECT * 
FROM orders_legacy o
JOIN users_new u ON o.user_code = u.user_code;

위 쿼리를 실행하면 MySQL 엔진은 결과를 반환하는 대신 즉시 아래와 같은 에러를 뱉어냅니다.

ERROR 1267 (HY000): Illegal mix of collations (utf8mb4_unicode_ci,IMPLICIT) and (utf8mb4_0900_ai_ci,IMPLICIT) for operation '='

에러 메시지를 자세히 보면 콜레이션 이름 뒤에 IMPLICIT이라는 키워드가 붙어 있습니다. 이 키워드가 바로 MySQL 내부 아키텍처를 이해하는 핵심 열쇠입니다.

2. 내부 작동 원리: Collation Coercibility (콜레이션 강제성/우선순위)

MySQL 옵티마이저는 = 연산이나 JOIN을 수행할 때, 양변의 문자열 비교 규칙(Collation)이 다르면 어느 한쪽의 규칙으로 통일해야 합니다. 이때 "누구의 콜레이션이 더 힘이 센가(우선순위가 높은가)?"를 판단하기 위해 모든 문자열 표현식에 Coercibility(강제성 수치, 0~6)라는 내부 점수를 부여합니다. 숫자가 낮을수록 우선순위가 높습니다(힘이 셉니다).

Coercibility 수치 등급 명칭 (에러 표기) 대상 표현식 (Expressions) 우선순위
0 EXPLICIT 쿼리에 명시적으로 COLLATE 절을 사용한 경우 (예: col COLLATE utf8mb4_bin) 최상 (1순위)
1 NONE 서로 다른 콜레이션을 가진 문자열을 CONCAT() 등으로 결합한 결과 2순위
2 IMPLICIT 테이블의 일반 컬럼(Column), 스토어드 루틴 파라미터, 지역 변수 3순위 (컬럼 기본값)
3 SYSCONST USER(), VERSION(), DATABASE() 등 시스템 함수 반환값 4순위
4 COERCIBLE 쿼리에 직접 입력한 문자열 리터럴 상수 (예: 'admin', 'ª') 5순위
5 NUMERIC 숫자나 날짜 타입이 문자열 컨텍스트에서 사용될 때 (예: 1234, NOW()) 6순위
6 IGNORABLE NULL 또는 NULL로부터 파생된 표현식 최하 (7순위)

MySQL의 콜레이션 충돌 해결 알고리즘 (Resolution Rules)

  1. Coercibility 수치가 다를 때: 수치가 더 낮은(우선순위가 높은) 쪽의 콜레이션을 따릅니다.
    • 예: WHERE o.user_code = 'admin' $\rightarrow$ 컬럼(IMPLICIT, 2) vs 상수 리터럴(COERCIBLE, 4)이므로, 상수 'admin'이 컬럼의 콜레이션을 따라가서 에러 없이 비교됩니다.
  2. Coercibility 수치가 같을 때 (JOIN 에러의 원인!):
    • o.user_code와 u.user_code는 둘 다 테이블 컬럼이므로 동일하게 Coercibility 2 (IMPLICIT)를 가집니다.
    • 양쪽의 힘(우선순위)이 2 vs 2로 팽팽하게 맞서는데, 둘 다 _bin(Binary) 콜레이션이 아니라면 MySQL 옵티마이저는 어느 쪽 가중치 알고리즘을 써야 할지 결정할 수 없어 Illegal mix of collations 에러를 발생시키고 쿼리를 중단합니다.

💡 예외 규칙 (_bin의 승리): 만약 동일한 문자셋(utf8mb4) 내에서 한쪽 컬럼이 utf8mb4_bin이고 다른 쪽이 utf8mb4_unicode_ci라면, 둘 다 Coercibility가 2라도 MySQL 내부 규칙에 의해 _bin 콜레이션이 승리하여 에러가 나지 않습니다. 하지만 아래에서 설명할 인덱스 무력화 참사가 발생합니다.

3. 실행 계획(Execution Plan) 분석: 잘못된 해결책이 부르는 장애

개발자들이 Illegal mix of collations 에러를 마주했을 때 가장 흔히 하는 실수는 쿼리의 ON 절 아무 곳에나 COLLATE 키워드를 붙여 에러만 안 나게 덮는 것입니다.

특정 컬럼에 COLLATE를 명시하면 해당 컬럼의 Coercibility가 2 (IMPLICIT)에서 0 (EXPLICIT)으로 격상되어 에러는 사라집니다. 하지만 COLLATE를 어느 쪽 테이블에 걸었느냐에 따라 실행 계획(Execution Plan)은 천국과 지옥으로 갈립니다.

시나리오: orders_legacy (1,000만 건, Driven) $\leftrightarrow$ users_new (100건, Driving) 조인

❌ 나쁜 튜닝: 대용량(Driven) 테이블 컬럼이 형변환되도록 유도한 경우

소규모 테이블(users_new)을 먼저 읽고 대용량 테이블(orders_legacy)을 인덱스로 찔러야(Index Lookup) 하는 상황에서, 소규모 테이블 쪽에 COLLATE utf8mb4_0900_ai_ci를 강제해 버리면 대용량 테이블의 컬럼이 묵시적 형변환을 당합니다.

-- [나쁜 예] u.user_code(0900_ai_ci)를 EXPLICIT(0)으로 선언 -> o.user_code가 변환됨!
EXPLAIN SELECT * 
FROM users_new u
JOIN orders_legacy o ON o.user_code = u.user_code COLLATE utf8mb4_0900_ai_ci;
id select_type table type possible_keys key rows Extra
1 SIMPLE u ALL NULL NULL 100 NULL
1 SIMPLE o ALL NULL NULL 10000000 Using where; Using join buffer (hash join)
  • 원인 분석 (옵티마이저 트레이스): 옵티마이저는 내부적으로 조인 조건을 CONVERT(o.user_code USING utf8mb4) COLLATE utf8mb4_0900_ai_ci = u.user_code로 재작성합니다. o.user_code 컬럼에 변형 함수가 씌워진 것과 같으므로, 기존 utf8mb4_unicode_ci 기준으로 정렬된 B-Tree 인덱스를 전혀 사용할 수 없습니다. 결국 1,000만 건 전체를 스캔(Full Scan)합니다.

 

올바른 튜닝: Driving 테이블(먼저 읽는 소규모 테이블) 값을 타겟 인덱스 콜레이션에 맞춤

대용량 테이블(orders_legacy)의 인덱스를 온전히 보호하려면, 비교 대상 값을 대용량 테이블의 콜레이션(utf8mb4_unicode_ci)으로 변환시켜 찔러 넣어주어야 합니다.

-- [좋은 예] u.user_code 값을 o.user_code의 콜레이션(unicode_ci)으로 명시적 변환하여 인덱스 보호
EXPLAIN SELECT * 
FROM users_new u
JOIN orders_legacy o ON o.user_code = (u.user_code COLLATE utf8mb4_unicode_ci);
id select_type table type possible_keys key rows Extra
1 SIMPLE u ALL NULL NULL 100 NULL
1 SIMPLE o ref idx_user_code idx_user_code 1 Using index condition
  • 튜닝 결과: u.user_code에서 읽은 100개의 문자열만 메모리에서 utf8mb4_unicode_ci로 평가된 뒤, o.user_code의 인덱스(idx_user_code)를 ref 타입으로 정확히 타격하여 0.01초 만에 쿼리가 종료됩니다.

 

4. 실무 트러블슈팅 및 근본적인 아키텍처 해결 전략

쿼리마다 COLLATE를 붙이는 것은 임시방편일 뿐입니다. 시니어 아키텍트가 권장하는 실무 해결 가이드 3단계를 소개합니다.

전략 1: 이관 및 통합 전 전수 조사 (Collation 파편화 탐지 쿼리)

데이터베이스 내에 조인 시 시한폭탄이 될 수 있는 콜레이션 불일치 컬럼들을 INFORMATION_SCHEMA를 통해 한 번에 색출해야 합니다.

-- 동일 컬럼명을 가지면서 테이블별로 Collation이 다르게 설정된 조인 위험군 검출 쿼리
SELECT 
    COLUMN_NAME,
    GROUP_CONCAT(DISTINCT CONCAT(TABLE_NAME, '(', COLLATION_NAME, ')') SEPARATOR ', ') AS table_collation_list,
    COUNT(DISTINCT COLLATION_NAME) AS collation_cnt
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'your_database_name'
  AND DATA_TYPE IN ('varchar', 'char', 'text')
GROUP BY COLUMN_NAME
HAVING COUNT(DISTINCT COLLATION_NAME) > 1;

전략 2: 스토어드 프로시저/함수(Routine) 생성 시의 숨은 함정 제거

테이블들의 콜레이션을 모두 통일했는데도 배치 프로시저를 돌릴 때만 Illegal mix of collations 에러가 나는 경우가 있습니다. 프로시저나 트리거는 생성될 당시(CREATE 시점)의 세션 collation_connection 설정값을 메타데이터에 영구 박제하기 때문입니다. 과거에 만든 프로시저 내부의 변수(DECLARE v_code VARCHAR(50);)는 구형 콜레이션을 가지고 있어 신규 테이블과 조인 시 충돌합니다.

-- 스토어드 프로시저 내부 변수의 구형 Collation 확인
SELECT ROUTINE_NAME, DATABASE_COLLATION, COLLATION_CONNECTION 
FROM INFORMATION_SCHEMA.ROUTINES 
WHERE ROUTINE_SCHEMA = 'your_database_name';

-- 해결책: 프로시저 내 변수 선언 시 명시적으로 COLLATE 지정 또는 올바른 세션 설정 후 재생성
DECLARE v_user_code VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs;

전략 3: 스키마 변경이 불가능할 때 — 가상 컬럼 함수 기반 인덱스(Functional Index) 활용

만약 외부 솔루션 테이블이거나 레거시 의존성 때문에 대용량 테이블(orders_legacy, utf8mb4_unicode_ci)의 컬럼 콜레이션을 직접 ALTER 할 수 없는데, 신규 테이블(utf8mb4_0900_as_cs 또는 utf8mb4_bin)이 대용량이고 레거시 테이블도 대용량이라 양쪽 모두 인덱스가 필요한 상황이라면 MySQL 8.0의 함수 기반 인덱스( 또는 Generated Column)로 해결할 수 있습니다.

-- 기존 컬럼은 건드리지 않고, 신규 콜레이션으로 형변환된 인덱스를 추가 생성
ALTER TABLE orders_legacy
ADD COLUMN user_code_bin VARCHAR(50) 
    GENERATED ALWAYS AS (user_code COLLATE utf8mb4_bin) VIRTUAL,
ADD INDEX idx_user_code_bin (user_code_bin);

이렇게 구성하면 기존 레거시 쿼리는 원래의 user_code(unicode_ci) 인덱스를 그대로 사용하고, 신규 테이블과의 조인 쿼리는 user_code_bin 인덱스를 타게 되어 무중단으로 완벽한 호환성과 성능을 확보할 수 있습니다.

 


5. 요약

  1. 서로 다른 콜레이션을 가진 컬럼끼리 JOIN 할 때 에러가 나는 이유는, 두 컬럼 모두 Coercibility(강제성 수치)가 2 (IMPLICIT)로 동일하여 MySQL이 우선순위를 결정하지 못하기 때문입니다.
  2. 에러가 나지 않는 _bin과의 조인이나, 쿼리에 무작정 COLLATE를 선언하는 방식은 Driven(후행) 테이블의 인덱스를 깨뜨려 Full Table Scan 장애를 유발합니다.
  3. 불가피하게 쿼리에서 COLLATE를 써야 한다면 반드시 먼저 읽히는 소규모(Driving) 테이블의 값에 대용량(Driven) 테이블의 콜레이션을 명시하여 인덱스를 보호해야 합니다.
  4. 근본적으로는 PK/FK 및 조인 키로 사용되는 코드성 컬럼들의 콜레이션을 전사 표준(예: utf8mb4_0900_as_cs 또는 utf8mb4_bin)으로 통일하는 스키마 거버넌스가 필수적입니다.

 

레거시 시스템(MySQL 5.7 이하 또는 MariaDB)에서 가장 표준적으로 권고되던 콜레이션은 단연 utf8mb4_unicode_ci였습니다. 다국어 정렬의 정확성을 보장하기 위해 많은 기업이 이 콜레이션을 표준으로 채택했죠.

하지만 화학식(H₂O), 수학 기호(x²), 서수 표현(1ª), 혹은 상표권 기호(™) 등이 포함된 데이터를 이관하거나 다룰 때, utf8mb4_unicode_ci 환경에서는 위첨자(Superscript)와 아래첨자(Subscript)가 일반 알파벳이나 숫자로 둔갑하여 심각한 데이터 정합성 오류를 일으킵니다.

이번 포스팅에서는 기존에 사용하던 utf8mb4_unicode_ci가 왜 첨자(Superscript/Subscript)의 차이를 무시하는지 그 내부 아키텍처를 파헤치고, 운영 중인 DB에서 안전하게 첨자를 구분하도록 변경하는 실무 마이그레이션 및 쿼리 튜닝 가이드를 공유합니다.

 


 

1. 문제 재현: utf8mb4_unicode_ci에서 첨자는 어떻게 취급되는가?

기존 환경(utf8mb4_unicode_ci)에서 위첨자 ª(U+00AA), ²(U+00B2) 및 아래첨자 ₁(U+2081) 데이터가 일반 문자 'a', '2', '1'과 어떻게 비교되는지 SQL로 직접 확인해 보겠습니다.

-- 기존 utf8mb4_unicode_ci 환경에서의 첨자 비교 테스트
SELECT 
    'ª' = 'a' COLLATE utf8mb4_unicode_ci AS super_a_eq_a,
    '²' = '2' COLLATE utf8mb4_unicode_ci AS super_2_eq_2,
    '₁' = '1' COLLATE utf8mb4_unicode_ci AS sub_1_eq_1,
    '²' = '₂' COLLATE utf8mb4_unicode_ci AS super_2_eq_sub_2;
super_a_eq_a super_2_eq_2 sub_1_eq_1 super_2_eq_sub_2
1 (True) 1 (True) 1 (True) 1 (True)

충격적이게도 utf8mb4_unicode_ci는 위첨자 ², 아래첨자 ₂, 그리고 일반 숫자 2를 모두 완벽히 똑같은 값으로 판별합니다. 이로 인해 데이터 이관 시 다음과 같은 장애가 발생합니다.

  1. 이관 시 중복 키 에러 (Duplicate Entry): 원본 데이터에서 'TYPE_2', 'TYPE_²', 'TYPE_₂'라는 3개의 서로 다른 상품 코드를 UNIQUE 제약조건이 걸린 타겟 테이블로 이관할 때, 첫 번째 'TYPE_2'만 적재되고 나머지 두 개는 중복 에러를 뱉으며 이관이 중단됩니다.
  2. 집계(GROUP BY) 데이터 증발: SELECT code, COUNT(*) FROM items GROUP BY code; 실행 시, 첨자로 구분된 개별 코드들이 하나의 그룹으로 강제 병합되어 통계 및 정산 데이터가 완전히 틀어집니다.

2. 내부 작동 원리: UCA 4.0.0과 유니코드 호환성 분해(NFKD)

왜 utf8mb4_unicode_ci는 멀쩡한 첨자를 일반 문자/숫자와 똑같이 취급할까요? 그 비밀은 이 콜레이션의 기반이 되는 UCA(Unicode Collation Algorithm) 4.0.0과 가중치 레벨(Weight Level) 구조에 있습니다.

유니코드 표준에서 위첨자(ª, ²)와 아래첨자(₁, ₂)는 기본 문자에 서식 태그(<super>, <sub>)가 결합된 호환성 문자(Compatibility Characters)로 분류됩니다.

  • ª (U+00AA) $\rightarrow$ 호환성 분해(NFKD) $\rightarrow$ <super> + a (U+0061)
  • ² (U+00B2) $\rightarrow$ 호환성 분해(NFKD) $\rightarrow$ <super> + 2 (U+0032)
  • ₂ (U+2082) $\rightarrow$ 호환성 분해(NFKD) $\rightarrow$ <sub> + 2 (U+0032)

UCA 알고리즘은 문자를 비교할 때 3단계의 가중치(Level)를 매깁니다.

가중치 단계 구분 대상 '2' vs '²' vs '₂'에서의 역할 utf8mb4_unicode_ci 반영 여부
Level 1 (Primary) 기본 문자 (Base Character) 모두 기본 숫자인 **'2'**로 동일하게 평가 반영함 (O)
Level 2 (Secondary) 악센트 (Accent/Diacritics) 악센트 없음 (모두 동일) 무시함 (X)
Level 3 (Tertiary) 대소문자 및 첨자 서식(<super>, <sub>) 일반 문자 vs 위첨자 vs 아래첨자 형태를 구분 무시함 (X)

즉, 첨자의 차이는 Level 3(Tertiary Weight) 영역에 기록됩니다. 하지만 utf8mb4_unicode_ci는 이름 끝의 _ci(Case-Insensitive)가 의미하듯 Level 1 가중치만 비교하고 Level 2와 Level 3 가중치는 완전히 잘라버립니다(Truncate).

실제 MySQL 엔진이 인덱스에 저장하는 내부 가중치 바이트를 WEIGHT_STRING()으로 뜯어보면 이를 명확히 확인할 수 있습니다.

-- utf8mb4_unicode_ci가 생성하는 내부 인덱스 정렬/비교 가중치 확인
SELECT 
    HEX(WEIGHT_STRING('2' COLLATE utf8mb4_unicode_ci)) AS weight_normal_2,
    HEX(WEIGHT_STRING('²' COLLATE utf8mb4_unicode_ci)) AS weight_super_2,
    HEX(WEIGHT_STRING('₂' COLLATE utf8mb4_unicode_ci)) AS weight_sub_2,
    HEX(WEIGHT_STRING('a' COLLATE utf8mb4_unicode_ci)) AS weight_normal_a,
    HEX(WEIGHT_STRING('ª' COLLATE utf8mb4_unicode_ci)) AS weight_super_a;
weight_normal_2 weight_super_2 weight_sub_2 weight_normal_a weight_super_a
0E2B 0E2B 0E2B 0E33 0E33

InnoDB의 B-Tree 인덱스는 원본 문자열이 아닌 이 가중치(0x0E2B, 0x0E33)를 기준으로 정렬하고 중복을 체크합니다. 따라서 DB 엔진 입장에서는 '2', '²', '₂'가 완벽히 동일한 값(0x0E2B)으로 보이는 것입니다.

3. 아키텍처 해결 방안: 어떤 Collation으로 전환해야 하는가?

기존에 utf8mb4_unicode_ci를 사용 중이었다면, MySQL 버전에 따라 첨자 차이를 정확히 인식하도록 다음 두 가지 콜레이션 중 하나로 전환해야 합니다.

구분 기존 환경 해결책 A (MySQL 5.7 / 8.0 공통) 해결책 B (MySQL 8.0 이상 권장)
Collation 명칭 utf8mb4_unicode_ci utf8mb4_bin utf8mb4_0900_as_cs
기반 알고리즘 UCA 4.0.0 (Level 1만 비교) Binary (UTF-8 코드포인트 직접 비교) UCA 9.0.0 (Level 1 + 2 + 3 모두 비교)
첨자 구분 ('²' vs '2') 구분 불가 (동일 취급) 완벽 구분 (0xC2B2 vs 0x32) 완벽 구분 (Level 3 가중치 반영)
정렬(ORDER BY) 특성 사전순 정렬 바이트 코드순 정렬 ('Z'가 'a'보다 앞섬) 자연스러운 다국어 사전순 정렬 유지
  • MySQL 5.7 이하 환경: 안타깝게도 utf8mb4_unicode_cs(Case-Sensitive)라는 콜레이션은 MySQL에 존재하지 않습니다. 따라서 5.7 버전에서는 utf8mb4_bin이 유일하고도 가장 확실한 해법입니다.
  • MySQL 8.0 이상 환경: 기존 unicode_ci가 주던 사전순 정렬의 이점을 유지하면서 첨자와 대소문자만 엄격히 분리하고 싶다면, Level 3 가중치까지 모두 비교하는 utf8mb4_0900_as_cs로 변경하는 것이 베스트 프랙티스입니다.

4. 실무 트러블슈팅 및 마이그레이션 팁

이미 운영 중인 utf8mb4_unicode_ci 테이블에서 첨자 문제를 해결하기 위한 실무 절차와 튜닝 팁입니다.

① 기존 테이블에서 첨자로 인해 뭉개진(충돌 잠재) 데이터 검출 쿼리

컬럼의 콜레이션을 변경하기 전, 현재 테이블에 일반 문자와 첨자 문자가 혼재되어 있는지 먼저 파악해야 합니다. WEIGHT_STRING은 같지만 실제 BINARY(바이트) 값은 다른 로우를 찾으면 됩니다.

-- utf8mb4_unicode_ci 기준으로는 같지만, 실제 바이트(첨자 여부 등)가 다른 데이터 검출
SELECT 
    a.id AS id_1, a.target_col AS val_1, HEX(a.target_col) AS hex_1,
    b.id AS id_2, b.target_col AS val_2, HEX(b.target_col) AS hex_2
FROM my_table a
JOIN my_table b 
  ON a.target_col = b.target_col          -- 1. 기존 unicode_ci 인덱스를 활용한 동일 가중치 조인
 AND BINARY a.target_col <> BINARY b.target_col -- 2. 실제 바이트(첨자/대소문자)가 다른 건만 필터링
 AND a.id < b.id;

② 컬럼 Collation 변경 시 Online DDL 제약사항 주의 (중요!)

운영 환경에서 ALTER TABLE로 컬럼의 콜레이션을 변경할 때, 해당 컬럼에 인덱스(Index)가 걸려 있다면 INSTANT나 INPLACE 알고리즘이 불가능할 수 있습니다.

콜레이션이 바뀌면 B-Tree 인덱스의 정렬 순서(Weight)가 완전히 달라지므로, MySQL은 인덱스를 처음부터 다시 빌드(Rebuild)해야 합니다. 대용량 테이블에서는 테이블 락(Metadata Lock)이나 디스크 I/O 폭증을 유발할 수 있으므로 반드시 ALGORITHM=COPY 또는 pt-online-schema-change, gh-ost 같은 무중단 스키마 변경 도구를 사용해야 합니다.

-- 대용량 운영 테이블의 안전한 Collation 변경 예시 (MySQL 8.0 기준)
ALTER TABLE my_table 
MODIFY COLUMN item_code VARCHAR(100) 
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL,
ALGORITHM=COPY, LOCK=SHARED; -- 읽기 허용, 대규모 환경에서는 gh-ost 권장

③ 실행 계획(Execution Plan)을 망치지 않는 임시 조회 쿼리 튜닝

당장 스키마(utf8mb4_unicode_ci)를 변경할 수 없는 상태에서 이관 프로그램이나 배치 쿼리가 정확히 위첨자 '²'가 포함된 데이터만 조회·업데이트해야 한다면, Index Range Scan + Binary Filter(2단계 필터링) 기법을 사용해야 합니다.

-- [나쁜 예] 컬럼에 직접 COLLATE를 걸면 인덱스 사용 불가 (type: ALL, Full Table Scan)
EXPLAIN SELECT * FROM chemical_formulas 
WHERE formula COLLATE utf8mb4_bin = 'H₂O';

-- [좋은 예] 기존 utf8mb4_unicode_ci 인덱스를 100% 활용한 뒤 첨자만 정밀 타격 (type: ref)
EXPLAIN SELECT * FROM chemical_formulas 
WHERE formula = 'H₂O'               -- Step 1: Index Lookup ('H2O', 'H²O', 'H₂O' 모두 추출)
  AND formula COLLATE utf8mb4_bin = 'H₂O'; -- Step 2: 추출된 소수의 건만 메모리에서 바이트 비교
id select_type table type possible_keys key rows filtered Extra
1 SIMPLE chemical_formulas ref idx_formula idx_formula 3 33.33 Using index condition; Using where
  • 실행 계획 해설: 첫 번째 조건(formula = 'H₂O')이 idx_formula 인덱스를 정상적으로 타서 수백만 건 중 가중치가 같은 3건(H2O, H²O, H₂O)만 디스크에서 읽어옵니다(rows: 3). 이후 두 번째 조건이 CPU 메모리 상에서 아래첨자 ₂가 정확히 일치하는 1건만 걸러내므로 성능 저하 없이 완벽한 정합성을 보장합니다.

 


 

5. 요약

  1. 기존에 사용하던 utf8mb4_unicode_ci는 UCA 알고리즘의 Level 3(Tertiary) 가중치를 무시하기 때문에, 위첨자(ª, ²)와 아래첨자(₁, ₂)를 일반 문자/숫자와 100% 동일한 값으로 처리합니다.
  2. 첨자 차이를 명확히 보존하며 데이터를 이관하려면, MySQL 5.7에서는 utf8mb4_bin으로, MySQL 8.0에서는 utf8mb4_0900_as_cs(또는 utf8mb4_bin)로 타겟 컬럼의 콜레이션을 변경해야 합니다.
  3. 콜레이션 변경은 인덱스 재구성을 수반하는 무거운 작업이므로, 변경 전이거나 스키마 수정이 불가능할 때는 WHERE col = '값' AND col COLLATE utf8mb4_bin = '값' 패턴으로 인덱스와 정합성을 동시에 챙기시기 바랍니다.

 

3. 태그 (Tags)

MySQL, utf8mb4_unicode_ci, Collation, 콜레이션, 위첨자, 아래첨자, 특수문자이관, 데이터이관, utf8mb4_bin, utf8mb4_0900_as_cs, UCA알고리즘, WEIGHT_STRING, 실행계획튜닝, DB마이그레이션, 트러블슈팅

지난 포스팅에서 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)

  1. utf8mb4_general_ci는 절대 대안이 아닙니다. ª와 ²는 우연히 피해 가지만, ä, é, ß 등 수많은 다국어 및 특수기호를 여전히 일반 알파벳으로 변질시키는 구형 비표준 콜레이션입니다.
  2. MySQL 8.0 이상에서 general_ci는 공식적으로 Deprecated(사용 중지 권고) 수순을 밟고 있는 레거시 기술이며, 타 테이블과 조인 시 심각한 콜레이션 충돌을 일으킵니다.
  3. 데이터 이관 시 문자 변질과 중복 키 오류를 막기 위한 유일무이한 정석은 utf8mb4_bin 또는 utf8mb4_0900_as_cs를 적용하는 것입니다.

 

+ Recent posts