dev notes

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

2026-04-0733 min read
공유

1편의 한계#

이전 편에서 Langfuse 기본 통합까지는 붙였습니다. 프롬프트 조회, 트레이싱, NoOp 패턴까지 넣어서 기능적으로는 돌아갔는데, 그 구조 그대로 프로덕션에 올리기엔 바로 걸리는 지점이 세 가지 있었습니다.

첫째, 매 요청마다 Langfuse API를 호출합니다. 같은 프롬프트를 계속 가져오는데, 네트워크를 타니까 요청당 수백 ms가 추가됩니다. 그런데 여기엔 단순한 '연산 절약' 이상의 이유가 숨어 있었습니다. 우리가 쓰는 관측 SaaS는 해외 리전에 호스팅돼 있어서, 프롬프트를 한 번 가져올 때마다 KR 리전에서 해외 리전까지 왕복하는 cross-region 호출이 발생합니다. Datadog으로 30일치 outbound span을 뒤져보니 이 왕복 지연이 128553ms로 찍혔습니다(120ms대 RTT가 기본으로 깔려 있습니다). 즉 캐시 미스 1건은 곧 128553ms짜리 태평양 왕복 1건입니다. Redis에서 꺼내면 ~1ms인 걸 매번 대륙 건너 찌르고 있었던 셈이라, 이 캐시는 CPU 연산을 아끼는 최적화라기보다 cross-region latency 자체를 없애는 작업에 가깝습니다.

둘째, 캐시가 없으니 Thundering Herd가 발생합니다. 트래픽이 몰리는 시간대에 같은 프롬프트를 100개 스레드가 동시에 요청하면, Langfuse API에 100번 호출이 갑니다. 사실상 같은 데이터를 100번 가져오는 셈입니다.

셋째, Langfuse가 SPOF가 됩니다. Langfuse 서버가 잠깐이라도 장애가 나면 프롬프트를 못 가져오고, LLM 호출 자체가 멈춥니다. 외부 의존성 하나 때문에 전체 서비스가 멈추는 건 프로덕션에서 용납할 수 없습니다.

이번 편은 그 문제들을 Redis 캐싱 + 동시성 제어 + fallback 전략으로 눌러가는 과정이라고 보면 됩니다.

Redis 캐싱 기본 구현#

가장 먼저 해야 할 건, Langfuse에서 가져온 프롬프트를 Redis에 캐싱하는 겁니다.

캐시 키 설계#

langfuse-prompt::{promptName}:{label}

프롬프트 이름과 라벨을 조합해서 캐시 키를 만듭니다. 같은 프롬프트라도 development 라벨과 production 라벨은 다른 버전일 수 있으니까, 라벨까지 키에 포함해야 합니다.

CachedPrompt — 캐시에 저장할 데이터#

java
@Getter
@NoArgsConstructor
@AllArgsConstructor(staticName = "of")
public class CachedPrompt {
    private String promptText;
    private PromptConfigData config;
    private Integer version;
}

여기서 record가 아니라 class로 만든 이유가 있습니다. Redis에 JSON으로 직렬화할 때 GenericJackson2JsonRedisSerializer를 쓰는데, 이 직렬화기는 DefaultTyping.NON_FINAL 옵션으로 클래스 타입 정보를 JSON에 함께 저장합니다. 문제는 record는 final 클래스라서 NON_FINAL 타이핑이 적용되지 않고, 역직렬화할 때 타입 정보가 없어서 실패합니다.

처음에 record로 만들었다가 Redis에서 꺼낼 때 LinkedHashMap으로 역직렬화되는 버그를 만난 적이 있어서, 캐시용 DTO는 일반 class로 만들게 됐습니다.

RedisTemplate 분리#

java
@Bean
public RedisTemplate<String, Object> langfuseRedisTemplate(
    RedisConnectionFactory connectionFactory) {
 
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(connectionFactory);
    template.setKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(
        new GenericJackson2JsonRedisSerializer(objectMapper()));
    template.setHashKeySerializer(new StringRedisSerializer());
    template.setHashValueSerializer(
        new GenericJackson2JsonRedisSerializer(objectMapper()));
    return template;
}
 
private ObjectMapper objectMapper() {
    ObjectMapper mapper = new ObjectMapper();
    mapper.registerModule(new Jdk8Module());
    mapper.activateDefaultTypingAsProperty(
        mapper.getPolymorphicTypeValidator(),
        ObjectMapper.DefaultTyping.NON_FINAL,
        "type"
    );
    return mapper;
}

