보이지 않는 Unicode 문자는 눈에 보이는 공간을 차지하지 않지만 문자열의 일부로 남아 있습니다. 대표적으로 폭이 0인 공백, 소프트 하이픈, 바이트 순서 표시(BOM), 줄바꿈 없는 공백, 방향 표시 문자가 있습니다. 각각 정당한 활자 조판 목적이 있지만, 복사-붙여넣기 과정에서 살아남으면 검색, 유효성 검사, 파싱, 중복 제거를 망가뜨립니다.
실제로 마주치게 되는 문자들
| 문자 | 코드 포인트 | 용도 | 망가뜨리는 것 |
|---|---|---|---|
| 폭이 0인 공백 | U+200B | 줄바꿈 위치 제안 | 검색, 문자열 비교 |
| 폭이 0인 비결합자 | U+200C | 글자 결합 방지 | 합자, 아랍어 및 인도 계열 문자 |
| 폭이 0인 결합자 | U+200D | 글자 결합 강제 | 이모지 시퀀스, 텍스트 길이 |
| 소프트 하이픈 | U+00AD | 줄바꿈 시에만 표시되는 하이픈 | 검색, 복사된 텍스트 |
| 줄바꿈 없는 공백 | U+00A0 | 줄바꿈되지 않는 공백 | 트리밍, CSV 파싱, 코드 |
| 좁은 줄바꿈 없는 공백 | U+202F | 문장 부호 앞의 얇은 공백 | 위와 동일 |
| 바이트 순서 표시 | U+FEFF | 파일 시작의 인코딩 표시 | CSV 첫 열, JSON 파싱 |
| 단어 결합자 | U+2060 | 폭 없이 줄바꿈 방지 | 검색 |
| 왼쪽에서 오른쪽 표시 | U+200E | 텍스트 방향 지정 | 표시 순서, 트리밍 |
| 오른쪽에서 왼쪽 표시 | U+200F | 텍스트 방향 지정 | 표시 순서 |
| RTL 재정의 | U+202E | 오른쪽에서 왼쪽 표시 강제 | 파일명 사칭 |
| 줄 구분자 | U+2028 | Unicode 줄바꿈 | JavaScript 문자열 리터럴 |
| 문단 구분자 | U+2029 | Unicode 문단 나누기 | 구형 파서의 JSON |
이 문자들 모두 각자의 역할이 있습니다. 문제는 존재 자체가 아니라, 서로 다르게 취급하는 시스템 사이를 소리 없이 오간다는 점입니다.
어디에서 생겨나는가
- 웹페이지에서 복사할 때. 사이트는 긴 문자열이 줄바꿈될 위치를 제어하기 위해 폭이 0인 공백을 넣고, 양쪽 정렬 텍스트에는 소프트 하이픈을 넣습니다. 둘 다 복사할 때 함께 따라옵니다.
- 워드 프로세서. 숫자와 단위를 붙여 두거나 이름을 한 줄에 유지하기 위해 줄바꿈 없는 공백이 자동으로 삽입됩니다.
- PDF 추출. PDF에서 복사하면 조판 시 단어가 줄에 걸쳐 끊긴 자리에 소프트 하이픈이 생기고, 양쪽 정렬 공백에서 줄바꿈 없는 공백이 함께 나옵니다.
- AI 채팅 인터페이스. 출력이 HTML로 렌더링되므로, 복사하면 그 서식에 사용된 공백 문자(주로 U+00A0과 U+202F)가 그대로 따라옵니다.
- 인코딩 변환. 파일이 BOM 포함 UTF-8로 저장되면 바이트 순서 표시가 생기며, Windows에서 흔합니다.
- 의도적인 삽입. 폭이 0인 문자는 유출 추적을 위해 문서에 지문을 심거나, 키워드 필터를 우회해 텍스트를 숨겨 넣는 데 쓰이기도 합니다.
패턴은 일관됩니다. 텍스트가 표시 맥락에서 데이터 맥락으로 이동할 때 이 문자들이 끼어듭니다.
무엇을 어떻게 망가뜨리는가
검색이 조용히 실패합니다. 단어 안에 폭이 0인 공백이 있으면 검색 결과가 나오지 않습니다. 단어가 눈앞에 분명히 보이는데도 이유를 알 수 없습니다.
유효성 검사가 올바른 입력을 거부합니다. 이메일 주소 끝에 줄바꿈 없는 공백이 붙어 있으면 형식 검사를 통과하지 못합니다. 사용자에게는 올바른 주소와 이해할 수 없는 오류 메시지만 보입니다.
비교가 실패합니다. "Ada" === "Ada\u200B"는 false입니다. 중복 제거가 뻔한 중복을 놓치고, 조회가 아무것도 반환하지 않으며, 조인에서 행이 빠집니다.
트리밍으로 해결되지 않습니다. 대부분의 trim() 구현은 ASCII 공백만 제거합니다. 줄바꿈 없는 공백은 많은 언어에서 .trim() 후에도 살아남기 때문에 "이미 트리밍했는데"가 흔한 막다른 길이 됩니다.
코드가 컴파일되지 않습니다. 일반 공백이 있어야 할 자리에 줄바꿈 없는 공백이 있으면, 완벽해 보이는 줄에서 구문 오류가 발생합니다.
CSV 가져오기가 손상됩니다. 바이트 순서 표시가 첫 번째 헤더에 붙어 id가 \uFEFFid가 되고, 첫 열이 조용히 매핑에 실패합니다.
길이가 틀어집니다. 이모지 시퀀스의 폭이 0인 결합자 때문에 눈에 보이는 이모지 하나가 여러 코드 포인트일 수 있어, 여유 있어 보이는 입력이 글자 수 제한에 걸립니다.
파일명이 속일 수 있습니다. U+202E는 표시되는 텍스트를 뒤집어 report\u202Egnp.exe가 reportexe.png처럼 보이게 할 수 있습니다. 이것은 호기심이 아니라 실제 보안 문제입니다.
찾아서 제거하는 방법
먼저 스캔하세요. 텍스트를 보이지 않는 문자 스캐너에 붙여넣으세요. 어떤 유형이 몇 개 있는지 보여주고, 요청하면 제거해 줍니다. 개수를 먼저 확인하면 문자 하나가 우연히 섞인 것인지, 체계적인 오염인지 판단할 수 있습니다.
에디터에서. VS Code는 unicode-highlight 설정으로 대부분의 보이지 않는 문자를 기본적으로 강조 표시합니다. 정규식을 지원하는 찾기-바꾸기에서는 다음 패턴으로 흔한 문자들을 잡을 수 있습니다:
[\u200B-\u200F\u00AD\uFEFF\u2060\u202A-\u202E\u2028\u2029]
코드에서는 데이터가 사용될 때가 아니라 들어올 때, 경계에서 정규화하세요:
const clean = input
.replace(/[\u200B-\u200F\u2060\uFEFF\u00AD\u202A-\u202E]/g, "")
.replace(/[\u00A0\u202F]/g, " ")
.normalize("NFC")
.trim();
두 단계 처리에 주목하세요. 폭이 0인 문자는 삭제하지만, 줄바꿈 없는 공백은 일반 공백으로 변환합니다. 삭제하면 단어가 서로 붙어 버리기 때문입니다.
무조건 제거하지 마세요. 폭이 0인 결합자는 이모지 시퀀스와 아랍어, 페르시아어, 많은 인도 계열 문자에서 필수적이며, 제거하면 단어의 렌더링이 달라집니다. 식별자, 키, 코드에서는 적극적으로 제거하고, 사용자에게 보이는 콘텐츠에서는 신중하게 제거하세요.
AI 도구가 이런 문자를 의도적으로 삽입하는지에 대한 구체적인 질문은 ChatGPT가 보이지 않는 문자를 숨겨 넣는가를 참고하세요. 보이지만 똑같이 문제를 일으키는 사례는 곡선 따옴표가 코드를 망가뜨리는 이유에서 다룹니다.
자주 묻는 질문
폭이 0인 공백이란 무엇인가요?
U+200B로, 눈에 보이는 폭을 차지하지 않지만 문자열의 일부로 남아 있는 문자입니다. 웹사이트는 긴 단어가 줄바꿈될 위치를 제안하는 데 사용합니다. 텍스트는 똑같아 보이지만 실제 바이트가 다르기 때문에 검색과 문자열 비교가 깨집니다.
분명히 있는 텍스트인데 검색이 안 되는 이유는 무엇인가요?
단어 안에 보이지 않는 문자가 있을 가능성이 매우 높습니다. 흔히 웹페이지나 PDF에서 따라온 폭이 0인 공백이나 소프트 하이픈입니다. 렌더링된 텍스트는 입력한 것과 일치하지만 실제 문자열은 그렇지 않습니다.
trim()으로 줄바꿈 없는 공백이 제거되나요?
대부분 그렇지 않습니다. 대부분의 trim 구현은 ASCII 공백만 제거하므로 줄바꿈 없는 공백(U+00A0)은 살아남습니다. 앞뒤 공백이 없어 보이는 입력이 트리밍 후에도 유효성 검사에 실패하는 이유입니다.
텍스트에서 보이지 않는 문자를 어떻게 제거하나요?
문자를 나열하고 제거해 주는 스캐너에 붙여넣거나, U+200B–U+200F, U+00AD, U+FEFF, U+2060, U+202A–U+202E 범위를 포함하는 정규식을 사용하세요. 폭이 0인 문자는 삭제하되, 줄바꿈 없는 공백은 삭제하지 말고 일반 공백으로 변환해야 단어가 붙지 않습니다.
보이지 않는 문자가 유용한 경우도 있나요?
네. 소프트 하이픈은 양쪽 정렬 텍스트의 단어 나누기를 제어하고, 줄바꿈 없는 공백은 값과 단위를 함께 유지하며, 폭이 0인 결합자는 이모지 시퀀스와 아랍어, 페르시아어, 인도 계열 문자에 필수적입니다. 코드와 식별자에서는 적극적으로, 산문에서는 신중하게 제거하세요.