[Spring] Graceful Shutdown과 SSE(long-lived 연결을 안전하게 다루는 법)

종료와 실시간 연결

운영 환경의 스프링 부트 애플리케이션은 끊임없이 배포된다. 새 버전을 배포할 때마다 기존 인스턴스는 SIGTERM 을 받고 사라진다. SIGTERM은 다음의 과정을 발생시킨다.

  • 진행 중이던 결제 트랜잭션(Transaction)이 중간에 끊긴다.
  • 외부 API 에 보낸 요청은 결과를 받지 못한 채 컨테이너가 죽는다.
  • 사용자 화면에 열려 있던 실시간 연결(SSE, WebSocket)이 끊긴 채로 남는다.
  • 로드밸런서는 잠시 동안 죽어 가는 인스턴스로 요청을 계속 보낸다.

이 모든 사고를 줄이는 메커니즘이 Graceful Shutdown 이다. 새로운 요청은 받지 않고, 진행 중인 요청은 마무리할 수 있게 일정 시간 유예를 둔 뒤 종료한다. 단순해 보이지만, 한 번의 요청이 길게 이어지는 SSE(Server-Sent Events) 같은 시나리오에서는 일반 HTTP 요청과 다른 방식으로 다뤄야 한다.

이 글은 톰캣과 스프링이 종료 신호를 받았을 때 어떤 순서로 자원을 정리하는지, 쿠버네티스 환경에서 어떤 설정 짝을 맞춰야 하는지, 그리고 SSE 같은 long-lived 연결을 어떻게 안전하게 살리고 끊을지를 정리한다.

키워드의미
Graceful Shutdown새 요청을 차단하고 진행 중인 요청은 마무리한 뒤 종료하는 절차
SIGTERM운영체제가 프로세스에 보내는 정상 종료 요청 신호
SIGKILL즉시 강제 종료 신호. 무시할 수 없다
readinessProbe쿠버네티스가 Pod 의 트래픽 수신 가능 여부를 판정하는 헬스 체크
SSEHTTP 위에서 서버에서 클라이언트 방향으로만 흐르는 이벤트 스트림

Graceful Shutdown

Graceful Shutdown 옵션이 없다면 SIGTERM 을 받는 즉시 프로세스가 종료된다. 워커 스레드가 어떤 일을 하고 있든 상관하지 않는다. 결제 중인 트랜잭션이 있어도 끊긴다. 톰캣은 이를 막기 위해 종료 시점에 다음 작업을 순서대로 수행한다.

// WebServerGracefulShutdownLifecycle - 동작 개요
private void doShutdown(GracefulShutdownCallback callback, CountDownLatch shutdownUnderway) {
    try {
        List<Connector> connectors = getConnectors();
        connectors.forEach(this::close);     // 1) 커넥터 종료, 새 요청 차단
        shutdownUnderway.countDown();
        awaitInactiveOrAborted();             // 2) 처리 중인 요청 종료 대기
        if (this.aborted) {
            callback.shutdownComplete(GracefulShutdownResult.REQUESTS_ACTIVE);
        } else {
            callback.shutdownComplete(GracefulShutdownResult.IDLE);
        }
    } finally {
        shutdownUnderway.countDown();
    }
}
 
private void awaitInactiveOrAborted() {
    for (Container host : this.tomcat.getEngine().findChildren()) {
        for (Container context : host.findChildren()) {
            while (!this.aborted && isActive(context)) {
                Thread.sleep(50);    // 3) 50ms 간격으로 활성 요청 확인
            }
        }
    }
}
  1. 톰캣 Connector 들을 닫는다. 이 시점부터 새 TCP 연결은 받지 않는다.
  2. 50ms 간격으로 현재 처리 중인 요청이 있는지 확인한다.
  3. 모든 요청이 종료될 때까지 또는 타임아웃이 발생할 때까지 대기한다.
  4. 타임아웃에 도달하면 aborted 플래그가 설정되어 강제로 마무리한다.

설정

server:
  shutdown: graceful
 
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s    # 종료 대기 최대 시간 (기본 30초)

server.shutdown: graceful 만으로 톰캣의 Graceful Shutdown 이 활성화되고, spring.lifecycle.timeout-per-shutdown-phase 로 대기 한도를 정한다. 이 시간을 넘기면 강제 종료된다.

SIGTERM 수신 후의 종료 시퀀스

