홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

Ctrl+C를 누르면 서버에 무슨 일이 일어나는가 — Signal과 Graceful Shutdown

정기창·2026년 3월 9일

개발 중에 Ctrl+C를 누르는 건 너무 자연스러운 동작입니다. 터미널에서 서버를 끄고, 다시 켜고. 하루에도 수십 번 반복합니다. 그런데 문득 궁금해졌습니다. Ctrl+C를 누르는 그 순간, 서버 내부에서는 정확히 무슨 일이 일어나는 걸까요?

진행 중인 HTTP 요청은 어떻게 되는지, 데이터베이스 커넥션은 제대로 정리되는지, 파일에 쓰고 있던 데이터는 안전한지. 개발 환경에서는 별 문제가 없었지만, 이 서버가 Docker 컨테이너 안에서 돌아가고 있고, 배포 도중 docker stop 명령을 받는다면 이야기가 달라집니다.

이 글에서는 OS 시그널의 기초부터 시작해서, Node.js와 NestJS에서의 처리 방법, Docker 환경에서 주의할 점까지 하나씩 따라가봅니다.

시그널이란 무엇인가

시그널(Signal)은 운영체제가 프로세스에게 보내는 비동기 알림입니다. "지금 종료해라", "설정을 다시 읽어라" 같은 메시지를 프로세스에 전달하는 방법입니다.

중요한 시그널 세 가지를 먼저 정리하겠습니다.

SIGINT (Signal Interrupt)

터미널에서 Ctrl+C를 누르면 발생합니다. 사람이 키보드로 직접 보내는 종료 요청입니다. "내가 지금 이 프로세스를 중단하고 싶다"는 사용자의 직접적인 개입입니다.

기본적으로 프로세스는 SIGINT를 받으면 즉시 종료됩니다. 하지만 프로세스가 원한다면, 이 기본 동작을 무시하고 자기만의 처리를 할 수 있습니다. 예를 들어 "SIGINT를 받으면 즉시 죽지 말고, 먼저 DB 커넥션을 정리한 뒤 종료해라"라고 프로그래밍할 수 있다는 뜻입니다. 이것을 시그널 핸들러(signal handler)를 등록한다고 표현합니다.

SIGTERM (Signal Terminate)

프로세스에게 "정상적으로 종료해달라"고 요청하는 시그널입니다. SIGINT와의 차이는 보내는 주체입니다. SIGINT가 사람이 키보드로 보내는 것이라면, SIGTERM은 다른 프로세스나 시스템이 프로그래밍적으로 보내는 종료 요청입니다.

예를 들면 이런 상황에서 SIGTERM이 전달됩니다.

  • docker stop → Docker 데몬이 컨테이너에 SIGTERM을 보냄
  • kill <PID> → 쉘에서 다른 프로세스에게 SIGTERM을 보냄
  • Coolify 배포 → 배포 시스템이 기존 컨테이너에 SIGTERM을 보냄

받는 쪽 입장에서는 SIGINT와 동일합니다. 핸들러를 등록하면 기본 동작(즉시 종료) 대신 원하는 정리 작업을 수행할 수 있습니다.

SIGKILL (Signal Kill)

SIGKILL은 앞의 두 시그널과 근본적으로 다릅니다. 이 시그널에 대해서는 핸들러를 등록하는 것 자체가 불가능합니다. 운영체제 커널이 프로세스를 즉시 제거합니다. 프로세스에게는 어떤 코드도 실행할 틈이 주어지지 않습니다. 정리 작업도, 마지막 로그도 남길 수 없습니다.

kill -9 <PID>가 바로 SIGKILL입니다. "더 이상 기다릴 수 없으니 지금 당장 죽여라"라는 의미입니다.

세 시그널의 차이

정리하면 이렇습니다.

시그널      번호    핸들러 등록 가능?    누가 보내는가?
─────────  ────   ──────────────    ──────────────────────
SIGINT      2      ✅ 가능            사람 (Ctrl+C)
SIGTERM    15      ✅ 가능            시스템 (docker stop, kill, 배포 시스템)
SIGKILL     9      ❌ 불가            최후의 수단 (kill -9)

SIGINT와 SIGTERM은 "종료해달라는 요청"입니다. 보내는 주체가 사람이냐 시스템이냐의 차이일 뿐, 받는 쪽에서 처리하는 방식은 동일합니다. 프로세스는 이 요청을 받아들여서 바로 죽을 수도 있고, 자기만의 정리 작업을 수행한 뒤에 죽을 수도 있습니다. 반면 SIGKILL은 요청이 아니라 강제 집행입니다. 프로세스의 의사와 무관하게 OS가 직접 프로세스를 제거합니다.

