본문 바로가기

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-8과 길이 제한을 문서화하거나 일반 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이 데이터를 주고받는다

클라이언트 연결을 listener가 accept하여 별도 세션 Socket을 만들고 요청과 응답을 주고받습니다. 두 서버 소켓의 역할을 고정해 연결과 메시지의 시간을 나눌 수 있습니다.

리스너는 연결을 받고 두 Socket이 데이터를 주고받는다

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

TCP 왕복 · 리스너와 연결 소켓

리스너는 연결을 받고 두 Socket이 데이터를 주고받는다

ServerSocket은 연결을 받고, 요청과 응답은 클라이언트와 수락된 Socket 사이로 오갑니다.

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

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

이 예의 조건

  • listener와 accept가 반환한 세션 Socket은 다른 객체이다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보리스너는 연결을 받고 두 Socket이 데이터를 주고받는다 리스너는 연결을 받고 두 Socket이 데이터를 주고받는다 EchoSocketRoundTrip의 루프백 리스너, 클라이언트 Socket, accept가 반환한 Socket을 연결과 데이터 방향으로 구분합니다. TCP 왕복 · 리스너와 연결 소켓 리스너는 연결을 받고 두 Socket이 데이터를 주고받는다 ServerSocket은 연결을 받고, 요청과 응답은 클라이언트와 수락된 Socket 사이로 오갑니다. ServerSocket 리스너 원문의 동작: 루프백의 임시 포트에서 accept 자원 경계: 직접 메시지를 읽고 쓰지 않음 클라이언트 Socket 원문의 동작: Hello를 쓰고 Hello World!를 읽음 자원 경계: 클라이언트의 try-with-resources가 닫음 accept가 반환한 Socket 원문의 동작: Hello를 읽고 응답을 씀 자원 경계: 서버 작업의 try-with-resources가 닫음 리스너를 닫는 것과 이미 수락한 Socket을 닫는 것은 별개입니다. 이 원문에는 connect·read·Future.get의 시간 제한이 없으며, 요청·응답 문자열은 코드에 지정된 값입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
연결 대기

서버 listener는 accept에서 연결을 기다립니다.

  • 서버 역할: ServerSocket listener
  • 세션: 아직 없음
  • listener가 세션 메시지를 직접 주고받지 않는다.
연결 수락

클라이언트 connect를 accept하여 별도 세션 Socket을 얻습니다.

  • 클라이언트: 연결된 Socket
  • 서버: listener + accepted Socket
  • 수락된 연결의 송수신 책임이 accepted Socket으로 이동한다.
요청 읽기

클라이언트가 Hello를 보내고 서버 세션이 읽습니다.

  • 요청: Hello
  • 수신자: accepted Socket
  • 연결 완료 후 세션 데이터가 전송된다.
응답 읽기

서버 세션이 Hello World!를 보내고 클라이언트가 읽습니다.

  • 응답: Hello World!
  • listener: 연결 수락 역할 유지
  • 응답도 동일 세션의 반대 방향으로 이동한다.

리스너는 연결을 받고 두 Socket이 데이터를 주고받는다

클라이언트 연결을 listener가 accept하여 별도 세션 Socket을 만들고 요청과 응답을 주고받습니다. 두 서버 소켓의 역할을 고정해 연결과 메시지의 시간을 나눌 수 있습니다.

리스너는 연결을 받고 두 Socket이 데이터를 주고받는다

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

TCP 왕복 · 리스너와 연결 소켓

리스너는 연결을 받고 두 Socket이 데이터를 주고받는다

ServerSocket은 연결을 받고, 요청과 응답은 클라이언트와 수락된 Socket 사이로 오갑니다.

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

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

이 예의 조건

  • listener와 accept가 반환한 세션 Socket은 다른 객체이다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보리스너는 연결을 받고 두 Socket이 데이터를 주고받는다 리스너는 연결을 받고 두 Socket이 데이터를 주고받는다 EchoSocketRoundTrip의 루프백 리스너, 클라이언트 Socket, accept가 반환한 Socket을 연결과 데이터 방향으로 구분합니다. TCP 왕복 · 리스너와 연결 소켓 리스너는 연결을 받고 두 Socket이 데이터를 주고받는다 ServerSocket은 연결을 받고, 요청과 응답은 클라이언트와 수락된 Socket 사이로 오갑니다. ServerSocket 리스너 원문의 동작: 루프백의 임시 포트에서 accept 자원 경계: 직접 메시지를 읽고 쓰지 않음 클라이언트 Socket 원문의 동작: Hello를 쓰고 Hello World!를 읽음 자원 경계: 클라이언트의 try-with-resources가 닫음 accept가 반환한 Socket 원문의 동작: Hello를 읽고 응답을 씀 자원 경계: 서버 작업의 try-with-resources가 닫음 리스너를 닫는 것과 이미 수락한 Socket을 닫는 것은 별개입니다. 이 원문에는 connect·read·Future.get의 시간 제한이 없으며, 요청·응답 문자열은 코드에 지정된 값입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
