왜 헬스 체크만으로는 무중단 배포가 부족한가

Spring Boot 서비스를 컨테이너나 Kubernetes에서 운영할 때 가장 흔한 실수는 /actuator/health 하나만 열어 두고 무중단 배포가 준비됐다고 판단하는 것입니다. 일반 헬스 체크는 애플리케이션이 살아 있는지 확인하는 데는 유용하지만, 지금 트래픽을 받아도 되는지, 종료 중인 인스턴스에 새 요청이 들어가도 되는지까지 항상 표현하지는 못합니다. 배포 중 장애를 줄이려면 생존 상태, 준비 상태, 종료 절차를 분리해서 설계해야 합니다.

핵심은 liveness는 프로세스를 다시 시작해야 할 정도의 치명적 상태를 판단하고, readiness는 로드밸런서가 트래픽을 보내도 되는 상태를 판단하게 만드는 것입니다. 예를 들어 데이터베이스 연결이 잠깐 느려졌다는 이유로 liveness를 실패시키면 플랫폼이 정상 복구 가능한 애플리케이션을 계속 재시작할 수 있습니다. 반대로 readiness가 계속 성공하면 초기화가 끝나지 않은 서버나 종료 중인 서버가 사용자 요청을 받아 오류를 만들 수 있습니다.

기본 설정: Actuator와 probe endpoint 분리

Spring Boot Actuator는 운영 관측을 위한 엔드포인트를 제공하며, liveness와 readiness 상태를 별도의 health group으로 노출할 수 있습니다. 운영 환경에서는 health 상세 정보가 외부에 과하게 노출되지 않도록 하고, probe 경로는 로드밸런서나 오케스트레이터가 접근하기 쉬운 경로로 고정하는 것이 좋습니다. 관리 포트를 별도로 쓰는 경우에도 실제 웹 서버 포트가 막혔는지 확인해야 하므로 main server port에 추가 경로를 노출하는 구성을 검토합니다.

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: never
      probes:
        enabled: true
        add-additional-paths: true
      group:
        readiness:
          include: readinessState,db,diskSpace
        liveness:
          include: livenessState
server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

위 설정은 readiness에는 readinessState와 함께 데이터베이스, 디스크 공간처럼 요청 처리 가능 여부에 영향을 주는 항목을 포함하고, liveness에는 애플리케이션 자체가 깨졌는지만 보도록 범위를 좁힙니다. readiness에 외부 API를 너무 많이 넣으면 외부 장애가 곧바로 전체 서비스 제거로 이어질 수 있으므로, 반드시 트래픽을 받으면 안 되는 조건만 넣어야 합니다.

Kubernetes probe 값은 애플리케이션 시간에 맞춘다

probe 경로는 Spring Boot의 기본 Actuator 경로를 그대로 써도 되지만, main port에 추가 경로를 노출했다면 /livez, /readyz처럼 짧고 고정된 경로로 두는 편이 운영하기 쉽습니다. 중요한 것은 initialDelaySeconds, periodSeconds, timeoutSeconds, failureThreshold를 애플리케이션의 실제 시작 시간과 요청 지연에 맞추는 것입니다. 시작이 40초 걸리는 서비스에 liveness를 10초부터 강하게 걸면 배포 직후 재시작 루프에 빠질 수 있습니다.

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: api
          image: registry.example.com/order-api:2026.06.10
          ports:
            - containerPort: 8080
          livenessProbe:
            httpGet:
              path: /livez
              port: 8080
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 2
          startupProbe:
            httpGet:
              path: /livez
              port: 8080
            periodSeconds: 5
            failureThreshold: 18

startupProbe는 시작 시간이 긴 애플리케이션에서 특히 유용합니다. startupProbe가 성공하기 전까지 liveness 판단을 늦출 수 있어, 초기 캐시 로딩이나 마이그레이션 점검 때문에 늦게 뜨는 서비스가 불필요하게 종료되는 상황을 줄입니다. readiness는 시작 작업이 끝나기 전에는 실패해야 하며, 그래야 새 Pod가 트래픽을 받기 전에 충분히 준비됩니다.

종료 중 새 요청을 막고 진행 중 요청은 마무리한다

