Skip to content
블로그로 돌아가기
튜토리얼

UTF-8 BOM: JSON 파싱 오류와 CSV 깨짐 해결

파일은 멀쩡한데 JSON.parse가 실패한다면 원인은 보이지 않는 EF BB BF입니다. BOM 확인법, 언어별 제거법, Excel이 BOM을 요구하는 이유를 온라인으로 정리했습니다.

14 분 소요

UTF-8 BOM: JSON 파싱 오류와 CSV 깨짐 해결

UTF-8 BOM 때문에 생기는 JSON 파싱 오류는 눈에 보이지 않는 3바이트입니다. 편집기에서 열어 봐도 파일은 멀쩡하고 cat은 예상 그대로를 출력합니다. 린터도 아무 말이 없습니다. 그런데 JSON.parse는 맨 첫 글자에서 예외를 던집니다.

node v25.8.2에서 측정한 예외는 이렇게 생겼습니다:

SyntaxError: Unexpected token '', "{"a":1}" is not valid JSON

따옴표 안에 터미널이 무엇을 그려 냈든, 그것은 글자 하나입니다. 바로 U+FEFF이고, 파일에는 EF BB BF 세 바이트로 저장됩니다. 엄격한 JSON에는 이 글자가 들어갈 자리가 없습니다. 파서는 위치 0에서 {, [, 숫자, 따옴표, 공백 문자 중 하나를 기대하는데, U+FEFF는 그 어느 것도 아닙니다.

이미 BOM인 줄 아신다면, 손댈 수 있는 쪽을 고르세요:

손댈 수 있는 곳해결책
Node에서 파일을 읽을 때JSON.parse(raw.replace(/^/, ''))
Python에서 파일을 읽을 때open(path, encoding='utf-8-sig')
디스크에 놓인 파일 자체tail -c +4 data.json > clean.json

나머지 내용은 그것만으로 끝나지 않을 때를 위한 것입니다. BOM처럼 보이지만 BOM이 아닌 오류, 지워도 자꾸 다시 집어넣는 생성자, 지우는 쪽이 해결이 아니라 버그가 되는 유일한 형식을 차례로 다룹니다. BOM이 무엇인지, 새로 만드는 파일에 BOM을 넣어야 하는지는 UTF-8 vs UTF-16 vs Unicode 인코딩 완벽 가이드에서 다뤘습니다. 이 페이지는 이미 무언가가 깨진 뒤를 전제로 합니다.

아래의 측정값은 모두 node v25.8.2Python 3.14.5에서 나온 결과입니다.

1. BOM을 탓하기 전에 오류 메시지가 먼저 배제해 주는 것

위치 0의 JSON 오류를 검색해서 오는 사람 대부분은 BOM과 무관한 문제를 겪고 있습니다. 서로 다른 네 가지 문제가 같은 모양의 메시지를 내놓는데, 따옴표 안의 글자를 한 번만 보면 구분됩니다. 아래는 V8이 실제로 내보내는 문자열 그대로입니다:

오류 메시지실제 정체다음 단계
Unexpected token '', "{"a":1}" is not valid JSON바이트 0에 놓인 UTF-8 BOM2절
Unexpected token '<', "<!DOCTYPE "... is not valid JSON응답이 HTML이었습니다. 오류 페이지, 로그인 리다이렉트, 프록시 안내 중 하나입니다원본 응답 본문과 상태 코드를 로그로 남기세요
Unexpected end of JSON input본문이 비어 있었습니다상태 코드와 Content-Length를 확인하세요
"undefined" is not valid JSON한 번도 할당된 적 없는 변수를 JSON.parse에 넘겼습니다호출하는 쪽을 고치세요

규칙은 외울 만큼 짧습니다. 작은따옴표 안의 글자를 읽으세요. <라면 HTML을 받은 것입니다. 선택도 되지 않는 네모나 빈칸, 물음표라면 U+FEFF입니다. 따옴표 안에 아무것도 없다면 애초에 입력 자체가 없었던 것입니다.

예전 문구와 지금 문구

json parse unexpected token position 0으로 검색해서 나오는 글은 대부분 예전 V8 메시지를 기준으로 쓰였습니다:

SyntaxError: Unexpected token in JSON at position 0

그 문구는 오프셋만 알려 주고 글자는 감췄습니다. 지금 문구는 반대로 글자와 입력의 일부를 보여 줍니다. 훨씬 쓸모 있습니다. 대신 검색해서 도착한 페이지가 지금 쓰지도 않는 런타임을 기준으로 쓰였을 수 있습니다. 오류가 아직도 글자 대신 위치를 말한다면 예전 엔진을 쓰고 있는 것이고, 아래의 진단 과정은 그대로 유효합니다.

2. 10초 만에 BOM인지 확인하기

빠른 것부터 늘어놓았습니다. 넷 중 아무거나 하나면 결론이 납니다.

앞의 세 바이트를 보세요.

$ hexdump -C data.json | head -1
00000000  ef bb bf 7b 22 61 22 3a  31 7d                    |...{"a":1}|

7b({) 앞의 ef bb bf가 BOM입니다. 오른쪽 ASCII 칸의 ...은 보여 줄 만한 출력 가능 글자가 없다고 hexdump가 실토하는 부분입니다.

file에게 물어보세요. 곧이곧대로 알려 줍니다. 파일 종류에 대한 판정 자체도 달라집니다:

$ file data.json
data.json: Unicode text, UTF-8 (with BOM) text, with no line terminators

$ file clean.json
clean.json: JSON data

Node에서 첫 코드 포인트를 확인하세요.

const fs = require('fs');
const raw = fs.readFileSync('data.json', 'utf8');
console.log(raw.charCodeAt(0) === 0xFEFF);   // true

편집기 상태 표시줄을 읽으세요. VS Code는 오른쪽 아래 구석에 UTF-8 with BOM을 표시하고, 그것을 클릭하면 Save with encoding을 띄워 줍니다. 파일이 멀쩡해 보인 이유가 그 라벨 한 줄에 다 적혀 있습니다. 편집기는 알고 있었고, 다만 티를 내지 않았을 뿐입니다.

로컬에서 덤프를 뜰 수 없는 데이터를 바이트 단위로 들여다봐야 한다면 Base64 인코더 디코더에 붙여넣으세요. 페이로드 맨 앞의 UTF-8 BOM은 언제나 77u/로 시작하는 문자열로 인코딩됩니다. 이 접두사를 눈에 익혀 두면 로그 한 줄만 보고도 알아챌 수 있습니다.

3. BOM은 어디에서 왔는가

빌드 단계가 한 시간마다 다시 생성하는 파일에서 BOM을 지우는 수정은 수명이 한 시간짜리입니다. 흔한 생성자들입니다:

  • Excel의 다른 이름으로 저장 → CSV UTF-8. 이것은 버그가 아니라 의도된 동작이며, 이유는 7절에서 설명합니다.
  • 메모장을 비롯한 Windows 편집기들. UTF-8 with BOM을 별도의 저장 옵션으로 두고, 때로는 그것을 기본값으로 삼습니다.
  • VS Code. files.encodingutf8bom으로 설정된 경우인데, 사용자 설정에 있을 수도 있고 아무도 들여다보지 않는 .vscode/settings.json에 커밋되어 있을 수도 있습니다.
  • PowerShell의 셸 리다이렉션. 일부 PowerShell 버전에서 >Out-File은 기본적으로 BOM을 씁니다. 게다가 그 기본값이 Windows 전용 5.x 계열과 크로스 플랫폼 6/7 계열에서 서로 다릅니다. 이 부분은 기억에 의존하지 마십시오. 파일 하나를 직접 써 보고 2절의 명령으로 앞 세 바이트를 확인하세요.
  • 직접 짠 내보내기 코드. 시그니처를 붙일지 말지 명시하지 않고 UTF-8 인코더를 만들면 프레임워크가 고른 기본값을 그대로 물려받습니다. 그 기본값이 프레임워크마다 달랐습니다. 오래된 .NET과 오래된 Java의 내보내기 경로가 단골입니다.
  • 데이터베이스와 BI 내보내기 도구. 결과물을 받아 보는 쪽이 대개 스프레드시트이다 보니 BOM을 함께 붙여 내보내는 경우가 많습니다.

파일이 협력사나 벤더에서 오고 생성자를 바꿀 수 없다면 4절로 건너뛰어 읽는 시점에 제거하세요. 자기 저장소에서 나오는 파일이라면 9절이 오래가는 답입니다.

4. 자바스크립트(JavaScript)와 Node에서 고치기

혼란이 가장 몰리는 지점입니다. 자바스크립트 생태계에는 BOM 정책이 하나가 아니기 때문입니다. 여러 개이고, 서로 어긋납니다. 같은 파일, 같은 런타임, node v25.8.2 측정값입니다:

APIBOM 처리이어지는 JSON.parse
fetchres.json()제거됨성공
fs.readFileSync(f, 'utf8')유지됨실패
new TextDecoder() (기본값)제거됨성공
new TextDecoder('utf-8', { ignoreBOM: true })유지됨실패
require('./data.json')제거됨해당 없음, 이미 파싱됨
import(..., { with: { type: 'json' } })제거됨해당 없음, 이미 파싱됨

이 표에서 두 가지가 따라 나옵니다. 둘 다 반나절은 잡아먹는 것들입니다.

ignoreBOM은 이름과 정반대로 동작합니다

ignoreBOM: true는 “BOM을 무시하라”는 뜻이 아닙니다. “BOM의 특별한 의미를 무시하고 평범한 글자로 그대로 두라”는 뜻입니다. BOM을 없애 주는 쪽은 기본값인 false입니다. 이 이름은 디코더가 무엇을 무시하는지만 말하고 결과가 어떻게 되는지는 말하지 않습니다. 그래서 이름만 보고 켜면 지우려던 바로 그 바이트를 고스란히 남기는 디코더가 됩니다.

브라우저에서는 되는데 Node에서는 깨지는 이유

이 문제는 대개 이런 모습으로 나타납니다. 같은 JSON URL이 프런트엔드 코드에서는 잘 파싱되는데, Node 스크립트가 디스크에서 그 파일을 읽는 순간 예외가 납니다. 파일은 아무것도 달라지지 않았습니다. res.json()TextDecoder와 같은 경로로 디코딩하면서 도중에 BOM을 떨어뜨립니다. fs.readFileSync(path, 'utf8')은 파일에 들어 있는 글자를 U+FEFF까지 하나도 빼지 않고 그대로 넘겨줍니다.

require('./config.json')은 되는데 JSON.parse(fs.readFileSync('./config.json', 'utf8'))은 안 되는 것도 같은 비대칭 때문입니다. Node의 JSON 모듈 로더는 BOM을 제거하지만, 직접 읽는 경로는 그렇지 않습니다.

제거하기

const fs = require('fs');
const raw = fs.readFileSync('data.json', 'utf8');
const data = JSON.parse(raw.replace(/^/, ''));

패턴은 ^로 고정하세요. 고정하지 않은 전역 치환은 문자열 값 안에 정당하게 들어 있는 U+FEFF까지 지워 버리는데, 그것은 수정이 아니라 데이터 손실입니다.

우연히 통하는 대안도 하나 있습니다. JSON.parse(raw.trim()) 역시 성공하는데, ECMAScript가 U+FEFF를 공백 문자로 분류하고 String.prototype.trim이 그것을 제거하기 때문입니다. 위에서 확인했듯 실제로 그렇게 동작하지만, 이는 자바스크립트 명세의 우연일 뿐 다른 언어로 이어지지 않습니다. Python의 str.strip()은 U+FEFF를 발견한 자리에 그대로 둡니다.

제거한 결과가 예외만 안 나는 상태인지 정말 유효한 JSON인지 확인하고 싶다면 JSON 포맷터 온라인 검증기에 붙여넣으세요. BOM이 사라진 뒤에도 위치 0에서 걸린다면, 남는 후보는 JSON 문자열 이스케이프 완벽 가이드에서 다룬 평범한 이스케이프 문제들입니다.

5. Python에서 고치기: utf-8-sig

Python은 오류 메시지에서 문제의 이름을 직접 불러 주는 유일한 런타임입니다. BOM이 붙은 파일을 그냥 UTF-8로 열면 json이 원인과 해결책을 한 줄에 같이 담아 알려 줍니다:

JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0)

unexpected utf-8 bom으로 검색해서 여기까지 오셨다면, 그 문자열의 출처가 바로 이것입니다. 메시지가 가리키는 코덱은 BOM을 시그니처로 읽고 버립니다:

import json

with open('data.json', encoding='utf-8-sig') as f:
    data = json.load(f)

utf-8-sig는 BOM이 없는 파일에도 안전합니다. 있으면 하나 떼어 내고 없으면 평범한 UTF-8처럼 동작하므로, 직접 만들지 않은 파일이라면 무엇이든 이쪽을 기본값으로 삼는 것이 맞습니다.

바이트와 텍스트는 다르게 동작합니다

버그가 간헐적으로 보이게 만드는 비대칭이라 알아 둘 만합니다:

import json

json.loads(open('data.json', 'rb').read())       # {'a': 1}      성공
json.loads(open('data.json', encoding='utf-8').read())  # 위의 오류를 던집니다

bytes를 받은 json.loads는 먼저 인코딩 탐지 단계를 거쳐 BOM을 찾아내고 utf-8-sig로 디코딩해 줍니다. 이미 디코딩된 str을 넘기면 탐지할 것이 남아 있지 않으므로 U+FEFF가 파서까지 도달합니다. 똑같아 보이는 두 경로 중 한쪽만 조용히 상황을 처리해 주는 셈입니다.

일부러 BOM을 쓰기

같은 코덱은 반대 방향으로도 동작하며, Excel용 파일은 이렇게 생성합니다:

with open('report.csv', 'w', encoding='utf-8-sig', newline='') as f:
    f.write('name\n')

그 파일은 ef bb bf로 시작합니다. 언제 그렇게 하고 싶어지는지는 7절에서 다룹니다.

CSV의 함정

BOM이 붙은 텍스트를 받은 csv.DictReader는 올바른 CSV 파서가 해야 할 일을 정확히 해내고, 그 결과 아무도 맞출 수 없는 키를 내놓습니다:

import csv, io

data = 'name,age\nAlice,30\n'
print(list(next(csv.DictReader(io.StringIO(data))).keys()))
# ['name', 'age']

첫 번째 열의 이름은 name이 아닙니다. U+FEFF 다음에 name이 오는 이름이고, 그래서 row['name'] 조회는 매번 KeyError를 내는데 헤더는 어느 디버거에서 봐도 멀쩡하게 찍힙니다. 파일을 encoding='utf-8-sig'로 열면 리더가 보기도 전에 제거됩니다.

6. Java, Go, PHP, 셸에서 BOM 제거하기

모든 해결책은 층위만 다를 뿐 같은 해결책입니다. 손에 든 것이 바이트냐 텍스트냐에 따라 세 바이트(EF BB BF)를 지우거나 한 글자(U+FEFF)를 지우면 됩니다. 쓰는 언어에 BOM을 아는 코덱이 없다면 직접 하세요.

Java는 BOM을 맨 앞의  글자로 디코딩합니다:

String text = Files.readString(path, StandardCharsets.UTF_8);
if (!text.isEmpty() && text.charAt(0) == '') {
    text = text.substring(1);
}

Go는 언마셜링에 들어가기 전에 바이트 수준에서 처리합니다:

raw, err := os.ReadFile("data.json")
if err != nil {
    return err
}
raw = bytes.TrimPrefix(raw, []byte{0xEF, 0xBB, 0xBF})

var v map[string]any
err = json.Unmarshal(raw, &v)

PHP는 바이트에 고정한 패턴을 씁니다:

$raw  = file_get_contents('data.json');
$raw  = preg_replace('/^\xEF\xBB\xBF/', '', $raw);
$data = json_decode($raw, true);

변수가 아니라 파일 자체에서 BOM을 지울 때 쓰는 명령입니다. 네 가지 모두 ef bb bf로 시작하는 파일에 대고 확인했습니다:

# 제자리 수정, GNU sed(Linux). 이스케이프를 해석하는 쪽은 sed가 아니라 셸입니다.
sed -i $'1s/^\xEF\xBB\xBF//' data.json

# 제자리 수정, BSD sed(macOS)
sed -i '' $'1s/^\xEF\xBB\xBF//' data.json

# 제자리 수정, Perl이 있는 곳이면 어디서나. 첫 줄만 대상입니다.
perl -i -pe 's/^\x{ef}\x{bb}\x{bf}// if $. == 1' data.json

# 앞의 세 바이트를 뺀 복사본. BOM이 있다는 것을 알 때만 안전합니다.
tail -c +4 data.json > clean.json

tail을 쓰는 방식이 가장 거칩니다. 그 세 바이트가 BOM이었든 아니든 무조건 지웁니다. 2절로 먼저 확인하세요.

7. CSV라는 예외: Excel이 BOM을 남겨 두길 요구할 때

여기까지는 BOM을 손상으로 취급했습니다. 그런데 한 군데에서는 BOM이 구조를 떠받치고 있어서, 지우는 순간 멀쩡하던 파일이 깨집니다.

csv bom excel 검색은 정반대인 두 종류의 불만으로 갈립니다. 하나의 규칙이 엉뚱한 방향으로 적용되고 있다는 신호입니다:

  1. “CSV를 Excel에서 열었더니 제대로 된 글자 대신 Ã©æ¥æ¬èª가 나옵니다.” BOM이 없는 경우입니다.
  2. “첫 번째 열 이름이 name이라서 스크립트가 찾지 못합니다.” BOM이 있는 경우입니다.

Excel이 BOM을 원하는 이유

Windows의 Excel에는 CSV가 UTF-8인지 알아낼 믿을 만한 방법이 없습니다. 헤더도, 선언도, 메타데이터도 없습니다. .csv 파일은 그냥 바이트입니다. 신호가 없으면 시스템 로캘로 넘어가서 미국과 서유럽에서는 Windows-1252, 러시아에서는 Windows-1251로 읽고, 비-ASCII 글자는 전부 깨져 나옵니다. 그 신호가 바로 BOM입니다. 맨 앞의 세 바이트만 있으면 Excel은 UTF-8을 제대로 읽습니다.

그래서 CSV의 BOM은 결함이 아니라 기능이고, 판단 기준도 한 줄로 정리됩니다:

기계가 파싱할 파일이면 BOM을 지우고, 사람이 Excel에서 더블클릭할 파일이면 남겨 두십시오.

반대편에서 생기는 실패

같은 파일을 파서에 넣으면 BOM이 첫 헤더 칸에 녹아 들어갑니다. Node에서는 이렇습니다:

const header = 'name,age'.split(',');
console.log(JSON.stringify(header));   // ["name","age"]

const row = { 'name': 'Alice', age: 30 };
console.log(row.name);                 // undefined

row.nameundefined인데, 로그에서도 디버거에서도 console.table에서도 키는 name으로 찍힙니다. 5절의 Python KeyError와 모양이 같은 버그이고, 그래서 “필드 이름은 맞는데 값이 없다”는 증상을 보면 BOM부터 의심하는 편이 낫습니다.

저희 변환기들은 이 양쪽을 의도적으로 나눠서 처리합니다. CSV to JSON 변환기는 파싱하기 전에 입력 맨 앞의 BOM을 제거하므로, Excel에서 바로 나온 파일도 name이 아니라 name을 내놓습니다. 반대 방향인 JSON to CSV 변환기는 BOM을 명시적인 토글로 두었고, Excel 프리셋을 고르면 세미콜론 구분자, CRLF 줄바꿈과 함께 BOM이 켜집니다. 유럽 Excel 로캘에서 실제로 필요한 조합입니다. 구분자와 따옴표 처리, 타입 추론처럼 변환에서 결정해야 할 나머지 항목은 CSV와 JSON 변환 온라인 가이드에 정리해 두었습니다.

8. JSON 너머: BOM이 또 나타나는 곳

JSON은 시끄럽게 알려 줍니다. 다른 형식들은 그렇지 않습니다.

셸 스크립트. BOM이 파일의 시작과 #! 사이에 끼어 있으면 커널은 셔뱅을 보지 못하고, 지정한 인터프리터도 실행하지 않습니다. macOS에서 측정해 보니 셸이 sh로 넘어간 뒤 셔뱅 줄을 없는 파일이라고 보고했습니다:

./bom.sh: line 1: #!/bin/sh: No such file or directory

그러고도 스크립트는 잘못된 인터프리터 아래에서 그냥 실행됐는데, 이는 실패하는 것보다 나쁩니다. 다른 시스템은 표현이 다른데, 흔히 보이는 것은 bad interpreter 오류입니다. #!/usr/bin/env python3으로 시작하는 멀쩡한 스크립트가 그 경로는 없다고 우긴다면 바이트를 확인하세요.

PHP. <?php ... ?> 바깥은 전부 출력이고, 여는 태그 앞의 BOM은 코드가 돌기도 전에 나가는 3바이트짜리 출력입니다. 그러면 첫 header(), session_start(), setcookie() 호출이 headers already sent 경고와 함께 실패하면서, 1행이 비어 보이는 파일의 1행을 가리킵니다.

.env 파일을 비롯한 모든 키-값 형식. CSV의 경우와 메커니즘이 똑같습니다. 첫 변수의 이름은 DATABASE_URL이 아니라 U+FEFF 다음에 DATABASE_URL이 오는 이름이라 조회가 빗나가는데, 사람이 읽기에는 파일이 멀쩡합니다. 뒤따르는 변수는 전부 잘 동작하니, 특정 설정 하나만의 문제처럼 보입니다.

XML은 반대 방향의 예외입니다. XML 명세는 인코딩 자동 감지의 일부로 문서 맨 앞의 UTF-8 BOM을 명시적으로 허용하고, 파서도 이를 감당해야 합니다. 시험해 보니 Python의 xml.etree.ElementTree는 BOM이 붙은 문서를 군말 없이 받아들였습니다. XML이 실패하고 있다면 원인은 아마 BOM이 아닙니다.

9. 원천에서 막기

여기까지 왔으면 파일에서 BOM을 지우는 일은 이미 끝난 문제입니다. 남는 것은 그 파일에 BOM이 다시 붙지 않게 만드는 일입니다.

.editorconfig에 인코딩을 고정하세요. charset 속성은 utf-8utf-8-bom을 서로 다른 값으로 받으므로, 원하는 쪽을 적어 두면 해석의 여지가 없습니다:

[*]
charset = utf-8

그것을 덮어쓰는 편집기 설정을 확인하세요. VS Code에서는 "files.encoding": "utf8"이고, 찾아봐야 할 값은 utf8bom입니다. 사용자 설정뿐 아니라 워크스페이스의 .vscode/settings.json도 확인하세요. 커밋된 워크스페이스 설정은 팀 전원에게 조용히 적용되기 때문입니다.

CI나 pre-commit 훅에서 검사하세요. 아래 스크립트는 이식성이 좋고 의존성이 없으며, 뭔가를 찾아내면 0이 아닌 코드로 종료합니다:

#!/bin/sh
# 추적 중인 파일 가운데 EF BB BF로 시작하는 것이 있으면 실패
found=0
for f in $(git ls-files '*.json' '*.md' '*.sh'); do
  if [ "$(head -c3 "$f" | od -An -tx1 | tr -d '[:space:]')" = "efbbbf" ]; then
    echo "BOM: $f"
    found=1
  fi
done
exit $found

두 경우 모두 확인했습니다. BOM이 붙은 파일이 추적되고 있으면 해당 경로들을 나열하고 1로 종료하며, 파일이 깨끗해지면 0으로 종료합니다.

허용되는 예외 하나를 문서로 남기세요. “어디에도 BOM 금지”라는 규칙은 누군가에게 스프레드시트 내보내기가 필요해지는 순간 깨지고, 그다음부터는 통째로 무시됩니다. 그러지 말고 예외를 명시하세요. BOM은 Excel용으로 생성하는 CSV 파일에서만 허용하고 그 밖의 어디에서도 허용하지 않는다고 적으면 됩니다. 검사 스크립트에서 내보내기 디렉터리를 제외하면 규칙이 현실과 부딪혀도 살아남습니다.

10. 60초짜리 이분 탐색 절차

순서대로 실행하세요. 각 단계는 조사를 끝내거나, 다음 단계에 더 작은 문제를 넘겨줍니다.

  1. 위치가 아니라 글자를 읽으세요. 1절입니다. <라면 HTML이니 여기서 끝입니다. 따옴표 안이 비었다면 본문이 비어 있었던 것입니다. 읽을 수 없는 네모가 보이면 계속 진행하세요.
  2. 바이트를 확인하세요. hexdump -C file | head -1입니다. 앞의 세 바이트가 ef bb bf가 아니면 멈추십시오. BOM이 아니므로 아래 내용은 아무 도움도 되지 않습니다.
  3. 어디에서 들어오는지 찾으세요. 디스크에 있는 파일부터 BOM이 붙어 있습니까, 아니면 디스크에서는 깨끗한데 코드가 읽어 들인 뒤에 붙어 있습니까? 디스크에서 깨끗하다면 파이프라인 어딘가가 붙이고 있다는 뜻입니다.
  4. 고칠 쪽을 한 곳으로 정하세요. 생성자가 벤더이거나 업로드이거나 소유권이 없는 빌드 단계라면 읽는 시점에 제거하세요. 생성자가 내 것이라면 생성자를 고치세요. 읽는 쪽 수정은 리더가 늘어날 때마다 되풀이해야 하기 때문입니다.
  5. 수정은 더 깊은 곳이 아니라 디코딩 경계에 넣으세요. 함수 세 개를 지난 뒤의 문자열에 .lstrip()을 거는 것이 아니라, open() 호출에 encoding='utf-8-sig'를 주는 것입니다. 스택 깊은 곳에서 고치면 그 파일을 읽는 다음 코드 경로가 같은 버그를 처음부터 다시 발견하게 됩니다.
  6. 바이트가 바뀌었는지 확인하세요. 2단계를 다시 실행하세요. 한 코드 경로에서는 통하지만 파일 자체는 그대로 둔 수정은 다음 경로에서 실패합니다.
  7. 검사 스크립트를 추가하세요. 9절입니다. 그러지 않으면 다음 분기에 이 모든 과정을 다시 하게 됩니다.

FAQ

UTF-8 BOM은 필수인가요?

아닙니다. UTF-8은 바이트 순서가 하나뿐이라 표시로 구분해 줄 것이 없습니다. Unicode는 UTF-8 BOM을 인코딩 시그니처로 허용하되 권장하지는 않으며, JSON은 아예 금지합니다. RFC 8259는 구현이 JSON 텍스트에 바이트 순서 표시를 추가해서는 안 된다고 규정합니다.

편집기에서는 멀쩡해 보이는데 왜 파싱에 실패하나요?

U+FEFF가 아무것도 그리지 않기 때문입니다. 이 글자를 알아보는 편집기는 글자를 숨기고 대신 상태 표시줄에 UTF-8 with BOM이라고 적어 둡니다. 알아보지 못하는 편집기는 그냥 0픽셀을 그립니다. cat도, less도, 코드 리뷰의 diff도 똑같아 보이기는 마찬가지입니다. 오직 바이트 단위로 봐야 드러납니다.

JSON.parse가 BOM을 자동으로 제거해 주기도 하나요?

아닙니다. 한 번도 그러지 않습니다. JSON.parse는 문자열을 받고, U+FEFF가 어디에 나오든 예상하지 못한 글자로 취급합니다. 제거하는 쪽은 그 위층입니다. fetch 뒤의 res.json(), .json 파일에 대한 Node의 require(), 기본 설정 그대로의 TextDecoder가 모두 파서가 무언가를 보기 전에 제거합니다.

CSV 파일에서 BOM을 제거해야 하나요?

누가 그 파일을 여느냐에 따라 다릅니다. 어떤 파서든 BOM을 첫 열 이름에 접어 넣기 때문에 namename이 되고 모든 조회가 빗나갑니다. 그런 경우에는 제거하세요. 반면 Windows의 Excel은 BOM으로 UTF-8을 감지하며, BOM이 없으면 악센트 글자와 CJK 글자를 깨뜨립니다. 그런 경우에는 남겨 두세요.

BOM은 너비 없는 공백(zero-width space)과 같은 것인가요?

코드 포인트는 같지만 역할이 다릅니다. 오프셋 0의 U+FEFF는 바이트 순서 표시입니다. 문서의 다른 자리에 있으면 ZERO WIDTH NO-BREAK SPACE이며, Unicode는 이 용법을 U+2060 WORD JOINER에 넘기고 폐기했습니다. 오래된 텍스트에는 여전히 남아 있고, 그래서 파일 한가운데에서 U+FEFF가 튀어나오는 것입니다.

BOM이 git diff와 파일 크기에 영향을 주나요?

디스크에서 3바이트를 차지하고, 그 파일을 건드리는 모든 diff에 잡음 한 줄을 더합니다. Git은 바이트를 비교하므로 렌더링된 텍스트가 똑같아도 BOM을 넣거나 빼면 1행이 다시 쓰입니다. 리뷰에서 아무도 설명하지 못하는 한 줄짜리 변경의 출처가 바로 이것입니다.

태그: utf-8 bom json csv debugging character-encoding