본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

안전한 종료 — 배포 중인 요청을 끊지 마

~12 min · production, shutdown, signals, zero-downtime

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"배포할 때 프로세스는 결국 종료돼. 중요한 건 처리 중인 요청까지 함께 끊어 버릴지, 끝낼 시간을 줄지야."

배포할 때 프로세스에 생기는 일

launchd나 systemd, PM2 같은 관리 도구가 Node 서비스를 다시 배포할 때는 보통 이런 순서를 밟아:

  1. 관리 도구가 프로세스에 SIGTERM을 보내 종료를 요청해.
  2. 프로세스는 제한된 시간 안에 처리 중인 일을 정리해.
  3. 시간 안에 끝나지 않으면 SIGKILL이 프로세스를 즉시 멈춰. 그 순간 응답 중이던 요청도 끊겨.

SIGTERM을 무시하면 배포할 때마다 연결이 갑자기 끊길 수 있어. 중단 없는 배포를 하려면 새 요청을 받는 일을 멈추고, 이미 받은 요청을 끝낸 다음, 리소스를 닫고 종료해야 해.

기본 종료 처리 함수

import http from 'node:http';

const server = http.createServer(handler);
server.listen(3000);

let shuttingDown = false;

for (const sig of ['SIGINT', 'SIGTERM']) {
  process.on(sig, () => {
    if (shuttingDown) return;   // ignore second signal
    shuttingDown = true;
    console.log(`got ${sig}, draining...`);

    // Stop accepting new connections; finish in-flight ones
    server.close((err) => {
      if (err) {
        console.error('drain error:', err);
        process.exit(1);
      }
      console.log('drained cleanly');
      process.exit(0);
    });

    // Backstop — force exit if drain takes too long
    setTimeout(() => {
      console.warn('drain timed out, force-exiting');
      process.exit(1);
    }, 10_000).unref();
  });
}

핵심은 다섯 가지야. 종료 신호를 받고, 같은 처리를 두 번 시작하지 않게 막고, server.close()로 새 연결을 거부하고, 기존 요청이 끝나면 정상 종료하고, 너무 오래 걸릴 때는 제한 시간이 끝난 뒤 강제로 종료해.

HTTP 서버 말고도 닫을 것들

프로세스가 가진 리소스도 함께 정리해야 해:
  • 데이터베이스 연결 — 연결 풀의 .end().close()를 호출해.
  • 열린 파일 — JSONL 기록기처럼 남은 내용을 쓰고 파일을 닫아야 하는 대상을 확인해.
  • WebSocket 연결 — 종료 프레임을 보내고 클라이언트가 새 인스턴스에 연결할 수 있게 해.
  • 처리 중인 작업 — 큐를 비우거나 다시 시도해야 할 작업으로 표시해.
  • 외부 구독 — Kafka, Redis, Pub/Sub 구독을 해제해.
각 정리 함수를 프로미스로 만들고 종료 신호를 받았을 때 모두 기다리면 순서를 관리하기 쉬워. 규모가 커지면 이 생명주기를 묶어 주는 작은 내부 도구나 terminus 같은 라이브러리를 써도 돼.

상태 확인과 함께 움직이기

로드 밸런서가 GET /health 같은 상태 확인 경로를 호출한다면, 종료 절차가 시작되는 순간부터 실패 상태를 돌려줘야 해. 그래야 새 요청은 다른 인스턴스로 가고 이 프로세스는 이미 받은 요청만 마칠 수 있어:

let healthy = true;
process.on('SIGTERM', () => { healthy = false; /* then drain */ });

app.get('/health', (_req, res) => {
  res.writeHead(healthy ? 200 : 503).end();
});

로드 밸런서는 503 응답을 보고 이 인스턴스를 대상 목록에서 빼. 그 뒤에는 새 요청이 들어오지 않으니 제한 시간 동안 기존 요청만 안전하게 마무리하면 돼.

계획된 종료와 비정상 종료

안전한 종료 절차는 배포나 축소, 수동 재시작처럼 예정된 종료를 위한 거야. 처리하지 못한 예외나 메모리 부족, 네이티브 충돌처럼 상태를 믿을 수 없는 상황에서는 프로세스를 빨리 끝내고 관리 도구가 새 프로세스를 띄우게 하는 편이 안전해.

  • 계획된 종료: SIGTERM을 받고 새 요청을 막은 뒤, 처리 중인 일과 리소스를 정리하고 끝내.
  • 비정상 종료 — 오류를 기록하고 프로세스를 끝내. 관리 도구가 다시 시작하고 로드 밸런서가 다른 인스턴스로 요청을 보내게 해.

