Java 네트워크 프로그래밍 – 서버 종료와 셧다운 훅

지금까지 클라이언트가 비정상 종료할 때 서버 자원이 누수되는 문제를 해결했다. 이번에는 한 단계 더 나아가 서버 자체를 종료할 때 연결된 모든 소켓과 스트림을 깔끔하게 반납하는 방법을 다룬다. 핵심은 자바가 제공하는 셧다운 훅(Shutdown Hook)과, 세션을 중앙에서 관리하는 SessionManager

셧다운 훅(Shutdown Hook)이란

자바는 프로세스 종료 시 후처리 작업을 실행할 수 있는 셧다운 훅 기능을 지원한다. 프로세스 종료는 크게 두 가지로 나뉜다

정상 종료 – 셧다운 훅이 실행된다

  • 모든 non 데몬 스레드 실행 완료
  • Ctrl+C, kill 명령(kill-9 제외), IntelliJ Stop 버튼

강제 종료 – 셧다운 훅이 실행되지 않는다

  • kill -9 (Linux), taskkill /F (Windows), 작업 관리자 강제 종료

정상 종료 시 자바는 셧다운 훅 실행이 완전히 끝날 때까지 기다린다. 셧다운 훅이 끝나면 다른 스레드의 실행 여부와 관계없이 프로세스가 종료된다

설계

서버를 종료할 때 연결된 모든 세션의 소켓과 스트림을 닫으려면, 생성된 세션을 어딘가에서 중앙 관리해야 한다

  • SessionManagerV6: 생성된 세션을 컬렉션으로 보관하고, 서버 종료 시 일괄 종료
  • ShutdownHook: 자바 프로세스 종료 시, SessionManager, ServerSocket을 정리
  • SessionV6.close(): 자신의 자원(소켓, 스트림)을 정리하는 단일 진입점

전체 코드

SessionManagerV6

public class SessionManagerV6 {
    private final List<SessionV6> sessions = new ArrayList<>();

    public synchronized void add(SessionV6 session) {
        sessions.add(session);
    }

    public synchronized void remove(SessionV6 session) {
        sessions.remove(session);
    }

    public synchronized void closeAll() {
        for (SessionV6 session : sessions) {
            session.close();
        }
        sessions.clear();
    }
}

add(), remove(), closeAll() 모두 synchronized로 보호한다. closeAll() 실행 중에 다른 스레드가 remove()를 동시에 호출하면 컬렉션 상태가 꼬일 수 있기 때문이다

SessionV6

import static network.tcp.SocketCloseUtil.closeAll;
import static util.MyLogger.log;

public class SessionV6 implements Runnable {
    private final Socket socket;
    private final DataInputStream input;
    private final DataOutputStream output;
    private final SessionManagerV6 sessionManager;
    private boolean closed = false;

    public SessionV6(Socket socket, SessionManagerV6 sessionManager) throws IOException {
        this.socket = socket;
        this.input = new DataInputStream(socket.getInputStream());
        this.output = new DataOutputStream(socket.getOutputStream());
        this.sessionManager = sessionManager;
        this.sessionManager.add(this); // 생성 즉시 등록
    }

    @Override
    public void run() {
        try {
            while (true) {
                String received = input.readUTF(); // 블로킹
                log("client -> server: " + received);

                if (received.equals("exit")) break;

                String toSend = received + " World!";
                output.writeUTF(toSend);
                log("client <- server: " + toSend);
            }
        } catch (IOException e) {
            log(e);
        } finally {
            sessionManager.remove(this);
            close();
        }
    }

    // 클라이언트 종료 시, 서버 종료 시 동시에 호출될 수 있다
    public synchronized void close() {
        if (closed) return; // 중복 호출 방지
        closeAll(socket, input, output);
        closed = true;
        log("연결 종료: " + socket);
    }
}

try-with-resources를 사용하지 않는 이유

try-with-resourcestry 블록이 끝나는 시점에 자원을 닫는다. 하지만 SessionV6try 블록 외부, 즉 서버 종료 시점에도 close()가 호출되어야 한다. 자원의 해제 타이밍이 try 블록에 종속되지 않으므로 try-with-resources를 사용할 수 없다. 이런 경우에는 finally와 명시적인 자원 정리 메서드를 직접 관리해야 한다

