[Next.js] dev 서버가 조용히 죽거나 포트를 붙잡고 있을 때 — 원인 세 가지
요약
자동화 스크립트에서 개발 서버를 띄우고 곧바로 curl로 검증하는 흐름은 흔하다. 그런데 이게 조용히 실패한다. 서버가 사라져서 연결이 거부되거나, 죽였는데 포트가 살아 있거나, 포트를 바꿔 띄웠는데도 기동이 거부된다. 세 증상은 원인이 전부 다르다. 하나씩 잡아야 한다.
이 글은 Next.js 16 개발 서버(npm run dev)를 자동 검증 파이프라인에 물리다가 세 번 연속 헛발질한 기록이다. 셸의 프로세스 수명, npm의 부모/자식 구조, Next의 중복 방지 락 — 세 층위가 각각 한 번씩 발목을 잡았다.
증상 1 — 띄운 서버가 다음 순간 사라진다
서버를 백그라운드로 올리고, 다음 단계에서 요청을 보냈더니 전부 빈 응답이 왔다.
PORT=3100 npm run dev > /tmp/dev.log 2>&1 &
# ... 다음 스텝 ...
curl -s -o /dev/null -w '%{http_code}\n' localhost:3100/
# 000
000은 HTTP 상태 코드가 아니라 "연결 자체가 안 됐다"는 curl의 표시다. 프로세스 종료 코드를 보니 이렇게 남아 있었다.
[1]+ Exit 1 PORT=3100 npm run dev > /tmp/dev.log 2>&1
Background command failed with exit code 143
143 = 128 + 15, 즉 SIGTERM이다. 누가 죽였나? 아무도 명시적으로 죽이지 않았다. 백그라운드 작업의 수명이 그것을 띄운 셸에 묶여 있어서, 셸이 끝나는 순간 함께 정리된 것이다. 스텝마다 새 셸이 뜨는 CI·자동화 환경에서는 "띄운 다음 스텝"에 서버가 이미 없다.
해결은 구조를 바꾸는 것이다. 기동·대기·검증·종료를 한 덩어리로 묶는다.
PORT=3100 npm run dev > /tmp/dev.log 2>&1 &
for i in $(seq 1 30); do curl -s -o /dev/null localhost:3100/ && break; sleep 1; done
curl -s localhost:3100/api/health # 실제 검증들
curl -s -o /dev/null -w '%{http_code}\n' localhost:3100/some/page
# 정리 (다음 절에서 다듬는다)
sleep 5 같은 고정 대기 대신 준비될 때까지 폴링하는 것도 중요하다. 첫 컴파일 시간은 머신과 캐시 상태에 따라 몇 초에서 수십 초까지 흔들린다.
증상 2 — 죽였는데 포트가 살아 있다
다음은 더 고약했다. 서버를 종료하고 새 환경변수를 넣어 다시 띄웠는데, 로그인 API가 계속 401을 반환했다.
POST /api/auth/login 401 in 3ms
해시 자체를 의심해서 오프라인으로 검증까지 해봤다. 문제가 없었다.
compare: true
값은 맞는데 서버가 거부한다. 그럼 서버가 그 값을 못 보고 있다는 뜻이다. 포트를 확인해보니 답이 나왔다.
ss -tlnp | grep ":3100 "
# LISTEN 0 511 *:3100 *:* users:(("next-server (v1",pid=952198,fd=22))
종료했다고 믿은 서버가 그대로 떠 있었다. 원인은 프로세스 구조다. npm run dev는 npm이 부모고, 실제로 포트를 여는 것은 자식 next-server다. 부모 PID를 kill하면 npm만 죽고 자식은 고아로 남아 포트를 계속 점유한다. 그리고 그 서버는 처음 뜰 때의 환경변수를 그대로 들고 있으므로, 새로 주입한 값이 절대 반영되지 않는다.
그래서 종료는 프로세스 이름이나 명령줄이 아니라 포트를 여는 프로세스를 특정해서 해야 한다.
ss -tlnp | grep ":3100 " | grep -oP 'pid=\K[0-9]+' | xargs -r kill
패턴 kill은 두 방향으로 위험하다.
pkill -f "next dev" # 쓰지 말 것
첫째, 같은 머신에서 돌아가는 다른 프로젝트의 서버까지 잡는다. 둘째, 정작 목표물은 못 잡을 수 있다 — 실제 리스너의 프로세스명은 next dev가 아니라 next-server라서 패턴에 안 걸리고 살아남는다. 실제로 이 사고에서 pkill은 아무것도 해결하지 못했고, 살아남은 낡은 서버가 계속 401을 뱉었다.
증상 3 — 포트를 바꿨는데도 기동이 거부된다
세 번째는 완전히 다른 종류였다. 다른 작업이 이미 3000번에서 개발 서버를 돌리고 있는 상황에서, 충돌을 피하려고 3100번으로 띄웠다. 그런데도 거부당했다.
⨯ Another next dev server is already running.
- Local: http://localhost:3000
- PID: 1038379
- Dir: /path/to/project
- Log: .next/dev/logs/next-development.log
Run kill 1038379 to stop it.
여기서 핵심은 마지막에서 두 번째 줄, Dir:이다. Next의 dev 중복 방지 락은 포트가 아니라 프로젝트 디렉토리 단위다. 같은 디렉토리를 가리키는 한 포트를 아무리 바꿔도 두 번째 dev 서버는 뜨지 않는다. 락 파일이 .next/ 안에 있으니 당연한 동작이지만, "포트만 다르면 되겠지"라는 직관과는 어긋난다.
병렬로 작업해야 한다면 디렉토리를 분리해야 한다. git 저장소라면 워크트리가 가장 싸다.
git worktree add ../project-verify -b verify-branch
cd ../project-verify
npm ci
PORT=3100 npm run dev
디렉토리가 다르면 .next/도 별개라 락이 걸리지 않는다. 부수적으로 소스 트리도 격리되므로, 옆 작업의 미커밋 변경이 내 검증 결과에 섞여 들어오는 사고도 함께 막힌다.
정리 — 검증 스크립트의 형태
세 원인을 모두 반영하면 검증 절차는 이런 모양이 된다.
#!/usr/bin/env bash
set -euo pipefail
PORT=3100
# 0) 낡은 리스너가 남아 있으면 먼저 정리 — 환경변수 변경이 무시되는 사고 예방
ss -tlnp | grep ":$PORT " | grep -oP 'pid=\K[0-9]+' | xargs -r kill
sleep 1
# 1) 기동 + 준비될 때까지 폴링
PORT=$PORT npm run dev > /tmp/dev-$PORT.log 2>&1 &
for i in $(seq 1 30); do curl -s -o /dev/null "localhost:$PORT/" && break; sleep 1; done
# 2) 검증
curl -s -o /dev/null -w 'home: %{http_code}\n' "localhost:$PORT/"
# 3) 종료는 포트 리스너 PID로
ss -tlnp | grep ":$PORT " | grep -oP 'pid=\K[0-9]+' | xargs -r kill
여기에 운영 원칙 두 가지를 덧붙이면 재발이 거의 사라진다.
포트를 문서에 못 박아라. 검증 절차에 포트를 명시하지 않으면 누군가는 기본 포트(3000)로 검증하게 되고, 그때 채점 대상은 내 코드가 아니라 옆 작업의 앱이다. 이 경우 실패도 통과도 모두 신뢰할 수 없다 — 거짓 실패보다 거짓 통과가 훨씬 위험하다.
기동 전 포트 점유를 확인해라. ss -tlnp | grep -c ":3100 "가 0이 아니면 낡은 서버가 살아 있다는 뜻이고, 그 상태로 진행하면 증상 2를 그대로 반복한다.
교훈
세 증상은 표면적으로 모두 "서버가 이상하다"였지만, 층위가 각각 달랐다.
| 증상 | 층위 | 판별 신호 |
|---|---|---|
| 다음 스텝에서 연결 거부 | 셸 — 백그라운드 작업 수명 | 종료 코드 143(SIGTERM) |
| 죽였는데 옛 동작 유지 | 프로세스 — npm 부모 ≠ 리스너 자식 | ss -tlnp에 포트가 남아 있음 |
| 포트를 바꿔도 기동 거부 | 프레임워크 — 디렉토리 단위 락 | 에러 메시지의 Dir: 줄 |
디버깅에서 가장 비쌌던 것은 첫 번째 가설에 매달린 시간이었다. 401이 계속 나올 때 해시 생성 방식을 세 번 고쳐봤지만, 정작 원인은 인증 로직 바깥에 있었다. "값이 맞는데 서버가 거부한다"면 그 서버가 내가 방금 띄운 서버가 맞는지부터 확인하는 편이 빠르다.
댓글
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.