아무튼, 쓰기

Graceful Shutdown 딮다이브 본문

스프링

Graceful Shutdown 딮다이브

순원이 2026. 2. 9. 13:14
식당 문을 닫을 시간이 되었습니다. 손님들에게 ’지금 당장 나가!’라고 소리치시겠습니까, 아니면 ’마지막 주문은 끝났으니, 드시던 음식은 편안히 드시고 가세요’라고 안내하시겠습니까?

Part 1. Why - 왜 우아하게 꺼야 하는가?

1. 탄생배경: 백화점 정전 사태

여러분이 백화점 에스컬레이터를 타고 3층에서 4층으로 올라가는 중이라고 상상해 봅시다. 그런데 갑자기 전기가 뚝 끊깁니다. 에스컬레이터는 급정거하고, 사람들은 휘청거립니다. 계산대에서 카드를 긁던 손님은 “결제가 된 거야, 만 거야?”라며 불안해합니다.

서버 배포도 마찬가지입니다. 우리는 하루에도 수십 번씩 새로운 코드를 배포합니다. 그때마다 실행 중인 애플리케이션을 종료하고(SIGKILL) 새 버전을 띄웁니다. 만약 아무런 대비 없이 프로세스를 죽인다면 어떤 일이 벌어질까요?

  • 진행 중인 결제 요청 중단 사용자는 돈이 빠져나갔는데, 주문은 생성되지 않습니다.
  • 데이터 정합성 깨짐 DB 트랜잭션이 커밋되지 않은 채 연결이 끊깁니다.
  • 클라이언트 에러 급증 502 Bad Gateway 에러가 사용자 화면을 뒤덮습니다.

Graceful Shutdown(우아한 종료)은 바로 이 갑작스러운 정전을 안전한 영업 종료로 바꾸는 기술입니다.

2. 이 기술이 해결하는 것과 못하는 것

구분Graceful Shutdown이 해결하는 것 (✅)해결하지 못하는 것 (❌)
요청 손실처리 중인 요청을 끝까지 완료 후 종료이미 타임아웃이 발생한 악성 요청
데이터DB 커넥션 풀을 안전하게 닫고 트랜잭션 종료하드웨어 전원 차단 (플러그 뽑힘)
사용자 경험배포 중에도 500 에러 없이 무중단 서비스배포 후 새 버전의 논리적 버그

Part 2. How - 내부 동작 원리

3. 내부 동작: 3단계로 이해하기

Spring Boot가 SIGTERM 신호를 받으면 내부적으로 ContextClosedEvent가 발생하며 다음과 같은 일이 순차적으로 일어납니다.

Step 1: 입장 금지 (WebServer Shutdown)

"죄송합니다, 오늘 영업은 종료되었습니다"
  • Actuator 연동 /actuator/health/readiness 엔드포인트가 503 Service Unavailable을 반환합니다
  • 톰캣(Tomcat) 더 이상 새로운 HTTP 요청을 받지 않습니다
  • 기존 연결 유지 이미 들어온 요청은 처리를 완료할 때까지 대기

Step 2: 기존 손님 응대 (Thread Wait)

"드시던 음식은 편안히 드시고 가세요"
  • 현재 처리 중인 스레드가 작업을 마칠 때까지 기다립니다
  • 이때 설정된 타임아웃(timeout-per-shutdown-phase)이 중요합니다
  • HTTP 요청, 비동기 작업, 스케줄러가 모두 완료될 때까지 대기

Step 3: 청소 및 셔터 내리기 (Resource Cleanup)

"불 끄고, 문 잠그고, 가스 밸브 잠그기"
  • 비동기 스레드풀 중단
  • Scheduler 중단
  • Controller, Service, Repository 종료
  • DB Connection Pool (HikariCP) 종료
  • ApplicationContext 파기
Spring Boot 설정 (application.yml)
server:
shutdown: graceful # 기본값은 immediate

spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 정리할 시간 30초 줌

4. Deep Dive: OS Signal부터 TCP까지

4.1 SIGTERM vs SIGKILL: 커널 레벨의 대화

운영체제는 프로세스를 종료할 때 Signal이라는 신호를 보냅니다.

