dev notes

Langfuse Trace나 Datadog LLM Inspector 대신 LLM 사용량 추적을 직접 만든 이유

2026-04-0630 min read
공유

배경#

사내 B2B SaaS 서비스에서 OpenAI API를 호출하는 기능이 점점 늘어나고 있었습니다. 문서 요약, 스케줄러 기반 배치 분석 등 여러 도메인에서 LLM을 쓰고 있었는데, 문제는 어떤 고객사가 얼마나 쓰고 있는지 전혀 보이지 않는다는 점이었습니다.

멀티테넌트 SaaS라서 고객사(tenantId) 단위로 사용량을 분리해야 과금과 용량 계획이 가능합니다. 이게 없으면 특정 고객사가 비정상적으로 많이 호출해도 알 수가 없고, 전체 비용이 어디서 나오는지도 모릅니다.

서드파티 검토#

Langfuse, Datadog LLM Monitoring을 먼저 검토했습니다.

Langfuse는 트레이싱, 프롬프트 버전 관리, 비용 추적까지 지원하는 오픈소스 LLM 관측 도구입니다. 기능은 충분했지만, 저희 환경에서 몇 가지가 걸렸습니다.

첫째, 테넌트별 분리가 자연스럽지 않습니다. Langfuse는 프로젝트 단위로 데이터를 구분하는데, 고객사가 수십 개이고 계속 늘어나는 환경에서 고객사마다 프로젝트를 만드는 건 관리가 어렵습니다. metadata에 tenantId을 넣을 수는 있지만, 집계 쿼리를 돌릴 때마다 필터링해야 하는 구조가 됩니다. 결국 필요한 건 "이번 달 A사가 GPT-5.2를 몇 번 호출했는지"를 바로 조회하는 것이었고, 이건 우리 DB에 직접 쌓는 게 훨씬 간단합니다.

둘째, 프롬프트 유출 우려가 있었습니다. 내부 프롬프트와 응답 전문이 외부 서버(Langfuse Cloud든 자체 호스팅이든)로 나가는 구조라, 보안 리뷰에서 문제가 될 수 있었습니다. 자체 호스팅도 가능하지만 인프라 관리 부담이 추가됩니다.

셋째, 비용입니다. 호출량이 늘수록 Langfuse Cloud 비용도 같이 올라가는데, 저희가 필요한 건 "누가, 어떤 모델을, 얼마나 호출했는지"만 알면 되는 수준이라, 풀스택 관측 도구의 비용을 지불할 정당성이 부족했습니다.

Langfuse 자체는 좋은 도구입니다. 실제로 다른 프로젝트에서는 프롬프트 버전 관리와 LLM 호출 트레이싱 용도로 Langfuse를 쓰고 있고, 그 과정은 스프링 프로젝트에 Langfuse 도입하기에서 다뤘습니다. Langfuse는 호출 성능 분석, 프롬프트 실험, 전반적인 LLM 비용 모니터링처럼 관측과 최적화가 목적일 때 강합니다. 하지만 이번 요구사항은 성격이 다릅니다. "이번 달 A사가 GPT-4o를 몇 번 호출했는지"를 조회하고, 추후 테넌트별 과금 정책까지 연결할 수 있는 사용량 집계 파이프라인이 필요한 것이었습니다. 과금의 원천 데이터가 되려면 우리 DB에 직접 쌓는 게 더 간단하고 정확합니다.

설계 결정: 왜 Decorator 패턴인가#

요구사항은 명확합니다:

  1. 기존 코드 변경 없이 모든 LLM 호출을 잡아야 합니다
  2. 사용량 저장이 LLM 호출 성능에 영향을 주면 안 됩니다
  3. 저장 실패가 비즈니스 로직을 중단시키면 안 됩니다

"기존 코드 변경 없이"가 핵심이었습니다. 서비스 전반에서 LLMService 인터페이스를 주입받아 쓰고 있었는데, 각 호출 지점마다 사용량 저장 코드를 넣으면 수십 곳을 건드려야 합니다. 빠뜨리는 곳도 생기고, 나중에 새로운 LLM 호출 지점이 추가될 때마다 사용량 저장을 함께 넣어야 하는 부담이 생깁니다.

