- Published on
Next.js 14 App Router 메모리 누수 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려지고, 응답 시간이 튀고, 컨테이너가 OOMKilled로 죽는 문제를 추적하다 보면 종종 결론이 같습니다. “메모리가 계속 쌓인다.” Next.js 14 App Router는 서버 컴포넌트, 라우트 핸들러, 서버 액션, 스트리밍 렌더링, 캐시 계층이 얽혀 있어서 누수가 발생하기 쉬운 지점이 여러 군데 있습니다.
이 글은 “이론적 가능성”이 아니라, 운영에서 반복적으로 보이는 Next.js 14 App Router 메모리 누수 9가지 패턴을 원인과 해결책 중심으로 정리합니다.
아래 내용을 보기 전에 한 가지 전제를 잡겠습니다.
- 진짜 누수: 요청이 끝났는데도 참조가 남아 GC가 못 치우는 상태
- 누수처럼 보이는 증가: 캐시, 버퍼, 커넥션 풀, 스트리밍 백프레셔로 인해 “한동안” 메모리가 유지되는 상태
둘 다 운영 장애로 이어질 수 있으니, 관찰 지표와 수정 방향을 함께 다룹니다.
먼저 점검할 관측 포인트
1) 프로세스 메모리 vs 힙 메모리 구분
Node 프로세스 RSS가 오르는데 heapUsed는 일정하면, 네이티브 버퍼(예: Buffer), 압축/암호화, 이미지 처리, 커넥션, 런타임 내부 캐시일 가능성이 큽니다.
// app/api/health/memory/route.js
export async function GET() {
const m = process.memoryUsage()
return Response.json({
rss: m.rss,
heapTotal: m.heapTotal,
heapUsed: m.heapUsed,
external: m.external,
arrayBuffers: m.arrayBuffers,
})
}
2) 스냅샷과 할당 프로파일
운영에서 바로 힙 스냅샷을 뜨기 어렵다면, 먼저 로컬/스테이징에서 동일 트래픽을 재현하고 --inspect 또는 heapdump로 비교하세요.
3) “요청 종료 후에도 남는 참조”를 찾기
대부분의 누수는 결국 아래 중 하나입니다.
- 전역 변수/싱글톤에 요청 단위 데이터를 저장
- 이벤트 리스너/타이머가 해제되지 않음
- 스트림/응답이 끝나지 않음
1. 전역 변수에 요청 단위 데이터 누적
App Router에서 흔히 하는 실수는, 모듈 스코프(전역)에 요청별 데이터를 푸시하는 것입니다. 서버는 멀티 요청을 처리하므로, 이 배열은 계속 커집니다.
// ❌ 나쁜 예: 요청마다 누적
const auditLogs = []
export async function POST(req) {
const body = await req.json()
auditLogs.push({ at: Date.now(), body })
return Response.json({ ok: true })
}
해결
- 요청 단위 데이터는 요청 범위에서만 사용
- 꼭 저장해야 한다면 외부 저장소(DB, Redis, 로그 파이프라인)로 내보내기
// ✅ 좋은 예: 요청 범위에서만 사용
export async function POST(req) {
const body = await req.json()
const audit = { at: Date.now(), body }
// 예: 외부로 전송(로그 수집, DB 등)
// await sendAudit(audit)
return Response.json({ ok: true })
}
2. 서버에서 EventEmitter 리스너 누적
서버 액션이나 라우트 핸들러에서 이벤트 리스너를 매 요청마다 등록하고 해제하지 않으면, 리스너가 계속 쌓여 메모리와 CPU를 동시에 잡아먹습니다.
import { EventEmitter } from 'node:events'
const bus = new EventEmitter()
export async function GET() {
// ❌ 요청마다 리스너 추가
bus.on('tick', () => {
// ...
})
return new Response('ok')
}
해결
- 리스너는 앱 부팅 시 1회만 등록
- 요청 범위에서 등록해야 한다면
once사용 또는finally에서 제거
import { EventEmitter } from 'node:events'
const bus = new EventEmitter()
export async function GET() {
const handler = () => {}
bus.on('tick', handler)
try {
return new Response('ok')
} finally {
bus.off('tick', handler)
}
}
3. setInterval 또는 장수 타이머가 서버에서 해제되지 않음
App Router의 서버 코드에서 setInterval을 만들고 해제하지 않으면, 서버 프로세스가 살아있는 동안 계속 실행되며 클로저가 참조하는 객체가 GC 대상에서 제외될 수 있습니다.
// ❌ 나쁜 예
let started = false
export async function GET() {
if (!started) {
started = true
setInterval(() => {
// 주기 작업
}, 1000)
}
return new Response('ok')
}
해결
- 주기 작업은 별도 워커/크론으로 분리
- 부득이하면 서버 시작 시점에만 만들고 종료 훅에서 정리
// ✅ 최소한의 방어: interval 핸들 보관
let intervalId
export async function GET() {
if (!intervalId) {
intervalId = setInterval(() => {}, 1000)
}
return new Response('ok')
}
운영 관점에서 주기 작업은 웹 서버와 분리하는 편이 안전합니다.
4. fetch 응답을 끝까지 소비하지 않아 스트림/소켓이 남음
Node의 fetch는 응답 바디가 스트림입니다. 에러 처리나 조건 분기에서 바디를 소비하지 않고 반환하면, 연결이 유지되거나 내부 버퍼가 예상보다 오래 남을 수 있습니다.
export async function GET() {
const res = await fetch('https://example.com/large')
if (!res.ok) {
// ❌ 바디를 읽지 않고 바로 반환
return new Response('upstream error', { status: 502 })
}
return new Response(await res.text())
}
해결
- 실패 케이스에서도 바디를 읽거나 취소
export async function GET() {
const res = await fetch('https://example.com/large')
if (!res.ok) {
// ✅ 바디 소비 후 종료
await res.arrayBuffer().catch(() => {})
return new Response('upstream error', { status: 502 })
}
return new Response(await res.text())
}
대용량이면 res.body?.cancel()을 고려할 수 있습니다.
5. 대용량 파일/이미지를 arrayBuffer로 통째로 올림
App Router에서 업로드/다운로드를 처리할 때, 편의상 arrayBuffer()로 전부 메모리에 올리면 요청이 몰릴 때 쉽게 OOM으로 갑니다. 이는 “누수”라기보다 “메모리 폭증”이지만, 현상은 동일하게 서버가 죽습니다.
// ❌ 나쁜 예: 대용량을 통째로 메모리에
export async function POST(req) {
const buf = await req.arrayBuffer()
// ... 처리
return Response.json({ size: buf.byteLength })
}
해결
- 스트리밍 처리로 전환
- 가능하면 스토리지로 바로 파이프
// ✅ 예시: 스트림 존재 여부 확인(환경에 따라 다름)
export async function POST(req) {
if (!req.body) return new Response('no body', { status: 400 })
// 여기서 req.body를 바로 파이프하는 구조로 설계
// 예: 업로드 서버, S3 presigned URL, 또는 스트리밍 파서 사용
return Response.json({ ok: true })
}
6. 서버 컴포넌트에서 “큰 객체”를 props로 계속 전달
서버 컴포넌트는 렌더링 중에 큰 데이터를 들고 다닐 수 있습니다. 특히 페이지 단에서 대용량 배열/원본 JSON을 만들고 여러 컴포넌트로 props 전달하면, 직렬화/렌더링 과정에서 메모리 피크가 커집니다.
// ❌ 나쁜 예: 큰 데이터를 그대로 전달
export default async function Page() {
const big = await getHugeRows() // 수만 건
return <Table rows={big} />
}
해결
- 필요한 필드만 선택
- 페이지네이션/커서 기반 로딩
- 테이블 렌더는 클라이언트로 넘기더라도 데이터는 쪼개서 전달
export default async function Page() {
const rows = await getRows({ limit: 100 })
const slim = rows.map(r => ({ id: r.id, name: r.name }))
return <Table rows={slim} />
}
DB 접근 최적화 관점은 서버 메모리와 직결됩니다. 대량 로딩이 반복된다면 쿼리 전략도 함께 점검하세요. 예를 들어 JPA 기반 백엔드라면 N+1로 불필요한 데이터가 폭증할 수 있습니다. 관련해서는 Spring Boot JPA N+1 폭탄 - 배치·페치조인 튜닝도 함께 참고할 만합니다.
7. unstable_cache 또는 데이터 캐시를 “무제한 키”로 사용
Next.js의 캐시 계층은 매우 강력하지만, 캐시 키 설계를 잘못하면 사실상 메모리/스토리지에 무한히 쌓입니다.
- 사용자별로 다른 값(예:
userId)을 키에 넣고 - TTL 또는 revalidate 없이
- 요청마다 새로운 키가 만들어지면
캐시가 누수처럼 누적됩니다.
import { unstable_cache } from 'next/cache'
// ❌ 나쁜 예: userId가 무한히 들어오면 키도 무한
export const getProfile = unstable_cache(
async (userId) => fetchProfile(userId),
['profile']
)
해결
- 캐시 대상은 “공유 가능한 데이터”로 제한
- 사용자별 데이터는 캐시하더라도 TTL을 짧게
- 키 설계를 명시적으로
import { unstable_cache } from 'next/cache'
export const getPublicConfig = unstable_cache(
async () => fetch('https://example.com/config').then(r => r.json()),
['public-config'],
{ revalidate: 60 }
)
8. 개발 모드에서 HMR로 인해 싱글톤이 중복 생성되어 착시
개발 모드에서는 모듈이 재로딩되며 전역 싱글톤이 여러 번 만들어질 수 있습니다. 이때 “메모리가 계속 오른다”가 재현되지만, 프로덕션에서는 동일하지 않을 수 있습니다.
대표적으로 DB 클라이언트(Prisma 등)를 매번 새로 만들면 개발 중 연결/메모리가 증가합니다.
// ❌ 나쁜 예
import { PrismaClient } from '@prisma/client'
export const prisma = new PrismaClient()
해결
개발 모드에서만 globalThis에 붙여 재사용합니다.
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis
export const prisma =
globalForPrisma.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
또한 Node 런타임/모듈 시스템 이슈로 의도치 않게 중복 로딩이 발생할 때가 있습니다. ESM 전환 과정에서 로딩 방식이 바뀌며 문제가 커질 수 있으니, 필요하면 Node 22에서 require() 막힘? ESM 전환 실전도 같이 확인하세요.
9. 클라이언트 컴포넌트에서 구독/리스너 정리 누락
App Router는 서버 이슈만 있는 게 아닙니다. 클라이언트 컴포넌트에서 라우팅이 잦은 서비스는, 구독 해제를 빼먹으면 탭을 오래 켜둘수록 메모리가 상승합니다.
addEventListener해제 누락setInterval해제 누락- WebSocket, SSE, BroadcastChannel 미정리
- 상태 관리 store 구독 미정리
'use client'
import { useEffect } from 'react'
export default function Page() {
useEffect(() => {
const onScroll = () => {}
window.addEventListener('scroll', onScroll)
// ❌ cleanup 없음
}, [])
return null
}
해결
useEffect에서 반드시 cleanup을 반환하세요.
'use client'
import { useEffect } from 'react'
export default function Page() {
useEffect(() => {
const onScroll = () => {}
window.addEventListener('scroll', onScroll)
return () => {
window.removeEventListener('scroll', onScroll)
}
}, [])
return null
}
클라이언트에서 메모리 누수는 결국 INP 같은 체감 성능 지표 악화로 이어집니다. 장시간 사용 시 버벅임이 있다면 Chrome INP 나쁨 - Long Task 쪼개기 실전 가이드 관점에서 함께 점검하면 원인 분리가 빨라집니다.
운영에서의 빠른 진단 체크리스트
서버(App Router) 쪽
- 요청 수가 늘수록 RSS가 선형 증가하는가
- 힙 스냅샷에서 특정 배열/맵이 계속 커지는가
EventEmitter리스너 경고(맥스 리스너) 로그가 있는가- 업스트림
fetch실패 시 바디 소비/취소가 되는가 - 대용량 바디를
arrayBuffer()로 받고 있지 않은가 - 캐시 키가 사용자/세션 단위로 무한히 생성되지 않는가
클라이언트 쪽
- 라우트 이동을 반복할 때 이벤트 리스너 수가 증가하는가
- WebSocket, SSE 연결이 페이지 이동 후에도 남는가
- 긴 세션에서 INP, 메모리 스냅샷이 악화되는가
마무리: “누수”는 대부분 구조 문제다
Next.js 14 App Router의 메모리 문제는 프레임워크가 나쁘다기보다, 서버와 클라이언트 경계가 흐려진 구조에서 “요청 단위”와 “프로세스 단위”의 수명을 헷갈릴 때 자주 발생합니다.
- 전역에 요청 데이터를 두지 않기
- 리스너/타이머/구독은 생성과 해제를 한 쌍으로 관리하기
- 스트리밍/대용량 처리는 메모리에 올리지 않기
- 캐시는 키 설계와 TTL을 먼저 정하기
위 9가지를 체크해도 해결이 어렵다면, 현재 배포 환경(서버리스인지, Node 런타임인지, 컨테이너인지), 트래픽 패턴, 메모리 그래프(힙/RSS), 재현 경로를 기준으로 케이스를 더 좁혀야 합니다.