여기서 핵심적인 설계 철학이 보입니다. 운영체제는 프로세스에게 먼저 정중하게 요청(SIGTERM)하고, 응답이 없을 때 강제로 종료(SIGKILL)하는 2단계 구조를 제공합니다. 이 틈새, 즉 SIGTERM을 받고 SIGKILL이 오기 전까지의 시간이 바로 Graceful Shutdown을 구현할 수 있는 구간입니다.

Node.js에서 시그널 처리하기

Node.js는 process 객체를 통해 OS 시그널을 이벤트로 받을 수 있습니다. 앞서 말한 "시그널 핸들러를 등록한다"는 것이 코드로는 이렇게 됩니다.

// 핸들러 미등록 상태:
// SIGTERM 수신 → Node.js 즉시 종료 (기본 동작)

// 핸들러 등록:
process.on('SIGTERM', () => {
  console.log('SIGTERM 수신: 종료 준비를 시작합니다');
  // 여기서 원하는 정리 작업 수행
  process.exit(0);  // 내가 직접 종료를 결정
});

process.on('SIGINT', () => {
  console.log('SIGINT 수신: Ctrl+C가 눌렸습니다');
  // 정리 작업 수행
  process.exit(0);
});

process.on('SIGTERM', ...)을 호출하는 순간, Node.js에게 "SIGTERM이 오면 기본 동작(즉시 종료) 대신 이 함수를 실행해라"라고 알려주는 것입니다. 그래서 핸들러 안에서 process.exit()을 직접 호출하지 않으면 프로세스가 종료되지 않습니다. 기본 동작을 덮어썼기 때문입니다.

이벤트 루프와 시그널의 관계

이전에 이벤트 루프 시리즈에서 다뤘던 내용과 연결됩니다. 시그널은 이벤트 루프가 돌아가는 중간에 끼어들어 처리됩니다. 정확히는 이벤트 루프의 각 페이즈 사이에서 시그널 핸들러가 실행됩니다.

이것이 의미하는 바는 명확합니다. CPU를 점유하는 무한 루프가 돌고 있다면, 이벤트 루프가 다음 페이즈로 넘어가지 못하므로 시그널 핸들러도 실행되지 않습니다. 이런 상황에서는 결국 SIGKILL만이 프로세스를 종료할 수 있습니다.

비동기 정리 작업의 주의점

시그널 핸들러에서 비동기 작업을 해야 할 때가 많습니다. 데이터베이스 커넥션을 닫거나, 진행 중인 작업을 마무리해야 하니까요.

process.on('SIGTERM', async () => {
  console.log('SIGTERM 수신: 정리 작업을 시작합니다');
  
  try {
    await server.close();           // 새 요청 수신 중단
    await database.disconnect();     // DB 커넥션 정리
    console.log('정리 완료, 종료합니다');
    process.exit(0);
  } catch (error) {
    console.error('정리 중 에러 발생:', error);
    process.exit(1);
  }
});

여기서 주의할 점이 있습니다. 정리 작업이 무한히 걸릴 수도 있다는 것입니다. 네트워크가 불안정해서 DB 커넥션을 닫는 데 시간이 걸리거나, 어떤 이유로 await가 resolve되지 않을 수도 있습니다.

이를 위한 안전장치로 타임아웃을 설정하는 것이 좋습니다.

process.on('SIGTERM', async () => {
  console.log('SIGTERM 수신');
  
  const forceExitTimeout = setTimeout(() => {
    console.error('정리 작업 타임아웃: 강제 종료합니다');
    process.exit(1);
  }, 10000); // 10초 후 강제 종료
  
  try {
    await server.close();
    await database.disconnect();
    clearTimeout(forceExitTimeout);
    process.exit(0);
  } catch (error) {
    clearTimeout(forceExitTimeout);
    process.exit(1);
  }
});

왜 Graceful Shutdown이 필요한가

이론적인 이야기를 했으니, 구체적인 상황을 생각해보겠습니다.

블로그 서버가 돌아가고 있습니다. 사용자가 글을 저장하는 중입니다. 이때 서버가 새 버전 배포를 위해 종료된다면 어떻게 될까요?

