🚚 허브 간 최단 경로 탐색 캐시 스탬피드 (Cache Stampede) 발생+해결

2026. 1. 19. 15:25·Spring Boot
이전 문제 상황
  • 출발지와 목적지 사이 허브 간 경로를 다익스트라 알고리즘으로 선정 후, Kakao Api를 호출해 허브 간 소요 시간 산출
  • 이때 Kakao Api 응답을 기다리는 방식의 경우 16 ~ 20 초의 많은 시간이 걸림

 

 

 

첫 번째 해결

- Redis를 도입하여 TTL 5분이 지나지 않은 값은 Redis에 값을 꺼내 사용 (Redis TTL 5분, DB TTL 10분)

- Redis TTL 5분이 지난 경우 DB 조회 후 Redis에 값 갱신 (이때 우선 Redis에 갱신 후 Kakao Api를 호출하여 최신 값으로 갱신)

- Redis와 DB 모두 값이 없는 Cold Start의 경우 Kakao Api 호출 후 값 갱신

 

RedisHubEdgeCache

@Component
@RequiredArgsConstructor
public class RedisHubEdgeCache {

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper om;

    public Optional<HubInfo> get(String key){
        String value = redisTemplate.opsForValue().get(key);
        if(value == null){
            return Optional.empty();
        }
        try {
            return Optional.of(om.readValue(value, HubInfo.class));
        } catch (Exception e) {
            redisTemplate.delete(key);
            return Optional.empty();
        }
    }

    public void add(String key, HubInfo hubInfo, Duration ttl){
        try {
            redisTemplate.opsForValue().set(key, om.writeValueAsString(hubInfo), ttl);
        } catch (Exception ignored) {
            // 캐시 저장 실패는 복구 불가능 오류가 아니므로 실패를 시키지 않음
        }
    }

}

 

@Service
@Slf4j
@RequiredArgsConstructor
public class HubEdgeWeightService implements HubEdgeWeightProvider {

    private final Duration redisTtl = Duration.ofMinutes(5);

    private final HubInfoRepository hubInfoRepository;
    private final HubRepository hubRepository;
    private final KakaoMapClient kakaoMapClient;
    private final RedisHubEdgeCache redisHubEdgeCache;
    private final CacheStats cacheStats;

    @Override
    @Transactional
    public EdgeWeight getWeight(UUID startHubId, UUID endHubId) {

        // 1) redis 조회 (5분인 상태)
        String keys = HubEdgeCacheKeys.edgeKeys(String.valueOf(startHubId), String.valueOf(endHubId));
        Optional<HubInfo> optionalHubInfo = redisHubEdgeCache.get(keys);
        if (optionalHubInfo.isPresent()) {
            cacheStats.hit();
            log.info("redis Cache hit");
            HubInfo hubInfo = optionalHubInfo.get();
            return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
        }
        cacheStats.miss();

        // 2) DB TTL인 10분 이내의 캐시값이 있으면 기존 값 그대로 사용
        HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
            .orElseThrow(()-> new BusinessException(ErrorCode.HUB_INFO_NOT_FOUND));
        LocalDateTime now = LocalDateTime.now();

        if (hubInfo.getDeliveryDuration() != null && hubInfo.getDistance() != null && !hubInfo.checkUpdateTime(now)) {

            redisHubEdgeCache.add(keys, hubInfo, redisTtl);
            log.info("[HubEdgeWeight] cache hit: {} -> {} ({}h {}m, {} km)",
                startHubId, endHubId, hubInfo.getDeliveryDuration()/3600, hubInfo.getDeliveryDuration() % 3600 / 60, hubInfo.getDistance());

            return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
        }

        // 3) DB에도 없다면 Kakao API로 실제 거리/시간 계산
        // 허브 존재 여부 확인
        Hub startHub = hubRepository.findById(hubInfo.getStartHubId())
            .orElseThrow(() -> new BusinessException(ErrorCode.HUB_NOT_FOUND));
        Hub endHub = hubRepository.findById(hubInfo.getEndHubId())
            .orElseThrow(() -> new BusinessException(ErrorCode.HUB_NOT_FOUND));

        // 해당 허브들에 위도, 경도값 추출
        String origin = startHub.getLongitude() + "," + startHub.getLatitude();
        String destination = endHub.getLongitude() + "," + endHub.getLatitude();

        DirectionInfoResponseV1 direction = kakaoMapClient.getDirection(
            origin,
            destination,
            2,          // carType
            "DIESEL",   // carFuel
            true        // carHipass
        );

        // Kakao 응답은 초 단위
        int durationSec = (int) direction.duration();
        BigDecimal distanceKm = BigDecimal
            .valueOf(direction.distance() / 1000.0)
            .setScale(3, RoundingMode.HALF_UP);

        hubInfo.updateDeliveryInfo(durationSec, distanceKm);
        // redis의 값은 after commit
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                redisHubEdgeCache.add(keys, hubInfo, redisTtl);
            }
        });

        log.info("[HubEdgeWeight] cache hit: {} -> {} ({}h {}m, {} km)",
            startHubId, endHubId, hubInfo.getDeliveryDuration() / 3600, hubInfo.getDeliveryDuration() % 3600 / 60, hubInfo.getDistance());

        return new EdgeWeight(startHubId, endHubId, durationSec, distanceKm);
    }
}

 

 

 

 