AOP(@Around)도 검토했습니다. LLM 호출 메서드에 aspect를 걸면 기존 코드를 안 건드려도 되긴 합니다. 하지만 두 가지 이유로 Decorator를 선택했습니다:

  • LLMService 인터페이스가 단일 진입점이라 AOP를 쓸 만큼 여러 클래스에 퍼져있지 않습니다. Decorator 하나로 전부 커버됩니다
  • Provider fallback 후 실제 사용된 provider를 알아야 해서, 순서 관리가 중요합니다. Decorator에서 delegate 호출 전후로 명시적으로 제어하는 게 AOP의 암묵적 동작보다 유지보수하기 좋다고 판단했습니다

전체 구조#

Loading diagram...

Decorator 본체#

java
@Slf4j
@Component
@Primary
@RequiredArgsConstructor
public class LLMUsageTrackingDecorator implements LLMService {
 
    private final LLMServiceImpl delegate;
    private final LLMUsageService llmUsageService;
    private final LLMMetricsExtractor metricsExtractor;
    private final LLMProviderExtractor providerExtractor;
    private final TenantAware tenantAware;
 
    @Override
    public <T> T chat(Collection<LLMProviderPriority> preferredProviders,
                      String systemPromptContent,
                      Collection<LLMMessage> messages,
                      Class<T> entity) {
        return trackAndExecute(
            preferredProviders, systemPromptContent, messages,
            () -> delegate.chat(preferredProviders, systemPromptContent, messages, entity),
            metricsExtractor::calculateResponseTextLength
        );
    }
 
    // chat(), chatWithPrompt() 총 6개 오버로드를 동일한 패턴으로 래핑
    // ...
}

@Primary를 붙여서 기존에 LLMService를 주입받는 코드는 아무것도 안 바꿔도 됩니다. Spring 컨테이너가 LLMUsageTrackingDecorator를 우선 주입하고, Decorator 안에서 LLMServiceImpl을 delegate로 들고 있으면서 실제 호출을 위임합니다.

LLMServicechat(), chatWithPrompt() 총 6개 오버로드가 있는데, 전부 trackAndExecute()라는 하나의 핵심 메서드로 수렴합니다.

핵심 메서드: trackAndExecute#

java
private <T> T trackAndExecute(
        Collection<LLMProviderPriority> preferredProviders,
        String systemPromptContent,
        Collection<LLMMessage> messages,
        LLMExecutor<T> executor,
        ResponseLengthCalculator<T> lengthCalculator) {
 
    Integer tenantId = resolveTenantId();
    UsageMetrics metrics = metricsExtractor.extractPreExecutionMetrics(
        tenantId, messages, systemPromptContent);
 
    try {
        T result = executor.execute();
        saveSuccessUsage(metrics, result, preferredProviders, lengthCalculator);
        return result;
 
    } catch (RuntimeException e) {
        saveFailureUsage(metrics, preferredProviders, e);
        throw e;
 
    } finally {
        LLMExecutionContext.clear();
    }
}

흐름은 단순합니다:

  1. 호출 전: tenantId, API 엔드포인트, 요청 텍스트 길이를 추출합니다
  2. 호출: delegate에게 위임합니다
  3. 호출 후: 성공이면 응답 길이 + 실제 provider를 기록하고, 실패면 에러 메시지를 기록합니다
  4. 항상: LLMExecutionContext.clear()로 ThreadLocal을 정리합니다

성공/실패 모두 사용량을 저장합니다. 실패한 호출도 API 비용이 나가기 때문입니다. 실패 시에는 STATUS_ERROR와 에러 메시지를 함께 기록해서 나중에 실패 패턴 분석에 활용합니다.

비동기 저장: @Async + 전용 스레드 풀#

사용량 저장은 비동기로 처리합니다. LLM API 응답이 수백ms~수초인데, 사용량 DB INSERT 때문에 거기에 추가 레이턴시가 붙으면 안 됩니다.

java
@Service
@RequiredArgsConstructor
@Slf4j
public class LLMUsageService {
 
    private final LLMUsageRepository llmUsageRepository;
 
