Java에서 LLM 호출 시 토큰 개수 세기 및 비용 추정

1. 개요

GPT-4o 같은 LLM을 호출할 때마다 토큰 기준으로 비용을 지불합니다. 문자 개수나 요청 수로 계산하지 않습니다. 측정하지 않으면 월말 청구서가 깜짝 놀랄 수 있습니다. 특히 긴 대화 이력이나 RAG 문서를 포함하는 prompt일 때 더욱 그렇습니다.

이 글에서는 JVM 내에서 JTokkit을 사용해 토큰을 세고, BigDecimal로 비용을 계산하며, 예산을 초과하는 요청을 차단하는 방법을 배웁니다. 마지막으로 추정 값과 Spring AI가 반환한 실제 토큰 수를 대조합니다.

2. 토큰이란 무엇이며 왜 개수를 세어야 하는가

토큰은 모델의 tokenizer가 텍스트를 분할하는 단위입니다. 영어의 경우 평균 토큰 하나는 약 4개 문자에 해당합니다. 한국어나 베트남어처럼 같은 의미인데 훨씬 많은 토큰을 소비하는 언어들이 있습니다. 이는 한글이나 악센트 기호 같은 문자들이 tokenizer 학습 데이터에 자주 나타나지 않기 때문입니다.

한 번의 요청 비용은 두 부분으로 나뉘며 가격이 다릅니다:

  • 입력 토큰(prompt tokens): 시스템 prompt, 대화 이력, 질문, 첨부 문서.
  • 출력 토큰(completion tokens): 모델이 생성한 부분. 입력보다 훨씬 비싼 경향이 있습니다.

토큰을 세어야 하는 시점은 두 가지입니다:

  1. 호출 전: 비용을 추정하고, context를 자르거나, 너무 비싼 요청을 거절하기 위해.
  2. 호출 후: 실제 비용을 기록하고, provider가 반환한 수치를 기반으로.

3. JTokkit으로 로컬 토큰 개수 세기

JTokkit은 순수 Java 라이브러리로 OpenAI의 tiktoken과 동일한 BPE 인코딩 테이블을 구현합니다. 네트워크 호출이 필요하지 않습니다.

<dependency>
    <groupId>com.knuddels</groupId>
    <artifactId>jtokkit</artifactId>
    <version>1.1.0</version>
</dependency>

간단한 문자열의 토큰 개수 세기:

import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.Encoding;
import com.knuddels.jtokkit.api.EncodingRegistry;
import com.knuddels.jtokkit.api.EncodingType;

public class TokenCountDemo {

    public static void main(String[] args) {
        EncodingRegistry registry = Encodings.newDefaultEncodingRegistry();
        Encoding encoding = registry.getEncoding(EncodingType.O200K_BASE);

        String english = "Hello, world!";
        String korean = "안녕하세요, 세계입니다! 주문이 확인되었습니다.";

        System.out.println(english + " -> " + encoding.countTokens(english));
        System.out.println(korean + " -> " + encoding.countTokens(korean));
    }
}

각 부분 설명:

  • Encodings.newDefaultEncodingRegistry()는 인코딩 테이블을 미리 로드합니다. 이 로드 작업은 시간과 메모리가 많이 걸리므로 한 번만 생성하고 재사용해야 합니다.
  • O200K_BASE는 GPT-4o의 인코딩입니다. GPT-4나 GPT-3.5 같은 구형 모델은 CL100K_BASE를 사용합니다.
  • countTokens는 개수만 반환합니다. 토큰 목록을 보관할 필요가 없어서 encode보다 빠릅니다.

영어 문장 결과는 다음과 같습니다. 한국어 문장은 인코딩에 따라 다르지만 훨씬 높을 것입니다:

Hello, world! -> 4
안녕하세요, 세계입니다! 주문이 확인되었습니다. -> (약 15–20)

모델명으로 인코딩을 선택하려면 registry.getEncodingForModel(ModelType.GPT_4O)를 사용하세요.

주의: JTokkit은 OpenAI의 tokenizer와만 정확합니다. Claude, Gemini, Llama는 다른 tokenizer를 사용합니다. 이런 모델의 경우 JTokkit을 대략적 추정용으로만 사용하고 10–20% 안전 마진을 더하세요.

