MySQL 의 utf8 은 UTF-8 이 아니다

닉네임에 이모지를 넣었더니 500 에러가 났다. 인코딩은 분명 utf8 로 맞췄는데도. MySQL 의 utf8 은 이름만 UTF-8 이고, 문자 하나에 3바이트까지만 담는다.

사용자가 닉네임에 이모지를 넣자 500 이 떨어졌다. 로그에는 Incorrect string value: '\xF0\x9F\x98\x80'. 인코딩은 분명 utf8 로 맞춰 놨는데도.

이보다 더 고약한 경우도 있다. 에러 없이 저장은 되는데, 다시 읽어 보면 이모지 자리가 물음표나 빈칸으로 바뀌어 있다. 차라리 에러가 나는 게 낫다. 이쪽은 데이터가 이미 사라진 뒤라 되돌릴 방법이 없다.

한국어 서비스니까 이모지만 조심하면 될까? 그렇지 않다. 확장 한자와 일부 고문자도 똑같은 자리에서 깨진다. 이름에 쓰는 한자 중에도 4바이트 영역에 있는 글자가 있어서, 회원 가입에서 특정 사용자만 계속 실패하는 모습으로 나타나기도 한다.

utf8 이라는 이름의 3바이트

MySQL 의 utf8 은 문자당 최대 3바이트만 담는 반쪽짜리다. 정식 이름은 utf8mb3 이고 utf8 은 그 별칭이었다. U+FFFF 를 넘는 문자는 3바이트에 안 들어간다. 이모지는 전부, 보충 한자도 상당수가 여기 걸린다. 진짜 UTF-8 은 utf8mb4 다.

왜 이렇게 됐을까. 역사가 있다. MySQL 이 UTF-8 을 넣던 무렵엔 유니코드가 3바이트면 충분해 보였고, 인덱스 키 길이를 아끼려고 3바이트로 못을 박았다. 나중에 4바이트 문자가 필요해졌을 땐 utf8 이라는 이름이 이미 너무 널리 쓰이고 있었다. 그래서 이름을 고치는 대신 utf8mb4 를 새로 만들었다.

실무에서 골치 아픈 건 깨지는 곳이 한 군데가 아니라는 점이다. 문자셋은 서버, 데이터베이스, 테이블, 컬럼, 커넥션 다섯 곳에서 따로따로 정해진다. 이 중 하나라도 utf8mb3 이면 거기서 잘린다. 테이블을 전부 utf8mb4 로 바꿨는데도 안 된다면 대개 커넥션이 범인이다. 드라이버가 접속하면서 SET NAMES utf8 을 보내고 있다.

그래서 환경마다 증상이 다르다. 로컬에선 되는데 운영에서만 깨지는 일이 흔하다.

다섯 곳을 하나씩 확인하기

다섯 곳을 다 봐야 한다:

코드
· 서버·커넥션이 무엇을 쓰고 있나
SHOW VARIABLES LIKE 'character_set%';
 character_set_server = utf8mb3
 character_set_client = utf8mb3   ← 커넥션이 범인인 경우가 많다

 ALTER DATABASE app
 CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

 ALTER TABLE users CONVERT TO
 CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

· 커넥션 — JDBC 는 URL 이 아니라 서버 기본값을 따르게 두는 편이 낫다
 jdbc:mysql://host/app?connectionCollation=utf8mb4_0900_ai_ci

✕ 문제 지점 · ✓ 해법 · 그대로 돌리기 전에 자기 환경에 맞게 고친다.

VARCHAR(255) 를 191 로 줄이기 전에

전환할 때 제일 자주 거론되는 함정은 인덱스 키 길이다. 흔히 보는 처방이 "VARCHAR(255) 를 191 로 줄여라"인데, 이건 조건이 붙어야 맞는 말이다.

InnoDB 의 인덱스 접두사 한계는 행 형식마다 다르다. 옛 COMPACT 나 REDUNDANT 형식에서는 767바이트라, utf8mb4 기준으로 191자에서 막힌다(191 × 4 = 764). DYNAMIC 이나 COMPRESSED 형식에서는 3072바이트라 VARCHAR(255) 가 그대로 들어간다(255 × 4 = 1020). 그리고 MySQL 5.7.9 부터 기본 행 형식이 DYNAMIC 이다.

191 은 만능 처방이 아니다. 옛 행 형식을 피해 가는 우회일 뿐이다. 마이그레이션이 Specified key was too long 에서 멈췄다면 컬럼을 줄이기 전에 ROW_FORMAT 부터 보면 된다. 191자를 넘긴 데이터가 이미 있으면 컬럼 길이를 줄이는 순간 잘려 나간다.