    @Async("llmUsageExecutor")
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void saveUsageAsync(Integer tenantId, String apiEndpoint,
                               String provider, String model,
                               int requestTextLength, int responseTextLength,
                               String status, String errorMessage) {
        if (tenantId == null) {
            log.warn("[LLMUsageService] tenantId이 null입니다. "
                   + "스케줄러/배치 또는 컨텍스트 손실 가능성. "
                   + "endpoint={}, provider={}, model={}",
                     apiEndpoint, provider, model);
            return;
        }
 
        try {
            LLMUsage usage = LLMUsage.builder()
                .tenantId(tenantId)
                .apiEndpoint(apiEndpoint)
                .provider(provider != null ? provider : "UNKNOWN")
                .model(model != null ? model : "UNKNOWN")
                .requestTextLength(requestTextLength)
                .responseTextLength(responseTextLength)
                .status(status)
                .errorMessage(errorMessage)
                .build();
 
            llmUsageRepository.save(usage);
        } catch (Exception e) {
            log.error("[LLMUsageService] LLM 사용량 저장 실패. company={}, endpoint={}",
                      tenantId, apiEndpoint, e);
        }
    }
}

두 가지 설계 포인트가 있습니다.

@Transactional(propagation = Propagation.NOT_SUPPORTED)를 써서 기존 트랜잭션 밖에서 실행합니다. 사용량 저장이 비즈니스 트랜잭션에 참여하면, 저장 실패 시 비즈니스 트랜잭션까지 롤백될 수 있습니다. 사용량 로깅 때문에 비즈니스 데이터 저장이 실패하면 안 되니까요.

예외도 잡아서 로그만 남깁니다. catch (Exception e)로 모든 예외를 먹는 건 보통 안티패턴이지만, 여기서는 의도적입니다. 사용량 저장은 비즈니스 크리티컬하지 않기 때문에 어떤 상황에서도 호출자에게 예외가 전파되면 안 됩니다.

스레드 풀 설정#

java
@Bean("llmUsageExecutor")
public Executor llmUsageExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(2);
    executor.setMaxPoolSize(5);
    executor.setQueueCapacity(100);
    executor.setThreadNamePrefix("LLMUsage-");
    executor.setRejectedExecutionHandler(new DiscardOldestPolicy());
    executor.setTaskDecorator(new ContextCopyingTaskDecorator());
    executor.initialize();
    return executor;
}

기본 @Async 스레드 풀(core 5, max 20, queue 500)과 별도로 전용 풀을 만들었습니다. 이유는:

  • 격리: 사용량 저장 태스크가 기본 비동기 풀을 잡아먹으면, 다른 비동기 작업(알림 발송 등)에 영향을 줍니다
  • 크기 제한: core 2, max 5로 작게 잡았습니다. LLM 호출이 분당 수십 건 수준이라 이 정도면 충분하고, 리소스를 많이 쓸 이유가 없습니다
  • DiscardOldestPolicy: 큐(100)가 꽉 차면 가장 오래된 태스크를 버립니다. CallerRunsPolicy를 쓰면 caller 스레드(HTTP 요청 스레드)에서 실행되면서 응답이 느려질 수 있고, AbortPolicy를 쓰면 RejectedExecutionException이 발생합니다. 둘 다 사용량 로깅 목적에는 과한 반응이라, 조용히 버리는 쪽을 선택했습니다

ContextCopyingTaskDecorator는 caller 스레드의 RequestContextHolder, MDC를 async 스레드로 복사하는 역할입니다. 이게 나중에 문제의 원인이 됩니다.

Provider Fallback 추적#

LLM 호출에는 fallback이 걸려 있습니다. OpenAI가 타임아웃 나면 Anthropic으로 재시도하는 식입니다. 이때 사용량 기록에는 실제로 성공한 provider가 남아야 합니다.

요청할 때 지정한 provider와 실제 성공한 provider가 다를 수 있는 상황을 LLMExecutionContext로 해결했습니다.

java
public class LLMExecutionContext {
 
    private static final ThreadLocal<LLMProvider> actualUsedProvider = new ThreadLocal<>();
 
    public static LLMProvider getActualProvider() {
        return actualUsedProvider.get();
    }
 
    public static void setActualProvider(LLMProvider provider) {
        actualUsedProvider.set(provider);
    }
 
    public static void clear() {
        actualUsedProvider.remove();
    }
}

LLMServiceImpl이 API 호출에 성공하면 LLMExecutionContext.setActualProvider()를 호출합니다. Decorator는 이걸 읽어서 실제 provider/model을 기록합니다. fallback이 발생하지 않았으면 요청 시 지정한 provider와 동일하고, fallback이 발생했으면 재시도에 성공한 provider가 들어갑니다.