Signal코드의미프로세스의 선택권
SIGTERM15“이제 곧 문 닫을 거야. 정리 좀 해.”✅ 가능 (정중한 요청)
SIGKILL9“닥치고 짐 싸. 당장 나가.”❌ 불가능 (강제 퇴거)

SIGTERM의 동작 원리
1. 운영체제가 프로세스에게 Signal 15번 전송
2. JVM의 Shutdown Hook이 이를 감지
3. Spring Boot의 SpringApplication.exit() 메서드 호출
4. ContextClosedEvent 발생 → Graceful Shutdown 시작

SIGKILL의 위험성
- 프로세스는 이 시그널을 감지(Catch)하거나 무시(Ignore)할 수 없습니다
- 커널이 프로세스의 PCB(Process Control Block)를 강제로 회수
- try-catch-finally 블록의 finally조차 실행되지 않음
- 열려있던 파일, 소켓, DB 연결이 모두 비정상 종료

4.2 TCP 4-Way Handshake: 네트워크 연결 종료

애플리케이션이 종료될 때 네트워크 레벨에서는 소켓 연결 해제 과정인 4-Way Handshake가 발생합니다.

Client                          Server
  |                               |
  |  <---- FIN (종료 요청) ----   | (1) Active Close
  |                               |
  |  ---- ACK (확인) ---->        | (2) CLOSE_WAIT
  |                               |
  |  <---- FIN (종료 준비 완료) - | (3) LAST_ACK
  |                               |
  |  ---- ACK (최종 확인) ---->   | (4) TIME_WAIT
  |                               |
  ✅ 연결 완전 종료

⚠️ 주의할 점: SIGKILL의 경우
- 서버가 갑자기 SIGKILL로 죽으면, FIN 패킷을 보낼 겨를도 없이 사라집니다
- 클라이언트는 연결이 끊어진 줄 모르고 계속 데이터를 보냅니다
- 결국 RST(Reset) 패킷을 받거나 타임아웃이 발생 → Connection Reset by Peer 에러

Graceful Shutdown의 역할

FIN 패킷을 정상적으로 보낼 시간을 벌어주어, 클라이언트에게 “나 이제 종료할게”라고 정중하게 알릴 수 있습니다.

4.3 BIO vs NIO: 인터럽트 처리의 차이

문제 상황

종료 신호를 받아서 스레드에게 interrupt()를 보냈는데도 스레드가 멈추지 않는 경우가 있습니다.

// ❌ 전통적인 BIO (Blocking I/O)
InputStream input = socket.getInputStream();
int data = input.read();  // interrupt() 받아도 계속 블로킹됨!

왜 BIO는 인터럽트를 무시할까?

  1. Thread.interrupt()의 실체: 자바에서 interrupt()를 호출하면 해당 스레드의 인터럽트 플래그(boolean flag)만 true로 설정합니다. 실제로 CPU 실행을 강제로 멈추는 것이 아닙니다.
  1. Native Method의 무관심: InputStream.read()는 내부적으로 OS의 recv() 시스템 콜을 호출합니다. 전통적인 BIO 구현체는 이 플래그를 주기적으로 확인하지 않도록 설계되었습니다.
  1. OS의 입장: 리눅스 커널에서 해당 스레드는 I/O 대기 상태(TASK_INTERRUPTIBLE)이지만, 자바 레벨의 interrupt()는 OS 시그널이 아니므로 커널을 깨우지 못합니다.
  1. 반면 NIO는?: AbstractInterruptibleChannel이 인터럽트 발생 시 채널을 비동기적으로 닫아버려(close()) OS 블로킹을 강제로 풉니다.
// ✅ NIO (Non-blocking I/O)
SocketChannel channel = SocketChannel.open();
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer);  // interrupt() 받으면 ClosedByInterruptException 발생!

실무 적용

BIO 기반 라이브러리를 사용한다면:
- interrupt()만으로는 부족합니다
- 소켓을 강제로 닫아버리는 것(socket.close())이 확실한 종료 방법입니다

5. Spring Boot는 어떤 순서로 죽는가? (Lifecycle)

5.1 SmartLifecycle과 Phase 개념

Spring Boot는 SmartLifecycle 인터페이스를 통해 시작과 종료의 순서를 제어합니다.

