dev notes

스프링 프로젝트에 Langfuse 도입하기 [2] — 프로덕션 안정화

2026-05-0726 min read
공유

프로덕션 요청 경로의 리전 간 네트워크 병목#

이전 편에서는 Langfuse의 프롬프트 조회와 LLM 호출 트레이싱을 Spring 애플리케이션에 통합하고, 비활성 환경을 위한 NoOp 구현까지 다뤘습니다. 기능 통합은 끝났지만 프로덕션 배치 구조를 기준으로 요청 경로를 다시 확인하자, 프롬프트 조회가 성능과 가용성에 직접 영향을 주고 있었습니다.

애플리케이션과 Redis는 같은 VPC에 있지만 Langfuse 서버는 US 리전에 있습니다. 1편의 구현은 LLM 요청을 처리할 때마다 Langfuse API에서 프롬프트를 동기 조회하므로, 같은 버전의 프롬프트를 반복해서 사용하더라도 매 요청이 리전 간 네트워크를 왕복합니다. Datadog의 30일치 outbound span에서 이 구간은 128~553ms로 측정됐고, 같은 VPC의 Redis 조회는 약 1ms였습니다.

이 네트워크 경로는 세 가지 운영 문제로 이어졌습니다. 우선 Langfuse 조회 시간이 LLM 요청 지연에 그대로 더해졌고, 같은 프롬프트를 요청하는 트래픽이 동시에 몰리면 동일한 원격 호출이 요청 수만큼 증폭됐습니다. Langfuse 또는 리전 간 네트워크에 장애가 발생했을 때 프롬프트를 가져오지 못해 LLM 호출까지 중단되는 구조도 문제였습니다.

이번 편의 목표는 같은 VPC의 Redis를 기본 조회 경로로 두고 캐시 미스에서만 Langfuse를 호출하며, 동시성 제어로 중복 조회를 제한하고 마지막 정상 프롬프트를 fallback으로 사용하는 구조를 순서대로 다루는 것입니다.

Redis 기반 프롬프트 캐시#

첫 단계는 Langfuse에서 조회한 프롬프트를 Redis에 저장하고, 이후 요청이 같은 VPC 안에서 값을 읽도록 기본 캐시 경로를 구성하는 것입니다.

캐시 키 설계#

캐시 키는 langfuse-prompt::{promptName}:{label} 형식으로 정했습니다. 프롬프트 이름만 사용하면 development에서 조회한 버전이 production 캐시를 덮어쓸 수 있으므로, 같은 이름 아래에서 서로 다른 버전을 가리키는 라벨까지 키에 포함했습니다.

캐시 데이터 모델#

캐시에는 프롬프트 본문과 모델 설정, 버전을 함께 저장합니다. 초기에는 이 세 필드를 가진 CachedPromptrecord로 정의했지만, 이 프로젝트의 RedisTemplate<String, Object>GenericJackson2JsonRedisSerializer로 값을 읽고 ObjectMapper에는 DefaultTyping.NON_FINAL이 설정돼 있었습니다. record는 final 타입이라 직렬화된 JSON에 구체 타입 정보가 포함되지 않았고, Object로 역직렬화한 결과는 CachedPrompt가 아닌 LinkedHashMap이었습니다.

이 현상은 record를 Redis에 저장할 수 없어서 발생한 것이 아닙니다. CachedPrompt 타입을 지정하는 serializer를 사용하거나 타입 정보 정책을 바꾸면 record도 사용할 수 있지만, 여기서는 기존 RedisTemplate의 설정을 유지하기 위해 캐시 DTO를 non-final class로 변경했습니다.

전용 RedisTemplate 구성#

Langfuse 캐시에는 String key와 위 DTO를 처리하는 전용 RedisTemplate을 사용했습니다. NON_FINAL 타입 정보를 기존 Redis 설정 전체에 적용하면 다른 캐시의 JSON 형식까지 바뀔 수 있으므로, serializer와 ObjectMapper의 적용 범위를 이 템플릿으로 제한했습니다.

기본 캐시 조회 구조#

