[Next.js] Ready는 찍히는데 서버가 없다 — dev 서버 OOM과 오염된 .next 캐시 재발 루프
발단: 검증 파이프라인이 dev 서버 앞에서 멈췄다
Next.js 앱을 자동으로 빌드하고 띄운 뒤 몇 개의 엔드포인트를 curl로 두드려 정상 응답이 오는지 확인하는 검증 스크립트를 돌리고 있었다. 사람이 지켜보지 않아도 무인으로 도는 파이프라인이라, 서버가 뜨는 걸 폴링으로 기다렸다가 요청을 던지는 흔한 구조다.
그런데 이 파이프라인이 어느 순간부터 dev 서버 기동 단계에서 매번 막혔다. 서버는 분명 뜨는 것처럼 보이는데, 첫 요청을 보내면 연결이 거부된다. 재기동해도 결과는 똑같다. 이 글은 "Ready 로그까지 멀쩡히 찍고 나서 첫 컴파일 도중 프로세스가 조용히 사라지는" 증상을 추적해 커널 OOM kill과 오염된 빌드 캐시가 만든 재발 루프까지 도달한 기록이다. 같은 패턴을 만난 사람이 로그를 무작정 늘리기 전에 어디부터 봐야 하는지가 이 글의 결론이다.
증상: Ready는 찍히는데 프로세스가 없다
npm run dev(Next.js 16, Turbopack)를 띄우면 로그는 여기까지는 완벽하게 정상이었다.
> next dev
▲ Next.js 16.0.0 (Turbopack)
- Local: http://localhost:3000
✓ Ready in 200ms
○ Compiling / ...
마지막 줄이 ○ Compiling / ...이다. 그리고 그 뒤로 아무것도 없다. 에러도, 스택 트레이스도, 종료 코드 로그도 없이 프로세스가 사라진다. 셸에서 보기엔 종료 코드 0으로 얌전히 끝난 것처럼 보이기까지 한다.
폴링 쪽에서는 이렇게 보였다.
$ curl localhost:3000
curl: (7) Failed to connect to localhost port 3000: Connection refused
✓ Ready in 200ms를 이미 봤으니 서버가 떴다고 믿고 요청을 보내는데, 정작 소켓은 죽어 있다. Ready와 Connection refused가 공존하는 이 모순이 추적의 출발점이었다.
추적 과정: 의심하고, 기각하고
시간순으로 무엇을 의심했다가 무엇 때문에 접었는지 적는다. 결론만 보면 당연해 보이지만, 실제로는 엉뚱한 곳을 한참 팠다.
1) 포트 충돌 / 좀비 프로세스를 의심했다. Ready 후 죽으니 이전 인스턴스가 포트를 붙잡고 있어서 새 인스턴스가 조용히 실패하는 건가 싶었다. ss -ltnp로 3000 포트를 확인했지만 붙잡은 프로세스는 없었다. 기각.
2) 애플리케이션 코드의 크래시를 의심했다. 첫 컴파일 중 죽으니 특정 모듈 임포트에서 예외가 터지는 건가 싶었다. 그런데 예외라면 스택 트레이스가 남아야 하는데 로그가 완전히 침묵이다. 종료 코드도 0처럼 보인다. 애플리케이션 레벨 크래시의 지문과 맞지 않았다. 보류.
3) 메모리를 봤지만 무죄로 판정했다 — 이게 함정이었다. 서버가 죽은 뒤 free -g를 쳐보면 30GB 넘게 여유가 있었다.
$ free -g
total used free shared buff/cache available
Mem: 31 1 30 0 0 30
메모리가 이렇게 남는데 메모리 문제일 리 없다고 판단하고 이 방향을 접었다. 사후에 측정한 free가 정상으로 보인다는 사실 자체가 오히려 단서였는데 그때는 몰랐다. 뒤에서 다시 설명한다.
4) 백그라운드 실행 모드를 의심했다. dev 서버를 백그라운드 프로세스로 띄우고 폴링하는 구조였는데, 이 실행 방식 자체가 뭔가 부모-자식 프로세스 정리 문제를 일으키나 싶었다. 이 의심은 절반만 맞았다(뒤의 교훈 참고). 하지만 근본 원인은 아니었다.
로그를 늘려도, 애플리케이션 코드를 뒤져도 진전이 없었다. 방향을 틀어 애플리케이션 로그가 아니라 커널 로그를 봤다.
원인 규명: 커널이 죽이고 있었다
$ journalctl -k --since "15 min ago" | grep -i oom-kill
kernel: tokio-runtime-w invoked oom-killer: gfp_mask=0x...
kernel: Out of memory: Killed process 12345 (next-server (v1)) total-vm:28104468kB, ...
프로세스가 조용히 죽은 게 아니었다. 커널 OOM killer가 죽이고 있었다. total-vm:28104468kB — 약 28GB. Turbopack이 첫 컴파일에서 순간적으로 메모리를 폭증시켜 가용 메모리를 전량 소진했고, 커널이 next-server를 사살했다. tokio-runtime-w invoked oom-killer가 방아쇠였다.
여기서 앞의 함정이 풀린다. OOM kill로 프로세스가 죽으면 그 프로세스가 쥐고 있던 메모리는 즉시 회수된다. 그래서 사후에 free를 치면 언제 그랬냐는 듯 여유가 넘쳐 보인다. 순간 폭증 → 사살 → 즉시 회수라서, 폭증하던 그 찰나를 free로 포착하지 못하면 메모리는 영원히 무죄처럼 보인다. Ready가 찍힌 것도 설명된다. 서버 소켓을 여는 것과 첫 요청에서 라우트를 컴파일하는 것은 다른 단계이고, 메모리가 터지는 건 후자다.
그런데 진짜 골치는 이 다음이다. 최초 1회의 OOM은 외부 요인이었지만(그 순간 호스트에 다른 부하가 겹쳤다), 그 뒤로는 외부 부하가 없어도 매 기동마다 같은 지점에서 죽었다. 재발 루프에 빠진 것이다.
범인은 .next 빌드 캐시였다. OOM kill은 프로세스를 강제로 중간에 끊기 때문에, 그 순간 기록 중이던 .next 캐시가 오염된 채로 디스크에 남는다. 다음 기동은 이 오염된 캐시를 읽어 컴파일을 시작하고, 오염된 상태가 컴파일을 또 같은 폭주로 몰아 다시 OOM에 도달한다. 한 번 궤도에 오르면 스스로 빠져나오지 못하는 자기 재생산 루프다.
[최초 OOM: 외부 부하가 겹쳐 메모리 폭증 → 커널이 kill]
│
▼
프로세스 강제 중단 → .next 캐시가 오염된 채 디스크에 잔존
│
┌───────────────┘
▼
npm run dev → ✓ Ready → ○ Compiling
│ (오염된 .next 캐시를 읽음)
▼
컴파일이 같은 메모리 폭주 재현 → OOM kill
│
▼
.next 다시 오염 ──┐
▲ │
└──────────────┘ (외부 부하 없이도 자가 재생산)
해결: 캐시를 지운다
메커니즘을 알고 나면 해결은 두 줄이다. 먼저 OOM이 맞는지 커널 로그로 확정한다.
$ journalctl -k --since "15 min ago" | grep -i oom-kill
# oom-killer / Out of memory: Killed 라인이 나오면 OOM 확정
확정되면 오염된 빌드 캐시를 지우고 다시 띄운다.
$ rm -rf .next
$ npm run dev
✓ Ready in 200ms
○ Compiling / ...
✓ Compiled / in 3.2s
.next를 날린 직후 기동은 컴파일까지 정상적으로 끝났고, 같은 스펙에서 재현은 0회였다. 루프의 연료가 오염된 캐시였으니, 연료를 제거하자 루프가 끊긴 것이다. (최초 OOM을 부른 외부 부하 요인이 상존한다면 메모리 자체를 늘리거나 NODE_OPTIONS로 힙 상한을 조정하는 등의 별도 대책이 필요하지만, 이 사례에서 재발을 만든 건 어디까지나 캐시 오염이었다.)
재발 방지와 교훈
정리하면 다음과 같다.
| 관측된 신호 | 잘못된 해석 | 실제 의미 |
|---|---|---|
Ready 후 첫 컴파일 중 조용히 소멸 |
앱 코드 크래시 | 커널 OOM kill (로그 없이 죽음) |
| 종료 코드 0처럼 보임 | 정상 종료 | 외부 시그널(SIGKILL)에 의한 사살 |
사후 free에 메모리 여유 넘침 |
메모리 문제 아님 | kill 후 즉시 회수돼 정상처럼 보일 뿐 |
| 재기동해도 같은 지점에서 죽음 | 코드가 결정적으로 틀림 | 오염된 .next 캐시의 재발 루프 |
- dev 서버가 "Ready 후 첫 컴파일 중 조용히 죽는" 패턴이면, 애플리케이션 로그를 늘리기 전에
journalctl -k의 oom-kill부터 확인하라. 침묵하며 죽는 프로세스는 대개 자기 로그가 아니라 커널 로그에 부고가 실린다. - OOM이 확인되면 다음 수는 빌드 캐시(
.next) 삭제다. 강제 종료로 오염된 캐시는 재발 루프의 연료다. free가 정상으로 보인다고 메모리를 무죄로 단정하지 마라. 순간 폭증형 OOM은 사후 측정으로 잡히지 않는다.- readiness 폴링에는 방어를 두 겹으로 걸어라.
curl에--max-time을 붙여 죽은 소켓에 무한정 매달리지 않게 하고, 서버 프로세스 생존은kill -0 "$pid"로 별도 확인한다.Ready로그만 믿으면 이미 죽은 서버에 요청을 던지게 된다. - 부수 관찰 하나. dev 서버를 도구의 백그라운드 모드로 띄우면 폴링 도중 소멸하는 일이 있었다(OOM과 인과가 완전히 분리되지는 않았다). 검증 스크립트 안에서
setsid npm run dev &로 직접 띄우고 같은 스크립트에서 정리하는 방식이 실측상 더 안정적이었다.
교훈을 한 줄로 줄이면 이렇다. 조용히 죽는 프로세스의 사인은 그 프로세스의 로그가 아니라 커널 로그에 있다. 그리고 강제 종료가 남긴 오염 상태는 지워주기 전까지 같은 사고를 반복한다.
댓글
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.