~ / posts / developer
💻 개발자

이모지 글자 수가 2자·11자로 세지는 이유와 grapheme 세는 법

SNS 프로필 소개를 쓰다가 이상한 걸 겪은 적 있을 거예요. 글자 수 제한이 30자인데, 이모지 몇 개 넣었더니 갑자기 "글자 수 초과"가 떠요. 눈으로 세면 분명 25자인데요. 결론부터 말하면 컴퓨터가 생각하는 "한 글자"와 사람이 보는 "한 글자"가 다르기 때문이에요. 이모지 하나는 내부적으로 여러 조각으로 이뤄져 있어서, 세는 방식에 따라 1자, 2자, 7자, 11자로 달라져요.

계산기가 틀린 게 아니에요. 가족 이모지처럼 복잡한 건 사람 넷을 이어 붙여 만든 거라 내부가 길어요. 왜 그런지 실제로 조각을 세어서 보여 드리고, 개발자라면 사람 기준으로 정확히 세는 방법(Intl.Segmenter)까지 Node.js와 Python으로 확인하며 정리했어요.

핵심 요약

사람이 한 덩어리로 보는 글자를 grapheme(그래핌)이라 하고, 그 안은 여러 코드 포인트로 이뤄질 수 있어요.

웃는 얼굴 이모지는 JS .length로 2, 피부색 지정 이모지는 4, 가족 이모지는 11로 세져요. 사람 눈엔 모두 1글자예요.

가족 이모지는 사람 이모지 4개를 ZWJ(폭 없는 결합 문자) 3개로 이어 붙여서 코드 포인트가 7개예요.

사람 기준으로 세려면 JS는 Intl.Segmenter, 바이트가 중요하면 UTF-8 바이트를 따로 확인하세요.

사람이 보는 글자, 컴퓨터가 보는 글자

사람이 화면에서 한 덩어리로 보는 글자를 전문 용어로 grapheme(그래핌), 정확히는 grapheme cluster라고 불러요. 그런데 컴퓨터 안에서 그 한 덩어리는 여러 개의 조각(코드 포인트)으로 이뤄져 있을 수 있어요.

여기에 "어떤 단위로 세느냐"가 겹쳐서 숫자가 여러 개 나와요.

평범한 한글이나 영어는 이 넷이 (바이트만 빼고) 거의 같아요. '가'는 grapheme 1, 코드 포인트 1, UTF-16 1, UTF-8 3바이트예요. 문제는 이모지예요.

이모지가 여러 글자로 세지는 세 가지 이유

첫째, 번호가 큰 문자. 이모지 대부분은 유니코드에서 번호가 아주 큰 영역(U+10000 이상)에 있어요. 자바스크립트처럼 내부적으로 UTF-16을 쓰는 환경에서는 이런 문자를 두 칸에 나눠 담아요(서로게이트 페어). 그래서 웃는 얼굴 이모지 하나가 .length 로 2가 나와요. 웹사이트 입력창의 글자 수 제한이 이 방식이면 이모지는 2자로 세져요.

둘째, 조합형 이모지. 가족 이모지(어른 둘, 아이 둘)는 사실 사람 이모지 네 개를 "이어 붙이기" 기호(ZWJ, Zero Width Joiner, 폭 없는 결합 문자)로 연결한 거예요. 코드 포인트를 직접 뽑아 보면 이래요.

[...'👨‍👩‍👧‍👦'].map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase());
// ['U+1F468', 'U+200D', 'U+1F469', 'U+200D', 'U+1F467', 'U+200D', 'U+1F466']
//   남성      ZWJ        여성       ZWJ        여아       ZWJ        남아

사람 넷(1F468, 1F469, 1F467, 1F466)에 연결 기호(200D) 셋, 코드 포인트가 일곱 개예요. 화면에서는 한 덩어리로 보이지만 내부는 이렇게 길어요.

가족 이모지가 남성·여성·여아·남아 이모지 4개를 ZWJ 3개로 이어 붙인 7개 코드 포인트로 이뤄지고, 세는 방식에 따라 1·7·11·25로 달라지는 구조도
한 덩어리처럼 보여도 안은 일곱 조각

셋째, 피부색과 성별·국기 변형. 손 흔드는 이모지에 피부색을 고르면 기본 이모지 뒤에 피부색 조각이 하나 더 붙어요(코드 포인트 2개). 국기 이모지는 국가 코드 알파벳 두 개에 해당하는 특수 문자(지역 표시 기호) 두 개로 만들어져요. 태극기도 마찬가지예요.

