배포 중 작업 유실을 막는 Graceful Shutdown 구현하기
Email Worker의 안정적인 무중단 배포를 위해 Deferred Promise 패턴을 활용한 Graceful Shutdown을 구현한 경험을 공유합니다.
문제 상황
코드 리뷰에서 다음과 같은 피드백을 받았습니다.
email-worker가 특정 동작을 수행하고 있을 때, 신규 버전 배포로 인해 재시작이 되면
진행중이던 작업이 유실될 가능성이 높아 보입니다.
특히 email-worker의 경우 네트워크 오버헤드로 인해 하나의 작업 처리 기간이 비교적 길기에
더욱 가능성이 높아보입니다.
구체적인 문제 시나리오
1. 사용자가 회원가입 시도
2. API 서버가 이메일 전송 메시지를 RabbitMQ에 전송
3. Email Worker가 큐에서 메시지를 받아 이메일 전송 시작 (약 3초 소요)
4. 🚨 이때 새 버전 배포 발생!
5. Docker가 컨테이너 강제 종료 (SIGKILL)
6. ❌ 이메일 전송 실패
7. ❌ 사용자는 인증 메일을 받지 못함
8. ❌ 메시지는 큐에서 이미 제거됨 (ACK 전송됨)
이 문제를 어떻게 해결할 수 있을까요?
해결 방법 탐색
배포 중 작업 유실을 막는 방법으로 다음을 고려했습니다.
두 개의 무중단 배포 방식과 종료를 잘 처리하는 방식입니다.
Blue/Green Deploy
운영중인 구버전의 Blue 그룹과 신버전인 Green 그룹을 동시에 띄워놓고, 로드밸런서를 활용해서 트래픽을 한 번에 Green 그룹으로 전환하는 방식입니다.
그림을 순차적으로 표현하면 다음과 같습니다.
1.Blue 그룹 운영중 Green 그룹 구성

2.잠깐 동안은 Blue 그룹과 Green 그룹을 같이 운영

3.Blue 그룹 종료

