우아한 종료 (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