- 부하 테스트 걸기 전 캐시 miss와 hit 갯수를 알아보기 위해 CacheStats 작성

@Component
public class CacheStats {
    private final LongAdder hit = new LongAdder();
    private final LongAdder miss = new LongAdder();

    public void hit() { hit.increment(); }
    public void miss() { miss.increment(); }

    public long hits() { return hit.sum(); }
    public long misses() { return miss.sum(); }
}

 

 

CacheStatsEndPointConfig

- 캐시가 제대로 효율을 내고 있는지 운영 중에 확인하기 위해 사용

implementation 'org.springframework.boot:spring-boot-starter-actuator'

- 엔드포인트 설정

management:
  endpoints:
    web:
      exposure:
        include: cache-stats
@Component
@Endpoint(id = "cache-stats")
@RequiredArgsConstructor
public class CacheStatsEndPointConfig {

    private final CacheStats cacheStats;

    @ReadOperation
    public Map<String, Object> stats() {
        long hit = cacheStats.hits();
        long miss = cacheStats.misses();
        long total = hit+miss;

        double hitRate = (total == 0) ? 0.0 : (hit * 100.0 / total);

        return Map.of("hits", hit, "misses", miss, "total", total, "hitRate", hitRate);
    }
}

 

 

 

 

 

 

Jemeter 부하 테스트

- 동시 사용자 200명 × 사용자당 100회 요청 (총 20,000 requests)의 예시

- 결과 : p95: 850ms, p99: 13.4s, 평균 448ms

- 기존 16초 걸리던 상황보다 16,000ms → 488ms 👉 약 32배 개선

 

 

그러나 Redis TTL  5분 만료 시점에 miss가 동시 다발적으로 발생하여 지연 시간이 스파이크 모양으로 튀는 모습

 

 

 

http://localhost:19093/actuator/cache-stats에 접속 시 miss와 hit 개수 & 퍼센트를 알 수 있다.

 

 

 

 

 

이 코드의 문제

Redis TTL 끝나는 타이밍에 트래픽이 몰리면, 들어온 모든 요청이 DB에 한번에 몰려
캐시 스탬피드(cache stampede) 발생

 

 

[ 캐시 스탬피드 (Cache Stampede) ]

  • 여러 요청이 동시에 캐시 미스가 나면서, 원본 데이터(DB·외부 API)에 한꺼번에 몰려가는 현상
  • 200명이 동시 요청으로 몰릴 시 Redis에 값이 있지만 TTL이 만료 된 경우 lock으로 한 명의 사용자만 DB에서 값을 가져오거나 DB 값도 TTL이 만료 되었다면 외부 Api(Kakao Api)를 호출해야 함
    -> 나머지 199명에게는 만료된 값을 주거나 일정 시간 & 일정 횟수만큼 재시도 처리 후 실패 처리 등 처리 필요

 

 

 

 

 

목표

- 라즈베리파이나 프리티어 AWS로 서버를 돌릴 경우 낮은 서버 사양과 Kakao Api를 보내는 하루 & 월 사용 제한이 있으므로 스케줄링을 사용하여 항상 최신의 응답을 캐시에 넣을 시 많은 비용이 발생

- 비용 절감을 위해 스케줄링 사용 없이 사용자의 요청이 있을 때만 외부 Api를 전송하여 최신의 값을 제공해야 함

 

저장소의 TTL 분리

  • Redis TTL (10분)
  • DB TTL (15분)

응답 원칙

  • Redis가 있으면 무조건 Redis 먼저 사용
  • Redis hit이면 항상 즉시 응답
    • 단, Redis TTL이 5분 이하로 남아있으면
      → 응답은 그대로 주되, refreshLock(single-flight) 성공한 1명만 외부 API로 백그라운드 갱신(refresh-ahead)
  • Redis miss이면 DB 확인
  • DB도 없거나 DB TTL 초과(15분 초과)면 외부 API 필요 (single-flight)
  • Redis 장애면 DB fallback + DB bulkhead(동시성 제한)로 “느리게라도” 응답, DB 보호

 

 

 

 

 

