본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
25장 : TCP 네트워크

네트워크 자원과 예외 보존

AutoCloseable 선언 역순, 핵심 예외와 `close` 예외의 억제됨 관계, 외부에서 받은 Socket의 소유권, 운영체제별 단절 표현을 이용해 네트워크 자원 코드를 단순화합니다.

수동 finally는 자원마다 null 검사와 중첩 catch가 필요하고 원래 예외를 보존하는 코드가 길어집니다. try-with-resources는 AutoCloseable 자원을 선언된 순서의 반대로 닫고, 본문 실패를 주 예외로 유지하면서 close 실패를 억제됨으로 붙입니다. Socket과 입출력 스트림 모두 AutoCloseable이므로 세션 경계에 바로 적용할 수 있습니다.

간결함보다 더 중요한 장점은 언어가 예외 우선순위를 일관되게 처리한다는 점입니다. 원인 분석에 필요한 업무 실패를 close 오류가 덮지 않고, 뒤 자원 close가 실패해도 앞 자원의 close를 계속 시도합니다. 로그와 오류 번역에서 getSuppressed()를 함께 보아야 이 정보가 실제로 활용됩니다.

수동 finally 중복

네트워크 양쪽에 유사한 null 검사와 close 순서를 복사하면 한쪽 수정이 다른 쪽에 반영되지 않습니다. 출력 flush 추가나 예외 로깅이 서로 달라져 종료 동작이 환경마다 달라집니다. 아래 코드는 일부 자원 생성 실패와 close 중 예외를 직접 해결해야 하는 전형적인 구조입니다.

bad/DuplicatedManualCleanup.java
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.Socket;

public final class DuplicatedManualCleanup {
    static void exchange(Socket socket) throws Exception {
        DataInputStream input = null;
        DataOutputStream output = null;
        try {
            input = new DataInputStream(socket.getInputStream());
            output = new DataOutputStream(socket.getOutputStream());
            output.writeUTF("request");
            System.out.println(input.readUTF());
        } finally {
            if (output != null) output.close();
            if (input != null) input.close();
            socket.close();
        }
    }

    public static void main(String[] args) {
        System.out.println("첫 close 예외가 뒤 정리를 막을 수 있습니다");
    }
}

try-with-resources는 이 보일러플레이트를 자원 선언부로 바꿉니다. 단, 선언만 옮긴다고 소유권이 자동 결정되지는 않습니다. 메서드가 인자로 받은 Socket을 닫아도 된다는 규칙이 있을 때만 try 자원으로 넣습니다.

외부 소켓의 소유권 인수

Java 9부터 final 또는 effectively final 지역 변수를 try 괄호에 직접 넣을 수 있습니다. acceptor가 세션에 Socket 소유권을 넘겼다면 세션은 try (socket; input; output) 형태로 모두 닫을 수 있습니다. 선언의 역순을 고려해 Socket을 먼저 선언하고 그 위의 스트림을 뒤에 선언합니다.

src/TryResourceSession.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 TryResourceSession {
    static String handle(Socket socket) throws Exception {
        try (socket;
             var input = new DataInputStream(socket.getInputStream());
             var output = new DataOutputStream(socket.getOutputStream())) {
            String request = input.readUTF();
            output.writeUTF("ok:" + request);
            return request;
        }
    }

    public static void main(String[] args) throws Exception {
        try (var server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
             var executor = Executors.newSingleThreadExecutor()) {
            var handled = executor.submit(() -> handle(server.accept()));
            try (var client = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort());
                 var input = new DataInputStream(client.getInputStream());
                 var output = new DataOutputStream(client.getOutputStream())) {
                output.writeUTF("hello");
                System.out.println(input.readUTF());
            }
            System.out.println("handled=" + handled.get());
        }
    }
}

세션 처리 중 예외가 나도 산출물, 입력, 소켓 순으로 닫힙니다. Data 스트림 close가 아래 Socket까지 닫을 수 있으므로 이후 close는 이미 닫힌 자원에 대한 멱등 동작이 됩니다. 명시적 세 선언은 각 래퍼의 수명과 순서를 보여 주지만 프로젝트 규칙에 따라 가장 바깥 래퍼 하나만 소유하는 방식도 가능합니다.

주 예외와 억제 예외 기록

예외 메시지 한 줄만 남기면 닫기 과정의 추가 실패를 잃습니다. 주 예외의 타입, 메시지, 단계와 억제됨의 타입을 배열로 기록합니다. 억제됨이 다시 억제됨 또는 원인을 가질 수 있지만 무한 순환과 로그 폭주를 막도록 깊이와 개수를 제한합니다.

src/SuppressedExceptionReport.java
import java.util.Arrays;

public final class SuppressedExceptionReport {
    static final class Failing implements AutoCloseable {
        private final String name;
        Failing(String name) { this.name = name; }
        @Override public void close() throws Exception { throw new Exception("close " + name); }
    }

