[TanStack Query] Lv.2 코드로 구현한 수치 기반 캐싱
이전 글에서는 캐싱을 감이 아니라 수치로 판단하기 위해
2026.04.07 - [[FE]] - [TanStack Query] Lv.1 수치 기반 선택
[TanStack Query] Lv.1 수치 기반 선택
프론트엔드에서 캐싱은 흔하다.기존 프로젝트에서는 이미 TanStack Query나 SWR 같은 라이브러리를 사용하고 있고,“어디에 캐싱을 적용할 것인가”에 대한 나름의 기준도 가지고 있다.하지만 그 기
juncci.tistory.com
| calls | 요청 발생 시 count 증가 | endpoint별 총 호출 수 |
| duplicateRate | duplicateCalls / calls | 동일 endpoint 중복 요청 비율 |
| rehitWithin30sRate | rehitCalls / (calls - 1) | 30초 내 재호출 비율 |
| avgMs | totalDuration / calls | 평균 응답 시간 |
| p95Ms | percentile(durations, 95) | 상위 5% 지연 시간 |
| errorRate | errorCalls / calls | 실패 요청 비율 |
최소 지표 세트를 정의하고 이를 ROI 관점에서 해석하는 기준을 정리했다.
핵심은 단순했다. “캐싱할 수 있는가”가 아니라 “캐싱했을 때 실제로 얼마나 이득이 발생하는가”를 판단해야 한다는 점이다.
이번 글에서는 그 판단 기준을 실제 코드에서 어떻게 만들었는지를 정리해보려 한다.
구체적으로는 다음 세 가지를 다룬다.
- API 요청에서 수치를 어떻게 수집했는가
- 각 지표가 코드에서 어떤 방식으로 계산되는가
- 얻은 수치를 기준으로 TanStack Query 캐싱을 어떻게 적용했는가
이번 글은 설계보다 구현에 가깝다.
1탄이 판단 기준을 세우는 글이었다면, 2탄은 그 기준을 실제 동작하는 코드로 옮긴 과정에 대한 기록이다.
1️⃣ 전체 흐름 먼저 보기
이번 글에서 설명할 코드는 크게 두 부분으로 나뉜다.
첫 번째는 계측 코드다.
클라이언트의 apiFetch, 서버의 fetchUpstream 같은 요청 진입점에서 요청 정보를 수집하고,
endpoint별로 메모리에 누적한 뒤 각종 지표를 계산한다.
두 번째는 캐싱 적용 코드다.
앞에서 수집한 수치를 보고, 구조적 중복이 큰 API는
TanStack Query의 queryKey와 staleTime으로 대응한다.
즉 흐름은 아래와 같다.
요청 발생
→ 계측 함수 호출
→ endpoint 단위로 상태 누적
→ 지표 계산
→ 캐싱 필요성 판단
→ TanStack Query 적용
이 흐름을 먼저 잡고 각 단계를 보면 코드가 훨씬 읽기 쉬워진다.
2️⃣ 지표가 만들어지는 전체 코드 구조
먼저 각 지표를 어떻게 만드는지 보기 전에,
전체적으로 어떤 상태를 저장하고 있었는지부터 보는 게 이해가 잘 될것 같다.
이 구조를 이해해야 calls, duplicateRate, p95Ms 같은 값이 왜 저렇게 계산되는지 자연스럽게 연결된다.
type EndpointMetric = {
calls: number;
totalDurationMs: number;
durationsMs: number[];
calledAt: number[];
duplicateCalls: number;
rehitWithinWindowCalls: number;
errorCalls: number;
};
이 구조는 endpoint 하나에 대한 누적 상태를 표현한다.
핵심은 단순히 호출 횟수만 세는 것이 아니라, 응답 시간 배열과 호출 시점 배열까지 함께 저장하고 있다는 점이다.
calls는 말 그대로 총 호출 수다.
totalDurationMs는 평균 응답 시간을 계산하기 위한 누적 합이다.
durationsMs는 p95를 계산하기 위해 개별 응답 시간을 보관한다.
calledAt은 호출 간 간격을 계산하기 위해 필요하다.
그리고 duplicateCalls, rehitWithinWindowCalls, errorCalls는 각각 중복 호출, 30초 내 재호출, 오류 발생 횟수를 누적한다.
이 객체 하나가 “한 endpoint의 요청 패턴”을 요약한 상태라고 보면 된다.
3️⃣ 계측은 어디서 시작됐는가
지표는 나중에 따로 계산하는 것이 아니라, 요청이 끝날 때마다 기록되는 값들이 쌓여서 만들어진다.
그래서 가장 먼저 봐야 할 것은 “어디서 track 함수를 호출했는가”다.
[1] 클라이언트 계측 진입점
클라이언트에서는 apiFetch에서 계측을 시작했다.
trackApiRequest({
url: requestUrl,
method: requestMethod,
status: res.status,
durationMs: endTime - startTime,
});
이 코드는 단순하지만 의미가 크다. 요청이 성공적으로 끝난 시점마다 다음 네 가지 정보가 전달된다.
- 어떤 URL이 호출됐는지
- 어떤 HTTP Method인지
- 최종 응답 상태가 무엇인지
- 요청에 몇 ms가 걸렸는지
calls, avgMs, p95Ms, errorRate를 계산하기 위한 가장 기본 데이터가 여기서 들어온다. 이 구조의 장점은 요청을 보내는 코드마다 개별적으로 지표를 넣지 않아도 된다는 것이다. apiFetch를 통과하는 요청은 자동으로 계측 대상이 되기 때문에, 관측 범위가 넓고 누락 가능성이 적다.
[2] 서버(BFF) 계측 진입점
이 프로젝트에서는 Next를 중간 서버로 두고 BFF 레이어를 설계 했다.
[참고]
2026.01.25 - [[FE]] - Next.js App Router에서 BFF 레이어를 구성한 방법과 고민
Next.js App Router에서 BFF 레이어를 구성한 방법과 고민
프론트엔드에서 백엔드 API를 직접 호출하는 구조는 꽤 자연스러운 선택이다.나 역시 처음에는 별다른 고민 없이 이 방식을 사용했다.하지만 서비스가 커지면서, 프론트엔드가 단순히 UI를 그리
juncci.tistory.com
서버에서도 비슷한 방식으로 upstream 호출을 계측했다.
trackServerApiRequest({
path: bffPath ?? upstreamPath,
method,
status: res.status,
durationMs: Date.now() - startMs,
});
그리고 타임아웃도 실패 요청으로 집계했다.
trackServerApiRequest({
path: bffPath ?? upstreamPath,
method,
status: 504,
durationMs: Date.now() - startMs,
});
이렇게 하면 클라이언트 요청과 서버 요청을 서로 다른 기준으로 보지 않고, 같은 축에서 볼 수 있다. 1탄에서 설명했던 것처럼, 이 구조의 핵심은 “모든 요청을 동일한 방식으로 수집한다”는 점이다.
그래야 이후의 duplicateRate, p95, errorRate가 비교 가능한 값이 된다.
4️⃣ endpoint를 그대로 쓰지 않고 정규화한 이유
계측에서 가장 중요한 부분 중 하나는 endpoint 정규화다.
이 과정을 생략하면 duplicateRate 같은 수치는 사실상 무의미해진다.
const normalizedPath = parsed.pathname
.replace(/\/\d+(?=\/|$)/g, '/:id')
.replace(UUID_REGEX, ':uuid');
예를 들어 아래 두 요청이 있다고 하자.
GET /users/1
GET /users/2
이 둘은 문자열로 보면 서로 다른 요청이다. 하지만 설계 관점에서는 같은 패턴이다.
둘 다 “유저 상세 조회”이고, 캐싱 전략도 동일하게 가져가야 한다.
그래서 숫자 ID와 UUID를 치환해 /users/:id, /reports/:uuid 같은 형태로 통합했다.
이 정규화가 중요한 이유는 분명하다.
중복 호출은 개별 URL 기준이 아니라 동일한 데이터 패턴 기준으로 판단해야 하기 때문이라고 생각했다.
그래야 “같은 종류의 요청이 얼마나 반복되고 있는가”를 볼 수 있다.
1탄에서 endpoint 단위로 분석한 이유를 따로 설명한 것도 바로 이 지점 때문이다.
5️⃣ 이제 각 수치가 코드에서 어떻게 만들어지는지 보기
여기서부터가 핵심이다.
지표 이름만 보면 추상적으로 느껴질 수 있지만, 실제로는 꽤 단순한 누적과 계산의 조합이다.
calls — 총 호출 수는 어떻게 만들었는가
calls는 가장 단순한 지표다. 요청이 endpoint에 도달할 때마다 1씩 증가시킨다.
record.calls += 1;
이 한 줄이 의미하는 것은 명확하다.
해당 endpoint가 세션 동안 몇 번 호출되었는지를 그대로 누적한다는 뜻이다.
단순해 보이지만 이 값은 모든 계산의 출발점이다.
duplicateRate도, avgMs도, errorRate도 결국 calls를 분모로 쓰거나 calls를 기반으로 해석된다.
그래서 calls는 단순 count가 아니라 “이 endpoint를 최적화할 가치가 있는가”를 판단하는 기본 볼륨 지표라고 볼 수 있다.
duplicateRate — 중복 호출 비율은 어떻게 계산했는가
duplicateRate는 처음부터 비율로 저장한 것이 아니라, 먼저 duplicateCalls를 누적하고 나중에 나눠서 계산했다.
중복 여부는 아래처럼 판단했다.
if (record.calls > 0) {
record.duplicateCalls += 1;
}
이 로직은 현재 요청을 처리하기 전 이미 호출 이력이 있으면,
이번 요청은 중복이라고 보는 방식이다. 즉 기준은 매우 단순하다.
- 첫 번째 호출: 중복 아님
- 두 번째 호출부터: 중복 호출
그리고 최종적으로는 아래처럼 비율을 만든다.
const duplicateRate = data.calls > 0 ? data.duplicateCalls / data.calls : 0;
이 방식의 장점은 정의가 명확하다는 점이다.
여기서 말하는 duplicate는 “같은 endpoint가 한 번 이상 다시 호출된 비율”이다.
결국 TanStack Query를 적용했을 때 줄일 수 있는
잠재 요청량을 거칠게 보여주는 값이라고 볼 수 있다.
rehitWithin30sRate — 30초 내 재호출은 어떻게 계산했는가
이 지표는 단순 중복보다 한 단계 더 사용자 행동에 가깝다. 핵심은 “직전 호출과의 시간 차이”다.
const prevCalledAt = record.calledAt[record.calledAt.length - 1];
if (typeof prevCalledAt === 'number' && now - prevCalledAt < 30_000) {
record.rehitWithinWindowCalls += 1;
}
현재 요청이 들어왔을 때, 같은 endpoint의 마지막 호출 시점을 꺼내서 두 시각의 차이를 본다. 그 차이가 30초 미만이면 rehit 카운트를 증가시킨다.
최종 계산은 다음과 같다.
const rehitBase = Math.max(1, data.calls - 1);
const rehitWithin30sRate = data.rehitWithinWindowCalls / rehitBase;
여기서 중요한 건 이 값이 단순 중복과는 다른 의미를 가진다는 점이다. duplicateRate가 “구조적으로 다시 요청되고 있는가”를 보여준다면, rehitWithin30sRate는 “사용자가 짧은 시간 안에 같은 데이터를 다시 보는 패턴이 있는가”를 보여준다. 그래서 이 수치는 staleTime을 얼마나 공격적으로 줄지 또는 늘릴지를 생각할 때 직접적인 힌트가 된다.
avgMs — 평균 응답 시간은 어떻게 계산했는가
평균 응답 시간도 별도의 통계 라이브러리 없이 단순 누적합으로 계산했다.
먼저 요청이 끝날 때마다 응답 시간을 계속 더한다.
record.totalDurationMs += durationMs;
그리고 집계 시점에 calls로 나눈다.
const avg = data.calls > 0 ? data.totalDurationMs / data.calls : 0;
중요한 점은 평균을 매번 다시 계산하지 않았다는 것이다.
각 요청이 들어올 때 합계를 쌓아두고, 출력 시점에만 평균을 만든다.
이렇게 하면 상태 관리가 단순하고 성능 부담도 적을 것 같아서 이렇게 진행해봤다 ㅎㅎ
p95Ms — 왜 배열을 저장했고 어떻게 계산했는가
p95는 평균처럼 합계만으로는 구할 수 없다. 분포를 알아야 하기 때문에,
각 요청의 duration을 배열로 저장해야 한다.
record.durationsMs.push(durationMs);
그리고 나중에 정렬해서 95퍼센타일 인덱스를 찾는다.
const index = Math.max(0, Math.ceil((p / 100) * sorted.length) - 1);
return sorted[index] ?? 0;
즉 p95 계산의 핵심은 아래 두 단계다.
- endpoint별 응답 시간 배열 유지
- 집계 시 정렬 후 95% 지점 추출
1탄에서 평균보다 p95를 더 중요하게 봤던 이유는
“사용자는 평균이 아니라 가끔 느린 경험을 기억하기 때문”이었다.
여기서는 그 개념이 코드로 어떻게 구현됐는지를 보여준다.
errorRate — 실패 요청은 어떤 기준으로 셌는가
오류율은 status code를 기준으로 계산했다. 기준은 꽤 실용적이다.
if (status >= 400 || status === 0) {
record.errorCalls += 1;
}
즉 아래 경우를 모두 실패로 봤다.
- HTTP 4xx
- HTTP 5xx
- 네트워크 실패(status = 0)
- 서버에서 504로 처리한 타임아웃
최종 비율은 아래처럼 계산한다.
const errorRate = data.calls > 0 ? data.errorCalls / data.calls : 0;
이 값이 중요한 이유는 1탄에서 설명했듯, errorRate가 높아지는 순간 캐싱의 의미가 달라지기 때문이다.
이때부터 캐싱은 속도 개선이 아니라 방어 전략이 된다.
6️⃣ 각 지표를 한 번에 만드는 핵심 로직
위에서 지표를 하나씩 나눠 설명했지만,
실제 구현은 요청 1건이 들어올 때 아래와 같은 흐름으로 한 번에 누적된다.
function trackRequest(record, { durationMs, status, now }) {
record.calls += 1;
if (record.calls > 1) {
record.duplicateCalls += 1;
}
const prevCalledAt = record.calledAt[record.calledAt.length - 1];
if (typeof prevCalledAt === 'number' && now - prevCalledAt < 30_000) {
record.rehitWithinWindowCalls += 1;
}
record.totalDurationMs += durationMs;
record.durationsMs.push(durationMs);
if (status >= 400 || status === 0) {
record.errorCalls += 1;
}
record.calledAt.push(now);
}
이 코드의 핵심은 “나중에 복잡한 계산을 하기 위해, 요청 시점에 필요한 최소 상태만 누적한다”는 점이다.
이 접근을 쓰면 DB나 외부 분석 시스템 없이도 로컬 개발 환경에서 바로 API 패턴을 볼 수 있다.
또한 지표들이 서로 같은 기준으로 만들어지기 때문에 해석의 일관성도 좋아진다.
7️⃣ 출력 단계에서는 어떤 값을 실제로 봤는가
각 요청에서 누적된 상태는 주기적으로 집계해서 출력했다.
const avg = data.calls > 0 ? data.totalDurationMs / data.calls : 0;
const p95 = percentile(data.durationsMs, 95);
const duplicateRate = data.calls > 0 ? data.duplicateCalls / data.calls : 0;
const rehitBase = Math.max(1, data.calls - 1);
const rehitWithin30sRate = data.rehitWithinWindowCalls / rehitBase;
const errorRate = data.calls > 0 ? data.errorCalls / data.calls : 0;
그리고 이 값들을 표 형태로 확인했다.
console.table(rows);