Langfuse 전용 RedisTemplate을 별도 빈으로 만드는 이유는, 다른 캐시와 직렬화 설정이 다르기 때문입니다. NON_FINAL 타이핑을 전역으로 켜면 다른 캐시에서 불필요한 타입 정보가 붙어서 호환성 문제가 생길 수 있습니다.

캐시 서비스 — 기본 버전#

java
@Service
@RequiredArgsConstructor
public class LangfusePromptCacheService {
 
    private static final String CACHE_PREFIX = "langfuse-prompt::";
 
    private final RedisTemplate<String, Object> langfuseRedisTemplate;
    private final LangfuseClient langfuseClient;
 
    @Value("${langfuse.cache.ttl-seconds:1800}")
    private int ttlSeconds;
 
    public CachedPrompt getCachedPromptByLabel(String promptName, PromptLabel label) {
        String cacheKey = CACHE_PREFIX + promptName + ":" + label.getValue();
 
        // 캐시에서 조회
        CachedPrompt cached = getFromCache(cacheKey);
        if (cached != null) {
            return cached;
        }
 
        // 캐시 미스 → Langfuse API 호출
        CachedPrompt fetched = fetchFromLangfuse(promptName, label);
        putToCache(cacheKey, fetched);
        return fetched;
    }
 
    private CachedPrompt getFromCache(String key) {
        try {
            return (CachedPrompt) langfuseRedisTemplate.opsForValue().get(key);
        } catch (Exception e) {
            log.warn("[LangfuseCache] 캐시 조회 실패: {}", key, e);
            return null;
        }
    }
 
    private void putToCache(String key, CachedPrompt value) {
        try {
            langfuseRedisTemplate.opsForValue()
                .set(key, value, Duration.ofSeconds(ttlSeconds));
        } catch (Exception e) {
            log.warn("[LangfuseCache] 캐시 저장 실패: {}", key, e);
        }
    }
}

캐시 읽기/쓰기 모두 try-catch로 감싸는 게 중요합니다. Redis가 죽어도 Langfuse API 호출로 넘어가야 하니까요. 캐시는 성능 최적화일 뿐, 캐시 실패가 비즈니스 로직을 중단시키면 안 됩니다.

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

동시성 제어 — Thundering Herd 방지#

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

Loading diagram...

Double-Checked Locking#

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

java
private final ConcurrentHashMap<String, ReentrantLock> lockMap = new ConcurrentHashMap<>();
 
public CachedPrompt getCachedPromptByLabel(String promptName, PromptLabel label) {
    String cacheKey = CACHE_PREFIX + promptName + ":" + label.getValue();
 
    // 1차 체크: 락 없이 빠른 경로
    CachedPrompt cached = getFromCache(cacheKey);
    if (cached != null) {
        return cached;
    }
 
    // 락 획득
    ReentrantLock lock = lockMap.computeIfAbsent(cacheKey, k -> new ReentrantLock());
    lock.lock();
    try {
        // 2차 체크: 다른 스레드가 이미 캐시를 채웠는지 확인
        cached = getFromCache(cacheKey);
        if (cached != null) {
            return cached;
        }
 
        // 이 스레드만 API 호출
        CachedPrompt fetched = fetchFromLangfuse(promptName, label);
        putToCache(cacheKey, fetched);
        return fetched;
    } finally {
        lock.unlock();
    }
}

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

Loading diagram...

메모리 누수 방지#

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

이걸 해결하려면 미사용 락을 주기적으로 정리해야 합니다.

java
public class PromptLocalLockManager {
 
    private static final long LOCK_EXPIRY = Duration.ofMinutes(10).toMillis();
    private static final long CLEANUP_INTERVAL = Duration.ofMinutes(5).toMillis();
 
    private final ConcurrentHashMap<String, LockEntry> keyLocks = new ConcurrentHashMap<>();
    private final ScheduledExecutorService cleanupScheduler;
 
    private record LockEntry(ReentrantLock lock, AtomicLong lastUsed) {
        static LockEntry create() {
            return new LockEntry(new ReentrantLock(), new AtomicLong(System.currentTimeMillis()));
        }
 
        void touch() {
            lastUsed.set(System.currentTimeMillis());
        }
 
