Node.js 서버 안전하게 종료하기 (Graceful Shutdown): 배포할 때마다 요청이 끊기는 이유
배포나 재시작 순간 처리 중이던 요청이 끊긴다면 종료 신호(SIGTERM)를 처리하지 않았기 때문이다. server.close()로 진행 중인 요청을 마치고 끄는 법, keep-alive 연결 때문에 바로 안 꺼지는 함정, Docker와 PM2에서 신호가 전달되지 않는 문제까지 실제로 실행해 확인하며 정리했다.
배포할 때마다 에러가 몇 건씩 생긴다
평소에는 멀쩡한데, 배포하거나 서버를 재시작하는 순간에만 사용자 쪽에서 에러가 몇 건씩 난다. 결제나 글 저장처럼 시간이 걸리는 요청이 중간에 끊긴 것이다.
원인을 직접 확인해보자. 2초가 걸리는 요청을 보내고, 0.5초 뒤에 서버에 종료 신호(SIGTERM)를 보냈다.
[test] SIGTERM 전송 (570ms)
[test] 서버 종료됨 signal=SIGTERM (577ms) ← 신호를 받자마자 즉시 종료
[test] 처리 중이던 요청: 실패 (UND_ERR_SOCKET) ← 2초짜리 요청이 중간에 끊김
Node.js는 SIGTERM을 받았을 때 따로 처리하는 코드가 없으면 그 즉시 프로세스를 끝낸다. 처리 중이던 요청, 쓰던 파일, 아직 커밋 안 된 DB 트랜잭션은 그대로 버려진다.
우아한 종료(Graceful Shutdown)는 종료 신호를 받으면 새 요청은 더 받지 않고, 처리 중인 요청은 끝까지 마친 뒤, 연결과 자원을 정리하고 종료하는 것이다.
종료 신호는 어디서 오나
서버를 "끄는" 거의 모든 도구는 먼저 SIGTERM(정중한 종료 요청)을 보내고, 일정 시간 안에 안 꺼지면 SIGKILL(강제 종료)을 보낸다. SIGKILL은 프로세스가 가로챌 수 없다.
| 상황 | 보내는 신호 | 강제 종료까지 기다리는 시간 |
|---|---|---|
| 터미널에서 Ctrl + C | SIGINT | - |
| docker stop | SIGTERM | 기본 10초 |
| Kubernetes 파드 종료 | SIGTERM | 기본 30초 |
| pm2 stop / pm2 reload | SIGINT | 기본 1.6초 (kill_timeout) |
| 서버리스, PaaS 재배포 | 보통 SIGTERM | 플랫폼마다 다름 |
그래서 우리 서버가 할 일은 유예 시간 안에 스스로 깔끔하게 끝내는 것이다.
기본 구현: server.close()
import http from "node:http";
const server = http.createServer(app);
server.listen(3000);
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown) return; // 신호가 여러 번 와도 한 번만
shuttingDown = true;
console.log(`${signal} 받음, 종료 시작`);
// 1. 새 연결은 받지 않고, 진행 중인 요청이 끝나면 콜백 실행
server.close(async () => {
// 2. DB, Redis 같은 외부 연결 정리
await db.end();
console.log("정상 종료");
process.exit(0);
});
// 3. 그래도 안 끝나면 강제 종료 (플랫폼의 유예 시간보다 짧게)
setTimeout(() => {
console.error("시간 초과, 강제 종료");
process.exit(1);
}, 10_000).unref();
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
같은 실험을 다시 해보면 이렇게 바뀐다.
[test] SIGTERM 전송 (567ms)
[server] SIGTERM 받음, 종료 시작
[test] 종료 중 새 요청: 거부됨 (ECONNREFUSED) ← 새 요청은 받지 않고
[test] 처리 중이던 요청: 성공 "slow done" ← 진행 중이던 요청은 끝까지 처리
[server] 모든 연결 종료
강제 종료 타이머의 .unref()는 "이 타이머 때문에 프로세스가 살아있지는 마라"는 뜻이다. 정리가 일찍 끝나면 타이머를 기다리지 않고 바로 종료된다.
함정: 요청은 끝났는데 왜 바로 안 꺼지지?
위 코드로 실험했을 때, 마지막 응답은 2초에 나갔는데 프로세스는 5초가 넘어서야 종료됐다.
[test] 처리 중이던 요청: 성공 (2083ms)
[server] 모든 연결 종료 (5094ms) ← 3초를 더 기다림
원인은 HTTP keep-alive다. 클라이언트는 연결을 재사용하려고 응답을 받은 뒤에도 연결을 열어둔다. server.close()는 호출한 순간 쉬고 있던 연결만 닫는데, 이 연결은 그 순간에는 요청을 처리 중이었다. 그래서 응답 후 쉬는 상태가 된 연결이 서버의 keep-alive 타임아웃(Node.js 기본 5초)까지 남아 있었던 것이다.
유예 시간이 짧은 환경(PM2 기본 1.6초)이라면 이 몇 초 때문에 정리 코드를 다 실행하지 못하고 강제 종료될 수 있다. 종료 중에는 응답이 끝날 때마다 쉬는 연결을 바로 닫아주면 된다.
// 종료 중에는 응답이 끝날 때마다 쉬고 있는 연결을 정리한다
server.on("request", (req, res) => {
res.on("finish", () => {
if (shuttingDown) setImmediate(() => server.closeIdleConnections());
});
});
function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
server.close(() => process.exit(0));
server.closeIdleConnections(); // 지금 쉬고 있는 연결은 바로 닫기
setTimeout(() => {
server.closeAllConnections(); // 마지막 수단: 남은 연결 전부 끊기
process.exit(1);
}, 10_000).unref();
}
[test] 처리 중이던 요청: 성공 (2088ms)
[server] 모든 연결 종료 (2091ms) ← 응답 직후 바로 종료
closeIdleConnections()와 closeAllConnections()는 Node.js 18.2부터 쓸 수 있다.
Docker: 신호가 아예 전달되지 않는 경우
종료 코드를 잘 짜놨는데도 docker stop이 항상 10초씩 걸리고 결국 강제 종료된다면, 신호가 Node.js까지 오지 않는 것이다.
# ❌ shell 형식: /bin/sh -c "node server.js"로 실행됨
CMD node server.js
# ✅ exec 형식: node가 컨테이너의 PID 1이 되어 신호를 직접 받음
CMD ["node", "server.js"]
shell 형식으로 쓰면 셸(/bin/sh)이 PID 1이 되고 Node.js는 그 자식 프로세스가 된다. 셸은 받은 SIGTERM을 자식에게 전달하지 않기 때문에, Node.js는 신호를 받지 못한 채 10초 뒤 SIGKILL로 죽는다.
| 실행 방식 | 신호 전달 |
|---|---|
| CMD node server.js (shell 형식) | ❌ 셸이 받고 전달 안 함 |
| CMD ["node", "server.js"] (exec 형식) | ✅ |
| CMD ["npm", "start"] | ⚠️ npm이 중간에 끼어든다. Node.js 공식 Docker 가이드도 node를 직접 실행하도록 권장 |
| docker run --init 또는 tini 사용 | ✅ 신호 전달과 좀비 프로세스 정리를 대신 해줌 |
PM2: SIGINT와 짧은 유예 시간
PM2는 프로세스를 멈출 때 SIGTERM이 아니라 SIGINT 를 보내고, 기본 1.6초 뒤에 강제 종료한다. 그래서 두 가지를 챙겨야 한다.
- SIGINT도 처리한다. (위 코드처럼 process.on("SIGINT", shutdown))
- 요청 처리나 정리에 시간이 더 걸린다면 kill_timeout을 늘린다.
// ecosystem.config.js
module.exports = {
apps: [
{
name: "api",
script: "server.js",
kill_timeout: 10000, // 강제 종료까지 10초 기다림
},
],
};
로드밸런서 뒤에 있다면
여러 서버를 로드밸런서 뒤에 두고 있다면, 종료를 시작하자마자 헬스 체크가 실패하도록 바꿔서 로드밸런서가 더 이상 트래픽을 보내지 않게 하는 것도 좋다.
app.get("/health", (req, res) => {
res.status(shuttingDown ? 503 : 200).end();
});
정리: 왜 알아두면 좋은가
| 문제 | 해결 |
|---|---|
| 종료 신호에 바로 죽어서 요청이 끊김 | SIGTERM/SIGINT 처리 + server.close() |
| 정리가 끝나지 않아 영원히 안 꺼짐 | 유예 시간보다 짧은 강제 종료 타이머 (.unref()) |
| 요청은 끝났는데 몇 초 더 안 꺼짐 | keep-alive 연결을 closeIdleConnections()로 정리 |
| DB 연결, 파일이 정리되지 않음 | server.close 콜백에서 외부 자원 정리 |
| Docker에서 항상 10초 후 강제 종료 | exec 형식 CMD ["node", "server.js"] 또는 --init |
| PM2에서 정리 중 강제 종료 | SIGINT 처리 + kill_timeout 늘리기 |
| 종료 중인 서버로 트래픽이 계속 옴 | 헬스 체크를 503으로 |
배포는 하루에도 여러 번 일어난다. 그때마다 몇 건씩 끊기는 요청은 로그에서 잘 보이지 않지만, 사용자에게는 "가끔 결제가 실패하는 서비스"로 남는다. 서버를 시작하는 코드만큼, 서버를 끄는 코드도 신경 써서 짜두자.