1. 배경
2. 결론 요약
3. 스탬피드 발생 상황
4. 싱글 플라이트
5. 가상 스레드 (자바 25)
6. PER
7. 소프트 TTL
1. 배경
비밀로그에는 다른 사용자의 롤링페이퍼로 가기위해 모든 회원의 목록을 볼 수 있는 공간이 있다.
캐시 구조는 String을 쓰며 키는 member:page:0:size:10 형식으로 0페이지의 사이즈 10 을 나타낸다.
값에는 회원 이름이랑 회원 ID의 목록을 JSON으로 넣었다. 사용자가 요청한 페이지와 사이즈를 그대로 캐싱하여. 캐시 어사이드로 갱신하고 삽입시에는 DB에만 저장한다. TTL은 1분이다.
캐시 스탬피드 방지 목적으로 싱글플라이트를 적용하였는데 스레드 스파이크 현상이 식별되었다.
따라서 다른 스탬피드 방지 전략들을 테스트 하였다.
스탬피드 발생 상황부터 천천히 거슬러 올라가겠다
테스트는 아래로 이루어진다.
기본 데이터 : 회원 10만 명
스레드 풀 : 80 고정
DB 커넥션 : 40 고정
Redis 커넥션 : 40
힙메모리 : 1GB
로컬 도커 네트워크 환경
회원 목록 API : 캐시 스탬피드가 발생하는 지점 1분간 초당 90건 까지 선형적 상승 이후 4분간 유지
롤링페이퍼 조회 API : 1분간 초당 10건 까지 선형적 상승 이후 4분간 유지
캐시 메트릭을 명확히 하기 위해 1페이지만 테스트했다.
롤링페이퍼 조회 API는 스탬피드의 영향을 체크하기 위한 별도의 API
2. 결론 요약
| 총 요청 | TPS | P99 응답시간 | 캐시 스탬피드 | 테스트 중 스레드 풀 포화 비율 | |
| 캐시 스탬피드 | 69,169 | 232 | 1,138ms | 발생 | 11.8% |
| 싱글 플라이트 | 68,279 | 228 | 1,168ms | X | 50% |
| 싱글 플라이트 + 가상 스레드 (자바 25) | 57,627 | 192 | 1,672ms | X | - |
| PER | 72,281 | 240 | 1,160ms | X | 20% |
| 소프트 TTL | 78,896 | 263 | 989ms | X | 10.5% |
스레드 풀 포화 비율이 가장 낮고 스탬피드가 발생하지 않으며 가장 처리량이 좋은 소프트 TTL 방식을 선택했다.
3. 스탬피드 발생 상황
캐시가 없으면 DB에서 조회하고 캐시에삽입하여 반환한다.
public Page<SimpleMemberDTO> findAllMembers(Pageable pageable) {
int page = pageable.getPageNumber();
int size = pageable.getPageSize();
Page<SimpleMemberDTO> memberByPage = redisMemberAdapter.getMemberByPage(page, size);
if (memberByPage.isEmpty()) {
memberByPage = memberQueryRepository.findAllMembers(pageable);
redisMemberAdapter.saveMemberPage(page, size, memberByPage.getContent());
}
return memberByPage;
}


캐시 스탬피드가 발생했다.

스레드 풀 상태고 여유가 있다.
마이크로 미터를 이용하여 톰캣안의 스레드 풀이 대기중인지 밖에 있는지 측정했다. 그 이유는 스레드의 waiting과 timed_waiting가 여러의미를 가지기 때문이다.
자바의 스레드풀 익스큐터에 있는 코어 스레드는 waiting상태다. 부하에 의해 늘어나 대기중인 스레드는 timed_waiting이다.
하지만 러너블에서 대기를 할 경우도 waiting이며 타임아웃이 있는 락에서 대기중이면 timed_waiting이다.
즉, 풀에있는 상황과 진행되지만 대기하는 상황이 한 눈에 들어오지 않아 추론이 필요하다.
예를들면

