안동민 개발노트

본문 시작

소켓 자원 정리

네트워크 읽기 실패와 연결 종료에 대응해 소켓·스트림의 정리 범위를 정하고, 정리 실패와 원래 예외를 함께 다루는 방법을 살펴봅니다.

서버 프로세스는 여러 날 살아 있으므로 연결 하나의 누수가 반복되면 파일 디스크립터와 메모리가 고갈됩니다.

정상적인 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는 생성된 것만 닫아야 합니다.

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());
    }
}
closeAll이 다음 자원으로 넘어가는 조건을 구분한다

ManualNetworkClose.closeAll의 인자 순서, null 처리와 IOException catch 범위를 나타냅니다.

closeAll이 다음 자원으로 넘어가는 조건을 구분한다
현재 인자의 결과closeAll의 처리뒤 인자의 정리
null건너뜀계속 진행
close 정상 반환실패 목록에 추가하지 않음계속 진행
IOException 발생실패 목록에 보관계속 진행
RuntimeException 또는 Error 발생이 catch의 대상이 아님그 자리에서 전파되어 뒤 정리를 건너뛸 수 있음
null
closeAll의 처리: 건너뜀
뒤 인자의 정리: 계속 진행
close 정상 반환
closeAll의 처리: 실패 목록에 추가하지 않음
뒤 인자의 정리: 계속 진행
IOException 발생
closeAll의 처리: 실패 목록에 보관
뒤 인자의 정리: 계속 진행
RuntimeException 또는 Error 발생
closeAll의 처리: 이 catch의 대상이 아님
뒤 인자의 정리: 그 자리에서 전파되어 뒤 정리를 건너뛸 수 있음

main의 Tracked는 예외를 던지지 않습니다. FinallyEchoSession은 반환받은 실패 목록을 기록하거나 주 예외에 합치지 않고 버립니다.

인자 순서는 바깥 출력, 바깥 입력, 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로 대체합니다.

세션은 IOException을 잡아 기록합니다. 이 main은 한 연결만 처리하며 다음 연결을 다시 수락하는 루프는 없습니다.


서버·세션 소켓의 수명

리스너 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·입력·출력 순서로 획득한 상황을 가정해 출력·입력·Socket 순서로 닫고, 수집한 IOException 목록을 failures()로 확인하게 합니다.

정답과 해설
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());
    }
}
close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다

IdempotentSessionCloseSolution의 CAS, 자원 정리, failures 필드 공개 순서를 구분합니다.

close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다
호출 지점현재 구현의 동작완료·공개의 의미
첫 compareAndSet 성공closed를 true로 바꾸고 세 슬롯 정리 시작true는 정리 완료 표시가 아님
다른 close의 compareAndSet 실패즉시 반환먼저 실행 중인 정리가 끝날 때까지 기다리지 않음
정리한 스레드의 failures 대입수집한 IOException 목록을 기록같은 스레드가 close 반환 뒤 읽으면 결과를 볼 수 있음
다른 스레드의 failures 호출일반 필드를 읽음완료 대기와 별도 공개 규칙이 없으므로 최신 결과 보장 없음
첫 compareAndSet 성공
현재 구현의 동작: closed를 true로 바꾸고 세 슬롯 정리 시작
완료·공개의 의미: true는 정리 완료 표시가 아님
다른 close의 compareAndSet 실패
현재 구현의 동작: 즉시 반환
완료·공개의 의미: 먼저 실행 중인 정리가 끝날 때까지 기다리지 않음
정리한 스레드의 failures 대입
현재 구현의 동작: 수집한 IOException 목록을 기록
완료·공개의 의미: 같은 스레드가 close 반환 뒤 읽으면 결과를 볼 수 있음
다른 스레드의 failures 호출
현재 구현의 동작: 일반 필드를 읽음
완료·공개의 의미: 완료 대기와 별도 공개 규칙이 없으므로 최신 결과 보장 없음

main은 같은 Closeable을 세 슬롯에 넣고 close를 순서대로 두 번 호출합니다. 슬롯별 호출은 세 번이며 두 번째 전체 호출은 생략됩니다. 여러 스레드의 동시 호출을 실행하는 main은 아닙니다.


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

finally는 본문을 빠져나갈 때 정리를 시도할 위치를 마련합니다. 각 정리 호출이 실패한 뒤에도 다음 자원을 닫을 수 있는지는 정리 코드의 예외 처리 범위에 달려 있습니다.

자원 생성의 부분 성공, 역순 close, 각 정리 실패의 격리, 리스너와 세션의 다른 수명을 점검해 각 세션이 획득한 자원의 정리 경로를 확인합니다.