본문 바로가기

글자 하나는 무엇으로 셀까요?

같은 텍스트를 네 가지 시스템이 네 가지 방식으로 셉니다. 영어 문장에서는 결과가 같기 때문에, 어느 날 달라지기 전까지는 문제가 드러나지 않습니다.

최종 검토:

이모지 하나, 답은 네 가지

피부색을 적용한 엄지척 이모지나 가족 이모지를 예로 들어 보겠습니다. 네 가지 방식으로 세면 네 가지 숫자가 나오며, 그중 틀린 것은 없습니다.

읽는 사람에게는 한 글자입니다. 하나로 보이고, 지울 때도 백스페이스 한 번이면 됩니다.

정규 표현식은 7개로 볼 수 있습니다. 기본 이모지, 수식 문자, 결합 문자, 또 다른 이모지 등 여러 코드 포인트가 보이지 않는 연결 문자로 이어져 만들어지기 때문입니다.

JavaScript는 11이라고 알려 줍니다. 텍스트를 16비트 단위로 저장하는데, 기본 범위 밖의 문자는 단위 두 개를 차지하므로 각 코드 포인트가 두 번씩 세어질 수 있습니다.

데이터베이스 열은 25바이트로 봅니다. 저장할 때 UTF-8로 인코딩하면 코드 포인트 하나가 최대 4바이트를 차지합니다.

일반적인 영어 문장에서는 네 숫자가 모두 같습니다. 그래서 누군가 악센트가 들어간 이름, 일본어 문장, 이모지 하나를 입력하기 전까지는 문제가 보이지 않다가, 3년 동안 잘 작동하던 필드가 갑자기 데이터를 잘라 내기 시작합니다.

내 제한이 뜻하는 숫자

실제로 물어야 할 질문은 “이 텍스트가 얼마나 긴가”가 아닙니다. “무엇이 제한하고 있는가”이며, 흔한 답은 네 가지입니다.

데이터베이스 열을 VARCHAR로 선언하면 대부분의 엔진에서 바이트를 셉니다. 그래서 영어 255자는 넉넉히 들어가는 필드가 글자당 3바이트인 일본어는 훨씬 적게 받아들이고, 악센트 없는 이름은 들어가던 필드에 악센트가 들어간 같은 이름이 가끔 들어가지 않습니다.

SMS는 자체 7비트 문자 집합 기준으로 160자이며, 그 밖의 문자가 하나라도 들어가는 순간 메시지당 70자로 줄어듭니다. 워드프로세서에서 붙여 넣은 둥근 따옴표 하나나 이모지 하나 때문에 한 건짜리 메시지가 세 건이 되어 요금이 세 배가 될 수 있습니다.

JavaScript로 만든 폼 유효성 검사기는 거의 항상 UTF-16 단위를 셉니다. 그래서 280자라고 적힌 필드가 이모지가 가득한 문자열을 훨씬 짧아 보이는데도 거부할 수 있습니다.

사람은 눈에 보이는 것을 셉니다. 헤드라인, 타이틀 태그처럼 시각적인 길이 제한이 있는 곳에서는 이것만이 의미 있는 기준입니다.

숫자가 달라지는 곳

문제를 일으키는 입력은 예측할 수 있기 때문에, 이론보다 어떤 입력이 문제인지 아는 편이 더 유용합니다.

이모지, 특히 피부색, 가족, 국기, 직업처럼 조합된 이모지. 국기는 지역 표시 문자 두 개로 이루어지고, 직업 이모지는 보통 사람, 결합 문자, 사물로 이루어집니다.

악센트 문자와 결합 문자. é는 코드 포인트 하나로 쓸 수도 있고, e 뒤에 결합용 양음 부호를 붙여 쓸 수도 있습니다. 둘은 똑같아 보이지만 정렬 순서가 다르고, 비교하면 다르다고 나오고, 세는 숫자도 다릅니다. 실제 데이터에는 두 형태가 모두 나오며, 같은 열에 섞여 있는 경우도 많습니다.

라틴 문자가 아닌 문자. 한국어, 중국어, 일본어 글자는 UTF-8에서 각각 3바이트입니다. 힌디어, 태국어, 타밀어, 아랍어는 읽는 사람에게는 한 단위로 보이지만 여러 코드 포인트로 이루어진 묶음을 만듭니다.

수학 기호와 음악 기호. 기본 범위 밖에 있어서 UTF-16에서는 두 번씩 세어집니다.

