안동민 개발노트

본문 시작

TCP 첫 연결과 다중 클라이언트 서버

localhost 단일 에코 서버를 직접 실행한 뒤 IP·포트·DNS, Socket·ServerSocket의 블로킹 지점과 다중 클라이언트 구조로 확장합니다.

네트워크에서 클라이언트는 서비스를 요청하는 역할이고 서버는 요청을 받아 결과를 제공하는 역할입니다.

이 이름은 장비의 고정 신분이 아닙니다.

웹 서버가 데이터베이스에 질의할 때 웹 서버는 데이터베이스의 클라이언트가 됩니다.

한 프로세스도 관계마다 두 역할을 오갈 수 있으므로 “누가 먼저 요청했는가”를 기준으로 읽습니다.

원격 메서드 호출처럼 보여도 네트워크 경계에는 실패와 지연, 부분 전달, 재시도가 있습니다.

같은 JVM의 객체 호출은 같은 프로세스 안에서 반환값이나 예외를 전달하지만, 그 호출도 블로킹할 수 있습니다. 네트워크에서는 상대가 처리 중인지, 응답이 유실됐는지, 연결이 끊겼는지를 한쪽 관측만으로 확정하기도 어렵습니다.

프로토콜은 메시지 구분과 시간 제한, 중복 처리 정책을 따로 가져야 합니다.


localhost에서 한 번 주고받기

복잡한 서버 구조를 보기 전에 한 클라이언트가 한 메시지를 보내고 같은 메시지를 돌려받는 에코 서버를 실행합니다.

서버는 ServerSocket으로 연결을 기다리고, 클라이언트는 Socket으로 서버의 주소와 포트에 연결합니다.

연결이 성립하면 양쪽의 Socket이 바이트를 주고받는 통로가 됩니다.

먼저 서버를 실행합니다.

accept()는 클라이언트가 연결할 때까지 기다리고, 연결되면 통신 전용 Socket을 반환합니다.

이 예제는 메시지 하나만 처리하고 종료합니다. new ServerSocket(5000)은 루프백 전용 바인딩이 아니며, 여기의 클라이언트가 localhost로 접속하는 것입니다. 뒤의 통합 예제는 루프백 주소를 명시합니다.

src/EchoServer.java
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.ServerSocket;
import java.net.Socket;

public final class EchoServer {
    public static void main(String[] args) throws Exception {
        try (ServerSocket server = new ServerSocket(5000)) {
            System.out.println("서버 대기: 5000");
            try (Socket socket = server.accept();
                 var input = new DataInputStream(socket.getInputStream());
                 var output = new DataOutputStream(socket.getOutputStream())) {
                String message = input.readUTF();
                System.out.println("받음: " + message);
                output.writeUTF(message);
                output.flush();
            }
        }
    }
}

다른 터미널에서 클라이언트를 실행합니다.

localhost는 현재 컴퓨터를 뜻하고 5000은 방금 서버가 연 포트입니다.

src/EchoClient.java
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.Socket;

public final class EchoClient {
    public static void main(String[] args) throws Exception {
        try (Socket socket = new Socket("localhost", 5000);
             var input = new DataInputStream(socket.getInputStream());
             var output = new DataOutputStream(socket.getOutputStream())) {
            output.writeUTF("hello");
            output.flush();
            System.out.println("응답: " + input.readUTF());
        }
    }
}
javac --release 25 -d out src/EchoServer.java src/EchoClient.java

# 터미널 1
java -cp out EchoServer

# 터미널 2
java -cp out EchoClient

서버를 먼저 실행해야 하는 이유는 클라이언트가 연결할 대상이 준비되어 있어야 하기 때문입니다.

writeUTF와 readUTF는 자바 학습용으로 문자열 길이와 내용을 함께 주고받습니다.

두 메서드의 호출 순서가 양쪽에서 맞지 않으면 서로 읽기만 기다릴 수 있습니다.

이 정상 왕복을 기준으로 주소, 메시지 경계, 여러 클라이언트 처리 문제를 하나씩 추가합니다.


