CSV 인코딩: UTF-8·BOM·CP949 와 엑셀 한글 깨짐 실측

같은 CSV 를 UTF-8·BOM·CP949 로 써서 바이트를 확인. Python·PHP·sqlite3 로 다시 읽고, BOM 부작용, EUC-KR 에 없는 똠, 이모지 오류까지 실측해 엑셀용 판단표로 정리.

시리즈: 문자 인코딩 강좌 4/6 (중급). CSV 를 내보내거나 받아서 처리하다가 거래처나 회계 담당자에게 "한글이 깨져요" 라는 말을 듣는 개발자를 위한 회입니다. 같은 데이터를 여러 인코딩으로 써서 바이트를 확인하고, 세 가지 도구로 다시 읽어 보고, "누가 여는가" 에 따라 인코딩을 고르는 판단표를 만듭니다.

엑셀로 열었더니 源��삝,�꽌�슱 같은 글자가 나왔다거나, 공공데이터포털에서 받은 CSV 를 pandas 나 Python 으로 읽다가 UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb1 을 만났다거나, 헤더에 분명 name 이 있는데 KeyError: 'name' 으로 가져오기가 실패한다면 이 글이 그 원인을 다룹니다. CSV 에는 인코딩을 적어 둘 자리가 없어서 모든 프로그램이 추측하고, 그 추측의 흔적이 바이트에 남습니다. 여기서는 각 경우의 정확한 바이트를 Python 으로 만들고, Python csv, PHP fgetcsv, sqlite3 .import 로 읽고, BOM 의 영향을 잽니다. 엑셀과 윈도우는 여기에 없어서 그 동작은 마이크로소프트 문서에 근거한 설명이며 "여기서는 실행하지 않음" 이라고 밝힙니다.

이전 강좌: 강좌 3: 유니코드 정규화와 자모 분리. 전체 지도는 프로그래밍 학습 로드맵에 있습니다.

검증 환경: macOS 26.6.2, Apple Silicon Mac(M1 Pro), Python 3.9.6(csv, codecs), PHP 8.5.6(mbstring), sqlite3 3.51.0, macOS 기본 iconv 와 file, xxd. 경로는 ~/demo 로 줄였습니다.

같은 행을 세 가지 인코딩으로 쓰기

BOM(바이트 순서 표시)은 파일 맨 앞에 두는 U+FEFF 라는 글자로, UTF-8 에서는 EF BB BF 3바이트입니다. UTF-8 에는 바이트 순서가 없으니 여기서는 "이 파일은 UTF-8" 이라는 표시로만 쓰입니다. CP949 는 한국어 윈도우가 쓰는 코드 페이지로, EUC-KR 에 한글 음절을 더 넣은 확장입니다. 마이크로소프트는 "통합형 한글 코드(UHC)" 라고도 부르고, Python 에서는 cp949, PHP 에서는 CP949 또는 UHC 라고 씁니다.

# write_csv.py - 같은 행을 세 가지 인코딩으로 쓴다
import csv, sys
ROWS = {
    "en": [["name", "city", "note"], ["Zoë", "São Paulo", "it’s café"]],
    "ja": [["name", "city", "note"], ["髙橋", "東京", "①~③"]],
    "ko": [["name", "city", "note"], ["김똠", "서울", "한글"]],
    "es": [["name", "city", "note"], ["Peña", "Bogotá", "¿Qué tal?"]],
}
LEGACY = {"en": "cp1252", "ja": "cp932", "ko": "cp949", "es": "cp1252"}
lang = sys.argv[1]
for enc in ("utf-8", "utf-8-sig", LEGACY[lang]):
    with open(f"{lang}_{enc}.csv", "w", encoding=enc, newline="") as f:
        csv.writer(f).writerows(ROWS[lang])

newline="" 은 중요합니다. csv 모듈이 \r\n 을 직접 쓰기 때문에(아래의 0d 0a), 이게 없으면 줄바꿈이 한 번 더 변환됩니다. 한국어 행을 세 인코딩으로 봅니다.

