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: 사람 눈에 보이는 글자 덩어리 수
- 코드 포인트: 유니코드 문자 번호의 개수 (Python
len) - UTF-16 길이: 자바스크립트
.length가 세는 칸 수 - UTF-8 바이트: 저장했을 때의 바이트 수
평범한 한글이나 영어는 이 넷이 (바이트만 빼고) 거의 같아요. '가'는 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) 셋, 코드 포인트가 일곱 개예요. 화면에서는 한 덩어리로 보이지만 내부는 이렇게 길어요.
셋째, 피부색과 성별·국기 변형. 손 흔드는 이모지에 피부색을 고르면 기본 이모지 뒤에 피부색 조각이 하나 더 붙어요(코드 포인트 2개). 국기 이모지는 국가 코드 알파벳 두 개에 해당하는 특수 문자(지역 표시 기호) 두 개로 만들어져요. 태극기도 마찬가지예요.
같은 입력, 세는 방식에 따른 결과
실제로 각 이모지를 네 가지 방식으로 세어 봤어요.
| 입력 | grapheme(사람) | 코드 포인트 | UTF-16(JS .length) | UTF-8 바이트 |
|---|---|---|---|---|
| 가 | 1 | 1 | 1 | 3 |
| 웃는 얼굴 😀 | 1 | 1 | 2 | 4 |
| 태극기 🇰🇷 | 1 | 2 | 4 | 8 |
| 피부색 지정 손 👋🏽 | 1 | 2 | 4 | 8 |
| 가족 이모지 👨👩👧👦 | 1 | 7 | 11 | 25 |
한 덩어리가 바이트로는 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코드 포인트)로 나뉘어서 같은 일이 생겨요.
실무에서 터지는 장면들
이 차이는 생각보다 자주, 그리고 조용히 사고를 일으켜요.
- 프로필·닉네임 글자 수 초과: 앞에서 본 것처럼 화면엔 25자인데 서버는 UTF-16으로 세서 막아요. 사용자는 "분명 짧은데 왜 안 되지?" 하며 문의를 넣어요.
- 문자열 자르기(truncate)가 이모지를 반 토막: 긴 글을 "앞 100자만" 잘라 미리보기를 만들 때, UTF-16 칸 기준으로 자르면 서로게이트 페어나 ZWJ 결합 중간에서 잘려요. 그러면 깨진 네모(□)나 엉뚱한 반쪽 이모지가 나와요.
slice(0, 100)이 대표적 범인이에요. - 거꾸로 뒤집기: 문자열을 뒤집는 코드(
[...s].reverse())가 이모지 조각 순서를 섞어서 전혀 다른 이모지나 깨진 글자를 만들어요. - DB 저장 실패: MySQL의
utf8은 사실 3바이트까지만 담는 반쪽짜리라, 4바이트인 이모지를 넣으면 저장이 실패하거나 잘려요. 이모지를 받으려면utf8mb4를 써야 해요. "이모지만 넣으면 글이 안 올라가요" 문의의 단골 원인이에요.
세 번째 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('');
}
그래서 어떻게 세야 하나
일반 사용자라면 이 정도만 기억하면 돼요.
- 제한이 빡빡한 곳에서는 이모지를 아끼세요. 한 개가 2자 이상으로 세질 수 있어요.
- 이모지가 들어간 글은 실제 입력창에서 최종 확인하세요.
글자 수·바이트 세기는 사람이 보는 기준(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 차이에서 이어서 볼 수 있어요.