java
// LLMProviderExtractor에서
public String extractActualProvider(Collection<LLMProviderPriority> preferredProviders) {
    // 1순위: ExecutionContext에서 실제 성공한 provider
    LLMProvider actualProvider = LLMExecutionContext.getActualProvider();
    if (actualProvider != null && actualProvider.type() != null) {
        return actualProvider.type().name();
    }
 
    // 2순위: 요청 시 지정한 우선순위 첫 번째 provider
    return extractProvider(preferredProviders);
}

실패 시에는 LLMExecutionContext에 값이 안 들어가니까, 요청 시 지정한 provider가 기록됩니다. "OpenAI로 요청했는데 실패했다"라는 정보 자체도 의미가 있으니까요.

Decorator의 finally 블록에서 LLMExecutionContext.clear()를 호출해서 ThreadLocal을 정리합니다. 이걸 빼먹으면 스레드 풀에서 스레드가 재사용될 때 이전 요청의 provider 정보가 남아있는 버그가 생깁니다.

메트릭 추출#

저장하는 메트릭은 다음과 같습니다:

필드설명추출 방식
tenantId고객사 번호TenantAware (HTTP 세션 / 스케줄러 컨텍스트)
apiEndpointAPI 엔드포인트RequestContextHolder → "POST /api/reports/summary"
providerLLM 제공자LLMExecutionContext (성공) / 우선순위 첫 번째 (실패)
model모델명동일
requestTextLength요청 텍스트 길이모든 message content 길이 합산 + system prompt 길이
responseTextLength응답 텍스트 길이String → length(), Entity → JSON 직렬화 후 length()
status성공/실패SUCCESS / ERROR
errorMessage에러 메시지Exception.getMessage()

실제로 DB에 쌓인 데이터는 이런 형태입니다:

tenantIdapiEndpointprovidermodelreqLenresLenstatuscreatedAt
1042POST /api/reports/summaryOPENAIgpt-4o3,2411,876SUCCESS2026-01-15 14:23:11
1042POST /api/reports/summaryANTHROPICclaude-sonnet-4-53,2412,104SUCCESS2026-01-15 14:23:15
2187internal-serviceOPENAIgpt-4o8,4724,310SUCCESS2026-01-15 15:00:00
1042POST /api/chat/completionOPENAIgpt-4o5120ERROR2026-01-15 14:25:03

두 번째 행을 보면 같은 요청인데 provider가 ANTHROPIC입니다. OpenAI가 타임아웃 나서 fallback된 케이스입니다. 세 번째 행의 internal-service는 스케줄러에서 호출된 배치 잡이고, 네 번째 행은 실패한 호출로 responseTextLength가 0이고 status가 ERROR입니다.

이런 데이터가 쌓이면 "A사가 이번 달 GPT-4o를 몇 번 호출했고, 그중 몇 건이 fallback됐는지"를 SQL 한 줄로 뽑을 수 있습니다.

토큰 단위가 아니라 텍스트 길이를 쓴 이유는, OpenAI/Anthropic의 응답 body에서 usage 필드를 파싱하려면 LLMServiceImpl 내부를 건드려야 하기 때문입니다. Decorator는 delegate의 반환값(파싱된 결과)만 볼 수 있지, raw HTTP 응답은 볼 수 없습니다. 텍스트 길이로도 대략적인 비용 추정은 가능하고, 토큰 추출이 필요해지면 LLMServiceImpl에 확장 포인트를 넣으면 됩니다.

API 엔드포인트 추출도 흥미로운 부분입니다:

java
public String extractApiEndpoint() {
    try {
        if (!(RequestContextHolder.getRequestAttributes()
                instanceof ServletRequestAttributes attributes)) {
            return INTERNAL_SERVICE;
        }
 
        HttpServletRequest request = attributes.getRequest();
        String method = request.getMethod();
        String uri = request.getRequestURI();
        return method + " " + uri;  // "POST /api/reports/summary"
    } catch (Exception e) {
        return INTERNAL_SERVICE;
    }
}

HTTP 요청에서 호출된 경우 POST /api/reports/summary 같은 엔드포인트가 기록되고, 스케줄러/배치에서 호출된 경우 internal-service가 기록됩니다. 이렇게 하면 나중에 "어떤 API가 LLM을 가장 많이 쓰는지" 집계가 가능합니다.