java
public CachedPrompt get(String promptName, PromptLabel label) {
    String key = cacheKey(promptName, label);
    CachedPrompt cached = getFromRedis(key);
    if (cached != null) return cached;
 
    CachedPrompt fetched = fetchFromLangfuse(promptName, label);
    putToRedis(key, fetched, ttlSeconds);
    return fetched;
}

실제 getFromRedisputToRedis는 Redis 예외를 캐시 miss와 저장 실패로 변환합니다. Redis 장애 시에는 Langfuse 직접 조회로 내려가되, 캐시 계층의 실패가 비즈니스 요청을 즉시 중단시키지는 않도록 경계를 잡았습니다.

여기까지가 기본 캐싱입니다. 하지만 이 코드에는 동시성 문제가 있습니다.

Cache Stampede 동시성 제어#

캐시가 만료되는 순간을 생각해보면, 여러 스레드가 거의 동시에 getFromCache()를 호출하고, 전부 null을 받고, 전부 fetchFromLangfuse()를 호출합니다. 같은 데이터를 가져오는 API를 N번 호출하는 거죠.

Double-Checked Locking#

이 문제를 해결하는 고전적인 패턴이 Double-Checked Locking입니다. 캐시 키별로 락을 만들어서, 같은 프롬프트에 대한 API 호출을 하나로 직렬화합니다.

java
CachedPrompt cached = getFromRedis(key);
if (cached != null) return cached;
 
return lockManager.executeWithLock(key, () -> {
    CachedPrompt rechecked = getFromRedis(key);
    if (rechecked != null) return rechecked;
 
    CachedPrompt fetched = fetchFromLangfuse(promptName, label);
    putToRedis(key, fetched, ttlSeconds);
    return fetched;
});

1차 체크에서 캐시 hit이면 락 없이 바로 반환합니다. 캐시 miss일 때만 락을 잡고, 2차 체크에서 다른 스레드가 이미 채웠는지 한 번 더 확인합니다. 덕분에 100개 스레드가 동시에 들어와도 API 호출은 1번만 발생합니다.

키별 락 수명주기 관리#

위 코드에 한 가지 문제가 있습니다. lockMap에 락 객체가 계속 쌓인다는 점입니다. 프롬프트가 100개이고 라벨이 3개면 300개의 ReentrantLock이 만들어지는데, 더 이상 사용되지 않는 프롬프트의 락도 계속 남아있습니다.

PromptLocalLockManager는 키마다 ReentrantLock과 마지막 사용 시각을 보관하고, 5분마다 10분 이상 사용되지 않은 항목을 정리합니다. 정리 대상은 만료 시각만 지난 항목이 아니라 현재 잠겨 있지 않고 대기 중인 스레드도 없는 항목으로 제한해야, 정리 직후 같은 키에 두 개의 락이 생기는 경쟁 조건을 피할 수 있습니다. 호출부에는 위 예제처럼 두 번째 캐시 확인과 원격 조회를 하나의 executeWithLock 블록으로 묶는 API만 노출했습니다.

프로덕션 적용 결과#

캐시 적용 이후에는 성수기처럼 요청이 계속 몰리는 구간에서도 같은 프롬프트에 대한 요청이 Redis cache hit 경로에서 처리됐습니다. 요청마다 발생하던 Langfuse 조회가 사라지면서 트래픽 증가가 cross-region 호출 증가로 이어지지 않았고, 프롬프트 조회 시간도 일반 요청의 응답 시간에서 분리됐습니다.

Double-Checked Locking은 캐시가 처음 채워지는 시점의 중복 조회를 막는 역할에만 관여하고, 이후 요청은 락을 거치지 않고 Redis에서 바로 값을 읽습니다. 운영 트래픽이 순간적인 폭증보다 성수기에 지속되는 고부하에 가까웠기 때문에, 원격 호출 횟수를 요청량이 아니라 캐시 키와 갱신 주기에 묶어둔 효과가 컸습니다.

멀티 인스턴스 분산 락#

로컬 락은 같은 JVM 안에서만 동작합니다. k8s로 pod을 3개 띄우면, 각 pod의 로컬 락은 서로 모릅니다. 캐시가 만료되면 pod 3개가 각각 Langfuse API를 호출하게 됩니다.

이걸 해결하려면 인스턴스 간에 공유되는 락이 필요하고, Redis가 이미 있으니까 Redis 기반 분산 락을 만듭니다.