~/demo % xxd ko_utf-8.csv
00000000: 6e61 6d65 2c63 6974 792c 6e6f 7465 0d0a  name,city,note..
00000010: eab9 80eb 98a0 2cec 849c ec9a b82c ed95  ......,......,..
00000020: 9cea b880 0d0a                           ......
~/demo % xxd ko_utf-8-sig.csv | head -1
00000000: efbb bf6e 616d 652c 6369 7479 2c6e 6f74  ...name,city,not
~/demo % xxd ko_cp949.csv
00000000: 6e61 6d65 2c63 6974 792c 6e6f 7465 0d0a  name,city,note..
00000010: b1e8 8c63 2cbc adbf ef2c c7d1 b1db 0d0a  ...c,....,......

한글 음절은 UTF-8 에서 3바이트, CP949 에서 2바이트입니다. 파일 크기는 38, 41, 32바이트였습니다. 눈여겨볼 곳은 8c63 입니다. '똠' 의 CP949 코드인데, 두 번째 바이트 63 이 ASCII 의 c 와 같은 값이라 xxd 오른쪽에 c 가 보입니다. file 명령은 CP949 를 알아보지 못하고 charset=unknown-8bit 라고 답했습니다.

EUC-KR 과 CP949 는 같지 않다

EUC-KR 은 KS X 1001 완성형 2,350자만 담고 있어서, 자주 쓰지 않는 음절은 아예 없습니다. 똠·뷁이 대표적입니다. 각 도구가 어떻게 반응하는지 확인했습니다.

~/demo % python3 read_csv.py ko_cp949.csv euc_kr
UnicodeDecodeError: 'euc_kr' codec can't decode byte 0x8c in position 18: illegal multibyte sequence
~/demo % iconv -f EUC-KR -t UTF-8 ko_cp949.csv; echo "exit=$?"
iconv: iconv(): Illegal byte sequence
name,city,note
김exit=1
~/demo % iconv -f CP949 -t UTF-8 ko_cp949.csv
name,city,note
김똠,서울,한글

iconv 는 '김' 까지 출력하고 '똠' 에서 멈췄습니다. 18번째 바이트 8c 는 EUC-KR 에서는 쓰지 않는 범위라서입니다. 반대 방향도 도구마다 다릅니다.

'똠' 을 EUC-KR 로 쓰기 결과
Python "똠".encode("euc_kr") a4 d4 a4 a8 a4 c7 a4 b1(8바이트)
iconv -f UTF-8 -t EUC-KR iconv(): Illegal byte sequence, 종료 코드 1
PHP mb_convert_encoding("똠", "EUC-KR") 3f(?)
CP949 로 쓰면(세 도구 공통) 8c 63

Python 의 euc_kr 은 KS X 1001 의 "8바이트 조합 표현"(채움 문자 + 초성·중성·종성)으로 똠을 썼습니다. 이 8바이트를 iconv -f EUC-KR 로 읽으면 ㅤㄸㅗㅁ 처럼 자모가 따로 나왔습니다. 같은 "EUC-KR" 이라는 이름으로 세 도구가 세 가지 결과를 낸 셈입니다. 한국에서 만들어진 "EUC-KR CSV" 는 사실상 거의 모두 CP949 이므로, 읽을 때는 cp949 를 지정하는 것이 안전합니다.

다시 읽기: Python·PHP·sqlite3

인코딩을 잘못 고른 Python csv

# read_csv.py - 지정한 인코딩으로 CSV 를 읽고 헤더를 보여 준다
import csv, sys
path, enc = sys.argv[1], sys.argv[2]
with open(path, encoding=enc, newline="") as f:
    rows = list(csv.DictReader(f))
print(enc, list(rows[0].keys()))
print("row['name'] =", rows[0].get("name"))
~/demo % python3 read_csv.py ko_cp949.csv utf-8
Traceback (most recent call last):
  ...
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb1 in position 16: invalid start byte