프로덕션 버그 #1: tenantId null → 배치 잡 크래시#

릴리즈 후 첫 번째 문제는 스케줄러에서 터졌습니다.

원래 saveUsageAsync()에서 tenantId == null이면 IllegalArgumentException을 던지게 해뒀습니다. "tenantId 없이 사용량을 저장하는 건 의미가 없으니 빨리 실패하자"는 판단이었습니다.

java
// AS-IS
if (tenantId == null) {
    throw new IllegalArgumentException("tenantId must not be null");
}

그런데 스케줄러에서 LLM을 호출하는 배치 잡이 있었습니다. 스케줄러는 HTTP 요청 없이 실행되기 때문에 tenantId이 null입니다. @Async 스레드에서 예외가 터지면 SimpleAsyncUncaughtExceptionHandler가 잡아서 로그만 남기긴 하지만, 예외가 발생한다는 것 자체가 의도하지 않은 동작이었습니다.

당시에는 @Async 스레드에서 왜 tenantId이 null인지 정확한 원인을 파악하지 못한 상태였습니다. 확실한 건 하나였는데, 사용량 저장 실패 때문에 비즈니스 로직이 깨지면 안 된다는 것. 그래서 일단 예외 대신 WARN 로그 + early return으로 바꿔서 장애 전파부터 차단했습니다.

java
// TO-BE
if (tenantId == null) {
    log.warn("[LLMUsageService] tenantId이 null입니다. "
           + "스케줄러/배치 또는 컨텍스트 손실 가능성. "
           + "endpoint={}, provider={}, model={}",
             apiEndpoint, provider, model);
    return;
}

이 시점에서는 "스케줄러에서 호출하면 tenantId이 없을 수 있으니 그냥 skip하자"로 마무리했습니다. 근본 원인보다는 증상 억제에 가까운 처리라는 건 알고 있었지만, 우선 장애 전파를 끊는 게 급했습니다.

문제를 다시 들여다본 건 릴리즈 후 WARN 로그를 모니터링하면서였습니다. 로그를 집계해보니 스케줄러뿐만 아니라 HTTP 요청에서 호출된 경우에도 tenantId이 null로 찍히는 건이 있었습니다. 스케줄러에 HTTP 컨텍스트가 없어서 null이 되는 거라면, HTTP 요청에서는 null이 나오면 안 됩니다. 가정이 틀렸다는 뜻이고, 원인이 다른 곳에 있다는 신호였습니다.

프로덕션 버그 #2: @Async 컨텍스트 손실#

WARN 로그를 자세히 보면 패턴이 보입니다.

WARN LLMUsage-1 tenant:acme member_id:10001
  - [LLMUsageService] tenantId이 null입니다.
    endpoint=internal-service, provider=OPENAI, model=gpt-4o

WARN LLMUsage-2 tenant:globex member_id:20002
  - [LLMUsageService] tenantId이 null입니다.
    endpoint=internal-service, provider=OPENAI, model=gpt-4o

tenantmember_id가 MDC에 찍혀있습니다. 즉, HTTP 요청 컨텍스트 자체는 있는 상태에서 LLM 호출이 시작된 건데, tenantId이 null이 됐다는 뜻입니다.

원인 분석#

문제는 tenantId을 추출하는 시점에 있었습니다.

원래 코드에서는 LLMMetricsExtractor.extractPreExecutionMetrics() 안에서 tenantId을 추출하고 있었습니다.

java
// AS-IS: extractPreExecutionMetrics 내부에서 tenantId 추출
public UsageMetrics extractPreExecutionMetrics(
        Collection<LLMMessage> messages, String systemPromptContent) {
    Integer tenantId = extractTenantId();  // ← 여기서 세션 접근
    return new UsageMetrics(tenantId, extractApiEndpoint(),
                            calculateRequestTextLength(messages, systemPromptContent));
}

이 메서드 자체는 trackAndExecute() 안에서 호출되고, trackAndExecute()는 caller 스레드(HTTP 요청 스레드)에서 실행되니까 문제가 없어야 합니다.

하지만 ContextCopyingTaskDecorator의 동작을 자세히 보면 문제가 보입니다:

java
public class ContextCopyingTaskDecorator implements TaskDecorator {
 