IP와 포트의 역할

IP 패킷에는 출발지와 목적지 주소가 들어가고 라우터가 목적지 네트워크 쪽으로 전달합니다.

IP는 최선형 전달이므로 도착·순서·중복 제거를 보장하지 않습니다.

TCP는 IP 위에서 연결을 만들고 순서 있는 신뢰 바이트 스트림, 재전송, 흐름 제어를 제공합니다.

UDP는 연결 상태와 신뢰 처리를 줄여 작은 데이터그램을 전달하며 손실과 순서가 필요하면 애플리케이션이 다룹니다.

한 컴퓨터에는 여러 서버 프로그램이 동시에 실행되므로 IP만으로는 목적 프로세스를 고를 수 없습니다.

포트 번호가 로컬 통신 끝점을 구분합니다.

서버는 알려진 포트에 bind하고 클라이언트는 대개 운영체제가 고른 임시 포트를 사용합니다.

TCP 연결은 출발 IP·포트와 목적 IP·포트의 조합으로 식별되므로 같은 서버 포트가 여러 클라이언트를 동시에 받을 수 있습니다.

DNS는 사람이 기억하는 호스트 이름을 IP 주소 집합으로 해석합니다.

조회 결과는 hosts 파일, 로컬 캐시, 재귀 DNS 해석기를 거칠 수 있고 TTL 동안 캐시됩니다.

하나의 이름이 IPv4와 IPv6 또는 여러 서버 주소를 반환할 수 있으므로 첫 주소가 영원히 같은 장비라고 가정하지 않습니다.

src/LocalAddressInspector.java
import java.net.InetAddress;
import java.util.Arrays;

public final class LocalAddressInspector {
    public static void main(String[] args) throws Exception {
        InetAddress loopback = InetAddress.getLoopbackAddress();
        InetAddress[] localhost = InetAddress.getAllByName("localhost");
        System.out.println("loopback=" + loopback.getHostAddress());
        System.out.println("isLoopback=" + loopback.isLoopbackAddress());
        System.out.println("localhost=" + Arrays.stream(localhost)
                .map(InetAddress::getHostAddress).toList());
    }
}

localhost와 루프백 주소는 패킷이 외부 네트워크 인터페이스로 나가지 않고 현재 컴퓨터로 돌아오게 합니다.

통합 예제에서 외부 DNS와 방화벽에 의존하지 않도록 루프백을 사용합니다.

실제 배포에서는 서버가 어느 인터페이스에 bind하는지에 따라 로컬 전용인지 외부 접근 가능인지 달라집니다.


TCP의 바이트 흐름

TCP 연결은 3-way handshake 뒤 양방향 바이트 스트림을 제공합니다.

한 번의 write가 상대의 한 번 read와 대응한다는 보장은 없습니다.

100바이트 쓰기가 여러 read로 나뉘거나 여러 write가 한 read에서 합쳐질 수 있습니다.

길이 접두사, 구분자, 고정 크기 중 하나로 애플리케이션 메시지 구분을 정의해야 합니다.

DataOutputStream.writeUTF와 DataInputStream.readUTF는 길이가 포함된 자바 전용 학습 프레임을 간단히 만듭니다.

다른 언어와 통신할 공개 프로토콜에는 수정 UTF와 길이 제한을 문서화하거나 일반 UTF-8 길이 프레임을 직접 정의합니다.

JSON이라고 해도 TCP가 JSON 끝을 알지는 못하므로 줄 구분 또는 Content-Length 같은 경계 표시가 필요합니다.


accept·read 역할 분리

ServerSocket.accept()는 새 연결이 들어올 때까지 블로킹하고, 연결된 Socket의 read는 데이터가 올 때까지 별도로 블로킹합니다.

한 스레드가 첫 클라이언트의 read 루프에 들어가면 accept로 돌아가지 못해 다음 클라이언트 처리가 멈춥니다.

운영체제 연결 대기열에 연결이 잠시 머물 수 있어 두 번째 클라이언트의 connect는 성공한 것처럼 보이지만 애플리케이션 응답은 없습니다.