sequenceDiagram
    autonumber
    participant K as Operator/k8s
    participant P as Spring Boot Process
    participant T as Tomcat Connector
    participant W as Worker Threads
    participant DB as DB/HikariCP

    K->>P: SIGTERM
    P->>T: 모든 Connector close 호출
    Note over T: 이 시점부터 새 연결 거부
    P->>P: ContextClosedEvent 발행
    P->>W: 진행 중인 요청 모니터링 (50ms 간격)
    W->>DB: 트랜잭션 진행 중
    DB-->>W: 응답
    W-->>P: 요청 처리 완료
    alt 모든 요청 정상 종료
        P->>P: 모든 워커 idle 확인
        P->>DB: HikariCP 풀 종료
        P-->>K: 정상 종료
    else 타임아웃 (30초 초과)
        P->>P: aborted=true 표시
        P->>W: 강제 인터럽트
        P->>DB: 풀 강제 종료
        P-->>K: REQUESTS_ACTIVE 로그 후 종료
    end
  • Connector 를 닫는 순간부터는 새 요청을 받지 않는다. 그러나 이미 받은 요청은 끝까지 처리한다.
  • 풀(HikariCP, Redis, Kafka 등)은 워커가 완전히 종료된 뒤에 닫는 것이 안전하다. 스프링은 빈의 라이프사이클을 역순으로 정리하면서 이를 자동으로 처리한다.

쿠버네티스에서의 Graceful Shutdown

쿠버네티스는 Pod 종료 절차를 별도로 정의한다. 스프링의 설정 하나만으로는 부족하고, Pod spec 과의 조합을 신경 써야 한다.

spec:
  terminationGracePeriodSeconds: 60    # k8s가 SIGKILL을 보내기 전까지의 시간
  containers:
    - name: app
      lifecycle:
        preStop:
          exec:
            command: ["sleep", "5"]    # 로드밸런서가 Pod를 제외할 시간 확보

  • terminationGracePeriodSecondsspring.lifecycle.timeout-per-shutdown-phase 보다 충분히 길어야 한다.
    • 스프링이 정상 종료할 시간을 확보해야 한다. 보통 스프링 30초, k8s 60초 정도가 안전한 출발점이다.
  • preStop Hook 의 sleep 5
    • 로드밸런서나 Ingress Controller 가 이 Pod 는 endpoint 에서 제외됨을 인지하기 전에 SIGTERM 이 먼저 도달하면, 일부 요청이 죽어가는 Pod 로 그대로 라우팅된다. 5초 정도 sleep 을 두면 로드밸런서 재구성을 기다릴 수 있다.

SIGTERM 과 readinessProbe

SIGTERM을 받은 뒤에도 일정 시간 readiness가 살아 있으면 kube-proxy가 이 Pod를 endpoint 에서 빼지 않는다. preStop sleep 과 readinessProbe 실패 응답을 함께 활용한다.

SSE

SSE 는 HTTP 위에서 서버에서 클라이언트로 단방향 스트림을 유지한다. 클라이언트가 GET /events 를 보내면 서버는 연결을 끊지 않고 text/event-stream 형식으로 데이터를 계속 흘려보낸다.

GET /events HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 42
 
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
 
id: 43
event: progress
data: {"percent": 10}
 
id: 44
event: progress
data: {"percent": 40}

이 연결은 일반 REST 요청과는 결이 다르다. 응답이 한 번에 끝나지 않고 짧게는 수십 초, 길게는 수십 분 동안 유지된다. 그래서 Graceful Shutdown 시점에는 다음 같은 질문이 추가로 생긴다.

  • 종료 신호를 받았을 때 SSE 연결을 어떻게 끝낼 것인가.
  • 클라이언트는 끊긴 연결을 어떻게 자동으로 다시 잇는가.
  • 멀티 인스턴스 환경에서 다른 인스턴스에 연결된 클라이언트에 이벤트를 어떻게 보낼 것인가.

SSE 연결 생명주기

EventSource API 는 연결이 끊기면 자동으로 재연결을 시도하며, 마지막으로 받은 이벤트 ID를 Last-Event-ID 헤더로 보낸다. 서버는 이 헤더를 보고 누락된 이벤트를 재전송해야 한다. 단순한 알림 시나리오에서는 이를 무시해도 무방하지만, 결제·작업 진행률 등 정합성이 중요한 시나리오에서는 이벤트 로그를 외부 저장소(Redis Stream, Kafka)에 두고 Last-Event-ID 기반 replay 를 구현해야 한다.