어떻게 해야 할까

제한이 세는 것을 세세요. 네 가지 숫자를 모두 보여 주는 글자 수 세기 도구가 따져 보는 것보다 빠르게 바로 답을 줍니다. 코드에서는 알맞은 함수를 쓰세요. JavaScript의 Intl.Segmenter는 읽는 사람이 보는 단위를, Python의 len(s.encode("utf-8"))은 바이트를 세며, PHP에서는 strlen 대신 mb_strlen을 쓰세요.

폼이 아니라 열을 고치세요. 데이터베이스 필드가 제약이라면 근본적인 해결책은 그 앞단에 있습니다. MySQL에서는 utf8mb4로, PostgreSQL에서는 text로 선언하세요. MySQL의 예전 “utf8”은 글자당 3바이트까지만 저장해서 이모지를 아예 저장할 수 없는 것으로 유명합니다. 오랫동안 이어진 함정으로, 수많은 실제 데이터를 조용히 잘라 냈습니다. 폼에서 신중하게 세는 것은 처음부터 다르게 선언했어야 할 열을 위한 임시방편일 뿐입니다.

입력 단계에서 정규화하세요. 입력 시점에 텍스트를 하나의 정규형으로 변환하면 é의 두 가지 표기가 하나로 합쳐지고, 비교, 정렬, 세기가 모두 일치하기 시작합니다. 모든 언어에 이를 위한 함수가 있으며, 경계에서 한 번 처리하는 것이 모든 곳에서 두 형태를 다루는 것보다 훨씬 쉽습니다.

관련 사례: Base64

같은 종류의 놀라움을 일으키기 때문에 짚어 둘 만합니다. 데이터를 Base64로 인코딩하면 3바이트씩 가져와 4글자로 쓰기 때문에 결과가 약 3분의 1 커집니다. 비효율이 아니라 산수입니다. 6 MB 첨부 파일은 약 8 MB의 텍스트가 되며, 그래서 용량 제한보다 넉넉히 작은 파일도 전송을 위해 인코딩되면 거부될 수 있습니다.

이메일 첨부 파일이 제한을 지킨 것 같은데도 반송되는 이유도 같은 계산으로 설명됩니다. 상한에 맞춰 크기를 정해야 한다면 인코딩된 형태를 기준으로 하세요.

자주 묻는 질문

255자 필드가 왜 더 짧은 텍스트를 거부하나요?

거의 확실히 글자가 아니라 바이트를 세기 때문입니다. UTF-8에서 악센트 문자는 2바이트, 한중일 문자는 3바이트를 차지하므로 255바이트는 일본어 85자일 수 있습니다. 열을 utf8mb4나 text로 선언하면 제대로 해결됩니다.

이모지 하나 때문에 정말 SMS 세 건 요금이 나오나요?

그럴 수 있습니다. GSM 문자 집합의 문자로만 된 메시지는 한 건당 160자이지만, 그 밖의 문자가 하나만 있어도 메시지 전체가 70자 인코딩으로 바뀝니다. 그래서 이모지 하나가 든 150자 메시지는 한 건이 아니라 세 건이 됩니다.

타이틀 태그에는 어떤 숫자를 써야 하나요?

어느 것도 정확하지 않습니다. 검색 엔진은 글자 수가 아니라 픽셀 너비로 자르기 때문에, 폭이 넓은 대문자로 된 제목이 폭이 좁은 소문자로 된 제목보다 먼저 잘립니다. 흔히 약 60자를 기준으로 삼지만, 제한이라기보다 참고용입니다.

똑같아 보이는 두 문자열이 왜 일치하지 않나요?

보통 악센트 문자가 두 가지 방식으로 쓰였기 때문입니다. 한쪽은 코드 포인트 하나로, 다른 쪽은 기본 글자에 결합 부호를 더한 형태입니다. 비교하기 전에 둘 다 같은 형태로 정규화하면 일치하며, 데이터가 시스템에 들어오는 시점에 해 둘 만한 작업입니다.

단어 수가 글자 수보다 더 안정적인가요?

영어라면 대체로 그렇습니다. 하지만 어디서나 그런 것은 아닙니다. 중국어와 일본어는 단어를 공백으로 구분하지 않고 태국어도 마찬가지라서, 이런 문자에서는 단어를 세려면 분절이 필요하고 도구마다 답이 다릅니다.