서버를 띄우는 건 잘 했는데, 끄는 건 그냥 프로세스를 kill하면 되는 걸까? 배포할 때 서버를 재시작하면 그 순간 처리 중이던 요청은 어떻게 되는 걸까?

이전 글들에서 Netty 서버를 구성하고 실행하는 방법을 살펴봤습니다. 그런데 운영 환경에서는 서버를 안전하게 종료 하는 것이 서버를 띄우는 것만큼 중요합니다. 이번 글에서는 Netty의 Graceful Shutdown 패턴 — shutdownGracefully()의 동작 원리, Boss/Worker 종료 순서, JVM ShutdownHook 연동, 그리고 실전에서 사용하는 전체 종료 흐름을 정리합니다.


왜 Graceful Shutdown이 중요한가

서버를 그냥 강제 종료하면(kill -9) 다음과 같은 문제가 발생합니다.

  • **진행 중인 요청 유실 **: 클라이언트가 응답을 받지 못하고 타임아웃 또는 커넥션 리셋 에러를 받는다
  • ** 리소스 릭 **: 열려 있는 소켓, 파일 핸들, 데이터베이스 커넥션이 제대로 정리되지 않는다
  • ** 데이터 정합성 깨짐 **: 트랜잭션 중간에 종료되면 데이터가 불완전한 상태로 남는다
  • ** 연결된 시스템에 영향 **: 다른 서비스가 이 서버와의 연결 문제로 연쇄적으로 에러를 겪는다

Graceful Shutdown은 "새로운 요청은 더 이상 받지 않되, 이미 처리 중인 요청은 끝까지 마무리한 뒤 종료하는 것"입니다. 배포, 스케일 인, 롤링 업데이트 — 운영 환경에서 서버가 종료되는 상황은 생각보다 자주 발생합니다.


shutdownGracefully() — 핵심 API

Netty의 EventLoopGroupshutdownGracefully() 메서드를 제공합니다. 이 메서드가 Graceful Shutdown의 핵심입니다.

기본 사용

JAVA
EventLoopGroup group = new NioEventLoopGroup();

// 기본값: quietPeriod=2초, timeout=15초
group.shutdownGracefully();

파라미터가 있는 버전

JAVA
// shutdownGracefully(quietPeriod, timeout, unit)
group.shutdownGracefully(2, 15, TimeUnit.SECONDS);

두 파라미터의 의미를 정확히 이해하는 게 중요합니다.

파라미터의미기본값
quietPeriod새 태스크가 제출되지 않는 것을 확인하는 유예 기간2초
timeout이 시간이 지나면 quietPeriod와 관계없이 강제 종료15초

동작 흐름

PLAINTEXT
shutdownGracefully(2, 15, SECONDS) 호출

├─ 상태를 SHUTTING_DOWN으로 변경

├─ 대기 중인 태스크 실행

├─ quietPeriod(2초) 시작
│   ├─ 2초 동안 새 태스크 없음 → 종료 완료
│   └─ 2초 안에 새 태스크 들어옴 → 태스크 실행 후 quietPeriod 리셋

├─ timeout(15초)에 도달하면?
│   └─ quietPeriod와 관계없이 강제 종료

└─ 상태를 TERMINATED로 변경, Future 완료

quietPeriod는 "정말 더 이상 할 일이 없나?" 확인하는 기간이고, timeout은 "아무리 늦어도 이 시간 안에는 끝내라"는 최종 데드라인입니다. 실무에서 quietPeriod를 너무 길게 잡으면 배포가 느려지고, timeout을 너무 짧게 잡으면 요청이 유실될 수 있습니다.


종료 순서 — 왜 순서가 중요한가

Netty 서버의 종료에는 명확한 순서가 있습니다.

PLAINTEXT
Graceful Shutdown 3단계

① 새 연결 수락 중지
   └─ Boss EventLoopGroup 종료 → ServerSocketChannel 닫힘
   └─ 이 시점부터 새 클라이언트는 connection refused

② 진행 중인 요청 처리 완료 대기
   └─ Worker EventLoopGroup이 기존 연결의 요청을 마저 처리
   └─ quietPeriod 동안 새 이벤트가 없으면 종료 준비 완료

③ EventLoopGroup 종료
   └─ Worker EventLoopGroup 종료
   └─ 모든 Channel 닫힘, 리소스 해제

이 순서를 지키지 않으면 문제가 생깁니다.

PLAINTEXT
잘못된 순서 (Worker를 먼저 종료)