시나리오별 동작

 

1. Redis와 DB 모두 값이 없는 초기 상황 (Cord start) 

  • Redis miss
  • DB miss
  • refreshLock(single-flight) : 200명 중 1명만 외부 API 호출
  • 나머지 199명:
    • jitter 재조회 2~3회로 Redis에 값이 생겼는지 확인
    • 3회에도 Redis에 값이 없으면 → DB 조회 -> 그래도 없다면 503 에러 발생
  • 외부 API 성공 시:
    • DB upsert
    • Redis set (TTL 10분)

핵심: Cold Start는 이전에 만료된 값이 없으니 Redis -> DB 순차적으로 조회
그래도 없다면 503 에러

 

 

 

2. Redis hit + TTL > 5분 (Redis Fresh)

  • Redis hit으로 즉시 응답 후 종료

 

 

3. Redis가 사망 후 복구되어 값이 없거나 값이 있어도 TTL이 만료된 상황이지만, DB에선 값도 있으며 TTL도 유효한 상황

(Redis가 죽었다가 복구했거나, Redis가 만료돼서 비어있는 상황)

  • Redis miss
  • DB hit + DB TTL 유효
  • warmupLock(single-flight) : 1명만 DB 조회
    • DB 값을 Redis에 즉시 warm-up (TTL 10분) 하고 응답
    • 동시에 refreshLock을 잡은 1명이 백그라운드 외부 API 갱신
  • 나머지 199명:
    • jitter 재조회 2~3회로 Redis가 채워졌는지 확인
    • 채워지면 Redis로 응답
    • 3회 재조회 실패 시 DB 값을 fallback으로 응답
핵심: Redis miss 시 DB로 한 명만 내려가서 Redis를 채우고, 나머지는 Redis에서 응답을 받음
( 나머지 199명 -> jitter 재조회 2~3회로 Redis 값 받아서 반환 )

 

 

 

4. Redis TTL 만료이고, DB에 값이 있지만 TTL이 초과인 상황

  • Redis miss
  • DB 값은 있으나 TTL 초과 → 신선하지 않음
  • refreshLock(single-flight) : 1명만 외부 API 호출
  • 나머지 199명:
    • jitter 재조회 2~3회로 Redis 값 생성 기다림
    • 199명에게 DB에 만료된 값이라도 준다면 지연 시간을 줄일 수 있지만 너무 과거의 값을 주게 된다면 신뢰성을 잃음
    • 그러나 3회 재시도에도 Redis에 값이 없으면 : DB stale 값으로 응답(Degraded) - 일단 신선하지 않은 결과라도 전달
  • 외부 API 성공 시:
    • DB upsert
    • Redis set (TTL 10분)
핵심: 외부 API가 필요하지만, 실패 시에도 서비스 연속성을 위해 DB stale을 최후 fallback으로 둔다.

 

 

 

5. Redis나 Kakao Map Api에 문제가 생기면?

  • Redis 장애면 DB fallback + DB bulkhead
  • 속도를 느리게라도 DB에 값을 응답으로 보내줘야 함
  • 현재 로직에선 주문 후 배송이 이뤄지는 비동기 로직으로 구현되어 있어 사용자에게 따로 알림을 전송하진 않음

 

 

 

 

 

구현

- Redis에 분산락을 사용하여 하나의 요청만 Kakao에 갱신되도록 해야 함

- Lock는 2개로 나뉨 

  - Redis에 값이 없을 시 DB로 내려가 Redis를 채움

  - Cold Start 상황, Redis TTL이 5분 이하이거나 DB값의 TTL도 만료인 경우 외부 Api(Kakao Api) 호출하는 용

  1. 1명만 refresh lock → Kakao 갱신 시도
  2. Kakao에게 응답을 성공적으로 받을 시 DB upsert + Redis set
  3. 나머지 유저들은 리더가 응답을 받아올 때까지 Redis 3회 재조회 ->그래도 없으면 503

Redis 클라이언트 중 Lettuce 사용

  • Redis 서버에 명령을 안전하고 효율적으로 전달
  • 비동기 / 논블로킹 기반
  • Redis polling + jitter 구조에 안정적
  • 분산락을 “자동으로” 제공하진 않음

