우아한 종료 (Graceful Shutdown)

Graceful Shutdown이란?

Graceful Shutdown은 애플리케이션이 종료될 때 현재 처리 중인 작업을 안전하게 완료하고, 리소스를 정리한 후 종료하는 방식입니다. 갑작스러운 종료(Abrupt Shutdown)와 달리, 진행 중인 요청을 처리하고 데이터 손실을 방지합니다.

왜 필요한가?

일반적인 종료 문제점

// 갑작스러운 종료 시 발생할 수 있는 문제들
- 처리 중인 HTTP 요청이 중단됨
- 데이터베이스 트랜잭션이 롤백됨
- 파일 쓰기 작업이 중단되어 데이터 손실
- 외부 API 호출이 타임아웃
- 메시지 큐의 메시지가 손실됨

Graceful Shutdown의 이점

  1. 데이터 무결성: 진행 중인 트랜잭션을 완료
  2. 사용자 경험: 진행 중인 요청에 대한 정상 응답
  3. 리소스 정리: 연결, 파일, 스레드 등 정리
  4. 무중단 배포: 롤링 업데이트 시 서비스 중단 최소화

JVM Shutdown Hook

JVM은 종료 시 등록된 Shutdown Hook을 실행합니다.

기본 사용법

public class ShutdownHookExample {
public static void main(String[] args) {
// Shutdown Hook 등록
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("Shutdown Hook 실행 중...");

// 리소스 정리 작업
closeConnections();
saveState();

System.out.println("정리 작업 완료");
}));

System.out.println("애플리케이션 실행 중...");

// 애플리케이션 로직
}

private static void closeConnections() {
// DB 연결, 소켓 등 종료
}

private static void saveState() {
// 현재 상태 저장
}
}

Shutdown Hook 실행 시점

다음과 같은 경우에 Shutdown Hook이 실행됩니다:

  • 정상 종료 (System.exit())
  • SIGTERM 시그널 수신
  • Ctrl+C (SIGINT)
  • OutOfMemoryError 등 치명적 오류

실행되지 않는 경우:

  • SIGKILL (kill -9)
  • JVM 크래시
  • 운영체제 강제 종료

Spring Boot에서의 Graceful Shutdown

설정 방법

Spring Boot 2.3 이상에서는 내장 지원합니다.

# application.yml
server:
shutdown: graceful # graceful 또는 immediate (기본값)

spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 최대 대기 시간 (기본 30초)

또는 properties 파일:

# application.properties
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s

동작 방식

1. 종료 신호 수신 (SIGTERM)

2. 새로운 요청 거부 (503 Service Unavailable)

3. 진행 중인 요청 처리 완료 대기

4. 타임아웃 또는 모든 요청 완료

5. Bean 소멸 (PreDestroy)

6. 애플리케이션 종료

PreDestroy를 활용한 정리 작업

@Component
public class GracefulShutdownHandler {

private final ExecutorService executorService;
private final DataSource dataSource;

@PreDestroy
public void onShutdown() {
log.info("Graceful Shutdown 시작");

// 1. ExecutorService 종료
shutdownExecutor();

// 2. 캐시 저장
saveCache();

// 3. 연결 종료
closeConnections();

log.info("Graceful Shutdown 완료");
}

private void shutdownExecutor() {
executorService.shutdown();
try {
if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) {
executorService.shutdownNow();
}
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
}

private void saveCache() {
// 메모리 캐시를 디스크에 저장
}

private void closeConnections() {
// DB, Redis 등 연결 종료
}
}

SmartLifecycle 인터페이스

더 세밀한 제어가 필요한 경우 SmartLifecycle을 구현합니다.

@Component
public class GracefulShutdownLifecycle implements SmartLifecycle {

private volatile boolean running = false;

@Override
public void start() {
running = true;
log.info("애플리케이션 시작");
}

@Override
public void stop() {
log.info("Graceful Shutdown 시작");

// 진행 중인 작업 완료 대기
waitForTasksToComplete();

running = false;
log.info("Graceful Shutdown 완료");
}

@Override
public boolean isRunning() {
return running;
}

@Override
public int getPhase() {
return Integer.MAX_VALUE; // 가장 마지막에 종료
}

private void waitForTasksToComplete() {
// 작업 완료 대기 로직
}
}

Kubernetes 환경에서의 Graceful Shutdown

Pod 종료 프로세스

1. kubectl delete pod 실행

2. Pod 상태를 Terminating으로 변경

3. SIGTERM 신호를 컨테이너에 전송

4. terminationGracePeriodSeconds 대기 (기본 30초)

5. 타임아웃 시 SIGKILL 전송

설정 예시

apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
terminationGracePeriodSeconds: 60 # 60초 대기
containers:
- name: app
image: my-app:latest
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"] # 로드밸런서 제거 대기

Spring Boot + Kubernetes 베스트 프랙티스