핵심 원칙
- 시작할 때 Integer.MIN_VALUE (가장 낮은 값)부터 순차적으로 시작
- 종료할 때 Integer.MAX_VALUE (가장 높은 값)부터 역순으로 종료
- 즉, 가장 늦게 시작한 녀석이 가장 먼저 종료 (LIFO 구조)

5.2 핵심 컴포넌트별 Phase 값과 종료 순서

순서컴포넌트Phase 값실제 정수값설명
1WebServerGracefulShutdownMAX_VALUE - 10242,147,482,623가장 먼저 실행되어 새로운 HTTP 요청을 차단
2KafkaMessageListenerContainerMAX_VALUE - 1002,147,483,547카프카 컨슈머가 메시지 수신을 중단
3ThreadPoolTaskSchedulerMAX_VALUE / 21,073,741,824스케줄러가 중단됨
4ThreadPoolTaskExecutorMAX_VALUE / 21,073,741,824비동기 스레드 풀이 중단됨
5Controller / ServicePhaseless (0)-비즈니스 로직 빈들이 파괴됨
6HikariDataSourcePhaseless (0)-가장 마지막에 DB 연결을 닫음
[주의] 일반적인 Bean(@Service)은 Phase 0으로 간주되지만, SmartLifecycle 종료 이후에 컨텍스트가 닫히면서 (ContextClosedEvent) 생성의 역순(Reverse Dependency)으로 파괴됩니다.

5.3 중요한 Q&A

Q1. HTTP 요청은 중간에 끊기나요?

아니요, 안전합니다.

  • Step 1에서 WebServer가 기존에 들어온 요청이 다 끝날 때까지 기다려줍니다 (server.shutdown: graceful)
  • 서비스 로직이 다 돌고 응답을 보낸 뒤에야 Step 2로 넘어갑니다
  • HTTP 요청은 Tomcat 내부의 스레드 풀을 사용하므로 별도로 관리됩니다

Q2. @Async (비동기 작업)은 어떻게 되나요?

설정에 따라 다릅니다.

기본값(await-termination: false)에서는:
- 스레드 풀이 닫히면서 실행 중인 작업도 즉시 인터럽트되어 강제 종료
- 알림 발송, 정산 로직 등이 중간에 끊길 수 있음

# ✅ 해결책: 비동기 작업도 끝까지 기다리게 설정
spring:
task:
execution:
shutdown:
await-termination:true
await-termination-period: 20s

Q3. 서비스가 스레드 풀을 의존하는데, 왜 역순으로 안 꺼지나요?

DI 원칙 vs Lifecycle 원칙의 충돌

  • 보통 의존성 역순(Service → Repository)으로 꺼지는 게 원칙입니다
  • 하지만 SmartLifecycle은 계급이 다릅니다
  • ThreadPoolTaskExecutorSmartLifecycle 구현체로서 Phase 값(MAX_VALUE / 2)을 가지고 있어, 일반 빈(Service, Phase 0)들이 파괴되기도 전에 먼저 stop() 신호를 받습니다
  • 즉, Service가 아직 살아있는 상태에서 스레드 풀이 먼저 문을 닫아버리는 상황이 발생합니다

해결 방법
- ThreadPoolTaskExecutor의 Phase를 0보다 작게 설정하거나
- (일반적) await-termination 을 켜서 “문을 닫더라도 하던 건 끝내라”고 지시

Q4. SmartLifecycle 종료 후 컨텍스트 종료 작업인, @PreDestroy에서 비동기 작업을 호출하면?

무조건 실패합니다.

@Service
public class MyService {
    @Autowired
    private ThreadPoolTaskExecutor executor;

    @PreDestroy
    public void cleanup() {
        // ❌ 이미 executor는 Step 2에서 종료됨!
        executor.submit(() -> {
            log.info("마지막 정리 작업");  // 실행 안 됨!
        });
    }
}

Step 3(Service 파괴) 시점에는 이미 Step 2(ThreadPool)가 문을 닫았습니다.

종료 시점에는 동기(main thread)로 처리하세요.

6.4 설정 함정: Timeout 충돌 주의

[주의] Timeout 충돌

spring:
task:
execution:
shutdown:
await-termination-period: 70s  # 비동기 작업 70초 대기
lifecycle:
timeout-per-shutdown-phase: 30s  # 전체 Phase는 30초만 대기!

결과: 30초 뒤에 강제 종료됩니다.

