로컬 서버가 세션이 끝나도 안 죽어서, 어제 글을 계속 내보내고 있었다 (실측)
발행 자동화용으로 띄워둔 로컬 HTTP 서버가 작업 세션이 끊긴 뒤에도 백그라운드에서 계속 살아있었다. 다음에 열어보니 며칠 전 초안을 여전히 내보내고 있었다.
증상 — 포트는 열려 있는데 아무도 모른다
이 프로젝트는 네이버 블로그에 글을 올릴 때 로컬 서버(localhost:7391)를 하나 띄운다. 브라우저 확장이 이 서버에서 제목·본문·태그·이미지를 통째로 받아가 자동으로 채워 넣는 구조다. 원리는 단순하다.
node scripts/naver-serve.mjs drafts/naver/<초안>.txt
문제는 이 명령을 백그라운드로 띄우고(nohup ... &) 작업 세션이 끝나면, 이 프로세스가 계속 돌아간다는 걸 몰랐다는 점이다. 다음 세션을 시작하면서 lsof와 curl로 직접 확인해보니 이랬다.
$ lsof -i :7391
COMMAND PID USER FD TYPE
node 80492 minseop 12u IPv4 (LISTEN)
$ curl -s http://127.0.0.1:7391/draft | head -c 100
{"slug":"2026-08-08-다가구주택-선순위보증금...
이틀 전 세션에서 마지막으로 다루던 초안을 여전히 서빙하고 있었다. 이번 세션에서 새 초안 작업을 하다가 무심코 이 서버를 그대로 썼다면, 확장은 새 초안이 아니라 옛 초안을 받아갔을 것이다.
원인 — 종료 신호를 받을 방법이 없다
서버 코드를 보면 이유가 바로 나온다.
const server = http.createServer((req, res) => {
// ... 초안 파일을 읽어 응답
});
server.listen(PORT, HOST, () => {
console.log(`📡 http://${HOST}:${PORT}/draft (Ctrl+C로 종료)`);
});
server.close()를 부르는 코드가 어디에도 없다. 안내 문구도 “Ctrl+C로 종료”다 — 즉 이 서버는 애초에 사람이 터미널 앞에서 직접 끄는 걸 전제로 설계됐다. 그런데 실제로는 nohup node ... &로 백그라운드에 띄우는 경우가 많았다. 이 방식은 터미널이 닫혀도(또는 세션이 끊겨도) 프로세스가 살아남도록 만드는 방식 그 자체라, Ctrl+C가 애초에 이 프로세스에 도달할 수 없다.
더 근본적으로는, 이 프로세스 입장에서 “작업 세션이 끝났다”는 이벤트 자체가 존재하지 않는다. 세션은 이 서버 바깥의 개념(대화가 끊기거나, 다음 세션으로 넘어가는 것)이라서, 프로세스에게 그걸 알려줄 신호가 코드 어디에도 없다. 종료 로직을 안 짠 게 아니라, 종료해야 할 시점을 이 프로세스가 원리적으로 알 방법이 없었다.
재발 — 절차 대응은 절차를 안 지키면 무력하다
첫 사고 이후 만든 대응은 세션 시작 점검 스크립트에 포트 점검을 넣는 것이었다.
const PORTS = [7391]; // naver-serve. 늘어나면 여기 추가한다.
// ...
const pids = sh("lsof", ["-ti", `:${port}`, "-sTCP:LISTEN"]);
if (pids) {
const title = /* 서버에 실제로 물어봐서 확인 */;
say(` ⚠️ :${port} 사용 중 — PID ${pid}`);
say(` 서빙 중인 글: ${title ?? "(응답 없음)"}`);
}
핵심은 포트 번호나 PID가 아니라 “서빙 중인 글 제목”을 직접 물어본다는 점이다. 프로세스 목록만 봐서는 그게 오늘 띄운 서버인지 사흘 전 서버인지 구분이 안 되는데, 실제로 어떤 초안을 내보내고 있는지 물어보면 바로 판단이 선다.
이 대응 이후 다음 세션에서 실제로 재발했다 — 이번엔 다른 초안(다가구주택 선순위보증금)을 물고 있는 걸 세션 시작 점검이 자동으로 잡아냈다. 즉 대응 자체는 작동했다. 다만 이 대응이 작동하려면 그 세션 시작 점검을 실제로 실행해야 한다는 전제가 있다. 실행을 빼먹으면 감지도 없다.
정리
로컬 서버가 죽지 않는 게 코드 버그는 아니었다 — Ctrl+C 핸들러는 의도대로 동작한다. 문제는 백그라운드 실행 방식과 세션 경계 사이의 간극이었다. 이 서버는 “언제까지 살아있어야 하는가”를 스스로 판단할 정보가 없고, 그 판단을 사람(또는 다음 세션)에게 완전히 떠넘긴 구조였다. 근본적으로 고치려면 유휴 시간 기반 자동 종료 같은, 프로세스 스스로 판단할 수 있는 장치가 필요하다 — 지금은 여전히 “점검 스크립트를 실행하는 사람”에게 의존하는 상태다.
자주 묻는 질문
왜 서버가 스스로 안 꺼지나요?
Node.js의 http.createServer는 명시적으로 close()를 부르거나 프로세스가 종료돼야 멈춘다. 이 서버는 SIGINT(Ctrl+C) 핸들러 하나로만 설계돼 있는데, 백그라운드로 띄운(nohup + &) 프로세스는 터미널 세션이 끊겨도 SIGINT를 받지 않는다. 즉 '작업이 끝났다'는 신호 자체가 이 프로세스에 전달될 방법이 없다.
왜 하필 이 서버였나요?
이 서버는 발행 자동화 확장이 초안 파일을 읽어가는 통로다. 로컬 파일시스템은 브라우저 확장이 직접 못 읽어서(file:// 접근은 매번 사용자 허용이 필요하고 디렉터리 순회도 안 됨) HTTP로 우회하는 구조인데, 그 우회로가 상태를 계속 들고 있다는 걸 설계할 때는 놓쳤다.
왜 두 번이나 반복됐나요?
첫 번째 사고 이후 만든 대응은 '세션 시작할 때 확인하고 필요하면 끄기'였다. 그런데 이건 사람(또는 다음 세션의 나)이 그 점검을 실제로 실행해야만 작동하는 대응이다. 실행을 깜빡하면 그대로 재발한다 — 실제로 재발했다. 근본 대응은 절차가 아니라 자동 감지였어야 했다.