Skip to Content

15장. BOM과 이모지 — 눈에 안 보이는 바이트들

만화로 보는 요약 — 먼저 읽어보세요

만화로 보는 요약 — 먼저 읽어보세요

BOM과 이모지는 눈에 보이는 글자와 실제 바이트·코드포인트가 다름을 보여주며, 문자열 길이는 무엇을 세는지부터 정해야 한다.

면접 실전 질문: ① UTF-8 BOM이 만드는 대표적인 문제는? ② ZWJ는 이모지에서 어떤 역할을 하나? ③ 바이트·코드 유닛·코드포인트·자소 묶음의 차이는?


배경 — 눈에 보이는 게 전부가 아니다

14장에서 UTF-8의 강점은 “눈에 안 띄게 스며든다”였어요. 그런데 이 장에선 그 ‘눈에 안 보임’이 거꾸로 개발자를 괴롭히는 두 장면을 봅니다. 분명히 눈엔 안 보이는데 바이트로는 존재하는 것들. 하나는 파일 맨 앞에 숨은 유령 세 바이트 BOM, 다른 하나는 한 글자처럼 보이지만 속은 여러 조각인 이모지예요.

공통점은 이겁니다. 화면에 보이는 ‘글자 수’와, 실제 저장된 바이트·코드포인트가 다르다. 이 어긋남이 실무에서 온갖 미스터리 버그를 만듭니다.

스토리 — 유령 세 바이트, BOM

먼저 BOM(Byte Order Mark). 이름 그대로 바이트 순서 표식이에요. 12장에서 UTF-16은 한 글자가 2바이트랬죠. 그런데 그 두 바이트를 00 41로 쓸지 41 00으로 쓸지가 시스템마다 달랐어요 (엔디안 문제). 그래서 파일 맨 앞에 “나는 이 순서로 썼어” 라는 표식을 붙였는데, 그게 U+FEFF, BOM입니다. FF FE면 리틀 엔디안, FE FF면 빅 엔디안인 식이죠.

문제는 여기서 생겨요. UTF-8은 1바이트 단위라 바이트 순서 문제가 아예 없습니다. 그런데도 일부 프로그램(특히 윈도우 메모장)이 “이 파일은 UTF-8이야”라는 표시로 UTF-8 BOM, 즉 EF BB BF 세 바이트를 파일 맨 앞에 붙여버려요. 필요도 없는데 말이죠.

raw = "hello" # BOM이 앞에 붙은 채 읽힌 텍스트 print(raw.encode("utf-8").hex()) # efbbbf68656c6c6f — 앞 3바이트가 BOM print(raw[0] == "h") # False! 첫 글자가 h가 아니다 print(len(raw)) # 6 — 눈엔 hello(5)인데 길이는 6

화면엔 그냥 hello로 보이는데, 첫 글자는 h가 아니라 유령 문자 U+FEFF예요. 이게 실무에서 어떤 사고를 낼까요?

  • 셸 스크립트가 안 돕니다. #!/bin/bash 첫 줄 앞에 BOM이 끼면, 커널이 EF BB BF#!를 보고 shebang을 인식 못 해요.
  • 소스 코드 파싱 에러. 첫 줄 맨 앞 유령 바이트 때문에 일부 컴파일러·툴에선 파서가 첫 토큰을 못 읽습니다(요즘은 BOM을 관용하는 컴파일러도 많지만, 안 그런 도구도 여전하죠).
  • 문자열 비교 실패. if line == "config"가 계속 거짓이에요. 눈엔 똑같은데, 한쪽엔 안 보이는 BOM이 붙어 있거든요. 이런 버그는 눈으로는 절대 못 찾습니다.
  • JSON 파싱 실패. 맨 앞 BOM 때문에 “예상치 못한 토큰” 에러가 나죠.

BOM은 “친절하게” 인코딩을 알려주려던 표식인데, 정작 그걸 모르는 쪽에선 보이지 않는 지뢰가 된 거예요.

스토리 — 한 글자인 척하는 이모지

두 번째 유령은 이모지입니다. “이모지 하나 = 글자 하나”라고 생각하기 쉽지만, 속을 열어보면 전혀 아니에요.

단순한 😀부터도 12장에서 봤듯 코드포인트 하나(U+1F600)지만 UTF-16으론 2 코드 유닛, UTF-8로는 4바이트예요. 그런데 요즘 이모지는 훨씬 복잡합니다. 가족 이모지를 뜯어볼까요.