연결 대기

서버 listener는 accept에서 연결을 기다립니다.

  • 서버 역할: ServerSocket listener
  • 세션: 아직 없음
  • listener가 세션 메시지를 직접 주고받지 않는다.
연결 수락

클라이언트 connect를 accept하여 별도 세션 Socket을 얻습니다.

  • 클라이언트: 연결된 Socket
  • 서버: listener + accepted Socket
  • 수락된 연결의 송수신 책임이 accepted Socket으로 이동한다.
요청 읽기

클라이언트가 Hello를 보내고 서버 세션이 읽습니다.

  • 요청: Hello
  • 수신자: accepted Socket
  • 연결 완료 후 세션 데이터가 전송된다.
응답 읽기

서버 세션이 Hello World!를 보내고 클라이언트가 읽습니다.

  • 응답: Hello World!
  • listener: 연결 수락 역할 유지
  • 응답도 동일 세션의 반대 방향으로 이동한다.

연결 대기열과 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());
            }
        }
    }
}

본문 예외를 유지한 채 자원을 역순으로 닫는다

본문 예외를 주 예외로 유지하며 두 자원을 선언 역순으로 닫아 suppressed[0], suppressed[1]을 붙입니다. 예외 목록이 쌓이는 실제 전환을 보여 줄 수 있습니다.

본문 예외를 유지한 채 자원을 역순으로 닫는다

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

자원 정리 · 역순과 예외 보존

본문 예외를 유지한 채 자원을 역순으로 닫는다

second와 first의 close 실패를 억제 예외에 보관하고, 본문의 body 예외를 주 예외로 전달합니다.

역순 close 실패는 본문 주 예외에 붙는다

역순 close 실패는 본문 주 예외에 붙는다본문 body 예외를 유지한다. second.close를 먼저, first.close를 다음에 시도하고 각각 close-second와 close-first를 suppressed 0·1로 붙인다.주 예외 bodyIllegalStateException1. secondclose 시도2. firstclose 시도억제 [0]second억제 [1]first
역순 close 실패는 본문 주 예외에 붙는다본문 body 예외를 유지한다. second.close를 먼저, first.close를 다음에 시도하고 각각 close-second와 close-first를 suppressed 0·1로 붙인다.주 예외 bodyIllegalStateException1. secondclose 시도2. firstclose 시도억제 [0]second억제 [1]first

본문 body 예외를 유지한다. second.close를 먼저, first.close를 다음에 시도하고 각각 close-second와 close-first를 suppressed 0·1로 붙인다.

상태별 상세 해설
  1. 두 번째 자원 second를 먼저 close

    발생하는 예외
    Exception("close-second")
    catch에서의 위치
    primary의 suppressed[0]
  2. 첫 번째 자원 first를 다음 close

    발생하는 예외
    Exception("close-first")
    catch에서의 위치
    primary의 suppressed[1]
  3. 자원 정리 뒤 catch로 전달

    발생하는 예외
    본문의 IllegalStateException("body")
    catch에서의 위치
    primary 자체 · 정리 예외로 교체되지 않음

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

이 예의 조건

  • 자원은 선언 역순으로 닫는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
5second와 first의 close 실패를 억제 예외로 보관하고, 본문의 body 예외를 주 예외로 전달합니다.
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보본문 예외를 유지한 채 자원을 역순으로 닫는다 본문 예외를 유지한 채 자원을 역순으로 닫는다 SuppressedCloseOrderSolution의 두 자원과 body 예외를 기준으로 close 및 suppressed 순서를 보여줍니다. 자원 정리 · 역순과 예외 보존 본문 예외를 유지한 채 자원을 역순으로 닫는다 second와 first의 close 실패를 억제 예외로 보관하고, 본문의 body 예외를 주 예외로 전달합니다. 두 번째 자원 second를 먼저 close 발생하는 예외 Exception("close-second") catch에서의 위치 primary의 suppressed[0] 첫 번째 자원 first를 다음 close 발생하는 예외 Exception("close-first") catch에서의 위치 primary의 suppressed[1] 자원 정리 뒤 catch로 전달 발생하는 예외 본문의 IllegalStateException("body") catch에서의 위치 primary 자체 · 정리 예외로 교체되지 않음 두 자원 생성이 성공하고 본문이 예외를 던지는 원문의 경로입니다. 자원 생성 실패나 본문 정상 완료일 때의 주 예외 선택과 구분합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
본문 실패