bad/SingleThreadedBlockingServer.java
import java.io.DataInputStream;
import java.net.ServerSocket;
import java.net.Socket;

public final class SingleThreadedBlockingServer {
    static void serve(ServerSocket server) throws Exception {
        while (!server.isClosed()) {
            try (Socket socket = server.accept();
                 var input = new DataInputStream(socket.getInputStream())) {
                while (!"exit".equals(input.readUTF())) {
                    // 첫 연결이 입력을 기다리는 동안 다음 accept는 실행되지 않는다.
                }
            }
        }
    }

    public static void main(String[] args) throws Exception {
        try (var server = new ServerSocket(0)) {
            System.out.println("port=" + server.getLocalPort());
            // serve(server)는 블로킹 서버의 구조를 보여 주므로 여기서는 호출하지 않는다.
        }
    }
}

단일 클라이언트 도구라면 의도일 수 있지만 다중 사용자 서버의 기본 구조로는 부족합니다.

accept 전용 루프는 연결을 받아 세션 작업에 넘기고 곧바로 다음 accept로 돌아갑니다.

세션 작업은 자신의 Socket만 소유하며 읽기·응답·닫기를 담당합니다.


단일 프로세스의 소켓 왕복 재현

고정 포트는 다른 프로그램과 충돌할 수 있으므로 예제 서버를 0번 포트로 열어 운영체제가 빈 포트를 선택하게 합니다.

서버가 준비된 뒤 실제 포트를 클라이언트에 전달합니다.

서버 실패가 보이지 않게 사라지지 않도록 Future로 결과를 회수합니다.

src/EchoSocketRoundTrip.java
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.Executors;

public final class EchoSocketRoundTrip {
    public static void main(String[] args) throws Exception {
        try (var server = new ServerSocket(0, 10, InetAddress.getLoopbackAddress());
             var executor = Executors.newSingleThreadExecutor()) {
            var serverResult = executor.submit(() -> {
                try (Socket accepted = server.accept();
                     var input = new DataInputStream(accepted.getInputStream());
                     var output = new DataOutputStream(accepted.getOutputStream())) {
                    String request = input.readUTF();
                    output.writeUTF(request + " World!");
                    output.flush();
                    return request;
                }
            });

            try (Socket client = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort());
                 var input = new DataInputStream(client.getInputStream());
                 var output = new DataOutputStream(client.getOutputStream())) {
                output.writeUTF("Hello");
                output.flush();
                System.out.println(input.readUTF());
            }
            System.out.println("serverReceived=" + serverResult.get());
        }
    }
}
리스너는 연결을 받고 두 Socket이 데이터를 주고받는다

EchoSocketRoundTrip의 루프백 리스너, 클라이언트 Socket, accept가 반환한 Socket을 연결과 데이터 방향으로 구분합니다.

리스너 수락과 세션의 양방향 바이트 통로클라이언트가 루프백 리스너에 연결하면 accept가 통신 Socket을 반환합니다. Hello는 클라이언트에서 서버 세션으로, Hello World!는 반대로 전달됩니다.connect로 연결 요청accept 반환요청 Hello응답 Hello World!클라이언트 Socket요청 쓰기 · 응답 읽기ServerSocket루프백 · 임시 포트의 리스너accept가 반환한 Socket요청 읽기 · 응답 쓰기
ServerSocket 리스너
원문의 동작: 루프백의 임시 포트에서 accept
자원 경계: 직접 메시지를 읽고 쓰지 않음
클라이언트 Socket
원문의 동작: Hello를 쓰고 Hello World!를 읽음
자원 경계: 클라이언트의 try-with-resources가 닫음
accept가 반환한 Socket
원문의 동작: Hello를 읽고 응답을 씀
자원 경계: 서버 작업의 try-with-resources가 닫음

리스너를 닫는 것과 이미 수락한 Socket을 닫는 것은 별개입니다. 이 원문에는 connect·read·Future.get의 시간 제한이 없으며, 요청·응답 문자열은 코드에 지정된 값입니다.