SmartLifecycle의 Phase Timeout이 더 상위 개념이기 때문에, 스레드 풀이 아무리 “70초 기다려줘”라고 해도 컨테이너가 “30초 지났어, 방 빼!” 하고 프로세스를 죽입니다.

✅ 올바른 설정

spring:
task:
execution:
shutdown:
await-termination:true
await-termination-period: 70s
lifecycle:
timeout-per-shutdown-phase: 90s  # await-termination보다 넉넉하게!

6.5 왜 DB 커넥션 풀이 가장 늦게 닫히나요?

능동적 종료 vs 수동적 파괴의 차이

WebServer, KafkaListener, Scheduler 등은 새로운 일을 만들어내는 주체입니다.
- 이들은 가장 먼저 멈춰야 합니다
- 그래야 더 이상 새로운 트랜잭션이 생기지 않으니까요 (수도꼭지 잠그기)

Service, Repository, DataSource 등은 일을 처리하는 도구입니다.
- 이들은 남은 설거지가 끝날 때까지 살아있어야 합니다

Spring의 빈 종료 순서는 철저하게 의존성 역순입니다.

[생성 순서] DataSource → Repository → Service
           (서비스가 리포지토리를 쓰고, 리포지토리가 DB를 씀)

[종료 순서] Service → Repository → DataSource
           (서비스가 죽고, 리포지토리가 죽고, 마지막에 DB 문을 닫음)

만약 DB가 Service보다 먼저 종료된다면?

→ Service의 @PreDestroy 메서드에서 마지막으로 DB에 로그를 남기려 할 때, 이미 DB 연결이 끊겨있어 예외가 발생할 것입니다.

그래서 DB는 가장 마지막까지 불을 켜두고 있어야 합니다.

7. 실제 장애 사례로 검증: 원리를 알면 보이는 것들

이제 내부 동작을 이해했으니, 실제 장애 사례를 분석하면서 배운 원리를 확인해겠습니다.

사례 1: 매일 오후 3시마다 발생하는 502 에러

상황

- 커머스 서비스
- 배포는 성공하는데, 사용자 화면에 간헐적으로 502 Bad Gateway 발생
- 에러 로그에는 Connection Reset by Peer

원리로 분석하기

[TCP 레벨 분석]
1. WAS가 SIGTERM 받음
   ↓
2. Phase 1: WebServerGracefulShutdown 시작
   - 새 요청 차단 (리스너 포트 닫음)
   - TCP FIN 패킷 전송 시작
   ↓
3. 하지만 ALB는 모름!
   - Health Check Interval 10초
   - 마지막 성공 체크 5초 전
   ↓
4. ALB: "5초 전에 확인했는데 살아있었어"
   - 죽어가는 WAS 소켓에 새 요청 전송
   ↓
5. WAS: 이미 소켓 닫힘
   - RST(Reset) 패킷 전송
   ↓
6. ALB → 클라이언트: 502 Bad Gateway

왜 발생했을까?
- Race Condition WAS의 FIN 전송과 ALB의 트래픽 라우팅 사이의 타이밍 문제
- TCP 4-Way Handshake 완료 전에 새 요청이 들어옴
- Phase 1은 기존 요청만 보호하지, 헬스 체크 주기는 고려하지 않음

해결책: PreStop Hook

# Kubernetes 환경
lifecycle:
preStop:
exec:
command:["/bin/sh","-c","sleep 15"]

동작 원리:

SIGTERM 받음
  ↓
[PreStop Hook] 15초 대기
  - 헬스 체크만 503으로 변경
  - 아직 Phase 1 시작 안 함!
  - TCP 연결 유지
  ↓
ALB Health Check 수행 (10초 주기)
  - "503이네? 트래픽 차단!"
  ↓
15초 후 진짜 Phase 1 시작
  - 이미 트래픽 차단된 상태
  - 안전하게 FIN 전송

배운 원리 적용
- SIGTERM → Phase 순서 PreStop은 Phase 시작 전에 실행
- TCP FIN 트래픽 차단 후에 보내야 안전
- 타이밍 계산 PreStop Sleep >= Health Check Interval + 여유

사례 2: 결제 완료 후 주문이 사라짐

상황

- 사용자가 결제 완료 후 주문 내역이 없다고 고객센터 문의
- PG사에는 결제 완료 기록이 있는데, DB에는 주문 데이터 없음

