이전 문제 상황
- 출발지와 목적지 사이 허브 간 경로를 다익스트라 알고리즘으로 선정 후, 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 TTL이 5분 이하로 남아있으면
- 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명만 refresh lock → Kakao 갱신 시도
- Kakao에게 응답을 성공적으로 받을 시 DB upsert + Redis set
- 나머지 유저들은 리더가 응답을 받아올 때까지 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 |