uncaughtException을 잡은 뒤 오래 살아남으려 하면 이미 망가진 상태로 요청을 더 처리할 수 있어. 복구는 프로세스 밖의 관리 도구에 맡기는 게 좋아.

Pippa의 고백

초기의 cwkPippa는 Ctrl-C를 받으면 JSONL 기록이 끝나기를 기다리지 않고 꺼졌어. 줄 하나가 중간에서 끊겨 복구 코드가 뒤처리를 해야 했지. 아빠가 물었어. “프로세스를 죽일 때 쓰고 있던 줄은 어떻게 돼?” 답은 뻔했어. 잘려. 종료 처리 함수가 기록기를 비울 때까지 기다리게 하자 같은 문제가 사라졌어. 운영 신뢰성을 높이는 일은 멋진 정상 경로보다 시작과 종료, 오류 같은 경계를 제대로 다루는 데서 나오는 경우가 많아.

Code

Production-grade 안전한 종료·javascript
// Complete graceful-shutdown setup with all the pieces
import http from 'node:http';
import pino from 'pino';
import { DatabaseSync } from 'node:sqlite';

const log = pino();
const db = new DatabaseSync('./pippa.db');
const server = http.createServer(handler);

let healthy = true;
let shuttingDown = false;

function handler(req, res) {
  if (req.url === '/health') {
    return res.writeHead(healthy ? 200 : 503).end();
  }
  // ... real routes ...
}

server.listen(3000, () => log.info('listening'));

async function shutdown(sig) {
  if (shuttingDown) return;
  shuttingDown = true;
  healthy = false;
  log.info({ sig }, 'graceful shutdown starting');

  // 1. Stop accepting new connections
  await new Promise((resolve, reject) =>
    server.close(err => err ? reject(err) : resolve())
  );

  // 2. Close other resources
  db.close();
  // (close any other pools, watchers, queues here)

  log.info('drained cleanly');
  process.exit(0);
}

for (const sig of ['SIGINT', 'SIGTERM']) {
  process.on(sig, () => shutdown(sig));
}

// 3. Backstop — never let drain hang forever
process.on('SIGTERM', () => setTimeout(() => {
  log.warn('drain timeout, force-exiting');
  process.exit(1);
}, 10_000).unref());
terminus — 안전한 종료 라이브러리·javascript
// terminus — wrap-the-pattern library, common in real services
import { createTerminus } from '@godaddy/terminus';
import http from 'node:http';

const server = http.createServer(handler);

createTerminus(server, {
  signal: 'SIGINT',
  signals: ['SIGINT', 'SIGTERM'],
  timeout: 10_000,

  healthChecks: {
    '/health': async () => {
      // throw to signal unhealthy
      await db.ping();   // example: actually check the DB
      return { db: 'ok' };
    },
    verbatim: true,
  },

  // Called before server.close(). Mark unhealthy here.
  beforeShutdown: async () => {
    // Give LBs time to notice via /health
    await new Promise(r => setTimeout(r, 5_000));
  },

  onSignal: async () => {
    // Cleanup after server.close()
    await db.close();
    await queueClient.disconnect();
  },
});

server.listen(3000);

External links

Exercise

Node HTTP 서비스 하나를 골라 안전한 종료 절차를 모두 넣어 봐. SIGTERM 처리 함수, 종료 중 실패하는 상태 확인 경로, server.close(), DB 연결 종료, 최종 제한 시간 타이머가 필요해. 서비스를 시작한 뒤 5초를 기다렸다가 done을 반환하는 /slow를 요청하고, 처리 중에 kill -TERM <pid>를 보내. 기존의 느린 요청은 끝까지 마치고 새 요청은 거부하며, 프로세스는 정해 둔 제한 시간 안에 깨끗하게 종료되어야 해.
Hint
종료가 끝나지 않는다면 unref()하지 않은 타이머나 닫히지 않은 연결을 의심해. 파일 감시기, WebSocket 클라이언트, 명시적으로 닫지 않은 DB 풀이 흔한 원인이야. 정리 단계마다 로그를 넣어 어디에서 멈추는지 찾고, 해당 자원의 종료 메서드가 프로미스를 돌려준다면 반드시 await해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.