AI 에이전트를 활용하다 보면 여러 작업을 병렬로 진행하는 일이 많아집니다.
여러 git worktree를 오가며 개발서버를 띄우기도 하고, Claude Code가 세션에서 돌면서 직접 개발서버를 띄우기도 합니다. 그러다 이미 점유된 포트 때문에 새 개발서버를 띄우지 못하는 상황이 생깁니다.
그렇게 알게 모르게 띄워진 개발서버를 정리해 봅시다.
포트 하나 정리하기
포트 번호를 알고 있다면 lsof로 어떤 프로세스가 그 포트를 사용 중인지 바로 확인할 수 있습니다.
lsof -i :3000shellCOMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
node 96142 bepyan 27u IPv6 0x2db279d161bb8a26 0t0 TCP *:3000 (LISTEN)text두 번째 컬럼이 PID입니다. 이 PID에 종료 신호를 보내면 됩니다.
kill 96142shell-9(SIGKILL)는 프로세스가 종료 처리를 할 기회를 주지 않습니다. 개발서버가 자식이나 임시 파일을 직접 정리한다면 그 과정이 건너뛰어질 수 있습니다.
기본 kill(SIGTERM)로 보내고, 그래도 안 죽을 때만 -9를 붙이는 것을 추천 드립니다.
포트 대역 나누기
포트를 하나씩 아는 경우는 드뭅니다. 어떤 포트가 열려 있는지 모르니 고아로 남게 되죠.
그렇다고 열려 있는 포트를 전부 죽일 수는 없으니, 대역을 나눠서 기준을 정해둡니다.
아래 포트 정책은 제 로컬 개발 환경에 맞춘 휴리스틱입니다. 자동 종료 대역은 자신의 환경에 맞게 조정하세요.
| 포트 대역 | 정책 | 기준 |
|---|---|---|
0 ~ 2999 | 보호 | 시스템 서비스, DB, 로컬 인프라가 쓰는 영역 |
3000 ~ 9999 | 자동 정리 후보 | 내 환경에서 개발서버용으로 사용하는 대역 |
10000 ~ 65535 | 조회 후 수동 정리 | 워커·도구·동적 할당 포트가 섞일 수 있음 |
3000 ~ 9999를 자동 정리 대상으로 삼은 건 이 대역에 개발 도구 기본값이 몰려 있기 때문입니다.
Next.js는 3000, Vite는 5173, Astro는 4321, Storybook은 6006을 씁니다.
10000 이상은 로컬에서 쓰는 보수적인 기준입니다. 이 영역에는 자식 워커가 동적으로 받은 포트가 자주 보여서 자동 종료하지 않습니다.
대역 한 번에 정리하기
lsof에서도 -iTCP:3000-9999처럼 포트 범위를 지정할 수 있습니다. 여기에 awk를 붙여 Node.js·Bun·Deno 프로세스만 추리고 출력 형태를 정리합니다.
lsof -nP -iTCP:3000-9999 -sTCP:LISTEN | awk 'NR>1 && $1 ~ /^(node|bun|deno)$/ {
split($9, a, ":")
port = a[length(a)]
printf "%-10s %-8s %s\n", $1, $2, port
}'shell-nP로 호스트명·서비스명 변환을 끄고, -sTCP:LISTEN으로 LISTEN 소켓만 남깁니다.
awk는 NAME 컬럼(*:3000, [::1]:9710)을 :로 쪼개 포트를 읽고, 런타임만 남깁니다.
런타임 필터를 빼면 macOS에서 ControlCenter가 점유한 5000, 7000번까지 걸려듭니다.
node 26925 8081
node 50735 8082
node 55455 8083
node 56130 9711
node 56130 9710
node 96142 3000shell목록이 납득할 만하면 printf를 print $2로 바꿔 PID만 뽑고, xargs kill로 넘깁니다.
lsof -nP -iTCP:3000-9999 -sTCP:LISTEN |
awk 'NR>1 && $1 ~ /^(node|bun|deno)$/ {
split($9, a, ":"); print $2
}' |
sort -u |
xargs killshellsort -u는 중복 PID를 걸러줍니다. 위 출력에서 PID 56130이 두 포트를 듣느라 두 줄로 나온 것처럼요.
애매한 대역은 눈으로
10000 이상은 죽이기 전에 정체를 확인합니다. PID만 뽑아서 ps에 넘기면 됩니다.
lsof -nP -iTCP:10000-65535 -sTCP:LISTEN |
awk 'NR>1 && $1 ~ /^(node|bun|deno)$/ { print $2 }' |
sort -u |
xargs ps -fpshell UID PID PPID C STIME TTY TIME CMD
501 96377 96142 0 5:34PM ?? 0:00.34 .../bin/node .../esbuild
501 96387 96377 0 5:34PM ?? 0:00.08 .../bin/node .../worker
501 98073 97993 0 5:34PM ?? 0:00.33 .../bin/node .../vite-nodeshellPPID를 함께 보면 높은 포트를 일괄 종료하기 어려운 이유가 보입니다.
96377의 부모는 96142, 즉 앞에서 본 3000번 개발서버입니다. 96387의 부모는 다시 96377이고요.
이들은 고아가 아니라 개발서버가 띄운 자식 워커이고, 각자 동적으로 받은 포트를 열어둔 것뿐입니다.
부모를 정상적으로 kill하면 알아서 따라 죽습니다.
PPID가 1이면 원래 부모가 사라진 뒤 재부모화된 프로세스일 수 있습니다. 데몬처럼 그대로 살아야 하는 경우도 있으니 CMD까지 보고 정리 대상을 고릅니다.
셸 함수로 만들어두기
스크립트를 매번 입력하기 번거로우니 .zshrc에 넣어둡니다.
# 개발서버 조회: lsdev [시작포트] [끝포트]
lsdev() {
local from="${1:-3000}" to="${2:-9999}"
lsof -nP -iTCP:"${from}-${to}" -sTCP:LISTEN | awk '
NR>1 && $1 ~ /^(node|bun|deno)$/ {
split($9, a, ":"); port = a[length(a)]
printf "%-10s %-8s %s\n", $1, $2, port
}'
}
# 개발서버 정리: killdev [시작포트] [끝포트]
killdev() {
local targets=$(lsdev "$@")
if [ -z "$targets" ]; then
echo "정리할 개발서버가 없습니다."
return
fi
echo "$targets"
echo "$targets" | awk '{print $2}' | sort -u | xargs kill
}shell포트 범위는 lsof -iTCP:시작-끝으로 넘기고, awk는 런타임과 출력만 담당합니다.
killdev는 lsdev 결과를 그대로 재사용해서 무엇을 죽일지 먼저 보여준 뒤 종료합니다.
killdev # 3000~9999 정리
killdev 4000 4999 # 특정 대역만 정리
lsdev 10000 65535 # 높은 대역은 조회만shell