    @Override
    public Runnable decorate(Runnable runnable) {
        RequestAttributes context = RequestContextHolder.currentRequestAttributes();
        // ↑ decorate() 시점에 caller 스레드의 RequestAttributes를 캡처
 
        return () -> {
            try {
                RequestContextHolder.setRequestAttributes(context);
                // ↑ async 스레드에서 캡처한 컨텍스트를 세팅
                runnable.run();
            } finally {
                RequestContextHolder.resetRequestAttributes();
                // ↑ 실행 끝나면 정리
            }
        };
    }
}

currentRequestAttributes()RequestAttributes가 없으면 IllegalStateException을 던집니다. 스케줄러/배치에서 시작된 요청은 HTTP 컨텍스트가 없기 때문에, ContextCopyingTaskDecorator.decorate() 시점에서 예외가 발생합니다.

이 예외가 DiscardOldestPolicy에 잡혀서 태스크가 조용히 버려지거나, 혹은 예외가 전파되면서 async 태스크 자체가 실행되지 않는 상황이 발생한 겁니다. 결국 saveUsageAsync()에 tenantId이 null로 도달하는 케이스와, 아예 도달하지 못하는 케이스 두 가지가 혼재하고 있었습니다.

정리하면:

Loading diagram...

Spring의 @Async를 쓸 때 흔히 밟는 함정입니다. 같은 JVM 안이니까 컨텍스트가 당연히 넘어갈 것 같지만, RequestContextHolder, SecurityContextHolder, MDCThreadLocal 기반 컨텍스트는 스레드가 바뀌면 명시적으로 전파하지 않는 한 사라집니다.

ContextCopyingTaskDecorator는 HTTP 요청 컨텍스트가 있을 때만 정상 동작합니다. 스케줄러처럼 HTTP 컨텍스트 없이 시작되는 흐름에서는 currentRequestAttributes()가 실패하면서 전체 전파가 깨집니다.

해결: caller 스레드에서 미리 추출#

수정 방향은 간단합니다. tenantId을 @Async 스레드로 넘기기 전에, caller 스레드에서 미리 꺼내두는 것.

java
// TO-BE: Decorator에서 tenantId을 미리 추출
private Integer resolveTenantId() {
    try {
        return tenantAware.getTenantId();
    } catch (Exception e) {
        log.debug("[LLMUsageTrackingDecorator] Failed to resolve tenantId", e);
        return null;
    }
}

trackAndExecute()의 첫 번째 줄에서 resolveTenantId()을 호출합니다. 이 시점은 caller 스레드(HTTP 요청 스레드 또는 스케줄러 스레드)이므로, HTTP 요청이면 세션에서 꺼내지고, 스케줄러면 null이 반환됩니다.

java
private <T> T trackAndExecute(...) {
    Integer tenantId = resolveTenantId();  // ← caller 스레드에서 미리 추출
    UsageMetrics metrics = metricsExtractor.extractPreExecutionMetrics(
        tenantId, messages, systemPromptContent);
        // ↑ tenantId을 파라미터로 전달, 더 이상 내부에서 세션 접근 안 함
    // ...
}

extractPreExecutionMetrics()의 시그니처를 바꿔서 tenantId을 파라미터로 받게 했습니다. 이렇게 하면 메서드 안에서 RequestContextHolder에 접근할 필요가 없어지고, 스케줄러든 HTTP 요청이든 동일하게 동작합니다.

커밋 diff를 보면 변경 규모가 크지 않습니다:

LLMUsageTrackingDecorator.java  | 16 ++++-    (resolveTenantId 추가)
LLMMetricsExtractor.java        | 38 ++------  (세션 접근 코드 제거)
LLMMetricsExtractorTest.java    | 71 +-------  (tenantId 관련 테스트 정리)
3 files changed, 22 insertions(+), 103 deletions(-)

22줄 추가, 103줄 삭제. 세션 접근 로직과 관련 테스트가 빠지면서 오히려 코드가 줄었습니다. LLMMetricsExtractor는 순수하게 메트릭 계산만 하는 역할이 되었고, 컨텍스트 의존성이 사라지면서 테스트도 단순해졌습니다.

타임라인#