16번째 바이트는 '김' 의 첫 바이트 b1 입니다. UTF-8 에서 b1 은 이어지는 바이트로만 쓸 수 있어서 시작 바이트가 될 수 없습니다. 공공데이터 CSV 를 처음 열 때 가장 많이 보는 에러가 이것입니다. 엄격한 디코더가 시끄럽게 실패하는 건 좋은 일입니다. 잘못된 데이터가 한 글자도 들어가지 않았으니까요.

BOM 부작용 측정

이번에는 BOM 이 있는 파일을 일반 utf-8 로 읽습니다.

~/demo % python3 read_csv.py ko_utf-8-sig.csv utf-8
utf-8 ['name', 'city', 'note']
row['name'] = None
~/demo % python3 read_csv.py ko_utf-8-sig.csv utf-8-sig
utf-8-sig ['name', 'city', 'note']
row['name'] = 김똠
~/demo % python3 read_csv.py ko_utf-8.csv utf-8-sig
utf-8-sig ['name', 'city', 'note']
row['name'] = 김똠

첫 헤더가 name 이 됐습니다. 터미널에서는 대개 name 으로 보여서 버그가 숨습니다. row.get("name") 은 None, row["name"] 이면 KeyError 입니다. 중요한 건 마지막 결과입니다. utf-8-sig 는 BOM 없는 파일도 읽습니다. 읽을 때 기본값은 이것으로 두면 안전합니다.

PHP fgetcsv 도 BOM 을 떼어 내지 않습니다.

<?php
// read.php - fgetcsv 와 BOM
$f = fopen($argv[1], 'r');
$head = fgetcsv($f, null, ',', '"', '');
$row  = fgetcsv($f, null, ',', '"', '');
$rec  = array_combine($head, $row);
var_dump($head[0], strlen($head[0]), isset($rec['name']));
echo bin2hex($head[0]), "\n";
~/demo % php read.php ko_utf-8-sig.csv
string(7) "name"
int(7)
bool(false)
efbbbf6e616d65

네 글자 헤더가 7바이트라면 BOM 입니다. 다섯 번째 인수 '' 에도 주의하세요. 빼면 PHP 8.5 는 Deprecated: fgetcsv(): the $escape parameter must be provided as its default value will change 를 냅니다. BOM 을 떼고, 알고 있는 레거시 인코딩을 변환하는 작은 읽기 함수입니다.

<?php
// read2.php - BOM 을 떼고 레거시 인코딩을 UTF-8 로 바꾼다
function open_csv(string $path, string $from = 'UTF-8') {
    $data = file_get_contents($path);
    if (str_starts_with($data, "\xEF\xBB\xBF")) {
        $data = substr($data, 3);
    } elseif ($from !== 'UTF-8') {
        $data = mb_convert_encoding($data, 'UTF-8', $from);
    }
    $f = fopen('php://memory', 'r+');
    fwrite($f, $data);
    rewind($f);
    return $f;
}
$f = open_csv($argv[1], $argv[2] ?? 'UTF-8');
while (($r = fgetcsv($f, null, ',', '"', '')) !== false) {
    echo implode(' | ', $r), "\n";
}
~/demo % php read2.php ko_cp949.csv CP949
name | city | note
김똠 | 서울 | 한글

sqlite3 .import

sqlite3 3.51.0 은 BOM 을 알아서 처리했습니다. 반면 레거시 바이트는 아무 말 없이 그대로 저장했습니다.

~/demo % sqlite3 :memory: ".import --csv ko_utf-8-sig.csv t" "select name, hex(name), length(name) from t;"
김똠|EAB980EB98A0|2
~/demo % sqlite3 :memory: ".import --csv ko_cp949.csv t" "select name, hex(name), length(name) from t;"
��c|B1E88C63|3

두 번째 테이블에는 UTF-8 로 올바르지 않은 바이트가 TEXT 컬럼에 들어갔고 length 도 틀렸습니다. sqlite3 는 변환해 주지 않으니 가져오기 전에 iconv -f CP949 -t UTF-8 로 바꿉니다.

엑셀은 무엇을 하나(설명만, 여기서는 실행하지 않음)