Lua 기반 락 소유권 검증#

java
private static final String UNLOCK_SCRIPT = """
    if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('del', KEYS[1])
    end
    return 0
    """;

락 획득은 SET key uuid NX EX 15로 처리하고, 해제할 때만 위 Lua 스크립트를 실행합니다.

UUID로 소유권 증명SET key uuid NX EX ttl에서 value를 UUID로 넣습니다. 해제할 때 GET으로 값을 비교해서 자기가 잡은 락만 풀 수 있게 합니다. 이게 없으면 A 인스턴스가 잡은 락을 B 인스턴스가 풀어버리는 문제가 생깁니다.

Lua 스크립트로 원자성 보장 — Lock과 Unlock 모두 Lua 스크립트로 처리합니다. GET 후 DEL을 별도 명령으로 보내면 그 사이에 다른 명령이 끼어들 수 있습니다. Lua 스크립트는 Redis에서 원자적으로 실행됩니다.

락 TTL 분리 — 캐시 TTL은 데이터의 유효기간이고, 락 TTL은 갱신 작업이 끝날 때까지 소유권을 유지하는 시간입니다. 예제의 15초는 Langfuse 호출 타임아웃인 10초보다 길고 캐시 TTL인 30분보다는 훨씬 짧습니다. 정상 경로에서는 원격 조회가 끝날 때까지 락을 유지하되, 프로세스가 종료된 경우에는 15초 뒤 다른 인스턴스가 갱신을 재개하도록 정한 값입니다.

획득 실패 시 갱신 생략 — 분산 락은 비동기 갱신에서만 사용하므로, 1초 안에 획득하지 못하면 예외를 발생시키지 않고 해당 실행을 종료합니다. 이미 락을 획득한 인스턴스의 갱신 결과를 다음 캐시 조회에서 사용하면 됩니다.

운영에서는 여러 pod이 같은 키의 갱신 시점에 도달하더라도 락을 획득한 인스턴스만 Langfuse를 조회하고, 나머지는 갱신을 생략한 뒤 새로 저장된 캐시를 사용합니다. 이 분산 락은 사용자 요청 경로가 아니라 백그라운드 갱신에만 적용되므로, 락 경합이 고객 응답 시간에 직접 추가되지 않습니다.

로컬·분산 락 계층화#

정리해보면 락을 나눠 쓰는 기준은 이렇습니다:

상황사용하는 락이유
캐시 미스 시 동기 조회로컬 락 (ReentrantLock)같은 JVM 내 Thundering Herd 방지
비동기 캐시 갱신분산 락 (Redis)인스턴스 간 중복 갱신 방지

동기 조회에서는 로컬 락만 쓰면 충분합니다. 같은 JVM 안에서 중복만 막으면 되고, 다른 인스턴스가 동시에 API를 호출하더라도 결과는 같으니까 큰 문제는 없습니다. 분산 락까지 쓰면 락 획득에 네트워크 왕복이 추가되어서 오히려 느려집니다.

분산 락이 진짜 필요한 건 비동기 캐시 갱신입니다. 백그라운드에서 캐시를 미리 갱신하는 작업은 사용자 요청과 무관하게 돌아가기 때문에, 인스턴스 3개가 동시에 같은 프롬프트를 갱신하면 Langfuse API에 불필요한 부하를 줍니다.

Stale-While-Revalidate 기반 비동기 갱신#

캐시를 썼더니 API 호출은 줄었는데, 새로운 문제가 생겼습니다. TTL이 만료되는 순간에 요청이 오면, 그 요청은 캐시 미스 → Langfuse API 호출 → 응답 대기까지 전부 기다려야 합니다. 운이 나쁘면 사용자 입장에서 갑자기 느려지는 경험을 합니다.

이걸 해결하는 방법이 Stale-While-Revalidate 패턴입니다. 캐시가 만료되기 전에, 백그라운드 스레드가 미리 새 값을 가져와서 캐시를 갱신합니다.

Loading diagram...

운영 캐시 TTL은 30분이며, 만료 5초 전에 비동기 갱신을 실행합니다. 이 구간의 요청은 기존 값을 사용하고, 백그라운드 작업이 새 프롬프트를 가져와 TTL을 다시 설정합니다.