장점:
- 완전한 무중단 배포
- 빠른 롤백 가능
단점:
- 서버 리소스 2배 필요
- 트래픽이 거의 없는 우리 상황에는 과함
Rolling Update
A, B, C라는 기존 서버가 있다고 가정하면 다음과 같습니다.
A, B, C 순차적으로 새로운 업데이트 버전인 A', B', C'로 교체해주는 방식을 말합니다.
서버 3대: [A] [B] [C] ← 사용자 트래픽
1단계: [A*] [B] [C] ← A만 업데이트
2단계: [A*] [B*] [C] ← B 업데이트
3단계: [A*] [B*] [C*] ← C 업데이트
장점:
- 단계적 배포로 리스크 분산
- 완전한 무중단
단점:
- 최소 2개 이상의 인스턴스 필요
- 로드밸런서 필요
- 우리는 서버 1대만 운영 중임
Graceful Shutdown
우리 서버는 트래픽도 많이 없고, 인스턴스도 하나인 수준입니다.
그래서 RabbitMQ의 메시지 소비를 막고, 이미 메모리에 올라가 있는 작업만 완료한 뒤에 업데이트하면 되겠다는 생각을 했습니다.
구현해야 하는 내용은 아래와 같습니다.
배포 시작
↓
SIGTERM 전송
↓
새 메시지 받지 않기
↓
진행 중인 작업 완료 (5~10초)
↓
정상 종료
장점:
- 추가 리소스 불필요
- 구현이 비교적 간단
- 소규모 트래픽 환경에 적합
단점:
- 완전 무중단은 아님 (수초~수십초 다운타임)
- 매우 긴 작업은 여전히 위험
Graceful Shutdown이란?
Graceful Shutdown은 애플리케이션을 종료할 때 다음 단계를 거치는 것을 말합니다:
종료 과정
1. 종료 신호 수신 (SIGTERM)
↓
2. 새로운 요청 거부
↓
3. 진행 중인 작업 완료 대기
↓
4. 리소스 정리 (RDBMS, Redis, RabbitMQ 등)
↓
5. 정상 종료 (exit 0)
구현 요구사항
Graceful Shutdown을 구현하려면:
- SIGTERM 신호 처리: Docker/OS가 보내는 종료 신호 감지
- 작업 추적: 현재 진행 중인 작업 개수 관리
- 완료 대기: 비동기 작업이 끝날 때까지 대기
- 타임아웃 설정: 무한 대기 방지
이 중 완료 대기 가 가장 까다로운 부분입니다. 이를 해결하기 위해 Deferred Promise 패턴을 사용했습니다.
Deferred Promise 패턴
문제: 비동기 이벤트를 어떻게 기다리지?
Graceful Shutdown을 구현하려면:
// Shutdown 함수에서
async function handleShutdown() {
console.log('종료 시작');
// 여기서 작업을 기다려야 함. 하지만 메시지 소비는 다른 메서드에서 작업 중임.
await emailConsumer.waitForPendingTasks();
console.log('모든 작업 완료, 종료');
}
// 메시지 처리 완료 (완전히 다른 시점, 다른 함수)
function onMessageComplete() {
pendingTasks--;
// 여기서 어떻게 '대기 중인 Shutdown'에게 알릴 수 있는가?
if (pendingTasks === 0) {
}
}
핵심 문제: 서로 다른 함수, 다른 시점에서 Promise를 제어하는 것.
해결: Deferred Promise
Deferred Promise는 Promise의 resolve/reject를 외부에서 제어하는 패턴입니다.
// 일반적인 Promise (내부 제어)
const promise = new Promise((resolve) => {
setTimeout(() => resolve('완료'), 1000); // 내부에서만 제어
});
// Deferred Promise (외부 제어)
let externalResolve;
const promise = new Promise((resolve) => {
externalResolve = resolve; // resolve를 밖으로 빼냄!
});
// 나중에 외부에서
externalResolve('완료'); // 외부에서 Promise 완료!
간단한 예제
class Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: any) => void;
constructor() {
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
}
// 사용 예시
const deferred = new Deferred<string>();
// 어디선가 대기
deferred.promise.then(result => {
console.log('완료:', result);
});
// 나중에 다른 곳에서 완료
setTimeout(() => {
deferred.resolve('작업 끝!');
}, 1000);
정리
우리의 경우에는 shutdown 시점에는 종료를 대기하기 위한 Promise를 발행하고,
작업이 완료될 때 그 Promise의 resolve 함수를 호출해서 다음 리소스 정리 단계로 넘어가게 만들 수 있었습니다.
실제 구현
EmailConsumer 클래스 구조
@injectable()
export class EmailConsumer {
private consumerTag: string | null;
private shuttingDownFlag = false; // Shutdown 상태
private pendingTasks = 0; // 진행 중인 작업 수
private shutdownResolver: (() => void) | null = null; // Deferred Promise 패턴의 resolve를 저장할 변수
// ...
}
작업 완료 대기 함수
async waitForPendingTasks(): Promise<void> {
// 대기 중인 작업이 없으면 바로 리턴
if (this.pendingTasks === 0) {
logger.info('[EmailConsumer] 대기 중인 작업 없음');
return;
}
logger.info(`[EmailConsumer] ${this.pendingTasks}개 작업 완료 대기 중...`);
return new Promise((resolve) => {
this.shutdownResolver = resolve; // resolve 저장!
// 안전장치: 최대 10초 대기
setTimeout(() => {
logger.warn('[EmailConsumer] 대기 시간 초과 - 강제 종료');
resolve();
}, 10000);
});
}
shutdown 시, waitForPendingTasks 메서드를 호출하면 내부적으로 만든 Promise의 resolve를 showdownResolver에 저장합니다.
메시지 소비 작업이 이루어지는 메서드 내부에서 메모리에 대기중인 모든 작업이 종료되면, showdownResolver를 호출해서 대기가 끝나도록 만듭니다.
그리고 무한 대기할 수도 있기에, 도커 종료 대기 시간보다 짧은 시간만큼 setTimeout을 설정해서 강제 종료한 뒤에 리소스를 정리할 수 있도록 합니다.
메시지 처리
async start() {
this.consumerTag = await this.rabbitmqService.consumeMessage(
RMQ_QUEUES.EMAIL_SEND,
async (payload: EmailPayload) => {
// Shutdown 중이면 메시지 처리 안 함
if (this.shuttingDownFlag) {
logger.warn('[EmailConsumer] Shutdown 중 - 메시지 건너뜀');
throw new Error('SHUTDOWN_IN_PROGRESS');
}
this.pendingTasks++; // 작업 시작
logger.info(`이메일 전송 시작 (대기 중인 작업: ${this.pendingTasks})`);
try {
await this.handleEmailByType(payload);
logger.info('이메일 전송 완료');
} finally {
this.pendingTasks--; // 작업 완료
logger.info(`남은 작업: ${this.pendingTasks}`);
// 모든 작업 완료 시 알림
if (
this.shuttingDownFlag &&
this.pendingTasks === 0 &&
this.shutdownResolver
) {
logger.info('모든 작업 완료 - Shutdown 진행');
this.shutdownResolver(); // Promise 완료!
}
}
},
);
}
Shutdown 핸들러
async function handleShutdown(dependencies, signal) {
logger.info(`${signal} 신호 수신, email-worker 종료 중...`);
try {
// 1. 새 메시지 받지 않기
logger.info('새로운 메시지 수신 중지...');
await dependencies.emailConsumer.stopConsuming();
// 2. 진행 중인 작업 완료 대기
logger.info('진행 중인 이메일 전송 작업 완료 대기...');
await dependencies.emailConsumer.waitForPendingTasks();
// 3. 정리
logger.info('Consumer 정리 중...');
await dependencies.emailConsumer.close();
logger.info('RabbitMQ 연결 종료 중...');
await dependencies.rabbitMQManager.disconnect();
logger.info('Email Worker 정상 종료 완료');
process.exit(0);
} catch (error) {
logger.error(`Graceful Shutdown 중 에러: ${error}`);
process.exit(1);
}
}
// SIGTERM 핸들러 등록
process.on('SIGTERM', async () => {
await handleShutdown(dependencies, 'SIGTERM');
});
동작 흐름
[정상 동작]
메시지 수신 → pendingTasks++ → 이메일 전송 → pendingTasks-- → ACK
[Shutdown 동작]
SIGTERM 수신
↓
stopConsuming() → shuttingDownFlag = true
↓
waitForPendingTasks()
↓ (Promise 생성, resolve 저장)
대기... (pendingTasks = 2)
↓
이메일 1 완료 → pendingTasks-- (= 1)
↓
이메일 2 완료 → pendingTasks-- (= 0)
↓
shutdownResolver() 호출
↓
Promise 완료, 정리 시작
↓
정상 종료
💡TMI) Promise.withResolvers()
Deferred Promise 패턴은 코드를 읽기 어렵게 만들 수도 있어서, 안티 패턴으로 간주되는 경향이 있습니다. Promise는 비동기 로직과 결과 처리를 명확하게 분리해야 하는데, 해당 패턴이 경계를 모호하게 만들기 때문입니다.
이런 Deferred 패턴을 표준화된 형태로 ES2024부터 Promise.withResolvers()라는 static 메서드를 지원합니다. const { promise, resolve, reject } = Promise.withResolvers();
위의 구현이 Promise.withResolvers()를 활용하면 다음과 같습니다.const { promise, resolve } = Promise.withResolvers<void>(); this.shutdownResolver = resolve; // setTimeout은 Promise.race로 처리 const timeout = new Promise<void>((timeoutResolve) => { setTimeout(() => { logger.warn('⚠️ [EmailConsumer] 대기 시간 초과'); timeoutResolve(); }, 10000); }); // 둘 중 먼저 끝나는 것 await Promise.race([promise, timeout]);
이렇게 만들 수 있음에도, withResolvers 메서드를 사용하면 setTimeout 때문에 코드가 더 복잡하다고 느껴서 Deferred Promise 패턴을 활용했습니다.
인프라 설정
애플리케이션 코드 외에도, Docker와 GitHub Actions 설정도 필요합니다.
주의사항
문제: sh가 PID 1이 되면 SIGTERM이 Node.js에 전달되지 않습니다.
# 잘못된 방법
CMD ["sh", "-c", "npm run start"]
# 프로세스 트리:
# PID 1: sh ← Docker가 SIGTERM을 여기로 보냄
# └─ npm ← 전달 안 됨!
# └─ node
exec나 npm을 호출하도록 해서, PID 1인 프로세스가 npm이 될 수 있도록 설정해야 합니다.
그래야 SIGTERM이 Node.js로 전달됩니다.
docker-compose.yml
services:
email-worker:
image: ghcr.io/org/email-worker:latest
stop_grace_period: 30s # Graceful shutdown 대기 시간
# ...
주의: 애플리케이션 타임아웃(10초) < Docker Grace Period(30초)
GitHub Actions 워크플로우
env:
STOP_GRACE_PERIOD: 30
jobs:
deploy:
steps:
- name: Graceful Shutdown & 서비스 재시작
run: |
# -t 옵션으로 대기 시간 설정
docker compose stop -t $STOP_GRACE_PERIOD email-worker
docker compose up -d --force-recreate email-worker
한계점
우리 팀의 상황에 맞게 문제를 해결할 수는 있었지만 아래와 같은 이유로, 완벽하지는 않습니다.
- 매우 긴 작업: 30초 이상 걸리는 작업은 여전히 위험
- 부분 다운타임: 완전 무중단은 아님 (5~30초)
- 수동 관리: 타임아웃 등 설정 필요
참고 자료
'개발 > 개발 공부' 카테고리의 다른 글
| amqplib을 가볍게 이해하기 (0) | 2025.10.31 |
|---|---|
| [AWS] RDS로 데이터 삽입 삽질 (0) | 2025.07.03 |
| 잘못된 리팩토링, OOP와 순수 함수 (0) | 2025.06.07 |
| 테스트 코드는 왜 작성하기 어려울까? (0) | 2025.05.31 |
| 검색 구현을 위한 기초 공부 (0) | 2025.05.05 |