Java 네트워크 프로그래밍 – 지속 통신과 다중 클라이언트 문제

이전 글에서 “Hello”를 한 번 주고받고 종료하는 가장 단순한 네트워크 프로그램을 만들었다. 이번에는 exit를 입력할 때까지 계속 메시지를 주고받는 버전으로 개선하고, 여기서 자연스럽게 드러나는 단일 스레드 서버의 구조적 한계를 분석한다

네트워크 프로그램 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 ClientV2 {
    public 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();
    }
}

ServerV2

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

import static util.MyLogger.log;

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

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

        Socket socket = serverSocket.accept(); // 블로킹
        log("소켓 연결: " + socket);
        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();
        serverSocket.close();
    }
}

V1과의 차이는 단 하나다. 메시지를 주고받는 부분을 while(true)로 감쌌다. exit를 수신하면 양쪽 모두 루프를 빠져나와 자원을 정리하고 종료한다

실행 결과 – 클라이언트

13:59:50.460 [     main] 클라이언트 시작
13:59:50.466 [     main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=56524]
전송 문자: hello
13:59:53.882 [     main] client -> server: hello
13:59:53.882 [     main] client <- server: hello World!
전송 문자: hi
13:59:55.017 [     main] client -> server: hi
13:59:55.018 [     main] client <- server: hi World!
전송 문자: exit
13:59:56.552 [     main] client -> server: exit
13:59:56.552 [     main] 연결 종료: Socket[addr=localhost/127.0.0.1,port=12345,localport=56524]

실행 결과 – 서버

13:59:47.388 [     main] 서버 시작
13:59:47.389 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
13:59:50.466 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=56524,localport=12345]
13:59:53.882 [     main] client -> server: hello
13:59:53.882 [     main] client <- server: hello World!
13:59:55.017 [     main] client -> server: hi
13:59:55.018 [     main] client <- server: hi World!
13:59:56.552 [     main] client -> server: exit
13:59:56.553 [     main] 연결 종료: Socket[addr=/127.0.0.1,port=56524,localport=12345]

ServerV2의 구조적 한계 – 다중 클라이언트 불가

V2는 잘 동작하는 것처럼 보이지만, 두 번째 클라이언트가 접속하는 순간 문제가 드러난다

ClientV2-2 실행 결과

14:12:42.420 [     main] 클라이언트 시작
14:12:42.426 [     main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=58298]
전송 문자: hello
14:12:46.158 [     main] client -> server: hello
// 여기서 응답 없이 멈춤

소켓 연결까지는 됐지만, 메시지를 보내도 서버로부터 아무런 응답이 오지 않는다

왜 이런 문제가 발생하는가?

TCP 연결과 Socket 객체는 별개다

  • 이 문제를 이해하려면 TCP 연결이 완료되는 시점과 서버의 Socket 객체가 생성되는 시점을 구분해야 한다.
  • 클라이언트가 new Socket("localhost", 12345)를 호출하면, OS 계층에서 3-way handshake가 진행된다. 이 과정은 자바 애플리케이션이 아닌 OS가 처리하기 때문에, 서버 애플리케이션에서 accept()를 아직 호출하지 않았더라도 TCP 연결 자체는 완료된다. 해당 정보는 OS의 backlog queue에 쌓인다
  • 따라서 두 번째 클라이언트도 TCP 연결은 정상적으로 완료되고, 클라이언트의 Socket 객체도 정상 생성된다. 두 번째 클라이언트가 메시지를 전송하면, 메시지는 네트워크를 타고 서버 OS에 도달한다. 하지만 서버 애플리케이션에서 accept()를 호출해 Socket 객체를 만들지 않았기 때문에 이 메시지는 서버 OS의 TCP 수신 버퍼에서 대기한 채로 머물게 된다

ServerSocket만으로 TCP 연결은 완료된다. accept()는 이미 완료된 TCP 연결 정보를 기반으로 서버 측에 Socket 객체를 생성하는 역할이다. 이 Socket 객체가 있어야 비로소 스트림을 통해 메시지를 읽고 쓸 수 있다

메시지 송수신 경로

소켓의 스트림으로 데이터를 주고받는다는 것은 내부적으로 다음 경로를 거친다

[클라이언트]
애플리케이션 → OS TCP 송신 버퍼 → 네트워크 카드 → 인터넷

[서버]
인터넷 → 네트워크 카드 → OS TCP 수신 버퍼 → 애플리케이션

두 번째 클라이언트의 메시지는 서버의 TCP 수신 버퍼까지는 도달하지만, 서버 애플리케이션에서 Socket 객체를 통해 readUTF()를 호출하지 않으면 그 메시지를 꺼낼 수 없다

ServerV2 코드의 문제

ServerSocket serverSocket = new ServerSocket(PORT);
Socket socket = serverSocket.accept(); // 최초 1회만 호출

while (true) {
    String received = input.readUTF(); // 첫 번째 클라이언트 전용 루프
    output.writeUTF(toSend);
}

accept()는 최초 1회만 호출된다. 이후 메인 스레드는 while루프 안에서 첫 번째 클라이언트와의 통신에만 집중한다. 두 번째 클라이언트가 접속해도 accept()를 다시 호출할 기회가 없으므로, 서버 측 Socket 객체가 생성되지 않는다

핵심 – 두 개의 블로킹 작업은 별도의 스레드가 필요하다

ServerV2에는 블로킹 작업이 두 가지 존재한다

메서드블로킹 조건
serverSocket.accept()새로운 클라이언트가 접속할 때까지 대기
input.readUTF()클라이언트가 메시지를 보낼 때까지 대기

두 작업이 하나의 스레드를 공유하면, 한쪽이 블로킹되는 순간 다른 작업은 영원히 실행될 수 없다. 여러 클라이언트를 동시에 처리하려면, 이 두 블로킹 작업을 각각 별도의 스레드에서 처리해야 한다. 구체적으로 다음과 같이 역할을 분리해야 한다

  • 메인 스레드: accept()를 반복 호출하며 새 클라이언트 접속만 담당
  • 클라이언트별 스레드: 각 클라이언트와의 메시지 송수신을 전담

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