광고
~ / posts / developer
💻 개발자

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

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

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

광고

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

사람이 화면에서 한 덩어리로 보는 글자를 전문 용어로 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로 달라지는 구조도
한 덩어리처럼 보여도 안은 일곱 조각

UTF-16에서 이모지가 왜 정확히 2칸인지는 계산으로 확인할 수 있어요. U+FFFF를 넘는 코드 포인트는 0x10000을 뺀 20비트를 위 10비트와 아래 10비트로 나눠, 각각 0xD800과 0xDC00에 더해 두 칸(상위·하위 서로게이트)에 담아요. 웃는 얼굴 U+1F600은 0xF600이 되고, 위 10비트 0x3D → D83D, 아래 10비트 0x200 → DE00이에요. 이 규칙은 MDN의 String.length 설명에도 정리돼 있어요.

웃는 얼굴 이모지가 코드 포인트 U+1F600 하나, UTF-16 두 칸 D83D·DE00, UTF-8 네 바이트 F0·9F·98·80으로 저장되고, slice(0, 1)로 자르면 짝 잃은 서로게이트가 남는다는 것을 보여 주는 그림
같은 이모지도 인코딩마다 칸 수가 달라요

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

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

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

입력grapheme(사람)코드 포인트UTF-16(JS .length)UTF-8 바이트
가1113
웃는 얼굴 😀1124
태극기 🇰🇷1248
피부색 지정 손 👋🏽1248
가족 이모지 👨‍👩‍👧‍👦171125
빨간 하트 ❤️1226
키캡 숫자 1️⃣1337
무지개 깃발 🏳️‍🌈14614
자모 분리형(NFD) 각1339

아래쪽 네 줄이 실무에서 더 자주 헷갈려요. 빨간 하트는 흑백 하트(U+2764) 뒤에 "그림으로 보여 줘"라는 변형 선택자 VS16(U+FE0F)이 붙은 2조각이에요. 키캡 1은 숫자 1, VS16, 키캡 테두리(U+20E3) 3조각이고, 무지개 깃발은 흰 깃발 + VS16 + ZWJ + 무지개 4조각이에요. 숫자는 모두 아래 Node.js 코드로 직접 뽑았어요.

// Node.js 22.18 에서 실행한 코드와 출력
const seg = new Intl.Segmenter('ko', { granularity: 'grapheme' });
for (const s of ['❤️', '1️⃣', '🏳️‍🌈', '각'.normalize('NFD')]) {
  console.log([...seg.segment(s)].length, [...s].length, s.length,
    new TextEncoder().encode(s).length,
    [...s].map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase()).join(' '));
}
// 1 2 2 6 U+2764 U+FE0F
// 1 3 3 7 U+31 U+FE0F U+20E3
// 1 4 6 14 U+1F3F3 U+FE0F U+200D U+1F308
// 1 3 3 9 U+1100 U+1161 U+11A8
같은 글자 하나를 세는 방식별 결과
코드 포인트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 문제는 실제로 돌려 보면 바로 보여요. 가족 이모지 앞에 한글 두 글자를 두고 UTF-16 기준 3칸만 자르면, 이모지의 상위 서로게이트 한 칸만 남아요.

const t = '안녕👨‍👩‍👧‍👦하세요';
const cut = t.slice(0, 3);
console.log(JSON.stringify(cut));        // "안녕\ud83d"
console.log(cut.isWellFormed());         // false
console.log(JSON.stringify(cut.toWellFormed())); // "안녕�"
encodeURIComponent(cut);                 // URIError: URI malformed

마지막 줄이 특히 위험해요. 잘린 문자열을 URL 쿼리에 넣는 순간 예외가 나서 페이지 전체가 멈출 수 있어요. ES2024에 들어온 String.prototype.isWellFormed()로 짝 잃은 서로게이트를 검사할 수 있지만, 근본 해결은 처음부터 grapheme 단위로 자르는 거예요.

미리보기를 만들 땐 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 여야 해요. 지원 현황과 옵션은 MDN Intl.Segmenter 문서에 있고, grapheme을 어디서 끊는지는 유니코드 표준 UAX #29 텍스트 분할 규칙이 정해요. 이모지 조합 규칙(ZWJ 시퀀스, 피부색 수정자)은 UTS #51 유니코드 이모지에 정리돼 있어요. Python은 표준 라이브러리에 grapheme 분할이 없어서 regex 모듈의 \X 나 외부 라이브러리를 써야 해요.

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

언어·DB별로 같은 이모지를 세면

가족 이모지 하나를 각 언어와 DB의 기본 길이 함수로 세어 봤어요. 표의 숫자는 이 글을 쓰며 직접 실행한 결과예요(Node.js 22.18, Python 3.9, Java 17, macOS Swift, SQLite 3.44). PostgreSQL 줄만 문서 기준이에요.

환경기본 길이 함수가족 이모지 결과세는 단위
JavaScripts.length11UTF-16 칸
JavaScript[...s].length7코드 포인트
JavaScriptIntl.Segmenter1grapheme
Python 3len(s)7코드 포인트
Javas.length() / codePointCount11 / 7UTF-16 칸 / 코드 포인트
Swifts.count1grapheme (언어 기본값)
SQLitelength(s) / length(cast(s as blob))7 / 25코드 포인트 / 바이트
PostgreSQL (UTF-8 DB)char_length(s) / octet_length(s)7 / 25문자 / 바이트

Swift만 기본 count 가 사람 기준이라는 점이 눈에 띄어요. PostgreSQL은 문자열 함수 문서에 char_length 는 문자 수, octet_length 는 바이트 수라고 정의돼 있어요. 결국 서버에서 "30자 제한"을 걸 때 대부분의 기본 함수는 grapheme이 아니라 코드 포인트나 UTF-16을 센다는 것만 기억해 두면 돼요.

// Java 17
String s = "👨‍👩‍👧‍👦";
System.out.println(s.length() + " " + s.codePointCount(0, s.length())
    + " " + s.getBytes("UTF-8").length);   // 11 7 25
-- SQLite 3.44
SELECT length('👨‍👩‍👧‍👦'), length(CAST('👨‍👩‍👧‍👦' AS BLOB));  -- 7 | 25

글자 수 제한 정책 정하기

정책화면 카운터서버 검증DB 컬럼맞는 경우
사람 기준Intl.Segmentergrapheme 라이브러리넉넉한 VARCHAR 또는 TEXT닉네임, 소개글
코드 포인트 기준[...s].lengthlen(s) (Python)VARCHAR(n) (MySQL·PostgreSQL은 문자 수)내부 시스템
바이트 기준TextEncoder 바이트len(s.encode())바이트 제한 필드SMS, 레거시 연동

사람 기준으로 정했다면 DB 컬럼은 grapheme 수보다 훨씬 넉넉하게 잡아야 해요. 가족 이모지 하나가 코드 포인트 7개라서, "30자"를 grapheme으로 허용하면 코드 포인트는 수백 개가 될 수 있어요. 그래서 grapheme 제한과 함께 바이트 상한(예: 1,000바이트)을 하나 더 두는 방식이 안전해요.

그리고 글자 수 제한을 둘 때는 화면 카운터, 서버 검증, 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#이모지 길이#글자수 초과
다음 글 →URL 인코딩 원리와 한글 URL, encodeURI vs encodeURIComponent 차이
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →