dev notes

Spring @Async 기반 LLM 사용량 저장과 요청 컨텍스트 분리

2026-05-1415 min read
공유

LLM 응답과 사용량 저장 경로 분리#

이전 글에서는 1순위 Provider가 실패했을 때 다음 후보로 전환하고, 실제 응답을 만든 Provider를 실행 컨텍스트에 기록하는 구조를 다뤘습니다.

이 정보를 기능별·테넌트별로 분석하려면 Provider와 모델뿐 아니라 테넌트 식별 정보, 호출 endpoint, 요청·응답 길이와 실행 결과를 하나의 사용량 레코드에 연결해야 했습니다.

여기서 기록하는 사용량은 Provider가 반환한 토큰 수 입력과 출력 원문도 저장하지 않고 텍스트 길이와 실행 결과만 집계합니다. 이 데이터는 모델 비용 정산이 아니라 기능별 호출량과 응답 규모를 확인하기 위한 운영 지표로 사용합니다.

사용량 저장이 LLM 응답 시간을 늘리지 않도록 DB 저장은 @Async로 요청 처리 경로에서 분리했습니다.

mermaid나
flowchart LR
    A[LLM 요청] --> D[LLMUsageTrackingDecorator]
    D -->|호출 전| C[tenantId·요청 길이 수집]
    D --> L[LLM 실행]
    L -->|응답·실제 Provider| D
    D -. LLMUsageEvent .-> X[llmUsageExecutor]
    X -->|core worker 사용 가능| W[Usage worker]
    X -->|core worker 사용 중| Q[bounded queue 100]
    Q --> W
    Q -->|queue full·pool 여유| E[추가 worker·최대 5]
    E --> W
    Q -. queue full·max 도달 .-> H[Drop handler]
    H --> M[drop counter·제한 로그]
    W --> DB[(Usage DB)]
    D --> R[고객 응답]

HTTP 세션에 결합된 테넌트 정보#

기존 LLMMetricsExtractor는 요청 길이를 계산하면서 RequestContextHolder를 통해 HTTP 세션의 tenantId도 함께 조회했습니다.

일반적인 API 요청에서는 값을 가져올 수 있었지만, 사용량 수집 로직이 Servlet 요청에 의존하면서 HTTP context가 없는 배치나 스케줄러에서는 같은 방식으로 테넌트를 식별할 수 없었습니다.

저장 시점에 tenantId가 없으면 레코드를 저장하지 않도록 방어하고 있었기 때문에 잘못된 테넌트로 데이터가 기록되지는 않았습니다. 대신 정상적인 호출이라도 실행 경로에 따라 사용량이 누락될 수 있었습니다.

비동기 경계에서 요청 컨텍스트 제거하기#

tenantIdLLMUsageTrackingDecorator에서 LLM 호출을 시작하기 전에 가져오도록 변경했습니다. API endpoint와 요청 길이도 같은 시점에 수집하고, LLMMetricsExtractor에서는 HTTP 세션 조회를 제거했습니다.

반면 Provider와 모델은 호출이 끝난 뒤에야 확정됩니다. fallback이 발생하면 처음 선택한 Provider와 실제 응답을 만든 Provider가 다를 수 있기 때문입니다.

사용량 저장에 필요한 값은 다음 두 시점에 나뉘어 만들어집니다.

  • 호출 전: tenantId, endpoint, 요청 길이
  • 호출 후: 실제 Provider, 모델, 응답 길이, 실행 결과

호출 전에 수집한 값은 LLM 실행 동안 유지하고, 호출이 끝난 뒤 실제 Provider와 응답 정보를 합쳐 LLMUsageEvent를 생성했습니다. 비동기 저장에는 이 완성된 이벤트만 넘깁니다.

java
@Builder
public record LLMUsageEvent(
    Integer tenantId,
    String apiEndpoint,
    String provider,
    String model,
    int requestTextLength,
    int responseTextLength,
    String status,
    String errorMessage
) {}

LLM 실행 계층은 성공한 Provider를 LLMExecutionContext에 기록합니다. extractActualProviderOrDefault()는 실행 중 기록된 값을 우선 사용하고, 값이 없을 때만 후보 목록의 첫 Provider를 기본값으로 사용합니다.

java
try {
    T result = executor.execute();
 
    LLMProvider actualProvider =
        providerExtractor.extractActualProviderOrDefault(preferredProviders);
 
    LLMUsageEvent event = LLMUsageEvent.builder()
        .tenantId(metrics.tenantId())
        .apiEndpoint(metrics.apiEndpoint())
        .provider(actualProvider.type().name())
        .model(actualProvider.modelText())
        .requestTextLength(metrics.requestTextLength())
        .responseTextLength(lengthCalculator.calculate(result))
        .status("SUCCESS")
        .build();
 
    llmUsageService.saveUsageAsync(event);
    return result;
} finally {
    LLMExecutionContext.clear();
}