원리로 분석하기

@Transactional
public void createOrder(OrderRequest request) {
    // 1. 주문 생성 (DB INSERT)
    orderRepository.save(order);

    // 2. 결제 API 호출 (외부 통신 - 5초 소요)
    paymentClient.process(request);  // ← 이 시점에 서버 종료 신호!

    // 3. 주문 상태 업데이트
    order.setStatus(PAID);  // 실행 안 됨!
}

Phase별 분석:

설정:
timeout-per-shutdown-phase: 30s

[Phase 1] WebServer
  - HTTP 요청 처리 중 (createOrder 메서드 실행)
  - 20초 소요... 계속 실행 중
  ↓
30초 경과
  ↓
Spring Lifecycle: "Phase 1 타임아웃!"
  ↓
강제 종료 (SIGKILL 유사)
  ↓
트랜잭션 롤백
  - orderRepository.save(order) → 롤백
  - paymentClient.process() → 이미 완료 (외부 시스템)
  ↓
결과: 결제는 됐는데 주문은 없음!

왜 발생했을까?
- Phase Timeout timeout-per-shutdown-phase보다 긴 트랜잭션
- DB 종료 순서 DB는 Phase 4에서 닫히지만, Phase 1 타임아웃에 트랜잭션이 중단됨
- 외부 API 호출 트랜잭션 안에서 네트워크 I/O (안티패턴)

해결책

1. 타임아웃 증가

spring:
lifecycle:
timeout-per-shutdown-phase: 60s  # 가장 긴 트랜잭션 + 여유

2. 트랜잭션 분리 (더 나은 방법)

@Service
public class OrderService {

    @Transactional
    public Order createOrder(OrderRequest request) {
        // 1. 주문 생성 (빠름, 1초)
        Order order = orderRepository.save(new Order(request));
        order.setStatus(PENDING_PAYMENT);
        return order;
    }

    // 트랜잭션 밖으로 분리
    public void processPayment(Order order) {
        // 2. 결제 요청 (느림, 5초)
        PaymentResult result = paymentClient.process(order);

        // 3. 결과 반영
        updateOrderStatus(order.getId(), result);
    }

    @Transactional
    public void updateOrderStatus(Long orderId, PaymentResult result) {
        Order order = orderRepository.findById(orderId);
        order.setStatus(result.isSuccess() ? PAID : FAILED);
    }
}

배운 원리 적용
- Phase Timeout 우선순위 모든 Phase는 timeout-per-shutdown-phase에 제한됨
- DB 종료 순서 DB는 마지막(Phase 4)이지만, 트랜잭션은 Phase 1에서 중단될 수 있음
- Long Running Task 외부 API 호출은 트랜잭션 밖으로

사례 3: 비동기 작업이 중간에 끊긴 알림 미발송

상황
- 주문 완료 후 사용자에게 푸시 알림 발송
- 배포 후 일부 사용자가 “알림을 못 받았다”고 문의

코드

@Service
public class OrderService {

    @Async
    public void sendNotification(Order order) {
        // 외부 푸시 서버 호출 (3초 소요)
        pushClient.send(order.getUserId(), "주문이 완료되었습니다");
    }
}

Phase별 분석:

설정:
timeout-per-shutdown-phase: 30s
await-termination-period: 70s  # 이건 무시됨!

[Phase 1] WebServer 종료 (10초 소요)
  ✅ 완료
  ↓
[Phase 3] ThreadPoolTaskExecutor 종료
  - 비동기 작업 10개 실행 중
  - await-termination-period 70s 설정했지만...
  ↓
30초 경과 (timeout-per-shutdown-phase)
  ↓
Spring Lifecycle: "Phase 3 타임아웃!"
  ↓
ThreadPool 강제 종료
  - interrupt() 호출
  - 실행 중이던 5개 작업 중단

왜 발생했을까?
- Timeout 충돌 await-termination-period: 70s < timeout-per-shutdown-phase: 30s
- SmartLifecycle Phase ThreadPool은 Phase 3에서 종료되며, Phase Timeout이 우선
- min() 로직 실제 대기 = min(70s, 30s) = 30초

해결책

spring:
lifecycle:
timeout-per-shutdown-phase: 90s  # await-termination보다 길게!