사진과 같은 상황에서는 고정 스레드 풀이 80개 였고 때문에 80개의 스레드는 waiting상태가 디폴트이다
15시 14분경 runnable과 waiting이 맞닿은 시점 약 80개의 runnable 스레드가 생겼고, 이 때 스레드 풀의 총량을 넘어선 것 임을 알 수 있으며 그 결과 14분 전후에 waiting스레드가 모두 많은 것처럼 보이지만
14분 이전은 풀에서 waiting하는 여유로운 상황 14분 이후는 실행되었지만 포화로인해 waiting하는 상황으로 구분해야한다.
따라서 스레드 풀을 별도로 메트릭을 만들어서 둘 다 참고하고 있다.

테스트 기간동안 스레드풀의 80개 스레드 중 70개 이상의 스레드가 일하는 시간은 11.8%를 차지한다.

4. 싱글 플라이트
최초에 활용한 방법이다.
싱글 플라이트는 요청 모으기라고 불리는 방법으로 DB에 하나의 스레드만 들어가게 하고 나머지는 대기하여서 DB에 들어간 스레드가 가져온 결과를 받아간다. 분산락에 비해 싱글플라이트의 장점은 특정 스레드가 캐시를 갱신하면 기다리던 스레드들은 Redis를 조회하지않고 결과를 가져가기 때문에 Redis로 가는 트래픽도 줄일 수 있다. 반면 분산락은 기다리던 스레드들도 Redis에 요청을 보내서 캐시를 가져간다.
싱글 플라이트도 락을 활용한다. 단일 환경이라 분산락을 쓸 필요가 없기 때문에 락은 컨커런트해시맵에 캐시 키를 남기는 방법을 활용했다. 향후 서버를 증설할 계획은 없으며, 락을 분산락으로 교체한다고 해도 금방 작업이 가능하다.
싱글플라이트의 단점은
1. 캐시 갱신을 하는 스레드에 장애가 발생하면 나머지 스레드들도 모조리 장애가 발생하는 에러 전파 리스크가 있다.
2. 분산 환경에서 생각해야할 것들이 많다. 분산 환경에서는 레디스 펍섭을 사용하는데 대기하는 모든 스레드들이 펍섭을 구독함으로서 레디스로 가는 요청을 줄인다는 장점이 약해진다. 그리고 펍섭은 fire-and-forget이다 뒤늦게 들어온 요청이 락을 획득하지 못해서 펍섭 채널 구독을 하려했는데 그 순간 갱신이 완료되어 채널이 사라지면 빈 페이지를 반환한다. 이를 해결하기 위해서는 메시지를 유지하거나 캐시를 한번 더 하거나 복잡한 비즈니스 로직이 있어야한다.
단일 서버에서는 분산락과 싱글플라이트의 차이가 없다. 애초에 단일 서버이기 때문에 분산락이 아니어도 된다. 캐시 갱신이 끝나면 기다리던 스레드들이 레디스를 조회하느냐 조회한 결과를 바로 가져가느냐의 차이다. 레디스로 가는 부하를 더 줄이기 위해 정했다.
단일 환경에서의 싱글 플라이트는 컴플리터블퓨처로 구현할 수 있다. 컴플리터블퓨처의 get() 메소드는 내부적으로 LockSupport.park()가 작동하며 특정 시간 만큼 대기하도록 설정할 수 있다. 이미 어떠한 페이지가 요청을 모으고 있을 수 있기 때문에 페이지의 구분을 위해 싱글플라이트 키를 따로 정의했다.
public Page<SimpleMemberDTO> findAllMembers(Pageable pageable) {
int page = pageable.getPageNumber();
int size = pageable.getPageSize();
Page<SimpleMemberDTO> memberByPage = redisMemberAdapter.getMemberByPage(page, size);
if (!memberByPage.isEmpty()) {
return memberByPage;
}
String flightKey = page + ":" + size;
CompletableFuture<Page<SimpleMemberDTO>> newFuture = new CompletableFuture<>();
CompletableFuture<Page<SimpleMemberDTO>> existing = inFlight.putIfAbsent(flightKey, newFuture);
if (existing != null) {
try {
return existing.get(5, TimeUnit.SECONDS);
} catch (ExecutionException | InterruptedException | TimeoutException e) {
return redisMemberAdapter.getMemberByPage(page, size);
}
}
try {
Page<SimpleMemberDTO> result = memberQueryRepository.findAllMembers(pageable);
redisMemberAdapter.saveMemberPage(page, size, result.getContent());
newFuture.complete(result);
return result;
} catch (Exception e) {
newFuture.completeExceptionally(e);
throw e;
} finally {
inFlight.remove(flightKey, newFuture);
}
}

