문자는 사람이 읽는 기호이고, 코드 포인트는 유니코드가 문자마다 매긴 번호입니다. 예를 들어 한의 코드 포인트는 U+D55C입니다. 인코딩은 이 코드 포인트를 실제 바이트로 바꾸는 규칙이며, 같은 문자도 인코딩에 따라 바이트가 완전히 달라집니다.
자바의 String은 메모리에서 UTF-16 코드 유닛으로 문자를 들고 있습니다. 파일이나 네트워크로 내보낼 때 비로소 getBytes(Charset)으로 특정 인코딩의 바이트가 됩니다. "인코딩이 깨진다"는 말은 이 변환·역변환 두 지점 중 한쪽에서 잘못된 Charset을 썼다는 뜻입니다.
세 인코딩은 한글을 담는 방식이 다릅니다. 실무에서 마주치는 차이는 아래 표와 같습니다.
| 인코딩 | 한글 바이트 수 | 완성형 한글 | 확장 한글(똠·펲 등) |
|---|---|---|---|
| UTF-8 | 3바이트 | 지원 | 지원 |
| EUC-KR | 2바이트 | 2350자만 지원 | 미지원 |
| CP949(MS949) | 2바이트 | 지원 | 지원(확장 영역) |
EUC-KR 은 옛 KS X 1001 완성형 2350자만 담을 수 있어 "똠, 뷁" 같은 조합형 확장 한글을 표현하지 못합니다. 윈도우가 쓰던 CP949(MS949)는 EUC-KR 을 그대로 포함하면서 남는 바이트 영역에 확장 한글을 추가한 규격입니다. 자바 Charset.forName("EUC-KR")과 "MS949"는 이 차이 그대로 동작하므로, 레거시 연동에서는 반드시 어느 쪽인지 확인해야 합니다.
BOM(Byte Order Mark)은 파일 맨 앞에 붙는 EF BB BF(UTF-8 기준) 바이트로, 이 파일이 어떤 인코딩·바이트 순서인지 알려주는 표식입니다. UTF-8 은 바이트 순서 문제가 없어 원래 BOM 이 필요 없지만, 관행적으로 Windows 프로그램이 UTF-8 임을 표시하려고 붙입니다.
Excel 은 BOM 이 없는 UTF-8 CSV 를 EUC-KR 로 오인해 한글을 깨뜨립니다. 그래서 Excel 로 열 CSV 를 만들 때는 일부러 BOM 을 붙입니다. 반대로 이 CSV 를 자바로 다시 읽을 때 BOM 을 제거하지 않으면, 첫 줄 첫 글자 앞에 보이지 않는 U+FEFF 문자가 남아 문자열 비교가 실패합니다.
JDK 17 이하는 OS 로케일에 따라 file.encoding 기본값이 MS949나 EUC-KR이 되는 경우가 있었습니다. JDK 18 부터는 JEP 400 으로 file.encoding 기본값이 플랫폼과 무관하게 UTF-8 로 고정됐습니다. 이 레슨은 JDK 21 기준이라 별도 옵션 없이도 UTF-8 이 기본입니다.
콘솔 출력은 별도로 stdout.encoding이 OS 코드페이지를 따르는 경우가 있어, Windows 콘솔에서 한글이 깨지면 -Dstdout.encoding=UTF-8을 주거나 chcp 65001로 콘솔 코드페이지를 바꿉니다. -Dfile.encoding=UTF-8은 파일 입출력 기본값, -Dstdout.encoding=UTF-8은 콘솔 출력 전용이라 용도가 다릅니다.
증상만 보고도 원인을 좁힐 수 있습니다.
| 증상 | 원인 |
|---|---|
??? |
인코딩 불가 문자를 물음표로 치환(손실 발생) |
�(네모, U+FFFD) |
잘못된 바이트를 대체 문자로 치환 |
占쏙옙 |
UTF-8 바이트를 EUC-KR/CP949 로 오인 디코딩 |
ë° 같은 라틴 조합 |
UTF-8 바이트를 ISO-8859-1 로 오인 디코딩 |
???는 이미 손실이 일어난 상태라 원본 복구가 불가능합니다. 반면 占쏙옙이나 ë° 패턴은 원본 바이트가 그대로 남아있을 가능성이 높아, 올바른 인코딩으로 다시 디코딩하면 복구되는 경우가 많습니다.
자바 String.length()는 UTF-16 코드 유닛 개수이지, 저장될 바이트 수가 아닙니다. 고정 길이 전문(EDI, 은행권 배치)이나 VARCHAR2(100 BYTE)처럼 바이트 기준 컬럼에 한글을 넣으면, 글자 수 기준으로 자를 때 한글 중간에서 잘려 마지막 글자가 깨집니다.
Oracle VARCHAR2는 BYTE와 CHAR 두 가지 길이 시맨틱을 지원합니다. VARCHAR2(100 CHAR)는 글자 수 100자를 보장하지만, VARCHAR2(100 BYTE)(기본값)는 바이트 100을 보장해 한글이 섞이면 실제 저장 가능한 글자 수가 줄어듭니다. NLS_CHARACTERSET이 AL32UTF8이면 한글 1자가 3바이트를 쓰므로 여유를 넉넉히 잡아야 합니다.
유니코드는 같은 글자를 완성형(NFC, 한 한 코드 포인트)과 분해형(NFD, ㅎ+ㅏ+ㄴ 자모 조합)으로 모두 표현할 수 있습니다. macOS 는 파일명을 NFD 로 저장하는 관행이 있어, 맥에서 만든 zip·파일을 Windows·Linux 서버로 옮기면 화면에는 같은 글자로 보여도 바이트가 달라 파일을 못 찾는 문제가 생깁니다.
java.text.Normalizer로 Form.NFC·Form.NFD 간 변환을 할 수 있습니다. 외부에서 들어온 파일명은 저장 전에 NFC 로 정규화해두는 것이 안전합니다.
HTTP 다운로드 응답의 Content-Disposition 헤더는 기본적으로 ASCII 만 허용합니다. 한글 파일명은 RFC 5987 형식인 filename*=UTF-8''인코딩된값으로 넣고, 구형 브라우저를 위해 ASCII 대체 filename="download.xlsx"도 함께 둡니다.
ZIP 파일 포맷은 표준 항목 이름 인코딩을 CP437 로 정의하고 있어, 한글 파일명을 담은 zip 을 압축 프로그램마다 다르게 해석해 깨지는 경우가 흔합니다. 자바 ZipOutputStream은 UTF-8 로 이름을 쓰고 UTF-8 플래그(APPNOTE 0x0800)를 세팅하는 라이브러리(Apache Commons Compress 등)를 쓰면 대부분 호환됩니다.
레거시 EUC-KR 시스템과 연동할 때는 "경계에서만 변환"이 원칙입니다. 애플리케이션 내부는 UTF-8(자바 String)로 통일하고, 레거시로 나가는 전문·파일만 그 순간에 EUC-KR 로 인코딩합니다. 중간에 여러 번 인코딩을 바꾸면 어디선가 반드시 깨집니다.
Spring Boot 관련 설정 키는 아래만 기억해도 충분합니다.
| 설정 키 | 역할 |
|---|---|
server.servlet.encoding.charset |
HTTP 요청·응답 기본 인코딩 |
spring.datasource.url 의 characterEncoding=UTF-8 |
JDBC 연결 인코딩(MySQL 계열) |
file.encoding VM 옵션 |
파일 입출력 기본 인코딩(JDK 18+ 는 UTF-8 기본) |