무중단 배포에서 종료 처리는 시작 처리만큼 중요합니다. 플랫폼이 SIGTERM을 보내면 애플리케이션은 더 이상 새 트래픽을 받지 않도록 readiness를 실패 상태로 전환하고, 이미 처리 중인 요청은 지정된 시간 안에 마무리해야 합니다. Spring Boot의 graceful shutdown은 내장 웹 서버가 새 요청 수락을 중지하고 진행 중 요청을 기다리는 흐름을 제공합니다. 다만 timeout-per-shutdown-phase가 너무 짧으면 정상 요청이 중간에 끊기고, 너무 길면 배포 속도와 롤백 속도가 느려집니다.

배치성 작업이나 메시지 소비자가 함께 있는 서비스라면 HTTP 요청만 기다려서는 부족합니다. Kafka, RabbitMQ, 스케줄러, 내부 워커가 있으면 종료 이벤트에서 새 작업 수신을 중지하고 현재 작업을 제한 시간 안에 마무리하도록 별도 제어가 필요합니다. readiness가 실패한 뒤에도 몇 초 동안 기존 연결이나 로드밸런서 캐시로 요청이 들어올 수 있으므로, 종료 직후 바로 프로세스를 끊지 않는 것이 안전합니다.

import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.availability.ReadinessState;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class ShutdownReadinessListener {
    private final ApplicationEventPublisher publisher;

    public ShutdownReadinessListener(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    @EventListener
    public void onClosed(ContextClosedEvent event) {
        AvailabilityChangeEvent.publish(
            publisher,
            event,
            ReadinessState.REFUSING_TRAFFIC
        );
    }
}

커스텀 HealthIndicator는 빠르고 보수적으로 만든다

readiness에 커스텀 점검을 넣을 때는 빠르게 끝나는 로컬 판단을 우선해야 합니다. 원격 API를 매번 호출하거나 무거운 SQL을 실행하면 health endpoint 자체가 병목이 됩니다. 캐시된 최근 점검 결과를 반환하거나, 짧은 timeout을 둔 경량 쿼리만 사용하는 방식이 좋습니다. 또한 점검 실패가 정말 트래픽 차단 사유인지 먼저 구분해야 합니다. 결제 API가 일시적으로 느린데 전체 상품 조회 API까지 readiness 실패로 막아 버리면 장애 범위가 커집니다.

  • liveness에는 JVM이나 애플리케이션 내부가 복구 불가능한 상태인지에 가까운 항목만 둡니다.
  • readiness에는 요청 처리에 반드시 필요한 데이터베이스, 필수 저장소, 로컬 초기화 완료 여부를 넣습니다.
  • 외부 API 상태는 전체 서비스 차단보다 기능별 degrade, circuit breaker, fallback으로 처리할 수 있는지 먼저 검토합니다.
  • health endpoint 응답 시간은 별도 모니터링하고, 느린 health check가 배포 장애를 만들지 않게 합니다.

배포 전 점검 체크리스트

  • 새 버전 Pod가 readiness 성공 전에는 트래픽을 받지 않는지 확인합니다.
  • SIGTERM 이후 readiness가 실패하고, graceful shutdown 시간 안에 진행 중 요청이 정리되는지 부하 테스트로 검증합니다.
  • startupProbe가 실제 최악의 시작 시간을 감당하는지 확인합니다.
  • terminationGracePeriodSeconds가 Spring shutdown timeout보다 충분히 긴지 맞춥니다.
  • liveness 실패가 재시작으로 해결되는 문제에만 발생하도록 범위를 줄입니다.
  • probe endpoint, 로드밸런서 health check, 방화벽 경로가 모두 같은 포트를 기준으로 검증되는지 확인합니다.

정리하면 Spring Boot 무중단 배포의 핵심은 단순히 헬스 체크를 켜는 것이 아니라, 시작 중, 정상 운영 중, 종료 중 상태를 각각 다른 신호로 표현하는 것입니다. liveness는 재시작 판단, readiness는 트래픽 수신 판단, graceful shutdown은 요청 마무리 책임으로 나누면 배포 중 502, 재시작 루프, 초기화 전 요청 유입을 크게 줄일 수 있습니다.