1. 문자에 붙인 번호와 저장할 바이트는 다르다
한글 문서를 내보냈다가 다시 열었는데 제목만 이상한 문자로 보인다고 합시다. 글자를 입력한 사람의 언어가 바뀐 것이 아니라, 저장된 숫자를 읽는 규칙이 어긋났을 수 있습니다. Unicode는 여러 문자와 기호에 코드 포인트라는 번호를 부여하는 표준이고, UTF-8은 그 번호에 대응하는 값을 바이트의 순서로 표현하는 인코딩입니다. 예를 들어 한글 음절 ‘가’의 코드 포인트는 U+AC00입니다. U+ 뒤의 표기는 16진수이며, 파일에서 몇 번째에 있는지나 메모리 주소를 가리키는 표기가 아닙니다. 이 번호는 글꼴의 모양이나 화면의 픽셀 위치를 지정하지 않습니다. 같은 코드 포인트도 글꼴과 조판 환경에 따라 다르게 그려질 수 있습니다. 따라서 Unicode 지원, UTF-8 저장, 한글 글꼴 설치는 서로 연결되지만 각각 확인해야 할 대상입니다. Unicode의 UTF 설명은 문자와 저장 형식을 나누어 이해하는 출발점입니다.
UTF-8에서 ‘가’는 16진수로 EA B0 80이라는 세 바이트가 됩니다. 반면 영문 A는 U+0041이며 UTF-8에서는 41이라는 한 바이트입니다. 바이트는 여덟 비트의 단위이며, 이처럼 같은 문서 안에서도 문자별 바이트 길이가 달라질 수 있습니다. 파일의 .txt나 .csv 확장자만으로 실제 인코딩을 알아낼 수 있는 것도 아닙니다. UTF-8은 올바른 Unicode 스칼라 값을 한 바이트에서 네 바이트로 표현하므로, 화면에 보이는 글자 수만 곱해서 파일의 크기를 정확히 계산할 수는 없습니다. 한글 음절에 대한 이 예를 모든 한글 관련 기호나 모든 언어에 적용해서도 안 됩니다. 인코딩은 문자 정보를 바이트로 옮기는 규칙이며 압축이나 암호화가 아닙니다. 같은 문장도 파일 형식의 추가 정보, 줄바꿈, 전송 압축에 따라 전체 크기가 달라질 수 있습니다. WHATWG Encoding Standard가 정의하는 바이트와 문자 사이의 변환을, 파일 전체의 용량이나 비밀 유지 기능과 구분하면 문제를 더 좁혀 볼 수 있습니다.
2. 바이트 수·코드 단위·눈에 보이는 글자 수
JavaScript에서 문자열의 length를 읽으면 UTF-16 코드 단위의 개수가 나옵니다. 이는 UTF-8 파일의 바이트 수나 사람이 느끼는 글자 수를 뜻하지 않습니다. 예를 들어 웃는 얼굴 ‘😀’는 코드 포인트 U+1F600 하나이지만 UTF-16 코드 단위는 두 개여서 '😀'.length는 2입니다. UTF-8로 인코딩한 결과는 F0 9F 98 80으로 네 바이트입니다. codePointAt(0)으로 얻는 128512라는 십진수는 U+1F600과 같은 번호이지 글자 수나 파일 크기가 아닙니다. ‘가’는 이 세 기준에서 각각 한 코드 포인트, 한 UTF-16 코드 단위, 세 UTF-8 바이트입니다. 같은 문자열을 보고도 숫자가 다른 이유는 측정 단위가 다르기 때문입니다. MDN의 문자열 길이 설명은 length의 단위를 명시합니다. 이 수치만으로 JavaScript 엔진이 실제 메모리를 몇 바이트 사용하는지까지 단정할 수는 없습니다.
코드 포인트를 하나씩 세어도 사용자에게 보이는 글자 수와 항상 같지는 않습니다. 기본 문자에 결합 부호가 붙거나 여러 코드 포인트가 이모지 하나를 이루는 경우가 있기 때문입니다. Unicode 문자 분할 규칙은 이런 묶음을 다루는 확장 자소 군집의 경계를 설명합니다. Array.from(text)처럼 문자열을 코드 포인트 단위로 펼치는 방법도 여러 코드 포인트의 묶음까지 자동으로 세어 주지는 않습니다. 브라우저에서 이를 세려면 지원 환경을 확인한 뒤 Intl.Segmenter의 granularity를 'grapheme'으로 지정하는 방법을 검토할 수 있습니다. 다만 자소 군집은 글꼴이 그리는 그림 하나와 일대일 대응하는 보장이 아니고, 언어별 편집 기대도 다를 수 있습니다. 소개문 입력 칸에는 사용자가 이해할 글자 수를, 저장 공간이나 통신 제한에는 바이트 수를 따로 적용해야 합니다. 화면에 ‘20자’라고 쓰면서 내부에서는 UTF-16 코드 단위만 잘라 버리면 이모지나 결합 문자를 중간에서 끊는 문제가 생길 수 있습니다.
3. 한글이 깨질 때는 저장·전달·표시를 나누어 본다
문자 깨짐은 바이트를 쓸 때 적용한 인코딩과 읽을 때 적용한 디코딩이 다를 때 생길 수 있습니다. UTF-8로 저장한 파일을 다른 인코딩으로 해석하면, 파일 자체가 바뀌지 않았어도 화면에 엉뚱한 문자가 나타날 수 있습니다. HTML에서는 실제 저장 형식과 HTTP 응답의 Content-Type에 담긴 charset, 문서 앞부분의 meta charset 선언을 함께 확인합니다. HTML의 lang='ko'는 내용의 언어를 알려 주는 속성으로, 한글 바이트를 읽는 인코딩을 선택하는 charset과는 역할이 다릅니다. W3C의 인코딩 선언 안내는 선언만 UTF-8로 바꾸지 말고 실제 파일도 UTF-8로 저장하며, 서버와 문서의 선언을 일치시키도록 설명합니다. 인코딩 표지가 있으면 판정 우선순위도 영향을 받으므로 meta 하나가 언제나 다른 정보를 덮어쓴다고 생각하면 안 됩니다. 파일에서 직접 열었을 때와 서버로 받았을 때 결과가 다르면 전달 단계의 설정이 중요한 단서입니다.
글꼴에 필요한 글자가 없어 네모 상자처럼 표시되는 문제는 잘못된 디코딩과 원인이 다를 수 있습니다. 문자 데이터는 올바른데 그릴 모양을 찾지 못한 경우라면 저장 인코딩을 바꿔도 해결되지 않습니다. 반대로 이미 다른 문자로 해석된 내용을 새 글꼴로 표시한다고 원래 글자가 돌아오는 것도 아닙니다. 조사할 때는 문제가 있는 파일의 복사본을 보관하고, 편집기가 선택한 인코딩, 서버 응답의 선언, 브라우저가 해석한 문자와 사용한 글꼴을 차례로 비교합니다. 이때 화면 모양만 보고 원인을 확정하지 말고, 같은 부분을 복사했을 때 어떤 코드 포인트가 나오는지도 확인합니다. 특정 브라우저에서만 달라지는지, 같은 파일을 다른 편집기에서도 잘못 읽는지 기록하면 표시 문제와 데이터 변형을 구분하기 쉽습니다. 정상적인 글자를 찾을 때까지 저장을 반복하는 방식은 원래 바이트를 잃게 할 수 있습니다. 페이지의 선언이 모두 맞는데도 특정 게시물만 이상하다면, 그 내용이 데이터베이스에 들어오기 전 어느 단계에서 바뀌었는지도 살펴야 합니다.
4. TextEncoder와 TextDecoder로 같은 값을 확인하기
간단한 실험은 자신이 만든 짧은 문자열로 시작합니다. TextEncoder는 JavaScript 문자열을 UTF-8 바이트 배열로 바꿉니다. new TextEncoder().encode('가')의 결과를 16진수로 읽으면 EA B0 80이고, 배열의 length는 3입니다. 개발 도구가 같은 값을 십진수 234, 176, 128로 표시할 수 있는데, 이는 다른 데이터가 아니라 숫자를 적는 진법의 차이입니다. 같은 문자열의 일반 length가 1인 것과 비교하면 단위 차이가 드러납니다. 이 배열을 new TextDecoder('utf-8').decode(bytes)로 읽으면 ‘가’가 됩니다. 여기서 bytes는 앞에서 만든 바이트 배열을 가리킵니다. 인코더는 임의로 지정한 옛 인코딩으로 저장하는 범용 변환기가 아니라 UTF-8을 생성하는 도구입니다. 실제 파일을 조사할 때는 먼저 확보한 원본 바이트를 사용해야 합니다. 이미 잘못 읽힌 화면 문장을 다시 UTF-8로 인코딩하는 실험은 최초 파일이 어떤 바이트였는지를 증명하지 못합니다.
TextDecoder 생성자에 'utf-8'과 { fatal: true }를 지정하면, 해당 규칙에 맞지 않는 입력을 만났을 때 조용히 대체하는 대신 오류로 처리할 수 있습니다. 예를 들어 FF라는 단일 바이트는 유효한 UTF-8 입력이 아니므로 이 모드의 디코딩은 실패합니다. 기본적인 대체 모드에서는 잘못된 부분에 U+FFFD라는 대체 문자가 나타날 수 있습니다. 그렇다고 오류가 없다는 사실이 원래 인코딩을 알아냈거나 내용의 정확성을 확인했다는 뜻은 아닙니다. 여러 인코딩에서 겹치는 바이트도 있고, 이미 다른 문자로 바뀐 결과가 유효한 UTF-8로 다시 저장되었을 수도 있습니다. 이 검사는 선택한 디코더가 입력을 받아들일 수 있는지 확인하는 절차입니다. 문서의 출처, 원문 일치 여부, 파일이 중간에 변조되지 않았는지까지 자동으로 검증하는 기능은 아닙니다.
5. 정규화는 같은 글자의 표현을 맞추는 별도 작업
정상적으로 읽힌 두 문자열도 내부의 코드 포인트 순서는 다를 수 있습니다. ‘가’는 U+AC00 한 개로 표현할 수도 있고, 결합하는 초성 U+1100과 중성 U+1161의 순서로 표현할 수도 있습니다. 뒤의 두 값은 일상적인 호환 자모 ‘ㄱ’과 ‘ㅏ’를 그대로 나열하는 예와 다르므로 번호를 함께 확인해야 합니다. 두 표현은 보통 같은 음절로 그려지지만 JavaScript의 원시 문자열 비교에서는 같지 않을 수 있습니다. Unicode 정규화 규칙에 따른 NFC를 양쪽에 적용하면 이 정준적으로 동등한 예를 같은 표현으로 비교할 수 있습니다. NFD는 정준 분해를 사용합니다. 이 예에서는 NFC의 UTF-8 표현이 세 바이트이고 NFD의 두 자모 표현은 여섯 바이트여서, 화면이 같아도 저장 크기가 달라질 수 있습니다. 검색어와 저장된 이름이 눈에는 같은데 일치하지 않을 때 이런 표현 차이를 조사할 수 있으며, 이는 바이트를 잘못 해석한 문자 깨짐과 구분해야 합니다.
정규화 형식의 선택도 목적에 따라 달라집니다. NFC가 정준적 동등성을 다루는 반면 NFKC는 호환 분해를 포함하여 전각 숫자나 원으로 둘러싼 숫자처럼 구별되어 입력된 표현을 더 넓게 바꿀 수 있습니다. 검색의 비교용 값을 만들 때는 이런 변환이 도움이 될 수 있지만, 수학 표기나 원문 자료에서는 차이가 의미를 가질 수 있습니다. 따라서 원본을 보관하고 필요한 비교용 사본에 어떤 형식을 적용할지 정하는 접근이 유용합니다. 정규화가 대소문자 비교, 언어별 정렬, 발음이 같은 단어 찾기까지 대신하지도 않습니다. 기존 파일명이나 계정 식별자를 일괄 변환하면 서로 다른 항목이 같은 비교값으로 합쳐질 수 있으므로 서비스의 명명 규칙과 충돌 처리를 먼저 검토해야 합니다. 눈에 보이는 모양이 비슷하다는 이유만으로 모든 문자를 같은 값으로 저장하는 것은 정규화의 일반 목표가 아닙니다.
6. 원문을 보존하고 변환의 경계를 기록한다
손상 원인을 좁힐 때는 최초 파일을 덮어쓰기 전에 원본 바이트와 입수 경로를 남깁니다. 복사본에서 예상 인코딩으로 읽어 보고, 올바르게 보이는 결과를 다시 저장했을 때에도 같은 내용이 유지되는지 확인합니다. W3C의 BOM 설명에 나오는 UTF-8의 처음 표지는 EF BB BF라는 표지이며 필수 바이트는 아닙니다. UTF-8은 바이트 순서를 선택할 필요가 없으므로 이 표지는 UTF-16의 바이트 순서 선택과 같은 역할로 이해하면 안 됩니다. BOM을 붙이거나 지우는 일과 본문 바이트를 다른 인코딩으로 변환하는 일도 구분합니다. 이미 읽을 수 없는 값이 대체 문자나 물음표로 바뀌어 저장되었다면 여러 원래 값이 하나로 합쳐졌을 수 있어 자동 복원을 보장할 수 없습니다. 원래 인코딩과 변환 이력이 확인될 때에만 되돌릴 가능성을 따져야 하며, 정규화는 잃어버린 바이트를 복구하는 도구가 아닙니다.
다국어 기능을 만들 때는 영문 한 줄만으로 확인을 끝내지 않습니다. 같은 짧은 문장 묶음에 한글 음절, 결합 부호가 있는 라틴 문자, 보충 영역의 이모지, 아랍어나 히브리어처럼 오른쪽에서 왼쪽으로 쓰는 글을 포함해 입력·저장·재조회·내보내기를 비교합니다. 각 단계에서 확인할 값은 화면의 모양, 코드 포인트, UTF-8 바이트 수와 제품이 정한 글자 수 기준입니다. 읽기 방향은 UTF-8을 다른 인코딩으로 바꾸어 해결하는 속성이 아니며 화면의 언어와 방향 설정도 따로 검토합니다. 입력 제한이나 검색 비교를 수정할 때에도 기존 원문을 보존하고 바뀐 기준을 설명해야 합니다. 브라우저의 안내 숫자와 서버의 허용 기준도 같은 단위를 사용하는지 확인하고, 서로 다른 제한이 필요하면 각각 표시합니다. 이 글의 숫자와 코드 예시는 짧은 예제 문자열의 변환을 보여 주며 특정 서비스의 장애를 측정한 결과는 아닙니다. 문자 번호, 저장 바이트, 문자열 단위, 표시와 비교 규칙을 차례로 확인하면 각 경계에서 무엇이 달라졌는지 재현할 수 있습니다.