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

안동민 개발노트

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

소켓 자원 정리

네트워크 `read` 실패와 클라이언트 강제 종료에서도 Socket·InputStream·OutputStream을 누수 없이 닫고 `close` 실패가 원래 예외를 가리지 않게 만드는 수동 정리 구분을 설계합니다.

서버 프로세스는 여러 날 살아 있으므로 연결 하나의 누수가 반복되면 파일 디스크립터와 메모리가 고갈됩니다. 정상적인 exit 메시지 뒤에만 close를 놓으면 클라이언트 프로세스 종료, 네트워크 단절, 파서 오류에서 정리 코드에 도달하지 못합니다. 자원 획득과 사용 전체를 try로 감싸고 정리를 finally에 두어야 합니다.

연결된 스트림은 Socket에서 만들어졌지만 각각 버퍼와 상태를 가질 수 있습니다. 보통 가장 바깥 출력과 입력을 닫고 마지막에 Socket을 닫습니다. Socket close가 하위 스트림까지 닫는 구현 효과가 있어도 소유한 각 계층을 명시적으로 정리하면 독립 버퍼의 flush와 오류 관찰 위치가 분명해집니다.

정상 루프 뒤 close에 따른 누수

readUTF()는 상대가 정상 종료해도 EOFException을 던질 수 있고 강제 종료에서는 SocketException이 발생할 수 있습니다. 아래 구조는 예외가 나면 마지막 세 close를 모두 건너뜁니다. 짧은 명령줄 클라이언트에서는 프로세스 종료가 정리해 주는 것처럼 보이지만 장기 서버에서는 연결마다 누적됩니다.

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

public final class CloseAfterReadLoop {
    static void handle(Socket socket) throws Exception {
        var input = new DataInputStream(socket.getInputStream());
        var output = new DataOutputStream(socket.getOutputStream());
        while (true) {
            String message = input.readUTF();
            if (message.equals("exit")) break;
            output.writeUTF(message);
        }
        input.close();
        output.close();
        socket.close();
    }

    public static void main(String[] args) {
        System.out.println("read 예외가 나면 마지막 정리 구간을 통과하지 않습니다");
    }
}

예외를 catch해서 로그만 남긴 뒤에도 finally는 실행됩니다. 핵심은 catch 유무가 아니라 정리 구간이 제어 흐름과 독립적이라는 점입니다. 메서드가 정상 반환, 조기 return, 검사 예외, 런타임 예외 중 어느 경로로 끝나도 같은 소유 자원을 닫아야 합니다.

closeAll의 독립 오류 처리

자원 생성도 중간에 실패할 수 있습니다. Socket은 만들어졌지만 입력 스트림 생성에서 예외가 나면 출력 변수는 null입니다. finally는 생성된 것만 닫아야 합니다. 한 close 실패 때문에 다음 자원 정리가 중단되지 않도록 호출마다 예외를 격리합니다.

src/ManualNetworkClose.java
import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

public final class ManualNetworkClose {
    static List<IOException> closeAll(Closeable... resources) {
        List<IOException> failures = new ArrayList<>();
        for (Closeable resource : resources) {
            if (resource == null) continue;
            try {
                resource.close();
            } catch (IOException error) {
                failures.add(error);
            }
        }
        return List.copyOf(failures);
    }

    static final class Tracked implements Closeable {
        private final String name;
        Tracked(String name) { this.name = name; }
        @Override public void close() throws IOException {
            System.out.println("close=" + name);
        }
    }

    public static void main(String[] args) {
        System.out.println("failures=" + closeAll(
                new Tracked("output"), new Tracked("input"), new Tracked("socket")).size());
    }
}

인자 순서는 바깥 출력, 바깥 입력, Socket처럼 원하는 정리 순서로 넘깁니다. close 실패를 무조건 버리면 실제 flush 오류를 놓칠 수 있으므로 주 작업이 성공했는지에 따라 반환·로그·억제됨 결합 정책을 정합니다. 이 수동 도구는 try-with-resources가 적용되기 어려운 수명 구조를 이해하기 위한 것이며 일반적인 지역 자원에는 언어 기능이 더 안전합니다.

수신 소켓의 종료 책임

accept 루프가 Socket을 만들지만 연결별 세션 객체에 소유권을 넘길 수 있습니다. 넘긴 뒤 acceptor는 닫지 않고 세션이 finally에서 닫습니다. 작업 제출이 거부되거나 세션 객체 생성이 실패하면 소유권 이전이 완료되지 않았으므로 acceptor가 즉시 Socket을 닫아야 합니다.

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

public final class FinallyEchoSession {
    static void session(Socket socket) {
        DataInputStream input = null;
        DataOutputStream output = null;
        try {
            input = new DataInputStream(socket.getInputStream());
            output = new DataOutputStream(socket.getOutputStream());
            String message = input.readUTF();
            output.writeUTF("reply:" + message);
            output.flush();
        } catch (IOException error) {
            System.err.println("session=" + error.getClass().getSimpleName());
        } finally {
            ManualNetworkClose.closeAll(output, input, socket);
        }
    }