close() 동시성 처리

close()는 두 곳에서 호출될 수 있다

  • 클라이언트가 종료되어 run()finally 블록에서 호출
  • 서버 종료 시 SessionManager.closeAll()에서 호출

ServerV6

public class ServerV6 {
    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        log("서버 시작");
        SessionManagerV6 sessionManager = new SessionManagerV6();
        ServerSocket serverSocket = new ServerSocket(PORT);
        log("서버 소켓 시작 - 리스닝 포트: " + PORT);

        // 셧다운 훅 등록
        ShutdownHook shutdownHook = new ShutdownHook(serverSocket, sessionManager);
        Runtime.getRuntime().addShutdownHook(new Thread(shutdownHook, "shutdown"));

        try {
            while (true) {
                Socket socket = serverSocket.accept(); // 블로킹
                log("소켓 연결: " + socket);

                SessionV6 session = new SessionV6(socket, sessionManager);
                Thread thread = new Thread(session);
                thread.start();
            }
        } catch (IOException e) {
            log("서버 소켓 종료: " + e);
        }
    }

    static class ShutdownHook implements Runnable {
        private final ServerSocket serverSocket;
        private final SessionManagerV6 sessionManager;

        public ShutdownHook(ServerSocket serverSocket, SessionManagerV6 sessionManager) {
            this.serverSocket = serverSocket;
            this.sessionManager = sessionManager;
        }

        @Override
        public void run() {
            log("shutdownHook 실행");
            try {
                sessionManager.closeAll(); // 모든 세션 자원 정리
                serverSocket.close();      // 서버 소켓 닫기
                Thread.sleep(1000);        // 다른 스레드 정리 대기
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}
  • 정상 종료 신호가 오면 자바는 “shutdown” 이름의 스레드에서 ShutdownHook.run() 을 실행하고, 그 실행이 끝날 때까지 기다린 후 프로세스를 종료한다.
  • Thread.sleep(1000)이 필요한 이유는 셧다운 훅이 끝나면 다른 스레드의 상태와 무관하게 프로세스가 즉시 종료되기 때문이다. 세션 스레드들이 예외를 받고 로그를 남기는 시간을 벌어주기 위해 1초를 대기한다.

서버 종료 시 실행 흐름

서버를 Stop하면 다음 순서로 자원이 정리된다

  • 자바 프로세스 종료 신호 수신
  • shutdown 스레드 → ShutdownHook.run() 실행
  • sessionManager.closeAll() → 각 세션의 close() 호출 → 소켓/스트림 닫힘
  • sessions.close() → 컬렉션 비움
  • serverSocket.close() → 서버 소켓 닫힘

세션 소켓이 닫히면 input.readUTF()에서 블로킹 중이던 Thread-0SocketException: Socket closed를 받고 종료된다. ServerSocket이 닫히면 accept()에서 대기하면 main 스레드도 동일한 예외를 받고 종료된다

실행 결과

17:43:57.484 [     main] 서버 시작
17:43:57.486 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
17:43:59.037 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=65407,localport=12345]
17:44:00.799 [ shutdown] shutdownHook 실행
17:44:00.800 [ shutdown] 연결 종료: Socket[addr=/127.0.0.1,port=65407,localport=12345]
17:44:00.800 [ Thread-0] java.net.SocketException: Socket closed
17:44:00.800 [     main] 서버 소켓 종료: java.net.SocketException: Socket closed

shutdown 스레드가 세션을 닫고, 그 영향으로 Thread-0과 main 스레드가 순차적으로 정리되는 것을 확인할 수 있다

클라이언트가 정상 종료 (exit)해도 자원이 반납된다. 클라이언트가 강제 종료되어 예외가 발생해도 finally에서 자원이 반납된다. 서버 자체가 종료될 때도 셧다운 훅이 모든 세션과 서버 소켓을 정리한다. 서버 개발에서 자원 정리는 기능 구현만큼 중요하다. 자원 누수는 즉시 드러나지 않지만 서비스가 커질수록 반드시 장애로 이어진다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 2편, I/O, 네트워크, 리플렉션