마이크로소프트 문서와 지원 글에 따르면, 윈도우용 엑셀은 더블클릭으로 연 .csv 를 맨 앞에 UTF-8 BOM 이 없는 한 시스템의 "ANSI" 코드 페이지로 읽습니다. 한국어 윈도우라면 CP949 입니다. 다른 이름으로 저장에는 BOM 을 붙여 쓰는 "CSV UTF-8(쉼표로 분리)" 이 있고, "데이터 > 텍스트/CSV에서" 를 쓰면 인코딩을 직접 고를 수 있습니다. 엑셀을 여기서 돌릴 수는 없지만, UTF-8 바이트를 CP949 로 디코딩하면 그렇게 읽었을 때의 결과를 정확히 재현할 수 있습니다.

ko cp949 -> 源��삝,�꽌�슱,�븳湲�
   BOM bytes as cp949 -> '癤�'

"김똠,서울,한글" 이 源��삝,�꽌�슱,�븳湲� 이 됐습니다. 한자와 깨진 한글이 섞여 나오는 것이 UTF-8 을 CP949 로 읽었을 때의 전형적인 모습입니다. 거꾸로 CP949 파일을 UTF-8 로 읽는 프로그램(맥의 일부 앱, 웹 서비스)에서는 앞의 0xb1 에러나 � 가 나옵니다.

인코딩할 수 없는 글자

레거시 코드 페이지는 작아서, 회원 닉네임의 이모지 하나로 내보내기가 깨집니다.

# emoji_ko.py
import csv
with open("out.csv", "w", encoding="cp949", newline="") as f:
    csv.writer(f).writerows([["id", "nickname"], ["1", "민수👍"]])
~/demo % python3 emoji_ko.py
Traceback (most recent call last):
  File "~/demo/emoji_ko.py", line 3, in <module>
    csv.writer(f).writerows([["id", "nickname"], ["1", "민수👍"]])
UnicodeEncodeError: 'cp949' codec can't encode character '\U0001f44d' in position 4: illegal multibyte sequence
~/demo % wc -c out.csv
      13 out.csv

position 4 는 파일이 아니라 행 텍스트 1,민수👍 안의 위치입니다. 최악은 파일 내용입니다. 예외가 났을 때 out.csv 에는 이미 13바이트짜리 헤더 줄 id,nickname\r\n 이 들어가 있어서, cron 작업은 "멀쩡해 보이지만 잘린 CSV" 를 남깁니다.

PHP 는 반대로 조용히 바꿉니다.

~/demo % php write.php CP949 '민수👍' | xxd
00000000: 6964 2c6e 6963 6b6e 616d 650d 0a31 2cb9  id,nickname..1,.
00000010: cebc f63f 0d0a                           ...?..

이모지가 경고 없이 3f(?) 가 됐습니다. Python 도 errors="replace" 면 민수?, errors="xmlcharrefreplace" 면 민수&#128077; 가 됩니다. 정산 데이터라면 에러로 멈추는 게 맞고, 표시용 닉네임이라면 치환도 괜찮습니다. 의식적으로 고르세요.

흔한 실수: "일단 전부 BOM 을 붙인다"

실수. 엑셀에서 한글이 깨진다는 항의가 한 번 들어오자, 사내 서비스가 읽는 CSV 까지 전부 BOM 을 붙인다.

실제로 일어나는 일. 일반 utf-8 로 읽는 쪽에서는 name(Python)이나 7바이트 name(PHP)이 되고, 첫 컬럼을 조용히 못 찾게 됩니다. 셸 도구에도 보입니다. cut -d, -f1 ko_utf-8-sig.csv | head -1 | xxd 는 efbb bf6e 616d 65 를 출력했고, cat a.csv a.csv 를 xxd 로 보면 데이터 중간에 0aef bbbf 6e61 6d65 가 남았습니다.

해결. 파일 단위가 아니라 읽는 쪽 단위로 정합니다. BOM 은 사람이 엑셀로 여는 파일에만 붙이고, 받는 CSV 는 모두 utf-8-sig 로 읽은 뒤 실패하면 알고 있는 레거시 인코딩 하나로만 바꿉니다.