Lettuce 선택 이유 

  • Redis의 Lettuce 락은 Lock를 얻지 못한 스레드가 Lock 획득 시도를 반복하는 스핀락(spin lock) 문제가 있으므로 무조건 사용하면 안 된다라는 블로그들 글이 있다.
  • 하지만 지금의 경우 Lock 획득하기 위해 싸우는게 목적이 아닌 "갱신자 한 명을 선출 후 나머지는 Lock를 대기하지 않음"
  • 즉 Lock을 기다리지 않는 single-flight 캐시 갱신 정책을 사용하므로 최소 기능(SET NX PX + 토큰 기반 해제)만으로 투명하고 가볍게 구현 가능한 Lettuce 방식을 선택
@Component
@RequiredArgsConstructor
public class RedisHubEdgeCache {

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper om;

    public Optional<CacheHit<HubInfo>> get(String key){
        try {
            String value = redisTemplate.opsForValue().get(key);
            if(value == null) { return Optional.empty(); }

            long sec = redisTemplate.getExpire(key);
            Duration ttl = sec < 0 ? Duration.ZERO : Duration.ofSeconds(sec);

            return Optional.of(new CacheHit<>(om.readValue(value, HubInfo.class), ttl));

        } catch (Exception e) {
            // 역직렬화/redis 오류면 캐시 제거 후 miss 처리
            redisTemplate.delete(key);

            return Optional.empty();
        }
    }

    public void add(String key, HubInfo hubInfo, Duration ttl){
        try {
            redisTemplate.opsForValue().set(key, om.writeValueAsString(hubInfo), ttl);
        } catch (Exception ignored) {
            // 캐시 저장 실패는 복구 불가능 오류가 아니므로 실패를 시키지 않음
        }
    }

    // DB 조회/갱신은 1명만 가능하도록 Lock
    public boolean tryLock(String key, Duration ttl){
        Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "1", ttl);
        return Boolean.TRUE.equals(result);
    }

    public void unlock(String key){
        try { redisTemplate.delete(key); } catch (Exception ignored) {}
    }
}

 

 

HubEdgeWeightService 

@Service
@Slf4j
@RequiredArgsConstructor
public class HubEdgeWeightService implements HubEdgeWeightProvider {

     private static final Duration REDIS_TTL = Duration.ofMinutes(6);

    // single-flight lock TTL (너무 길게 잡지 말고 “짧게”)
    private static final Duration LOCK_TTL = Duration.ofSeconds(10);

    // DB bulkhead: Redis 장애 시 DB 동시 접근 제한
    private final Semaphore dbBulkhead = new Semaphore(10);

    // jitter 재조회(50 -> 100 -> 200ms)
    private static final long[] JITTER_MS = {50, 150, 250};

    // Redis가 죽으면 Redis lock을 못 쓰니, JVM 내부 single-flight로 Kakao 폭주 방지 (같은 키에 대해 1명만 진입시키는 락 사용)
    private final ConcurrentHashMap<String, ReentrantLock> localLocks = new ConcurrentHashMap<>();

    private final HubInfoRepository hubInfoRepository;
    private final HubRepository hubRepository;
    private final KakaoMapClient kakaoMapClient;
    private final RedisHubEdgeCache redisHubEdgeCache;
    private final CacheStats cacheStats;
    private final TransactionTemplate transactionTemplate;

    @Override
    @Transactional
    public EdgeWeight getWeight(UUID startHubId, UUID endHubId) {

        String redisKey = HubEdgeCacheKeys.edgeKeys(String.valueOf(startHubId), String.valueOf(endHubId));
        final String refreshLockKey = redisKey + ":refreshLock";
        final String warmupLockKey = redisKey + ":warmupLock";
        boolean redisDown = false;

        LocalDateTime now = LocalDateTime.now();

        /** 1) Redis hit (항상 즉시 응답) */
        try {
            Optional<CacheHit<HubInfo>> redisHit = redisHubEdgeCache.get(redisKey);
            if (redisHit.isPresent()) {

                HubInfo hubInfo = redisHit.get().value();
                Duration ttlTime = redisHit.get().ttlTime();

                cacheStats.hit();
                log.info("redis Cache hit");

                // Redis TTL이 5분 이하면 값은 반환해서 주지만 Kakao API를 호출하여 시간 갱신
                if (ttlTime.compareTo(Duration.ofMinutes(5)) <= 0) {

                    // 동시에 refresh-ahead 트리거(1명만)
                    triggerRefreshAheadAsync(refreshLockKey, startHubId, endHubId, redisKey);
                }

                return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
            }
            cacheStats.miss();
        } catch (DataAccessException e) {

            // Redis 장애: 아래에서 DB fallback + bulkhead
            redisDown = true;
            log.warn("Redis down -> fallback to DB with bulkhead key={}", redisKey, e);
        }

        /** 2) Redis가 완전히 죽은 경우: DB로 "느리게라도 무조건" 반환 */
        if (redisDown) {
            return handleRedisDownMustReturnDb(startHubId, endHubId, redisKey, now);
        }

        /** 3) Redis miss (정상 Redis 동작) */
        return handleRedisMissNormal(startHubId, endHubId, warmupLockKey, refreshLockKey, redisKey, now);
    }