중요한 점은 LLMExecutionContext 자체를 비동기 worker로 넘기지 않는다는 것입니다. 실제 Provider를 호출 스레드에서 꺼내 LLMUsageEvent에 넣은 뒤 saveUsageAsync()를 호출합니다. LLMExecutionContext는 사용이 끝나면 finally에서 제거해, 같은 스레드가 다른 요청에 재사용될 때 이전 Provider 정보가 남지 않도록 했습니다.

이 구조로 바꾸면서 사용량 전용 executor에 설정돼 있던 ContextCopyingTaskDecorator도 제거했습니다.

diff
- executor.setTaskDecorator(new ContextCopyingTaskDecorator());

기존에는 worker에서 RequestAttributes를 다시 사용할 수 있도록 요청 컨텍스트를 복제했지만, 이제 저장에 필요한 값은 LLMUsageEvent 안에 모두 들어 있습니다. worker는 HTTP 세션이나 LLMExecutionContext를 조회하지 않고 전달받은 이벤트만 저장합니다.

Spring Framework 5.3.39의 TaskDecorator API 문서는 실행할 Runnable을 감싸 컨텍스트를 설정하거나 실행 전후의 동작을 추가하는 용도로 설명합니다. 사용량 저장 경로에는 더 이상 복원할 요청 컨텍스트가 없었기 때문에 공용 TaskDecorator를 유지하지 않았습니다.

저장 실패 로그에도 tenantId와 endpoint처럼 필요한 식별값을 직접 남겼습니다. HTTP 요청 전체를 worker 스레드로 복사하지 않아도 저장 실패를 추적할 수 있습니다.

전용 executor와 유실 정책#

사용량 저장은 다른 비동기 작업과 분리된 llmUsageExecutor에서 처리합니다.

설정은 core pool 2, max pool 5, queue capacity 100입니다. core worker 2개가 모두 사용 중이면 새 작업은 queue에 먼저 쌓입니다. queue가 100개까지 찬 뒤에야 worker 수가 최대 5개까지 늘어나고, queue와 max worker가 모두 사용 중일 때 rejection handler가 호출됩니다.

초기 설정에는 다른 비동기 executor에서 사용하던 DiscardOldestPolicy가 적용돼 있었습니다.

이 정책은 queue가 가득 차면 가장 오래 대기한 작업을 제거한 뒤 새 작업을 다시 제출합니다. 사용량 저장에 그대로 적용하면 이미 접수된 레코드가 뒤늦게 사라질 수 있고, 몇 건이 폐기됐는지도 별도로 확인하기 어렵습니다.

Java 17 API 문서DiscardOldestPolicy가 가장 오래 대기한 미처리 작업을 버리고 현재 작업을 다시 제출하는 방식이라고 설명합니다.

사용량 자체는 일부 누락을 허용했지만, 누락 건수는 운영에서 확인할 필요가 있었습니다. 그래서 기존 queue의 작업은 유지하고 새로 거부된 작업을 집계하는 rejection handler로 교체했습니다.

java
@Slf4j
@RequiredArgsConstructor
final class ObservableDropPolicy implements RejectedExecutionHandler {
 
    private static final long WARN_INTERVAL_MS = 60_000;
 
    private final Counter droppedCounter;
    private final AtomicLong lastWarnedAt = new AtomicLong();
 
    @Override
    public void rejectedExecution(
        Runnable task,
        ThreadPoolExecutor executor
    ) {
        droppedCounter.increment();
 
        long now = System.currentTimeMillis();
        long previous = lastWarnedAt.get();
 
        if (now - previous >= WARN_INTERVAL_MS
            && lastWarnedAt.compareAndSet(previous, now)) {
            log.warn(
                "LLM usage task dropped. queue={}, active={}",
                executor.getQueue().size(),
                executor.getActiveCount()
            );
        }
    }
}

executor를 구성할 때 같은 handler에 Micrometer counter를 전달합니다.

java
Counter droppedCounter = Counter.builder("llm.usage.async.dropped")
    .description("Dropped LLM usage tasks")
    .register(meterRegistry);
 
executor.setRejectedExecutionHandler(
    new ObservableDropPolicy(droppedCounter)
);

