Development Log
Next.js SSR V8 Heap OOM: QueryClient 생명주기와 gcTime 개선 사례
Next.js SSR에서 반복된 V8 heap OOM을 QueryClient 생명주기와 gcTime 관점에서 추적하고, 메모리 사용량을 낮춘 과정을 정리한 사례입니다.
- #Next.js
- #TanStack Query
- #SSR
- #Performance
# 1. 장애 개요2026-04-30 운영 EKS의 front-q1 SSR 프로세스에서 Node.js V8 heap OOM이 발생했다. 동일 Pod의 컨테이너 restart count가 0 → 7로 증가했고, Ready 상태는 반복적으로 1 → 0 → 1로 전환됐다. 서버 QueryClient 생명주기와 SSR 캐시 timer 정책을 개선한 뒤, 동일 로컬 반복 요청에서
heapUsed안정 구간이 약 155MB → 93MB로 감소했다.
| 항목 | 내용 |
| 발생 일시 | 2026-04-30 |
| 대상 | U+tv모바일 운영 EKS의 front-q1 SSR 프로세스 |
| 사용자 규모 | 현재 기준 일일 총 진입 약 **5.17만** 규모로 운영 중인 서비스 |
| 직접 확인된 장애 | V8 `JavaScript heap out of memory` Fatal Error와 동일 Pod 컨테이너의 반복 재시작 |
| 운영 영향 | 동일 Pod restart **0 → 7**, Ready 반복 하락, 동일 ReplicaSet 여러 Pod에 restart **5\~8회** 누적 |
| 코드 분석 기반 주요 원인 후보 | 서버 QueryClient의 요청 간 공유 가능성과 SSR에 적용된 유한한 `gcTime`으로 인한 timer·캐시 참조 유지 |
현재 자료가 직접 증명하는 것은 Node.js V8 heap OOM → 프로세스 종료 → 컨테이너 restart다. 장애 당시 Kubernetes Last State의
reason: OOMKilled또는 Exit Code 137이 확인되기 전까지는 “EKS OOM Kill”이라고 단정하지 않는다.
2. 운영 증거
2.1 장애 당시 실행 환경
배포 설정 저장소에서 front-q1 컨테이너의 memory request/limit이 500Mi로 설정된 것을 확인했다. Live Manifest 이력과 정확히 일치하는지는 추가 확인 항목으로 남겼다.
그림 1. 장애 당시 설정 원본 - memory request/limit 500Mi
2.2 V8 Heap OOM 직접 증거
장애 로그에서 Mark-Compact GC가 반복된 뒤 Allocation failed - JavaScript heap out of memory로 프로세스가 종료됐다. Old Space 사용량은 약 240~260MB 부근에서 충분히 회수되지 않았다.
그림 2. V8 GC 실패와 JavaScript heap out of memory Fatal Error
이 로그는 V8 heap OOM을 직접 입증하지만, 표시된 253~259MB 구간만으로 정확한
heap_size_limit이 256MiB였다고 확정하지 않는다.
2.3 컨테이너 재시작 영향
K9s에서는 동일 Q1 ReplicaSet의 여러 Pod에 RESTARTS 5~8회가 누적됐다.
그림 3. front-q1 Pod 다수의 RESTARTS 누적
동일 Pod
...-dcxk5만 고정한 Datadog 지표에서는 container.restarts가 0 → 7로 계단식 증가했다. 핵심 장애 구간은 약 13:35~14:16이며, 현재 문서에서는 검증 가능한 범위인 수분 단위 반복 재시작으로 표현한다.
그림 4. 동일 Pod의 container restart count 0 → 7
2.4 Restart·Ready·Memory 상관관계
같은 Pod와 같은 시간축에서 다음 세 지표를 함께 확인했다.
container.restarts: 0 → 7container.ready: 1에서 반복적으로 0으로 하락한 뒤 복귀memory.working_set: 약 230~370MiB 범위에서 상승 후 급락 반복 그림 5. 동일 Pod의 restart·ready·memory working set 동시 관측동일 시간대에 working set 상승·급락, V8 heap OOM, 컨테이너 restart, Ready 하락·복귀가 함께 관찰됐다. 이를 결합하면 working set 상승·급락 → V8 heap OOM → 프로세스 종료 → 컨테이너 restart → Ready 복귀 흐름과 시간적으로 일치한다. working set 그래프만으로 원인을 단독 입증하는 것은 아니며, Fatal 로그와 상태 지표를 함께 해석한 결과다.
3. 원인 분석
3.1 서버 QueryClient 생명주기
변경 전에는 서버용 QueryClient 생성 분기가 주석 처리되어 서버에서도 모듈 전역 queryClient 재사용 경로를 탈 수 있었다.
// 서버 환경에서는 항상 새 QueryClient 인스턴스를 생성
// const isClient = checkIsClient();
// if (!isClient) {
// return makeQueryClient(config);
// }
if (!queryClient || JSON.stringify(config) !== JSON.stringify(currentQueryClientConfig)) {
queryClient = makeQueryClient(config);
currentQueryClientConfig = config;
}
return queryClient;
브라우저에서는 사용자 앱 하나가 QueryClient를 오래 재사용하는 것이 자연스럽지만, SSR 서버는 한 프로세스에서 여러 사용자의 요청을 처리한다. 서버 QueryClient가 요청 단위로 끊기지 않으면 Prefetch 데이터가 요청 종료 후에도 같은 프로세스에 남거나, 사용자별 캐시가 같은 보관함에 섞일 위험이 있다.
3.2 SSR에 적용된 유한한 gcTime
기존 공통 설정의 gcTime: 5분이 SSR Prefetch에도 적용될 수 있었다. 유한한 gcTime은 캐시 정리를 위한 timer를 만들 수 있으며, 요청이 끝난 뒤에도 timer와 관련 QueryClient·캐시 참조가 일정 시간 남을 수 있다.
트래픽과 SSR Prefetch 대상이 늘어나면 요청별 캐시와 timer가 동시에 누적되고, V8이 충분히 회수하지 못할 경우 heap OOM으로 이어질 수 있다.
3.3 원인 해석 범위
| 구분 | 판단 |
| 직접 확인 | V8 heap OOM, 동일 Pod 컨테이너 restart, Ready 하락, working set 상승·급락 |
| 코드 분석 기반 주요 원인 후보 | 서버 QueryClient 요청 간 공유 가능성, SSR 캐시 timer와 참조 수명주기 |
| 단정하지 않는 내용 | QueryClient가 유일한 root cause였다는 주장, Kubernetes `OOMKilled`, 정확한 V8 heap 상한 |
4. 수정 내용
4.1 서버와 브라우저의 QueryClient 분리
팀과 함께 브라우저는 싱글톤을 유지하고, 서버는 동일 렌더 요청 안에서만 공유되는 QueryClient를 사용하도록 분리했다.
const getServerQueryClient = cache(() =>
makeQueryClient({
defaultOptions: {
queries: { gcTime: Infinity },
},
})
);
export const getQueryClient = (
config: QueryClientConfig = DEFAULT_QUERY_CLIENT_CONFIG
): QueryClient => {
if (!checkIsClient()) {
return getServerQueryClient();
}
if (!queryClient || JSON.stringify(config) !== JSON.stringify(currentQueryClientConfig)) {
queryClient = makeQueryClient(config);
currentQueryClientConfig = config;
}
return queryClient;
};
React.cache()는 서버 프로세스 전체에서 영구 공유하는 싱글톤이 아니라 동일한 서버 렌더 요청 안에서 호출 결과를 재사용한다. 요청이 끝나 참조가 해제되면 QueryClient 전체가 V8 GC 대상이 될 수 있다.
4.2 서버 gcTime 정책 조정
서버 QueryClient 기본 정책과 주요 SSR Prefetch 경로에 gcTime: Infinity를 적용했다.
await queryClient.prefetchQuery({
...utvPanelQueryOptions,
gcTime: Infinity,
});
await queryClient.prefetchInfiniteQuery({
...infiniteQueryOptions,
gcTime: Infinity,
});
이 설정은 서버 데이터를 영구 보관하려는 목적이 아니다. 쿼리별 삭제 timer 등록을 피하고 요청 종료 후 QueryClient 전체가 GC 대상이 되도록 하는 것이 목적이다. 실제 회수 시점은 V8이 결정한다.
4.3 Node.js 메모리 계측
process.memoryUsage()를 5초 주기로 기록해 rss, heapTotal, heapUsed, external, arrayBuffers를 비교했다. 검증 완료 후 임시 계측 코드는 운영 코드에서 제거했다.
const memory = process.memoryUsage();
console.info(JSON.stringify({
event: 'node_memory_usage',
pid: process.pid,
uptime_sec: Math.round(process.uptime()),
rss_mb: toMb(memory.rss),
heap_total_mb: toMb(memory.heapTotal),
heap_used_mb: toMb(memory.heapUsed),
external_mb: toMb(memory.external),
array_buffers_mb: toMb(memory.arrayBuffers),
app_mode: process.env.NEXT_PUBLIC_APP_MODE,
}));
4.4 적용 범위와 커밋
| 영역 | 변경 내용 | 관련 커밋 |
| QueryClient 생명주기 | 서버·브라우저 분리, 서버 요청 단위 QueryClient 구성 | `bb218cac` |
| 서버 기본 gcTime | 서버 QueryClient의 timer 미등록 정책 | `944e4d1c`, `6d0c783a`, `b2379af7` |
| SSR Prefetch | 홈·상세·시리즈·더보기·탐색·Navigation 경로에 `gcTime: Infinity` 명시 | `11d872c2` |
| 메모리 계측 | 계측 추가 → 로컬 범위 조정 → 검증 후 제거 | `232d163d`, `1d075465`, `ae525c66` |
5. 검증 결과
5.1 로컬 반복 요청 전후
동일한 로컬 실행·반복 요청 조건에서 실제 JavaScript 힙 사용량인 heapUsed의 안정 구간을 비교했다.
| 구분 | `heapUsed` 안정 구간 | 대표값 |
| 개선 전 | 154.74\~154.85MB | 약 **154.8MB** |
| 개선 후 | 93.17\~93.27MB | 약 **93.2MB** |
| 변화 | 약 **61.6MB 감소** | 약 **39.8% 감소** |
개선 전: heapUsed 약 154.8MB 구간
개선 후: heapUsed 약 93.2MB 구간

