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으로 캐시하며, 모델에 데이터를 전달하기 전에 패딩/절단을 꼼꼼히 확인하세요.

huggingfacenlptokenizerjavaspring-boot