    /**
     * Redis가 완전히 죽었을 때 정책: - DB bulkhead는 "대기"해서라도 들어가는 방향으로 - DB에 시간/거리 값이 있으면 TTL 상관없이 무조건 반환
     * (단 리더는 무조건 최신값을 받게 구현 (DB TTL 만료 시 사용자가 한명만 접근하더라도 최신의 값을 주어야 되기 때문))

     * 예시 1 : Redis에 값이 없는 상황에서 DB에 값이 없는 상황 (cold start)
     * -> 로컬 single-flight로 1명만 Kakao Api 호출 -> bulkhead로 10명씩만 처리하기 때문에 많은 지연이 발생할 수 있음

     * 예시 2 : Redis가 없는 상황에서 DB에 값은 있지만 TTL이 만료된 상황 -> DB에서 값을 읽고 TTL이 만료된 값이라도 일단 반환 (bulkhead 적용) ->
     * 만료된 값을 받고 있다가 1명이 백그라운드 갱신 성공 시 최신의 값을 받게 됨 (여전히 bulkhead 적용)

     * - DB에 값이 없으면 cold-start처럼 외부 API 필요 (JVM single-flight로 Kakao api 폭주 방지)
     */
    private EdgeWeight handleRedisDownMustReturnDb(UUID startHubId, UUID endHubId, String redisKey, LocalDateTime now) {

        // 느리게라도 대기해서 bulkhead 진입
        dbBulkhead.acquireUninterruptibly();

        try {
            HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
                .orElseThrow(() -> new BusinessException(ErrorCode.HUB_NOT_FOUND));

            boolean hasValue = hubInfo.hasDeliveryInfo();

            // Redis 사망 + DB에 값도 없는 ColdStart 상황
            if (!hasValue) {
                return coldStartWithLocalSingleFlightSync(startHubId, endHubId, redisKey);
            }

            // DB에 값은 있지만 TTL이 만료 된 상황으로 우선 만료된 값을 주며 1명만 백그라운드 갱신
            if (hubInfo.checkUpdateTime(now)) {
                triggerRefreshWhenRedisDownAsync(startHubId, endHubId, redisKey);
            }

            return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
        } finally {
            dbBulkhead.release();
        }
    }

    /**
     * Redis down + DB TTL 만료(stale)여도 일단 반환하고, 1명만 백그라운드로 최신 갱신 시도
     * (중요: Thread에서 JPA 쓰려면 트랜잭션 필요 -> TransactionTemplate 사용)
     */
    private void triggerRefreshWhenRedisDownAsync(UUID startHubId, UUID endHubId, String redisKey) {

        ReentrantLock lock = localLocks.computeIfAbsent(redisKey, k -> new ReentrantLock());

        // reader가 아니라면 그냥 return
        if (!lock.tryLock()) {
            return;
        }

        new Thread(() -> {
            try {
                transactionTemplate.execute(status -> {
                HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
                    .orElseThrow(() -> new BusinessException(ErrorCode.HUB_NOT_FOUND));

                // Redis는 죽었으니 DB만 갱신
                DirectionInfoResponseV1 directionInfoResponse = getKakaoMap(hubInfo);
                dbSet(directionInfoResponse.duration(), directionInfoResponse.distance(), hubInfo);

                // TransactionTemplate.execute()는 반드시 return 값이 필요하지만 지금은 필요가 없으므로 null 반환
                return null;
                });
            } catch (Exception e) {
                log.error("[RedisDownRefresh] failed {} -> {}", startHubId, endHubId, e);
            }finally {
                lock.unlock();
            }
        }).start();
    }