task:
execution:
shutdown:
await-termination:true
await-termination-period: 70s  # 90초 안에 들어감

배운 원리 적용
- SmartLifecycle Phase ThreadPool은 Phase 3 (MAX_VALUE / 2)
- Timeout 우선순위 timeout-per-shutdown-phase가 상위 개념
- 설정 검증 timeout-per-shutdown-phase >= await-termination-period + 여유

정리: 원리를 알면 장애를 예방할 수 있다

장애 사례관련 원리핵심 교훈
502 에러TCP FIN, Phase 1 타이밍PreStop으로 헬스 체크 대기 필요
결제/주문 불일치Phase Timeout, 트랜잭션외부 API는 트랜잭션 밖으로 분리
알림 미발송Phase 3, Timeout 충돌timeout-per-shutdown-phase > await-termination

Part 3. 실전 적용

8. 실전 적용: 설정과 패턴 구현

필수 설정과 함께, 운영 환경에서 빈번하게 발생하는 이슈들의 해결 패턴을 정리했습니다.

8.1 필수 설정 (application.yml)

가장 기본이 되는 Spring Boot 설정입니다.

# 기본 Graceful Shutdown 설정
server:
  shutdown: graceful
  tomcat:
    connection-timeout: 20s
    keep-alive-timeout: 75s  # ALB Idle Timeout(60s)보다 길게!

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

  # 비동기 작업 설정 (중요)
  task:
    execution:
      shutdown:
        await-termination: true  # 하던 작업은 끝내고 종료
        await-termination-period: 20s
    scheduling:
      shutdown:
        await-termination: true
        await-termination-period: 10s

8.2 컨테이너 환경: PID 1 문제 (Docker/K8s)

[면접 질문] "PID 1 문제(Zombie Reaper)에 대해 설명해 주세요."

문제: Docker에서 ENTRYPOINT ["java", ...]로 실행하면 자바가 PID 1이 되어 SIGTERM을 무시합니다. 결국 강제 종료(SIGKILL) 당합니다. 이는 리눅스 커널에서 PID 1을 특수한(init) 프로세스로 취급하여 기본적인 시그널 핸들러를 등록하지 않기 때문입니다.

해결책: tini를 사용하거나 exec로 셸을 대체하여, 시그널 전파와 좀비 프로세스 회수(Reaping) 역할을 하는 init 프로세스를 둬야 합니다.

# ✅ 좋은 예 1: tini 사용
FROM openjdk:17-slim
RUN apt-get update && apt-get install -y tini
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["java", "-jar", "app.jar"]
# ✅ 좋은 예 2: exec 사용 (entrypoint.sh)
#!/bin/bash
exec java -jar app.jar

8.3 로드밸런서와 502 에러 (PreStop Hook)

[면접 질문] "배포 중 502 에러가 간헐적으로 발생하는데, 원인이 뭘까요?" (L7 vs L4)

시니어의 답변: 단순 설정 문제가 아닐 가능성이 높습니다. AWS ALB(L7)와 EC2(WAS) 사이의 Keep-Alive Connection 처리 방식에 따른 Race Condition일 수 있습니다.

문제 상황 (Race Condition): WAS는 종료되었지만 로드밸런서(ALB)는 헬스 체크 주기 때문에 이를 모르고 요청을 보냅니다.

  1. WAS가 FIN을 보냄 (종료 시작)
  1. ALB는 그 직전(또는 동시에) 그 소켓으로 새 요청을 보냄
  1. WAS는 이미 닫혔으니 RST를 보냄 → 502 Bad Gateway

해결책: 종료 신호를 받으면 바로 끄지 말고, PreStop Hook으로 헬스 체크를 실패시키고 잠시 대기합니다. 또한 Keep-Alive Timeout을 튜닝합니다.

  1. PreStop Hook: 로드밸런서가 헬스 체크 실패를 인지할 시간을 벌어줍니다
# Kubernetes lifecycle hook
lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 15"]

8.4 Long Running Task와 타임아웃 전략

문제: 파일 업로드나 배치가 timeout-per-shutdown-phase(기본 30초)보다 오래 걸리면 강제 종료됩니다.

해결책

  1. 설정 변경: timeout-per-shutdown-phase를 넉넉하게 늘립니다.
  1. 코드 레벨 제한: 트랜잭션 내에서 외부 API 호출을 빼거나, 비동기 작업에 타임아웃을 겁니다.
