UTF-8 BOM(EF BB BF): 보이지 않는 세 바이트를 표준마다 다르게 다루는 이유
UTF-8 파일 앞의 EF BB BF를 브라우저는 건너뛰고 JSON.parse는 오류로 보는 이유를 유니코드, RFC 3629, JSON(RFC 8259), WHATWG 인코딩·HTML 표준의 규칙으로 정리했습니다.
UTF-8 파일 맨 앞에 EF BB BF 세 바이트가 있으면, 어떤 프로그램은 아무렇지 않게 읽고 어떤 파서는 첫 글자에서 멈춥니다. 이 세 바이트가 BOM(byte order mark)입니다. 반응이 갈리는 건 대개 구현 실수 때문이 아닙니다. 표준마다 BOM에 맡긴 역할이 다릅니다. JSON 오류 정리에서 한 문단으로 짚고 넘어간 BOM을, 여기서는 표준별로 나눠 자세히 봅니다.
BOM은 U+FEFF 한 글자다
BOM은 따로 정한 바이트 규칙이 아닙니다. 문자 U+FEFF를 데이터 맨 앞에 둔 것이라 인코딩마다 바이트가 달라집니다.
| 인코딩 | BOM 바이트 |
|---|---|
| UTF-8 | EF BB BF |
| UTF-16 빅 엔디언 | FE FF |
| UTF-16 리틀 엔디언 | FF FE |
| UTF-32 빅 엔디언 | 00 00 FE FF |
| UTF-32 리틀 엔디언 | FF FE 00 00 |
UTF-16과 UTF-32에서는 이름 그대로 바이트 순서를 알려 줍니다. UTF-8은 바이트 순서가 하나뿐이라 알려 줄 것이 없습니다. 그래서 유니코드 FAQ는 UTF-8의 BOM을 “이 데이터는 UTF-8”이라는 서명(signature)으로 설명합니다. 유니코드 표준 18.0은 UTF-8에 BOM이 필요하지 않고, 사용을 권장하거나 반대하지도 않는다고 적습니다.
맨 앞이 아니면 내용이다
U+FEFF의 이름은 ZERO WIDTH NO-BREAK SPACE입니다. 예전에는 이 문자로 단어 사이 줄바꿈을 막았습니다. 지금 유니코드는 그 용도에 U+2060 WORD JOINER를 쓰라고 권합니다. BOM과 혼동되지 않기 때문입니다.
데이터 중간에 나온 U+FEFF는 BOM이 아닙니다. 호환성을 위해 폭 없는 공백으로 다루며 파일이나 문자열 내용의 일부가 됩니다. RFC 3629도 맨 앞이 아닌 U+FEFF를 서명으로 해석하면 안 된다고 규정합니다(MUST NOT).
맨 앞의 U+FEFF를 서명으로 허용할지에 대해서는 경우가 나뉩니다.
- 프로토콜이 항상 UTF-8로 정한 요소에서는 서명을 금지해야 합니다(SHOULD).
- 인코딩 표시 수단이 있고 구현이 그 수단을 항상 제대로 사용할 것으로 기대되는 요소에서도 금지해야 합니다(SHOULD).
- 인코딩 표시 수단이 없고 금지를 강제하기 어려운 요소에서는 금지하지 않아야 합니다(SHOULD NOT).
- 표시 수단이 있어도 구현이 항상 제대로 사용하리라 기대하기 어려운 요소에서도 금지하지 않아야 합니다(SHOULD NOT).
- 서명을 금지한 요소에서는 맨 앞 U+FEFF도 폭 없는 공백 문자로 해석해야 합니다(MUST).
RFC는 MIME 엔티티처럼 큰 요소가 어디서 왔고 인코딩 표시가 얼마나 믿을 만한지에 따라 두 SHOULD NOT 경우 중 하나에 해당할 수 있다고 설명합니다.
JSON: 붙이면 안 되고, 읽을 때 무시는 선택
RFC 8259 8.1절에 따르면 닫힌 환경 밖에서 주고받는 JSON은 UTF-8이어야 하고(MUST), 네트워크로 보내는 JSON 앞에 BOM을 붙이면 안 됩니다(MUST NOT). 반면 파서는 상호운용을 위해 BOM을 무시해도 됩니다(MAY).
무시할지는 구현이 정합니다. JSON 문법에서 공백으로 인정되는 문자는 스페이스, 탭, LF, CR뿐입니다. BOM을 건너뛰지 않는 파서에게 U+FEFF는 잘못된 첫 글자입니다.
JSON.parse('\uFEFF{"a":1}')
// SyntaxError
위 동작은 작성 당일 Node.js v24.13.0에서 확인했습니다.
Python 공식 문서에 따르면 json 모듈은 직렬화할 때 BOM을 붙이지 않고, 역직렬화할 문자열 앞에 BOM이 있으면 ValueError를 냅니다. 바이트를 디코딩할 때 'utf-8'은 맨 앞 U+FEFF를 문자로 남기지만, 'utf-8-sig'는 맨 앞 세 바이트를 건너뜁니다.
브라우저의 fetch()는 다르게 동작합니다. Fetch 표준에서 Response.text()와 Response.json()은 UTF-8 디코딩을 거치며 이 단계에서 맨 앞 BOM이 제거됩니다. 그래서 같은 파일이 fetch로는 읽히다가도, BOM을 남기는 방식으로 문자열을 만든 뒤 JSON.parse에 넘기면 실패할 수 있습니다.
웹에서는 BOM을 HTTP 헤더보다 먼저 본다
WHATWG Encoding 표준의 디코딩 알고리즘은 본문을 읽기 전에 BOM부터 확인합니다. 대상은 EF BB BF, FE FF, FF FE입니다. BOM이 있으면 해당 인코딩으로 읽고 BOM은 버립니다.
HTML 파서도 인코딩 판별의 첫 단계에서 BOM을 확인합니다. 찾으면 그 결과를 certain으로 취급하며, HTTP Content-Type의 charset과 <meta> 선언은 그다음에 봅니다.
여기서는 작성 규칙과 브라우저 처리 규칙을 구분해야 합니다. “문서의 실제 인코딩은 UTF-8이어야 한다”는 HTML 문서를 만드는 쪽의 적합성 요건입니다. 브라우저 처리 규칙은 별도로 Shift_JIS, EUC-KR, windows-1252 같은 기존 인코딩 문서를 디코딩하는 방법도 정의합니다.
문서가 BOM으로 시작하지 않고 Content-Type에도 인코딩이 없으며 iframe의 srcdoc 문서도 아니라면, <meta charset> 또는 같은 역할의 http-equiv 선언으로 인코딩을 밝혀야 합니다. 선언은 문서 앞 1024바이트 안에 있어야 합니다.
TextDecoder의 옵션 이름은 직관적이지 않습니다. 기본값 ignoreBOM: false는 BOM을 인식하고 결과에서 제거한다는 뜻입니다. true로 주면 U+FEFF가 결과 문자열에 남습니다.
정리
| 기준 | BOM 처리 |
|---|---|
| Unicode 18.0 | UTF-8에 필요하지 않으며 사용을 권장하거나 반대하지 않음 |
| RFC 3629 | 항상 UTF-8인 요소 등에서는 금지 권고. 금지된 곳의 U+FEFF는 문자로 해석 |
| RFC 8259 | 네트워크 JSON에 붙이면 안 됨. 파서가 무시하는 것은 선택 |
| WHATWG Encoding·HTML 처리 규칙 | 인코딩 판별에서 먼저 확인하고 제거 |
| HTML 작성 규칙 | 실제 문서 인코딩은 UTF-8 |
JSON.parse, Python json |
문자열 앞 U+FEFF를 오류로 처리 |
네트워크 JSON처럼 UTF-8을 이미 요구하거나 선언하는 형식에서는, 그 형식이나 받는 도구가 BOM을 명시적으로 기대하지 않는 한 붙이지 마세요. 이미 BOM이 붙은 데이터를 받아야 한다면 문자열에서 나중에 지우기보다 바이트를 디코딩할 때 처리하는 편이 낫습니다.
첫 글자에서 원인 모를 오류가 난다면 편집기 화면 대신 바이트를 확인할 수 있습니다.
od -An -tx1 -N3 파일명
출력이 ef bb bf라면 파일은 UTF-8 BOM으로 시작합니다.