# read_any.py - UTF-8(BOM 유무 상관없이) 또는 알고 있는 레거시 인코딩 하나
import csv, io
def read_rows(path, legacy):
    data = open(path, "rb").read()
    try:
        text, used = data.decode("utf-8-sig"), "utf-8-sig"
    except UnicodeDecodeError:
        text, used = data.decode(legacy), legacy      # strict 그대로: 실패는 크게
    return used, list(csv.DictReader(io.StringIO(text, newline="")))
ko_cp949.csv      cp949     {'name': '김똠', 'city': '서울', 'note': '한글'}

셸에서 BOM 을 붙이고 떼는 건 바이트 조작입니다. { printf '\xEF\xBB\xBF'; cat in.csv; } > out.csv 로 붙이고 tail -c +4 로 뗍니다. 둘 다 Python 의 결과와 바이트 단위로 같았습니다(cmp 출력 없음). macOS iconv 에는 UTF-8-BOM 이 없습니다(iconv_open(UTF-8-BOM, UTF-8): Invalid argument).

크기: UTF-8 은 커지나?

"UTF-8 로 바꾸면 파일이 커진다" 는 반대도 자주 듣습니다. 언어별 샘플 단어로 1만 행 CSV(id·name·city·note)를 시드 42 로 만들고 바이트 수를 셌습니다.

막대그래프(KB): 영어 UTF-8 276.5·cp1252 255.8, 스페인어 UTF-8 309.8·cp1252 279.7, 일본어 UTF-8 298.6·cp932 221.9, 한국어 UTF-8 322.0·cp949 245.4.
같은 1만 행 CSV(id·name·city·note)의 언어별·인코딩별 크기. 시드 42, 단위 KB(1 KB = 1,000바이트). 라틴 문자는 UTF-8 차이가 작고 일본어·한국어는 약 30% 늘어난다. BOM 은 3바이트.

한국어는 UTF-8 이 CP949 보다 31%, 일본어는 35% 컸습니다. 한글·가나·한자가 UTF-8 에서는 3바이트, CP949·CP932 에서는 2바이트이기 때문입니다. 영어와 스페인어는 8%, 11% 였습니다. 하지만 gzip(레벨 9, mtime=0)을 걸면 차이는 거의 사라져 한국어도 3.9%(34,779바이트 대 33,471바이트)였습니다. 전송·저장 비용은 문의 한 건 응대보다 훨씬 쌉니다.

어떤 인코딩으로 쓸까: 판단표

판단 그림. 질문: 그 CSV 를 누가 여는가. 사람이 엑셀에서 더블클릭한다면 BOM 있는 UTF-8. 우리 코드·DB 가져오기·유닉스 도구가 읽는다면 BOM 없는 UTF-8. 코드 페이지가 정해진 오래된 시스템이면 cp932·cp949·cp1252 등 바로 그 코드 페이지로 쓰고, 바꿀 수 없는 글자는 에러로 할지 치환할지 정한다.
CSV 인코딩은 습관이 아니라 '누가 여는가' 로 정한다. 무엇으로 쓰든, 받는 CSV 는 먼저 utf-8-sig 로 읽고 실패하면 미리 아는 레거시 코드 페이지 하나로만 바꾼다.
여는 사람·시스템 쓸 인코딩 주의
윈도우 엑셀에서 더블클릭하는 사람 BOM 있는 UTF-8(utf-8-sig) 엑셀 동작은 문서 근거 설명, 실행하지 않음
우리 코드, DB 가져오기, API BOM 없는 UTF-8 읽는 쪽은 utf-8-sig 로
유닉스 도구(cut·sort·awk·cat) BOM 없는 UTF-8 BOM 이 첫 필드에 붙음
"EUC-KR 로 달라" 는 오래된 시스템 CP949(cp949), 똠·뷁이 들어가면 상대가 정말 읽는지 확인 이모지 등은 에러냐 치환이냐를 정함
상대를 모르거나 섞여 있음 BOM 있는 UTF-8 + 가져오기 방법 안내 원본 UTF-8 도 보관

