토크나이저를 처음 보면 별것 아닌 전처리처럼 보인다. 문장을 잘게 쪼개서 숫자로 바꾸는 도구. 설명은 대개 거기서 끝난다. 그런데 LLM을 조금만 깊게 보면 토크나이저는 모델 바깥의 부속품이 아니다. 모델이 세상을 읽는 단위 자체를 정하는 장치다.
사람은 문장을 단어와 의미로 읽는다. 모델은 토큰 ID의 배열을 읽는다. 안녕하세요가 하나의 토큰인지, 안녕과 하세요로 나뉘는지, 혹은 더 작은 byte 조각으로 쪼개지는지는 모델이 처음부터 끝까지 보게 될 입력의 모양을 바꾼다. 같은 한국어 문장이라도 토크나이저가 다르면 길이가 달라지고, 길이가 달라지면 attention 비용도 달라진다. 긴 문서 처리, 한국어 성능, 코드 생성, 특수 토큰 설계가 여기서 영향을 받는다.
토크나이저는 원문 문자열을 모델이 계산할 수 있는 token ID 배열로 바꾼다.
모델은 글자를 직접 읽지 않는다
Transformer에 텍스트를 그대로 넣을 수는 없다. 신경망은 문자열을 계산하지 않고 숫자 텐서를 계산한다. 그래서 텍스트는 먼저 토큰으로 쪼개지고, 각 토큰은 vocabulary에 있는 정수 ID로 바뀐다.
예를 들어 어떤 토크나이저가 다음처럼 문장을 나눈다고 하자.
입력 문장: 나는 토크나이저를 공부한다
토큰: [나는, 토크, 나이저, 를, 공부, 한다]
토큰 ID: [8123, 441, 9201, 317, 2304, 901]
모델이 받는 것은 나는 토크나이저를 공부한다라는 문자열이 아니라 [8123, 441, 9201, 317, 2304, 901] 같은 정수 배열이다. 그 다음 embedding layer가 각 ID를 벡터로 바꾸고, Transformer block이 그 벡터들 사이의 관계를 계산한다.
여기서 자주 생기는 오해가 있다. 토큰은 단어와 같지 않다. 영어의 unbelievable은 un, believ, able처럼 쪼개질 수 있고, 한국어의 공부했습니다는 공부, 했, 습니다처럼 나뉠 수도 있다. 코드에서는 def, Ġfunction, () 같은 단위가 토큰이 된다. 공백도 중요하다. GPT 계열 토크나이저에서 hello와 hello는 다른 토큰으로 잡히는 경우가 흔하다.
토크나이저는 문자열과 토큰 ID 사이의 양방향 약속이다.
encode: "Transformer" -> [41762]
decode: [41762] -> "Transformer"
훈련 때 쓰인 토크나이저와 추론 때 쓰는 토크나이저가 달라지면 모델은 익숙한 입력 분포를 잃는다. 모델 파라미터만 있으면 되는 게 아니다. vocabulary, merge rule, normalizer, special token 설정까지 같이 있어야 같은 모델이다.
왜 문자 단위로 넣지 않을까
가장 단순한 방법은 한 글자씩 넣는 것이다.
나는 밥을 먹었다
-> [나, 는, , 밥, 을, , 먹, 었, 다]
이 방식은 vocabulary가 작다. 한국어, 영어, 숫자, 기호를 모두 넣어도 단어 사전보다 훨씬 작게 유지된다. 처음 보는 단어도 처리한다. 좋아 보인다.
문제는 길이다. 문자가 너무 작아서 같은 문장을 표현하는 토큰 수가 크게 늘어난다. Transformer의 self-attention은 시퀀스 길이에 민감하다. 토큰 수가 2배가 되면 attention에서 보는 쌍은 대략 4배로 늘어난다. 실제 구현은 FlashAttention, KV cache, chunking으로 많이 완화하지만 입력 토큰 수가 비용과 지연 시간의 중심이라는 사실은 바뀌지 않는다.
단어 단위 토크나이저는 반대 문제가 생긴다.
나는 밥을 먹었다
-> [나는, 밥을, 먹었다]
길이는 짧아진다. 대신 처음 보는 단어를 만나면 vocabulary에 없다. 예전 NLP 모델에서 [UNK] 토큰이 자주 등장한 이유다. ChatGPT, Qwen3, hrletsgo.me, calculateUserEmbedding() 같은 문자열을 모두 단어 사전에 미리 넣을 수는 없다.
현재 LLM 토크나이저의 주류는 이 둘 사이에 있다. 자주 나오는 문자열 조각은 크게 묶고, 드문 문자열은 더 작게 쪼갠다. 영어 단어는 단어에 가까운 조각으로, 한국어 조사나 어미는 더 작은 조각으로, URL과 코드는 byte 수준까지 내려가며 표현한다.
문자 단위는 길고, 단어 단위는 낯선 단어에 약하다. subword 토큰화는 둘 사이의 타협점이다.
BPE: 자주 붙어 다니는 조각을 합친다
BPE는 Byte Pair Encoding의 약자다. 원래는 압축 알고리듬으로 제안된 방식이고, NLP에서는 subword 토큰화를 만드는 데 응용됐다. NLP에 BPE를 적용한 대표 논문은 Sennrich 등이 쓴 Neural Machine Translation of Rare Words with Subword Units다.
BPE의 생각은 단순하다. 가장 자주 붙어 나오는 두 조각을 하나로 합친다. 이 일을 vocabulary 크기가 목표에 도달할 때까지 반복한다.
작은 예시로 보자. 학습 말뭉치에 이런 단어들이 있다고 하자.
low lower lowest
처음에는 문자 단위로 쪼갠다.
l o w
l o w e r
l o w e s t
가장 자주 붙어 나오는 쌍은 l o다. 합치면 lo가 된다.
lo w
lo w e r
lo w e s t
다음으로 lo w가 자주 나오면 low가 된다.
low
low e r
low e s t
이 과정을 반복하면 low, er, est 같은 조각이 vocabulary에 들어간다. 모델은 lowest를 통째 단어로 외우지 않아도 low와 est 조합으로 표현한다. 희귀 단어를 [UNK]로 버리지 않고 의미 있는 조각으로 처리하는 방법이다.
실제 BPE 학습은 훨씬 큰 말뭉치에서 pair 빈도를 세고 merge table을 만든다. 추론 때는 그 merge table을 사용해 문자열을 같은 규칙으로 합친다. 훈련 데이터에서 자주 같이 나온 문자열일수록 하나의 토큰이 될 가능성이 높다.
학습 결과 merge rule 일부가 다음과 같다고 가정한다.
("t", "h") -> "th"
("th", "e") -> "the"
("i", "n") -> "in"
("ing", "</w>") -> "ing</w>"
이 merge rule은 단순한 사전 목록보다 더 강하다. 단어 전체를 모르더라도 규칙을 적용해 부분 문자열을 조립하기 때문이다.
BPE에는 보통 사전 토큰화가 붙는다
BPE 설명에서 빠지기 쉬운 부분이 pre-tokenization이다. 전통적인 BPE 기반 NLP 토크나이저는 보통 공백, 구두점, 언어별 형태소 분석기 같은 장치로 텍스트를 먼저 어느 정도 나눈 뒤 그 내부에 BPE를 적용한다.
예를 들어 영어에서는 공백 기준 분리가 꽤 잘 먹힌다.
I love tokenizers.
-> [I, love, tokenizers, .]
한국어에서는 공백만으로 부족하다. 먹었습니다, 먹었고, 먹었지만처럼 어미와 조사가 단어 뒤에 붙고, 띄어쓰기 품질도 데이터마다 다르다. 그래서 한국어 토크나이저를 만들 때 MeCab 같은 형태소 분석기를 pre-tokenizer로 붙이는 설계가 나온다.
EXAONE 3.0 기술 보고서는 한국어와 영어 처리를 위해 MeCab 기반 pre-tokenization 뒤에 byte-level BPE를 적용했다고 설명한다. 논문 표현을 그대로 코드 흐름처럼 바꾸면 대략 이런 모양이다.
원문
-> MeCab 등으로 형태소/어절 단위 pre-tokenization
-> 각 조각에 byte-level BPE 적용
-> token ID 배열
이 설계는 한국어에서 꽤 자연스럽다. 형태소 분석기가 한국어의 어절 내부 구조를 어느 정도 반영하고, BBPE가 드문 표현이나 기호를 byte 수준에서 처리한다.
단점도 있다. pre-tokenizer는 언어별 규칙을 가진다. 영어와 한국어, 코드와 수식, 이모티콘과 URL이 섞인 데이터에서 어느 규칙이 먼저 적용되는지에 따라 결과가 흔들린다. 모델 입장에서는 그 규칙으로 만들어진 토큰 분포를 계속 학습하게 된다.
Byte-level encoding: 모르는 문자를 없애는 방법
Byte-level tokenizer는 문자열을 유니코드 문자 수준에서 바로 다루기보다 UTF-8 byte로 내려가 처리한다. 모든 텍스트 파일은 결국 byte 배열이다. 그래서 byte vocabulary를 바탕으로 하면 원칙적으로 어떤 문자도 표현한다. 처음 보는 한자, 깨진 기호, 새 이모지, 이상한 제어 문자까지 [UNK] 없이 통과시킨다.
"가"의 UTF-8 byte: EA B0 80
"A"의 UTF-8 byte: 41
"🙂"의 UTF-8 byte: F0 9F 99 82
byte-level 방식은 robust하다. 웹 데이터는 지저분하다. URL, HTML entity, 코드, 다국어 문장, 깨진 인코딩이 섞인다. byte로 내려가면 적어도 표현 불가능한 문자는 줄어든다.
대신 사람이 보기에는 토큰이 지저분해진다. GPT-2 계열 토크나이저에서 Ġ 같은 문자가 보이는 이유도 공백을 별도 방식으로 표현하기 때문이다. 이 기호는 사람이 쓰는 의미 기호라기보다 tokenizer 내부에서 공백 정보를 보존하기 위한 표식에 가깝다.
Byte-level BPE, 줄여서 BBPE는 byte 단위에서 시작해 BPE merge를 학습한다. Neural Machine Translation with Byte-Level Subwords는 byte-level subword가 다국어와 noisy text에서 vocabulary 문제를 줄이는 방향을 제시했다.
BBPE를 쓰면 vocabulary 밖 문자를 걱정하는 부담이 크게 줄어든다. 대신 같은 문장을 표현하는 토큰 수가 늘어날 수 있고, 특히 CJK 계열 문자는 byte 조각으로 내려가면 길이가 쉽게 증가한다. 좋은 토크나이저는 이 구간에서 타협을 잘한다. 자주 나오는 한국어 조각은 크게 묶고, 정말 드문 문자만 byte 조각으로 남긴다.
SentencePiece: 공백으로 먼저 자르지 않는다
SentencePiece는 Google이 공개한 subword tokenizer 라이브러리다. BPE와 Unigram Language Model tokenizer를 구현한다. 많이 쓰이는 이유는 특정 언어의 공백 규칙에 덜 기대는 방식으로 학습할 수 있기 때문이다.
영어는 공백이 단어 경계를 꽤 잘 나타낸다. 한국어, 일본어, 중국어에서는 공백 기준 분리가 훨씬 약하다. 일본어와 중국어에는 띄어쓰기가 없거나 제한적으로 쓰이고, 한국어는 띄어쓰기가 있어도 형태소 경계와 일치하지 않는다.
SentencePiece는 입력 문장을 raw text로 보고 학습한다. 공백도 일반 문자처럼 다루며, 보통 ▁ 기호로 공백 위치를 표시한다.
원문: 나는 토크나이저를 공부한다
SentencePiece식 표기 예:
▁나는 ▁토크 나이저 를 ▁공부 한다
여기서 ▁는 “이 앞에 공백이 있었다”는 표시다. 이 방식은 decode할 때 원래 공백을 복원하는 데 중요하다. 공백을 단순히 split 기준으로 소비해 버리면, 토큰 배열만 보고 원문을 정확히 되돌리기 어렵다.
SentencePiece가 구현하는 Unigram LM tokenizer는 BPE와 학습 철학이 다르다. BPE는 빈번한 pair를 점점 합친다. Unigram은 가능한 subword 후보를 많이 만든 뒤, 문장을 가장 그럴듯하게 설명하는 조각들의 확률 모델을 학습하고 불필요한 조각을 줄여 간다. 결과만 보면 둘 다 subword 토크나이저지만, 어떤 조각이 살아남는지의 기준이 다르다.
BPE는 결정적인 merge 순서가 강하다. Unigram은 여러 segmentation 후보 중 확률이 높은 것을 고르는 관점이 강하다. 그래서 Unigram 기반 토크나이저는 subword regularization 같은 학습 기법과도 잘 맞는다. 같은 문장을 매번 완전히 같은 방식으로만 쪼개지 않고, 확률적으로 조금 다른 segmentation을 보여 주면 모델이 토큰 경계에 덜 과적합한다.
BPE는 빈번한 pair를 합치고, Unigram은 후보 조각의 확률을 학습해 남길 조각을 고른다.
GPT-2, ModernBERT, EXAONE을 같은 줄에 놓고 보면
GPT-2는 byte-level BPE 계열 토크나이저를 사용했다. GPT-2 tokenizer의 vocabulary 크기는 50,257개로 알려져 있다. 이 숫자는 GPT-2 모델과 토크나이저를 같이 다룰 때 자주 마주친다.
ModernBERT는 BERT 계열 모델이면서 WordPiece 대신 OLMo의 BPE tokenizer를 사용한다. ModernBERT 논문은 vocabulary 크기 50,368개와 special token ['[UNK]', '[SEP]', '[PAD]', '[CLS]', '[MASK]'] 구성을 명시한다. BERT 계열이라고 항상 WordPiece를 쓰는 시대는 지났다. 모델 구조의 계보와 토크나이저 선택은 분리해서 봐야 한다.
EXAONE 3.0은 한국어 처리에서 MeCab pre-tokenization과 BBPE 조합을 사용한다. 이 조합은 한국어의 형태소적 특성과 byte-level의 안정성을 같이 가져가려는 선택이다. 한국어 LLM에서 tokenizer를 대충 고르면 같은 context length 안에 담기는 의미량이 달라진다. 한국어 한 문장이 영어보다 훨씬 많은 토큰으로 쪼개지는 모델은 긴 문서 처리에서 바로 손해를 본다.
간단히 비교하면 이렇다.
| 방식 | 시작 단위 | 장점 | 부담 |
|---|---|---|---|
| 문자 단위 | 문자 | 단순하고 OOV가 적다 | 시퀀스가 길어진다 |
| 단어 단위 | 단어 | 짧고 직관적이다 | 처음 보는 단어가 약하다 |
| BPE | 문자 또는 pre-token 조각 | 빈번한 subword를 잘 묶는다 | pre-tokenization 영향이 크다 |
| BBPE | byte | 거의 모든 문자열을 표현한다 | CJK/특수문자에서 토큰 수가 늘 수 있다 |
| SentencePiece BPE | raw text | 공백 의존이 작고 다국어에 편하다 | 학습 설정에 따라 결과 차이가 크다 |
| SentencePiece Unigram | subword 후보 확률 | 다양한 segmentation을 모델링한다 | BPE보다 직관적으로 설명하기 어렵다 |
특수 토큰은 모델과 대화하는 제어 문자다
토크나이저 vocabulary에는 일반 텍스트 조각만 들어가지 않는다. [PAD], [UNK], [CLS], [SEP], [MASK], <s>, </s>, <bos>, <eos> 같은 special token이 들어간다. 채팅 모델에서는 <|user|>, <|assistant|>, <|system|> 같은 role token이 붙기도 한다.
이 토큰들은 모델이 학습 중에 특별한 의미로 본다.
[CLS] 문장 전체 표현을 모으는 위치
[SEP] 두 문장 또는 segment 구분
[MASK] masked language modeling에서 가려진 자리
[PAD] batch 길이를 맞추기 위한 padding
<EOS> 생성 종료
생성 모델에서 EOS 토큰은 특히 중요하다. 모델은 “문장을 끝내는 법”도 학습한다. EOS 확률이 잘못 다뤄지면 답변이 너무 일찍 끊기거나 끝없이 이어진다. fine-tuning 데이터에서 EOS를 빼먹는 실수가 치명적인 이유가 여기에 있다.
채팅 템플릿도 토크나이저와 붙어 있다. 같은 대화라도 템플릿이 다르면 토큰 배열이 달라진다.
<|system|>
너는 도움이 되는 assistant다.
<|user|>
토크나이저가 뭐야?
<|assistant|>
모델은 이 패턴을 보고 다음 assistant 응답을 생성하도록 post-training된다. base model에 instruction template을 아무렇게나 붙인다고 chat model처럼 행동하지 않는다. 반대로 chat model에서 훈련 때 쓰던 template과 다른 형식을 넣으면 성능이 떨어진다. 모델이 “의미”만 읽는다고 생각하면 이 현상을 이해하기 어렵다. 모델은 토큰 패턴을 읽는다.
토크나이저가 나쁘면 한국어는 비싸진다
LLM API를 쓰다 보면 영어보다 한국어가 체감상 더 비싸거나 context를 빨리 먹는 경우가 있다. 토크나이저 때문이다. 같은 의미의 문장이라도 한국어가 더 많은 토큰으로 쪼개지면 입력 비용, 출력 비용, KV cache 메모리, attention 계산량이 모두 늘어난다.
예를 들어 다음 두 문장을 보자.
English: I will study tokenizers today.
Korean: 오늘은 토크나이저를 공부할 것이다.
어떤 토크나이저에서는 영어 문장이 7개 안팎의 토큰으로 끝나고, 한국어 문장은 조사와 어미 때문에 더 많이 쪼개진다. 다른 토크나이저에서는 한국어 subword가 잘 학습되어 차이가 줄어든다. 토큰 수는 언어 성능과 별개로 서빙 비용을 직접 바꾼다.
그래서 한국어 서비스를 만들 때 tokenizer fertility를 본다. fertility는 대략 “문자나 단어 대비 토큰이 얼마나 많이 나오는가”를 보는 지표다. 정확한 비교는 같은 평가 말뭉치에서 모델별 토크나이저로 직접 encode해서 재야 한다. 막연히 “한국어를 잘한다”는 말보다 1,000개 실제 사용자 질의를 넣고 평균 토큰 수를 보는 편이 실무 판단에 더 낫다.
texts = [
"오늘은 토크나이저를 공부할 것이다.",
"사용자의 결제 내역을 조회하고 환불 가능 여부를 판단한다.",
]
for text in texts:
ids = tokenizer.encode(text)
print(text, len(ids), ids[:20])
이 코드는 별것 아니지만 모델 선택 때 꽤 강하다. 한국어 상담 서비스, 문서 요약, RAG처럼 입력이 긴 시스템에서는 평균 토큰 수 차이가 바로 GPU 비용으로 이어진다.
vocabulary 크기는 크다고 무조건 좋지 않다
vocabulary를 크게 만들면 더 많은 문자열을 하나의 토큰으로 담을 수 있다. 입력 길이가 줄어드는 효과가 있다. 한국어, 코드, 도메인 용어를 많이 담고 싶을 때 매력적이다.
대신 embedding matrix와 output projection이 커진다. 언어 모델은 다음 토큰 확률을 vocabulary 전체에 대해 계산한다. vocabulary가 커지면 마지막 logits 차원도 커진다. 요즘 대형 모델에서는 전체 비용 중 attention과 MLP 비중이 크지만, vocab 크기는 메모리와 학습 안정성에 여전히 영향을 준다.
너무 큰 vocabulary는 희귀 토큰을 많이 만든다. 어떤 토큰이 학습 중에 충분히 등장하지 않으면 embedding이 약하다. 반대로 너무 작은 vocabulary는 모든 문장을 잘게 쪼개 context를 낭비한다. 좋은 tokenizer 학습은 “자주 나오는 조각은 크게, 드문 조각은 작게”라는 원칙을 데이터 분포에 맞게 구현하는 일이다.
도메인 특화 모델을 만들 때 새 토큰을 추가하고 싶은 유혹이 생긴다. 예를 들어 의료 코드, 사내 약어, 긴 함수명, 제품명을 하나의 토큰으로 넣고 싶어진다. 이 선택은 조심해야 한다. 새 토큰을 추가하면 embedding row가 새로 생긴다. 기존 모델은 그 토큰을 본 적이 없다. fine-tuning으로 그 embedding을 학습시킬 수는 있지만, 적은 데이터로 안정적인 의미 공간에 앉히기는 어렵다.
도메인 용어가 긴 문자열이라 비용이 심각하게 터지는 경우에는 tokenizer 확장이 의미가 있다. 일반적인 도메인 지식 주입에는 RAG나 prompt 설계가 먼저다. 토크나이저를 바꾸는 순간 모델 입력 분포 전체를 건드린다.
직접 보면 감이 빨리 온다
Hugging Face transformers를 쓰면 모델별 토크나이저 차이를 바로 볼 수 있다.
from transformers import AutoTokenizer
models = [
"gpt2",
"bert-base-uncased",
]
text = "오늘은 SentencePiece와 Byte-level BPE를 공부한다."
for name in models:
tok = AutoTokenizer.from_pretrained(name)
ids = tok.encode(text)
pieces = tok.convert_ids_to_tokens(ids)
print("\n", name)
print("tokens:", len(ids))
print(pieces)
실제로 돌려보면 같은 문장이 모델마다 전혀 다르게 쪼개진다. 어떤 토크나이저는 한국어 문자를 byte 비슷한 조각으로 길게 늘어놓고, 어떤 토크나이저는 subword로 조금 더 자연스럽게 잡는다. 코드와 URL을 넣으면 차이가 더 커진다.
tokenizers 라이브러리로 BPE 학습 흐름도 작게 재현할 수 있다.
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
corpus = [
"low lower lowest",
"newer wider lowest",
"token tokenizer tokenization",
]
with open("tiny.txt", "w", encoding="utf-8") as f:
for line in corpus:
f.write(line + "\n")
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace()
trainer = BpeTrainer(vocab_size=50, special_tokens=["[UNK]"])
tokenizer.train(["tiny.txt"], trainer)
encoded = tokenizer.encode("lowest tokenizer")
print(encoded.tokens)
print(encoded.ids)
이 작은 예제에서는 말뭉치가 너무 작아서 실제 LLM tokenizer와 비교할 수준은 아니다. 그래도 “자주 같이 나온 조각이 vocabulary에 들어간다”는 감각은 바로 생긴다. corpus를 바꿔 보면 merge 결과도 바뀐다. 토크나이저는 자연어의 보편 법칙이 아니라 훈련 말뭉치의 통계로 만들어진다.
Transformer를 따라가려면 토큰에서 시작해야 한다
Transformer 논문을 펼치면 attention, positional encoding, feed-forward network가 먼저 보인다. 구현을 따라가면 그 앞에 tokenizer가 있다. 모델에 들어가는 첫 번째 텐서가 token ID이기 때문이다.
raw text
-> tokenizer.encode()
-> input_ids
-> embedding(input_ids)
-> transformer blocks
-> logits over vocabulary
-> next token sampling
-> tokenizer.decode()
-> raw text
생성 모델의 마지막도 tokenizer다. 모델은 vocabulary 크기만큼의 logit을 내고, sampling은 그중 하나의 token ID를 고른다. 사람이 읽는 문장은 decode 결과다. 입력과 출력 양쪽 끝에 tokenizer가 붙어 있다.
다음에 attention을 보면 query, key, value가 갑자기 등장한다. 그 Q, K, V의 출발점은 token embedding이다. token embedding의 출발점은 tokenizer가 만든 ID 배열이다. 그래서 1탄은 여기서 시작하는 게 맞다.
다음 글에서는 embedding을 볼 생각이다. token ID가 어떻게 벡터가 되고, 왜 비슷한 토큰이 비슷한 공간에 놓인다고 말하는지부터 차근차근 따라가면 attention이 덜 갑작스럽다.