같은 입력, 세는 방식에 따른 결과

실제로 각 이모지를 네 가지 방식으로 세어 봤어요.

입력grapheme(사람)코드 포인트UTF-16(JS .length)UTF-8 바이트
가1113
웃는 얼굴 😀1124
태극기 🇰🇷1248
피부색 지정 손 👋🏽1248
가족 이모지 👨‍👩‍👧‍👦171125
같은 글자 하나를 세는 방식별 결과
코드 포인트UTF-16 길이UTF-8 바이트
0510152025가 · 코드 포인트 1개가 · UTF-16 길이 1개가 · UTF-8 바이트 3개가웃는얼굴 · 코드 포인트 1개웃는얼굴 · UTF-16 길이 2개웃는얼굴 · UTF-8 바이트 4개웃는얼굴태극기 · 코드 포인트 2개태극기 · UTF-16 길이 4개태극기 · UTF-8 바이트 8개태극기피부색 손 · 코드 포인트 2개피부색 손 · UTF-16 길이 4개피부색 손 · UTF-8 바이트 8개피부색 손가족 이모지 · 코드 포인트 7개가족 이모지 · UTF-16 길이 11개가족 이모지 · UTF-8 바이트 25개가족 이모지

사람 눈엔 다섯 개 모두 한 글자지만 내부 수치는 최대 25배 차이

단위: 개 · 자료: Node.js 22.18 · Python 3.9 로 각 문자를 직접 측정 (사람 기준 grapheme은 모두 1)
표로 보기
구분코드 포인트UTF-16 길이UTF-8 바이트
가113
웃는얼굴124
태극기248
피부색 손248
가족 이모지71125

한 덩어리가 바이트로는 25까지 가요. 바이트 제한이 있는 곳(생기부 입력, 오래된 시스템, SMS)에서 이모지를 피해야 하는 이유이기도 해요. 바이트 기준 이야기는 자소서 글자 수와 NEIS 바이트에서 다뤘어요.

한글에도 비슷한 일이 있다

드물지만 한글도 이 문제를 겪어요. 보통 쓰는 한글은 완성된 글자 하나가 한 조각이지만, 어떤 환경(특히 맥에서 만든 파일 이름 등)에서는 '가'를 'ㄱ'과 'ㅏ' 두 조각으로 나눠 저장하기도 해요.

import unicodedata
len('가')                               # 1 (NFC, 완성형)
len(unicodedata.normalize('NFD', '가')) # 2 (초성 ㄱ + 중성 ㅏ)
[hex(ord(c)) for c in unicodedata.normalize('NFD', '각')]
# ['0x1100', '0x1161', '0x11a8']  ← 초성·중성·종성 3조각

이걸 유니코드 정규화(NFC/NFD) 문제라고 불러요. 화면엔 똑같이 '가'인데 글자 수가 두 배로 세지거나, 검색이 안 되는 일이 여기서 생겨요. 정규화 차이는 정규식 입력 검증 함정에서도 다룬 적 있어요. 참고로 라틴 문자 é도 NFC(1코드 포인트)와 NFD(e + 억음부호, 2코드 포인트)로 나뉘어서 같은 일이 생겨요.

실무에서 터지는 장면들

이 차이는 생각보다 자주, 그리고 조용히 사고를 일으켜요.

세 번째 truncate 문제는 특히 흔해서, 미리보기를 만들 땐 grapheme 단위로 자르는 게 안전해요.

function sliceGraphemes(text, n) {
  const seg = new Intl.Segmenter('ko', { granularity: 'grapheme' });
  return [...seg.segment(text)].slice(0, n).map(s => s.segment).join('');
}

그래서 어떻게 세야 하나

일반 사용자라면 이 정도만 기억하면 돼요.

글자 수·바이트 세기는 사람이 보는 기준(grapheme)으로 글자를 세서 가족 이모지도 한 글자로 쳐요. 대신 바이트 칸을 보면 내부적으로 얼마나 무거운지 같이 확인할 수 있어요.

개발자를 위한 정확한 세는 법

자바스크립트의 .length 는 UTF-16 칸 수라 이모지를 과하게 세요. 사람 기준 글자 수가 필요하면 Intl.Segmenter 를 쓰세요.

function graphemeLength(text) {
  const seg = new Intl.Segmenter('ko', { granularity: 'grapheme' });
  return [...seg.segment(text)].length;
}
graphemeLength('👨‍👩‍👧‍👦');  // 1
'👨‍👩‍👧‍👦'.length;            // 11  (UTF-16)
[...'👨‍👩‍👧‍👦'].length;       // 7   (코드 포인트 — 스프레드는 여기까지만)

