메모리 누수 해결을 위한 여정 - gcTime 한 줄이 SSR을 죽인 진짜 이유
이전 글에서 범인으로 지목된 gcTime: 24h. 그런데 정말 '캐시가 24시간 살아 있어서' 누수가 났을까? TanStack Query 소스 두 파일을 열어 SSR 분기의 실제 메커니즘을 따라간 기록.
이전 글에서 메인 팝업 컴포넌트의 gcTime: 24h 한 줄이 서버 OOM의 직접 원인임을 확인하고 시리즈를 마무리했었습니다. 결론은 분명했지만, 글을 올린 뒤에도 한 가지 질문이 계속 남았습니다.
"왜 클라이언트에서는 같은 옵션이 문제를 일으키지 않았을까?"
이 질문의 답을 찾기 위해 가벼운 마음으로 재현 레포(gctime-oom)를 따로 만들었는데, 결과적으로 이전 글에서 제가 설명했던 메커니즘 자체가 절반쯤 틀렸다는 사실을 알게 됐습니다. 이번 편은 그 정정 기록이자, TanStack Query 소스 두 파일로 추적한 SSR gcTime 누수의 정확한 동작에 대한 글입니다.
1. 재현 환경 — 더 가혹하게
이전 글에서 사용했던 부하 테스트(--max-old-space-size=128, 500회 요청)를 그대로 가져와 한도를 더 조였습니다.
- Next.js 16 + React 19 + TanStack Query v5 + next-intl
next build && next start(production)NODE_OPTIONS='--max-old-space-size=64'— 절반인 64MB로 축소for i in {1..500}; do curl http://localhost:3000/ko; done
결과는 209번째 요청에서 FATAL ERROR: Ineffective mark-compacts near heap limit 으로 프로세스 사망. 이전 글의 패턴이 더 작은 힙에서 그대로 재현됐습니다.
그리고 useUsers 훅에서 gcTime: 24h 한 줄만 지우면, 같은 부하에서 500/500 정상 처리. RSS는 plateau를 찍은 뒤 자발적으로 회수됩니다.
[mem] t=0s rss=164MB
[mem] t=1s rss=184MB
[curl] progress req=200 ok=200
<--- Last few GCs --->
46463 ms: Mark-Compact (reduce) 62.4 → 61.9 MB
46479 ms: Mark-Compact (reduce) 62.9 → 61.9 MB
FATAL ERROR: Ineffective mark-compacts near heap limit
[curl] req=210 transport_failure rc=52
gcTime 라인을 제거한 뒤:
[mem] t=0s rss=165MB
[mem] t=4s rss=217MB peak=217MB ← 200req
[mem] t=9s rss=193MB delta=-24MB ← GC가 24MB 회수!
[mem] t=10s rss=187MB ← 500req 완주
[curl] done ok=500 bad=0 transport_fail=0
같은 부하, 같은 한도, 결과는 정반대. 한 줄이 모든 차이를 만듭니다.
2. 이전 글에서 제가 했던 설명 — 어디가 틀렸나
이전 글에서 저는 이렇게 적었습니다.
컴포넌트가 unmount → 쿼리가 inactive로 전환 →
gcTime카운트다운 시작 → 카운트다운 안에 같은 요청이 들어오면 리셋 → 트래픽이 많을수록 회수가 안 됨.
이 설명은 클라이언트 관점에서는 맞습니다. 그런데 우리가 본 OOM은 클라이언트가 아니라 SSR 서버에서 발생했고, 서버에서는 매 요청마다 새 QueryClient가 생성됩니다. "컴포넌트 unmount"라는 개념 자체가 적용되지 않습니다. 즉, 이전 글의 설명은 증상을 맞췄지만 메커니즘을 잘못 짚었습니다.
이 점은 글을 본 동료가 한 줄로 정확히 지적해줬습니다.
"내가 알기로는 설정을 안 하면
gcTime기본값이 Infinity 아니야?"
곧장 라이브러리 소스를 열었습니다.
3. 진짜 메커니즘 — TanStack Query 소스 두 곳
3.1 server default는 Infinity다
@tanstack/query-core/src/removable.ts L23-29:
protected updateGcTime(newGcTime: number | undefined): void {
// Default to 5 minutes (Infinity for server-side) if no gcTime is set
this.gcTime = Math.max(
this.gcTime || 0,
newGcTime ?? (isServer ? Infinity : 5 * 60 * 1000),
)
}클라이언트 default는 5분, 서버 default는 Infinity. 주석까지 친절하게 적혀 있습니다.
3.2 Infinity면 timer가 등록되지 않는다
같은 파일 L13-21:
protected scheduleGc(): void {
this.clearGcTimeout()
if (isValidTimeout(this.gcTime)) { // ← gate
this.#gcTimeout = timeoutManager.setTimeout(() => {
this.optionalRemove()
}, this.gcTime)
}
}utils.ts L93-95:
export function isValidTimeout(value: unknown): value is number {
return typeof value === 'number' && value >= 0 && value !== Infinity
}세 줄로 이어 읽으면:
useQuery옵션에gcTime을 안 주면 server에서는Infinity.isValidTimeout(Infinity)는 false.- 따라서
scheduleGc()의if가 거짓 →setTimeout호출 자체를 skip.
즉, server에서는 timer가 아예 등록되지 않습니다. cache를 reachable로 잡는 외부 참조가 없으니, request가 끝나면 QueryClient는 즉시 unreachable이 되어 GC 가능. 이게 TanStack이 의도한 SSR-friendly한 default입니다.
3.3 gcTime: 24h가 이 SSR 분기를 깨뜨린다
gcTime 옵션 |
server측 this.gcTime |
isValidTimeout |
timer 등록 | cache 잡힘 |
|---|---|---|---|---|
| 명시 안 함 | Infinity |
false | 안 됨 | 없음 |
0 |
0 |
true | 즉시 fire | 매우 짧게 |
5 * 60 * 1000 |
5min |
true | 5분 timer | 5분간 |
24h (이전 글의 코드) |
24h |
true | 24h timer | 24시간 |
명시값이 default 분기보다 우선이라, server-side default Infinity가 깨지고 setTimeout(24h)가 등록됩니다. 이 timer의 콜백이 클로저로 Query → QueryCache → QueryClient를 통째로 잡아 24시간 동안 reachable로 유지합니다.
요청 1회 = QueryClient 1개 + 24h 짜리 timer 1개. 요청이 누적되면 timer도 누적되고, timer가 잡고 있는 QueryClient들도 누적됩니다. 64MB old-space에 200개 정도 쌓이면 한도 초과.
3.4 V8이 회수하지 못하는 이유
24시간처럼 매우 긴 timer는 V8이 long-lived로 판단해 old generation으로 promote합니다. major(Mark-Compact) GC만이 회수 시도가 가능한데, timer가 reachable이라 회수 실패 → "Ineffective mark-compacts near heap limit" → fatal.
반대로 gcTime을 안 주면 timer 자체가 없으니, request가 끝나면 QueryClient가 즉시 unreachable이 되어 minor GC(scavenge)로도 자주 회수됩니다. 위 실험에서 본 217 → 187MB 자발 회수가 그 결과입니다.
4. 흔한 오해 정리 — 이전 글 독자에게 보내는 정정
이전 글에서 제가 남긴 (그리고 많은 글이 반복하는) 설명은 이렇습니다.
"
gcTime제거 = default 5분 → 5분 후에 회수돼서 살아난다"
틀렸습니다. client default만 5분이고 server default는 Infinity. 그리고 누수가 끊긴 진짜 이유는 "5분 후 timer가 fire돼서"가 아니라 "timer 자체가 등록되지 않아서" 입니다.
증거: 위 실험에서 RSS가 5분 timer를 기다릴 필요 없이 burst가 끝난 직후(10초 안)에 217 → 187MB로 떨어졌습니다. 애초에 기다릴 timer가 없었으니 가능한 회수입니다.
5. 그래서 — 라이브러리 버그인가?
아닙니다. 이건 알려진 footgun에 가깝습니다.
검색해 보니 정확히 같은 패턴이 커뮤니티에서 여러 번 보고됐습니다.
- TanStack/query #8136 —
gcTime is not working with SSR, but it works well with CSR— server default Infinity가 의도된 동작임을 메인테이너가 확인. - TanStack/query Discussion #3284 —
SSR and high memory consumption— per-requestQueryClient+ 유한gcTime= 누수,cacheTime: 0권장. - TanStack/router #7402 —
SSR memory leak under sustained load— autocannon 5분 / 50 concurrent 부하 시 33MB → 2.4GB로 폭증,gcTime: 0적용 시 plateau. 본 분석과 동일 결론. - vercel/next.js Discussion #77542 — Next.js 15 / React 19 환경에서도 동일 패턴 보고.
또 TanStack Query 공식 SSR 가이드에는 다음 문구가 박혀 있습니다.
If you are explicitly setting a non-Infinity
gcTimethen you will be responsible for clearing the cache early, and you can add a call toqueryClient.clear()after the request is handled and dehydrated state has been sent to the client.
한 가지 중요한 사실은, TanStack Query v3 시절에는 server default도 5분이었다는 것입니다. 그때부터 동일 누수가 보고됐고, v4에서 server default를 Infinity로 바꿔 라이브러리 차원에서 fix됐습니다. 우리가 본 누수는 사용자가 그 fix를 명시값으로 덮어 쓸 때만 재현됩니다.
| 측면 | 평가 |
|---|---|
| 라이브러리 버그? | No — 명시값을 존중하는 동작은 API 계약상 정상. v4 이후 default는 SSR-safe. |
| 라이브러리 sharp edge? | Yes — dev warning도, server cap도 없음. 같은 패턴이 2022년부터 반복 보고. |
| 공식 문서에 명시? | Yes — queryClient.clear() 호출이 사용자 책임. 단, 강조도가 약함. |
| 사용자 코드 1차 책임? | Yes — SSR에서 도는 query에 hooks 단에서 유한 gcTime 명시 + clear() 누락. |
6. 어떻게 고치나
이전 글에서는 "옵션을 제거한다"로 정리하고 끝냈지만, 실제로는 선택지가 더 있습니다. 우선순위 순입니다.
6.1 가장 단순한 fix — hooks 단에서 gcTime 빼기
export const useUsers = () => {
return useQuery({
queryKey: queryKeys.users,
queryFn: api.getUsers,
staleTime: 60 * 1000 * 60 * 12,
// gcTime 명시 X → server: Infinity (timer 미등록), client: 5min default
})
}6.2 클라이언트 캐시는 길게 두고 싶다면 — makeQueryClient에서 분기
import { isServer } from '@tanstack/react-query'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 12 * 60 * 60 * 1000,
gcTime: isServer ? Infinity : 24 * 60 * 60 * 1000, // server: timer 미등록
},
},
})
}이러면 hooks 단에서 굳이 옵션을 안 줘도 client만 24h가 적용되고, server는 leak-free입니다.
6.3 가장 정석 — prefetchQuery + <HydrationBoundary>
useUsers()를 client component에서 직접 호출하지 말고, server component에서 prefetch → dehydrate → client에서 hydrate하는 패턴으로 전환합니다. 서버 cache lifecycle이 요청 스코프와 일치해, 누수 가능성 자체가 사라집니다.
7. 디버깅 회고 — 한 줄로 OOM이 나는 시스템
이번 추적에서 다시 한 번 정리하게 된 것들.
- "메모리 회수가 안 되는" 신호는 V8이 친절히 알려줍니다. "Ineffective mark-compacts near heap limit"는 단순 OOM이 아니라 reachable leak이라는 강한 힌트입니다. 회수 가능한 garbage가 충분했다면 정상 GC로 처리됐을 것입니다.
- timer의 길이보다 timer의 존재 여부가 중요할 수 있습니다. Node에서는
setTimeout콜백이 클로저로 잡은 모든 것이 fire 또는clearTimeout까지 reachable입니다. 라이브러리가 default로 timer를 안 만드는 데에는 이유가 있습니다. - default는 라이브러리 작성자의 의도입니다. 명시값으로 덮을 땐, 그 default가 어떤 시나리오를 보호하고 있었는지 한 번 확인해 봅시다. SSR-safe default를 일반 옵션 한 줄로 깨는 건 흔한 footgun입니다.
- 추측보다 소스. "5분 default라서 회수된다"는 그럴듯한 추측이었지만 틀렸습니다. 라이브러리 코드 두 파일(
removable.ts,utils.ts)을 직접 읽고서야 정확한 메커니즘이 잡혔습니다. 이전 글을 쓸 때의 저는 이 단계를 생략했고, 그래서 결과는 맞췄지만 원리는 절반쯤 비껴 갔습니다. - Before/After 비교 + 정량 데이터로 확정.
gcTime한 줄을 빼고 같은 부하를 다시 돌려 RSS가 plateau → 회수되는 것까지 확인하니 가설이 사실로 굳어졌습니다. "고쳐 보고 같은 증상이 사라지는지"가 원인 분석의 마지막 증거입니다.
8. 한 줄 결론
server SSR마다 새
QueryClient가 생성되는 건 정상 패턴입니다. 문제는useUsers의gcTime: 24h가 TanStack Query의 SSR-friendly한 server default(Infinity→ timer 미등록)를 덮어써서, server에서도setTimeout(24h)가 매 요청 등록되고 그 timer가QueryClient를 24시간 reachable로 잡는 것입니다. hooks 단에서gcTime을 명시하지 않으면,isValidTimeout(Infinity) === false분기로 timer 자체가 만들어지지 않아 누수가 사라집니다.
라이브러리 한 옵션, 라이브러리 한 default, 라이브러리 한 가드 함수. 셋이 만나는 지점에서 production OOM이 나왔습니다. 이전 글에서 시리즈를 끝낸 줄 알았지만, "왜 클라이언트에서는 멀쩡한가" 라는 질문 하나가 한 편을 더 만들었습니다. SSR 환경에서 fetching 라이브러리를 쓸 땐, default를 덮을 때마다 한 번 더 의심해 봅시다.
참고
- TanStack Query — Server Rendering & Hydration 공식 가이드
@tanstack/query-core/src/removable.ts(v5.90.6)@tanstack/query-core/src/utils.ts(v5.90.6)- TanStack/query #8136 — gcTime is not working with SSR
- TanStack/query Discussion #3284 — SSR and high memory consumption
- TanStack/router #7402 — SSR memory leak under sustained load
- vercel/next.js Discussion #77542 — Memory leak when instantiating QueryClient per call