본문 바로가기
🚀 데브 노트 (IT 지식 & 팁)

배포만 하면 502 에러가 뜬다고요? 스프링 부트 무중단 배포의 완성, Graceful Shutdown 완벽 가이드

by 데브프리 2026. 8. 9.

안녕하세요, 여러분의 든든한 IT 길잡이 데브프리입니다.

 

개발 서버에서는 완벽했던 코드를 실서버에 배포(Deploy)하는 순간은 늘 긴장됩니다. 특히 CI/CD 파이프라인을 통해 '무중단 배포(Zero-Downtime Deployment)' 환경을 구축해 두었다면, 사용자는 배포가 일어나는지조차 모르게 자연스럽게 새 버전으로 넘어가야 정상이죠.

 

하지만 실무에서는 새 버전을 배포하는 딱 그 1~2초 사이, 게시글을 등록하거나 결제를 진행하던 일부 사용자들에게 '502 Bad Gateway'나 'Connection Reset' 에러가 튀어나오는 아찔한 장애가 매우 빈번하게 발생합니다.

 

"로드밸런서(LB)도 붙여놨고, 무중단 배포 툴도 썼는데 도대체 왜 트래픽이 유실되는 걸까요?"

 

이 현상의 핵심 원인은 기존에 떠 있던 구버전 서버가 너무 '무자비하게' 죽어버리기 때문입니다. 오늘은 이 찰나의 트래픽 유실을 완벽하게 방어하는 스프링 부트(Spring Boot)의 Graceful Shutdown(우아한 종료) 기능과, 쿠버네티스(Kubernetes) 환경에서 흔히 빠지는 함정 및 트러블슈팅 방법을 객관적으로 파헤쳐 드리겠습니다.

 

1. 하드 다운(Hard Shutdown)의 공포: 왜 502 에러가 뜰까?

서버를 업데이트하려면 필연적으로 기존에 돌아가던 구버전 애플리케이션 프로세스를 종료(Kill)해야 합니다.

문제는 리눅스나 도커(Docker) 컨테이너에서 종료 신호(SIGTERM)를 보냈을 때, 아무런 대비가 없는 스프링 부트 서버는 그 즉시 프로세스를 강제로 멈춰버린다는 점입니다.

 

이를 실무에서는 '하드 다운'이라고 부릅니다. 이 방식은 치명적인 문제 두 가지를 연쇄적으로 일으킵니다.

  1. 처리 중이던 요청의 증발: 사용자가 결제 버튼을 누르고 DB에 데이터를 넣고 있던 도중에 서버가 꺼집니다. 클라이언트는 응답을 받지 못해 에러 화면을 보게 되고, DB에는 트랜잭션 롤백(Rollback)이 발생하거나 심하면 데이터 정합성이 깨집니다.
  2. 신규 트래픽의 블랙홀: 로드밸런서가 "어? 이 서버 죽었네?"라고 인지하고 트래픽을 차단하기까지는 아주 약간의 시간(Health Check 주기)이 걸립니다. 그 찰나의 순간에 들어오는 신규 요청들은 이미 죽어가고 있는 서버로 향했다가 모두 502 에러를 맞고 튕겨 나갑니다.

하드 다운(Hard Shutdown)으로 인한 트래픽 유실 및 에러 발생 비유
진행 중이던 작업을 고려하지 않은 강제 종료는 마치 다리를 건너고 있는 도중에 다리를 끊어버리는 것과 같아 치명적인 데이터 유실을 초래합니다.

 

2. 해결 베스트 프랙티스: Spring Boot 3.x Graceful Shutdown 설정

이 문제를 해결하는 방법은 아주 간단명료합니다. 서버에게 "지금 들어온 신규 요청은 거절하고, 이미 처리 중이던 작업만 마저 다 끝내고 나서 조용히 꺼져줄래?"라고 지시하는 것입니다.

 

Spring Boot 2.3 버전부터 도입되어 최신 3.x 버전에서도 굳건히 쓰이는 Graceful Shutdown을 활성화하려면 application.yml에 아래 두 줄만 추가하면 됩니다.

YAML