12/30  feat: LLM 사용량 추적 기능 개발 (최초 PR)
01/16  fix: tenantId null 시 예외 대신 WARN 로그 처리
03/10  fix: @Async 스레드에서 tenantId 누락 근본 수정
03/31             릴리즈 전 마지막으로 WARN 로그 다수 발생
04/01             릴리즈 — WARN 로그 해소 확인

1차 수정(01/16)은 증상만 막은 것이었고, 2차 수정(03/10)이 근본 원인을 해결한 것입니다. 3/31의 WARN 로그는 4/1 릴리즈 전이라 아직 구버전이 돌고 있던 시점의 로그였습니다.

릴리즈 후 로그 모니터링에서 [LLMUsageService] tenantId이 null입니다 WARN 로그가 완전히 사라진 걸 확인했습니다. 릴리즈 전 일주일간 하루 평균 40~60건씩 찍히던 로그가 4/1 배포 이후 0건으로 떨어졌습니다. 스케줄러 호출 건은 caller 스레드에서 tenantId를 미리 추출하되 null이면 saveUsageAsync()에 null로 넘어가고, saveUsageAsync()에서 WARN + return 하는 흐름은 동일합니다. 다만 이전처럼 HTTP 요청에서 호출된 건까지 null이 찍히는 케이스가 사라진 것이 핵심입니다.

@Async + ThreadLocal에서 배운 것#

이번 건을 통해 정리된 원칙이 몇 가지 있습니다.

1. @Async 메서드에 넘길 값은 caller에서 미리 resolve하기

ThreadLocal에서 꺼내야 하는 값(RequestContextHolder, SecurityContextHolder, TenantAware 등)은 @Async 경계를 넘기기 전에 일반 변수에 담아두는 게 가장 안전합니다. TaskDecorator로 컨텍스트를 복사하는 방법도 있지만, 복사할 컨텍스트가 없는 경우(스케줄러, 배치)를 커버하지 못합니다.

2. TaskDecorator의 한계를 인지하기

ContextCopyingTaskDecorator는 HTTP 요청 컨텍스트가 있을 때만 정상 동작합니다. currentRequestAttributes()getRequestAttributes()와 달리 null을 반환하지 않고 예외를 던진다는 점을 놓치기 쉽습니다.

java
// currentRequestAttributes() — 없으면 IllegalStateException
RequestAttributes context = RequestContextHolder.currentRequestAttributes();
 
// getRequestAttributes() — 없으면 null
RequestAttributes context = RequestContextHolder.getRequestAttributes();

기존 ContextCopyingTaskDecoratorcurrentRequestAttributes()를 쓰고 있었기 때문에 스케줄러 컨텍스트에서 예외가 발생한 것입니다. getRequestAttributes()로 바꾸고 null 체크를 하면 TaskDecorator 자체의 문제는 해결할 수 있지만, 근본적으로 "필요한 값을 미리 꺼내두기"가 더 확실한 해법입니다.

3. 비동기 로깅은 fail-safe하게

사용량 추적처럼 부가적인 기능은 어떤 상황에서도 메인 로직을 방해하면 안 됩니다. 이번에는 예외 전파, 트랜잭션 참여, 스레드 풀 고갈 3가지를 모두 방어했지만, 컨텍스트 전파 실패라는 네 번째 경로를 놓쳤습니다.

현재 구조의 한계와 확장 포인트#

직접 만든 만큼 서드파티 대비 부족한 부분이 있습니다.

토큰 단위 비용 추적: 현재는 텍스트 길이 기반이라 정확한 토큰 수/비용을 모릅니다. OpenAI 응답의 usage.prompt_tokens, usage.completion_tokens를 파싱하면 가능하지만, LLMServiceImpl 내부를 건드려야 합니다.

프롬프트 버전 관리: Langfuse는 프롬프트를 버전별로 관리하고 A/B 테스트할 수 있는데, 이건 사용량 추적과 별개의 영역이라 현재 구조에서는 커버하지 않습니다.

실시간 대시보드: DB에 쌓기만 하고 있어서, 별도 대시보드를 만들거나 기존 모니터링 도구와 연동해야 합니다.

토큰 추출은 LLMUsage 도메인에 promptTokens, completionTokens 필드를 추가하고, LLMServiceImpl에서 raw response를 파싱하는 확장 포인트를 넣으면 됩니다. Decorator의 구조를 바꿀 필요 없이 saveUsageAsync()의 파라미터만 늘리면 됩니다.

Connected Notes