아무튼, 쓰기
Graceful Shutdown 딮다이브 본문
식당 문을 닫을 시간이 되었습니다. 손님들에게 ’지금 당장 나가!’라고 소리치시겠습니까, 아니면 ’마지막 주문은 끝났으니, 드시던 음식은 편안히 드시고 가세요’라고 안내하시겠습니까?
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 | 코드 | 의미 | 프로세스의 선택권 |
|---|---|---|---|
| SIGTERM | 15 | “이제 곧 문 닫을 거야. 정리 좀 해.” | ✅ 가능 (정중한 요청) |
| SIGKILL | 9 | “닥치고 짐 싸. 당장 나가.” | ❌ 불가능 (강제 퇴거) |
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는 인터럽트를 무시할까?
- Thread.interrupt()의 실체: 자바에서
interrupt()를 호출하면 해당 스레드의 인터럽트 플래그(boolean flag)만 true로 설정합니다. 실제로 CPU 실행을 강제로 멈추는 것이 아닙니다.
- Native Method의 무관심:
InputStream.read()는 내부적으로 OS의recv()시스템 콜을 호출합니다. 전통적인 BIO 구현체는 이 플래그를 주기적으로 확인하지 않도록 설계되었습니다.
- OS의 입장: 리눅스 커널에서 해당 스레드는 I/O 대기 상태(
TASK_INTERRUPTIBLE)이지만, 자바 레벨의interrupt()는 OS 시그널이 아니므로 커널을 깨우지 못합니다.
- 반면 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 값 | 실제 정수값 | 설명 |
|---|---|---|---|---|
| 1 | WebServerGracefulShutdown | MAX_VALUE - 1024 | 2,147,482,623 | 가장 먼저 실행되어 새로운 HTTP 요청을 차단 |
| 2 | KafkaMessageListenerContainer | MAX_VALUE - 100 | 2,147,483,547 | 카프카 컨슈머가 메시지 수신을 중단 |
| 3 | ThreadPoolTaskScheduler | MAX_VALUE / 2 | 1,073,741,824 | 스케줄러가 중단됨 |
| 4 | ThreadPoolTaskExecutor | MAX_VALUE / 2 | 1,073,741,824 | 비동기 스레드 풀이 중단됨 |
| 5 | Controller / Service | Phaseless (0) | - | 비즈니스 로직 빈들이 파괴됨 |
| 6 | HikariDataSource | Phaseless (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은 계급이 다릅니다
ThreadPoolTaskExecutor는SmartLifecycle구현체로서 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)는 헬스 체크 주기 때문에 이를 모르고 요청을 보냅니다.
- WAS가
FIN을 보냄 (종료 시작)
- ALB는 그 직전(또는 동시에) 그 소켓으로 새 요청을 보냄
- WAS는 이미 닫혔으니
RST를 보냄 → 502 Bad Gateway
해결책: 종료 신호를 받으면 바로 끄지 말고, PreStop Hook으로 헬스 체크를 실패시키고 잠시 대기합니다. 또한 Keep-Alive Timeout을 튜닝합니다.
- PreStop Hook: 로드밸런서가 헬스 체크 실패를 인지할 시간을 벌어줍니다
# Kubernetes lifecycle hook
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
8.4 Long Running Task와 타임아웃 전략
문제: 파일 업로드나 배치가 timeout-per-shutdown-phase(기본 30초)보다 오래 걸리면 강제 종료됩니다.
해결책
- 설정 변경:
timeout-per-shutdown-phase를 넉넉하게 늘립니다.
- 코드 레벨 제한: 트랜잭션 내에서 외부 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이 불필요하거나 과도한 경우:
- 단순 통계 집계 배치 서버
- 데이터 유실이 허용됨
- 재실행하면 그만
- 오히려 빠른 재시작이 유리
- 로깅 전용 서버
- 로그 몇 줄 유실은 큰 문제가 아님
- 복잡한 종료 로직보다 단순함이 나음
- 개발/테스트 환경
- 완벽한 종료보다 빠른 개발 사이클이 중요
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)
우아한 종료 설정을 통해, 여러분의 퇴근길도 우아해지길 바랍니다. 🥂
'스프링' 카테고리의 다른 글
| Resilience4j 딮다이브 (0) | 2026.02.08 |
|---|---|
| @Qualifier를 붙였는데 왜 무시될까? (스프링 빈 선택 코드 뜯어보기) (0) | 2026.01.03 |
| Spring Batch로 187초 → 6.65초: 28배 성능 개선 전과정 (0) | 2025.12.04 |
| 실시간 랭킹 MySQL로 버티다가, 결국 Redis ZSET으로 갈아탄 이야기 (0) | 2025.11.28 |
| 왜 내 readOnly는 슬레이브로 가지 않는가? (0) | 2025.11.12 |