    /** Redis down + DB에도 값이 없는 cold-start
     * 리더 한명만 kakao Api를 호출하며 나머지는 DB 값이 채워질 때까지 짧게 재조회 시도
    */
    private EdgeWeight coldStartWithLocalSingleFlightSync(UUID startHubId, UUID endHubId, String redisKey) {

        ReentrantLock lock = localLocks.computeIfAbsent(redisKey, k -> new ReentrantLock());

        boolean tryLock = lock.tryLock();
        if (tryLock) {
            try {
                HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
                    .orElseThrow(() -> new BusinessException(ErrorCode.HUB_NOT_FOUND));

                // 혹시 그 사이에 다른 트랜잭션에서 값이 채워졌으면 그대로 반환
                if (hubInfo.hasDeliveryInfo()){
                    return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
                }

                // DB만 갱신
                DirectionInfoResponseV1 directionInfoResponse = getKakaoMap(hubInfo);
                dbSet(directionInfoResponse.duration(), directionInfoResponse.distance(), hubInfo);

                return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
            }finally {
                lock.unlock();
            }
        }

        for(long ms : JITTER_MS){
            sleep(ms);
            HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
                .orElseThrow(() -> new BusinessException(ErrorCode.HUB_NOT_FOUND));

            // 값이 DB에 갱신이 되었으면 반환
            if (hubInfo.hasDeliveryInfo()){
                return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
            }
        }
        throw new BusinessException(ErrorCode.TEMPORARILY_UNAVAILABLE);
    }

    /**
     * Redis 서버는 정상인데 TTL 만료인 경우: warmupLeader 1명만 DB 내려가서 Redis 채움 -> 나머지는 jitter로 Redis 재조회
     * DB에 값도 TTL 만료 상태라면 refreshLock으로 1명만 Kakao 호출 (리더는 최신값으로 응답하도록 구성)
     * 예시 (Redis 만료 + DB도 TTL 만료 상황에서 200명 접근 시 리더 1명만 최신 값을 받고 나머지 199명은 redis에 재처리 3회를 하면서 리더가 갱신한 값을 받게 됨)
     * 3회 처리에도 받지 못한다면 에러를 띄움
     */
    private EdgeWeight handleRedisMissNormal(UUID startHubId, UUID endHubId, String warmupLockKey,
        String refreshLockKey, String redisKey, LocalDateTime now) {

        // DB에 값이 있다면 한명만 갱신 수행
        boolean warmupLeader = redisHubEdgeCache.tryLock(warmupLockKey, LOCK_TTL);

        if (warmupLeader) {
            try {
                HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
                    .orElseThrow(() -> new BusinessException(ErrorCode.HUB_NOT_FOUND));

                //DB에도 값이 없으면 cold-start (1명만 Kakao 동기 호출해서 생성 후 응답)
                if (! hubInfo.hasDeliveryInfo()) {
                    return coldStartFetchWithRedisLock(startHubId, endHubId, refreshLockKey, redisKey, hubInfo);
                }
                // DB의 값이 존재하고 TTL도 살아 있으면 Redis에 warm-up 후 즉시 응답
                if (!hubInfo.checkUpdateTime(now)) { // false = fresh
                    redisHubEdgeCache.add(redisKey, hubInfo, REDIS_TTL);
                    return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
                }

                // Redis 서버는 살아 있고 DB에 값도 있지만 DB 값의 TTL이 만료 된 상황이라면 예전 값이라도 주는 동시에 1명은 값 갱신
                boolean leader = redisHubEdgeCache.tryLock(refreshLockKey, LOCK_TTL);
                if (leader) {
                    try {
                        // 리더는 항상 최신값을 받아서 응답
                        DirectionInfoResponseV1 directionInfoResponse = getKakaoMap(hubInfo);
                        RedisAndDbSet(directionInfoResponse.duration(), directionInfoResponse.distance(), hubInfo, redisKey);
                        return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
                    } finally {
                        redisHubEdgeCache.unlock(refreshLockKey);
                    }
                }
                // warmupLeader가 되지 못한 나머지 요청들은 우선 db에 값을 응답받고 jitter로 Redis에 값 생성을 기다리게 된다
            } finally {
                redisHubEdgeCache.unlock(warmupLockKey);
            }
        }

        return jitterWaitRedis(startHubId, endHubId, redisKey)
            .orElseThrow(() -> new BusinessException(ErrorCode.TEMPORARILY_UNAVAILABLE));
    }

    private EdgeWeight coldStartFetchWithRedisLock(UUID startHubId, UUID endHubId, String refreshLockKey, String redisKey, HubInfo hubInfo) {
        boolean leader = redisHubEdgeCache.tryLock(refreshLockKey, LOCK_TTL);

        if (leader) {
            try {
                DirectionInfoResponseV1 directionInfoResponse = getKakaoMap(hubInfo);
                RedisAndDbSet(directionInfoResponse.duration(), directionInfoResponse.distance(), hubInfo, redisKey);

                // 리더는 최신값으로 즉시 응답
                return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
            } finally {
                redisHubEdgeCache.unlock(refreshLockKey);
            }
        }

        return jitterWaitRedis(startHubId, endHubId, redisKey)
            .orElseThrow(() -> new BusinessException(ErrorCode.TEMPORARILY_UNAVAILABLE));
    }