연결 대기열과 accept 역할 구분

TCP handshake가 끝난 연결 정보는 서버 애플리케이션이 accept할 때까지 운영체제 큐에 대기할 수 있습니다.

연결 대기열은 무한 대기열이 아니며 운영체제 설정과 구현에 따라 실효 크기가 달라집니다.

애플리케이션이 accept를 늦게 호출하면 새 연결이 시간 제한 또는 거부될 수 있습니다.

accept가 반환한 시점에는 연결별 로컬·원격 주소가 정해진 Socket이 생깁니다.

그 뒤 수신 바이트는 운영체제 TCP 버퍼에 도착하고 애플리케이션 read가 소비합니다.

네트워크 카드에 패킷이 도착했다는 사실과 자바 세션이 요청을 처리했다는 사실은 다른 단계입니다.

큐 대기와 세션 처리 지연을 구분해 관측합니다.


연결별 작업과 스레드 상한

전통적인 플랫폼 스레드는 연결당 블로킹 코드를 이해하기 쉽지만 연결 수만큼 스택과 스케줄링 비용이 늘어납니다.

유한 풀을 쓰면 동시 처리 상한이 생기는 대신 세션이 오래 read에서 대기하면 풀 전체가 점유될 수 있습니다.

Java의 가상 스레드는 많은 블로킹 세션을 간단한 동기 코드로 표현하는 선택지지만 데이터베이스와 외부 API의 동시성 상한은 별도로 제한해야 합니다.

src/MultiClientEchoServer.java
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;

public final class MultiClientEchoServer {
    static void session(Socket socket) {
        try (socket;
             var input = new DataInputStream(socket.getInputStream());
             var output = new DataOutputStream(socket.getOutputStream())) {
            String request = input.readUTF();
            output.writeUTF(request.toUpperCase());
        } catch (Exception error) {
            System.err.println("session failed: " + error.getClass().getSimpleName());
        }
    }

    public static void main(String[] args) throws Exception {
        int clients = 3;
        try (var server = new ServerSocket(0, clients, InetAddress.getLoopbackAddress());
             var sessions = Executors.newVirtualThreadPerTaskExecutor()) {
            Thread acceptor = Thread.ofPlatform().start(() -> {
                try {
                    for (int index = 0; index < clients; index++) {
                        Socket socket = server.accept();
                        sessions.submit(() -> session(socket));
                    }
                } catch (Exception error) {
                    throw new RuntimeException(error);
                }
            });

            List<String> replies = new ArrayList<>();
            for (int index = 1; index <= clients; index++) {
                try (var socket = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort());
                     var input = new DataInputStream(socket.getInputStream());
                     var output = new DataOutputStream(socket.getOutputStream())) {
                    output.writeUTF("client-" + index);
                    replies.add(input.readUTF());
                }
            }
            acceptor.join();
            System.out.println(replies);
        }
    }
}

세션 함수가 Socket을 소유하고 try-with-resources로 닫습니다. 다만 이 main은 클라이언트 하나의 응답을 읽고 닫은 뒤 다음 클라이언트를 연결합니다. 세션 실행을 분리한 구조와 클라이언트 세 개의 동시 왕복을 구분해야 합니다.

accept 루프가 만든 Socket을 작업 제출 전에 예외로 잃지 않도록 제출 실패 경로도 운영 코드에서는 처리해야 합니다. 현재 acceptor.join()은 그 스레드의 예외를 main으로 전달하지 않으며, sessions.submit이 반환한 Future도 회수하지 않습니다.

운영 서버에는 리스너를 닫아 accept 대기를 깨우고 활성 세션을 추적해 함께 종료하는 절차가 필요합니다. 이 main에는 활성 세션 등록부나 시간 제한을 둔 종료 절차가 없습니다.


try-with-resources와 예외 보존

네트워크 세션은 Socket, 입력, 출력처럼 서로 관련된 여러 자원을 갖습니다.

정상 경로 끝에 close를 나열하면 read 또는 업무 코드 예외에서 실행되지 않습니다.

