https://kimfishes.tistory.com/13
🚚 허브 간 최단 경로 탐색 캐시 스탬피드 (Cache Stampede) 발생+해결
이전 문제 상황출발지와 목적지 사이 허브 간 경로를 다익스트라 알고리즘으로 선정 후, Kakao Api를 호출해 허브 간 소요 시간 산출이때 Kakao Api 응답을 기다리는 방식의 경우 16 ~ 20 초의 많은 시
kimfishes.tistory.com
나의 상황
- 1명만 refresh lock → Kakao Api를 호출하여 값 갱신 시도
- 나머지는 Redis 재조회 2~3회 후 없으면 TTL 시간이 지난 값이라도 반환
- 성공 시 DB upsert + Redis set
- 허브 간 소요시간 저장 시 Redis에 분산락을 사용하여 하나의 요청만 Kakao에 요청하여 값이 갱신되도록 해야 함
- 동일 키에 대해 갱신자 1명만 선출하면 되고, 나머지는 락 대기 없이 값을 랜덤하게 2 ~ 3회 기다리다가 그래도 값을 받지 못하면 TTL 시간이 지난 값이라도 반환
Lettuce이란
- Lettuce는 Redis와 비동기로 통신할 수 있는 자바 클라이언트
- Redis 서버에 명령을 안전하고 효율적으로 전달
- 비동기 / 논블로킹 기반
- Redis polling + jitter 구조에 안정적
- 분산락을 “자동으로” 제공하진 않음
단점
- “락 자체”를 제공하지 않는다 → 직접 설계/검증 필요
- 재시도/폴링 설계를 잘못하면 결국 “스핀락”이 된다
- TTL 만료 시간에 Lock 획득으로 동시 갱신자 가능성
- A가 갱신 중인데 TTL 만료 -> B가 락 획득 → Kakao API를 또 호출하여 결과적으로 “중복 호출” 발생
Redisson ( 고수준 라이브러리 분산락 ) 이란
- Redis 위에 “분산 객체”를 제공하는 라이브러리
- RLock, RMap, RSemaphore 같은 고수준 API 제공
- 내부적으로 Lua + watchdog + TTL 자동 연장
단점
- 락이 무거움 (watchdog 스레드)
- “외부 API 1번만 호출” 같은 정밀 제어엔 과함
Lettuce 선택 이유
- Redis의 Lettuce 락은 Lock를 얻지 못한 스레드가 Lock 획득 시도를 반복하는 스핀락(spin lock) 문제가 있으므로 무조건 사용하면 안된다라는 블로그들에 글이 꽤 있다.
- 하지만 지금의 경우 Lock 획득하기 위해 싸우는게 목적이 아닌 "갱신자 한 명을 선출하는 게 목적"
- 즉 락을 기다리지 않는 single-flight 캐시 갱신 정책을 사용하므로 최소 기능(SET NX PX + 토큰 기반 해제)만으로 투명하고 가볍게 구현 가능한 Lettuce 방식을 선택
- 혹여나 중복 호출이 발생하더라도 허브 간 소요 시간 결과에 악 영향을 끼치지 않음
Lettuce을 언제 사용하는지
- 락 보유 시간이 짧다 (수백 ms ~ 수 초)
- “대기”가 아니라 선출이 목적
- 실패해도 치명적이지 않다 (503/캐시미스 허용, 재시도 가능)
- 구현을 투명하게 유지하고 싶다 (키/TTL/해제 조건 직접 제어)
언제 Redisson을 사용하는지
- 락을 잡고 하는 작업이 수십 초~분 단위인 경우
- 락이 중간에 풀리면 치명적인 중복 실행이 되는 경우 ( 금전/정산/재고 오류 )
- 동일 자원(좌석, 쿠폰, 포인트) 업데이트를 순서대로 처리가 필요한 경우
- 임계구역 작업이 꼭 성공해야 해서 대기 후 획득이 필요한 경우
예시 코드
@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) {}
}
}
결론
- 이번 문제는 “분산락으로 모든 요청을 직렬화”하는 문제가 아니라,
캐시 갱신자 1명을 선출해 외부 API 호출을 1회로 제한하는 single-flight 문제다.
- 따라서 무거운 락 추상화보다 SET NX PX + 토큰 기반 해제로 충분하며,
운영 중 갱신 시간이 길어질 수 있는 경우에만 Redisson(Watchdog)로 확장하는 전략이 합리적이다.
'Spring Boot' 카테고리의 다른 글
| Spring Boot 4에서 외부 API 호출 무엇을 선택해야 할까? (0) | 2026.03.27 |
|---|---|
| 왜 UUID v7을 선택했는가? (Snowflake와 비교까지) (0) | 2026.02.26 |
| 🚚 허브 간 최단 경로 탐색 캐시 스탬피드 (Cache Stampede) 발생+해결 (1) | 2026.01.19 |
| 🚚 허브 간 최단 경로 탐색 알고리즘 구현 + Kakao Map Api 연동 (Spring Boot) (0) | 2025.12.06 |
| DDD의 페이징 로직 (Spring Boot) (0) | 2025.12.02 |