    static void run() throws Exception {
        try (var first = new Failing("first");
             var second = new Failing("second")) {
            throw new IllegalStateException("read failed");
        }
    }

    public static void main(String[] args) {
        try {
            run();
        } catch (Exception primary) {
            System.out.println("primary=" + primary.getClass().getSimpleName());
            System.out.println("message=" + primary.getMessage());
            Arrays.stream(primary.getSuppressed())
                    .forEach(value -> System.out.println("suppressed=" + value.getMessage()));
        }
    }
}

본문이 예외 없이 끝나고 close만 실패하면 역순 첫 실패가 주 예외가 되고 나머지 close 실패가 억제됨이 됩니다. 이 차이를 장애 분류에 반영합니다. 쓰기 성공 뒤 flush·close가 실패했다면 호출자에게 저장 성공을 반환해서는 안 됩니다.

EOF와 연결 재설정의 차이

상대가 TCP 종료 절차를 통해 송신 방향을 닫으면 입력은 EOF를 반환합니다. DataInputStream의 readUTF처럼 고수준 메서드는 필요한 바이트를 얻지 못해 EOFException을 던질 수 있습니다. 상대 프로세스 강제 종료, 읽지 않은 데이터가 남은 상태의 close, 중간 장비 개입에서는 RST가 발생해 SocketException으로 보일 수 있습니다.

운영체제에 따라 메시지는 Connection reset, An existing connection was forcibly closed처럼 다릅니다. 메시지 문자열을 정확히 비교해 정상 종료를 판단하지 않습니다. 프로토콜이 기대한 exit 또는 완전한 응답을 받았는지, 어느 단계에서 예외 타입이 났는지로 업무 결과를 정합니다.

정상적인 클라이언트 이탈도 서버 로그에서 IOException이 될 수 있으므로 무조건 오류 알림을 울리지 않습니다. 요청 처리 중 응답 일부가 사라졌다면 실패 지표에 포함하고, 유휴 연결의 자연 종료라면 정보 수준으로 낮출 수 있습니다. 같은 예외 타입도 세션 상태에 따라 의미가 달라집니다.

장수 세션의 자원 관리

세션 자원이 메서드 하나의 렉시컬 범위를 넘거나 서버 shutdown이 다른 스레드에서 close해야 한다면, 객체 자체가 AutoCloseable을 구현하고 내부에 멱등 close를 둡니다. run 메서드는 try (this)처럼 자기 수명을 감싸거나 finally에서 close()를 호출할 수 있습니다. 수명 관리자는 활성 객체를 등록하고 종료 시 close합니다.

등록과 정리 사이 경쟁을 막으려면 “등록 완료 뒤 세션 시작” 순서를 지키고 finally에서 반드시 제거합니다. shutdown이 먼저 시작됐다면 새 등록을 거부하고 받은 Socket을 즉시 닫습니다. 이 상태 머신은 다음 문서의 세션 관리자에서 구체화합니다.

적용 판단표

자원 형태권장 구조이유
한 메서드 안의 클라이언트try-with-resources범위가 명확함
연결별 서버 세션세션 runtrySocket 소유권 이전
공유 ServerSocket서버 수명 관리자전체 accept 수명
빌린 스트림선언하지 않음호출자가 닫음
장수 세션 객체AutoCloseable + 멱등 close외부 shutdown

연습 문제

같은 두 자원을 사용해 한 번은 본문이 성공하고 한 번은 본문도 실패하게 실행하세요. 두 경우의 주 예외와 억제됨 개수를 결과 record로 반환합니다.

정답과 해설
exercise/CloseFailureMatrixSolution.java
public final class CloseFailureMatrixSolution {
    record Result(String primary, int suppressed) {}
    static final class Broken implements AutoCloseable {
        private final String name;
        Broken(String name) { this.name = name; }
        @Override public void close() throws Exception { throw new Exception(name); }
    }

    static Result execute(boolean bodyFails) {
        try (var first = new Broken("first-close");
             var second = new Broken("second-close")) {
            if (bodyFails) throw new IllegalStateException("body");
            return new Result("unreachable", 0);
        } catch (Exception error) {
            return new Result(error.getMessage(), error.getSuppressed().length);
        }
    }

    public static void main(String[] args) {
        System.out.println("bodySuccess=" + execute(false));
        System.out.println("bodyFailure=" + execute(true));
    }
}

본문 성공에서는 역순으로 먼저 닫힌 second-close가 주 예외이고 first-close 하나가 억제됨입니다. 본문 실패에서는 본문이 주 예외이고 두 close 실패가 모두 억제됨으로 보존됩니다.

네트워크 자원 범위를 승인하는 조건

try-with-resources를 적용한 네트워크 코드는 짧아질 뿐 아니라 예외 정보의 우선순위가 정확해집니다. Socket 소유권을 넘기는 지점, 선언 역순, EOF와 초기화의 다른 의미, 억제됨 로깅을 확인하면 플랫폼별 단절에도 같은 자원 규칙을 유지할 수 있습니다.