$ cd /blog
오류 해결

Next.js 서버 메모리 500MB, 범인은 이미지 최적화였습니다 — 환경변수 2개로 절반 줄인 실전기

Railway로 옮긴 Next.js 사이트가 500MB에서 플래토를 그려 원인을 실측으로 분해했습니다. sharp 이미지 최적화가 주범이었고, 코드 수정 없이 환경변수 2개로 같은 부하에서 444MB→247MB로 줄인 과정을 정리했습니다.


안녕하세요. Jay입니다!

지난 글에서 Next.js 사이트를 Vercel에서 Railway로 옮긴 과정을 정리했는데요, 이전을 마치고 하루 지나 메모리 차트를 열어보니 500MB 부근에서 수평선을 그리고 있었습니다. Railway는 컨테이너 리소스(메모리·CPU) 기준으로 과금하는 곳이라, 상시 500MB는 곧 상시 요금입니다. "Next.js 서버가 원래 이만큼 먹는 건가?"를 확인하려고 로컬에서 똑같이 재현해봤고, 결론부터 말하면 범인은 이미지 최적화(sharp) 였습니다. 코드 수정 없이 환경변수 2개로 같은 부하에서 절반 이하로 줄인 과정을 공유합니다.

⚠️ 2026-07-29 정정 — 힙 상한을 256MB로 잡았다가 서버가 죽었습니다. 이 글에 처음 적었던 --max-old-space-size=256너무 빠듯한 값이었습니다. 다음 날 실제로 OOM이 나면서 서버가 4분 동안 5번 재시작했습니다. 아래 처방은 512MB 기준으로 수정했고, 왜 그런지는 글 끝의 "후일담" 절에 그대로 적었습니다. 이미 256으로 설정하셨다면 지금 올려두시길 권합니다.

📈 증상 — 재시작하면 낮고, 하루 지나면 500MB

차트 패턴은 이랬습니다. 배포(재시작) 직후엔 수십 MB로 시작 → 몇 시간에 걸쳐 계단식으로 상승 → 500MB 부근에서 플래토. 계속 우상향하면 메모리 누수를 의심해야 하지만, 수평으로 멈추는 모양이라 누수는 아니었습니다. 그래도 "어디에 쌓여서 500까지 가는가"는 별개의 질문이죠.

추측으로 고치는 대신, 배포에 쓰는 standalone 빌드를 로컬에서 그대로 띄우고 프로세스 메모리(RSS)를 단계별로 측정했습니다. 방법은 간단합니다 — node .next/standalone/server.js로 서버를 올리고, curl로 실제 트래픽처럼 페이지를 순서대로 요청하면서 ps로 메모리를 찍는 겁니다.

🔬 실측 — 메모리가 쌓이는 세 곳

측정 결과, 메모리는 세 군데에서 쌓이고 있었습니다.

단계 RSS 원인
부팅 직후 ~90MB Next 서버 베이스
페이지 35개 서빙 후 ~220MB 라우트별 서버 모듈이 첫 방문 때 로드돼 상주 (+2~5MB/라우트)
이미지 18장 변환 후 444MB sharp 이미지 최적화 (+255MB)

첫 번째 상승(페이지 모듈)은 구조적인 겁니다. 페이지가 300개가 넘는 사이트라 검색 봇이 하루에 걸쳐 전 라우트를 훑으면 모듈이 하나씩 로드돼 계단식으로 오릅니다 — 프로덕션 차트의 완만한 상승 구간과 정확히 일치했습니다.

문제는 두 번째였습니다. next/image가 스크린샷 PNG를 WebP로 변환하는 순간 메모리가 수백 MB 단위로 뛰었고, 변환이 끝나도 OS에 반환되지 않았습니다. 이유를 찾아보니 두 가지가 겹쳐 있었어요.

  1. Next.js는 sharp의 동시 처리 스레드를 vCPU 수의 절반으로 설정하는데, 클라우드 공유 호스트는 vCPU가 크게 잡히는 경우가 많아 스레드가 필요 이상으로 늘어납니다.
  2. sharp가 쓰는 libvips는 내부 캐시와 메모리 풀을 유지해서, 한 번 커진 메모리가 잘 줄어들지 않습니다.

🛠 처방 — 환경변수 2개

코드는 한 줄도 고치지 않았습니다. Railway 서비스 Variables에 두 개를 추가했을 뿐입니다.

NODE_OPTIONS=--max-old-space-size=512   # V8 힙 상한 → GC가 부지런해짐
VIPS_CONCURRENCY=1                      # libvips 스레드 1개로 제한
  • --max-old-space-size=512: Node는 힙 상한이 넉넉하면 가비지 컬렉션을 게으르게 합니다. 상한을 걸면 GC가 일찍일찍 돌아서 JS 쪽 메모리가 눌러집니다. 상시 서버 + 메모리 과금 환경에서 특히 효과적입니다. 다만 값은 실측치의 두 배 정도로 잡으세요. 저는 처음에 실측 247MB에 딱 붙여 256으로 걸었다가 하루 만에 서버가 죽었습니다(아래 후일담).
  • VIPS_CONCURRENCY=1: sharp(libvips)의 파이프라인 스레드를 1개로 제한합니다. 스레드마다 잡는 버퍼가 사라져서 변환 시 메모리 폭이 크게 줄어듭니다.

같은 부하(페이지 35개 + 이미지 변환 18장)를 다시 돌린 결과입니다.