        boolean isExpired() {
            return System.currentTimeMillis() - lastUsed.get() > LOCK_EXPIRY;
        }
    }
 
    public PromptLocalLockManager() {
        this.cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
            Thread t = new Thread(r, "langfuse-lock-cleanup");
            t.setDaemon(true);
            return t;
        });
        cleanupScheduler.scheduleAtFixedRate(
            this::cleanupUnusedLocks,
            CLEANUP_INTERVAL, CLEANUP_INTERVAL, TimeUnit.MILLISECONDS
        );
    }
 
    public <T> T executeWithLock(String key, Supplier<T> action) {
        LockEntry entry = keyLocks.computeIfAbsent(key, k -> LockEntry.create());
        entry.touch();
        entry.lock().lock();
        try {
            return action.get();
        } finally {
            entry.lock().unlock();
        }
    }
 
    private void cleanupUnusedLocks() {
        keyLocks.entrySet().removeIf(e ->
            e.getValue().isExpired() && !e.getValue().lock().isLocked()
        );
    }
}

LockEntry에 마지막 사용 시간을 기록해두고, 5분마다 돌면서 10분 이상 미사용된 락을 제거합니다. isLocked() 체크도 같이 하는 이유는, 만료 시간이 지났더라도 현재 누군가가 잡고 있는 락을 제거하면 안 되니까요.

캐시 서비스에서는 이렇게 씁니다:

java
public CachedPrompt getCachedPromptByLabel(String promptName, PromptLabel label) {
    String cacheKey = CACHE_PREFIX + promptName + ":" + label.getValue();
 
    // 1차 체크
    CachedPrompt cached = getFromCache(cacheKey);
    if (cached != null) return cached;
 
    // 2차 체크 + API 호출 (락 내부)
    return lockManager.executeWithLock(cacheKey, () -> {
        CachedPrompt rechecked = getFromCache(cacheKey);
        if (rechecked != null) return rechecked;
 
        CachedPrompt fetched = fetchFromLangfuse(promptName, label);
        putToCache(cacheKey, fetched);
        return fetched;
    });
}

숫자로 확인 — stampede가 정말 사라지는가#

'100개가 와도 1번'이라는 말은 그럴듯하지만, 실제로 눌리는지는 재봐야 압니다. 운영에 올라가 있는 PromptLocalLockManager를 그대로(verbatim) 떼어다 격리 하네스에 올리고, 콜드 상태(캐시가 비어 있음)에서 동시 200요청을 때렸습니다.

  • 실제 로더 호출: 200 → 1. 200개 스레드가 동시에 캐시 미스를 냈지만, Langfuse를 실제로 부른 건 딱 1번이었습니다. stampede가 그대로 사라졌습니다.
  • 대기 스레드 convoy p99 ≈ 807ms. 대신 나머지 199개는 락 앞에 줄을 섭니다. 선두 스레드가 로더(cross-region 왕복 포함)를 끝낼 때까지 전부 대기하기 때문에, 로더 지연이 그대로 꼬리 지연으로 튑니다. 동시 500요청으로 올려도 패턴은 같았습니다 — 로더 호출 1번, p99 811ms.

여기서 트레이드오프가 드러납니다. Double-Checked Locking은 API 호출 수를 N→1로 줄이는 대신, 대기 스레드를 convoy(호송대)로 묶습니다. 선두가 느리면 뒤가 전부 같이 느려집니다. 이 convoy를 어떻게 풀지가 뒤에 나올 비동기 갱신의 출발점입니다.

이 절과 뒤에 나오는 부하·락 측정 수치는 운영 코드를 verbatim으로 떼어다 격리 하네스에서 부하를 준 값입니다. 프로덕션 트래픽을 직접 모니터링한 값이 아닙니다. 반면 앞서 서두에서 나온 cross-region 128~553ms는 Datadog으로 잡은 30일치 프로덕션 outbound 실측이라 성격이 다릅니다 — 이건 실제 운영 트래픽에서 관측된 값입니다. 참고로 실제 프로덕션 부하 자체는 순간 대폭증(thundering herd)형이라기보다, 성수기에 지속되는 고부하 쪽에 가깝습니다.

분산 락 — 멀티 인스턴스 환경#

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

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

Lua 스크립트 기반 분산 락#

java
@Service
@RequiredArgsConstructor
public class LangfuseDistributedLockService {
 
    private final StringRedisTemplate redisTemplate;
 