8️⃣ TanStack Query 캐싱은 실제로 어떻게 적용했는가
여기서부터는 계측이 아니라 적용이다.
앞의 수치가 “어디를 캐싱할지”를 정했다면,
TanStack Query는 “그 캐싱을 어떤 방식으로 구현할지”를 담당한다.
기본적인 적용 형태는 아래와 같다.
(TanStack Query를 사용한 캐싱 전략은 따로 더 있으나 우선 간단하게 이 정도만 짚고 넘어가고자 한다.)
useQuery({
queryKey: ['user', 'me'],
queryFn: fetchUser,
staleTime: 30_000,
});
이 코드에서 중요한 건 두 가지다.
첫 번째는 queryKey다.
동일한 데이터를 바라보는 요청은 반드시 동일한 key를 써야 캐시가 공유된다. 즉 구조적 중복을 줄이려면,
fetch를 줄이는 것보다 먼저 key를 통일해야 한다.
두 번째는 staleTime이다.
1탄에서 정의한 rehitWithin30sRate가 높다면, 사용자는 짧은 시간 안에 같은 데이터를 다시 보는 경향이 있다는 뜻이다.
이때 30초 정도의 staleTime을 두면 불필요한 재요청을 줄이면서도 최신성 리스크를 크게 키우지 않을 수 있다고 생각했다.
즉 staleTime은 감으로 넣은 숫자가 아니라, 사용자 재방문 패턴을 반영한 값이 된다.
TanStack Query로 해결하고자 한 부분
코드 차원에서 TanStack Query를 적용한 목적은 단순한 속도 개선이 아니었다.
1) 동일 요청의 중복 제거
같은 데이터를 여러 컴포넌트에서 필요로 하더라도 queryKey가 같으면 캐시를 공유할 수 있다.
2) 짧은 시간 내 재호출 억제
rehitWithin30sRate가 높은 endpoint는 staleTime 설정만으로도 재요청을 상당히 줄일 수 있다.
3) 수정 이후 UI 즉시 반영
프로필 같은 데이터는 mutation 이후 setQueryData 또는 invalidateQueries로 캐시를 갱신할 수 있다.
queryClient.setQueryData(['user', 'me'], updatedUser);
queryClient.invalidateQueries(['user', 'me']);
즉 TanStack Query는 단순히 “캐시를 넣는 라이브러리”가 아니라,
1탄에서 정의한 중복 제거와 UX 개선을 프론트엔드 레벨에서 구체적으로 실현하는 도구로 사용됐다.
마무리
1탄에서 수치 기반 선택의 기준을 세웠다면, 이번 글에서는 그 기준이 어떻게 코드로 만들어졌는지를 살펴봤다. 중요한 건 지표가 어떤 복잡한 분석 시스템에서 나오는 것이 아니라, 요청이 발생하는 순간 필요한 상태를 꾸준히 누적함으로써 만들어졌다는 점이다.
calls는 호출마다 1씩 증가했고, duplicateRate는 두 번째 호출부터 중복으로 집계됐으며, rehitWithin30sRate는 직전 호출 시점과의 차이로 계산됐다.
avgMs와 p95Ms는 응답 시간을 누적하고 분포를 저장함으로써 만들었고, errorRate는 상태 코드를 기준으로 실패를 판단했다.
결국 이 구조의 핵심은 단순하다.
지표는 나중에 해석하는 값이 아니라,
요청이 쌓이는 방식 자체에서 만들어진다.
그리고 그렇게 얻은 수치가 TanStack Query의 queryKey, staleTime, 캐시 갱신 전략으로 연결되면서,
캐싱은 감이 아니라 코드로 검증 가능한 설계가 되었다.