fam = "👨‍👩‍👧‍👦" # 가족 이모지 — 눈엔 한 글자 print(len(fam)) # 7 — 코드포인트가 무려 7개! print([hex(ord(c)) for c in fam]) # ['0x1f468','0x200d','0x1f469','0x200d','0x1f467','0x200d','0x1f466'] print(len(fam.encode("utf-8"))) # 25 — 바이트로는 25개 # 참고) 자바·JS에선 "👨‍👩‍👧‍👦".length → 11 (UTF-16 코드 유닛 개수)

화면엔 가족 이모지 한 개인데, 속은 사람 이모지 4개(남·여·여아·남아)를 U+200D 라는 안 보이는 접착제로 이어 붙인 거예요. 이 U+200DZWJ(제로 폭 결합자, Zero-Width Joiner), 말 그대로 폭이 0이라 화면엔 안 보이지만 “앞뒤를 하나로 합쳐라”라고 지시하는 문자죠. 국기도 마찬가지예요. 🇰🇷지역 표시 문자 KR 두 개가 합쳐진 겁니다.

핵심 — “글자 하나”는 몇 개인가

BOM과 이모지가 알려주는 건 하나예요. “글자 수”라는 말이 사실은 층위마다 다르다. 가족 이모지 하나를 두고도 세는 방법이 이렇게 갈립니다.

  • 바이트 수: UTF-8로 25바이트.
  • 코드 유닛 수: UTF-16 기준(자바·JS가 세는 단위). 서로게이트까지 세면 11.
  • 코드포인트 수: 유니코드 번호 개수 7개.
  • 사용자가 보는 글자 수: 우리 눈엔 그냥 1개. 이 “사람이 하나로 인식하는 단위”를 자소 묶음(grapheme cluster) 이라고 불러요.

하나의 이모지가 25도 되고, 11도 되고, 7도 되고, 1도 됩니다. “이 문자열 길이가 얼마야?” 라는 질문에 정답이 없는 거예요. 어느 층위로 세느냐를 먼저 정해야 하니까요. BOM도 같은 얘기예요. 눈엔 0글자인데 바이트로는 3개, 코드포인트로는 1개죠.

이 책 6장에서 “바이트 수 ≠ 글자 수”를 처음 만났는데, 유니코드 시대엔 그 어긋남이 바이트 ≠ 코드 유닛 ≠ 코드포인트 ≠ 자소 묶음, 무려 네 층으로 벌어진 셈이에요. 화면으로 보는 것과 저장된 것 사이의 이 거리가, 다음 장에서 다룰 온갖 실무 버그의 근원입니다.

정리

  • BOM(U+FEFF) 은 UTF-16/32의 바이트 순서 표식(FF FE=LE, FE FF=BE)으로 태어났다. UTF-8은 순서 문제가 없는데도 일부 프로그램이 인코딩 표시로 EF BB BF 를 붙여, 이를 모르는 쪽에선 shebang 깨짐·컴파일 에러·== 비교 실패·JSON 파싱 실패 같은 보이지 않는 버그가 난다.
  • 이모지는 “한 글자 = 한 코드포인트”를 깬다. 😀도 UTF-8 4바이트지만, 가족 이모지 👨‍👩‍👧‍👦는 사람 4개를 ZWJ(U+200D) 로 이어 붙인 7개 코드포인트·25바이트, 국기 🇰🇷는 지역 표시 문자 2개다.
  • 핵심: “글자 수”는 층위마다 다르다 — 바이트 ≠ 코드 유닛 ≠ 코드포인트 ≠ 자소 묶음 (grapheme cluster). 가족 이모지 하나가 25 / 11 / 7 / 1로 동시에 세어진다.
  • 6장의 “바이트 ≠ 글자 수”가 유니코드 시대엔 네 층으로 벌어졌다.

생각해볼 질문: 자, 그럼 코드에서 이 가족 이모지의 길이를 재면 뭐가 나올까요? 1이라고 기대하겠지만, 세는 단위에 따라 자바·JS의 .length11, 파이썬 len()7이 나옵니다. 사용자는 백스페이스 한 번에 이모지가 지워지길 바라는데 실제론 한 조각만 지워지기도 하고요. 문자열을 특정 길이로 자르다 이모지 한가운데를 끊으면? 우리가 매일 쓰는 length, substring, charAt이 유니코드 앞에서 어떻게 거짓말을 하는지, 그 실무 현장으로 들어갑니다.

16장 · String.length가 거짓말할 때 — 이모지는 왜 2인가

Last updated on