Worker 종료 시작
   └─ 진행 중인 요청 강제 끊김        ← 문제!
   └─ Boss는 여전히 새 연결 수락 중    ← 문제!
   └─ 새로 수락된 연결을 처리할 Worker가 없음  ← 문제!

Boss 먼저, Worker는 나중에

코드로 보면 이렇습니다.

JAVA
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {
    ServerBootstrap b = new ServerBootstrap();
    b.group(bossGroup, workerGroup)
     .channel(NioServerSocketChannel.class)
     .childHandler(new MyChannelInitializer());

    Channel ch = b.bind(8080).sync().channel();
    ch.closeFuture().sync();

} finally {
    // Boss를 먼저 종료 → 새 연결 수락 중단
    bossGroup.shutdownGracefully();
    // Worker를 그 다음 종료 → 진행 중인 작업 완료 후 종료
    workerGroup.shutdownGracefully();
}

여기서 중요한 포인트가 있습니다. shutdownGracefully()는 ** 비동기 **입니다. 호출 즉시 반환되고, 실제 종료는 백그라운드에서 진행됩니다. 그래서 위 코드에서 Boss의 shutdownGracefully()를 호출한 직후 Worker의 shutdownGracefully()를 호출해도, Boss가 완전히 종료될 때까지 기다리는 게 아닙니다.

더 엄밀하게 Boss 종료를 기다린 뒤 Worker를 종료하려면 이렇게 합니다.

JAVA
// Boss 종료 완료까지 대기
bossGroup.shutdownGracefully().sync();
// 그 다음 Worker 종료
workerGroup.shutdownGracefully().sync();

실무에서는 두 가지 방식 모두 사용됩니다. Boss/Worker를 동시에 종료 트리거하더라도, Boss가 서버 소켓을 닫으면 새 연결이 차단되므로 대부분의 경우 문제없이 동작합니다. 다만 순서를 명시적으로 보장하고 싶다면 sync()로 Boss 종료를 기다린 후 Worker를 종료하는 것이 더 안전합니다.


closeFuture().sync() — 서버 메인 스레드 블로킹

Netty 서버 예제에서 거의 항상 보이는 패턴이 있습니다.

JAVA
Channel ch = b.bind(8080).sync().channel();
ch.closeFuture().sync();  // ← 이 줄

이 줄이 없으면 어떻게 될까요?

JAVA
// closeFuture().sync()가 없는 경우
public static void main(String[] args) throws Exception {
    EventLoopGroup bossGroup = new NioEventLoopGroup(1);
    EventLoopGroup workerGroup = new NioEventLoopGroup();

    ServerBootstrap b = new ServerBootstrap();
    b.group(bossGroup, workerGroup)
     .channel(NioServerSocketChannel.class)
     .childHandler(new MyChannelInitializer());

    b.bind(8080).sync();
    // main 메서드가 여기서 끝남 → finally 블록으로 가서 바로 종료!
}

bind().sync()가 완료되면 서버 소켓은 열려 있지만, main 스레드는 더 이상 할 일이 없어서 바로 다음 줄로 넘어갑니다. 만약 finally에서 shutdownGracefully()를 호출하고 있다면, 서버가 시작되자마자 종료됩니다.

closeFuture().sync()는 이 문제를 해결합니다.

JAVA
Channel ch = b.bind(8080).sync().channel();

// ch.closeFuture() → 이 채널이 닫힐 때 완료되는 Future
// .sync()          → 그 Future가 완료될 때까지 현재 스레드를 블로킹
ch.closeFuture().sync();

// 채널이 닫혀야 비로소 이 아래 코드가 실행됨

closeFuture().sync()는 "서버 채널이 닫힐 때까지 main 스레드를 살려두는 역할"입니다. 서버가 외부 신호(ShutdownHook, 채널 종료 이벤트 등)에 의해 닫힐 때까지 main 스레드가 대기하게 됩니다.


JVM ShutdownHook 연동

실제 운영 환경에서 서버가 종료되는 시점은 대부분 JVM이 종료 신호를 받을 때 입니다. kill 명령어, Ctrl+C, 또는 컨테이너 오케스트레이터(Kubernetes)의 SIGTERM 등이 이에 해당합니다.

JVM은 종료 신호를 받으면 Runtime.addShutdownHook()으로 등록된 스레드를 실행합니다. 여기에 Netty 종료 로직을 연결할 수 있습니다.

JAVA
public class NettyServer {

    private final EventLoopGroup bossGroup;
    private final EventLoopGroup workerGroup;
    private Channel serverChannel;

    public NettyServer() {
        this.bossGroup = new NioEventLoopGroup(1);
        this.workerGroup = new NioEventLoopGroup();
    }