    public static void main(String[] args) throws Exception {
        try (var server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
             var executor = Executors.newSingleThreadExecutor()) {
            var accepted = executor.submit(() -> {
                try {
                    session(server.accept());
                } catch (IOException error) {
                    throw new RuntimeException(error);
                }
            });
            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());
            }
            accepted.get();
        }
    }
}

예제의 ManualNetworkClose는 같은 교재의 앞 코드 파일과 함께 컴파일해야 하므로 실제 추출 검증에서는 두 클래스를 한 디렉터리에 둡니다. 독립 배포 코드에서는 close 도구를 공용 패키지에 두거나 try-with-resources로 대체합니다. 세션 catch가 연결 단위 오류를 처리하므로 한 클라이언트의 종료가 acceptor 전체를 끝내지 않습니다.

서버·세션 소켓의 수명

리스너 ServerSocket은 서버 전체 수명 동안 유지되고 accept 루프를 소유합니다. 연결 Socket은 개별 세션 동안만 존재합니다. 세션 종료 때 리스너를 닫으면 다른 클라이언트 접속까지 중단되고, 서버 종료 때 리스너만 닫으면 이미 수락한 세션은 read에서 계속 기다릴 수 있습니다.

서버 수명 관리자는 리스너와 활성 세션 목록을 별도로 보관합니다. 정상 종료에서는 새 accept를 막기 위해 리스너를 먼저 닫고, 등록된 세션에 종료 신호를 보내 read 블로킹을 깨우며, 세션 작업자 종료를 제한 시간 동안 기다립니다. 종료 경쟁으로 같은 Socket close가 두 번 호출될 수 있으므로 세션 close는 멱등적으로 설계합니다.

close 예외와 주 예외

수동 finally에서 output.close()가 던진 예외를 그대로 다시 던지면 try 본문의 read 실패가 사라질 수 있습니다. 원래 Throwable을 보관하고 close 오류를 addSuppressed로 붙이는 방법이 있지만 구현이 복잡합니다. 이것이 try-with-resources가 단순 자동 close 이상의 기능인 이유입니다.

네트워크 로그에는 연결 ID, 원격 주소, 처리 단계, 주 예외 종류를 남깁니다. close 실패는 부가 원인으로 연결하되 같은 단절에서 입력·산출물·소켓이 모두 유사 예외를 낼 수 있어 알림 중복을 줄입니다. 오류 메시지는 운영체제마다 달라질 수 있으므로 문자열보다 예외 타입과 단계로 분류합니다.

수동 정리 판단표

상황소유자정리 위치
accept 직후 제출 성공세션세션 finally
작업 제출 실패acceptor즉시 close
클라이언트 지역 Socket클라이언트 메서드지역 finally
서버 리스너서버 수명 관리자shutdown
빌린 OutputStream호출자변환 함수가 닫지 않음

연습 문제

여러 스레드가 동시에 호출해도 실제 close 순서를 한 번만 수행하는 SessionResources를 작성하세요. 출력·입력·Socket을 역순으로 닫고 각 IOException을 목록으로 반환합니다.

정답과 해설
exercise/IdempotentSessionCloseSolution.java
import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;

public final class IdempotentSessionCloseSolution {
    static final class SessionResources implements AutoCloseable {
        private final List<Closeable> order;
        private final AtomicBoolean closed = new AtomicBoolean();
        private List<IOException> failures = List.of();

        SessionResources(Closeable output, Closeable input, Closeable socket) {
            order = List.of(output, input, socket);
        }

        @Override public void close() {
            if (!closed.compareAndSet(false, true)) return;
            List<IOException> found = new ArrayList<>();
            for (Closeable resource : order) {
                try { resource.close(); }
                catch (IOException error) { found.add(error); }
            }
            failures = List.copyOf(found);
        }

        List<IOException> failures() { return failures; }
    }

    public static void main(String[] args) {
        Closeable tracked = () -> System.out.println("closed once per slot");
        var resources = new SessionResources(tracked, tracked, tracked);
        resources.close();
        resources.close();
        System.out.println(resources.failures());
    }
}

이 구현은 세 슬롯이 같은 객체라 세 번 호출되지만 두 번째 SessionResources.close 전체는 무시됩니다. 실제 세션에서는 서로 다른 래퍼를 넘깁니다. close 결과를 다른 스레드가 즉시 읽어야 한다면 실패 목록 공개의 메모리 가시성도 보완합니다.

수동 네트워크 정리의 종료 기준

finally 기반 정리는 예외가 어디에서 나도 소유한 자원을 모두 닫는 최소 안전망입니다. 자원 생성의 부분 성공, 역순 close, 각 정리 실패의 격리, 리스너와 세션의 다른 수명을 확인하면 클라이언트가 예고 없이 사라져도 서버 자원이 누적되지 않습니다.