TPS와 P99가 오히려 줄었다.

스탬피드가 사라졌다.

하지만 스레드 풀이 캐시 스탬피드 발생 이전보다 포화되었다. 파란색이 천장까지 오르면 포화된 것이다.

타임드 웨이팅 스레드가 치솟는게 보인다. 구분을 위해 스레드 풀도 고정크기로 하였다.

70개 이상의 스레드가 사용된 시간은 테스트 기간 중 50%다.
싱글 플라이트의 단점은 애플리케이션에서 스레드가 대기 한다는 것이다. DB로 들어간 스레드의 응답을 받기 위해서다.
5초의 타임아웃을 주고 대기를 시켰는데 그 사이에 스레드들이 스레드 풀을 점유한 것이다.
스레드의 스파이크를 잡을 수 있다면 응답시간을 더 줄일 수 있을 것이다. 여기서 가상스레드를 떠올렸다
5. 가상스레드 (자바 25)
가상 스레드는 OS스레드를 참조한다. OS 스레드는 플랫폼 스레드, 캐리어 스레드라고 불리는데 그 위에 버츄얼 스레드가 얹혀져서 항공 모함위에 비행기가 있는 느낌이다. 버츄얼 스레드는 플랫폼 스레드를 참조하고 있다.
플랫폼 스레드 풀에 지배당하지 않고 원하는만큼 요청을 받을 수 있어서 스레드 풀이 여유로워 질 것이다.
하지만 가상스레드 객체는 힙메모리에 할당되기에 GC가 많이 일어날 것이라는 염려가 있다.
나는 자바 21이라 thread pin 문제가 생길 수 있다. thread pin문제는 synchronized를 만나면 가상스레드가 pin되는 문제다. 한마디로 가상스레드가 플랫폼스레드에서 언마운트되지 못한다. 이 문제는 자바 24부터 해결되었다.
JVM 옵션에 -Djdk.tracePinnedThreads=short를 추가하여 테스트를 해보았는데. 단 한번이지만 스레드 핀이 발생하여 자바 25로 테스트하였다. 하지만 결과의 큰 차이는 없다.
앞으로 캐시 히트율과 커넥션 풀 사진은 분량과 가독성을 위해 생략하겠다 이하 모든 상황에서 커넥션의 스파이크가 발생하지 않았고 히트율에 문제가 없었다.
@Qualifier("memberQueryVirtualExecutor")
private final ExecutorService memberQueryVirtualExecutor;
@Configuration
public class VirtualThreadConfig {
@Bean(name = "memberQueryVirtualExecutor", destroyMethod = "shutdown")
public ExecutorService memberQueryVirtualExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}

성능이 가상스레드를 적용하기 전보다 안좋아졌다.

가상 스레드라 스레드 풀 사진은 의미가 없다 여기 보이는 러너블 스레드들은 롤링페이퍼 조회 API다
가상 스레드는 스레드 풀과 관련없이 힙 영역에 가상 스레드의 객체를 생성해서 GC가 많이 일어난다.
가상 스레드를 적용하지 않았을 때와 비교해보자




