Hugging Face의 Tokenizer: 원리와 Java에서의 사용
1. 개요
Hugging Face의 NLP 모델을 다룰 때, 첫 번째 단계는 항상 tokenization입니다 — 원본 텍스트를 모델이 이해하는 숫자 시퀀스로 변환하는 과정이죠. 이 글에서는 tokenizer가 어떻게 동작하는지, 일반적인 토큰 분할 전략(BPE, WordPiece, SentencePiece)이 무엇인지, 그리고 Python을 실행할 필요 없이 DJL 라이브러리를 통해 Java에서 HuggingFaceTokenizer를 직접 호출하는 방법을 설명합니다. 이 글을 읽고 나면, Spring Boot에서 텍스트를 인코딩/디코딩하고 패딩/절단을 제어하며 테스트를 작성하는 TokenizerService를 구축할 수 있게 됩니다.
2. Tokenizer란 무엇이고 왜 중요한가
Tokenizer는 두 가지 작업을 수행합니다: 문장을 token(단어, 부분 단어 또는 문자일 수 있음)으로 분할한 후, 각 token을 모델의 어휘(vocabulary)에서 정수 ID로 매핑합니다. 모델은 절대 문자를 “보지” 못합니다 — 이 숫자 ID 시퀀스만 봅니다.
예를 들어 "Order service xử lý thanh toán" 문장이 bert-base-uncased의 tokenizer로 처리되면 다음과 같이 나올 수 있습니다:
tokens: ["order", "service", "xu", "##ly", "thanh", "toan"]
ids: [2344, 2326, 1045, 2135, 7899, 8821]
##ly 기호는 이 token이 이전 token에 연결된다는 뜻입니다(부분 단어). 이것이 token 개수가 보통 단어 개수보다 많은 이유입니다 — API 호출 비용을 계산하거나 모델의 max_length를 제한할 때 중요한 점입니다.
주의: 각 사전학습된 모델에는 모델과 함께 훈련된 고유의 tokenizer가 포함되어 있습니다. 잘못된 tokenizer를 사용하면(예: BERT tokenizer를 GPT 모델에 사용) 의미 없는 ID가 나오고, 모델이 완전히 잘못된 예측을 할 수 있습니다.
3. 일반적인 tokenization 전략
3.1. BPE (Byte-Pair Encoding)
GPT-2, RoBERTa에서 사용됩니다. 문자에서 시작하여, 가장 자주 나타나는 문자 쌍을 새로운 token으로 병합하는 작업을 반복하다가 원하는 어휘 크기에 도달할 때까지 계속합니다.
3.2. WordPiece
BERT에서 사용됩니다. BPE와 유사하지만 병합할 쌍을 선택할 때 원시 빈도 대신 통계적 확률(likelihood)을 기반으로 합니다. 부분 단어는 ##로 표시되어 이전 단어에 연결됨을 나타냅니다.
3.3. SentencePiece
T5, XLNet, ALBERT에서 사용됩니다. 공백도 일반 문자로 취급합니다(기호 ▁로 표시), 따라서 일본어, 중국어처럼 명확한 단어 경계가 없는 언어를 더 잘 처리합니다.
이러한 알고리즘을 직접 구현할 필요가 없습니다 — Hugging Face는 각 모델과 함께 제공되는 tokenizer.json 파일에 이미 패키징되어 있으며, 여러분의 역할은 단지 로드하고 호출하는 것뿐입니다.
4. 의존성 추가
Java에서 직접 tokenizer를 사용하려면(Python 서버를 거치지 않고), DJL Hugging Face Tokenizers 라이브러리를 추가하세요:
<dependency>
<groupId>ai.djl.huggingface</groupId>
<artifactId>tokenizers</artifactId>
<version>0.28.0</version>
</dependency>
이 라이브러리는 Hugging Face의 표준 tokenizer.json 파일을 직접 읽으므로, 복잡한 JNI를 통해 JVM을 거칠 필요가 없습니다.
5. 기본 텍스트 인코딩
import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer;
import ai.djl.huggingface.tokenizers.Encoding;
import java.util.Arrays;
public class TokenizerDemo {
public static void main(String[] args) throws Exception {
HuggingFaceTokenizer tokenizer = HuggingFaceTokenizer.newInstance("bert-base-uncased");
Encoding encoding = tokenizer.encode("Order service handles payment");
System.out.println(Arrays.toString(encoding.getIds()));
System.out.println(Arrays.toString(encoding.getTokens()));
}
}
HuggingFaceTokenizer.newInstance(...): 모델 이름으로 Hugging Face Hub(또는 로컬 캐시)에서 tokenizer를 로드합니다.encode(...):ids,tokens,attentionMask를 포함하는Encoding객체를 반환합니다.- 첫 실행 시
tokenizer.json파일을~/.djl.ai/cache에 다운로드하며, 이후에는 캐시를 사용하므로 네트워크가 필요 없습니다.
참고 출력:
[101, 2344, 2326, 16024, 7909, 102]
[[CLS], order, service, handles, payment, [SEP]]
[CLS](문장 시작)과 [SEP](문장 끝) 두 개의 특수 token이 자동으로 삽입됩니다 — 이는 BERT의 규칙이며 오류가 아닙니다.
6. 역으로 디코딩하기
long[] ids = encoding.getIds();
String decoded = tokenizer.decode(ids);
System.out.println(decoded);
[CLS] order service handles payment [SEP]
decode는 디버깅할 때 유용합니다: tokenization 후 모델이 정확히 무엇을 보는지 확인하거나, 생성형 모델에서 나온 출력을 처리할 때 사용합니다.
7. 패딩, 절단, 그리고 배치
여러 문장을 한 번에 모델에 보낼 때, 문장들이 같은 길이여야 하고(패딩) 모델의 제한을 초과하면 안 됩니다(절단).
import java.util.Map;
HuggingFaceTokenizer tokenizer = HuggingFaceTokenizer.newInstance(
"bert-base-uncased",
Map.of(
"padding", "true",
"truncation", "true",
"maxLength", "16"
));
Encoding[] batch = tokenizer.batchEncode(new String[] {
"Order placed successfully",
"Payment failed due to insufficient balance"
});
for (Encoding e : batch) {
System.out.println(Arrays.toString(e.getIds()));
System.out.println(Arrays.toString(e.getAttentionMask()));
}
padding=true: 짧은 문장에[PAD]token(ID 0)이 추가되어 배치의 가장 긴 문장과 같은 길이가 됩니다.truncation=true+maxLength=16: 16개 token을 초과하는 문장은 잘립니다.attentionMask: 어떤 token이 실제(1)인지 패딩(0)인지 표시하는 1/0의 배열입니다 — 모델은 이 마스크를 사용하여 attention을 계산할 때 패딩 부분을 무시합니다.
[101, 2344, 2872, 4652, 102, 0, 0, 0]
[1, 1, 1, 1, 1, 0, 0, 0]
주의: 모델에 데이터를 전달할 때
attentionMask를 설정하지 않으면, 패딩 부분이 attention 계산에 포함될 수 있어 출력이 잘못되는데 런타임 오류가 발생하지 않습니다.
8. Spring Boot에 통합하기
tokenizer를 공유 bean으로 패킹하여, 매 요청마다 다시 로드하지 않도록 합니다(Hub/캐시에서 tokenizer 로드는 100–300ms가 소요되므로 요청 스레드에서 하면 안 됩니다).
import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.Map;
@Service
public class TokenizerService {
private final HuggingFaceTokenizer tokenizer;
public TokenizerService() throws IOException {
this.tokenizer = HuggingFaceTokenizer.newInstance(
"bert-base-uncased",
Map.of("padding", "true", "truncation", "true", "maxLength", "128"));
}
public long[] encode(String text) {
return tokenizer.encode(text).getIds();
}
public String decode(long[] ids) {
return tokenizer.decode(ids);
}
public int countTokens(String text) {
return tokenizer.encode(text).getTokens().length;
}
}
- 생성자는 bean이 생성될 때 tokenizer를 단 한 번 초기화하며, Spring이 singleton의 생명주기를 관리합니다.
countTokens: 모델을 호출하기 전에 입력 길이 제한을 확인할 때 유용합니다(예: Order 설명이 512 token을 초과하지 않도록).
다른 서비스에서 사용:
@RestController
@RequestMapping("/tokens")
public class TokenController {
private final TokenizerService tokenizerService;
public TokenController(TokenizerService tokenizerService) {
this.tokenizerService = tokenizerService;
}
@GetMapping("/count")
public int count(@RequestParam String text) {
return tokenizerService.countTokens(text);
}
}
GET /tokens/count?text=Order service xử lý thanh toán을 호출하면 해당 문장에 대해 BERT tokenizer가 생성한 token 개수를 반환합니다.
9. 테스트 작성
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TokenizerServiceTest {
@Test
void shouldEncodeAndDecodeRoundTrip() throws Exception {
TokenizerService service = new TokenizerService();
String original = "payment failed";
long[] ids = service.encode(original);
String decoded = service.decode(ids);
assertThat(ids).isNotEmpty();
assertThat(decoded).contains("payment").contains("failed");
}
@Test
void shouldCountMoreTokensThanWordsForSubwordSplit() throws Exception {
TokenizerService service = new TokenizerService();
int tokenCount = service.countTokens("unbelievable circumstances");
// "unbelievable"은 보통 여러 부분 단어로 분할됩니다
assertThat(tokenCount).isGreaterThan(2);
}
}
- 테스트 1은 인코딩 → 디코딩 왕복이 원본 내용을 유지하는지 확인합니다(tokenizer가
uncased이므로 대소문자/구두점이 손실될 수 있음). - 테스트 2는 부분 단어 동작을 확인합니다: 길고 드문 단어는 공백으로 세는 단어 수보다 더 많은 token으로 분할됩니다.
10. 흔한 오류와 사용하면 안 되는 경우
- 서로 다른 모델 패밀리의 tokenizer 혼동:
bert-base-uncased의 tokenizer는gpt2와 호환되지 않습니다. 항상 추론에 사용하는 모델과 같은 이름의 tokenizer를 로드하세요. - 패딩할 때 attention mask 미계산: 모델 결과가 잘못되지만 예외가 발생하지 않아 디버깅이 매우 어렵습니다. 항상
inputIds와 함께attentionMask를 모델에 전달하세요. - 매 요청마다 tokenizer 로드: 불필요한 시간 낭비와 I/O입니다. 위의
TokenizerService처럼 항상 singleton bean으로 선언하세요. - 프로덕션 환경에 네트워크 없음: 처음 tokenizer를 로드할 때 Hugging Face Hub에서 파일을 가져오려면 인터넷이 필요합니다. 오프라인 환경의 경우, 사전에
tokenizer.json을 다운로드하고HuggingFaceTokenizer.newInstance(Path.of(...))를 사용하여 로컬 경로에서 로드해야 합니다. - Hugging Face가 아닌 모델의 token 계산(예: OpenAI API의 GPT-4): GPT의 BPE tokenizer는 표준 Hugging Face tokenizer와 다릅니다. 이 경우 공급자의 전용 token 계산 라이브러리를 사용하고
HuggingFaceTokenizer를 사용하면 안 됩니다. - 베트남어 음성 기호가 있는 텍스트: 주로 영어로 훈련된 일부 tokenizer는 베트남어 단어를 최적으로 분할하지 못할 수 있습니다(너무 많은 이상한 부분 단어로 분할). 베트남어 처리가 주요 목적이면, 기본 BERT 대신 베트남어용으로 fine-tune된 모델/tokenizer(예:
vinai/phobert-base)를 선택하세요.
11. 결론
Tokenizer는 모델이 텍스트를 “보는” 방식을 결정하는 기초 단계이며 — BPE/WordPiece/SentencePiece의 메커니즘을 제대로 이해하면 어휘 불일치나 잘못된 attention mask 같은 디버깅하기 어려운 오류를 피할 수 있습니다. DJL 라이브러리를 사용하면 별도의 Python 서버를 구축할 필요 없이 Java에서 직접 인코딩/디코딩할 수 있으며, 일반적인 bean으로 Spring Boot에 깔끔하게 통합할 수 있습니다. 기억하세요: 항상 모델의 올바른 tokenizer를 사용하고, tokenizer를 singleton으로 캐시하며, 모델에 데이터를 전달하기 전에 패딩/절단을 꼼꼼히 확인하세요.