Graceful Shutdown이 없다면:

  • 진행 중인 POST 요청이 갑자기 끊깁니다. 사용자는 저장 버튼을 눌렀는데 응답이 없습니다.
  • MongoDB에 절반만 기록된 데이터가 남을 수 있습니다.
  • MySQL 커넥션 풀이 정리되지 않아 커넥션이 고아(orphan) 상태로 남습니다.
  • 파일 시스템에 쓰던 로그가 중간에 잘립니다.

Graceful Shutdown이 있다면:

  1. SIGTERM 수신 → 새로운 요청 수신을 중단합니다.
  2. 진행 중인 요청이 모두 완료될 때까지 기다립니다.
  3. 데이터베이스 커넥션을 정상적으로 닫습니다.
  4. 로그를 마무리하고 프로세스가 종료됩니다.

개발 환경에서는 이 차이를 체감하기 어렵습니다. Ctrl+C를 눌러도 별 문제가 없으니까요. 하지만 프로덕션 환경, 특히 무중단 배포(rolling deployment)를 하는 상황에서는 이야기가 완전히 달라집니다.

NestJS에서 Graceful Shutdown 구현하기

NestJS는 Graceful Shutdown을 위한 라이프사이클 인터페이스를 제공합니다. 제 블로그 백엔드에서 실제로 구현한 코드를 기반으로 설명하겠습니다.

1단계: Shutdown Hooks 활성화

먼저 main.ts에서 shutdown hooks를 활성화해야 합니다.

// packages/backend/src/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // NestJS shutdown hooks 활성화
  app.enableShutdownHooks();
  
  // 서버 타임아웃 설정
  const server = app.getHttpServer() as http.Server;
  server.setTimeout(180000);          // 요청 처리: 3분
  server.keepAliveTimeout = 175000;   // keep-alive: 175초
  server.headersTimeout = 180000;     // 헤더 수신: 3분
  server.requestTimeout = 180000;     // 요청 수신: 3분
  
  await app.listen(3000);
}

enableShutdownHooks()를 호출하면, NestJS가 시그널을 받았을 때 각 모듈의 라이프사이클 메서드를 자동으로 호출합니다. 이 한 줄이 없으면 모듈의 onModuleDestroy가 실행되지 않습니다.

2단계: 시그널 핸들러 등록

다음으로 SIGTERM과 SIGINT 핸들러를 등록합니다.

// packages/backend/src/main.ts
const logger = new Logger('Bootstrap');

process.on('SIGTERM', async () => {
  logger.log('SIGTERM signal received: closing HTTP server');
  await app.close();
  process.exit(0);
});

process.on('SIGINT', async () => {
  logger.log('SIGINT signal received: closing HTTP server');
  await app.close();
  process.exit(0);
});

app.close()가 호출되면 NestJS는 다음 순서로 정리를 진행합니다.

app.close() 호출
    ↓
1. OnModuleDestroy 훅 실행 (각 모듈)
    ↓
2. 커넥션 정리 (DB, 외부 서비스)
    ↓
3. HTTP 서버 종료
    ↓
process.exit(0)

SIGTERM과 SIGINT 두 가지 모두 처리하는 이유가 있습니다. 로컬 개발에서는 Ctrl+C(SIGINT)로 종료하고, Docker/배포 환경에서는 docker stop(SIGTERM)으로 종료하기 때문입니다. 둘 다 처리해야 어떤 환경에서든 안전하게 종료됩니다.

3단계: 모듈별 정리 작업 — OnModuleDestroy

각 모듈은 OnModuleDestroy 인터페이스를 구현해서 자신만의 정리 작업을 수행할 수 있습니다. 제 프로젝트에서는 MySQL 커넥션 풀을 정리하는 데 사용하고 있습니다.

// packages/backend/src/database/drizzle/drizzle.module.ts
import { OnModuleDestroy } from '@nestjs/common';

export class DrizzleModule implements OnModuleDestroy {
  constructor(
    @Optional() @Inject(MYSQL_POOL) private readonly pool: Pool | null,
  ) {}

  async onModuleDestroy() {
    if (this.pool) {
      await this.pool.end();
      console.log('MySQL connection pool closed');
    }
  }
}

몇 가지 눈여겨볼 점이 있습니다.

@Optional() 데코레이터: MySQL 설정이 없는 환경(예: MongoDB만 사용하는 개발 환경)에서도 에러 없이 동작합니다. pool이 null이면 정리를 건너뜁니다.