이 결과는
app_mode: local의 동일 조건 반복 요청 테스트다. “운영 서버 전체 메모리가 40% 감소했다”고 표현하지 않고, 로컬 반복 요청에서 Node.js heapUsed 안정 구간이 약 40% 감소했다고 표현한다.
5.2 배포 전후 운영 패턴
장애 및 배포 시점 - 2026-04-30 12:49~16:04
14:50 전후 배포·rollout 시점이 확인되며, 배포 전 Q1 Pod의 memory.working_set은 대략 120~380MiB 범위에서 반복적으로 상승·급락했다. 같은 시간대의 V8 OOM, restart 0 → 7, Ready 하락과 결합해 장애 구간의 메모리 불안정성을 확인했다.
**그림 6. 4/30 장애 및 배포 시점 - working set / memory limit 절대값
**

배포 이후 안정 구간 - 2026-05-01 15:00~05-07 23:59
배포 이후 Q1 Pod들의 memory.working_set은 대체로 약 130~170MiB의 낮고 평탄한 범위에서 수일간 유지됐다. 배포 과정에서 memory limit 변경도 함께 관찰되므로, 개선 효과를 Memory 사용률(%)에 단독 귀속하지 않고 working set 절대값과 장기 안정성을 운영 정황으로 사용한다.
**그림 7. 배포 이후 장기 관찰 - working set / memory limit 절대값
**