    @Value("${langfuse.cache.ttl-seconds:1800}")
    private int cacheTtl;
 
    private static final String LOCK_SCRIPT = """
        if redis.call('set', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then
            return 1
        else
            return 0
        end
        """;
 
    private static final String UNLOCK_SCRIPT = """
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
        """;
 
    public String tryLock(String key, int waitTimeSeconds) {
        String lockValue = UUID.randomUUID().toString();
        int lockTtl = cacheTtl + 10;  // 캐시 TTL보다 10초 길게
 
        long deadline = System.currentTimeMillis() + (waitTimeSeconds * 1000L);
 
        while (System.currentTimeMillis() < deadline) {
            Long result = redisTemplate.execute(
                new DefaultRedisScript<>(LOCK_SCRIPT, Long.class),
                List.of(key),
                lockValue, String.valueOf(lockTtl)
            );
 
            if (result != null && result == 1) {
                return lockValue;  // 락 획득 성공
            }
 
            try {
                Thread.sleep(100);  // 100ms 후 재시도
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return null;
            }
        }
        return null;  // 타임아웃
    }
 
    public void unlock(String key, String lockValue) {
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class),
            List.of(key),
            lockValue
        );
 
        if (result == null || result == 0) {
            log.warn("[DistributedLock] 락 해제 실패 — 이미 만료되었거나 다른 인스턴스가 보유: {}", key);
        }
    }
}

몇 가지 설계 결정을 짚어보면:

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

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

락 TTL = 캐시 TTL + 10초 — 캐시 갱신 작업이 끝나기 전에 락이 먼저 풀리면 안 되니까, 넉넉하게 잡습니다. 만약 프로세스가 비정상 종료되더라도 TTL이 지나면 락이 자동으로 풀려서 데드락이 발생하지 않습니다.

실패 시 null 반환 — 예외를 던지지 않고 null을 반환합니다. 분산 락은 비동기 갱신에서만 쓰기 때문에, 락을 못 잡으면 그냥 이번 갱신을 건너뛰면 됩니다. 다른 인스턴스가 하고 있을 테니까요.

이 Lua 락도 하네스에서 확인했습니다. 인스턴스 8개가 같은 프롬프트를 동시에 갱신하겠다고 달려드는 상황을 만들었더니:

  • 8개 중 락 획득은 1개. SET NX가 한 번만 성공하고, 나머지 7개는 0을 받고 조용히 물러났습니다. 갱신은 정확히 1번만 실행됐습니다.
  • TTL 자동 만료. 락을 잡은 채로 프로세스가 죽는 상황을 흉내내도, TTL이 지나자 락이 스스로 풀려서 다음 갱신이 정상 진행됐습니다. 데드락이 남지 않습니다.
  • 남의 락은 못 푼다. 다른 인스턴스가 잡은 값을 들고 unlock을 시도하면 UNLOCK_SCRIPT가 거부했습니다. GET 비교에서 값이 다르니 DEL을 안 하는 거죠. UUID 소유권 증명이 실제로 동작한다는 확인입니다.

로컬 락 + 분산 락 조합#

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

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

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

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

비동기 캐시 갱신#

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

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

Loading diagram...

TTL이 30초라면, 25초 시점에 비동기 갱신을 스케줄합니다. 25~30초 사이에 들어오는 요청은 기존 캐시로 즉시 응답하고, 백그라운드에서 새 값을 가져와서 캐시를 교체합니다.

구현#

java
private final ScheduledExecutorService cacheRefreshScheduler;
private final Set<String> scheduledRefreshKeys = ConcurrentHashMap.newKeySet();
 
private void scheduleAsyncCacheRefresh(String cacheKey, String promptName,
                                        PromptLabel label, int ttl) {
    if (ttl <= 5) return;  // TTL이 5초 이하면 비동기 갱신 의미 없음
 
    // 이미 스케줄된 키면 중복 등록 방지
    if (!scheduledRefreshKeys.add(cacheKey)) return;
 
    int delaySeconds = ttl - 5;
 
    cacheRefreshScheduler.schedule(() -> {
        try {
            executeAsyncCacheRefresh(cacheKey, promptName, label);
        } finally {
            scheduledRefreshKeys.remove(cacheKey);
        }
    }, delaySeconds, TimeUnit.SECONDS);
}