스프레드 연산자([...text])나 for...of 는 코드 포인트 단위라 서로게이트 페어는 합쳐 주지만 ZWJ 결합까지는 못 합쳐요. 그래서 가족 이모지가 7로 나와요. 진짜 사람 기준은 Intl.Segmenter(2024년 이후 주요 브라우저·Node 지원)여야 해요. Python은 표준 라이브러리에 grapheme 분할이 없어서 regex 모듈의 \X 나 외부 라이브러리를 써야 해요.

import regex   # pip install regex
len(regex.findall(r'\X', '👨‍👩‍👧‍👦'))  # 1
len('👨‍👩‍👧‍👦')                          # 7 (코드 포인트)

그리고 글자 수 제한을 둘 때는 화면 카운터, 서버 검증, DB 컬럼이 같은 기준으로 세는지 맞춰 둬야 해요. 화면은 grapheme으로 30자라며 통과시켰는데 서버가 UTF-16 길이로 막으면, 사용자는 이유도 모른 채 저장에 실패해요. 이모지 하나 때문에 고객 문의가 들어오는 걸 막는 가장 싼 방법이 이 기준 통일이에요.

자주 묻는 질문

이모지는 몇 글자로 세나요?

세는 방식에 따라 달라요. 사람 기준(grapheme)으로는 1글자지만, 자바스크립트 .length(UTF-16)로는 웃는 얼굴이 2, 가족 이모지가 11로 세져요. UTF-8 바이트로는 가족 이모지가 25바이트예요. 어느 기준을 쓰는 곳인지 확인해야 해요.

가족 이모지가 왜 11자로 세지나요?

사람 이모지 4개를 ZWJ(폭 없는 결합 문자) 3개로 이어 붙여 만들었기 때문이에요. 각 사람 이모지가 UTF-16에서 2칸, ZWJ가 1칸씩이라 2×4 + 1×3 = 11이 돼요. 코드 포인트로는 7개, 사람 눈에는 1글자예요.

JavaScript에서 이모지를 한 글자로 세려면 어떻게 하나요?

Intl.Segmenter 를 granularity: 'grapheme' 으로 써서 세면 돼요. .length 는 UTF-16 칸 수라 이모지를 과하게 세고, 스프레드 연산자는 코드 포인트까지만 합쳐서 ZWJ 결합 이모지를 여러 개로 세요.

이모지를 글자 수 제한에 쓰면 왜 문제가 되나요?

화면 카운터, 서버 검증, 데이터베이스가 서로 다른 기준으로 세면 사용자가 이유 없이 저장에 실패해요. 화면은 grapheme으로 통과했는데 서버가 UTF-16으로 막는 식이에요. 세 곳의 세는 기준을 통일하는 게 해결책이에요.

화면엔 똑같은 글자인데 글자 수가 다르게 나와요.

유니코드 정규화(NFC/NFD) 차이일 수 있어요. '가'가 완성형 1코드 포인트일 수도, 자모 분리형 2~3코드 포인트일 수도 있어요. 맥에서 만든 파일명이나 일부 입력기에서 분리형이 들어와요. normalize('NFC') 로 정규화한 뒤 세면 일치해요.

정리

이모지는 "사람이 보는 한 글자"와 "컴퓨터가 세는 조각 수"가 크게 달라요. 번호가 큰 문자, ZWJ로 이어 붙인 조합형, 피부색·국기 변형 때문에 하나가 2자에서 11자, 25바이트까지 세져요. 사람 기준으로 세려면 Intl.Segmenter 를, 저장 한도가 걱정이면 UTF-8 바이트를 확인하고, 무엇보다 화면·서버·DB의 세는 기준을 맞추는 게 핵심이에요.

사람 기준 글자 수와 바이트를 함께 보려면 글자 수·바이트 세기를 쓰고, 한글 바이트 이야기는 한글 바이트와 UTF-8·EUC-KR 차이에서 이어서 볼 수 있어요.

#이모지 글자수#grapheme#유니코드 이모지#ZWJ#Intl.Segmenter#이모지 길이#글자수 초과
← 이전 글JWT 보안 체크리스트 — alg none, 알고리즘 혼동, 토큰 저장 위치다음 글 →URL 인코딩 원리와 한글 URL, encodeURI vs encodeURIComponent 차이