    /**
     * Redis hit TTL 임박 시 한명만 값 갱신
     */
    private void triggerRefreshAheadAsync(String refreshLockKey, UUID startHubId, UUID endHubId, String redisKey) {
        boolean leader = redisHubEdgeCache.tryLock(refreshLockKey, LOCK_TTL);

        // 리더로 선정되지 못하면 반환
        if (!leader)
            return;

        // 선정된 리더만 비동기로 외부 API(Kakao Map)를 호출해 Redis + DB 갱신
        new Thread(() -> {
            try {
                transactionTemplate.execute(status -> {
                HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
                    .orElseThrow(() -> new BusinessException(ErrorCode.HUB_NOT_FOUND));

                // Kakao api 호출
                DirectionInfoResponseV1 directionInfoResponse = getKakaoMap(hubInfo);
                // 결과를 Redis와 DB에 갱신
                RedisAndDbSet(directionInfoResponse.duration(), directionInfoResponse.distance(), hubInfo, redisKey);
                return null;
                });
            } catch (Exception e) {
                log.error("[RefreshAhead] failed {} -> {}", startHubId, endHubId, e);
            } finally {
                redisHubEdgeCache.unlock(refreshLockKey); // 실패해도 락은 반드시 해제
            }
        }).start();
    }

    private DirectionInfoResponseV1 getKakaoMap(HubInfo hubInfo) {

        // 허브 존재 여부 확인
        Hub startHub = hubRepository.findById(hubInfo.getStartHubId())
            .orElseThrow(() -> new BusinessException(ErrorCode.HUB_NOT_FOUND));
        Hub endHub = hubRepository.findById(hubInfo.getEndHubId())
            .orElseThrow(() -> new BusinessException(ErrorCode.HUB_NOT_FOUND));

        // 해당 허브들에 위도, 경도값 추출
        String origin = startHub.getLongitude() + "," + startHub.getLatitude();
        String destination = endHub.getLongitude() + "," + endHub.getLatitude();

        // Kakao Api 호출
        return kakaoMapClient.getDirection(
            origin,
            destination,
            2,          // carType
            "DIESEL",          // carFuel
            true               // carHipass
        );
    }

    private void RedisAndDbSet(long duration, long distance, HubInfo hubInfo, String redisKey) {
        // Kakao 응답은 초 단위
        int durationSec = (int) duration;

        BigDecimal distanceKm = BigDecimal
            .valueOf(distance / 1000.0)
            .setScale(3, RoundingMode.HALF_UP);

        hubInfo.updateDeliveryInfo(durationSec, distanceKm);

        // redis의 값은 after commit
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                redisHubEdgeCache.add(redisKey, hubInfo, REDIS_TTL);
            }
        });
    }

    private void dbSet(long duration, long distance, HubInfo hubInfo) {
        // Kakao 응답은 초 단위
        int durationSec = (int) duration;

        BigDecimal distanceKm = BigDecimal
            .valueOf(distance / 1000.0)
            .setScale(3, RoundingMode.HALF_UP);

        hubInfo.updateDeliveryInfo(durationSec, distanceKm);
    }

    /** reader로 선출되지 못하고 Redis에 값 갱신을 기다리는 애들을 위한 jitter 재조회 */
    private Optional<EdgeWeight> jitterWaitRedis(UUID startHubId, UUID endHubId, String redisKey){
        for (long ms : JITTER_MS) {
            sleep(ms);

            try {
                Optional<CacheHit<HubInfo>> hubInfoCacheHit = redisHubEdgeCache.get(redisKey);
                if (hubInfoCacheHit.isPresent()) {
                    HubInfo hubInfo = hubInfoCacheHit.get().value();
                    return Optional.of(new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance()));
                }
            } catch (DataAccessException e) {
                return Optional.empty();
            }
        }
            return Optional.empty();
    }

    /** 이 스레드를 잠깐 재우지만 다른 스레드가 interrupt(중단 신호)로 깨우면 신호를 받고 중지
     (대기중 상황에서 타임아웃 발생 & 클라이언트가 연결을 끊음 & 서버 종료 등) */
    private void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException ignored) {
            Thread.currentThread().interrupt();
        }
    }
}

 

 

 

 