finally에서 첫 close가 실패하면 뒤 자원이 닫히지 않고 핵심 업무 예외가 close 예외로 가려질 수 있습니다.

try-with-resources는 선언의 역순으로 모든 close를 시도합니다.

본문에서 이미 예외가 났다면 그것을 주 예외로 던지고 close 실패는 getSuppressed()에 보관합니다.

본문이 성공하고 close만 실패하면 close 예외가 주 예외가 됩니다.

진단 로그는 주 예외와 억제됨 목록을 함께 기록해야 합니다.


네트워크 서버 판단표

경계필수 규칙검증 방법
주소 해석호스트와 IP 실패 구분루프백·잘못된 이름
연결connect 시간 제한미사용 포트·무응답 주소
메시지길이 또는 구분자분할·병합 입력
동시성accept와 세션 분리클라이언트 여러 개
자원소유자와 역순 closeread 중 종료
과부하동시 세션 상한장기 연결 부하

연습 문제

두 AutoCloseable 자원을 선언하고 본문과 두 close에서 모두 예외를 발생시키세요.

어떤 예외가 주 예외가 되는지와 close 호출 순서, 억제됨 배열 순서를 출력합니다.

정답과 해설
exercise/SuppressedCloseOrderSolution.java
public final class SuppressedCloseOrderSolution {
    static final class Resource implements AutoCloseable {
        private final String name;
        Resource(String name) { this.name = name; }
        void use() { System.out.println("use " + name); }
        @Override public void close() throws Exception {
            System.out.println("close " + name);
            throw new Exception("close-" + name);
        }
    }

    public static void main(String[] args) {
        try (var first = new Resource("first");
             var second = new Resource("second")) {
            first.use();
            second.use();
            throw new IllegalStateException("body");
        } catch (Exception primary) {
            System.out.println("primary=" + primary.getMessage());
            for (Throwable suppressed : primary.getSuppressed()) {
                System.out.println("suppressed=" + suppressed.getMessage());
            }
        }
    }
}
본문 예외를 유지한 채 자원을 역순으로 닫는다

SuppressedCloseOrderSolution의 두 자원과 body 예외를 기준으로 close 및 suppressed 순서를 보여줍니다.

본문 예외를 유지한 채 자원을 역순으로 닫는다
정리 순서발생하는 예외catch에서의 위치
두 번째 자원 second를 먼저 closeException("close-second")primary의 suppressed[0]
첫 번째 자원 first를 다음 closeException("close-first")primary의 suppressed[1]
자원 정리 뒤 catch로 전달본문의 IllegalStateException("body")primary 자체 · 정리 예외로 교체되지 않음
두 번째 자원 second를 먼저 close
발생하는 예외: Exception("close-second")
catch에서의 위치: primary의 suppressed[0]
첫 번째 자원 first를 다음 close
발생하는 예외: Exception("close-first")
catch에서의 위치: primary의 suppressed[1]
자원 정리 뒤 catch로 전달
발생하는 예외: 본문의 IllegalStateException("body")
catch에서의 위치: primary 자체 · 정리 예외로 교체되지 않음

두 자원 생성이 성공하고 본문이 예외를 던지는 원문의 경로입니다. 자원 생성 실패나 본문 정상 완료일 때의 주 예외 선택과 구분합니다.

네트워크 장애 로그에서 억제됨을 버리지 않으면 업무 실패와 자원 정리 실패를 동시에 추적할 수 있습니다.


다중 클라이언트 서버의 통합 결론

자바 네트워크 프로그램은 Socket 문법만 배우면 끝나지 않습니다.

DNS가 주소를 찾고 IP와 TCP가 바이트 경로를 만들며 포트가 프로세스를 구분하고, ServerSocket과 세션 Socket이 연결 수락과 데이터 처리를 나눕니다.

메시지 프레임, 블로킹 지점, 동시성 상한, 시간 제한, 자원 종료를 한 모델로 확인해야 여러 클라이언트가 와도 오래 살아 있는 서버가 됩니다.