CS · Encoding · Unicode

글자 수 세기 - 이모지 하나가 일곱 글자로 세지는 이유

입력창에 100자 제한을 걸었는데 사용자는 40자만 쓰고도 막힌다. 사람이 세는 글자와 컴퓨터가 세는 단위가 층마다 다르기 때문이다. 눈에 같아 보이는 두 글자가 서로 다른 데이터인 경우도 여기서 나온다.

·0과 1로 어떻게 담나 7편
목차
  1. 레고로 만든 강아지는 몇 개인가
  2. 세는 층이 넷이다
  3. 이모지는 조각을 붙여 만든다
  4. 같아 보이는데 다른 글자
  5. 정규화가 이걸 푼다
  6. 자를 때 무엇이 부서지나
  7. 실무에서: 어느 자를 쓸지 먼저 정한다
  8. 정리

“글자 수”라는 말이 층마다 다른 것을 가리킨다. 그래서 셀 때마다 답이 달라진다.

레고로 만든 강아지는 몇 개인가

책상에 레고로 조립한 강아지가 하나 놓여 있다. 몇 개냐고 물으면 답이 갈린다. 눈에 보이는 물건은 하나이고, 뜯어 보면 블록은 열두 개다. 둘 다 맞는 답인데 세는 단위가 다르다.

글자도 똑같다. 그리고 컴퓨터가 세는 단위는 하나가 아니라 여럿이다.

세는 층이 넷이다

네 식구가 그려진 가족 이모지 하나를 예로 보자. 화면에는 그림 하나가 보인다.

사람이 보는 글자화면에 보이는 덩어리1
👨‍👩‍👧‍👦
코드포인트사람 넷 + 보이지 않는 접착제 셋7
👨·👩·👧·👦
코드 유닛자바·자바스크립트의 length가 세는 것11
바이트파일과 네트워크에 실리는 양 (UTF-8)25

하나를 세는데 층마다 답이 다르다. 어느 자로 세는지 정하지 않으면 화면과 코드와 DB가 서로 다른 수를 든다.

같은 것을 세는데 1, 7, 11, 25가 나온다. 넷 다 틀리지 않았다.

언어마다 답이 갈리는 것도 여기서 나온다. 파이썬의 len은 코드포인트를 세서 7을 내놓고, 자바의 length()와 자바스크립트의 .length는 코드 유닛을 세서 11을 내놓는다. 버그가 아니라 서로 다른 자를 든 것이다.

세는 단위무엇을 세나어디서 쓰나
사람이 보는 글자화면에 보이는 덩어리사용자가 기대하는 수
코드포인트유니코드 번호유니코드 처리
코드 유닛언어 안쪽의 저장 단위자바·자바스크립트의 length
바이트파일·네트워크에 실린 양DB 길이 제한, 전송량

입력창 사고가 여기서 난다. 화면의 100자는 맨 윗줄로 세고, 코드의 length는 셋째 줄로 세고, DB 컬럼 제한은 넷째 줄로 센다. 세 군데가 서로 다른 자를 들고 있으니 사용자는 40자를 쓰고도 막힌다.

이모지는 조각을 붙여 만든다

왜 하나가 여럿이 되는지는 이모지를 뜯어 보면 보인다. 가족 이모지는 통째로 하나의 번호를 받은 게 아니라 사람 이모지 여럿을 보이지 않는 접착제로 이어 붙인 것이다.

  • 접착제 문자가 사이에 들어가 “이 조각들을 하나로 그려라”라고 지시한다. 눈에는 안 보이지만 데이터에는 있다.
  • 피부색도 별도 조각이다. 기본 이모지 뒤에 색 변형 문자를 붙여 만든다.
  • 국기는 알파벳 두 글자짜리 특수 문자를 이어 붙인 것이다. 한국 국기는 KR에 해당하는 조각 둘이다.

이 방식의 값은 조합 폭발을 막는 것이다. 가족 구성과 피부색 조합마다 번호를 따로 매기면 번호부가 감당이 안 된다. 조각을 조합해 만들면 번호 몇 개로 수만 가지를 표현한다.

대가가 지금 보고 있는 그것이다. 하나로 보이는 것이 실은 여러 개다. 그래서 뒤에서 한 글자를 지우면 이모지가 사라지는 게 아니라 조각 하나만 떨어져 나가 다른 그림으로 변하는 일이 벌어진다.

같아 보이는데 다른 글자

더 고약한 경우가 있다. 화면에 완전히 똑같이 보이는데 데이터가 다른 경우다.

보이는 글자 코드포인트 바이트 (UTF-8)
é
U+00E9통째로 하나
C3 A92바이트
é
U+0065 U+0301e + 악센트 기호
65 CC 813바이트
화면에서는 구별할 수 없다
데이터로는 다른 값이다
한글도 마찬가지다. 을 통째로 담을 수도 있고 ㅎ + ㅏ + ㄴ으로 나눠 담을 수도 있다