중요 내용

- Redis에 값이 있을 경우 사용자에게 반환하고 Redis hit TTL 임박 시 한 명만 값 갱신

- Redis가 완전히 죽은 경우 세마포어에서 설정한 만큼씩만 DB에 접근하여  "느리게라도 무조건" 반환

- Redis 서버는 정상인데 TTL 만료인 경우: warmupLeader 1명만 DB 내려가서 Redis 채움 -> 나머지는 jitter로 Redis 재조회

        /** 1) Redis hit (항상 즉시 응답) */
        try {
            Optional<CacheHit<HubInfo>> redisHit = redisHubEdgeCache.get(redisKey);
            if (redisHit.isPresent()) {

                HubInfo hubInfo = redisHit.get().value();
                Duration ttlTime = redisHit.get().ttlTime();

                cacheStats.hit();
                log.info("redis Cache hit");

                // Redis TTL이 5분 이하면 값은 반환해서 주지만 Kakao API를 호출하여 시간 갱신
                if (ttlTime.compareTo(Duration.ofMinutes(5)) <= 0) {

                    // 동시에 refresh-ahead 트리거(1명만)
                    triggerRefreshAheadAsync(refreshLockKey, startHubId, endHubId, redisKey);
                }

                return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
            }
            cacheStats.miss();
        } catch (DataAccessException e) {

            // Redis 장애: 아래에서 DB fallback + bulkhead
            redisDown = true;
            log.warn("Redis down -> fallback to DB with bulkhead key={}", redisKey, e);
        }

        /** 2) Redis가 완전히 죽은 경우: DB로 "느리게라도 무조건" 반환 */
        if (redisDown) {
            return handleRedisDownMustReturnDb(startHubId, endHubId, redisKey, now);
        }

        /** 3) Redis miss (정상 Redis 동작) */
        return handleRedisMissNormal(startHubId, endHubId, warmupLockKey, refreshLockKey, redisKey, now);

 

 

 

Refactoring 후 Jemeter 부하 테스트

- 동시 사용자 200명 × 사용자당 100회 요청 (총 20,000 requests)의 예시

- 결과 : p95: 1.13s, p99: 2.57s, 평균 336ms

- 기존 16초 걸리던 상황보다 16,000ms → 366ms 👉 약 48배 개선

- 리팩토링 전 488ms -> 366ms 👉 약 1.45배 개선

 

 

- 캐시 miss가 특정 시점에 집중되어 p99 기준 10초 이상 튀던 지연 구간이 사라지고, 응답 시간이 전반적으로 고르게 분포

 

- Cache Hit Rate 100% 유지, TTL 만료 구간에서도 성능 저하 없음

 

 

 

'Spring Boot' 카테고리의 다른 글

왜 UUID v7을 선택했는가? (Snowflake와 비교까지)  (0) 2026.02.26
Lettuce VS Redisson 분산 락  (0) 2026.02.13
🚚 허브 간 최단 경로 탐색 알고리즘 구현 + Kakao Map Api 연동 (Spring Boot)  (0) 2025.12.06
DDD의 페이징 로직 (Spring Boot)  (0) 2025.12.02
특정 이벤트 발생 시 Discord 실시간 알림 시스템 (Spring Boot)  (0) 2025.11.17
'Spring Boot' 카테고리의 다른 글
  • 왜 UUID v7을 선택했는가? (Snowflake와 비교까지)
  • Lettuce VS Redisson 분산 락
  • 🚚 허브 간 최단 경로 탐색 알고리즘 구현 + Kakao Map Api 연동 (Spring Boot)
  • DDD의 페이징 로직 (Spring Boot)
kimfishes
kimfishes
kimfishes 님의 블로그 입니다.
  • kimfishes
    kimfishes 님의 블로그
    kimfishes
  • 전체
    오늘
    어제
    • 전체 (18) N
      • Infra (5)
        • AWS (0)
        • LogBack (4)
      • Spring Boot (13) N
        • LLM (4) N
      • 일상 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Qdrant
    Pre-Signed URL
    loging
    ollama
    Spring boot
    분산 락
    UUID v7
    Discord 알림 연동
    spring ai
    스프링 알림 시스템
    LLM
    ELK
    traceId
    cache stampede
    pgvector
    Redis
    promtail
    캐시 스탬피드
    실시간 알림 시스템
    로깅
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
kimfishes
🚚 허브 간 최단 경로 탐색 캐시 스탬피드 (Cache Stampede) 발생+해결
상단으로

티스토리툴바