3.1. 대화 이력의 토큰 개수 세기

Chat API는 단순 문자열을 보내지 않습니다. role을 가진 message 목록을 보내며, 각 message에는 포맷팅 오버헤드 토큰이 붙습니다. OpenAI가 chat 모델에 대해 설명한 바에 따르면 각 message는 약 3개의 추가 토큰이 필요합니다. 답변도 3개의 “프라이밍” 토큰이 추가됩니다.

import com.knuddels.jtokkit.api.Encoding;
import java.util.List;

public record ChatMessage(String role, String content) {}

public class ChatTokenCounter {

    private static final int TOKENS_PER_MESSAGE = 3;
    private static final int REPLY_PRIMING = 3;

    private final Encoding encoding;

    public ChatTokenCounter(Encoding encoding) {
        this.encoding = encoding;
    }

    public int count(List<ChatMessage> messages) {
        int total = REPLY_PRIMING;
        for (ChatMessage message : messages) {
            total += TOKENS_PER_MESSAGE
                    + encoding.countTokens(message.role())
                    + encoding.countTokens(message.content());
        }
        return total;
    }
}

이는 대략적 수치입니다. 요청에 tool/function 정의나 이미지가 있으면 이들도 입력에 포함됩니다. JTokkit은 이를 직접 세지 못합니다. 따라서 항상 실제 수치로 대조해야 합니다(섹션 6).

4. 가격표 설정 및 비용 계산

모델 가격은 자주 변하므로 코드에 가격을 하드코딩하지 마세요. 설정 파일에 두세요. 아래 수치는 예시이며 백만 토큰당 USD입니다.

pricing:
  models:
    gpt-4o:
      input-per-million: 2.50
      output-per-million: 10.00
    gpt-4o-mini:
      input-per-million: 0.15
      output-per-million: 0.60

설정을 record에 바인딩:

import java.math.BigDecimal;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "pricing")
public record PricingProperties(Map<String, ModelPrice> models) {

    public record ModelPrice(BigDecimal inputPerMillion, BigDecimal outputPerMillion) {}

    public ModelPrice priceOf(String model) {
        ModelPrice price = models.get(model);
        if (price == null) {
            throw new IllegalArgumentException("모델의 가격이 설정되지 않음: " + model);
        }
        return price;
    }
}

@SpringBootApplication 클래스에 @EnableConfigurationProperties(PricingProperties.class) 또는 @ConfigurationPropertiesScan으로 바인딩을 활성화하세요. 모델명이 점을 포함하면(예: gpt-4.1) YAML의 키를 "[gpt-4.1]"로 작성하세요. 그렇지 않으면 Spring이 점을 중첩 레벨로 해석합니다.

이제 비용 계산 service:

import java.math.BigDecimal;
import java.math.RoundingMode;
import org.springframework.stereotype.Service;

@Service
public class CostEstimator {

    private static final BigDecimal ONE_MILLION = BigDecimal.valueOf(1_000_000);

    private final PricingProperties pricing;

    public CostEstimator(PricingProperties pricing) {
        this.pricing = pricing;
    }

    public BigDecimal estimate(String model, long inputTokens, long outputTokens) {
        PricingProperties.ModelPrice price = pricing.priceOf(model);

        BigDecimal inputCost = price.inputPerMillion()
                .multiply(BigDecimal.valueOf(inputTokens));
        BigDecimal outputCost = price.outputPerMillion()
                .multiply(BigDecimal.valueOf(outputTokens));

        return inputCost.add(outputCost)
                .divide(ONE_MILLION, 8, RoundingMode.HALF_UP);
    }
}

주목할 점:

  • double 대신 BigDecimal을 사용하세요. 한 요청이 몇 천분의 일 USD인데 double로 수백만 요청을 더하면 오차가 누적됩니다.
  • 정밀도를 유지하려면 먼저 곱한 후 나누세요.
  • 소수점 8자리까지 반올림합니다. 이 정도면 저렴한 요청의 손실도 충분히 작습니다.

예를 들어 gpt-4o에서 1.200개 입력 토큰과 300개 출력 토큰:

입력 : 1200 × 2.50 / 1_000_000 = 0.003
출력:  300 × 10.00 / 1_000_000 = 0.003
합계 : 0.00600000 USD

출력이 입력의 1/4밖에 안 되는데 비용은 같습니다. 이것이 답변 길이를 제한해야 하는 이유입니다.

5. 호출 전에 예산 초과 요청 차단

호출 전에는 모델이 얼마나 길게 답변할지 모릅니다. 하지만 상한선을 알 수 있습니다: max_tokens 파라미터입니다. 최악의 비용은 이미 센 입력에 max_tokens만큼의 출력을 더한 것입니다.

import java.math.BigDecimal;
import java.util.List;
import org.springframework.stereotype.Component;

@Component
public class BudgetGuard {

    private final ChatTokenCounter tokenCounter;
    private final CostEstimator costEstimator;

    public BudgetGuard(ChatTokenCounter tokenCounter, CostEstimator costEstimator) {
        this.tokenCounter = tokenCounter;
        this.costEstimator = costEstimator;
    }

    public BigDecimal checkWorstCase(String model, List<ChatMessage> messages,
                                     int maxOutputTokens, BigDecimal limitUsd) {
        int inputTokens = tokenCounter.count(messages);
        BigDecimal worstCase = costEstimator.estimate(model, inputTokens, maxOutputTokens);

        if (worstCase.compareTo(limitUsd) > 0) {
            throw new IllegalStateException(
                    "요청 비용 추정치 %s USD, 예산 한도 %s USD 초과"
                            .formatted(worstCase.toPlainString(), limitUsd.toPlainString()));
        }
        return worstCase;
    }
}

ChatTokenCounter를 bean으로 선언하고 공유 Encoding과 함께:

import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.EncodingType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TokenizerConfig {

    @Bean
    public ChatTokenCounter chatTokenCounter() {
        var encoding = Encodings.newDefaultEncodingRegistry()
                .getEncoding(EncodingType.O200K_BASE);
        return new ChatTokenCounter(encoding);
    }
}

BudgetGuard는 여러 방식으로 사용할 수 있습니다. 요청을 거절할 수도 있고, 예산이 충분할 때까지 이전 대화를 자를 수도 있습니다. 또는 gpt-4o-mini 같은 저렴한 모델로 전환할 수 있습니다.

6. Spring AI에서 실제 토큰 수 얻기

추정은 결정을 내리기 위한 것입니다. 실제로 청구하려면 provider가 반환한 수치를 사용하세요. Spring AI 추가:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
</dependencies>
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o
spring.ai.openai.chat.options.max-tokens=500

모델을 호출하고 Usage 읽기:

import java.math.BigDecimal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Service;

@Service
public class SupportAssistant {

    private static final Logger log = LoggerFactory.getLogger(SupportAssistant.class);

    private final ChatClient chatClient;
    private final CostEstimator costEstimator;

    public SupportAssistant(ChatClient.Builder builder, CostEstimator costEstimator) {
        this.chatClient = builder.build();
        this.costEstimator = costEstimator;
    }

    public String answer(String question) {
        ChatResponse response = chatClient.prompt()
                .user(question)
                .call()
                .chatResponse();

        Usage usage = response.getMetadata().getUsage();
        String model = response.getMetadata().getModel();

        BigDecimal cost = costEstimator.estimate(
                "gpt-4o", usage.getPromptTokens(), usage.getCompletionTokens());

        log.info("model={} prompt={} completion={} cost={} USD",
                model, usage.getPromptTokens(), usage.getCompletionTokens(),
                cost.toPlainString());

        return response.getResult().getOutput().getText();
    }
}

로그 출력은 다음과 같습니다:

model=gpt-4o-2024-08-06 prompt=412 completion=187 cost=0.00290000 USD

getModel()은 버전을 포함한 이름을 반환합니다(예: gpt-4o-2024-08-06). 따라서 예제에서는 고정 키 "gpt-4o"로 가격을 조회합니다. 실제로는 호출 전에 모델 이름을 정규화해야 합니다.