server:
  shutdown: graceful # 기본값은 immediate (강제 종료)

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s # 기존 요청을 최대 몇 초까지 기다려줄 것인가?

어떻게 동작할까요?

종료 신호(SIGTERM)가 들어오면 톰캣(Tomcat)이나 네티(Netty) 같은 내장 웹 서버는 즉시 네트워크 수신 단을 닫아버립니다. 새로운 요청은 더 이상 받지 않게 되죠. 하지만 기존에 이미 들어와서 스레드가 열심히 일하고 있는 요청들은 방해하지 않고 내버려 둡니다.

설정한 30s(30초) 안에 기존 작업들이 모두 200 OK 응답을 반환하고 나면, 그제야 스프링 부트 프로세스가 아주 우아하고 깔끔하게 자체 종료됩니다.

 

💡 [데브프리의 실무 꿀팁: 톰캣은 기다려주는데, @Async 백그라운드 스레드는 얄짤없이 죽는다고요?]

많은 백엔드 개발자들이 application.yml에 graceful 설정만 넣으면 끝인 줄 아는 치명적인 함정이 있습니다. 이 설정은 톰캣(웹 요청)에게만 유효합니다!

만약 여러분의 코드에 @Async나 @Scheduled로 돌아가는 비동기 백그라운드 작업이 있다면, 톰캣이 기다려주든 말든 스프링 부트가 종료될 때 이 스레드들은 즉시 강제 종료(하드 다운)되어 버립니다.

이를 완벽하게 방어하려면 비동기 스레드 풀을 세팅할 때 반드시 아래 두 가지 옵션을 켜주어야 합니다.

Java

@Bean
public ThreadPoolTaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    // ... (코어 사이즈 등 기본 설정)
    
    // 💡 1. 셧다운 시 큐에 남은 작업들을 모두 완료할 때까지 기다릴 것인가?
    executor.setWaitForTasksToCompleteOnShutdown(true); 
    // 💡 2. 최대 몇 초까지 기다려 줄 것인가? (Web의 30s와 맞춰주는 것이 정석)
    executor.setAwaitTerminationSeconds(30); 
    
    executor.initialize();
    return executor;
}

이렇게 세팅해야 웹 트래픽뿐만 아니라 보이지 않는 백그라운드 스레드까지 완벽하게 처리한 뒤 종료되는 진정한 '우아한 종료'가 완성됩니다!

 

3. 실전 트러블슈팅: K8s에 올렸더니 Graceful Shutdown이 안 먹혀요!

스프링 부트 설정을 완벽하게 마치고 쿠버네티스(Kubernetes) 환경에 배포했습니다. "이제 무중단 배포 완벽하겠지?"라고 안심했지만, 배포 시점에 또다시 502 에러가 튀어나옵니다.

왜 뚫렸을까? (네트워크 엔드포인트 동기화 지연)

이것은 스프링 부트의 잘못이 아니라 쿠버네티스의 아키텍처 특성 때문에 발생하는 아주 전형적인 실무 함정입니다.

쿠버네티스에서 파드(Pod)를 종료할 때, 두 가지 작업이 '동시에 병렬로' 일어납니다.

  1. 파드 안의 스프링 부트에 SIGTERM 종료 신호를 보냅니다. (스프링은 즉시 신규 트래픽 수신을 차단함)
  2. 쿠버네티스의 서비스(Service)와 인그레스(Ingress)의 엔드포인트 목록에서 해당 파드의 IP를 삭제합니다. (트래픽을 다른 곳으로 돌리기 위함)

문제는 2번 작업(네트워크 규칙 업데이트)이 완료되기까지 약간의 딜레이(보통 1~3초)가 발생한다는 점입니다.

스프링 부트는 종료 신호를 받자마자 문을 닫아버렸는데, 외부 로드밸런서는 아직 엔드포인트 업데이트를 못 받아서 닫힌 문을 향해 1~3초 동안 계속 트래픽을 쏟아붓게 됩니다. 이 찰나의 트래픽들이 모두 에러를 맞고 장렬하게 산화하는 것이죠.

