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 + CSIGINT-
docker stopSIGTERM기본 10초
Kubernetes 파드 종료SIGTERM기본 30초
pm2 stop / pm2 reloadSIGINT기본 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으로

배포는 하루에도 여러 번 일어난다. 그때마다 몇 건씩 끊기는 요청은 로그에서 잘 보이지 않지만, 사용자에게는 "가끔 결제가 실패하는 서비스"로 남는다. 서버를 시작하는 코드만큼, 서버를 끄는 코드도 신경 써서 짜두자.