scheduledRefreshKeys는 현재 스케줄된 키를 추적하는 Set입니다. 같은 프롬프트에 대해 갱신이 중복으로 스케줄되는 걸 방지합니다. ConcurrentHashMap.newKeySet()으로 만들어서 스레드 안전합니다.

java
private void executeAsyncCacheRefresh(String cacheKey, String promptName,
                                       PromptLabel label) {
    // 분산 락 획득 (1초 대기)
    String lockKey = "lock::" + cacheKey;
    String lockValue = distributedLockService.tryLock(lockKey, 1);
 
    if (lockValue == null) {
        log.debug("[LangfuseCache] 다른 인스턴스가 갱신 중: {}", cacheKey);
        return;  // 다른 인스턴스가 하고 있으니 건너뜀
    }
 
    try {
        CachedPrompt fetched = fetchFromLangfuse(promptName, label);
        putToCache(cacheKey, fetched);
        log.info("[LangfuseCache] 비동기 갱신 완료: {}", cacheKey);
 
        // 다음 갱신도 스케줄
        scheduleAsyncCacheRefresh(cacheKey, promptName, label, ttlSeconds);
    } catch (Exception e) {
        log.warn("[LangfuseCache] 비동기 갱신 실패: {}", cacheKey, e);
        // 실패해도 기존 캐시가 남아있으니 서비스에 영향 없음
    } finally {
        distributedLockService.unlock(lockKey, lockValue);
    }
}

비동기 갱신에서 분산 락을 쓰는 이유가 여기서 나옵니다. pod이 3개 있으면 3개 모두 같은 시점에 갱신을 스케줄하는데, 분산 락으로 하나만 실행되게 합니다. 나머지 2개는 tryLock에서 null을 받고 조용히 넘어갑니다.

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

효과 측정 — 만료 케이스와 콜드 케이스는 다르다#

비동기 갱신이 실제로 꼬리 지연을 잡는지도 하네스에서 확인했는데, 여기서 중요한 건 캐시가 만료된 경우와 아예 비어 있는 콜드 케이스가 완전히 다르게 동작한다는 점입니다.

  • 만료 케이스(stale 값이 남아 있음): p99 809 → 219ms, 실패 0건. stale-while-revalidate가 빛나는 지점입니다. 만료됐어도 직전 값이 남아 있으니 요청은 그 stale 값으로 즉시 응답하고(219ms), 갱신은 백그라운드로 넘깁니다. 앞서 본 809ms convoy가 3.7배 줄었고, 실패한 요청은 하나도 없었습니다.
  • 콜드 케이스(stale 값이 없음): 대기 807 → 206ms 경계, 단 199건 fail-fast. 콜드에서는 SWR을 쓸 수 없습니다. 내놓을 stale 값 자체가 없으니까요. lock.lock() 대신 tryLock으로 대기를 경계 지으면 선두를 뺀 나머지는 빠르게 실패로 떨어집니다 — 206ms 안에 응답이 오긴 하지만, 그중 199건은 값이 아니라 실패(fail-fast)입니다.

정리하면 SWR은 '한 번이라도 캐시를 채운 뒤'에만 convoy를 없애줍니다. 콜드 스타트의 첫 파도는 여전히 누군가는 cross-region 왕복을 기다리거나(로컬 락), 빠르게 실패를 받거나(tryLock) 둘 중 하나를 골라야 하는 트레이드오프가 남습니다. 완전히 공짜인 해법은 없습니다.

스케줄러 설정#

java
@Bean
public ScheduledExecutorService cacheRefreshScheduler() {
    ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(2);
    executor.setThreadFactory(r -> {
        Thread t = new Thread(r, "langfuse-cache-refresh");
        t.setDaemon(true);
        return t;
    });
    executor.setRemoveOnCancelPolicy(true);
    return executor;
}

Daemon 스레드로 설정하는 이유는, 애플리케이션 종료 시 이 스레드 때문에 프로세스가 안 죽는 걸 방지하기 위해서입니다. setRemoveOnCancelPolicy(true)는 취소된 작업을 큐에서 즉시 제거해서 메모리를 아낍니다.

장애 시나리오 — 3단계 fallback#

1편에서 라벨 fallback 체인을 소개했는데, 캐싱과 결합하면 전체 fallback 구조가 3단계로 확장됩니다.

Loading diagram...

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

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

