한글이 깨지는 이유: ASCII부터 UTF-8·UTF-16, EUC-KR·CP949, BOM과 엔디언까지
이 글의 핵심
한글 깨짐은 대부분 '쓴 쪽과 읽은 쪽이 다른 인코딩을 가정했다'는 한 가지 원인에서 나옵니다. 이 글은 ASCII·Unicode·UTF-8·UTF-16과 EUC-KR·CP949의 차이를 바이트 수준에서 정리한 뒤, 깨진 모양만 보고 어느 방향으로 잘못 읽었는지 알아내는 법, 인코딩 자동 감지 라이브러리의 한계, Excel CSV와 BOM, macOS에서 온 한글 파일명이 자모로 분리되는 NFD 문제, Windows에서 Python·MSVC가 cp949를 기본으로 쓰는 함정까지 실제로 자주 겪는 사례 중심으로 다룹니다.
들어가며: 왜 문자 인코딩을 알아야 하나?
개발하다 보면 한글이 깨지거나, 파일을 읽을 수 없거나, API 응답이 이상하게 나오는 경험을 합니다. 이 모든 문제의 근본 원인은 문자 인코딩입니다.
이 글에서 다룰 내용:
- ASCII, ANSI, Unicode의 역사
- UTF-8, UTF-16, UTF-32 인코딩 방식
- 한글 인코딩 (EUC-KR, CP949)
- BOM, Endian, 인코딩 감지
- 실전 문제 해결
문자 인코딩의 역사
타임라인
timeline
title 문자 인코딩 발전사
1963 : ASCII 제정\n7비트, 128자
1987 : ISO-8859-1 (Latin-1)\n8비트, 256자
1991 : Unicode 1.0\n16비트 통합 문자셋
1992 : UTF-8 발명\n가변 길이 인코딩
1996 : UTF-16\n서로게이트 페어
2003 : UTF-8 웹 표준화
2008 : UTF-8이 웹에서\n가장 많이 사용
2026 : UTF-8 점유율 약 98%
왜 여러 인코딩이 존재하나?
flowchart TB
Problem["문제: 컴퓨터는\n숫자만 이해"]
ASCII["ASCII\n영문 128자"]
Extended["확장 ASCII\n각국 언어 256자"]
Unicode["Unicode\n전 세계 문자 통합"]
Problem --> ASCII
ASCII --> Extended
Extended --> Unicode
ASCII --> Issue1["문제: 한글, 한자\n표현 불가"]
Extended --> Issue2["문제: 각국마다\n다른 코드 페이지"]
Unicode --> Solution["해결: 모든 문자에\n고유 번호 부여"]
ASCII: 7비트 문자 집합
ASCII란?
ASCII(American Standard Code for Information Interchange)는 7비트(0-127)로 영문 알파벳, 숫자, 특수문자를 표현합니다.
ASCII 테이블
Dec Hex Char | Dec Hex Char | Dec Hex Char
-------------------------------------------------
32 20 Space | 64 40 @ | 96 60 `
33 21 ! | 65 41 A | 97 61 a
34 22 " | 66 42 B | 98 62 b
35 23 # | 67 43 C | 99 63 c
...
48 30 0 | 80 50 P | 112 70 p
49 31 1 | 81 51 Q | 113 71 q
...
57 39 9 | 90 5A Z | 122 7A z
ASCII 제어 문자
# 주요 제어 문자
NUL = 0x00 # Null
LF = 0x0A # Line Feed (\n)
CR = 0x0D # Carriage Return (\r)
ESC = 0x1B # Escape
DEL = 0x7F # Delete
# 줄바꿈 방식
# Unix/Linux: LF (\n)
# Windows: CR+LF (\r\n)
# Mac (Classic): CR (\r)
ASCII 예제
# 문자 → 코드
ord('A') # 65
ord('a') # 97
ord('0') # 48
# 코드 → 문자
chr(65) # 'A'
chr(97) # 'a'
# ASCII 범위 확인
def is_ascii(text):
return all(ord(c) < 128 for c in text)
is_ascii("Hello") # True
is_ascii("안녕") # False
ANSI와 코드 페이지
ANSI란?
ANSI는 8비트(0-255)로 확장하여 각국 언어를 지원합니다. 하지만 코드 페이지(Code Page)마다 128-255 범위의 의미가 다릅니다.
주요 코드 페이지
| 코드 페이지 | 이름 | 지역 | 특징 |
|---|---|---|---|
| CP437 | OEM-US | 미국 | DOS 기본 |
| CP850 | Latin-1 | 서유럽 | DOS 다국어 |
| CP949 | 확장 완성형 | 한국 | Windows 한글 |
| CP932 | Shift-JIS | 일본 | Windows 일본어 |
| CP936 | GBK | 중국 | Windows 중국어 |
| ISO-8859-1 | Latin-1 | 서유럽 | Unix/Web |
| ISO-8859-15 | Latin-9 | 서유럽 | 유로(€) 추가 |
코드 페이지 문제
# 같은 바이트 값이 다른 의미
byte_value = 0xC7
# CP949 (한글): '한'
text_korean = byte_value.to_bytes(1, 'big').decode('cp949') # 에러 (2바이트 필요)
# ISO-8859-1 (Latin-1): 'Ç'
text_latin = byte_value.to_bytes(1, 'big').decode('latin-1') # 'Ç'
# 같은 파일을 다른 인코딩으로 읽으면 깨짐!
Unicode: 전 세계 문자 통합
Unicode란?
Unicode는 전 세계 모든 문자에 고유한 코드 포인트(Code Point)를 부여한 문자 집합입니다.
Unicode 구조
U+0000 ~ U+10FFFF (1,114,112개 코드 포인트)
U+0000 ~ U+007F : ASCII (128자)
U+0080 ~ U+00FF : Latin-1 Supplement
U+0100 ~ U+017F : Latin Extended-A
U+0370 ~ U+03FF : Greek
U+0400 ~ U+04FF : Cyrillic
U+0600 ~ U+06FF : Arabic
U+0E00 ~ U+0E7F : Thai
U+3040 ~ U+309F : Hiragana (일본어)
U+30A0 ~ U+30FF : Katakana (일본어)
U+4E00 ~ U+9FFF : CJK Unified Ideographs (한중일 한자)
U+AC00 ~ U+D7AF : Hangul Syllables (한글 11,172자)
U+1F600 ~ U+1F64F : Emoticons (이모지)
한글 Unicode 범위
# 한글 음절 (가-힣)
print(f"가: U+{ord('가'):04X}") # U+AC00
print(f"힣: U+{ord('힣'):04X}") # U+D7A3
# 한글 자모 (ㄱ-ㅎ, ㅏ-ㅣ)
print(f"ㄱ: U+{ord('ㄱ'):04X}") # U+3131
print(f"ㅎ: U+{ord('ㅎ'):04X}") # U+314E
print(f"ㅏ: U+{ord('ㅏ'):04X}") # U+314F
print(f"ㅣ: U+{ord('ㅣ'):04X}") # U+3163
# 이모지
print(f"😀: U+{ord('😀'):04X}") # U+1F600
Unicode vs 인코딩
Unicode: 문자 집합 (Character Set)
각 문자에 번호(코드 포인트) 부여
예: '한' = U+D55C
UTF-8/UTF-16/UTF-32: 인코딩 방식 (Encoding)
코드 포인트를 바이트로 변환하는 방법
예: U+D55C → UTF-8: ED 95 9C (3바이트)
→ UTF-16BE: D5 5C (2바이트)
UTF-8: 가변 길이 인코딩
UTF-8이란?
UTF-8은 1-4바이트 가변 길이로 Unicode를 인코딩합니다. 웹 표준이며, ASCII와 완벽히 호환됩니다.
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
UTF-8 인코딩 예시
영문 ‘A’ (U+0041)
코드 포인트: U+0041 (65)
이진수: 0100 0001
UTF-8 인코딩:
0100 0001 = 0x41 (1바이트)
메모리: 41
한글 ‘한’ (U+D55C)
코드 포인트: U+D55C (54,620)
이진수: 1101 0101 0101 1100
UTF-8 인코딩 (3바이트):
1110xxxx 10xxxxxx 10xxxxxx
1110 1101 10 010101 10 011100
E D 9 5 9 C
메모리: ED 95 9C
이모지 ’😀’ (U+1F600)
코드 포인트: U+1F600 (128,512)
이진수: 0001 1111 0110 0000 0000
UTF-8 인코딩 (4바이트):
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
11110 000 10 011111 10 011000 10 000000
F 0 9 F 9 8 8 0
메모리: F0 9F 98 80
UTF-8 디코딩 과정과 자기 동기화:
UTF-8 디코딩 알고리즘:
바이트 스트림: ED 95 9C EA B8 80 (한글)
1. 첫 바이트 읽기 (0xED = 11101101):
110xxxxx: 2바이트
1110xxxx: 3바이트 ← 매칭!
11110xxx: 4바이트
→ 3바이트 문자로 판단
2. 다음 2바이트 읽기 (0x95, 0x9C):
10xxxxxx 패턴 확인:
0x95 = 10010101 ✓
0x9C = 10011100 ✓
→ 유효한 연속 바이트
3. 비트 추출:
1110 1101 10 010101 10 011100
^^^^ ^^^^^^ ^^^^^^
비트 추출:
1101 (from byte 1)
010101 (from byte 2)
011100 (from byte 3)
결합: 1101 010101 011100 = D55C (hex)
4. Unicode 변환:
U+D55C = '한'
자기 동기화 (Self-Synchronization):
손상된 바이트 스트림:
... ?? ED 95 9C EA B8 80 ...
↑ 손상
일반 인코딩:
- 손상 지점부터 모든 문자 깨짐
- 파일 끝까지 복구 불가
UTF-8:
- 10xxxxxx: 연속 바이트 (다음 문자 일부)
- 0xxxxxxx, 110xxxxx, 1110xxxx, 11110xxx: 시작 바이트
복구 과정:
1. ?? (손상) → 건너뜀
2. ED (1110xxxx) → 새 문자 시작!
3. 95, 9C → 연속 바이트
4. 정상 디코딩 재개
→ 한 문자만 손실, 나머지 복구
예시:
손상: 48 65 6C ?? ED 95 9C
H e l ? 한
손상된 1문자만 깨지고, '한'은 정상 디코딩!
네트워크 패킷 손실에도 강건함:
패킷 1: [48 65 6C 6C 6F]
패킷 2: [손실]
패킷 3: [ED 95 9C]
→ 패킷 3부터 정상 디코딩 가능
UTF-8 인코딩/디코딩 내부 구현:
UTF-8 인코더 의사 코드:
function utf8_encode(code_point):
if code_point <= 0x7F:
# 1바이트 (ASCII)
return [code_point]
elif code_point <= 0x7FF:
# 2바이트
byte1 = 0xC0 | (code_point >> 6)
byte2 = 0x80 | (code_point & 0x3F)
return [byte1, byte2]
elif code_point <= 0xFFFF:
# 3바이트
byte1 = 0xE0 | (code_point >> 12)
byte2 = 0x80 | ((code_point >> 6) & 0x3F)
byte3 = 0x80 | (code_point & 0x3F)
return [byte1, byte2, byte3]
else:
# 4바이트
byte1 = 0xF0 | (code_point >> 18)
byte2 = 0x80 | ((code_point >> 12) & 0x3F)
byte3 = 0x80 | ((code_point >> 6) & 0x3F)
byte4 = 0x80 | (code_point & 0x3F)
return [byte1, byte2, byte3, byte4]
예시: '한' (U+D55C = 54620)
code_point = 0xD55C (이진: 1101 0101 0101 1100)
3바이트 인코딩:
byte1 = 0xE0 | (0xD55C >> 12)
= 0xE0 | 0x0D
= 0xED (1110 1101)
byte2 = 0x80 | ((0xD55C >> 6) & 0x3F)
= 0x80 | (0x355 & 0x3F)
= 0x80 | 0x15
= 0x95 (1001 0101)
byte3 = 0x80 | (0xD55C & 0x3F)
= 0x80 | 0x1C
= 0x9C (1001 1100)
결과: ED 95 9C
UTF-8 디코더 의사 코드:
function utf8_decode(bytes):
byte1 = bytes[0]
if (byte1 & 0x80) == 0:
# 1바이트 (0xxxxxxx)
return byte1
elif (byte1 & 0xE0) == 0xC0:
# 2바이트 (110xxxxx)
byte2 = bytes[1]
code_point = ((byte1 & 0x1F) << 6) | (byte2 & 0x3F)
return code_point
elif (byte1 & 0xF0) == 0xE0:
# 3바이트 (1110xxxx)
byte2 = bytes[1]
byte3 = bytes[2]
code_point = ((byte1 & 0x0F) << 12) |
((byte2 & 0x3F) << 6) |
(byte3 & 0x3F)
return code_point
elif (byte1 & 0xF8) == 0xF0:
# 4바이트 (11110xxx)
byte2 = bytes[1]
byte3 = bytes[2]
byte4 = bytes[3]
code_point = ((byte1 & 0x07) << 18) |
((byte2 & 0x3F) << 12) |
((byte3 & 0x3F) << 6) |
(byte4 & 0x3F)
return code_point
에러 처리:
- 연속 바이트가 10xxxxxx 패턴 아니면: 에러
- Overlong 인코딩 (보안 위험): 에러
예: U+0041 ('A')을 2바이트로 인코딩
→ C1 81 (정상: 41)
→ 거부!
- UTF-8 최대값 초과 (U+10FFFF 이상): 에러
UTF-8 장점
flowchart TB
UTF8[UTF-8]
Adv1["✅ ASCII 호환\n영문은 1바이트"]
Adv2["✅ 자기 동기화\n중간부터 읽기 가능"]
Adv3["✅ 바이트 순서 무관\nEndian 문제 없음"]
Adv4["✅ 웹 표준\n98% 점유율"]
UTF8 --> Adv1
UTF8 --> Adv2
UTF8 --> Adv3
UTF8 --> Adv4
Python으로 UTF-8 인코딩
# 문자열 → 바이트
text = "Hello 한글 😀"
# UTF-8 인코딩
utf8_bytes = text.encode('utf-8')
print(utf8_bytes)
# b'Hello \xed\x95\x9c\xea\xb8\x80 \xf0\x9f\x98\x80'
# 바이트 분석
for i, byte in enumerate(utf8_bytes):
print(f"{i:2d}: 0x{byte:02X} ({byte:3d}) {chr(byte) if byte < 128 else '?'}")
# 출력:
# 0: 0x48 ( 72) H
# 1: 0x65 (101) e
# 2: 0x6C (108) l
# 3: 0x6C (108) l
# 4: 0x6F (111) o
# 5: 0x20 ( 32)
# 6: 0xED (237) ? ← '한' 시작
# 7: 0x95 (149) ?
# 8: 0x9C (156) ?
# 9: 0xEA (234) ? ← '글' 시작
# 10: 0xB8 (184) ?
# 11: 0x80 (128) ?
# 12: 0x20 ( 32)
# 13: 0xF0 (240) ? ← '😀' 시작
# 14: 0x9F (159) ?
# 15: 0x98 (152) ?
# 16: 0x80 (128) ?
# 바이트 → 문자열
decoded = utf8_bytes.decode('utf-8')
print(decoded) # "Hello 한글 😀"
UTF-16과 UTF-32
UTF-16
UTF-16은 2바이트 또는 4바이트로 인코딩합니다. Windows, Java, JavaScript 내부에서 사용됩니다.
UTF-16 인코딩 규칙
코드 포인트 범위 | 바이트 수 | 방식
U+0000 ~ U+FFFF | 2바이트 | 그대로 인코딩
U+10000 ~ U+10FFFF | 4바이트 | 서로게이트 페어
서로게이트 페어 (Surrogate Pair)
# 이모지 '😀' (U+1F600)를 UTF-16으로 인코딩
# 1. U+1F600 - 0x10000 = 0xF600
# 2. 상위 10비트: 0x3D (61)
# 3. 하위 10비트: 0x200 (512)
# 4. High Surrogate: 0xD800 + 0x3D = 0xD83D
# 5. Low Surrogate: 0xDC00 + 0x200 = 0xDE00
text = "😀"
utf16_bytes = text.encode('utf-16-le')
print(utf16_bytes.hex()) # '3dd8 00de' (Little-Endian)
# UTF-16 BE (Big-Endian)
utf16_be = text.encode('utf-16-be')
print(utf16_be.hex()) # 'd83d de00'
UTF-16 예시
text = "Hello 한글"
# UTF-16 LE (Little-Endian)
utf16_le = text.encode('utf-16-le')
print(utf16_le.hex(' '))
# 48 00 65 00 6c 00 6c 00 6f 00 20 00 5c d5 00 ae
# UTF-16 BE (Big-Endian)
utf16_be = text.encode('utf-16-be')
print(utf16_be.hex(' '))
# 00 48 00 65 00 6c 00 6c 00 6f 00 20 d5 5c ae 00
UTF-32
UTF-32는 모든 문자를 4바이트 고정 길이로 인코딩합니다.
text = "A한😀"
# UTF-32 LE
utf32 = text.encode('utf-32-le')
print(utf32.hex())
# 41 00 00 00 5c d5 00 00 00 f6 01 00
# 각 문자가 정확히 4바이트
# 'A': 0x00000041
# '한': 0x0000D55C
# '😀': 0x0001F600
인코딩 비교
text = "Hello 한글 😀"
encodings = ['utf-8', 'utf-16-le', 'utf-16-be', 'utf-32-le']
for enc in encodings:
encoded = text.encode(enc)
print(f"{enc:12s}: {len(encoded):2d} bytes | {encoded.hex()[:40]}...")
# 출력:
# utf-8 : 17 bytes | 48656c6c6f20ed959ceab88020f09f9880...
# utf-16-le : 22 bytes | 480065006c006c006f0020005cd500ae20003dd8...
# utf-16-be : 22 bytes | 00480065006c006c006f0020d55cae000020d83d...
# utf-32-le : 40 bytes | 48000000650000006c0000006c0000006f000000...
# (10개 코드 포인트: UTF-16은 😀 하나가 서로게이트 페어라 11단위 = 22바이트, UTF-32는 10 × 4 = 40바이트)
한글 인코딩 (EUC-KR, CP949)
한글 인코딩 역사
timeline
title 한글 인코딩 발전
1987 : KS X 1001\n완성형 2,350자
1992 : EUC-KR\n완성형 표준
1996 : CP949 (MS)\n확장 완성형 11,172자
2000s : UTF-8\nUnicode 기반
EUC-KR
EUC-KR은 2바이트로 한글 2,350자를 표현합니다.
# EUC-KR 인코딩
text = "한글"
euckr_bytes = text.encode('euc-kr')
print(euckr_bytes.hex()) # c7d1 b1db
# '한': 0xC7D1
# '글': 0xB1DB
# 문제: '똠', '쀍' 같은 글자는 표현 불가
try:
"똠".encode('euc-kr')
except UnicodeEncodeError as e:
print(f"❌ EUC-KR로 인코딩 불가: {e}")
CP949 (확장 완성형)
CP949(Microsoft의 “통합형 한글 코드”, UHC)는 EUC-KR에 없는 한글 음절 8,822자를 EUC-KR이 쓰지 않던 바이트 영역에 추가해 현대 한글 11,172자를 모두 표현합니다. EUC-KR과 겹치는 2,350자는 바이트 값이 같으므로, EUC-KR 파일은 CP949로 읽어도 항상 정상으로 읽힙니다. 반대는 성립하지 않습니다.
# CP949 인코딩
text = "똠방각하"
cp949_bytes = text.encode('cp949')
print(cp949_bytes.hex(' '))
# 8c 63 b9 e6 b0 a2 c7 cf ← '똠'(8C 63)은 EUC-KR 범위(A1~FE) 밖의 바이트
# EUC-KR에 없는 글자도 표현 가능
text2 = "쀍똠뙠"
print(text2.encode('cp949').hex())
UTF-8 vs EUC-KR 비교
text = "Hello 한글"
# UTF-8: 영문 1바이트, 한글 3바이트
utf8 = text.encode('utf-8')
print(f"UTF-8: {len(utf8)} bytes | {utf8.hex()}")
# UTF-8: 14 bytes | 48656c6c6f20ed959ceab880
# EUC-KR: 영문 1바이트, 한글 2바이트
euckr = text.encode('euc-kr')
print(f"EUC-KR: {len(euckr)} bytes | {euckr.hex()}")
# EUC-KR: 10 bytes | 48656c6c6f20c7d1b1db
BOM과 Endian
BOM (Byte Order Mark)
BOM은 파일 시작 부분에 붙는 특수 바이트로, 인코딩 방식과 바이트 순서를 표시합니다.
인코딩 | BOM (hex) | 크기
UTF-8 | EF BB BF | 3바이트
UTF-16 LE | FF FE | 2바이트
UTF-16 BE | FE FF | 2바이트
UTF-32 LE | FF FE 00 00 | 4바이트
UTF-32 BE | 00 00 FE FF | 4바이트
BOM 예시
# UTF-8 with BOM
text = "Hello"
with open('file_with_bom.txt', 'wb') as f:
f.write(b'\xef\xbb\xbf') # BOM
f.write(text.encode('utf-8'))
# 파일 내용 (hex):
# EF BB BF 48 65 6C 6C 6F
# ^^^^^^^^ BOM
# ^^^^^^^^^^^^^^ "Hello"
# UTF-8 without BOM (권장)
with open('file_no_bom.txt', 'wb') as f:
f.write(text.encode('utf-8'))
# 파일 내용 (hex):
# 48 65 6C 6C 6F
BOM 감지 및 제거
def detect_and_remove_bom(data):
"""BOM 감지 및 제거"""
bom_signatures = [
(b'\xef\xbb\xbf', 'utf-8-sig'),
(b'\xff\xfe\x00\x00', 'utf-32-le'),
(b'\x00\x00\xfe\xff', 'utf-32-be'),
(b'\xff\xfe', 'utf-16-le'),
(b'\xfe\xff', 'utf-16-be'),
]
for bom, encoding in bom_signatures:
if data.startswith(bom):
return data[len(bom):], encoding
return data, None
# 사용
with open('file.txt', 'rb') as f:
data = f.read()
data, encoding = detect_and_remove_bom(data)
if encoding:
print(f"✅ BOM detected: {encoding}")
text = data.decode(encoding.replace('-sig', ''))
else:
print("ℹ️ No BOM, assuming UTF-8")
text = data.decode('utf-8')
Endian (바이트 순서)
# Big-Endian: 큰 바이트가 먼저
# Little-Endian: 작은 바이트가 먼저
# 예: 0x1234를 메모리에 저장
# Big-Endian: 12 34
# Little-Endian: 34 12
# UTF-16에서 중요
text = "한" # U+D55C
# UTF-16 BE (Big-Endian)
be = text.encode('utf-16-be')
print(be.hex()) # d5 5c
# UTF-16 LE (Little-Endian)
le = text.encode('utf-16-le')
print(le.hex()) # 5c d5
# UTF-8은 바이트 단위라 Endian 무관
utf8 = text.encode('utf-8')
print(utf8.hex()) # ed 95 9c (항상 같음)
실전 문제 해결
문제 1: 한글 깨짐 — 깨진 모양으로 원인 읽기
원인
# ❌ UTF-8로 저장했는데 CP949로 읽음
with open('file.txt', 'w', encoding='utf-8') as f:
f.write("한글")
# 잘못된 읽기: Python은 엄격해서 바로 에러를 냄
with open('file.txt', 'r', encoding='cp949') as f:
text = f.read() # UnicodeDecodeError: 'cp949' codec can't decode byte 0xed ...
Python은 잘못된 바이트를 만나면 에러를 내지만, 편집기·브라우저·DB 클라이언트는 대체 문자로 바꿔 보여 주기 때문에 사람이 보는 것은 “깨진 글자”입니다. 깨진 모양에는 규칙이 있어서, 모양만 보고도 어느 방향으로 잘못 읽었는지 알 수 있습니다.
| 보이는 모양 | 원인 | 확인 코드 |
|---|---|---|
�븳湲� — 한자·드문 한글이 섞이고 길이가 원문과 비슷 | UTF-8 바이트를 CP949로 읽음 | "한글".encode('utf-8').decode('cp949', 'replace') |
�ѱ� — 대체 문자(�)가 대부분, 짧음 | CP949 바이트를 UTF-8로 읽음 | "한글".encode('cp949').decode('utf-8', 'replace') |
í\x95\x9cê¸\x80, 안녕 — 악센트 붙은 라틴 문자 | UTF-8 바이트를 Latin-1/CP1252로 읽음 | "한글".encode('utf-8').decode('latin-1') |
???? — 물음표만 | 저장할 때 이미 대상 인코딩에 없는 문자를 ?로 바꿔 씀 | 원본 손실, 복구 불가 |
마지막 경우는 특히 주의해야 합니다. ????는 읽는 쪽 문제가 아니라 쓰는 순간 데이터가 사라진 것입니다. MySQL의 latin1 컬럼이나 한글을 지원하지 않는 코드 페이지로 저장하면서 생기는데, 이미 저장된 데이터는 어떤 인코딩으로 읽어도 되살릴 수 없습니다. 저는 한글 깨짐 문의를 받으면 가장 먼저 원본 바이트를 hex로 확인해서 3F 3F(물음표)인지, 아니면 멀쩡한 UTF-8·CP949 바이트인데 잘못 읽고 있는 것인지부터 구분합니다. 후자라면 읽는 쪽 인코딩만 바꾸면 되고, 전자라면 원본을 다시 받아야 합니다.
해결
# ✅ 올바른 인코딩으로 읽기
with open('file.txt', 'r', encoding='utf-8') as f:
text = f.read()
print(text) # '한글' (정상)
# ✅ 인코딩 자동 감지
import chardet
with open('file.txt', 'rb') as f:
raw_data = f.read()
result = chardet.detect(raw_data)
encoding = result['encoding']
confidence = result['confidence']
print(f"Detected: {encoding} ({confidence*100:.1f}% confidence)")
# chardet은 CP949 파일도 'EUC-KR'로 보고하는 경우가 많으므로 항상 cp949로 바꿔서 디코딩
if encoding and encoding.lower() in ('euc-kr', 'euc_kr'):
encoding = 'cp949'
text = raw_data.decode(encoding)
print(text)
자동 감지는 편하지만 믿고 맡기면 안 되는 도구입니다. 제가 실제로 가장 많이 본 문제는 chardet이 CP949 파일을 EUC-KR로 판정하는 것입니다. EUC-KR과 CP949는 대부분의 바이트가 겹쳐서 감지기가 구분하기 어렵고, 그 결과를 그대로 decode('euc-kr')에 넣으면 파일 대부분은 멀쩡히 읽히다가 “똠”, “쀍”, “햏” 같은 EUC-KR 밖의 글자가 나오는 줄에서만 에러가 납니다. 테스트 파일로는 재현이 안 되고 실제 고객 데이터에서만 터지는 종류라 원인을 찾는 데 한참 걸립니다. 한국어 텍스트에서 감지 결과가 EUC-KR이면 무조건 CP949로 읽는 것이 안전합니다(EUC-KR은 CP949의 부분집합이라 손해가 없습니다). 또 텍스트가 짧을수록 감지 정확도가 떨어지므로, 입력 인코딩이 정해져 있다면 감지보다 명시가 낫습니다. 최근 requests가 쓰는 charset-normalizer도 같은 한계가 있습니다.
문제 2: UnicodeDecodeError
# ❌ 잘못된 인코딩으로 디코딩
utf8_bytes = "한글".encode('utf-8')
try:
text = utf8_bytes.decode('ascii')
except UnicodeDecodeError as e:
print(f"❌ {e}")
# 'ascii' codec can't decode byte 0xed in position 0
# ✅ 에러 처리 옵션
# 1. 무시
text = utf8_bytes.decode('ascii', errors='ignore')
print(text) # "" (한글 제거됨)
# 2. 대체
text = utf8_bytes.decode('ascii', errors='replace')
print(text) # "������" (? 문자로 대체)
# 3. 바이트를 이스케이프로 남기기 (디코딩에서 쓸 수 있는 것은 backslashreplace)
text = utf8_bytes.decode('ascii', errors='backslashreplace')
print(text) # "\xed\x95\x9c\xea\xb8\x80"
# 참고: 'xmlcharrefreplace'는 encode 전용이라 decode에 쓰면 TypeError
print("한글".encode('ascii', errors='xmlcharrefreplace')) # b'한글'
문제 3: 웹에서 한글 깨짐
import requests
# ❌ 잘못된 방법
response = requests.get('https://example.com/korean-page')
print(response.text) # 깨질 수 있음
# ✅ Content-Type 헤더 확인
response = requests.get('https://example.com/korean-page')
content_type = response.headers.get('Content-Type', '')
print(f"Content-Type: {content_type}")
# Content-Type: text/html; charset=euc-kr
# ✅ 올바른 인코딩으로 디코딩
if 'euc-kr' in content_type.lower():
text = response.content.decode('cp949') # 헤더가 euc-kr이라도 실제로는 CP949인 경우가 많음
else:
text = response.text
# 주의: requests는 Content-Type이 text/*인데 charset이 없으면 ISO-8859-1로 가정함.
# 이때 response.text는 UTF-8 한글을 '안녕' 모양으로 깨뜨리므로
# response.encoding = response.apparent_encoding 또는 'utf-8'을 명시
# ✅ 또는 chardet으로 자동 감지
import chardet
detected = chardet.detect(response.content)
text = response.content.decode(detected['encoding'])
문제 4: CSV 파일 인코딩
CSV는 인코딩 사고가 가장 잦은 형식입니다. 한국어 Windows의 Excel은 CSV를 열 때 BOM이 없으면 시스템 코드 페이지(CP949)로 해석하고, “다른 이름으로 저장 → CSV”도 기본으로 CP949로 씁니다(별도의 “CSV UTF-8” 형식을 골라야 UTF-8). 그래서 서버에서 내려준 UTF-8 CSV를 사용자가 더블클릭하면 한글이 깨지고, 사용자가 Excel에서 수정해 다시 올린 CSV는 CP949라서 서버가 못 읽는 일이 한 사이클로 벌어집니다. 다운로드용 CSV는 utf-8-sig(BOM 포함)로 만들고, 업로드된 CSV는 BOM 유무를 먼저 보고 없으면 UTF-8 → CP949 순서로 시도하는 방식이 현실적입니다.
import csv
# ❌ Windows Excel에서 저장한 CSV (CP949)
with open('data.csv', 'r', encoding='utf-8') as f:
reader = csv.reader(f)
for row in reader:
print(row) # UnicodeDecodeError!
# ✅ 올바른 인코딩 (csv 모듈에는 newline=''를 함께 주는 것이 공식 권장)
with open('data.csv', 'r', encoding='cp949', newline='') as f:
reader = csv.reader(f)
for row in reader:
print(row)
# ✅ Excel에서 열 CSV를 만들 때는 BOM을 붙여 저장
with open('export.csv', 'w', encoding='utf-8-sig', newline='') as f:
csv.writer(f).writerow(['이름', '금액'])
# ✅ 인코딩 자동 감지
import chardet
with open('data.csv', 'rb') as f:
raw_data = f.read()
detected = chardet.detect(raw_data)
encoding = detected['encoding']
with open('data.csv', 'r', encoding=encoding) as f:
reader = csv.reader(f)
for row in reader:
print(row)
프로그래밍 언어별 처리
Python
# 기본 인코딩: UTF-8
text = "Hello 한글 😀"
# 인코딩
utf8 = text.encode('utf-8')
utf16 = text.encode('utf-16')
euckr = text.encode('euc-kr') # 이모지는 에러
# 디코딩
text = utf8.decode('utf-8')
# 파일 I/O
with open('file.txt', 'w', encoding='utf-8') as f:
f.write(text)
with open('file.txt', 'r', encoding='utf-8') as f:
text = f.read()
# 바이트 문자열 리터럴
utf8_bytes = b'\xed\x95\x9c\xea\xb8\x80'
text = utf8_bytes.decode('utf-8') # "한글"
JavaScript/Node.js
// JavaScript 내부: UTF-16
const text = "Hello 한글 😀";
// 문자열 길이 (주의: 서로게이트 페어)
console.log(text.length); // 11 (😀가 2로 계산됨)
// 올바른 길이
console.log([...text].length); // 10
// UTF-8 인코딩 (Node.js)
const buffer = Buffer.from(text, 'utf-8');
console.log(buffer); // <Buffer 48 65 6c 6c 6f 20 ...>
// 디코딩
const decoded = buffer.toString('utf-8');
console.log(decoded); // "Hello 한글 😀"
// 지원 인코딩
// utf-8, utf-16le, latin1, base64, hex, ascii
Java
// Java 내부: UTF-16
String text = "Hello 한글 😀";
// UTF-8 인코딩
byte[] utf8Bytes = text.getBytes(StandardCharsets.UTF_8);
System.out.println(Arrays.toString(utf8Bytes));
// 디코딩
String decoded = new String(utf8Bytes, StandardCharsets.UTF_8);
System.out.println(decoded);
// 파일 I/O
// UTF-8로 쓰기
Files.writeString(
Path.of("file.txt"),
text,
StandardCharsets.UTF_8
);
// UTF-8로 읽기
String content = Files.readString(
Path.of("file.txt"),
StandardCharsets.UTF_8
);
C++
#include <iostream>
#include <fstream>
#include <string>
int main() {
// C++20부터 u8"..."의 타입은 const char8_t[]라 std::string에 바로 담을 수 없음.
// 소스 파일을 UTF-8로 저장하고 컴파일러에 알려 주면(MSVC /utf-8) 일반 문자열 리터럴이 UTF-8이 됨
std::string utf8_str = "Hello 한글 😀";
// UTF-16 / UTF-32 문자열
std::u16string utf16_str = u"Hello 한글 😀";
std::u32string utf32_str = U"Hello 한글 😀";
// UTF-8 ↔ UTF-16 변환: std::wstring_convert/codecvt는 C++17에서 deprecated(C++26에서 제거)
// Windows는 MultiByteToWideChar, 크로스 플랫폼은 ICU나 simdutf 같은 라이브러리를 사용
// 파일 쓰기 (UTF-8)
std::ofstream file("file.txt", std::ios::binary);
file << utf8_str;
file.close();
// 파일 읽기
std::ifstream input("file.txt", std::ios::binary);
std::string content((std::istreambuf_iterator<char>(input)),
std::istreambuf_iterator<char>());
std::cout << content << std::endl;
return 0;
}
C++에서 한글이 깨지는 가장 흔한 원인은 코드가 아니라 컴파일러가 소스 파일을 어떤 인코딩으로 읽었는가입니다. MSVC는 BOM이 없는 소스 파일을 시스템 코드 페이지로 읽기 때문에, 한국어 Windows에서는 UTF-8로 저장한 .cpp를 CP949로 해석합니다. 그 결과 warning C4819: 현재 코드 페이지(949)에서 표시할 수 없는 문자가 파일에 들어 있습니다가 뜨거나, 문자열 리터럴이 조용히 다른 바이트로 컴파일됩니다. 해결책은 컴파일 옵션 /utf-8(소스와 실행 문자 집합을 모두 UTF-8로)입니다. CMake라면 target_compile_options(app PRIVATE $<$<CXX_COMPILER_ID:MSVC>:/utf-8>)를 넣어 두세요. 콘솔 출력까지 맞추려면 프로그램 시작 시 SetConsoleOutputCP(CP_UTF8)을 호출합니다.
Go
package main
import (
"fmt"
"unicode/utf8"
"golang.org/x/text/encoding/korean"
"golang.org/x/text/transform"
"io"
"strings"
)
func main() {
// Go 내부: UTF-8
text := "Hello 한글 😀"
// 바이트 길이 vs 문자(rune) 길이
fmt.Println("Bytes:", len(text)) // 17
fmt.Println("Runes:", utf8.RuneCountInString(text)) // 10
// UTF-8 → EUC-KR 변환
encoder := korean.EUCKR.NewEncoder()
euckrBytes, _, _ := transform.Bytes(encoder, []byte(text))
fmt.Printf("EUC-KR: %x\n", euckrBytes)
// EUC-KR → UTF-8 변환
decoder := korean.EUCKR.NewDecoder()
utf8Text, _, _ := transform.String(decoder, string(euckrBytes))
fmt.Println(utf8Text)
}
고급 주제
정규화 (Normalization)
import unicodedata
# 한글 '가'를 표현하는 두 가지 방법
# 1. 완성형 (NFC): U+AC00
nfc = "가"
print(f"NFC: {len(nfc)} chars, {nfc.encode('utf-8').hex()}")
# NFC: 1 chars, eab080
# 2. 분해형 (NFD): U+1100 + U+1161 (첫소리 ㄱ + 가운뎃소리 ㅏ)
nfd = unicodedata.normalize('NFD', nfc)
print(f"NFD: {len(nfd)} chars, {nfd.encode('utf-8').hex(' ')}")
# NFD: 2 chars, e1 84 80 e1 85 a1
# 비교
print(nfc == nfd) # False (다른 바이트 시퀀스)
# 정규화 후 비교
print(unicodedata.normalize('NFC', nfc) ==
unicodedata.normalize('NFC', nfd)) # True
정규화가 실무에서 문제가 되는 대표 사례는 macOS에서 온 한글 파일명입니다. macOS의 파일 시스템과 Finder는 파일명을 NFD(분해형)로 다루는 경우가 많아서, Mac에서 압축한 zip이나 Mac에서 업로드한 파일을 Windows·리눅스에서 보면 “ㅎㅏㄴㄱㅡㄹ.txt”처럼 자모가 풀려 보입니다. 인코딩은 둘 다 UTF-8이라 깨진 것은 아니지만, 같은 이름의 파일이 두 개 생기거나 파일명 == '한글.txt' 비교가 실패합니다. 파일 업로드를 받는 서버라면 저장 전에 파일명을 unicodedata.normalize('NFC', name)으로 정규화하고, 검색·비교하는 문자열도 같은 형태로 맞춰 두면 이런 문제가 사라집니다. 사용자 이름이나 검색어처럼 외부에서 들어오는 문자열 비교 전반에 같은 원칙이 적용됩니다.
인코딩 감지
import chardet
def detect_encoding(file_path):
"""파일 인코딩 자동 감지"""
with open(file_path, 'rb') as f:
raw_data = f.read()
result = chardet.detect(raw_data)
return {
'encoding': result['encoding'],
'confidence': result['confidence'],
'language': result.get('language', '')
}
# 사용
info = detect_encoding('unknown.txt')
print(f"Encoding: {info['encoding']}")
print(f"Confidence: {info['confidence']*100:.1f}%")
# 올바른 인코딩으로 읽기
with open('unknown.txt', 'r', encoding=info['encoding']) as f:
content = f.read()
인코딩 변환
def convert_file_encoding(input_file, output_file, from_enc, to_enc):
"""파일 인코딩 변환"""
# 원본 읽기
with open(input_file, 'r', encoding=from_enc) as f:
content = f.read()
# 새 인코딩으로 저장
with open(output_file, 'w', encoding=to_enc) as f:
f.write(content)
print(f"✅ Converted: {from_enc} → {to_enc}")
# EUC-KR → UTF-8 변환
convert_file_encoding('old.txt', 'new.txt', 'euc-kr', 'utf-8')
웹 개발에서의 인코딩
HTML
<!DOCTYPE html>
<html>
<head>
<!-- ✅ UTF-8 선언 (필수) -->
<meta charset="UTF-8">
<title>한글 페이지</title>
</head>
<body>
<h1>안녕하세요</h1>
</body>
</html>
HTTP 헤더
from flask import Flask, Response
app = Flask(__name__)
@app.route('/korean')
def korean_page():
content = "<h1>안녕하세요</h1>"
# ✅ Content-Type에 charset 명시
return Response(
content,
mimetype='text/html; charset=utf-8'
)
# ❌ charset 없으면 브라우저가 추측 (깨질 수 있음)
JSON
import json
data = {"name": "홍길동", "message": "안녕하세요"}
# JSON은 기본적으로 UTF-8
json_str = json.dumps(data, ensure_ascii=False)
print(json_str)
# {"name": "홍길동", "message": "안녕하세요"}
# ensure_ascii=True (기본값)
json_str_ascii = json.dumps(data, ensure_ascii=True)
print(json_str_ascii)
# {"name": "\ud64d\uae38\ub3d9", "message": "\uc548\ub155\ud558\uc138\uc694"}
URL 인코딩
from urllib.parse import quote, unquote
# URL에 한글 포함
text = "한글 검색"
# URL 인코딩 (UTF-8 기반)
encoded = quote(text)
print(encoded)
# %ED%95%9C%EA%B8%80%20%EA%B2%80%EC%83%89
# URL 디코딩
decoded = unquote(encoded)
print(decoded) # "한글 검색"
# 완전한 URL
url = f"https://example.com/search?q={encoded}"
print(url)
# https://example.com/search?q=%ED%95%9C%EA%B8%80%20%EA%B2%80%EC%83%89
데이터베이스 인코딩
MySQL
-- 데이터베이스 생성 (UTF-8)
CREATE DATABASE mydb
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
-- utf8mb4: 4바이트까지 저장하는 진짜 UTF-8 (이모지 지원). MySQL 8.0부터 기본값이고 기본 콜레이션은 utf8mb4_0900_ai_ci
-- utf8 (= utf8mb3): 3바이트까지만 저장 (이모지 저장 불가, deprecated)
-- utf8mb3 컬럼에 이모지를 넣으면 "Incorrect string value: '\xF0\x9F\x98\x80'" 에러가 나거나 설정에 따라 잘려서 저장됨
-- 테이블 생성
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(100) CHARACTER SET utf8mb4
);
-- 연결 시 인코딩 설정
SET NAMES utf8mb4;
PostgreSQL
-- 데이터베이스 생성 (template1과 다른 로케일을 쓰려면 TEMPLATE template0 필요)
CREATE DATABASE mydb
TEMPLATE template0
ENCODING 'UTF8'
LC_COLLATE 'ko_KR.UTF-8'
LC_CTYPE 'ko_KR.UTF-8';
-- 서버 OS에 ko_KR.UTF-8 로케일이 없으면 실패 (locale -a로 확인). 한글 정렬 순서가 필요 없다면 'C.UTF-8'도 선택지
-- 클라이언트 인코딩 확인
SHOW client_encoding;
-- 인코딩 변경
SET client_encoding TO 'UTF8';
Python + DB
import psycopg2
# PostgreSQL 연결
conn = psycopg2.connect(
host='localhost',
database='mydb',
user='user',
password='pass',
client_encoding='utf8'
)
cursor = conn.cursor()
# 한글 데이터 삽입
cursor.execute(
"INSERT INTO users (name) VALUES (%s)",
("홍길동",)
)
# 조회
cursor.execute("SELECT name FROM users")
name = cursor.fetchone()[0]
print(name) # "홍길동"
실전 도구
명령줄 도구
# 1. file 명령어로 인코딩 확인
file -i file.txt
# file.txt: text/plain; charset=utf-8
# 2. iconv로 인코딩 변환
iconv -f EUC-KR -t UTF-8 old.txt > new.txt
# 3. 여러 파일 일괄 변환
find . -name "*.txt" -exec iconv -f EUC-KR -t UTF-8 {} -o {}.utf8 \;
# 4. hexdump로 바이트 확인
echo "한글" | hexdump -C
# 00000000 ed 95 9c ea b8 80 0a
# 5. BOM 제거
tail -c +4 file_with_bom.txt > file_no_bom.txt # UTF-8 BOM (3바이트)
Python 스크립트
#!/usr/bin/env python3
"""
파일 인코딩 일괄 변환 도구
"""
import os
import sys
import chardet
from pathlib import Path
def convert_directory(directory, from_enc=None, to_enc='utf-8'):
"""디렉토리 내 모든 텍스트 파일 인코딩 변환"""
for file_path in Path(directory).rglob('*.txt'):
try:
# 원본 읽기
with open(file_path, 'rb') as f:
raw_data = f.read()
# 인코딩 감지
if from_enc is None:
detected = chardet.detect(raw_data)
source_enc = detected['encoding']
confidence = detected['confidence']
if confidence < 0.7:
print(f"⚠️ {file_path}: Low confidence ({confidence:.2f})")
continue
else:
source_enc = from_enc
# 이미 UTF-8이면 스킵
if source_enc.lower().replace('-', '') == 'utf8':
print(f"✓ {file_path}: Already UTF-8")
continue
# 변환
text = raw_data.decode(source_enc)
# 저장
with open(file_path, 'w', encoding=to_enc) as f:
f.write(text)
print(f"✅ {file_path}: {source_enc} → {to_enc}")
except Exception as e:
print(f"❌ {file_path}: {e}")
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: python convert_encoding.py <directory>")
sys.exit(1)
convert_directory(sys.argv[1])
인코딩 비교표
저장 공간 비교
text = "Hello 한글 😀"
encodings = {
'ASCII (영문만)': 'ascii',
'UTF-8': 'utf-8',
'UTF-16 LE': 'utf-16-le',
'UTF-16 BE': 'utf-16-be',
'UTF-32 LE': 'utf-32-le',
'EUC-KR': 'euc-kr',
'CP949': 'cp949',
}
print(f"원본 텍스트: {text}\n")
print(f"{'인코딩':15s} | {'바이트':6s} | Hex")
print("-" * 50)
for name, enc in encodings.items():
try:
encoded = text.encode(enc)
hex_str = encoded.hex()[:30] + ('...' if len(encoded) > 15 else '')
print(f"{name:15s} | {len(encoded):4d}B | {hex_str}")
except UnicodeEncodeError:
print(f"{name:15s} | {'N/A':6s} | (인코딩 불가)")
# 출력:
# 원본 텍스트: Hello 한글 😀
#
# 인코딩 | 바이트 | Hex
# --------------------------------------------------
# ASCII (영문만) | N/A | (인코딩 불가)
# UTF-8 | 17B | 48656c6c6f20ed959ceab88020f09f...
# UTF-16 LE | 22B | 480065006c006c006f0020005cd500...
# UTF-16 BE | 22B | 00480065006c006c006f0020d55cae...
# UTF-32 LE | 40B | 48000000650000006c0000006c0000...
# EUC-KR | N/A | (인코딩 불가)
# CP949 | N/A | (인코딩 불가)
특성 비교
| 인코딩 | 바이트/문자 | ASCII 호환 | 한글 효율 | 이모지 | 주요 사용처 |
|---|---|---|---|---|---|
| ASCII | 1 | ✅ | ❌ | ❌ | 영문 전용 |
| EUC-KR | 1-2 | ✅ | ✅✅ | ❌ | 한국 레거시 |
| CP949 | 1-2 | ✅ | ✅✅ | ❌ | Windows 한글 |
| UTF-8 | 1-4 | ✅ | ✅ | ✅ | 웹, Linux, 현대 표준 |
| UTF-16 | 2-4 | ❌ | ✅✅ | ✅ | Windows, Java 내부 |
| UTF-32 | 4 | ❌ | ❌ | ✅ | 내부 처리 |
실전 시나리오
시나리오 1: 레거시 시스템 연동
# 문제: 은행 API가 EUC-KR 응답
import requests
response = requests.get('http://legacy-bank-api.com/account')
# ❌ 자동 디코딩 (UTF-8 가정)
# print(response.text) # 깨짐
# ✅ 올바른 처리
content = response.content # 바이트
text = content.decode('euc-kr')
print(text)
# ✅ 또는 requests에 힌트 제공
response.encoding = 'euc-kr'
print(response.text)
시나리오 2: 다국어 지원 애플리케이션
import locale
import sys
def setup_encoding():
"""시스템 인코딩 설정"""
# 표준 출력 인코딩 확인
print(f"stdout encoding: {sys.stdout.encoding}")
# 시스템 로케일
print(f"System locale: {locale.getpreferredencoding()}")
# UTF-8 강제 (Python 3.7+)
if sys.stdout.encoding != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
# 다국어 텍스트 처리
texts = {
'en': "Hello",
'ko': "안녕하세요",
'ja': "こんにちは",
'zh': "你好",
'ar': "مرحبا",
'ru': "Здравствуйте",
'emoji': "👋🌍"
}
for lang, text in texts.items():
utf8 = text.encode('utf-8')
print(f"{lang:5s}: {text:15s} | {len(utf8):2d} bytes | {utf8.hex()[:30]}")
시나리오 3: 파일 업로드 처리
from flask import Flask, request
import chardet
app = Flask(__name__)
@app.route('/upload', methods=['POST'])
def upload_file():
file = request.files['file']
# 바이너리로 읽기
content = file.read()
# 인코딩 감지
detected = chardet.detect(content)
encoding = detected['encoding']
confidence = detected['confidence']
print(f"Detected: {encoding} ({confidence*100:.1f}%)")
# UTF-8로 변환
if encoding.lower() != 'utf-8':
try:
text = content.decode(encoding)
utf8_content = text.encode('utf-8')
return {
'status': 'converted',
'from': encoding,
'to': 'utf-8',
'content': text
}
except Exception as e:
return {'status': 'error', 'message': str(e)}, 400
return {
'status': 'ok',
'encoding': 'utf-8',
'content': content.decode('utf-8')
}
Python 코드에서 인코딩을 명시하고 BOM을 다루는 법
open()에 encoding을 항상 명시하기
Python의 open()은 encoding을 생략하면 OS 로케일 인코딩을 씁니다. 리눅스·macOS에서는 대개 UTF-8이라 문제가 안 보이다가, 한국어 Windows에서는 cp949가 기본값이 되어 같은 코드가 UTF-8 파일을 읽으며 UnicodeDecodeError를 냅니다. “제 PC에서는 되는데 Windows 쓰는 동료 PC에서만 안 돼요”의 단골 원인입니다. python -X warn_default_encoding(3.10+)으로 실행하면 encoding을 생략한 곳마다 EncodingWarning이 나오므로 한 번에 찾을 수 있고, 환경 변수 PYTHONUTF8=1로 UTF-8 모드를 켜면 기본값이 UTF-8로 바뀝니다. PEP 686에 따라 Python 3.15부터는 UTF-8 모드가 기본이 될 예정이지만, 그 전 버전을 쓰는 한 명시하는 습관이 가장 확실합니다.
에러 처리
# ✅ 에러 처리 전략
def safe_decode(data, encodings=('utf-8', 'cp949')):
"""여러 인코딩 시도. 순서가 중요: UTF-8을 먼저 시도해야 함.
(UTF-8은 규칙이 엄격해서 다른 인코딩 바이트를 우연히 통과하는 일이 드물지만,
latin-1은 모든 바이트를 받아들여 절대 실패하지 않으므로 목록에 넣으면 항상 '성공'으로 끝남)"""
for enc in encodings:
try:
return data.decode(enc), enc
except UnicodeDecodeError:
continue
# 모두 실패하면 에러 무시하고 디코딩
return data.decode('utf-8', errors='replace'), 'utf-8'
# 사용
with open('unknown.txt', 'rb') as f:
data = f.read()
text, encoding = safe_decode(data)
print(f"Decoded as {encoding}: {text}")
BOM 처리
# ✅ 읽을 때는 utf-8-sig: BOM이 있으면 제거, 없으면 그냥 UTF-8
with open('file.txt', 'r', encoding='utf-8-sig') as f:
text = f.read()
# ✅ 일반 파일은 BOM 없이 저장
with open('file.txt', 'w', encoding='utf-8') as f:
f.write(text)
# ⚠️ Excel에서 열 CSV처럼 BOM이 필요한 경우에만 utf-8-sig로 저장
with open('export.csv', 'w', encoding='utf-8-sig', newline='') as f:
f.write(text)
BOM을 피하라는 이유는 BOM을 모르는 도구가 그 3바이트를 내용으로 취급하기 때문입니다. 셸 스크립트의 #! 앞에 BOM이 붙으면 실행이 안 되고, JSON 파서 중 일부는 첫 글자에서 에러를 내며, PHP는 BOM을 그대로 출력해 “headers already sent” 에러를 냅니다. 반대로 Excel CSV처럼 BOM이 있어야 제대로 동작하는 곳도 있어서, “항상 금지”가 아니라 “받는 쪽이 필요로 할 때만”이 정확한 규칙입니다.
문제 해결 체크리스트
한글이 깨질 때
# 1. 파일 인코딩 확인
import chardet
with open('file.txt', 'rb') as f:
result = chardet.detect(f.read())
print(result)
# 2. 올바른 인코딩으로 읽기
with open('file.txt', 'r', encoding='cp949') as f:
text = f.read()
# 3. UTF-8로 재저장
with open('file.txt', 'w', encoding='utf-8') as f:
f.write(text)
웹에서 한글이 깨질 때
# 1. HTTP 헤더 확인
import requests
response = requests.get('https://example.com')
print(response.encoding) # ISO-8859-1 (잘못된 추측)
# 2. 올바른 인코딩 설정
response.encoding = 'utf-8'
print(response.text)
# 3. Content-Type 헤더 확인
print(response.headers.get('Content-Type'))
# text/html; charset=euc-kr
# 4. 명시적 디코딩
text = response.content.decode('euc-kr')
데이터베이스에서 한글이 깨질 때
# 1. 연결 인코딩 확인
import pymysql
conn = pymysql.connect(
host='localhost',
user='user',
password='pass',
database='mydb',
charset='utf8mb4' # ✅ 명시적 지정
)
# 2. 테이블 인코딩 확인
cursor = conn.cursor()
cursor.execute("SHOW CREATE TABLE users")
print(cursor.fetchone())
# 3. 인코딩 변환
# ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4;
디버깅 도구
Python 인코딩 디버거
def analyze_encoding(file_path):
"""파일 인코딩 상세 분석"""
with open(file_path, 'rb') as f:
raw_data = f.read()
print(f"📄 파일: {file_path}")
print(f"📊 크기: {len(raw_data)} bytes\n")
# BOM 확인
if raw_data.startswith(b'\xef\xbb\xbf'):
print("🔖 BOM: UTF-8")
elif raw_data.startswith(b'\xff\xfe'):
print("🔖 BOM: UTF-16 LE")
elif raw_data.startswith(b'\xfe\xff'):
print("🔖 BOM: UTF-16 BE")
else:
print("🔖 BOM: None")
# 인코딩 감지
detected = chardet.detect(raw_data)
print(f"\n🔍 감지된 인코딩: {detected['encoding']}")
print(f"📈 신뢰도: {detected['confidence']*100:.1f}%")
# 여러 인코딩으로 시도
print("\n🧪 디코딩 테스트:")
encodings = ['utf-8', 'cp949', 'euc-kr', 'utf-16', 'latin-1']
for enc in encodings:
try:
text = raw_data.decode(enc)
preview = text[:50].replace('\n', '\\n')
print(f" ✅ {enc:10s}: {preview}")
except UnicodeDecodeError as e:
print(f" ❌ {enc:10s}: {e}")
# Hex dump (처음 100바이트)
print(f"\n🔢 Hex Dump (first 100 bytes):")
for i in range(0, min(100, len(raw_data)), 16):
hex_str = ' '.join(f'{b:02x}' for b in raw_data[i:i+16])
ascii_str = ''.join(chr(b) if 32 <= b < 127 else '.' for b in raw_data[i:i+16])
print(f" {i:04x}: {hex_str:48s} | {ascii_str}")
# 사용
analyze_encoding('mystery.txt')
자주 묻는 질문 (FAQ)
Q. Windows 콘솔(cmd, PowerShell)에서 프로그램 출력 한글이 깨집니다.
A. 콘솔의 코드 페이지와 프로그램이 출력하는 바이트의 인코딩이 다르기 때문입니다. 한국어 Windows 콘솔의 기본 코드 페이지는 949이므로, UTF-8로 출력하는 프로그램(대부분의 크로스 플랫폼 도구, Git, Node.js)의 한글이 깨집니다. chcp 65001로 현재 콘솔을 UTF-8로 바꾸거나, Windows Terminal을 쓰거나, 설정의 “Beta: 세계 언어 지원을 위해 Unicode UTF-8 사용”을 켜서 시스템 코드 페이지 자체를 UTF-8로 바꿀 수 있습니다. 마지막 방법은 CP949를 가정하는 오래된 프로그램을 깨뜨릴 수 있으므로 업무용 PC에서는 영향을 확인한 뒤 적용하세요.
참고 자료
- Unicode Standard
- UTF-8 Specification (RFC 3629)
- Character Encoding in Python
- The Absolute Minimum Every Software Developer Must Know About Unicode