// 예: CompletableFuture로 타임아웃 설정
CompletableFuture.supplyAsync(() -> process(file))
    .orTimeout(40, TimeUnit.SECONDS);

8.5 카프카 컨슈머 (Kafka Consumer Lag)

문제: 메시지 처리 도중 종료되면, 오토 커밋(Auto Commit)으로 인해 메시지가 유실되거나 중복 처리될 수 있습니다.

해결책: Manual Ack를 사용하고, 종료 시점에는 커밋하지 않도록 합니다.

spring:
  kafka:
    listener:
      ack-mode: manual
// Kafka Listener 셧다운 로직 예시
@Component
public class KafkaGracefulShutdown implements DisposableBean {
    private final KafkaListenerEndpointRegistry registry;

    // 생성자 주입
    public KafkaGracefulShutdown(KafkaListenerEndpointRegistry registry) {
        this.registry = registry;
    }

    @Override
    public void destroy() throws Exception {
        // 컨테이너 우아한 종료 (메시지 처리 대기)
        registry.getListenerContainers().forEach(MessageListenerContainer::stop);
    }
}

8.6 분산 트랜잭션과 복구 (Saga Pattern)

[면접 질문] "분산 트랜잭션(Saga/TCC) 중 서버가 꺼지면 어떻게 되나요?"

문제: "주문 성공 -> 결제 요청 -> 서버 종료" 시나리오에서 결제는 됐는데 주문 상태가 업데이트되지 않는 정합성 문제. Graceful Shutdown은 In-Memory 상태만 보호해줄 뿐, 네트워크 건너편의 일은 보장 못 합니다.

해결책: 셧다운 설정으로 해결하는 게 아니라, 재시작 후 복구(Recovery) 로직으로 풀어야 합니다.

  • Outbox Pattern: DB에 이벤트를 먼저 기록하고 비동기로 발송.
  • Recovery Batch: 주기적으로 'Pending' 상태의 이벤트를 조회하여 상태 동기화.
'안 죽는 서버'를 만드는 게 아니라 '언제 죽어도 괜찮은 서버'를 만드는 것이 핵심입니다.

9. Trade-off & Alternatives

9.1 무엇을 얻고 무엇을 잃는가?

항목얻는 것 (Gain)잃는 것 (Cost)측정 지표
안정성배포 시 에러율 0%에 수렴배포 속도 저하. 기존 프로세스가 죽을 때까지 기다려야 함.Deployment Time +30s~1m
리소스데이터 정합성 보장종료 로직을 위한 구현 복잡도 증가 (비동기 작업 처리 등)코드 라인 수, 복잡도
운영롤링 업데이트 시 부드러운 트래픽 전환좀비 프로세스 위험. 종료되지 않고 버티는 스레드 발생 가능.Force Kill 빈도

9.2 언제 오버엔지니어링이 될까?

Graceful Shutdown이 불필요하거나 과도한 경우:

  1. 단순 통계 집계 배치 서버
    • 데이터 유실이 허용됨
    • 재실행하면 그만
    • 오히려 빠른 재시작이 유리
  1. 로깅 전용 서버
    • 로그 몇 줄 유실은 큰 문제가 아님
    • 복잡한 종료 로직보다 단순함이 나음
  1. 개발/테스트 환경
    • 완벽한 종료보다 빠른 개발 사이클이 중요

9.3 대안 방식들

대안 방식설명장점단점언제 선택할까?
Blue/Green 배포트래픽을 아예 새 버전(Green)으로 돌리고, 구 버전(Blue)은 트래픽 0인 상태에서 종료완벽한 무중단, 롤백 즉시 가능리소스 2배 필요리소스가 풍부하고, 완벽한 무중단을 원할 때
Canary 배포일부 트래픽만 새 버전으로 흘려보내며 간보기리스크 최소화, 점진적 배포복잡한 트래픽 관리대규모 시스템에서 리스크를 최소화할 때
Idempotency (멱등성)요청이 실패해서 재시도(Retry)해도 결과가 같도록 설계구현 단순, 장애 회복력 강함모든 로직에 적용 어려움Graceful Shutdown 구현이 너무 복잡할 때

Part 4. Insight - 심화 학습 (선택 읽기)