솔직한 한계와 버전 메모

  • 엑셀·윈도우·구글 스프레드시트는 실행하지 않았습니다. 엑셀 동작은 마이크로소프트 문서와 지원 글에 근거하며, 마이크로소프트 365 의 새 버전에서는 CSV 여는 방식이 바뀔 수 있습니다.
  • Python euc_kr 의 8바이트 조합 표현은 Python 3.9.6 에서 확인했습니다. 다른 언어·도구가 이 표현을 받아들이는지는 따로 확인해야 합니다.
  • str_starts_with 는 PHP 8.0 이상, fputcsv 의 줄바꿈 인수는 PHP 8.1 이상입니다. $escape 경고는 PHP 8.5.6 에서 확인했습니다.
  • sqlite3 의 BOM 처리는 3.51.0 에서만 확인했습니다.

연습 문제

  1. 거래처의 customers.csv 를 Python encoding="utf-8" 로 읽으면 print(reader.fieldnames) 에는 ['id', ...] 로 나오는데 KeyError: 'id' 가 납니다. 원인과 한 단어짜리 수정은?
  2. 공공데이터포털에서 받은 CSV 를 encoding="euc-kr" 로 읽다가 일부 파일에서만 illegal multibyte sequence 가 납니다. 왜 일부 파일만일까요?

정답

  1. 파일에 UTF-8 BOM 이 있어서 첫 헤더가 'id' 입니다. repr(reader.fieldnames[0]) 으로 확인할 수 있습니다. 수정은 encoding="utf-8-sig" 입니다.
  2. 그 파일들에만 EUC-KR(완성형 2,350자)에 없는 음절, 예를 들어 똠·뷁 같은 글자가 들어 있기 때문입니다. 파일은 처음부터 CP949 였고, 다른 파일은 우연히 공통 범위의 글자만 있어서 통과한 것입니다. encoding="cp949" 로 읽으면 모두 읽힙니다.

이전: 강좌 3: 유니코드 정규화와 자모 분리. 다음: 강좌 5: 문자열 길이, 이모지, 자소. 관련 글: 셸 파이프와 텍스트 도구, SQL 기초, HTTP 헤더와 charset.

자주 묻는 질문

CSV 첫 번째 컬럼 이름이 \ufeffname 이 되는 이유는?

파일 맨 앞에 UTF-8 BOM(EF BB BF)이 있는데 일반 utf-8 로 읽어서 BOM 이 첫 헤더에 붙었습니다. 실측에서 Python DictReader 는 '\ufeffname' 을 돌려주고 row['name'] 은 None 이었으며, PHP fgetcsv 는 7바이트짜리 'name' 을 돌려줬습니다. Python 은 encoding='utf-8-sig', PHP 는 앞 3바이트를 잘라 냅니다.

공공데이터 CSV 를 euc-kr 로 읽었는데 에러가 난다. cp949 와 뭐가 다른가?

EUC-KR(KS X 1001)에는 완성형 2,350자만 있어서 똠·뷁 같은 글자가 없습니다. 윈도우의 CP949 는 나머지 한글 음절 8,822자를 더한 확장입니다. 똠이 든 CP949 파일을 euc_kr 로 읽자 'illegal multibyte sequence' 로 멈췄고, cp949 로 읽으면 정상이었습니다. 한국 기관의 CSV 는 cp949 로 읽으세요.

CP949 로 CSV 를 쓰다가 이모지에서 멈췄다. 어떻게 하나?

CP949 에는 👍 이 없어서 Python 은 UnicodeEncodeError 로 멈췄고, 헤더 줄만 든 13바이트 파일이 남았습니다. PHP mb_convert_encoding 은 조용히 ? 로 바꿨습니다. 에러로 멈출지, 치환할지, UTF-8 그대로 줄지 의식적으로 고릅니다.

  • #CSV
  • #UTF-8
  • #BOM
  • #CP949
  • #EUC-KR
  • #엑셀
  • #한글 깨짐
  • #중급

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