Java 네트워크 프로그래밍 – 멀티스레드 서버와 자원 정리 문제

이전 글에서 단일 스레드 서버의 한계를 확인했다. accept()readUTF() 두 블로킹 작업이 하나의 스레드를 공유하면, 두 번째 클라이언트는 영원히 응답을 받을 수 없다. 이번에는 역할을 분리하여 여러 클라이언트를 동시에 처리하는 서버로 개선한다

설계 – 역할 분리

핵심은 두 블로킹 작업을 서로 다른 스레드에 위임하는 것이다

스레드역할
main 스레드serverSocket.accept() 반복 호출, 새 클라이언트 접속 처리
Session 스레드 (Thread-N)각 클라이언트의 메시지 숭수신 전담

클라이언트가 접속할 때마다 main 스레드는 Session 객체를 생성하고 새 스레드에서 실행시킨 후, 즉시 accept()로 돌아가 다음 연결을 기다린다

전체 코드

ClientV3

클라이언트 코드는 V2와 완전히 동일하다.

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.util.Scanner;

import static util.MyLogger.log;

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

    public static void main(String[] args) throws IOException {
        log("클라이언트 시작");
        Socket socket = new Socket("localhost", PORT);
        DataInputStream input = new DataInputStream(socket.getInputStream());
        DataOutputStream output = new DataOutputStream(socket.getOutputStream());
        log("소켓 연결: " + socket);

        Scanner scanner = new Scanner(System.in);

        while (true) {
            System.out.print("전송 문자: ");
            String toSend = scanner.nextLine();

            output.writeUTF(toSend);
            log("client -> server: " + toSend);

            if (toSend.equals("exit")) {
                break;
            }

            String received = input.readUTF();
            log("client <- server: " + received);
        }

        log("연결 종료: " + socket);
        input.close();
        output.close();
        socket.close();
    }
}

SessionV3

하나의 클라이언트와 메시지를 주고받는 역할만 전담한다. Runnable을 구현하여 별도 스레드에서 실행된다

package network.tcp.v3;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;

import static util.MyLogger.log;

public class SessionV3 implements Runnable {
    private final Socket socket;

    public SessionV3(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            DataInputStream input = new DataInputStream(socket.getInputStream());
            DataOutputStream output = new DataOutputStream(socket.getOutputStream());

            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);
            }

            // 자원 정리
            log("연결 종료: " + socket);
            input.close();
            output.close();
            socket.close();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

ServerV3

main 스레드는 accept()루프만 담당한다. 소켓이 생성되면 즉시 Session을 별도 스레드로 위임하고 다음 연결을 기다린다

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

import static util.MyLogger.log;

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

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

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

            SessionV3 session = new SessionV3(socket);
            Thread thread = new Thread(session);
            thread.start();
        }
    }
}

실행 결과

15:46:06.414 [     main] 서버 시작
15:46:06.415 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
15:46:08.611 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=53427,localport=12345]
15:46:23.292 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=53460,localport=12345]
15:46:24.744 [ Thread-0] client -> server: hello
15:46:24.744 [ Thread-0] client <- server: hello World!
15:46:27.418 [ Thread-1] client -> server: hi
15:46:27.418 [ Thread-1] client <- server: hi World!
15:46:34.101 [ Thread-1] client -> server: exit
15:46:34.101 [ Thread-1] 연결 종료: Socket[addr=/127.0.0.1,port=53460,localport=12345]
15:46:36.380 [ Thread-0] client -> server: exit
15:46:36.381 [ Thread-0] 연결 종료: Socket[addr=/127.0.0.1,port=53427,localport=12345]

로그에서 두 가지를 확인할 수 있다. 소켓 연결은 항상 main 스레드가 처리하고, 실제 메시지 송수신은 Thread-0, Thread-1이 각자 독립적으로 처리한다. 서버는 클라이언트가 종료돼도 계속 살아있으며, 언제든 새 클라이언트를 받을 수 있다

남아있는 문제 – 비정상 종료 시 자원 누수

exit를 입력해 정상 종료하면 문제없다. 하지만 클라이언트를 IDE의 Stop 버튼으로 강제 종료하면 서버에서 다음 예외가 발생한다

Mac/Linux

Exception in thread "Thread-0" java.lang.RuntimeException: java.io.EOFException
    at network.tcp.v3.SessionV3.run(SessionV3.java:45)
Caused by: java.io.EOFException
    at java.io.DataInputStream.readUTF(DataInputStream.java:575)
    at network.tcp.v3.SessionV3.run(SessionV3.java:26)

Windows

Exception in thread "Thread-0" java.lang.RuntimeException: java.net.SocketException: Connection reset
    at network.tcp.v3.SessionV3.run(SessionV3.java:45)
Caused by: java.net.SocketException: Connection reset

OS마다 예외 종류가 다른 이유는 TCP 연결 정리 방식의 차이 때문이다. Mac은 TCP 연결을 정상종료(FIN)하는 반면, WIndows는 강제 종료 (RST)한다.

핵심 문제 – 자원 정리 코드가 실행되지 않는다

run() 메서드의 구조를 보면 자원 정리 코드는 try 블록 안에 있다

public void run() {
    try {
        // ...
        while (true) {
            String received = input.readUTF(); // 여기서 EOFException 발생
            // ...
        }

        // 자원 정리 — EOFException 발생 시 실행되지 않음
        input.close();
        output.close();
        socket.close();
    } catch (IOException e) {
        throw new RuntimeException(e); // 자원 정리 없이 바로 탈출
    }
}

readUTF()에서 예외가 발생하면 실행 흐름이 즉시 catch로 점프하기 때문에, 그 아래의 자원 정리 코드는 완전히 건너뛰게 된다. 서버 로그에서 “연결 종료” 메시지가 출력되지 않는 것이 이를 증명한다

자바 객체는 GC가 처리하지만, 소켓과 스트림 같은 외부 자원은 GC의 대상이 아니다. 반드시 코드에서 명시적으로 닫아야 한다. TCP 연결의 경우 OS가 어느 정도 정리해주긴 하지만, 직접 닫는 것보다 훨씬 시간이 걸린다. 클라이언트가 수백 개에 달하는 실제 서버스 환경에서는 이것이 소켓 고갈이나 파일 디스크립터 부족으로 이어질 수 있다

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