다른 곳에서 가져온 텍스트 정리하기
PDF, 스프레드시트 셀, CMS 필드, 이메일 클라이언트, 채팅 앱에서 넘어온 텍스트에는 그것을 만든 프로그램의 서식 결정이 함께 딸려 옵니다. 그리고 그 결정들은 화면에서 거의 아무것도 보이지 않습니다. 그 결과 겉보기에는 멀쩡하고 인쇄해도 멀쩡한 문자열이 비교, 검색, JSON 파싱, 데이터베이스 조회에서 아무 이유 없이 실패합니다.
눈에 보이지 않는 문자
유니코드 입장에서 이것들은 엄연한 문자입니다. 다만 눈에 보이는 모양이 없거나, 다른 문자와 모양이 같을 뿐입니다.
| 문자 | 코드 포인트 | 주로 어디에서 오는가 | 무엇을 망가뜨리는가 |
|---|---|---|---|
| 줄바꿈 없는 공백 | U+00A0 | HTML의 , Word, PDF 레이아웃 | 공백 기준 분할, 정확 일치 |
| 좁은 줄바꿈 없는 공백 | U+202F | 프랑스어 조판, Word에서 온 날짜 | 같은 문제이며 알아채기는 더 어려움 |
| 전각 공백 | U+3000 | CJK 입력기 | 넓은 간격처럼 보임 |
| 폭 없는 공백 | U+200B | CMS의 줄바꿈 힌트, 웹에서 복사한 텍스트 | 보이지 않으면서 공백류로도 취급되지 않음 |
| 폭 없는 비결합자 / 결합자 | U+200C, U+200D | 페르시아어, 인도계 문자, 이모지 시퀀스 | 단어 경계, 문자 수 |
| 단어 결합자 | U+2060 | 조판 도구 | 눈에 보이는 것은 없고 텍스트 처리는 전부 |
| 소프트 하이픈 | U+00AD | Word의 하이픈 처리, PDF 내보내기 | 단어가 자기 자신과 일치하지 않게 됨 |
| 바이트 순서 표식 | U+FEFF | UTF-8 파일의 첫 바이트 | 첫 행의 첫 필드 |
| 좌에서 우 / 우에서 좌 표식 | U+200E, U+200F | 양방향 텍스트 | 숫자 주변에 남는 정체불명의 표식 |
| 줄 구분자와 문단 구분자 | U+2028, U+2029 | 일부 Mac 앱과 레이아웃 앱 | 줄 분할, 구형 JavaScript 파서 |
검색이 실패하는 이유는 검색이 모양이 아니라 코드 포인트를 비교하기 때문입니다. PDF가 New U+00A0 York를 건네주었는데 사용자가 일반 공백 U+0020을 써서 New York라고 입력했다면 두 문자열은 서로 다르고, 왜 그런지 알려주는 것은 아무것도 없습니다.
"New York".includes("New York") false, the gap is U+00A0
" text".trim().length 5, the zero-width space survives
줄바꿈 없는 공백은 대부분의 trim 함수와 대부분의 정규식 엔진의 \s에서 공백류로 취급됩니다. 그래서 문자열 양 끝에서는 사라지지만 가운데에서는 살아남으며, 하필 분할이 일반 공백을 기대하는 바로 그 자리에 남습니다. 폭 없는 공백은 어디에서도 공백류가 아니므로 trim이나 공백 축약을 해도 그대로 남습니다. 메일 클라이언트에서 복사한 텍스트로 이메일 추출기가 아무것도 찾아내지 못하는 이유도 이것입니다. 주소 안에 폭 없는 문자 하나만 있어도 패턴 일치가 멈춥니다.
숨은 문자 탐지기는 실제로 무엇이 들어 있는지 보여주고, 유니코드 이스케이프는 값 하나의 정확한 코드 포인트를 알려줍니다. 다만 눈에 띈다고 무조건 제거하지는 마십시오. 이모지 시퀀스 안의 폭 없는 결합자와 페르시아어나 데바나가리 문자의 폭 없는 비결합자는 잡음이 아니라 내용입니다.
워드프로세서가 대신 바꿔놓은 문장 부호
자동 고침은 입력하는 동안 조판용 문자를 대신 집어넣고, 그 치환 결과는 문서 밖으로 텍스트를 따라 나갑니다.
| 입력한 것 | 실제로 남은 것 | 코드 포인트 |
|---|---|---|
' | 오른쪽 작은따옴표 | U+2019 |
"와 " | 왼쪽 큰따옴표와 오른쪽 큰따옴표 | U+201C, U+201D |
단어 사이의 - | 엔 대시 또는 엠 대시 | U+2013, U+2014 |
... | 가로 말줄임표 | U+2026 |
숫자 앞의 - | 마이너스 기호 또는 줄바꿈 없는 하이픈 | U+2212, U+2011 |
산문에서는 아무 문제가 없지만 기계가 파싱하는 곳에서는 치명적입니다.
{ “name”: “Ada” } unexpected token, only U+0022 is a JSON string quote
git commit -m “fix” the shell sees three words, not one quoted argument
WHERE name = ‘Ada’ SQL syntax error near ‘
10\u201320 not a number range, U+2013 is not a hyphen
CSV에서는 실패가 더 조용하게 일어납니다. 파서는 곧은 큰따옴표만 필드 구분 기호로 인식하므로, 워드프로세서가 둥근 따옴표로 감싼 값은 따옴표가 없는 것으로 취급됩니다. 그 안에 쉼표가 하나라도 있으면 행이 갈라지고 그 뒤의 모든 열이 밀립니다. 음수에 U+2212를 쓰는 열은 텍스트로 가져와지고, 합계에서는 그 열이 조용히 빠집니다.
짧은 치환 목록을 갖춘 찾기 및 바꾸기가 해결책이지만, 코드나 CSV나 키로 향하는 텍스트에만 적용하십시오. 게시할 산문에 그대로 돌리면 의도적으로 쓴 문장 부호까지 밋밋하게 만들어버립니다.
줄 끝 문자와 끝에 붙은 공백
Windows 도구는 줄을 CR LF(U+000D U+000A)로 끝내고, Unix 도구는 LF만 쓰며, 아주 오래된 일부 Mac 내보내기는 아직도 CR만 씁니다. 대부분의 파서는 이를 감당합니다. 단순한 분할은 그렇지 못합니다.
"UK\r\n" split on "\n" -> "UK\r"
"UK\r" === "UK" false
"UK\r".length 3
표나 로그나 diff 화면에서 똑같이 보이는 두 문자열이 캐리지 리턴 하나, 끝의 공백 하나, 탭 하나 때문에 다를 수 있습니다. 텍스트 비교 도구가 어떤 줄을 변경됨으로 표시하는데 눈에 보이는 차이가 없다면 답은 이것입니다. 붙여 넣은 열을 줄 인용이나 줄 합치기에 통과시켰을 때 따옴표 안에 정체불명의 공백이 들어가는 이유도 같습니다.
정규화는 한 번에 처리하되 CR LF와 단독 CR를 함께 잡아야 합니다(\r\n?를 \n으로 치환). 그러지 않고 두 단계로 치환하면 빈 줄이 두 배로 늘어납니다.
한 글자를 쓰는 두 가지 방법
유니코드에서는 악센트가 붙은 글자를 코드 포인트 하나로 쓸 수도 있고, 기본 글자 뒤에 결합용 표식을 붙여 쓸 수도 있습니다. 둘 다 올바르지만, 둘은 같지 않습니다.
| 형식 | é가 저장되는 방식 | JavaScript에서의 코드 유닛 수 |
|---|---|---|
| NFC (결합형) | U+00E9 | 1 |
| NFD (분해형) | U+0065 U+0301 | 2 |
macOS는 오래전부터 분해형 파일 이름을 넘겨주었고, 여러 PDF 생성기와 입력기도 분해형 텍스트를 내보냅니다. 그래서 한쪽 출처에서 온 값은 키보드로 똑같이 입력한 값과 일치하지 않습니다.
"é" === "é" false
"é".normalize("NFC") === "é" true
파급 효과는 동등성 비교에서 끝나지 않습니다. 코드 포인트를 비교하는 정렬은 분해형을 그냥 e 옆에 놓고 결합형은 멀리 떨어뜨려 놓으므로, 이름 목록이 두 덩어리로 나뉘어 돌아옵니다. 문자 수도 형식에 따라 달라지는데, 이는 280자 제한이나 VARCHAR(50) 컬럼에서 문제가 되고, 텍스트 통계 도구도 같아 보이는 텍스트에 대해 다른 숫자를 보고합니다. 잘라내기는 더 나쁩니다. 분해형 문자열을 고정된 길이에서 자르면 기본 글자와 그 악센트 사이가 잘릴 수 있고, 남겨진 악센트는 뒤에 오는 아무 글자에나 붙습니다. 그래서 정규화하지 않은 입력에 텍스트 자르기를 쓰면 마지막 글자가 눈에 띄게 잘못 나올 수 있습니다.
호환 형식인 NFKC와 NFKD는 한 걸음 더 나아가 단지 비슷하게 생긴 문자들까지 하나로 접어버립니다. 합자 U+FB01은 fi가 되고, 전각 A U+FF21은 A가 되며, 마이크로 기호 U+00B5는 그리스 문자 뮤 U+03BC가 되고, 위 첨자 ²는 그냥 2가 됩니다. 검색이나 중복 제거용 키에는 훌륭하지만 화면에 보여줄 것에는 파괴적입니다. x²가 조용히 x2가 되어버리기 때문입니다.
실무 규칙은 이렇습니다. 저장과 표시에는 NFC를, NFKC는 아무도 보지 않는 키에만 씁니다.
대소문자 변환은 하나의 연산이 아닙니다
대문자로 바꾸는 일은 문자 하나하나의 대응 관계가 아니며, 로캘과 무관하지도 않습니다.
- 독일어
ßU+00DF는 대문자로 바꾸면SS가 되므로 문자열이 길어지고, 다시 소문자로 되돌려도 원래 값이 나오지 않습니다. - 그리스어 시그마는 단어 끝에서는
ς로, 그 밖의 위치에서는σ로 소문자화되며, 이 역시 왕복 변환을 깨뜨립니다. - 터키어와 아제르바이잔어에는 점 없는
ıU+0131과 점 있는 대문자İU+0130이 있습니다. 이 로캘에서I는ı로,i는İ로 변환됩니다.
마지막 항목이 바로 그 유명한 운영 환경 버그입니다. 헤더 이름이나 파일 확장자나 프로토콜 문자열을 소문자로 바꾸는 코드는 기본 로캘이 터키어인 기기에서 돌아가기 전까지는 어디서나 잘 동작합니다. 그 기기에서는 "FILE"이 fıle로 바뀌면서 file과 일치하지 않게 됩니다. Java와 .NET에서는 인자 없는 메서드가 기기의 기본 로캘을 쓰므로 toUpperCase()와 ToUpper()가 함정입니다. 기계가 읽을 값에는 불변 로캘을 명시하십시오. 비교 목적이라면 소문자화보다 케이스 폴딩(Python의 casefold())이 낫습니다. ß를 ss로 처리해 주기 때문입니다.
타이틀 케이스에는 정해진 정의가 하나도 없고, 그래서 "모든 단어의 첫 글자를 대문자로"라는 방식이 "The Lord Of The Rings"와 "IPhone" 같은 결과를 만듭니다. 대부분의 스타일 가이드는 첫 단어와 마지막 단어를 대문자로 쓰고 관사, 등위 접속사, 짧은 전치사를 제외한 나머지를 대문자로 쓰며, 하이픈으로 이어진 복합어는 양쪽 모두 대문자로 두고, 두문자어나 McDonald, O'Brien, iPhone처럼 중간에 대문자가 있는 이름은 건드리지 않습니다.
프로그래밍용 표기법에는 나름의 경계 문제가 있습니다. HTTPResponseCode를 스네이크 케이스로 바꾼 결과는 분할기가 연속된 대문자를 어떻게 다루는지에 전적으로 달려 있습니다. 대소문자 변환기는 http_response_code를 내놓지만, 단순한 구현은 h_t_t_p_response_code를 내놓습니다.
실제로 통하는 순서
각 단계는 앞 단계가 이미 실행되었다고 가정합니다. 순서가 어긋나면 서로 충돌합니다.
- 인코딩부터 정리합니다.
café같은 깨진 글자는 바이트를 잘못된 인코딩으로 디코딩했다는 뜻이므로, 증상을 땜질하지 말고 원본 바이트를 다시 디코딩하십시오. BOM은 텍스트의 맨 앞에서만 제거합니다. - 줄 끝 문자를 LF로 정규화합니다. 한 번의 처리로 끝냅니다.
- 잡음이라고 판단한 서식 문자를 제거합니다. 소프트 하이픈, 폭 없는 공백, 단어 결합자, 양방향 표식이 그 대상입니다. 이 작업은 정규화보다 먼저 해야 합니다. 기본 글자와 악센트 사이에 보이지 않는 문자가 끼어 있으면 NFC가 둘을 결합하지 못하기 때문입니다.
- 닮은꼴 문장 부호를 치환합니다. 목적지가 코드, CSV, JSON, 키인 경우에 한합니다.
- 유니코드 정규화를 적용합니다. 기본값은 NFC입니다.
- 이제 공백을 처리합니다. 남아 있는 특수한 공백들을 U+0020으로 바꾸고, 연속된 공백을 합친 뒤, 양 끝을 잘라냅니다. 이 작업을 3단계보다 먼저 하면 줄바꿈 없는 공백과 일반 공백이 만난 자리마다 이중 공백이 남고, 2단계보다 먼저 trim하면 각 줄 마지막 값에 캐리지 리턴이 붙은 채로 남습니다.
- 대소문자 변환은 맨 마지막에 하되, 로캘을 명시적으로 지정합니다.
단어 단위 작업도 3단계 뒤에 와야 합니다. 소프트 하이픈이 남아 있는 상태에서 텍스트 분할로 텍스트를 나누면 개수가 부풀려지고 반쪽짜리 단어가 나오며, 텍스트 검열을 포함해 용어를 훑는 모든 작업에 같은 이야기가 적용됩니다.
이 모든 것을 거치고도 값이 여전히 일치하지 않는다면 그 값을 눈으로 보는 일은 그만두십시오. 길이를 출력하고, 코드 포인트로 이스케이프한 다음, 이스케이프된 두 형태를 직접 비교하십시오. 화면에서는 결코 드러나지 않던 차이가 거기서는 한눈에 보입니다.