java
private CachedPrompt getLastKnownGood(String cacheKey) {
    try {
        // TTL 만료된 캐시에서 읽기 시도
        // (별도의 LKG 저장소를 쓰거나, TTL 없는 백업 키를 유지)
        return (CachedPrompt) langfuseRedisTemplate.opsForValue()
            .get("lkg::" + cacheKey);
    } catch (Exception e) {
        return null;
    }
}

LKG를 구현하는 방법은 여러 가지인데, 가장 단순한 건 캐시를 저장할 때 TTL 없는 백업 키에도 함께 저장하는 겁니다. 메모리를 좀 더 쓰지만 구현이 간단합니다.

3단계 — 코드 내 fallback 프롬프트: Redis도 Langfuse도 안 되면, 1편에서 만든 PromptId enum의 fallbackPrompt()가 사용됩니다. 코드에 하드코딩된 프롬프트라서 외부 의존성이 전혀 없습니다.

장애 유형별 동작#

장애 상황동작사용자 영향
Langfuse 일시 장애Redis 캐시 정상 → 영향 없음없음
Langfuse 장기 장애캐시 만료 후 LKG 캐시 사용프롬프트가 최신이 아닐 수 있음
Redis 장애매 요청마다 Langfuse API 호출latency 증가
둘 다 장애코드 fallback 프롬프트 사용동작하지만 프롬프트가 기본값
셋 다 실패 (fallback도 없음)빈 결과 반환해당 기능 비활성화

어떤 조합으로 장애가 나도 서비스 자체가 멈추지는 않습니다. 프롬프트의 최신성이 떨어질 수 있지만, 사용자 입장에서는 기능이 돌아가는 게 중요합니다.

전체 아키텍처#

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

Loading diagram...

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

남은 한계#

여기까지가 현재 운영에 올라가 있는 구조인데, 솔직히 아직 덜 다듬은 부분이 있습니다.

CircuitBreaker가 없습니다. 위의 3단계 fallback은 '실패하면 다음 단계로 내려간다'는 방어이지, Langfuse가 계속 죽어 있을 때 호출을 아예 차단하는 회로차단기는 아닙니다. 장기 장애면 매 갱신마다 타임아웃까지 기다렸다가 fallback으로 떨어지는 비용을 계속 냅니다. 여기에 CircuitBreaker를 얹어 'open' 상태에서 호출을 건너뛰게 하는 건 아직 숙제로 남아 있습니다.

동기 콜드미스 경로의 로컬 락은 무한 대기입니다. executeWithLocktryLock이 아니라 lock.lock()을 씁니다. 즉 선두 스레드가 끝날 때까지 대기 스레드는 상한 없이 기다립니다. 그런데 이게 완전히 무방비는 아닌 게, 선두 스레드가 부르는 Langfuse 클라이언트에 10초 타임아웃이 걸려 있어서 그게 사실상 락 홀드 시간의 상한 역할을 합니다. 의도한 설계라기보다 '타임아웃이 경계를 대신 잡아주고 있는' 상태에 가까워서, 락 자체에 대기 상한을 두는 게 더 정직한 방향입니다.

분산 락은 Redlock이 아니라 단일 Redis입니다. 여러 노드로 락을 복제하는 Redlock을 쓰지 않은 건, 이 락이 지키는 게 correctness가 아니기 때문입니다. 분산 락이 아주 드물게 깨져서 두 인스턴스가 동시에 갱신하더라도, 결과는 '같은 프롬프트를 두 번 가져와 같은 값으로 덮어쓰는' 것뿐입니다. 데이터가 깨지지 않습니다. 여기서 락의 목적은 갱신 중복으로 인한 불필요한 cross-region 호출을 줄이는 것이지 상호배제 보장이 아니라서, 단일 Redis로 충분하다고 판단했습니다. 만약 correctness가 걸린 락이었다면 이 선택은 틀린 선택이 됐을 겁니다.

다음 편#

여기까지 오면 단일 LLM 호출은 꽤 안정적으로 굴러갑니다. 다만 실제 서비스에서는 한 번에 처리하기 어려운 대용량 텍스트가 들어오기도 하고, OpenAI가 흔들리면 Anthropic으로 넘겨야 하는 상황도 생깁니다.

다음 편에서는 MapReduce 체인(대용량 텍스트 분할 처리), Adaptive Chain(자동 전략 선택), Provider Fallback(OpenAI ↔ Anthropic 전환) 쪽으로 이어집니다.


참고 자료

Connected Notes