    public void start(int port) throws InterruptedException {
        ServerBootstrap b = new ServerBootstrap();
        b.group(bossGroup, workerGroup)
         .channel(NioServerSocketChannel.class)
         .childHandler(new MyChannelInitializer());

        serverChannel = b.bind(port).sync().channel();

        // ShutdownHook 등록
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            System.out.println("종료 신호 수신, Graceful Shutdown 시작...");
            shutdown();
        }, "shutdown-hook"));

        System.out.println("서버 시작: 포트 " + port);
        serverChannel.closeFuture().sync();
    }

    public void shutdown() {
        // 1. 서버 채널 닫기 → 새 연결 수락 중단
        if (serverChannel != null) {
            serverChannel.close().syncUninterruptibly();
        }

        // 2. Boss 종료
        bossGroup.shutdownGracefully(2, 10, TimeUnit.SECONDS);

        // 3. Worker 종료
        workerGroup.shutdownGracefully(2, 10, TimeUnit.SECONDS);

        try {
            // 4. 실제 종료 완료 대기
            bossGroup.terminationFuture().sync();
            workerGroup.terminationFuture().sync();
            System.out.println("Graceful Shutdown 완료");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("종료 중 인터럽트 발생");
        }
    }
}

동작 흐름

PLAINTEXT
SIGTERM 수신 (kill, Ctrl+C, K8s 등)

├─ JVM이 ShutdownHook 스레드 실행

├─ shutdown() 호출
│   ├─ serverChannel.close() → 새 연결 차단
│   ├─ bossGroup.shutdownGracefully(2, 10, SECONDS)
│   ├─ workerGroup.shutdownGracefully(2, 10, SECONDS)
│   └─ terminationFuture().sync() → 종료 완료 대기

├─ 모든 EventLoop 종료 완료

└─ JVM 프로세스 종료

ShutdownHook 사용 시 주의사항

JAVA
// 주의: ShutdownHook에서 awaitTermination 또는 sync()로 대기하지 않으면,
// JVM이 Netty 정리 작업이 끝나기 전에 프로세스를 종료할 수 있다

// 나쁜 예 — 대기 없이 바로 반환
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    bossGroup.shutdownGracefully();   // 비동기 호출만 하고
    workerGroup.shutdownGracefully(); // 끝나기를 기다리지 않음
    // ShutdownHook 스레드 종료 → JVM이 바로 종료할 수 있음
}));

// 좋은 예 — 종료 완료까지 대기
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    bossGroup.shutdownGracefully();
    workerGroup.shutdownGracefully();
    try {
        bossGroup.terminationFuture().sync();   // 완료 대기
        workerGroup.terminationFuture().sync(); // 완료 대기
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}));

shutdownGracefully()는 비동기 메서드입니다. 호출하고 바로 반환되기 때문에, ShutdownHook에서 종료 완료를 기다리지 않으면 JVM이 먼저 죽을 수 있습니다. 반드시 terminationFuture().sync() 또는 awaitTermination()으로 대기해야 합니다.


실전 코드 — 전체 서버 종료 흐름

지금까지 배운 내용을 모두 합치면, 실전에서 사용할 수 있는 전체 종료 흐름이 됩니다.

try-finally 패턴 (가장 기본적인 형태)

JAVA
public class EchoServer {

    private final int port;

    public EchoServer(int port) {
        this.port = port;
    }

    public void start() throws InterruptedException {
        // EventLoopGroup 생성
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();

        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class)
             .option(ChannelOption.SO_BACKLOG, 128)
             .childOption(ChannelOption.SO_KEEPALIVE, true)
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 protected void initChannel(SocketChannel ch) {
                     ch.pipeline().addLast(new EchoServerHandler());
                 }
             });

            // 서버 바인딩
            ChannelFuture f = b.bind(port).sync();
            System.out.println("서버 시작: 포트 " + port);

            // 서버 채널이 닫힐 때까지 메인 스레드 블로킹
            f.channel().closeFuture().sync();

        } finally {
            // 어떤 예외가 발생하든 EventLoopGroup은 반드시 종료
            workerGroup.shutdownGracefully(2, 15, TimeUnit.SECONDS);
            bossGroup.shutdownGracefully(2, 15, TimeUnit.SECONDS);
        }
    }
}

ShutdownHook + 명시적 종료 메서드 패턴 (운영 환경)

JAVA
public class ProductionServer {

    private static final int QUIET_PERIOD = 2;  // 초
    private static final int TIMEOUT = 15;       // 초

    private final int port;
    private EventLoopGroup bossGroup;
    private EventLoopGroup workerGroup;
    private Channel serverChannel;