눈에는 같은 글자인데 바이트가 다르면 검색도 비교도 중복 검사도 전부 빗나간다.

é 같은 글자를 만드는 방법이 둘이다. 통째로 하나의 번호를 쓸 수도 있고, e 다음에 악센트 기호를 붙일 수도 있다. 화면에는 똑같이 그려지지만 바이트는 다르다.

그래서 이런 일이 벌어진다.

  • 눈으로는 같은 문자열인데 equalsfalse
  • 검색창에 똑같이 입력했는데 결과가 안 나온다
  • 중복 가입을 막았는데 같은 이름으로 또 가입된다

한글도 해당한다. 을 통째로 담을 수도 있고 ·· 자모로 나눠 담을 수도 있다.

정규화가 이걸 푼다

해법은 정규화다. 어느 한쪽 형태로 통일하는 변환이다.

  • 붙이는 쪽(NFC) - 가능한 것은 통째로 된 하나의 번호로 합친다. 웹 표준이 권장하는 방식이고 대개 이걸 쓴다.
  • 쪼개는 쪽(NFD) - 반대로 전부 조각으로 나눈다.

중요한 건 어느 쪽을 고르냐보다 어디서 하느냐다. 원칙은 둘이다.

  1. 들어올 때 정규화한다. 저장 전에 한 번 통일해 두면 그 뒤로는 신경 쓸 일이 없다.
  2. 비교하기 직전에도 정규화한다. 밖에서 들어온 값과 비교할 때는 그쪽이 어느 형태일지 모른다.

이건 해시테이블로 중복을 걸러 낼 때도 그대로 적용된다. 같은 이름인데 형태가 다르면 해시값 자체가 달라서 서로 다른 칸에 들어간다. 자료구조가 잘못한 게 아니라 들어간 데이터가 다른 것이다.

자를 때 무엇이 부서지나

앞 글에서 바이트 중간에서 자르면 글자가 깨진다고 했다. 여기까지 오면 자를 수 있는 자리가 층마다 다르다는 게 보인다.

어디서 자르나결과
바이트 중간 하나가 남는다
코드 유닛 중간이모지가 깨진 조각이 된다
코드포인트 사이글자는 온전한데 가족이 흩어진다
사람이 보는 글자 사이안전하다

그래서 “50자로 잘라 미리보기” 같은 기능은 생각보다 까다롭다. 맨 아랫줄로 자르려면 언어나 라이브러리가 사람이 보는 글자 단위를 지원해야 한다. 안 되면 최소한 코드포인트 경계로는 자르고, 끝에 붙은 접착제 문자는 같이 떼는 정도의 처리가 필요하다.

실무에서: 어느 자를 쓸지 먼저 정한다

이 층 문제는 코드를 잘 짜서 피하는 게 아니라 어느 단위로 셀지 정해서 피한다.

  • 길이 제한 - 사용자에게 보이는 제한이면 사람이 보는 글자로 세고, DB 컬럼 제한이면 바이트로 센다. 둘을 같은 숫자로 두면 반드시 어긋난다. DB 쪽을 넉넉히 잡는 것이 가장 싸다.
  • 중복·검색 - 저장 전 정규화가 답이다. 여기에 대소문자·앞뒤 공백 처리까지 묶어 한 함수에서 한 번에 하는 편이 안전하다. 여러 군데서 제각기 하면 반드시 빠지는 자리가 생긴다.
  • 파일 이름 - 맥은 자모를 쪼개는 형태로 저장하고 다른 OS는 합친 형태를 쓴다. 그래서 맥에서 만든 압축 파일의 한글 이름이 윈도우에서 자모 분리되어 보인다. 인코딩 사고가 아니라 정규화 사고다.
  • 비밀번호 - 정규화 시점이 로그인과 가입에서 다르면 가입은 됐는데 로그인이 안 되는 사고가 난다.

정리

  • “글자 수”는 층마다 다르다. 사람이 보는 글자 · 코드포인트 · 코드 유닛 · 바이트 넷이 각각 다른 값을 낸다.
  • 이모지는 조각을 접착제로 이어 붙인 것이다. 그래서 하나로 보여도 여럿이다.
  • 같아 보이는데 바이트가 다른 경우가 있다. 합친 형태와 쪼갠 형태다.
  • 해법은 정규화다. 들어올 때 한 번, 비교 직전에 한 번.
  • 자를 수 있는 안전한 자리는 사람이 보는 글자 사이뿐이다.
  • 실무에서는 코드를 고치기 전에 어느 자로 셀지부터 정한다.

다음 글은 방향을 뒤집는다. 지금까지는 글자를 바이트로 담았는데, 바이트를 글자로 담아야 하는 상황이 있다.