pool.end(): 커넥션 풀의 모든 커넥션을 정상적으로 닫습니다. 진행 중인 쿼리가 있다면 완료될 때까지 기다린 뒤 커넥션을 반환합니다. kill -9로 강제 종료하면 이 과정이 생략되어 DB 서버 쪽에 좀비 커넥션이 남을 수 있습니다.

MongoDB는? Mongoose는 app.close() 시 자동으로 커넥션을 정리합니다. 별도의 onModuleDestroy가 필요 없습니다. 다만 MySQL(Drizzle ORM)은 커넥션 풀을 직접 관리하기 때문에 명시적으로 닫아줘야 합니다.

Docker에서의 시그널 전달

여기서부터가 실제 프로덕션에서 문제가 되는 부분입니다. Docker 환경에서는 시그널이 예상과 다르게 동작할 수 있습니다.

docker stop의 동작 원리

docker stop 명령을 실행하면 Docker는 다음 순서로 동작합니다.

docker stop 실행
    ↓
1. 컨테이너의 PID 1 프로세스에 SIGTERM 전송
    ↓
2. 10초 대기 (기본 grace period)
    ↓
3. 아직 살아있으면 SIGKILL 전송 → 강제 종료

앞서 설명한 "정중한 요청 → 강제 종료"의 2단계 구조가 그대로 적용됩니다. 기본 대기 시간은 10초이고, docker stop -t 30처럼 변경할 수 있습니다.

여기서 중요한 키워드는 "PID 1 프로세스"입니다.

PID 1 문제: 시그널이 전달되지 않는 경우

Docker 컨테이너 안에서 가장 처음 실행되는 프로세스가 PID 1을 받습니다. docker stop은 이 PID 1에만 시그널을 보냅니다. 문제는 Dockerfile의 CMD 작성 방식에 따라 PID 1이 달라진다는 것입니다.

Shell form vs Exec form

Dockerfile의 CMD를 작성하는 두 가지 방식이 있습니다.

# Shell form — 문제가 될 수 있음
CMD node dist/main.js

# Exec form — 권장
CMD ["node", "dist/main.js"]

이 두 줄은 겉보기에 같은 일을 하는 것 같지만, 내부 동작이 완전히 다릅니다.

Shell form으로 작성하면 실제로는 이렇게 실행됩니다.

PID 1: /bin/sh -c "node dist/main.js"
  └─ PID 7: node dist/main.js

/bin/sh이 PID 1을 차지하고, Node.js 프로세스는 자식 프로세스가 됩니다. docker stop이 SIGTERM을 보내면 /bin/sh이 받는데, 기본 shell은 자식에게 시그널을 전달하지 않습니다. 결과적으로 Node.js 프로세스는 SIGTERM을 받지 못하고, 10초 후 SIGKILL로 강제 종료됩니다.

우리가 열심히 작성한 process.on('SIGTERM') 핸들러가 실행되지 않는 것입니다.

Exec form으로 작성하면:

PID 1: node dist/main.js

Node.js 프로세스가 직접 PID 1이 되어 SIGTERM을 정상적으로 수신합니다. Graceful Shutdown이 의도한 대로 동작합니다.

실제 Dockerfile 예시

제 블로그 백엔드의 Dockerfile에서 관련 부분을 보겠습니다.

# 실행 스테이지
FROM node:20-alpine AS runner

WORKDIR /app

# ... (빌드 아티팩트 복사, 의존성 설치 등)

EXPOSE 3000

# Exec form 사용 — Node.js가 PID 1이 됨
CMD ["node", "dist/main.js"]

Exec form을 사용하고 있으므로 docker stop 시 Node.js가 SIGTERM을 직접 수신하고, 우리가 등록한 핸들러가 정상적으로 실행됩니다.

STOPSIGNAL 설정

Docker는 기본적으로 SIGTERM을 보내지만, Dockerfile에서 다른 시그널을 지정할 수도 있습니다.

STOPSIGNAL SIGTERM  # 기본값, 보통 명시하지 않아도 됨

대부분의 경우 기본값인 SIGTERM이면 충분합니다. 특별한 이유가 없다면 변경할 필요가 없습니다.

npm start로 실행하면 안 되는 이유

같은 맥락에서 한 가지 더 짚을 점이 있습니다. Docker 안에서 npm start나 npm run start:prod로 실행하는 경우가 있습니다.

# ❌ 피해야 할 패턴
CMD ["npm", "run", "start:prod"]