    public ProductionServer(int port) {
        this.port = port;
    }

    public void start() throws InterruptedException {
        bossGroup = new NioEventLoopGroup(1);
        workerGroup = new NioEventLoopGroup();

        // ShutdownHook 등록 — SIGTERM, Ctrl+C 대응
        registerShutdownHook();

        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class)
             .option(ChannelOption.SO_BACKLOG, 256)
             .childOption(ChannelOption.SO_KEEPALIVE, true)
             .childOption(ChannelOption.TCP_NODELAY, true)
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 protected void initChannel(SocketChannel ch) {
                     ch.pipeline()
                       .addLast(new HttpServerCodec())
                       .addLast(new HttpObjectAggregator(65536))
                       .addLast(new BusinessHandler());
                 }
             });

            serverChannel = b.bind(port).sync().channel();
            System.out.println("서버 시작: 포트 " + port);

            // 메인 스레드 블로킹
            serverChannel.closeFuture().sync();

        } finally {
            shutdown();
        }
    }

    private void registerShutdownHook() {
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            System.out.println("[ShutdownHook] 종료 신호 수신");
            shutdown();
        }, "netty-shutdown-hook"));
    }

    private void shutdown() {
        // 1단계: 서버 채널 닫기 (새 연결 수락 중단)
        if (serverChannel != null && serverChannel.isOpen()) {
            System.out.println("[Shutdown] 서버 채널 닫기 시작");
            serverChannel.close().syncUninterruptibly();
            System.out.println("[Shutdown] 서버 채널 닫기 완료");
        }

        // 2단계: Boss 종료
        if (!bossGroup.isShuttingDown()) {
            System.out.println("[Shutdown] Boss EventLoopGroup 종료 시작");
            bossGroup.shutdownGracefully(QUIET_PERIOD, TIMEOUT, TimeUnit.SECONDS);
        }

        // 3단계: Worker 종료
        if (!workerGroup.isShuttingDown()) {
            System.out.println("[Shutdown] Worker EventLoopGroup 종료 시작");
            workerGroup.shutdownGracefully(QUIET_PERIOD, TIMEOUT, TimeUnit.SECONDS);
        }

        // 4단계: 종료 완료 대기
        try {
            bossGroup.terminationFuture().sync();
            workerGroup.terminationFuture().sync();
            System.out.println("[Shutdown] Graceful Shutdown 완료");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("[Shutdown] 종료 대기 중 인터럽트 발생");
        }
    }
}

타임아웃 설정 가이드

타임아웃 값은 서비스 특성에 따라 달라집니다.

서비스 유형quietPeriodtimeout이유
일반 HTTP API 서버2초15초대부분의 요청이 수 초 안에 완료됨
웹소켓/Long-Polling2초30초장시간 연결이 있을 수 있음
파일 전송 서버5초60초대용량 파일 전송 완료까지 오래 걸림
실시간 게임 서버1초10초빠른 배포 전환이 중요함
JAVA
// 일반 HTTP API 서버 — 대부분의 경우 이 정도면 충분
group.shutdownGracefully(2, 15, TimeUnit.SECONDS);

// 장시간 연결을 다루는 서버 — timeout을 넉넉하게
group.shutdownGracefully(2, 30, TimeUnit.SECONDS);

// 빠른 배포가 중요한 서버 — timeout을 짧게
group.shutdownGracefully(1, 10, TimeUnit.SECONDS);

Kubernetes 환경에서는 Pod의 terminationGracePeriodSeconds(기본 30초)보다 Netty의 timeout을 짧게 설정해야 합니다. 그래야 Netty가 정리를 마치기 전에 K8s가 SIGKILL을 보내는 상황을 피할 수 있습니다.


정리

Graceful Shutdown은 한 줄로 요약하면 "새 연결을 막고, 기존 작업을 마치고, 리소스를 정리한 뒤 종료" 입니다.

핵심 포인트를 다시 정리하면 이렇습니다.

  • shutdownGracefully(quietPeriod, timeout): quietPeriod 동안 새 태스크가 없으면 종료, timeout이 지나면 강제 종료
  • ** 종료 순서 **: Boss 먼저 종료(새 연결 차단) → Worker 나중에 종료(기존 작업 완료)
  • closeFuture().sync(): 서버 채널이 닫힐 때까지 메인 스레드를 살려두는 패턴
  • ShutdownHook: JVM 종료 신호를 받아 Netty 종료를 트리거, 반드시 종료 완료를 대기해야 함
  • try-finally: 어떤 예외가 발생하든 EventLoopGroup 종료를 보장하는 기본 패턴
댓글 로딩 중...