- Published on
Next.js App Router 메모리 폭주 - React 캐시·Zustand 누수
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Next.js App Router로 전환한 뒤 서버 메모리가 서서히 증가하다가 결국 OOM으로 죽는 케이스를 종종 봅니다. 특징은 이렇습니다.
- 트래픽이 일정해도 RSS가 계단식으로 상승
- 재시작하면 정상으로 돌아오지만 시간이 지나면 다시 증가
- 특정 API/페이지를 많이 호출할수록 더 빨리 악화
App Router의 서버 컴포넌트, React 캐시, 그리고 전역 상태 라이브러리(Zustand)가 결합되면 “의도치 않은 서버 상주 데이터”가 만들어지기 쉽습니다. 이 글에서는 React 캐시 오용으로 인한 무제한 메모이제이션과 **Zustand 스토어의 서버 공유(요청 간 상태 오염/누수)**를 중심으로 재현과 해결책을 정리합니다.
왜 App Router에서 누수가 더 자주 보일까
App Router는 서버 컴포넌트 기반이라 서버에서 실행되는 코드가 많습니다. 따라서 아래 실수가 곧바로 서버 메모리 증가로 이어집니다.
- 서버에서 실행되는 모듈 스코프에 “요청별 데이터”를 저장
cache()나unstable_cache()로 “키가 무한히 늘어나는 결과”를 캐싱- 전역 스토어(Zustand)를 서버에서 싱글턴으로 만들어 요청 간 공유
특히 개발 환경에서는 HMR과 리로딩이 섞여 증상이 가려지거나 과장될 수 있으니, **반드시 프로덕션 빌드(next build 후 next start)**로 재현하는 습관이 중요합니다.
1) React cache() 오용: 키 폭발로 메모리 폭주
React의 cache()는 함수 호출 결과를 메모이제이션합니다. 문제는 “캐시 키”가 사실상 함수 인자 조합이기 때문에, 인자가 무한히 다양해지면 캐시도 무한히 커집니다.
흔한 안티패턴: 사용자별 토큰/헤더/검색어를 그대로 키로 사용
아래는 서버 컴포넌트에서 흔히 하는 실수입니다. userId나 query가 다양해질수록 캐시 엔트리가 계속 쌓입니다.
// app/lib/user.ts
import { cache } from 'react'
export const getUserProfile = cache(async (userId: string) => {
// DB 조회라고 가정
return { userId, bio: '...' }
})
// app/profile/[userId]/page.tsx
import { getUserProfile } from '@/app/lib/user'
export default async function Page({ params }: { params: { userId: string } }) {
const profile = await getUserProfile(params.userId)
return <pre>{JSON.stringify(profile, null, 2)}</pre>
}
이 코드는 “기능적으로”는 맞지만, userId가 수만 명이면 서버 메모리에 수만 개 결과가 남습니다. 더 위험한 케이스는 cache()에 요청 헤더 전체나 JWT 같은 값을 인자로 넘기는 경우입니다.
import { cache } from 'react'
export const getSomething = cache(async (authHeader: string) => {
// authHeader 값이 요청마다 달라질 수 있음
return { ok: true }
})
해결 전략 A: cache()는 “소수의 키”에만 사용
cache()는 다음 조건일 때만 안전합니다.
- 인자 종류가 제한적(예: 고정된 설정 키, 언어 코드 몇 개)
- 결과가 거의 불변 또는 TTL로 갱신 가능
- 메모리 상주가 이득인 데이터
사용자별 데이터는 보통 캐시 계층을 메모리 대신 외부로 두는 편이 낫습니다(예: Redis). 혹은 Next.js의 데이터 캐시를 쓰더라도 키를 제한하고 TTL을 둬야 합니다.
해결 전략 B: Next.js unstable_cache()로 TTL과 키를 통제
unstable_cache()는 Next.js의 캐시 스토어를 사용하며, 키를 명시하고 revalidate를 줄 수 있습니다. “무한 키” 문제를 줄이려면 키 설계가 핵심입니다.
// app/lib/products.ts
import { unstable_cache } from 'next/cache'
export const getTopProducts = unstable_cache(
async () => {
// 상위 상품 20개 같은 제한된 결과
return [{ id: 1, name: 'A' }]
},
['top-products'],
{ revalidate: 60 }
)
사용자별 캐시가 정말 필요하다면, 키를 사용자 단위로 만들되 상한을 두거나(예: 최근 N명만) 외부 캐시로 옮기는 게 안전합니다.
해결 전략 C: 서버 메모리 캐시를 직접 만들 땐 LRU 필수
정말로 프로세스 메모리에 올려야 한다면, “무제한 Map”은 금지입니다. LRU 같은 축출 정책이 필요합니다.
// app/lib/lru.ts
import LRUCache from 'lru-cache'
export const userCache = new LRUCache<string, unknown>({
max: 1000, // 상한
ttl: 1000 * 60 * 5 // 5분
})
export async function getUserCached(userId: string) {
const hit = userCache.get(userId)
if (hit) return hit
const fresh = { userId, bio: '...' }
userCache.set(userId, fresh)
return fresh
}
핵심은 “서버 메모리는 유한”하다는 사실을 코드에 반영하는 것입니다.
2) Zustand 누수: 서버에서 싱글턴 스토어를 공유하는 실수
Zustand는 기본적으로 “전역 스토어”를 만들기 쉽습니다. 문제는 App Router에서 서버 코드가 모듈 스코프로 로딩되면, 그 전역 스토어가 요청 간 공유될 수 있다는 점입니다.
안티패턴: 서버에서 Zustand 스토어를 싱글턴으로 생성
// app/store/useSessionStore.ts
import { create } from 'zustand'
type SessionState = {
userId: string | null
setUserId: (id: string | null) => void
}
export const useSessionStore = create<SessionState>((set) => ({
userId: null,
setUserId: (id) => set({ userId: id })
}))
이걸 서버 컴포넌트나 서버 액션에서 직접 쓰거나(혹은 서버에서 import만 해도), 다음 문제가 생길 수 있습니다.
- 요청 A에서
userId를 세팅 - 요청 B가 같은 프로세스 스토어를 읽어버림(보안 사고)
- 스토어에 사용자별 데이터가 계속 쌓이면 메모리 증가
게다가 스토어에 “히스토리 배열”, “최근 조회 목록” 같은 것을 누적하면 누수처럼 보이는 현상이 더 쉽게 발생합니다.
올바른 패턴: 클라이언트에서만 스토어를 만들고, 요청별 초기화
App Router에서는 Zustand 스토어를 클라이언트 컴포넌트에서만 사용하고, 서버는 “초기 상태 프롭”만 내려주는 식으로 분리하는 게 안전합니다.
// app/providers.tsx
'use client'
import { useRef } from 'react'
import { createStore } from 'zustand/vanilla'
import { useStore } from 'zustand'
type SessionState = {
userId: string | null
setUserId: (id: string | null) => void
}
function createSessionStore(initialUserId: string | null) {
return createStore<SessionState>((set) => ({
userId: initialUserId,
setUserId: (id) => set({ userId: id })
}))
}
export function SessionProvider(
{ initialUserId, children }: { initialUserId: string | null, children: React.ReactNode }
) {
const storeRef = useRef<ReturnType<typeof createSessionStore> | null>(null)
if (!storeRef.current) {
storeRef.current = createSessionStore(initialUserId)
}
// 예시: 필요 시 Context로 storeRef.current를 내려서 useStore에 주입
return <>{children}</>
}
서버 컴포넌트는 다음처럼 “초기값만” 계산합니다.
// app/layout.tsx
import { cookies } from 'next/headers'
import { SessionProvider } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = cookies()
const userId = cookieStore.get('uid')?.value ?? null
return (
<html>
<body>
<SessionProvider initialUserId={userId}>{children}</SessionProvider>
</body>
</html>
)
}
요점은 서버 프로세스에 사용자 상태를 누적하지 말고, 클라이언트 런타임에서 사용자 단위로 스토어를 갖게 만드는 것입니다.
Zustand에서 특히 조심할 것: 구독 누적과 이벤트 리스너
다음 패턴은 클라이언트에서도 메모리 증가를 만들 수 있습니다.
store.subscribe()를 컴포넌트 바깥에서 호출하고 해제하지 않음window.addEventListener를 등록하고 cleanup 누락
// 안티패턴 예시
import { useSessionStore } from './useSessionStore'
useSessionStore.subscribe((s) => {
// 구독이 계속 남을 수 있음
console.log(s.userId)
})
구독은 React 생명주기 안에서 등록하고 반드시 해제하세요.
'use client'
import { useEffect } from 'react'
import { useSessionStore } from './useSessionStore'
export function DebugSubscriber() {
useEffect(() => {
const unsub = useSessionStore.subscribe((s) => {
console.log('userId', s.userId)
})
return () => unsub()
}, [])
return null
}
3) “누수처럼 보이는” 정상 동작도 있다: fetch 캐시와 렌더 캐시
메모리가 증가한다고 해서 항상 버그는 아닙니다. 다음은 정상적인 캐시/버퍼링이 “누수처럼” 보일 수 있는 지점입니다.
- Node.js의 힙이 커졌다가 GC 타이밍에 줄어드는 패턴
fetch의 캐시/중복 제거가 일정 기간 메모리를 점유- 이미지 최적화나 스트리밍 버퍼가 순간적으로 메모리 사용
하지만 트래픽에 비례해 단조 증가하고, GC 이후에도 바닥선이 계속 올라가면 실제 누수 가능성이 큽니다.
4) 재현과 진단: 어디서 새는지 빠르게 찾는 체크리스트
4-1. 프로덕션 모드로 실행
NODE_ENV=production next build
NODE_ENV=production next start
개발 모드의 HMR은 메모리 패턴을 왜곡합니다.
4-2. 간이 부하로 특정 라우트만 때리기
npx autocannon -c 20 -d 60 http://localhost:3000/profile/123
라우트별로 분리해서 때리면 “어느 페이지/액션이 범인인지” 빨리 좁혀집니다.
4-3. 힙 스냅샷/프로파일링
Node에서 힙 스냅샷을 떠서 어떤 객체가 늘어나는지 확인합니다.
node --inspect node_modules/.bin/next start
Chrome DevTools에서 Memory 탭으로 스냅샷을 비교하면, Map/Array가 특정 모듈에서 계속 늘어나는지 확인할 수 있습니다.
4-4. 의심 포인트 우선순위
- 모듈 스코프
Map/배열에 요청 데이터를 push cache()로 사용자별/요청별 키를 캐싱- Zustand를 서버에서 import해 전역 스토어로 사용
- 서버 액션에서 전역 컬렉션에 누적
5) 안전한 설계 패턴 모음
패턴 1: 요청별 데이터는 함수 스코프에만
export async function GET() {
const temp: string[] = []
temp.push('only for this request')
return Response.json({ ok: true })
}
모듈 스코프에 두면 프로세스 수명 동안 남습니다.
패턴 2: 캐시는 “상한”과 “만료”를 강제
- 프로세스 메모리 캐시라면 LRU/TTL
- 공유 캐시가 필요하면 Redis 같은 외부 캐시
- Next 데이터 캐시는 키를 제한하고
revalidate를 명시
패턴 3: Zustand는 클라이언트 전용 + 초기 상태 주입
- 서버에서 스토어 생성 금지
- 서버는 쿠키/세션을 읽고 초기값만 내려줌
- 구독은 cleanup 필수
6) 운영 대응: 당장 OOM을 막는 현실적인 방법
원인 수정이 최우선이지만, 서비스가 터지는 것을 막기 위한 응급 처치도 필요합니다.
- 컨테이너/프로세스 메모리 상한 설정
--max-old-space-size로 Node 힙 상한 설정
node --max-old-space-size=1024 node_modules/.bin/next start
- PM2/Kubernetes에서 메모리 기반 재시작(임시방편)
- 트래픽이 몰리는 라우트에 레이트 리밋
다만 자동 재시작은 “누수를 숨기는” 효과가 있으니, 힙 스냅샷과 함께 원인을 꼭 제거해야 합니다.
7) 결론: App Router 메모리 폭주의 80%는 키/스코프 문제
정리하면, Next.js App Router에서 메모리 폭주를 만드는 대표 원인은 다음 두 가지로 수렴합니다.
- React
cache()를 사용자별/요청별 데이터에 적용해 캐시 키가 무한히 증가 - Zustand 전역 스토어를 서버에서 싱글턴으로 만들어 요청 간 상태가 공유되거나 누적
해결의 방향도 단순합니다.
- 캐시는 TTL/LRU/키 제한으로 “유한화”
- 전역 상태는 클라이언트로 내리고 서버는 초기값만 제공
- 모듈 스코프에 요청 데이터를 저장하지 않기
운영 중 비슷한 패턴으로 “원인은 다른데 증상은 같은” 장애를 겪었다면, 병목과 튜닝을 체계적으로 접근하는 방법론은 Spring Boot 3 가상스레드 병목·튜닝 9단계도 참고할 만합니다. 또한 자동화 파이프라인에서 권한 문제로 진단/배포가 막히는 경우는 GitHub Actions 403 권한 오류 - GITHUB_TOKEN 설정법 글이 도움이 됩니다.