이 경우 프로세스 트리가 이렇게 됩니다.

PID 1: npm
  └─ PID 17: node dist/main.js

npm이 PID 1을 차지하고, 최신 버전의 npm은 시그널을 자식에게 전달하지만, 항상 그렇다는 보장은 없습니다. 불필요한 중간 프로세스 없이 CMD ["node", "dist/main.js"]로 직접 실행하는 것이 가장 확실합니다.

헬스체크와 Graceful Shutdown의 연결

이전 글에서 Liveness와 Readiness 헬스체크에 대해 다뤘습니다. Graceful Shutdown과 헬스체크는 긴밀하게 연결됩니다.

이상적인 종료 흐름은 다음과 같습니다.

SIGTERM 수신
    ↓
1. Readiness 상태를 "not ready"로 변경
   → 로드밸런서가 이 인스턴스로 새 트래픽 보내지 않음
    ↓
2. 진행 중인 요청 완료 대기
    ↓
3. DB 커넥션, 파일 핸들 등 리소스 정리
    ↓
4. 프로세스 종료

1단계가 핵심입니다. SIGTERM을 받자마자 서버를 닫는 것이 아니라, 먼저 "나는 더 이상 트래픽을 받을 준비가 안 되어있다"고 알린 뒤, 이미 들어온 요청을 마무리하는 것입니다.

현재 제 블로그 서버에서는 Docker HEALTHCHECK가 Readiness 엔드포인트(/health/ready)를 확인하고 있습니다. 서버가 종료되면 이 엔드포인트가 응답하지 못하게 되면서 자연스럽게 unhealthy 상태가 됩니다. Coolify 같은 배포 플랫폼은 이 상태를 감지하고 트래픽을 다른 인스턴스로 돌립니다.

정리: Graceful Shutdown 체크리스트

지금까지의 내용을 실무 체크리스트로 정리하겠습니다.

[ ] SIGTERM, SIGINT 핸들러가 등록되어 있는가?
[ ] 핸들러에서 app.close()를 호출하는가?
[ ] enableShutdownHooks()가 활성화되어 있는가?
[ ] DB 커넥션 풀을 onModuleDestroy에서 정리하는가?
[ ] Dockerfile의 CMD가 exec form인가? (JSON 배열 형식)
[ ] npm start 대신 node를 직접 실행하는가?
[ ] 정리 작업에 타임아웃이 설정되어 있는가?
[ ] 헬스체크 엔드포인트가 종료 상태를 반영하는가?

마무리

Ctrl+C라는 단순한 동작에서 시작했지만, 따라가다 보니 OS 시그널, Node.js 이벤트 루프, NestJS 라이프사이클, Docker 프로세스 관리까지 이어졌습니다.

개발 환경에서는 이 모든 것이 불필요하게 느껴질 수 있습니다. Ctrl+C를 눌러도 아무 문제가 없으니까요. 하지만 프로덕션에서 docker stop이 실행될 때, 진행 중인 요청이 안전하게 완료되고, DB 커넥션이 깨끗하게 정리되는 것 — 이것이 안정적인 서비스의 기본이라는 생각이 들었습니다.

이전에 헬스체크 글을 쓸 때는 "서비스가 살아있는지 감지하는 것"에 초점을 맞췄는데, 이번 글을 쓰면서 "서비스가 죽을 때도 예의 바르게 죽어야 한다"는 것을 좀 더 깊이 이해하게 되었습니다.

Node.jsNestJSDockerSignalGraceful ShutdownDevOps

관련 글

NestJS 서버로 추적하는 이벤트 루프 — 이론에서 실전으로 (5편)

이벤트 루프 이론을 실제 NestJS 서버 코드에 대입합니다. 서버 시작부터 HTTP 요청 처리까지, 6개 페이즈가 실제로 어떤 역할을 하는지 추적합니다.

관련도 88%

PM2 vs Coolify: 상황에 맞는 Node.js 배포 전략 선택하기

Node.js 배포 도구인 PM2와 Coolify의 차이점을 분석하고, 프로젝트 특성에 따른 선택 기준을 제시합니다.

관련도 88%

Liveness와 Readiness: 컨테이너 헬스체크의 두 가지 관점

Docker나 Kubernetes 환경에서 헬스체크를 구현할 때 Liveness와 Readiness의 차이를 이해하고, NestJS Backend와 Next.js Web에서 실제로 구현한 경험을 정리했습니다.

관련도 88%