용량도 따져 봐야 한다. utf8mb4 는 문자당 최대 4바이트라 인덱스가 커지고 정렬 버퍼도 더 먹는다. 인덱스가 여러 개 걸린 VARCHAR(255) 컬럼이 있는 큰 테이블이라면, 전환 뒤 디스크가 눈에 띄게 는다.

하나 더 있다. CONVERT TO CHARACTER SET 은 테이블을 통째로 다시 쓴다. 큰 테이블이면 그동안 락이 잡혀 있다. 온라인 DDL 이나 pt-online-schema-change 같은 도구를 써야 하는데, 그건 따로 길게 얘기할 거리다.

전환할 때 지킬 순서

  1. 새 프로젝트라면 고민할 것 없이 utf8mb4 로 시작한다. MySQL 8.0 은 기본값이 utf8mb4 다. 5.7 이하라면 my.cnf 에서 서버 기본값부터 바꿔 두자.
  2. 전환할 땐 서버, DB, 테이블, 컬럼, 커넥션 다섯 곳을 모두 봐야 한다. 하나라도 빠지면 거기서 잘린다. 남은 곳은 information_schema 로 기계적으로 찾을 수 있다(아래 점검).
  3. 마이그레이션 전에 ROW_FORMAT 부터 확인한다. DYNAMIC 이면 VARCHAR(255) 를 줄일 필요가 없다. 191 로 줄이는 건 마지막 수단이고, 그 전에 기존 데이터의 최대 길이부터 세어 봐야 한다.
  4. 콜레이션은 알고 골라야 한다. MySQL 8.0 기본값은 utf8mb4_0900_ai_ci 로 유니코드 9.0 기준이다. 5.7 에서 올라온 DB 라면 utf8mb4_general_ci 가 남아 있을 수 있는데, 이게 남아 있으면 이모지 비교가 깨진다.
  5. DB 가 4바이트를 받아 준다고 모든 경로가 안전하진 않다. 검색 엔진도, 캐시도, 로그 수집기도 각자 인코딩을 따로 갖고 있다. 애플리케이션 쪽 검증도 같이 두는 게 맞다.

아직 utf8mb3 로 남은 곳 찾기

빠진 곳을 기계적으로 찾는다:

코드
· 아직 utf8mb3 인 컬럼
SELECT table_name, column_name, character_set_name,
       collation_name
  FROM information_schema.columns
 WHERE table_schema = DATABASE()
 AND character_set_name LIKE 'utf8mb3%';

· 콜레이션이 섞여 있지는 않은지
SELECT collation_name, count(*)
  FROM information_schema.columns
 WHERE table_schema = DATABASE()
   AND character_set_name IS NOT NULL
 GROUP BY collation_name;
 general_ci 가 남아 있으면 이모지 비교가 깨진다

· 행 형식 — 191 로 줄이기 전에 이걸 먼저 본다
SELECT name, row_format FROM information_schema.innodb_tables
 WHERE name LIKE CONCAT(DATABASE(), '/%');

· 실제로 4바이트 문자가 들어 있는 행 세기
 SELECT count(*) FROM users WHERE nickname <> CONVERT(nickname USING utf8mb3);

핵심 한 줄utf8 은 함정이다. 새 프로젝트는 무조건 utf8mb4.

자주 묻는 것

MySQL utf8 과 utf8mb4 는 뭐가 다를까?

MySQL 의 utf8 은 utf8mb3 의 별칭이라 문자당 최대 3바이트만 담는다. 이모지처럼 U+FFFF 를 넘는 문자는 못 넣는다. 진짜 UTF-8 은 utf8mb4 다.

utf8mb4 로 바꿀 때 VARCHAR(255) 를 191 로 줄여야 할까?

DYNAMIC·COMPRESSED 행 형식이면 인덱스 접두사 한계가 3072바이트라 VARCHAR(255) 가 그대로 들어간다. 191 은 767바이트 한계인 옛 COMPACT·REDUNDANT 형식을 피하는 우회다. 줄이기 전에 ROW_FORMAT 부터 확인하면 된다.

브루니가 적는다

색을 바꾸며 숨는 카멜레온이다. 운영에서 실제로 터지는 문제를 한 편씩 적는다. 같은 실수를 두 번 하지 않으려고 쓰는 기록이다.

트랜잭션·락·DB 운영 글 21편 모두 보기 →