섹션 3.1에서 센 토큰 수와 실제 prompt 수를 비교할 때 몇 개 차이는 정상입니다. 큰 차이가 나면 보통 기본 system prompt, tool 정의 또는 advisor(메모리, RAG 등)가 추가 내용을 삽입했기 때문입니다.

7. CostEstimator 테스트

청구 로직은 틀릴 수 없는 부분입니다. Spring context 없이 독립적인 테스트를 작성하세요:

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.EncodingType;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;

class CostEstimatorTest {

    private final PricingProperties pricing = new PricingProperties(Map.of(
            "gpt-4o", new PricingProperties.ModelPrice(
                    new BigDecimal("2.50"), new BigDecimal("10.00"))));

    private final CostEstimator estimator = new CostEstimator(pricing);

    @Test
    void givenInputAndOutputTokens_whenEstimate_thenSumsBothParts() {
        BigDecimal cost = estimator.estimate("gpt-4o", 1_200, 300);

        assertThat(cost).isEqualByComparingTo("0.006");
    }

    @Test
    void givenUnknownModel_whenEstimate_thenThrows() {
        assertThatThrownBy(() -> estimator.estimate("unknown-model", 10, 10))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("unknown-model");
    }

    @Test
    void givenKoreanText_whenCount_thenUsesMoreTokensThanWords() {
        var encoding = Encodings.newDefaultEncodingRegistry()
                .getEncoding(EncodingType.O200K_BASE);
        var counter = new ChatTokenCounter(encoding);
        String content = "주문이 확인되었습니다";

        int tokens = counter.count(List.of(new ChatMessage("user", content)));

        assertThat(tokens).isGreaterThan(content.split(" ").length);
    }
}

첫 번째 테스트는 isEqualTo 대신 isEqualByComparingTo를 사용합니다. 0.00600000과 0.006은 scale이 다르기 때문에 BigDecimal의 equals는 false를 반환합니다.

8. 흔한 실수

  • 각 요청마다 EncodingRegistry 생성. 인코딩 테이블 로드는 상당히 무겁습니다. 한 번 생성하고 재사용하세요. Encoding은 thread-safe입니다.
  • 잘못된 인코딩 사용. GPT-4o를 CL100K_BASE로 세면 결과가 틀립니다. 모델에 맞는 인코딩을 선택하세요.
  • 출력만 계산. 출력이 입력보다 보통 4배 비싸거나 더 비쌉니다. 이 부분을 빠뜨리면 가장 큰 오차가 생깁니다.
  • “숨겨진” 토큰 빠뜨리기. Reasoning 모델(o-시리즈)은 reasoning 토큰을 생성합니다. 이 토큰은 응답에 나타나지 않지만 여전히 출력으로 청구됩니다. 반대로 캐시된 입력 토큰은 할인됩니다. 자세한 내용이 필요하면 getNativeUsage()를 읽으세요.
  • 돈 계산에 double 사용. 오차가 누적되어 청구서와 차이가 생깁니다.
  • 추정을 절대적으로 신뢰. 추정은 차단과 경고용입니다. 비용 기록은 실제 Usage를 기반으로 해야 합니다.

참고: 모든 요청마다 토큰을 미리 셀 필요는 없습니다. Prompt가 짧고 고정되어 있고 이미 max_tokens가 있으면 비용이 이미 상한선으로 제한됩니다. 이 경우 호출 후 Usage만 로그하면 충분합니다. 사용자별 변화하는 context(대화 이력, RAG, 파일 업로드)가 있을 때 미리 세는 것이 의미가 있습니다.

9. 결론

이 글에서는 JTokkit으로 로컬 토큰을 세고, 각 chat message의 오버헤드도 포함했습니다. BigDecimal로 정확히 비용을 계산하고 설정 파일의 가격표를 사용했습니다. 호출 전에 max_tokens로 최악의 비용을 차단했습니다.

핵심 원칙은: 결정을 내리려면 추정, 청구하려면 실제 수치입니다. 요청 전 BudgetGuard와 응답 후 Usage 로깅을 결합하면 모델별, 기능별, 또는 고객별로 비용을 추적할 수 있는 충분한 데이터를 얻을 수 있습니다.

llmtokenspring-aijava비용