# deployment.yaml
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"] # 트래픽 차단 대기
# application.yml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 45s # K8s terminationGracePeriodSeconds보다 짧게

실전 예제

HTTP 요청 처리 중 종료

@RestController
public class LongRunningController {

@GetMapping("/long-task")
public String longTask() throws InterruptedException {
log.info("Long task 시작");

// 30초 걸리는 작업
Thread.sleep(30000);

log.info("Long task 완료");
return "작업 완료";
}
}

Graceful Shutdown 설정 시:

  • 종료 신호를 받아도 이미 시작된 요청은 완료됨
  • 새로운 요청은 503 응답

일반 종료 시:

  • 요청이 중간에 끊김
  • 클라이언트는 연결 오류 발생

비동기 작업 처리

@Configuration
public class AsyncConfig {

@Bean
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setWaitForTasksToCompleteOnShutdown(true); // 중요!
executor.setAwaitTerminationSeconds(60); // 최대 대기 시간
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
}

메시지 큐 리스너

@Component
public class MessageQueueListener {

private final AtomicInteger processingCount = new AtomicInteger(0);

@RabbitListener(queues = "my-queue")
public void handleMessage(String message) {
processingCount.incrementAndGet();
try {
// 메시지 처리
processMessage(message);
} finally {
processingCount.decrementAndGet();
}
}

@PreDestroy
public void onShutdown() throws InterruptedException {
log.info("메시지 처리 대기 중: {}", processingCount.get());

// 처리 중인 메시지가 없을 때까지 대기
int waitTime = 0;
while (processingCount.get() > 0 && waitTime < 30000) {
Thread.sleep(100);
waitTime += 100;
}

log.info("모든 메시지 처리 완료");
}

private void processMessage(String message) {
// 메시지 처리 로직
}
}

모니터링 및 로깅

Shutdown 이벤트 처리

@Component
public class ShutdownEventListener {

@EventListener
public void handleContextClose(ContextClosedEvent event) {
log.info("애플리케이션 컨텍스트 종료 시작");
// 메트릭 전송, 알림 등
}

@EventListener
public void handleContextStopped(ContextStoppedEvent event) {
log.info("애플리케이션 컨텍스트 정지됨");
}
}

액추에이터를 통한 모니터링

management:
endpoint:
shutdown:
enabled: true # /actuator/shutdown 엔드포인트 활성화
endpoints:
web:
exposure:
include: health,info,shutdown
@RestController
public class HealthController {

private volatile boolean shuttingDown = false;

@PreDestroy
public void onShutdown() {
shuttingDown = true;
}

@GetMapping("/health/ready")
public ResponseEntity<String> readiness() {
if (shuttingDown) {
return ResponseEntity.status(503).body("Shutting down");
}
return ResponseEntity.ok("Ready");
}
}

주의사항

1. 타임아웃 설정

K8s terminationGracePeriodSeconds (60s)
> Spring timeout-per-shutdown-phase (45s)
> 실제 작업 완료 예상 시간 (30s)

여유 시간을 두어 강제 종료(SIGKILL)를 방지합니다.

2. 데드락 방지

@PreDestroy
public void cleanup() {
// 타임아웃 설정 필수
if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) {
log.warn("일부 작업이 타임아웃됨");
executorService.shutdownNow();
}
}

3. 멱등성 보장

Shutdown Hook은 여러 번 실행될 수 있으므로 멱등성을 보장해야 합니다.

private volatile boolean shutdownCompleted = false;

@PreDestroy
public synchronized void onShutdown() {
if (shutdownCompleted) {
return;
}

try {
// 정리 작업
} finally {
shutdownCompleted = true;
}
}

테스트

로컬 테스트

# 애플리케이션 시작
./gradlew bootRun

# SIGTERM 전송 (Graceful Shutdown 트리거)
kill -15 <PID>

# 로그 확인
# 진행 중인 요청이 완료될 때까지 대기하는지 확인

통합 테스트

@SpringBootTest
class GracefulShutdownTest {

@Autowired
private ConfigurableApplicationContext context;

@Test
void gracefulShutdown() throws Exception {
// 비동기 작업 시작
Future<String> future = executeLongTask();

// 컨텍스트 종료
context.close();

// 작업이 완료되었는지 확인
assertThat(future.isDone()).isTrue();
assertThat(future.get()).isEqualTo("완료");
}
}

정리

Graceful Shutdown은 프로덕션 환경에서 필수적인 기능입니다:

  1. Spring Boot 설정: server.shutdown=graceful
  2. 타임아웃 설정: 적절한 대기 시간 설정
  3. 리소스 정리: @PreDestroy, SmartLifecycle 활용
  4. K8s 고려: terminationGracePeriodSeconds와 조화
  5. 모니터링: 로깅 및 헬스체크 구현

적절한 Graceful Shutdown 구현으로 무중단 배포와 안정적인 서비스 운영이 가능합니다.

Share