본문 바로가기

[배포]

[배포] ✨ Graceful Shutdown

👨‍🍳 우리가 식당을 운영하고 있다고 생각해보자.

손님이 식사를 하고 있고, 주방에서는 요리가 한창이며, 계산대에서는 결제가 이뤄지고 있다.

그런데 어느 날, 갑자기 누군가 “이제 문 닫아야 해요!” 하고 전기를 뚝 끊어버렸다.

  1. 먹다 말고 나가야 하는 손님들 😨
  2. 완성되지 않은 요리 🍳
  3. 계산 중이던 손님 카드 결제 실패 💳
  4. 심지어 저장되지 않은 장부도 날아감 😭

이렇게 “갑작스러운 종료”는 많은 문제를 일으킬 수 있다.


❗️ Graceful Shutdown은 갑작스러운 종료를 방지하는 방법

"우아한 종료”라는 건, 말 그대로 서비스나 프로그램을 ‘예쁘게’, ‘안전하게’ 종료시키는 과정.

  1. “곧 종료할게!” 라는 신호를 받으면 👉 서버나 프로그램은 “오! 곧 꺼질 거구나” 하고 준비를 함.
  2. 새로운 요청을 막음  👉 새로운 손님은 받지 않고, 이미 식당에 들어온 손님만 마무리함.
  3. 현재 처리 중이던 작업들을 끝까지 마무리
    •  👉 파일 저장 중이면 저장을 완료
    •  👉 DB 작업 중이면 커밋 or 롤백 처리
    •  👉 사용자 요청이면 응답 끝까지 주기
  4. 모든 걸 안전하게 정리한 뒤 종료

🖥️ 서버에서의 실제 예시

  • 서버가 SIGTERM (종료 신호)을 받으면,
  • Thread 풀 정리, DB 연결 끊기, Queue 처리 마무리 등을 한 뒤,
  • 시스템을 종료

🤔 왜 이렇게까지 해야 할까?

  1. 데이터 유실 방지
  2. 사용자 경험 보호 (응답 실패 최소화)
  3. 자원 누수 방지 (메모리, 커넥션, 파일 등)
  4. 시스템의 안정성과 신뢰성 확보

🔧 구현 예시 (Java Spring Boot)

// 1) application.properties
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s  // 최대 30초 대기

// 2) GracefulShutdownHandler 등록
@Component
public class GracefulShutdown implements ApplicationListener<ContextClosedEvent> {

    private final WebServer webServer;

    @Autowired
    public GracefulShutdown(WebServer webServer) {
        this.webServer = webServer;
    }

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        System.out.println("🛑 ContextClosedEvent 수신: 종료 시작");
        // 1) 신규 연결 막기
        webServer.stop();  
        // 2) 기존 작업 완료 대기 (spring.lifecycle.timeout 설정 사용)
    }
}

// 3) @PreDestroy 로 리소스 정리
@Service
public class SomeService {
    @PreDestroy
    public void cleanup() {
        // 예: DB 커넥션 풀 닫기, 스케줄러 종료
        System.out.println("🔧 리소스 정리 중...");
    }
}
  • server.shutdown=graceful 설정으로 톰캣/언더토우/WebFlux가 SIGTERM 후 자동으로 대기
  • ContextClosedEvent로 커스텀 로직 추가 가능
  • @PreDestroy 를 활용해 빈 단위로도 정리

🔧 Node.js (Express) 구현 예제

const express = require('express');
const http = require('http');
const app = express();

let server = http.createServer(app);
let connections = new Set();

server.on('connection', (sock) => {
  connections.add(sock);
  sock.on('close', () => connections.delete(sock));
});

app.get('/', (req, res) => {
  // 예: 오래 걸리는 작업
  setTimeout(() => res.send('완료!'), 5000);
});

server.listen(3000, () => console.log('🚀 서버 시작'));

function gracefulShutdown() {
  console.log('🛑 종료 신호 수신, 신규 연결 차단');
  server.close(() => {
    console.log('✅ 모든 연결 종료, 프로세스 종료');
    process.exit(0);
  });
  // 아직 닫히지 않은 연결을 강제 종료 (예: 30초 후)
  setTimeout(() => {
    console.log('⏰ 타임아웃, 강제 종료');
    connections.forEach((sock) => sock.destroy());
    process.exit(1);
  }, 30_000);
}

process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
  • server.close() 로 더 이상 accept() 하지 않음
  • 열린 소켓을 추적해 필요 시 강제 끊기
  • 최대 대기 시간을 둬서 무한 대기를 방지

🌟 구현 시 주의사항 & 팁

  • 타임아웃: 무한 대기를 막기 위해 적절한 최대 대기시간을 설정
  • 상태 저장: 종료 중인 요청에도 “처리 중” 상태를 클라이언트에 알려줄 수 있는 API 설계 고려
  • 헬스체크: 로드 밸런서(LB)가 종료 중인 인스턴스를 감지하고 트래픽을 빼도록 /health 엔드포인트 구현
  • 테스트: 실제로 kill -TERM <pid> 등을 통해 시그널 동작을 반드시 로컬/스테이징에서 검증