dev notes

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

2026-04-0724 min read
공유

1편의 한계#

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

첫째, 매 요청마다 Langfuse API를 호출합니다. 같은 프롬프트를 계속 가져오는데, 네트워크를 타니까 요청당 ~500ms가 추가됩니다. Redis에서 가져오면 ~1ms인 걸 매번 외부 API를 찌르는 건 낭비입니다.

둘째, 캐시가 없으니 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;
    });
}

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

로컬 락은 같은 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을 반환합니다. 분산 락은 비동기 갱신에서만 쓰기 때문에, 락을 못 잡으면 그냥 이번 갱신을 건너뛰면 됩니다. 다른 인스턴스가 하고 있을 테니까요.

로컬 락 + 분산 락 조합#

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

상황사용하는 락이유
캐시 미스 시 동기 조회로컬 락 (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를 다시 호출해서 다음 갱신도 예약합니다. 체인처럼 이어지는 구조라서, 최초 캐시 로드 이후에는 계속 백그라운드에서 돌아갑니다.

스케줄러 설정#

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, 트레이싱이 전부 알아서 돌아갑니다.

다음 편#

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

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


참고 자료

Connected Notes