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개로 같은 부하에서 절반 이하로 줄인 과정을 공유합니다.
📈 증상 — 재시작하면 낮고, 하루 지나면 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에 반환되지 않았습니다. 이유를 찾아보니 두 가지가 겹쳐 있었어요.
- Next.js는 sharp의 동시 처리 스레드를 vCPU 수의 절반으로 설정하는데, 클라우드 공유 호스트는 vCPU가 크게 잡히는 경우가 많아 스레드가 필요 이상으로 늘어납니다.
- sharp가 쓰는 libvips는 내부 캐시와 메모리 풀을 유지해서, 한 번 커진 메모리가 잘 줄어들지 않습니다.
🛠 처방 — 환경변수 2개
코드는 한 줄도 고치지 않았습니다. Railway 서비스 Variables에 두 개를 추가했을 뿐입니다.
NODE_OPTIONS=--max-old-space-size=256 # V8 힙 상한 → GC가 부지런해짐
VIPS_CONCURRENCY=1 # libvips 스레드 1개로 제한
--max-old-space-size=256: Node는 힙 상한이 넉넉하면 가비지 컬렉션을 게으르게 합니다. 상한을 256MB로 걸면 GC가 일찍일찍 돌아서 JS 쪽 메모리가 눌러집니다. 상시 서버 + 메모리 과금 환경에서 특히 효과적입니다.VIPS_CONCURRENCY=1: sharp(libvips)의 파이프라인 스레드를 1개로 제한합니다. 스레드마다 잡는 버퍼가 사라져서 변환 시 메모리 폭이 크게 줄어듭니다.
같은 부하(페이지 35개 + 이미지 변환 18장)를 다시 돌린 결과입니다.
| 조건 | 최종 RSS |
|---|---|
| 기본 설정 | 444MB |
| 환경변수 2개 적용 | 247MB |
응답 품질도 확인했습니다. 페이지 응답 4ms, 새 이미지 변환 139ms — 개인 사이트 트래픽에서는 체감 차이가 없는 수준입니다. 이미지 변환이 초당 수십 건씩 몰리는 서비스라면 스레드 1개가 병목이 될 수 있으니, 그런 경우엔 2~4로 타협하면 됩니다.
💡 Jay의 한 줄 — "플랫폼이 바뀌면 보류했던 최적화도 다시 꺼내보세요"
사실 --max-old-space-size는 몇 달 전에 한 번 검토했다가 "효과 없음"으로 보류했던 옵션입니다. 그때는 Vercel 서버리스 환경이었고, 요청마다 짧게 살다 죽는 함수에는 힙 상한이 의미가 없었거든요. 그런데 상시 프로세스 + 메모리 과금인 Railway로 오니 같은 옵션이 가장 효과 좋은 레버가 됐습니다. 최적화의 답은 코드가 아니라 실행 환경에 따라 달라진다는 걸 다시 배웠습니다. 그리고 추측 대신 로컬에서 같은 조건으로 재현·측정하는 것 — 이번에도 이게 시간을 제일 아껴줬습니다.
결론
Next.js 상시 서버의 메모리가 높게 플래토를 그린다면, 순서는 이렇습니다. ① 우상향(누수)인지 수평(정상 상주)인지 먼저 구분하고 ② standalone 빌드를 로컬에서 띄워 페이지 서빙과 이미지 변환을 나눠 측정한 뒤 ③ NODE_OPTIONS=--max-old-space-size + VIPS_CONCURRENCY부터 적용해보세요. 코드 수정 없이 환경변수 2개로 메모리 요금을 절반으로 줄일 수 있었습니다.
다음에도 유익한 포스팅으로 찾아오겠습니다. 감사합니다!