조건 최종 RSS
기본 설정 444MB
환경변수 2개 적용 247MB

응답 품질도 확인했습니다. 페이지 응답 4ms, 새 이미지 변환 139ms — 개인 사이트 트래픽에서는 체감 차이가 없는 수준입니다. 이미지 변환이 초당 수십 건씩 몰리는 서비스라면 스레드 1개가 병목이 될 수 있으니, 그런 경우엔 2~4로 타협하면 됩니다.

⚠️ 후일담 — 256MB로 걸었더니 하루 만에 서버가 죽었습니다 (2026-07-29 추가)

이 글을 올린 다음 날, 앱에서 "랭킹을 불러오지 못한다"는 제보를 받았습니다. 서버 로그를 열어보니 이렇게 찍혀 있었습니다.

FATAL ERROR: Ineffective mark-compacts near heap limit
             Allocation failed - JavaScript heap out of memory
[1:0x...] 31796 ms: Mark-Compact 253.6 (262.8) -> 251.4 (262.8) MB

4분 동안 5번 죽고 5번 재시작했습니다. 힙이 매번 253MB에서 한계에 부딪혔습니다. 재시작 자체는 0.2초면 끝나지만, 그 순간 들어온 요청은 전부 502를 받습니다. 사용자에게는 "랭킹 로딩 실패"로 보였고요.

원인은 제가 이 글에 적었던 그 값입니다. 실측 결과가 247MB였는데 상한을 256MB로 걸었으니 여유가 9MB뿐이었습니다. 평소에는 버티지만 부하가 조금만 얹히면 그대로 넘어갑니다.

방아쇠는 자동화 봇이었습니다. 로그를 보니 이런 요청이 계속 들어오고 있었습니다.

/images/logo.png' OR 675=(SELECT 675 FROM PG_SLEEP(15))--
/images/icon.png-1 waitfor delay '0:0:15' --

이미지 경로 뒤에 SQL 인젝션 문자열을 붙여 훑는 스캐너입니다. 인젝션 자체는 통하지 않지만(이미지 경로라 DB를 타지 않습니다), next/image가 이 쓰레기 URL마다 처리를 시도하면서 메모리를 씁니다. 빠듯한 상한과 만나 OOM으로 넘어간 겁니다.

여기서 배운 것을 정리하면 이렇습니다.

  • 상한은 실측치의 두 배로 잡으세요. 실측 247MB면 512가 적당합니다. 상한을 관측값에 딱 붙이는 건 최적화가 아니라 지뢰입니다.
  • 평상시 트래픽만 보고 정하지 마세요. 공개된 서버에는 봇이 옵니다. 사람 트래픽이 0이어도 스캐너는 옵니다.
  • 힙 상한이 실제로 적용됐는지 확인할 방법을 만들어두세요. 저는 /api/health에서 v8.getHeapStatistics().heap_size_limit을 함께 내려주도록 고쳤습니다. 값 하나 노출해두면 다음에 같은 일이 생겼을 때 로그를 뒤질 필요가 없습니다.
import v8 from 'node:v8';

export function GET() {
  const h = v8.getHeapStatistics();
  return Response.json({
    status: 'ok',
    heapLimitMB: Math.round(h.heap_size_limit / 1048576),
    heapUsedMB: Math.round(h.used_heap_size / 1048576),
  });
}

지금은 상한을 올려 heapLimitMB: 560, 사용량은 그 절반 아래에서 안정적으로 돌고 있습니다.

💡 Jay의 한 줄 — "플랫폼이 바뀌면 보류했던 최적화도 다시 꺼내보세요"

사실 --max-old-space-size는 몇 달 전에 한 번 검토했다가 "효과 없음"으로 보류했던 옵션입니다. 그때는 Vercel 서버리스 환경이었고, 요청마다 짧게 살다 죽는 함수에는 힙 상한이 의미가 없었거든요. 그런데 상시 프로세스 + 메모리 과금인 Railway로 오니 같은 옵션이 가장 효과 좋은 레버가 됐습니다. 최적화의 답은 코드가 아니라 실행 환경에 따라 달라진다는 걸 다시 배웠습니다. 그리고 추측 대신 로컬에서 같은 조건으로 재현·측정하는 것 — 이번에도 이게 시간을 제일 아껴줬습니다.

결론

Next.js 상시 서버의 메모리가 높게 플래토를 그린다면, 순서는 이렇습니다. ① 우상향(누수)인지 수평(정상 상주)인지 먼저 구분하고 ② standalone 빌드를 로컬에서 띄워 페이지 서빙과 이미지 변환을 나눠 측정한 뒤 ③ NODE_OPTIONS=--max-old-space-size + VIPS_CONCURRENCY부터 적용해보세요. 코드 수정 없이 환경변수 2개로 메모리 요금을 절반으로 줄일 수 있었습니다.

다만 ③에서 상한 값은 실측치의 두 배로 잡으세요. 저는 실측 247MB에 256을 걸었다가 다음 날 OOM으로 서버가 크래시 루프를 돌았습니다. 절약과 지뢰는 종이 한 장 차이더라고요.

다음에도 유익한 포스팅으로 찾아오겠습니다. 감사합니다!

📚 참고 / 출처

#Next.js#Railway#메모리 최적화#sharp#Docker

$ ls related/

더 많은 IT·AI 활용법이 궁금하다면?

매주 새로운 실전 가이드가 업데이트됩니다.

전체 글 보기