queue와 worker가 모두 포화되면 새로 제출된 작업을 버리고 llm.usage.async.dropped counter를 증가시킵니다. 로그는 일정 간격으로 제한해 포화 상황에서 경고 로그가 추가 부하가 되지 않도록 했습니다.

CallerRunsPolicy는 사용하지 않았습니다. 저장 작업을 요청 스레드가 직접 실행하면 DB 지연이 그대로 고객 응답 시간에 포함되기 때문입니다.

설정역할
core pool2평상시 사용량 저장 처리
queue capacity100core worker 처리량을 넘은 작업의 완충
max pool5queue 포화 이후 추가 worker 확보
rejection policy관측 가능한 drop handler신규 작업 유실 집계

비동기 저장의 트랜잭션 경계#

saveUsageAsync()@Async("llmUsageExecutor")가 적용된 worker 스레드에서 실행됩니다.

기존에는 이 메서드에 @Transactional(propagation = Propagation.NOT_SUPPORTED)도 선언돼 있었습니다. Spring Framework 5.3.39의 @Transactional 문서는 일반적인 Spring 트랜잭션이 현재 실행 스레드에 귀속되며 새로 시작한 스레드로 전파되지 않는다고 설명합니다.

saveUsageAsync()가 실행되는 worker에는 호출 스레드의 트랜잭션이 전달되지 않으므로, 현재 구조에서 NOT_SUPPORTED로 중단할 트랜잭션은 없었습니다. 해당 애너테이션은 제거했습니다.

repository 예외는 saveUsageAsync() 내부에서 처리하고, tenantId가 없는 이벤트는 저장하지 않습니다. 사용량 저장 실패가 원래 LLM 요청으로 전파되거나 식별할 수 없는 레코드가 저장되는 경로를 차단했습니다.

일부 유실을 허용한 이유#

사용량 레코드는 과금이나 감사의 원천 데이터가 아니어서 일부 누락을 허용했습니다.

기능별 호출량과 응답 규모, Provider 사용 현황을 확인하기 위한 운영 지표였기 때문에 executor가 포화됐을 때 DB 저장을 고객 요청 스레드로 넘기는 대신 해당 작업을 버리고 누락 건수를 metric으로 남겼습니다.

호출 이력을 빠짐없이 보존해야 하는 데이터라면 같은 방식을 사용할 수 없습니다. 그런 경우에는 executor의 drop 정책이 아니라 영속 큐나 outbox처럼 실패 후 재처리할 수 있는 전달 구조가 필요합니다.

비동기 실행까지 테스트하기#

LLMUsageServiceTest에서는 tenantId 누락, 정상·오류 이벤트 저장, repository 예외 처리를 확인했습니다.

서비스를 직접 생성하는 테스트이기 때문에 @Async 자체는 동작하지 않습니다. 비동기 실행은 Spring context를 사용하는 통합 테스트에서 별도로 확인했습니다.

repository가 호출된 스레드 이름을 기록하고 Awaitility로 저장 완료를 기다린 뒤, llmUsageExecutor의 worker에서 실행됐는지 검사했습니다.

java
AtomicReference<String> workerThread = new AtomicReference<>();
 
doAnswer(invocation -> {
    workerThread.set(Thread.currentThread().getName());
    return null;
}).when(repository).save(any(LLMUsage.class));
 
usageService.saveUsageAsync(event);
 
await().atMost(Duration.ofSeconds(1)).untilAsserted(() ->
    assertThat(workerThread.get()).startsWith("LLMUsage-")
);

rejection 동작은 작은 test executor로 재현했습니다. core와 max를 각각 1, queue를 1로 두고 첫 번째 작업을 latch에서 대기시키면 두 번째 작업은 queue에 들어갑니다. 그 상태에서 세 번째 작업을 제출하면 rejection이 발생합니다.

세 번째 이벤트 제출 후 drop counter가 증가하는지와 호출부에 예외가 전달되지 않는지를 확인했습니다. latch를 해제한 뒤에는 먼저 접수된 두 작업이 정상적으로 저장되는지도 검증했습니다.

마무리하며#

tenantId와 요청 길이는 LLM 호출 전에 수집하고, 실제 Provider와 응답 정보는 호출이 끝난 뒤 LLMUsageEvent에 채웠습니다. worker에는 완성된 이벤트만 전달하면서 HTTP 요청 context와 TaskDecorator 의존성을 제거했습니다.

사용량 저장은 전용 executor로 분리했습니다. queue가 포화되면 새 작업을 drop하고, 누락 건수는 metric으로 기록합니다.

다음 글에서는 이 executor의 pool과 queue 크기를 실제 처리량을 기준으로 정한 과정을 다룹니다.

Connected Notes