비동기 갱신 구현#

java
void scheduleRefresh(String key, String promptName, PromptLabel label, int ttl) {
    if (!scheduledRefreshKeys.add(key)) return;
    scheduler.schedule(() -> {
        try {
            refresh(key, promptName, label);
        } finally {
            scheduledRefreshKeys.remove(key);
            scheduleRefresh(key, promptName, label, ttl);
        }
    }, ttl - 5, TimeUnit.SECONDS);
}
 
void refresh(String key, String promptName, PromptLabel label) {
    String lockKey = "lock::" + key;
    String lockValue = distributedLockService.tryLock(lockKey, 1);
    if (lockValue == null) return;
 
    try {
        CachedPrompt fetched = fetchFromLangfuse(promptName, label);
        putToRedis(key, fetched, ttlSeconds);
    } finally {
        distributedLockService.unlock(lockKey, lockValue);
    }
}

scheduledRefreshKeys는 한 인스턴스 안에서 같은 키가 중복 예약되는 것을 막고, Redis 락은 여러 pod이 동시에 갱신하는 것을 막습니다. 다음 실행은 현재 키를 Set에서 제거한 뒤 등록해야 합니다. 제거 전에 scheduleRefresh를 호출하면 중복 방지 조건에 걸려 예약 체인이 첫 실행에서 끝납니다.

갱신이 성공하면 scheduleAsyncCacheRefresh를 다시 호출해서 다음 갱신도 예약합니다. 체인처럼 이어지는 구조라서, 최초 캐시 로드 이후에는 계속 백그라운드에서 돌아갑니다.

운영 트래픽의 비동기 갱신 경로#

최초 조회로 캐시가 채워진 뒤에는 만료 전에 백그라운드 갱신이 실행되므로, 운영 요청은 갱신 완료를 기다리지 않고 기존 값을 계속 사용합니다. 성수기 트래픽에서도 동일한 프롬프트 요청은 Redis cache hit로 처리됐고, Langfuse 조회는 신규 캐시 키의 최초 적재와 정해진 갱신 시점에만 발생했습니다.

결과적으로 비동기 갱신의 효과는 특정 부하 테스트 수치보다 요청 경로 자체가 달라졌다는 데 있습니다. 적용 전에는 모든 요청이 US 리전의 Langfuse를 거쳤지만, 적용 후에는 Redis가 정상 요청의 기본 경로가 되고 Langfuse는 캐시를 채우거나 갱신할 때만 호출됩니다.

전용 스케줄러 구성#

갱신 작업은 두 개의 daemon thread를 가진 전용 ScheduledThreadPoolExecutor에서 실행하고, 취소된 작업은 setRemoveOnCancelPolicy(true)로 큐에서 바로 제거합니다. LLM 호출이나 일반 비동기 작업과 풀을 공유하지 않아 Langfuse 지연이 다른 작업의 실행 순서에 영향을 주지 않게 했습니다.

프롬프트 fallback 계층과 구현 범위#

1편의 라벨 fallback에 Last Known Good 캐시를 결합했습니다. 코드 내 기본 프롬프트까지 내려가는 3단계를 설계했지만, 현재 구현에서 연결된 경로는 라벨 fallback과 LKG까지입니다.

Loading diagram...

1단계 — 라벨 fallback: Langfuse API에서 development 라벨을 못 찾으면 production, 그래도 없으면 latest까지 시도합니다. 이건 1편에서 다뤘던 내용입니다.

2단계 — Last Known Good 캐시: Langfuse API 자체가 죽었을 때, TTL이 만료된 이전 캐시를 꺼내서 씁니다. 오래된 프롬프트라도 없는 것보다는 낫습니다.

일반 캐시 키는 TTL이 지나면 Redis에서 사라지므로 그 값을 다시 읽을 수 없습니다. 이 구현은 정상 조회 때 lkg::{cacheKey}에도 같은 값을 저장하고, 일반 캐시와 달리 자동 만료시키지 않습니다. 대신 사용한 프롬프트 버전과 저장 시각을 함께 기록하고 운영 정책에 맞는 최대 허용 기간을 조회 시점에 검사해야, 오래된 값이 무기한 사용되는 것을 막을 수 있습니다.