작업 본문이 IllegalStateException을 던집니다.

  • 주 예외: IllegalStateException
  • suppressed: []
  • 본문 예외를 유지한 채 자원 정리에 진입한다.
둘째 자원 닫기

second.close의 실패를 주 예외에 붙입니다.

  • 정리 대상: second
  • suppressed[0]: close-second
  • 정리 실패가 본문 주 예외를 덮어쓰지 않는다.
첫째 자원 닫기

first.close의 실패도 뒤에 붙이고 catch로 나갑니다.

  • 정리 대상: first
  • suppressed[1]: close-first
  • 주 예외: 본문 IllegalStateException 유지
  • 역순의 두 정리 실패가 각각 보존된다.

본문 예외를 유지한 채 자원을 역순으로 닫는다

본문 예외를 주 예외로 유지하며 두 자원을 선언 역순으로 닫아 suppressed[0], suppressed[1]을 붙입니다. 예외 목록이 쌓이는 실제 전환을 보여 줄 수 있습니다.

본문 예외를 유지한 채 자원을 역순으로 닫는다

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

자원 정리 · 역순과 예외 보존

본문 예외를 유지한 채 자원을 역순으로 닫는다

second와 first의 close 실패를 억제 예외에 보관하고, 본문의 body 예외를 주 예외로 전달합니다.

역순 close 실패는 본문 주 예외에 붙는다

역순 close 실패는 본문 주 예외에 붙는다본문 body 예외를 유지한다. second.close를 먼저, first.close를 다음에 시도하고 각각 close-second와 close-first를 suppressed 0·1로 붙인다.주 예외 bodyIllegalStateException1. secondclose 시도2. firstclose 시도억제 [0]second억제 [1]first
역순 close 실패는 본문 주 예외에 붙는다본문 body 예외를 유지한다. second.close를 먼저, first.close를 다음에 시도하고 각각 close-second와 close-first를 suppressed 0·1로 붙인다.주 예외 bodyIllegalStateException1. secondclose 시도2. firstclose 시도억제 [0]second억제 [1]first

본문 body 예외를 유지한다. second.close를 먼저, first.close를 다음에 시도하고 각각 close-second와 close-first를 suppressed 0·1로 붙인다.

상태별 상세 해설
  1. 두 번째 자원 second를 먼저 close

    발생하는 예외
    Exception("close-second")
    catch에서의 위치
    primary의 suppressed[0]
  2. 첫 번째 자원 first를 다음 close

    발생하는 예외
    Exception("close-first")
    catch에서의 위치
    primary의 suppressed[1]
  3. 자원 정리 뒤 catch로 전달

    발생하는 예외
    본문의 IllegalStateException("body")
    catch에서의 위치
    primary 자체 · 정리 예외로 교체되지 않음

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

이 예의 조건

  • 자원은 선언 역순으로 닫는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
5second와 first의 close 실패를 억제 예외로 보관하고, 본문의 body 예외를 주 예외로 전달합니다.
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보본문 예외를 유지한 채 자원을 역순으로 닫는다 본문 예외를 유지한 채 자원을 역순으로 닫는다 SuppressedCloseOrderSolution의 두 자원과 body 예외를 기준으로 close 및 suppressed 순서를 보여줍니다. 자원 정리 · 역순과 예외 보존 본문 예외를 유지한 채 자원을 역순으로 닫는다 second와 first의 close 실패를 억제 예외로 보관하고, 본문의 body 예외를 주 예외로 전달합니다. 두 번째 자원 second를 먼저 close 발생하는 예외 Exception("close-second") catch에서의 위치 primary의 suppressed[0] 첫 번째 자원 first를 다음 close 발생하는 예외 Exception("close-first") catch에서의 위치 primary의 suppressed[1] 자원 정리 뒤 catch로 전달 발생하는 예외 본문의 IllegalStateException("body") catch에서의 위치 primary 자체 · 정리 예외로 교체되지 않음 두 자원 생성이 성공하고 본문이 예외를 던지는 원문의 경로입니다. 자원 생성 실패나 본문 정상 완료일 때의 주 예외 선택과 구분합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
본문 실패

작업 본문이 IllegalStateException을 던집니다.

  • 주 예외: IllegalStateException
  • suppressed: []
  • 본문 예외를 유지한 채 자원 정리에 진입한다.
둘째 자원 닫기

second.close의 실패를 주 예외에 붙입니다.

  • 정리 대상: second
  • suppressed[0]: close-second
  • 정리 실패가 본문 주 예외를 덮어쓰지 않는다.
첫째 자원 닫기

first.close의 실패도 뒤에 붙이고 catch로 나갑니다.

  • 정리 대상: first
  • suppressed[1]: close-first
  • 주 예외: 본문 IllegalStateException 유지
  • 역순의 두 정리 실패가 각각 보존된다.

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


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

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

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

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