인코딩과 문자셋 — UTF-8, Unicode, 그리고 한글 깨짐의 원리
콘솔에 한글이 깨져서 나올 때, 도대체 어디서부터 봐야 할까? "문ìžì—´" 같은 의문의 문자가 화면에 뜨면 당황스럽다. 분명 내 코드에는 "문자열"이라고 적었는데 말이다. 이 문제를 제대로 해결하려면 문자 인코딩이라는 개념부터 잡아야 한다. 이번 글에서는 ASCII부터 Unicode, UTF-8까지 인코딩의 역사를 훑고, 자바에서 문자를 어떻게 다루는지, 그리고 한글 깨짐을 어떻게 디버깅하는지까지 정리한다.
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
1. 문자 인코딩이 왜 필요한가 — 컴퓨터는 숫자만 안다
핵심 한 줄
컴퓨터는 0과 1(바이트)만 이해하므로, 사람이 읽는 문자를 숫자로 변환하는 약속이 필요하다 — 그게 인코딩이다.
인코딩이란
- 인코딩(Encoding): 문자 → 바이트(숫자) 변환 규칙
- 디코딩(Decoding): 바이트(숫자) → 문자 변환 규칙
"가"라는 글자를 파일에 저장한다고 하자. 컴퓨터 입장에서 "가"는 의미가 없다. "가"를 어떤 숫자로 표현할지 정해야 저장할 수 있고, 그 숫자를 다시 "가"로 돌려놓을 수 있다.
문자 "가" ──인코딩──▸ 바이트 [0xEA, 0xB0, 0x80] (UTF-8 기준)
바이트 [0xEA, 0xB0, 0x80] ──디코딩──▸ 문자 "가"
인코딩과 디코딩에 같은 규칙을 사용해야 원래 문자가 복원된다. 다른 규칙을 쓰면? 그게 바로 글자가 깨지는 것이다.
2. ASCII — 모든 것의 시작, 7비트의 한계
핵심 한 줄
ASCII는 영문 알파벳·숫자·특수문자를 **7비트(128개)**로 표현하는 최초의 표준 인코딩이다.
ASCII 코드 구조
| 범위 | 내용 | 예시 |
|---|---|---|
| 0~31 | 제어 문자 | \n(10), \t(9) |
| 32~47 | 특수 문자 | 공백(32), !(33) |
| 48~57 | 숫자 | 0(48), 9(57) |
| 65~90 | 대문자 | A(65), Z(90) |
| 97~122 | 소문자 | a(97), z(122) |
// ASCII 값 확인해보기
char ch = 'A';
System.out.println((int) ch); // 65 — 'A'의 ASCII 코드
// 대문자 → 소문자 변환 원리
char lower = (char) (ch + 32); // 'a' — 대소문자 차이는 32
7비트의 한계
7비트면 0~127, 총 128개의 문자만 표현할 수 있다. 영어와 기본 특수문자에는 충분하지만, 한글, 한자, 일본어, 아랍어 같은 문자는 전혀 담을 수 없다. 세상에는 영어만 있는 게 아닌데 말이다.
이 한계를 극복하기 위해 각 나라마다 자기들만의 인코딩을 만들기 시작했다. 8번째 비트(128~255)를 활용하거나, 아예 2바이트를 쓰는 방식으로 확장했다.
3. 한글 인코딩 — EUC-KR, CP949, 그리고 조합형 vs 완성형
핵심 한 줄
한글 인코딩은 조합형(자모 조합)과 완성형(완성된 글자에 코드 부여) 두 갈래로 발전했고, 실무에서 주로 만나는 것은 완성형 계열의 EUC-KR과 CP949이다.
조합형 vs 완성형
| 방식 | 원리 | 장점 | 단점 |
|---|---|---|---|
| 조합형 | 초성·중성·종성 각각에 코드 할당 | 모든 한글 조합 가능 (11,172자) | 구현이 복잡 |
| 완성형 | "가", "나" 등 완성된 글자에 코드 할당 | 구현이 단순 | 표현 못하는 글자 존재 |
한글은 자모 조합으로 만들어지므로 이론적으로 11,172자가 가능하다. 초기 완성형(KS X 1001)은 이 중 2,350자만 수록해서, "똠"이나 "뷁" 같은 글자를 표현할 수 없었다. 이게 그 유명한 "완성형 한글의 한계"다.
EUC-KR
- KS X 1001 기반, 2,350자 한글 + 한자 + 특수문자
- 한글 한 글자는 2바이트
- ASCII 영역(1바이트)과 호환
- 문제: "똠방각하"의 "똠"을 표현할 수 없음
CP949 (= MS949)
- 마이크로소프트가 EUC-KR을 확장한 인코딩
- 11,172자 한글 모두 표현 가능
- EUC-KR의 상위 호환
- 윈도우의 기본 한글 인코딩으로 널리 사용됨
// EUC-KR과 CP949의 차이 확인
import java.nio.charset.Charset;
String text = "똠"; // EUC-KR에 없는 글자
// EUC-KR로 변환하면 '?'로 바뀜 (표현 불가)
byte[] eucKr = text.getBytes(Charset.forName("EUC-KR"));
System.out.println(new String(eucKr, Charset.forName("EUC-KR"))); // ?
// CP949(MS949)로 변환하면 정상
byte[] cp949 = text.getBytes(Charset.forName("x-windows-949"));
System.out.println(new String(cp949, Charset.forName("x-windows-949"))); // 똠
면접에서 "한글 인코딩 문제를 만난 경험이 있나요?"라는 질문이 나오면, EUC-KR과 UTF-8의 불일치 사례를 들면서 원인과 해결법을 설명하면 좋다.
4. Unicode — 전 세계 문자를 하나의 코드 포인트로
핵심 한 줄
Unicode는 전 세계 모든 문자에 고유한 **코드 포인트(Code Point)**를 부여하는 문자 집합(Character Set) 표준이다.
Unicode가 해결하는 문제
각 나라마다 독자적인 인코딩을 만들다 보니 혼란이 생겼다. 한국은 EUC-KR, 일본은 Shift_JIS, 중국은 GB2312… 서로 다른 인코딩을 쓰면 당연히 깨진다. "하나의 표준으로 전 세계 문자를 통일하자" — 이게 Unicode의 목표다.
코드 포인트
Unicode는 각 문자에 U+XXXX 형식의 고유 번호를 부여한다.
| 문자 | 코드 포인트 | 설명 |
|---|---|---|
| A | U+0041 | 라틴 대문자 A |
| 가 | U+AC00 | 한글 첫 번째 글자 |
| 한 | U+D55C | 한글 "한" |
| 😀 | U+1F600 | 이모지 |
// 코드 포인트 확인하기
String text = "가";
int codePoint = text.codePointAt(0);
System.out.println(Integer.toHexString(codePoint)); // ac00
System.out.printf("U+%04X%n", codePoint); // U+AC00
Unicode의 평면(Plane) 구조
Unicode는 총 17개 평면으로 나뉜다. 가장 중요한 것은 **BMP(Plane 0, U+0000U+FFFF)**로 한글·한자·라틴 등 자주 쓰는 문자가 여기에 있다. 이모지 같은 보충 문자는 **SMP(Plane 1, U+10000U+1FFFF)**에 해당한다.
중요한 포인트: Unicode 자체는 인코딩이 아니다. "이 문자의 번호는 U+AC00이다"라고 정의하는 것이지, "U+AC00을 바이트로 어떻게 저장할 것인가"는 별도의 문제다. 그 저장 방식이 바로 UTF-8, UTF-16, UTF-32다.
5. UTF-8, UTF-16, UTF-32 — 인코딩 방식의 차이
핵심 한 줄
UTF-8/16/32는 Unicode 코드 포인트를 실제 바이트로 변환하는 방식이며, 가변/고정 길이와 바이트 수에서 차이가 있다.
UTF-8
- 가변 길이: 1~4바이트
- ASCII 호환 — 영문은 1바이트, 그래서 기존 영문 텍스트와 호환성이 좋다
- 웹 표준 — 전 세계 웹 페이지의 98% 이상이 UTF-8을 사용
| 코드 포인트 범위 | 바이트 수 | 바이트 패턴 |
|---|---|---|
| U+0000 ~ U+007F | 1바이트 | 0xxxxxxx |
| U+0080 ~ U+07FF | 2바이트 | 110xxxxx 10xxxxxx |
| U+0800 ~ U+FFFF | 3바이트 | 1110xxxx 10xxxxxx 10xxxxxx |
| U+10000 ~ U+10FFFF | 4바이트 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
// "가"(U+AC00)를 UTF-8로 인코딩하면 3바이트
byte[] utf8 = "가".getBytes("UTF-8");
for (byte b : utf8) {
System.out.printf("%02X ", b); // EA B0 80
}
// 3바이트: 0xEA = 11101010, 0xB0 = 10110000, 0x80 = 10000000
UTF-16
- 가변 길이: 2바이트 또는 4바이트
- BMP 문자(U+0000~U+FFFF)는 2바이트
- 보충 문자(U+10000 이상)는 서로게이트 페어(Surrogate Pair) — 4바이트
- 자바의 내부 문자 처리가 바로 UTF-16이다
// 이모지는 서로게이트 페어로 표현된다
String emoji = "😀"; // U+1F600
System.out.println(emoji.length()); // 2 — char 2개 (서로게이트 페어)
System.out.println(emoji.codePointCount(0, emoji.length())); // 1 — 실제 문자 1개
char high = emoji.charAt(0); // 상위 서로게이트
char low = emoji.charAt(1); // 하위 서로게이트
System.out.printf("High: U+%04X, Low: U+%04X%n", (int) high, (int) low);
// High: U+D83D, Low: U+DE00
UTF-32
- 고정 길이: 항상 4바이트
- 구현이 가장 단순하지만 공간 낭비가 심함
- 실무에서는 거의 안 쓴다
인코딩별 바이트 수 비교
| 문자 | 코드 포인트 | UTF-8 | UTF-16 | UTF-32 |
|---|---|---|---|---|
| A | U+0041 | 1바이트 | 2바이트 | 4바이트 |
| © | U+00A9 | 2바이트 | 2바이트 | 4바이트 |
| 가 | U+AC00 | 3바이트 | 2바이트 | 4바이트 |
| 😀 | U+1F600 | 4바이트 | 4바이트 | 4바이트 |
한글 중심의 텍스트라면 UTF-16이 UTF-8보다 크기가 작을 수 있다(2바이트 vs 3바이트). 하지만 웹 환경에서는 호환성과 표준 때문에 UTF-8을 쓰는 게 일반적이다.
6. BOM (Byte Order Mark) — 왜 존재하고 언제 문제가 되는가
핵심 한 줄
BOM은 파일의 **바이트 순서(Endianness)**와 인코딩 방식을 알려주는 특수 마커인데, UTF-8에서는 불필요하며 오히려 문제를 일으킨다.
BOM이란
멀티바이트 인코딩(UTF-16, UTF-32)에서는 바이트 순서가 중요하다. 예를 들어 U+FEFF를 UTF-16으로 저장할 때:
- Big Endian (BE):
FE FF— 큰 바이트가 먼저 - Little Endian (LE):
FF FE— 작은 바이트가 먼저
파일 맨 앞에 이 마커를 넣어서 "이 파일은 BE/LE 중 어느 방식인지" 알려주는 것이다.
| 인코딩 | BOM 바이트 |
|---|---|
| UTF-8 | EF BB BF |
| UTF-16 BE | FE FF |
| UTF-16 LE | FF FE |
| UTF-32 BE | 00 00 FE FF |
| UTF-32 LE | FF FE 00 00 |
UTF-8에서 BOM이 문제가 되는 이유
UTF-8은 바이트 순서가 의미 없다(1바이트씩 순차적으로 읽으니까). 그런데 윈도우의 메모장(Notepad)이 UTF-8 파일을 저장할 때 BOM(EF BB BF)을 앞에 붙이는 경우가 있다.
// BOM이 포함된 파일을 읽을 때 문제
// 파일 첫 줄: "hello" → 실제 바이트: EF BB BF 68 65 6C 6C 6F
BufferedReader reader = new BufferedReader(
new InputStreamReader(new FileInputStream("bom_file.txt"), "UTF-8")
);
String firstLine = reader.readLine();
System.out.println(firstLine.length()); // 6 — "hello"는 5글자인데 BOM 때문에 6
System.out.println(firstLine.equals("hello")); // false!
// BOM 제거 방법
if (firstLine.charAt(0) == '\uFEFF') {
firstLine = firstLine.substring(1);
}
이런 BOM 때문에 JSON 파싱이 실패하거나, CSV 파일의 첫 번째 컬럼을 못 읽거나, 셸 스크립트가 실행이 안 되는 문제가 발생한다. UTF-8 파일은 BOM 없이 저장하는 것이 권장된다.
7. 자바의 문자 처리 — char는 UTF-16, String은 내부적으로 byte[]
핵심 한 줄
자바의 char는 UTF-16 코드 유닛(2바이트)이고, Java 9부터 String 내부는 Compact Strings로 바뀌어 Latin-1 문자는 1바이트씩 저장한다.
char와 UTF-16
자바가 설계된 1990년대에는 Unicode가 U+FFFF 이내에 모든 문자를 담을 수 있을 것이라 예상했다. 그래서 char를 2바이트(16비트)로 만들었다. 하지만 이모지와 고대 문자 등이 추가되면서 U+FFFF를 넘어갔고, 서로게이트 페어라는 개념이 등장했다.
// char는 BMP 문자만 담을 수 있다
char koreanChar = '가'; // OK — U+AC00은 BMP
// char emoji = '😀'; // 컴파일 에러 — U+1F600은 BMP 밖
// BMP 밖의 문자는 int(코드 포인트)로 다루기
int emojiCodePoint = 0x1F600;
String emoji = new String(Character.toChars(emojiCodePoint));
System.out.println(emoji); // 😀
String의 내부 구조 변화 (Java 9+: Compact Strings)
Java 8까지 String 내부는 char[] 배열이었다. 모든 문자를 2바이트씩 저장했으므로 ASCII 텍스트도 메모리를 2배 사용했다.
Java 9부터 Compact Strings가 도입되었다.
// String 내부 구조 (Java 9+, 간략화)
public final class String {
private final byte[] value; // char[]가 아니라 byte[]
private final byte coder; // 0 = LATIN1 (1바이트), 1 = UTF16 (2바이트)
}
- 문자열이 Latin-1(영문, 숫자 등) 범위 안이면 →
byte[]에 1바이트씩 저장 - 한글 등 Latin-1 밖의 문자가 포함되면 →
byte[]에 UTF-16으로 2바이트씩 저장
영문만 있는 "hello"는 Latin-1으로 5바이트, 한글이 섞인 "hello안녕"은 UTF-16으로 전체가 2바이트씩 저장된다. 하나라도 Latin-1 밖이면 전체가 UTF-16 모드가 된다.
String.length()의 함정
length()는 char 개수를 반환하지, 문자 개수를 반환하는 게 아니다.
String text = "Hello😀World";
System.out.println(text.length()); // 12 — 😀이 서로게이트 페어(char 2개)
System.out.println(text.codePointCount(0, text.length())); // 11 — 실제 문자 수
// 코드 포인트 단위로 순회하기
text.codePoints().forEach(cp -> {
System.out.printf("U+%04X: %s%n", cp, new String(Character.toChars(cp)));
});
면접에서 "자바의
char가 모든 유니코드 문자를 표현할 수 있나요?"라는 질문이 나올 수 있다. 정답은 아니오 — BMP 밖의 문자는char하나로 표현할 수 없고, 서로게이트 페어가 필요하다.
8. Charset과 CharsetEncoder/Decoder — 인코딩 변환
핵심 한 줄
java.nio.charset.Charset은 인코딩 방식을 나타내는 클래스이며, CharsetEncoder/CharsetDecoder로 바이트와 문자 간의 변환을 세밀하게 제어할 수 있다.
Charset 기본 사용법
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
// StandardCharsets 상수 사용 (권장)
Charset utf8 = StandardCharsets.UTF_8;
Charset eucKr = Charset.forName("EUC-KR"); // 이름으로도 가능
String과 Charset
String text = "한글 테스트";
// 인코딩: String → byte[]
byte[] utf8Bytes = text.getBytes(StandardCharsets.UTF_8); // 15바이트 (한글 3 × 5)
byte[] eucKrBytes = text.getBytes(Charset.forName("EUC-KR")); // 10바이트 (한글 2 × 5)
// 디코딩: byte[] → String — 반드시 같은 Charset으로!
String decoded = new String(utf8Bytes, StandardCharsets.UTF_8); // 정상
String broken = new String(utf8Bytes, Charset.forName("EUC-KR")); // 깨짐!
CharsetEncoder로 에러 처리
인코딩 변환 시 표현할 수 없는 문자가 있을 때의 처리 전략을 지정할 수 있다.
import java.nio.charset.CodingErrorAction;
CharsetEncoder encoder = StandardCharsets.US_ASCII.newEncoder()
.onUnmappableCharacter(CodingErrorAction.REPLACE); // 매핑 불가 → '?'로 대체
// REPORT: 예외 발생 | REPLACE: 대체 문자 | IGNORE: 건너뜀
9. InputStreamReader의 인코딩 지정 — 파일 읽기에서의 실수
핵심 한 줄
파일을 읽을 때 인코딩을 명시하지 않으면 JVM의 기본 인코딩에 의존하게 되어, 환경이 바뀌면 한글이 깨진다.
흔한 실수
// ❌ 나쁜 예: 인코딩을 명시하지 않음
BufferedReader reader = new BufferedReader(
new InputStreamReader(new FileInputStream("data.txt"))
);
// → JVM 기본 인코딩에 의존 — 로컬에서는 되지만 서버에서 깨질 수 있음
// ✅ 좋은 예: 인코딩을 명시
BufferedReader reader = new BufferedReader(
new InputStreamReader(new FileInputStream("data.txt"), StandardCharsets.UTF_8)
);
Files API 활용 (Java 7+)
java.nio.file.Files를 쓰면 더 간결하고 안전하다.
// 파일 읽기/쓰기 — 인코딩 명시
String content = Files.readString(Path.of("data.txt"), StandardCharsets.UTF_8); // Java 11+
List<String> lines = Files.readAllLines(Path.of("data.txt"), StandardCharsets.UTF_8);
Files.writeString(Path.of("output.txt"), "한글 내용", StandardCharsets.UTF_8);
// EUC-KR 레거시 파일 읽어서 UTF-8로 변환 저장
List<String> legacy = Files.readAllLines(Path.of("old.csv"), Charset.forName("EUC-KR"));
Files.write(Path.of("new.csv"), legacy, StandardCharsets.UTF_8);
콘솔 출력 깨짐
// JVM 실행 시 인코딩 지정
// java -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 MyApp
// 코드에서 기본 인코딩 확인
System.out.println("file.encoding: " + System.getProperty("file.encoding"));
10. 한글 깨짐 디버깅 — HTTP Content-Type, DB charset, 파일 인코딩 체크리스트
핵심 한 줄
한글이 깨지면 **"어디서 인코딩이 불일치하는가"**를 찾는 것이 핵심이다 — 파일, HTTP, DB, 콘솔 네 군데를 순서대로 확인하자.
깨짐 패턴으로 원인 추정하기
깨진 모양을 보면 원인을 추정할 수 있다.
| 깨진 모양 | 추정 원인 |
|---|---|
문ìžì—´ | UTF-8 바이트를 Latin-1/ISO-8859-1로 읽음 |
????? | 해당 인코딩에서 표현 불가 (매핑 실패) |
??? | EUC-KR/CP949로 읽어야 할 데이터를 다른 인코딩으로 읽음 |
¹®ÀÚ¿ | UTF-8 데이터를 EUC-KR로 읽음 |
\uFFFD (�) | 디코딩 실패 시 대체 문자 |
한글 깨짐 디버깅 체크리스트
1단계 — 파일 인코딩: 터미널에서 file -bi data.txt로 확인. 자바에서는 Files.readAllBytes()로 바이트를 직접 확인한다.
2단계 — HTTP Content-Type: 응답 헤더에 charset=UTF-8이 있는지 확인.
// Spring에서 인코딩 명시
@GetMapping(value = "/api/data", produces = "application/json; charset=UTF-8")
public String getData() { return "{\"name\": \"홍길동\"}"; }
// 서블릿에서 인코딩 설정
response.setCharacterEncoding("UTF-8");
request.setCharacterEncoding("UTF-8");
3단계 — DB 캐릭터셋: MySQL은 SHOW VARIABLES LIKE 'character_set%'로 확인. JDBC URL에 characterEncoding=UTF-8을 명시한다.
MySQL에서
utf8은 3바이트까지만 지원한다(이모지 불가). 반드시 **utf8mb4**를 사용해야 한다.
4단계 — 콘솔/IDE: IntelliJ File Encodings → UTF-8, JVM 옵션 -Dfile.encoding=UTF-8.
인코딩 불일치 복원 트릭
깨진 문자열은 **"깨진 과정의 역순"**으로 바이트를 복원할 수 있다.
// UTF-8 바이트를 Latin-1로 잘못 읽은 경우 복원
String broken = "문ìžì—´";
byte[] rawBytes = broken.getBytes(StandardCharsets.ISO_8859_1); // 원래 바이트 복원
String fixed = new String(rawBytes, StandardCharsets.UTF_8); // 올바른 인코딩으로 디코딩
System.out.println(fixed); // 문자열
11. 정리 테이블
인코딩 방식 비교
| 인코딩 | 바이트 수 | ASCII 호환 | 한글 지원 | 주요 사용처 |
|---|---|---|---|---|
| ASCII | 1 (고정) | O | X | 레거시 시스템 |
| EUC-KR | 1~2 (가변) | O | 2,350자 | 오래된 한국 웹사이트 |
| CP949 | 1~2 (가변) | O | 11,172자 | 윈도우 한국어 환경 |
| UTF-8 | 1~4 (가변) | O | O (3바이트) | 웹, API, 파일 (표준) |
| UTF-16 | 2~4 (가변) | X | O (2바이트) | 자바 내부, 윈도우 API |
| UTF-32 | 4 (고정) | X | O (4바이트) | 거의 안 씀 |
한글 깨짐 디버깅 요약
| 체크 포인트 | 확인 항목 |
|---|---|
| 소스 파일 | IDE 인코딩 설정 (UTF-8) |
| 파일 I/O | InputStreamReader, Files API에 Charset 명시 |
| HTTP | Content-Type 헤더에 charset=UTF-8 |
| DB | 테이블 utf8mb4, JDBC URL에 characterEncoding=UTF-8 |
| 콘솔 | -Dfile.encoding=UTF-8 JVM 옵션 |
| BOM | UTF-8 BOM(EF BB BF) 제거 |
마무리
인코딩 문제는 원리를 이해하면 어렵지 않다. 핵심은 딱 하나 — **"인코딩과 디코딩에 같은 규칙을 쓰고 있는가?"**다.
기억해둘 포인트를 정리하면:
- 컴퓨터는 숫자만 안다. 문자를 숫자로 바꾸는 약속이 인코딩이다.
- ASCII → EUC-KR → Unicode로 발전해왔고, 현재 표준은 UTF-8이다.
- 자바의
char는 UTF-16 코드 유닛이며, BMP 밖의 문자는 서로게이트 페어가 필요하다. String.length()는 char 개수이지 문자 개수가 아니다.codePointCount()를 쓰자.- 파일 읽기에서 인코딩을 명시하지 않는 것이 한글 깨짐의 가장 흔한 원인이다.
- 한글이 깨지면 파일 → HTTP → DB → 콘솔 순으로 인코딩 불일치를 추적하자.
면접에서 "인코딩에 대해 설명해주세요"라는 질문이 나오면, ASCII의 한계 → Unicode의 등장 → UTF-8의 가변 길이 인코딩 → 자바의 UTF-16 선택 이유까지 흐름을 잡아서 설명하면 깔끔하다.
다음 글에서는 JDBC와 커넥션 풀을 다룬다. 자바가 데이터베이스에 접근하는 원리부터, 커넥션 풀이 왜 필요한지, HikariCP는 어떻게 동작하는지까지 정리할 예정이다.