미구현 단계 — 코드 내 fallback 프롬프트: Redis와 Langfuse를 모두 사용할 수 없을 때 PromptId.fallbackPrompt()로 내려가는 경로를 설계했지만, 현재 서비스에는 호출 지점이 연결돼 있지 않습니다. 따라서 아래 표도 실제 동작을 기준으로 작성했습니다.

장애 유형별 처리 정책#

장애 상황동작사용자 영향
Langfuse 일시 장애Redis 캐시 정상 → 영향 없음없음
Langfuse 장기 장애캐시 만료 후 LKG 캐시 사용프롬프트가 최신이 아닐 수 있음
Redis 장애매 요청마다 Langfuse API 호출latency 증가
Redis와 Langfuse 동시 장애LKG 조회도 실패해 프롬프트 조회 중단해당 기능 실패

LKG가 남아 있는 동안에는 Langfuse 장애를 요청 경로에서 격리할 수 있지만, Redis까지 동시에 사용할 수 없으면 현재 구현은 프롬프트 조회에 실패합니다. 코드 fallback을 실제 방어선으로 사용하려면 설계 문서에만 두지 않고 PromptService의 마지막 분기에 연결한 뒤 계약 테스트로 확인해야 합니다.

전체 아키텍처#

1편과 2편을 합치면 최종 구조는 이렇게 됩니다.

Loading diagram...

비즈니스 서비스는 LLMService 하나만 알면 됩니다. 그 아래에서 프롬프트 캐싱, 동시성 제어, fallback, 트레이싱이 전부 알아서 돌아갑니다.

운영에서 선택한 트레이드오프#

이 구조는 프롬프트의 즉시 반영보다 요청 경로의 지연과 외부 의존성을 줄이는 데 우선순위를 뒀습니다. Redis 캐시를 사용하면 production 라벨이 변경된 직후에도 짧은 시간 동안 이전 버전이 제공될 수 있지만, 승인된 프롬프트만 운영에 승격한다는 전제에서는 매 요청마다 cross-region 조회를 반복하는 비용보다 감수할 수 있는 지연이라고 판단했습니다. 실제 호출에 사용한 프롬프트 버전을 메타데이터에 남겨 이 구간의 결과도 추적할 수 있게 했습니다.

LKG를 프롬프트 장애 대응 전략으로 선택한 이유는 Langfuse에서 새 버전을 조회하지 못하더라도 마지막으로 정상 확인된 프롬프트로 기능을 이어갈 수 있기 때문입니다. 다만 장애가 길어질수록 현재 운영 버전과 차이가 생길 수 있으므로, 최신성보다 가용성이 중요한 기능에만 적용했습니다.

분산 락 역시 강한 정합성을 보장하기보다 여러 pod의 중복 갱신과 cross-region 호출을 줄이는 목적으로 사용했습니다. 락이 보호하는 구간에서는 비즈니스 데이터를 변경하지 않으므로, 중복 실행의 영향은 같은 프롬프트를 다시 조회하고 캐시를 갱신하는 수준에 머뭅니다. 결제나 재고처럼 한 번의 중복 실행이 데이터 정합성에 영향을 주는 작업이라면 이와 다른 락 구성과 장애 정책이 필요합니다.

마무리하며#

이번 편에서는 Redis 캐시와 동시성 제어, LKG를 결합해 Langfuse 프롬프트 조회가 LLM 요청의 지연과 가용성에 미치는 영향을 줄였습니다. 정상 요청은 같은 VPC의 Redis를 기본 경로로 사용하고, Langfuse 호출은 최초 적재와 백그라운드 갱신으로 제한했습니다.

여기까지는 하나의 provider를 선택해 호출하는 실행 경로를 대상으로 합니다. 그러나 운영 중인 provider에 rate limit이나 장애가 발생하면 캐시만으로는 LLM 호출 자체를 보호할 수 없습니다. 다음 글에서는 OpenAI 호출 실패 시 Anthropic으로 전환하는 Provider Fallback과, 전환 이후 실제로 응답한 provider를 추적하는 방법을 다룹니다.


참고 자료

Connected Notes