| GC 발생 횟수 | STW 시간 | Eden 할당 | 승격 객체 | |
| 가상 스레드 적용 전 | 17회 | 100ms | 10GB | 45MB |
| 가상 스레드 적용 후 | 25회 | 1100ms | 14GB | 60MB |
최고 GC 스파이크는 약 200ms에 달해 응답시간이 그때 느려지고 스레드에 요청이 쌓였다.
6. PER
그렇다면 애초에 만료가되서 요청을 모으며 대기하는 것 보다 캐시가 만료되기전에 확률적으로 미리 갱신하면 이 상황을 극복할 수 있다고 생각했다.
PER은 캐시가 만료되기전에 확률적으로 캐시를 갱신하는 방법으로 베타 값을 어떻게 지정하느냐에 따라 강도를 지정할 수 있다. 베타 값은 트래픽과 캐시의 신선도에 따라 결정한다. 베타 값에 무관하게 TTL에 수렴함에 따라 갱신 확률이 100%에 가까워지는 것은 같지만, 베타값은 중요하다. TTL까지가는 과정에서 확률이 다르다. 너무 낮으면 갱신이 안될 수도 있고 캐시의 신선도가 떨어진다. 반면, 너무 높으면 너무 빨리 갱신되어버려 캐시가 의미 없어진다.
일반적으로 트래픽이 많으면 베타값을 낮게, 트래픽이 없으면 베타값을 높게 정하지만, 캐시 갱신이 얼마나 오래걸리는가, TTL이 얼마나 남았을 때 캐시가 갱신되기 원하는가에 따라 실험해보며 적절하게 정해야한다.
공식은 아래와 같다
currentTime - (timeToCompute * β * ln(random())) >= expiry
currentTime : 현재 시간
expiry : 캐시 만료 시간
timeToCompute : 캐시된 값을 다시 계산하는 데 걸리는 시간 = 현재 0.1초로 지정
beta (β) : 기본적으로 1.0보다 큰 값으로 설정 가능 (갱신 확률 조절)
rand() : 0 ~ 1 사이의 랜덤 값을 반환하는 함수
공식은 아래로 변환할수있다.
-(캐시 갱신에 걸리는 시간 * β * log 랜덤 값) >= expiry - currentTime
캐시 갱신에 걸리는 시간 * β * log 랜덤 값 <= 남은 TTL
나의 TTL은 1분이고 캐시 갱신시간은 0.1초로 가정했다. 그리고 테스트는 초당 요청 400건이다.
수학적으로 계산했을 때
0.1 * 20 * log 랜덤 <= 남은 TTL
2 * log랜덤 <= 남은 TTL
랜덤이 최대값일 때 0.00000....1 <= 남은 TTL
랜덤이 최소값일 때 나는 랜덤을 0.0001로 지정하여서 18.42 <= 남은 TTL
랜덤이 중간값일 때 1.38 <= 남은 TTL
log내부의 랜덤값이 중간 값 일 때
베타를 10으로 지정하면
DB에 접근하는 스레드의 평균 개수는 1.1개이며 캐시의 만료 시간 예상값은 만료 6초 전이다.
베타를 20으로 지정하면
DB에 접근하는 스레드의 평균 개수는 1.02개이며 캐시의 만료 시간은 만료 12초 전이다.
캐시는 여러개고 어떤 요청이 올지는 랜덤이기 때문에 아슬아슬한 확률이다
TTL이 1분이기 때문에 너무 빨리 30초전 20초전 이렇게 갱신되면 캐시의 의미가 없고 1초전, 2초전으로 맞추면 자칫하면 갱신이 안될 수도 있다. 따라서 5~10초 정도가 적당하다고 생각한다. 우선 베타 10으로 테스트했다.
PER을 구현했다.
1. 루아스크립트로 TTL이 있으면 캐시를 가져온다.
2. 캐시가 존재하면 PER 갱신 판단을 한다. 갱신을 하기로 하면 이벤트를 발행하고 메인스레드는 캐시들고 리턴
3. 이벤트는 비동기에서 캐시를 갱신한다.
4. 아예 캐시가 존재하지 않으면 싱글플라이트를 쓴다.
public Page<SimpleMemberDTO> findAllMembers(Pageable pageable) {
int page = pageable.getPageNumber();
int size = pageable.getPageSize();
// - (0.1 * 20 * 로그 (랜덤)) > 남은 시간
Optional<CacheMemberDTO> cacheResult = redisMemberAdapter.getMemberByPageWithPER(page, size);
if (cacheResult.isPresent()) {
CacheMemberDTO cacheMemberDTO = cacheResult.get();
Page<SimpleMemberDTO> simpleMemberDTOPage = cacheMemberDTO.getSimpleMemberDTOPage();
Double computeTTL = cacheMemberDTO.getComputeTTL();
double random = ThreadLocalRandom.current().nextDouble(0.0001, 1);
if ((COMPUTE_TIME * BETA * Math.log(random)) < -computeTTL) { // PER 조건 만족
eventPublisher.publishEvent(new MemberCacheRefreshEvent(pageable));
}
return simpleMemberDTOPage; // 반환
}
// 캐시 만료 시 싱글 플라이트
String flightKey = page + ":" + size;
CompletableFuture<Page<SimpleMemberDTO>> newFuture = new CompletableFuture<>();
CompletableFuture<Page<SimpleMemberDTO>> existing = inFlight.putIfAbsent(flightKey, newFuture);
if (existing != null) {
try {
return existing.get(5, TimeUnit.SECONDS);
} catch (ExecutionException | InterruptedException | TimeoutException e) {
return redisMemberAdapter.getMemberByPage(page, size);
}
}
try {
Page<SimpleMemberDTO> fresh = memberQueryRepository.findAllMembers(pageable);
redisMemberAdapter.saveMemberPage(page, size, fresh.getContent());
newFuture.complete(fresh);
return fresh;
} catch (Exception e) {
newFuture.completeExceptionally(e);
throw e;
} finally {
inFlight.remove(flightKey, newFuture);
}
}

