Published on

Next.js App Router 메모리 폭주 - React 캐시·Zustand 누수

Authors

Next.js App Router로 전환한 뒤 서버 메모리가 서서히 증가하다가 결국 OOM으로 죽는 케이스를 종종 봅니다. 특징은 이렇습니다.

  • 트래픽이 일정해도 RSS가 계단식으로 상승
  • 재시작하면 정상으로 돌아오지만 시간이 지나면 다시 증가
  • 특정 API/페이지를 많이 호출할수록 더 빨리 악화

App Router의 서버 컴포넌트, React 캐시, 그리고 전역 상태 라이브러리(Zustand)가 결합되면 “의도치 않은 서버 상주 데이터”가 만들어지기 쉽습니다. 이 글에서는 React 캐시 오용으로 인한 무제한 메모이제이션과 **Zustand 스토어의 서버 공유(요청 간 상태 오염/누수)**를 중심으로 재현과 해결책을 정리합니다.

왜 App Router에서 누수가 더 자주 보일까

App Router는 서버 컴포넌트 기반이라 서버에서 실행되는 코드가 많습니다. 따라서 아래 실수가 곧바로 서버 메모리 증가로 이어집니다.

  • 서버에서 실행되는 모듈 스코프에 “요청별 데이터”를 저장
  • cache()unstable_cache()로 “키가 무한히 늘어나는 결과”를 캐싱
  • 전역 스토어(Zustand)를 서버에서 싱글턴으로 만들어 요청 간 공유

특히 개발 환경에서는 HMR과 리로딩이 섞여 증상이 가려지거나 과장될 수 있으니, **반드시 프로덕션 빌드(next buildnext start)**로 재현하는 습관이 중요합니다.

1) React cache() 오용: 키 폭발로 메모리 폭주

React의 cache()는 함수 호출 결과를 메모이제이션합니다. 문제는 “캐시 키”가 사실상 함수 인자 조합이기 때문에, 인자가 무한히 다양해지면 캐시도 무한히 커집니다.

흔한 안티패턴: 사용자별 토큰/헤더/검색어를 그대로 키로 사용

아래는 서버 컴포넌트에서 흔히 하는 실수입니다. userIdquery가 다양해질수록 캐시 엔트리가 계속 쌓입니다.

// 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. 의심 포인트 우선순위

  1. 모듈 스코프 Map/배열에 요청 데이터를 push
  2. cache()로 사용자별/요청별 키를 캐싱
  3. Zustand를 서버에서 import해 전역 스토어로 사용
  4. 서버 액션에서 전역 컬렉션에 누적

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 설정법 글이 도움이 됩니다.