SSE 에서의 Graceful Shutdown

일반 HTTP 요청은 짧으니 30초 안에 모든 요청이 끝난다. 그러나 SSE 는 끝없이 열려 있다. Graceful Shutdown의 기본 동작은 모든 요청이 끝날 때까지 기다리는 것이므로, 클라이언트는 SSE 연결이 살아 있는 한 응답이 없는 서버를 계속해서 기다리게 된다.

1) 종료 이벤트를 감지해 emitter 들을 일괄 정리

ApplicationListener<ContextClosedEvent>SmartLifecycle 을 이용해 종료 단계에서 모든 emitter 를 명시적으로 complete 시킨다.

@Component
class SseShutdownHandler(
    private val emitterRegistry: JobEmitterRegistry,
) : SmartLifecycle {
 
    private val running = AtomicBoolean(false)
 
    override fun start() { running.set(true) }
 
    override fun stop() {
        running.set(false)
        emitterRegistry.completeAll()   // 모든 SseEmitter.complete() 호출
    }
 
    override fun isRunning(): Boolean = running.get()
 
    override fun getPhase(): Int = Int.MIN_VALUE   // 가장 먼저 stop
}

getPhase() 를 작게 두어 다른 빈들(특히 데이터베이스 풀)보다 먼저 종료되도록 한다. 클라이언트는 연결이 끊긴 것을 감지하고 EventSource 의 자동 재연결로 다른 인스턴스에 다시 붙는다.

2) 종료 시점에 새 SSE 연결을 받지 않도록 막는다

ContextClosedEvent 이후로는 컨트롤러 단에서 SseEmitter 를 즉시 complete 한 채로 반환하거나, readinessProbe 를 실패시켜 로드밸런서가 이 Pod 로 요청을 보내지 않게 한다. 후자가 일반적이며, 그래서 readinessProbe 와 Graceful Shutdown은 함께 적용되는 것이 좋다.

주의사항과 베스트프랙티스

1) server.shutdown: graceful 만으로 끝나지 않는다

스프링 옵션만 켠다고 안전한 종료가 보장되지 않는다. 쿠버네티스 terminationGracePeriodSeconds 와 preStop Hook, 로드밸런서의 endpoint 갱신 타이밍까지 모두 한 쌍으로 본다.

2) readinessProbe 로 트래픽을 먼저 끊는다

SIGTERM 을 받은 직후 readinessProbe 가 실패하도록 만들어 두면, 로드밸런서가 새 트래픽을 보내지 않게 된다. preStop sleep 과 함께 두 단계로 트래픽을 끊는 것이 가장 안전한 패턴이다.

3) SSE 연결을 가진 채로 Graceful Shutdown 을 기다리지 않는다

SSE 는 끝없이 열려 있으므로 일반적인 요청 종료 대기 모델로는 종료 타임아웃까지 항상 기다리게 된다. 종료 시점에는 SseEmitter.complete() 를 일괄 호출해 빠르게 정리한다. 클라이언트의 EventSource 가 자동 재연결로 다른 인스턴스에 다시 붙는다.

4) SseEmitter 의 타임아웃을 명시한다

기본값은 짧다. 비즈니스에 맞게 30분, 1시간 등으로 명시한다. 그리고 onTimeout 콜백에서 emitter 를 레지스트리에서 제거해야 메모리 누수를 막을 수 있다.

5) keep-alive 코멘트로 idle timeout 을 피한다

방화벽, 로드밸런서, 프록시는 일정 시간 데이터가 없으면 connection 을 끊는 경우가 많다. 15~30초마다 SSE 코멘트(: keep-alive\n\n) 또는 빈 이벤트를 보내 살아 있음을 알린다.

6) 멀티 인스턴스 환경에서는 Pub/Sub 을 사용한다

scale-out 된 환경에서 sticky session 은 운영 부담이 크다. Redis Pub/Sub 이나 Kafka 로 이벤트 fan-out 구조를 잡으면 어느 인스턴스에 붙어도 같은 이벤트가 도달한다.

7) 이벤트 누락 복원이 필요한 경우 외부 저장소를 둔다

Last-Event-ID 로 어디까지 받았는지 알 수 있지만, 서버 측에서 그 ID 이후의 이벤트를 재생할 수 있어야 의미가 있다. Redis Stream 이나 Kafka 에 이벤트 로그를 두고 replay 할 수 있게 설계한다.