쿠버네티스 엔드포인트 동기화 지연으로 인한 502 에러 발생 비유
쿠버네티스의 네트워크 라우팅 갱신은 파드 종료 신호와 동시에 진행되므로, 네트워크가 갱신되기 전에 서버가 먼저 수신을 닫아버리면 트래픽 유실이 발생합니다.

해결 베스트 프랙티스: preStop 훅(Hook)으로 시간 벌어주기

이 엇박자를 맞추기 위해서는 스프링 부트가 SIGTERM 신호를 받는 타이밍을 인위적으로 늦춰야 합니다.

쿠버네티스 deployment.yaml 파일의 컨테이너 설정에 preStop 훅을 추가하여, 로드밸런서가 파드 IP를 목록에서 완전히 제거할 때까지 스프링 부트가 문을 닫지 않고 약 10초 정도 기다리게(Sleep) 만들어주는 것이 업계 표준 프랙티스입니다.

YAML

# Kubernetes deployment.yaml 설정 예시
spec:
  containers:
  - name: my-spring-boot-app
    image: my-app:latest
    lifecycle:
      preStop:
        exec:
          # SIGTERM을 스프링에 던지기 전에 10초 동안 대기합니다.
          # 이 10초 동안 K8s는 안전하게 네트워크 엔드포인트를 갱신합니다.
          command: ["sh", "-c", "sleep 10"]

(주의: 이 sleep 10 시간과 스프링 부트의 대기 시간 30s를 합친 시간이 K8s의 terminationGracePeriodSeconds 기본값(30초)을 초과한다면, 해당 값도 넉넉하게 60초 정도로 늘려주어야 파드가 강제 종료되는 것을 막을 수 있습니다.)

 

4. 전문가의 확고한 결론 (Verdict)

Graceful Shutdown과 preStop 훅, 실무에 언제 도입하고 언제 피해야 할까요?

  • 👍 확실한 도입 타이밍: 웹 사용자에게 직접 서비스되는 사용자 대면(User-Facing) API 서버 (웹 백엔드, BFF 게이트웨이)라면 한 치의 예외도 없이 즉시 도입해야 합니다. 사용자가 결제를 진행하거나 중요한 폼을 제출하는 찰나에 서버 배포가 일어났다는 이유로 에러 페이지를 마주하게 하는 것은 서비스 신뢰도에 치명타를 입힙니다. CI/CD 자동화가 잘 되어 하루에 여러 번 배포가 일어나는 애자일(Agile) 조직일수록 이 설정은 무중단 배포를 완성하는 0순위 필수 조건입니다.
  • 🛑 도입을 굳이 피하거나 최소화해도 될 때: 새벽 시간에 아무도 모르게 동작하는 순수 백그라운드 배치(Batch) 서버나, 종료 시점에 유실되더라도 메시지 큐(Kafka, RabbitMQ)를 통해 완벽하게 자동 재시도가 가능한 비동기 컨슈머 서버의 경우에는 이 설정의 중요도가 상대적으로 낮습니다. 오히려 무거운 배치 작업이 도중에 멈추지 않고 30초 이상 물고 늘어지면 배포 파이프라인의 속도만 심각하게 저하시키므로, 즉시 멈추고 다음 배포된 서버에서 체크포인트부터 다시 재시작하게 설계하는 것이 훨씬 깔끔합니다.

아무리 로드밸런서를 잘 세팅하고 롤링 배포(Rolling Update)를 적용했더라도, 서버 스스로가 닫히는 과정을 세밀하게 통제하지 못하면 진정한 무중단 배포라 부를 수 없습니다. 오늘 알려드린 스프링 부트의 graceful 설정과 K8s preStop 조합을 통해, 하루 10번을 배포해도 에러율 0%를 자랑하는 단단한 인프라를 구축해 보시기 바랍니다.

 

항상 팩트에 기반한 객관적인 실무 가이드로 여러분의 삽질 시간을 줄여드리겠습니다. 다음 포스팅에서도 개발자들에게 유용한 백엔드 아키텍처 꿀팁으로 찾아뵙겠습니다!

 

👉 대규모 트래픽 처리 중 내 서버가 뻗을 위험도는? 카프카(Kafka) 리밸런싱 타임아웃 계산기 [링크]