| 구분 | 장애 및 배포 시점 | 배포 이후 안정 구간 |
| 기간 | 4/30 12:49\~16:04 | 5/1 15:00\~5/7 23:59 |
| `working_set` | 약 120\~380MiB, 반복 상승·급락 | 약 130\~170MiB, 비교적 평탄하게 유지 |
| 상태 지표 | restart 0 → 7, Ready 반복 하락 | 장기 working set 안정 정황 확인 |
| 해석 | V8 OOM 장애와 rollout 시점 | 배포 이후 운영 안정화 정황 |
memory limit이 배포 전후 변경됐기 때문에
working_set / memory.limits × 100비율을 개선율로 직접 비교하지 않는다. 이번 운영 근거는 실제 working set 절대값과 장기 패턴에 둔다.
5.3 현재 배포 상태
현재 Datadog Kubernetes Explorer에서는 memory request/limit 1000Mi, Ready/Current/Available 8/8로 확인된다.
그림 8. 현재 front-q1 Deployment - memory 1000Mi, Ready 8/8
이 화면은 4/30 장애 당시가 아닌 현재 상태다. 따라서 “메모리 2배 상향이 장애를 해결했다”는 단독 근거로 사용하지 않는다.
6. 결과와 해석 한계
확인된 결과
- V8 heap OOM과 동일 Pod 컨테이너의 반복 재시작을 로그·지표로 확인
- 동일 Pod의 restart 0 → 7, Ready 반복 하락, working set 상승·급락 확인
- 서버 QueryClient 생명주기와 SSR 캐시 timer 정책 개선
- 로컬 반복 요청에서
heapUsed안정 구간 약 155MB → 93MB - 배포 이후 Q1 Pod의
working_set이 약 130~170MiB 수준에서 수일간 비교적 안정적으로 유지되는 정황 확인
남은 확인 항목
- 장애 당시 이미지·환경 기준 V8
heap_size_limit - 당시
NODE_OPTIONS와--max-old-space-size적용 여부 - 컨테이너 Last State·Exit Code·Reason과 Kubernetes
OOMKilled여부 - 4/30 당시 Deployment·Helm·values 또는 이미지 태그 이력
- 배포 후 동일 Pod 기준 restart 0건과 Ready 안정 상태의 관찰 기간
7. 참고 자료
- TanStack Query SSR 가이드
- TanStack Query timeoutManager
- React cache
- Node.js --max-old-space-size
- Kubernetes 컨테이너 메모리 제한
- 유사 사례: Next.js SSR에서 TanStack Query gcTime이 만든 OOM
내부 기술 분석용 문서. 외부 제출 시 AWS Account ID, 사설 IP, ECR 주소, Pod·Node hash, Secret 이름 등 내부 식별자를 마스킹한다. 경력기술서에는 원본 화면보다 문제 → 진단 → 수정 → 검증의 흐름과 검증 가능한 수치만 사용한다.