10. Low-Level 엔지니어링 교훈

Lesson 1. TCP Half-Close의 오해와 진실

소켓을 닫을 때 close()를 호출하면 양쪽 스트림을 모두 닫아버리지만, 사실 TCP는 "나는 보낼 것 다 보냈다(FIN)"와 "너의 응답을 받을 준비는 되어있다"는 상태를 구분할 수 있습니다.

적용 포인트

대용량 파일 전송이나 스트리밍 서버를 구현할 때, 무작정 close()보다는 socket.shutdownOutput()을 사용하여 Half-Close(반종료) 상태를 활용하세요.

// ❌ 나쁜 예: 양쪽 스트림 모두 즉시 종료
socket.close();

// ✅ 좋은 예: Half-Close
socket.shutdownOutput();  // 송신만 종료, 수신은 계속 가능
// 클라이언트의 마지막 ACK나 에러 보고를 기다릴 수 있음
InputStream in = socket.getInputStream();
while (in.read() != -1) {
    // 클라이언트의 응답 수신
}
socket.close();  // 모든 작업 완료 후 완전 종료

Lesson 2. 가시성과 Memory Barrier

멀티스레드 환경에서 종료 플래그(running = false)는 특정 CPU 코어의 L1 캐시에만 머물러 있고, 다른 스레드들은 이를 모른 채 계속 돌 수 있습니다.

문제 코드

public class Worker implements Runnable {
    private boolean running = true;  // ❌ 가시성 보장 안 됨

    @Override
    public void run() {
        while (running) {
            // 작업 수행
        }
    }

    public void stop() {
        running = false;  // 다른 스레드가 못 볼 수 있음!
    }
}

해결책

public class Worker implements Runnable {
    private volatile boolean running = true;  // ✅ volatile 사용

    // 또는
    private final AtomicBoolean running = new AtomicBoolean(true);  //

    @Override
    public void run() {
        while (running) {  // 모든 스레드가 최신 값을 봄
            // 작업 수행
        }
    }
}

적용 포인트

종료 플래그 같은 상태 제어 변수는 반드시 volatile 키워드나 AtomicBoolean을 사용하세요. 이는 CPU 캐시가 아닌 메인 메모리에 값을 즉시 기록(Flush)하게 하여, 모든 스레드가 동시에 종료 신호를 볼 수 있게 보장합니다.

"보이는 것만 믿어라" - 동시성 프로그래밍의 제1원칙

💡 Lesson 4. InterruptedException 처리

종료 신호를 받아서 스레드를 중단시킬 때, InterruptedException을 올바르게 처리하지 않으면 종료가 되지 않습니다.

❌ 나쁜 예: 예외 삼키기

try {
    Thread.sleep(10000);
} catch (InterruptedException e) {
    e.printStackTrace();  // 예외를 먹어버림
    // 스레드는 계속 실행됨!
}

✅ 좋은 예: 인터럽트 상태 복구

try {
    Thread.sleep(10000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();  // 인터럽트 상태 복구
    log.warn("Shutdown signal received. Stopping task...");
    return;  // 즉시 메서드 종료
}

더 나은 예: 종료 플래그와 함께 사용

private volatile boolean running = true;

public void run() {
    while (running && !Thread.currentThread().isInterrupted()) {
        try {
            doWork();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.info("Task interrupted, cleaning up...");
            cleanup();
            return;
        }
    }
}

마치며: 우아한 이별을 위하여

체크리스트

  • server.shutdown: graceful 설정이 되어있는가?
  • lifecycle.timeout이 우리 서비스의 가장 긴 트랜잭션보다 긴가?
  • 비동기 작업도 끝까지 기다리도록 await-termination: true로 설정했는가?
  • 로드 밸런서의 헬스 체크 주기 내에 트래픽이 유입될 틈(Gap)을 막았는가? (PreStop Hook)
  • 배포 스크립트가 kill -9 대신 kill -15를 보내고 충분히 기다리는가?
  • 컨테이너 환경에서 PID 1 문제를 해결했는가? (tini 또는 exec)
  • 분산 트랜잭션 환경에서 재시작 후 복구 로직이 있는가? (Outbox Pattern)

우아한 종료 설정을 통해, 여러분의 퇴근길도 우아해지길 바랍니다. 🥂