성능이 지금까지보다 가장 좋아졌다.

그러나 스레드 풀은 비교적 포화된 모습을 보인다.

앞서 싱글 플라이트의 스레드 풀과 비교해보면 타임드웨이팅이 줄었다. 이는 특정 타임아웃에 스레드가 점유되지 않다는 것이며 기존 비즈니스 로직이 많아 스레드가 웨이팅 상태인 것이 보인다.

테스트 중 스레드 풀 포화 비율
PER은 스탬피드 상황에선 좋지만 평소 로직이 무겁다 TTL을 계산하고 캐시를 가져오고 PER여부를 계산하는 비즈니스 로직이 캐시 조회에 들어있기 때문이다
7. 소프트 TTL
싱글플라이트는 요청을 대기하는 스레드 때문에 스레드 스파이크가 일어난다
가상스레드를 접목하면 GC가 많이 발생하여 느려진다
PER은 레디스에 명령이 2번 발생하고 PER 계산로직이 있어 기본 로직이 무겁다
기본 로직이 무겁지도 않으면서 스레드 스파이크도 없애면서 캐시 스탬피드도 없애는 방법은 없을까?
여기서 논리적 TTL을 생각했다. 실제로 만료되지는 않지만 갱신시점을 알려주는 TTL이다
그럼 기존 캐시를 반환할 수 있어 다른 스레드가 대기하지 않아도 된다
논리적 TTL은 50초로 정했다. TTL 만료가 10초 이내에 남으면 갱신하는 것이다.
1. 캐시를 저장할 때 현재 시간을 저장한다.
2. 캐시를 조회할 때 현재 시간과 저장된 시간을 비교한다.
3. 논리적 TTL이 지났으면 비동기로 캐시를 갱신하고 메인 스레드는 그대로 캐시를 반환한다. 이 때, 컴플리터블퓨처의 플라이트키를 활용하여 동시 DB접근을 막는다.
4. 논리적 TTL이 여유로우면 그냥 캐시를 가져간다.
5. 아예 물리적 TTL이 만료되었으면 싱글플라이트를 한다.
public Page<SimpleMemberDTO> findAllMembers(Pageable pageable) {
int page = pageable.getPageNumber();
int size = pageable.getPageSize();
String flightKey = page + ":" + size;
CachedMemberPage cached = redisMemberAdapter.lookup(page, size);
if (cached != null) {
Page<SimpleMemberDTO> data = new PageImpl<>(cached.data(), PageRequest.of(page, size), cached.data().size());
if (cached.isStale() && softRefreshing.add(flightKey)) {
memberCacheRefresher.refresh(page, size, () -> softRefreshing.remove(flightKey));
}
return data;
}
return singleFlight(pageable, flightKey, page, size);
}
private Page<SimpleMemberDTO> singleFlight(Pageable pageable, String flightKey, int page, int size) {
CompletableFuture<Page<SimpleMemberDTO>> newFuture = new CompletableFuture<>();
CompletableFuture<Page<SimpleMemberDTO>> existing = inFlight.putIfAbsent(flightKey, newFuture);
if (existing != null) {
try {
return existing.get(5, TimeUnit.SECONDS);
} catch (ExecutionException | InterruptedException | TimeoutException e) {
CachedMemberPage again = redisMemberAdapter.lookup(page, size);
return again != null ?
new PageImpl<>(again.data(), PageRequest.of(page, size), again.data().size())
: Page.empty();
}
}
try {
Page<SimpleMemberDTO> result = memberQueryRepository.findAllMembers(pageable);
redisMemberAdapter.saveMemberPage(page, size, result.getContent());
newFuture.complete(result);
return result;
} catch (Exception e) {
newFuture.completeExceptionally(e);
throw e;
} finally {
inFlight.remove(flightKey, newFuture);
}
}
@Async("memberEventExecutor")
public void refresh(int page, int size, Runnable cleanup) {
try {
Page<SimpleMemberDTO> result = memberQueryRepository.findAllMembers(PageRequest.of(page, size));
redisMemberAdapter.saveMemberPage(page, size, result.getContent());
} catch (Exception e) {
log.warn("회원 페이지 캐시 비동기 갱신 실패: page={}, size={}, err={}", page, size, e.getMessage());
} finally {
cleanup.run();
}
}
public CachedMemberPage lookup(int page, int size) {
String raw = redisTemplate.opsForValue().get(String.format(MEMBER_KEY, page, size));
if (raw == null) return null;
try {
return objectMapper.readValue(raw, CachedMemberPage.class);
} catch (Exception e) {
log.warn("회원 캐시 역직렬화 오류");
return null;
}
}

크게 향상되었다.

스레드 풀이 가장 여유롭다.

테스트 중 스레드 풀 포화 비율은 10.5%다.
'트러블슈팅과 고민 > 트러블슈팅' 카테고리의 다른 글
| 레디스 파이프라인으로 인한 Netty 태스크 큐 병목 식별 및 해결 후 Spring Data Redis 기여 (0) | 2026.03.09 |
|---|---|
| DB의 4500만개의 데이터를 레디스로 옮기기 (0) | 2026.02.28 |
| 게시글 목록 조회 API 응답시간 200ms 이내 TPS 12.3배 개선 (0) | 2026.02.03 |
| 레디스 장애 시 서킷브레이커와 로컬 캐시를 활용한 실시간 인기글 API 개선 (0) | 2026.01.30 |
| 게시판 조회 쿼리 개선 (1) | 2025.12.31 |