본문 바로가기

finally로 소켓 자원 정리하기

예외가 나도 소켓과 스트림이 빠짐없이 닫히도록 finally로 정리하는 방법을 익히고, 닫는 순서와 소켓을 닫을 쪽, 정리하다 난 예외를 다루는 방법을 살펴봅니다.

서버 프로그램은 한 번 켜면 며칠, 몇 달씩 돌아갑니다. 그동안 연결을 하나 처리할 때마다 소켓을 하나씩 닫지 않고 남긴다면 어떻게 될까요? 열린 소켓은 운영체제의 파일 디스크립터(file descriptor)라는 자원을 하나씩 차지하는데, 한 프로세스가 동시에 가질 수 있는 파일 디스크립터 수에는 한도가 있습니다. 새어 나간 소켓이 쌓여 한도에 닿으면 accept()가 Too many open files 같은 메시지의 예외로 실패하고, 서버는 살아 있는데도 새 클라이언트를 받지 못합니다.

짧게 실행되고 끝나는 명령줄 프로그램에서는 이런 누수가 잘 드러나지 않습니다. 프로세스가 끝나면 운영체제가 남은 자원을 모두 회수하기 때문입니다. 하지만 서버는 끝나지 않으므로 같은 누수가 연결마다 쌓입니다.

앞 글의 예제는 try-with-resources로 이 문제를 피했습니다. 이 글에서는 같은 일을 finally로 직접 해 보면서, 자원을 빠짐없이 닫으려면 무엇을 챙겨야 하는지 살펴봅니다. 손으로 해 보면 try-with-resources가 대신해 주는 일이 무엇인지 분명해지고, 세션 객체처럼 한 메서드 안에서 열고 닫을 수 없는 자원을 다룰 때도 같은 원칙을 쓸 수 있습니다.

정상 흐름의 끝에서만 닫으면 생기는 누수

다음 handle()은 클라이언트가 exit를 보낼 때까지 받은 메시지를 돌려보내고, 반복이 끝나면 스트림과 소켓을 닫습니다. 정상적인 대화라면 문제가 없어 보입니다. main은 클라이언트가 exit를 보내지 않고 연결을 끊는 상황을 만든 뒤, 서버 쪽 소켓이 닫혔는지 확인합니다. handle()이 소켓을 닫는지 보려는 것이므로, 서버 쪽 소켓 accepted는 일부러 try-with-resources에 넣지 않았습니다.

bad/CloseAfterReadLoop.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;

public final class CloseAfterReadLoop {
    static void handle(Socket socket) throws IOException {
        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) throws Exception {
        InetAddress loopback = InetAddress.getLoopbackAddress();
        try (var server = new ServerSocket(0, 50, loopback)) {
            Socket client = new Socket(loopback, server.getLocalPort());
            Socket accepted = server.accept();
            client.close(); // exit를 보내지 않고 연결을 끊는다

            try {
                handle(accepted);
            } catch (IOException e) {
                System.out.println("handle 예외: " + e.getClass().getSimpleName());
            }
            System.out.println("서버 쪽 소켓이 닫혔는가: " + accepted.isClosed());
        }
    }
}

첫 줄은 handle()이 예외로 끝났을 때만 찍힙니다. 둘째 줄은 handle()이 돌아온 뒤 서버 쪽 소켓이 닫혔는지를 isClosed()로 확인한 값입니다.

handle 예외: EOFException
서버 쪽 소켓이 닫혔는가: false

클라이언트가 연결을 닫으면 서버 쪽 입력 스트림은 더 읽을 바이트가 없는 끝, 즉 EOF(end of file)에 도달합니다. readUTF()는 길이를 나타내는 2바이트조차 읽지 못하므로 EOFException을 던지고, 이 예외는 반복문을 빠져나가 handle() 밖으로 전파됩니다. 그 결과 마지막 세 줄의 close()는 한 번도 실행되지 않았고, 두 번째 출력처럼 서버 쪽 소켓은 열린 채 남았습니다. 클라이언트 프로세스가 강제로 종료되거나 네트워크가 끊긴 경우라면 EOFException 대신 SocketException이 날 수 있지만, 정리 코드를 건너뛰는 것은 마찬가지입니다.

클라이언트가 연결을 끊는 일은 서버 입장에서 전혀 특별한 사건이 아닙니다. 사용자가 프로그램을 그냥 닫거나, 노트북 덮개를 덮거나, 이동 중에 신호가 끊기는 일은 매일 일어납니다. 오래 도는 서버에서 이런 연결 하나하나가 소켓을 하나씩 남기면, 결국 앞에서 말한 파일 디스크립터 한도에 닿습니다.

finally로 옮겨 어떤 경로로 끝나도 닫기

정리 코드가 실행될지 말지가 제어 흐름에 따라 달라져서는 안 됩니다. 그래서 자원을 사용하는 코드 전체를 try로 감싸고, 닫는 코드는 finally에 둡니다. finally 블록은 try가 정상적으로 끝나든, break나 return으로 빠져나가든, 검사 예외나 비검사 예외로 끝나든 항상 실행됩니다. 예외를 catch로 잡아 로그만 남기는 경우에도 catch 다음에 실행되므로, 중요한 것은 catch가 있느냐가 아니라 정리 코드가 어떤 경로에서든 실행되느냐입니다. 앞 예제의 handle()만 다음과 같이 바꾸고 같은 main으로 실행해 보겠습니다.

static void handle(Socket socket) throws IOException {
    try {
        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);
        }
    } finally {
        socket.close(); // 어떤 경로로 끝나든 실행된다
    }
}

main은 그대로이므로 클라이언트가 끊는 상황도, 출력 형식도 같습니다. 달라질 수 있는 것은 둘째 줄의 isClosed() 값뿐입니다.

handle 예외: EOFException
서버 쪽 소켓이 닫혔는가: true

readUTF()는 앞에서와 똑같이 EOFException을 던졌지만, 예외가 handle() 밖으로 나가기 전에 finally가 실행되어 소켓이 닫혔습니다. 이때 소켓 하나만 닫았는데도 충분했던 데는 이유가 있습니다. Socket.close()는 그 소켓에서 얻은 입력 스트림과 출력 스트림도 함께 닫습니다. 게다가 DataInputStream과 DataOutputStream은 따로 쥐고 있는 바이트가 없으므로, 소켓을 바로 닫아도 잃는 데이터가 없습니다. 하지만 출력 스트림 중간에 버퍼가 끼어 있다면 이야기가 달라집니다.

버퍼가 있는 출력 스트림은 소켓보다 먼저 닫기

성능을 위해 출력 스트림을 BufferedOutputStream으로 감싸면, writeUTF()로 쓴 바이트는 곧바로 전송되지 않고 버퍼에 모입니다. 버퍼의 내용은 flush()를 호출하거나, 버퍼가 가득 차거나, 스트림을 닫을 때 비로소 소켓으로 넘어갑니다. 이 상태에서 소켓부터 닫으면 어떻게 되는지 다음 프로그램으로 비교해 보겠습니다. 서버 쪽에서 bye를 쓴 뒤, 한 번은 소켓을 먼저 닫고 한 번은 출력 래퍼를 먼저 닫습니다.

src/BufferedCloseOrder.java
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;

public final class BufferedCloseOrder {
    static void run(boolean socketFirst) throws IOException {
        InetAddress loopback = InetAddress.getLoopbackAddress();
        try (var server = new ServerSocket(0, 50, loopback);
             var client = new Socket(loopback, server.getLocalPort());
             var accepted = server.accept()) {
            var output = new DataOutputStream(
                    new BufferedOutputStream(accepted.getOutputStream()));
            output.writeUTF("bye"); // 아직 버퍼 안에 있다

            if (socketFirst) {
                accepted.close();
                try {
                    output.close();
                } catch (IOException e) {
                    System.out.println("output.close() 실패: " + e.getMessage());
                }
            } else {
                output.close(); // 버퍼를 비운 뒤 소켓까지 닫는다
            }

            var input = new DataInputStream(client.getInputStream());
            try {
                System.out.println("클라이언트가 받음: " + input.readUTF());
            } catch (EOFException e) {
                System.out.println("클라이언트가 받음: 없음(EOF)");
            }
        }
    }

    public static void main(String[] args) throws IOException {
        System.out.println("[소켓을 먼저 닫을 때]");
        run(true);
        System.out.println("[출력 래퍼를 먼저 닫을 때]");
        run(false);
    }
}

run(true)와 run(false)는 accepted.close()와 output.close() 중 무엇을 먼저 호출하느냐만 다릅니다. 두 경우 모두 클라이언트는 서버 쪽 소켓이 닫힌 뒤에 readUTF()로 응답을 한 번 읽어 보고, 읽지 못하면 EOFException을 잡아 없음(EOF)을 찍습니다.

[소켓을 먼저 닫을 때]
output.close() 실패: Socket closed
클라이언트가 받음: 없음(EOF)
[출력 래퍼를 먼저 닫을 때]
클라이언트가 받음: bye

소켓을 먼저 닫으면 버퍼에 있던 bye는 갈 곳을 잃습니다. 뒤늦게 호출한 output.close()는 닫기 전에 버퍼를 비우려고 소켓에 쓰다가 Socket closed 예외로 실패하고, 클라이언트는 응답 대신 연결의 끝만 받습니다. 반대로 가장 바깥의 출력 래퍼를 먼저 닫으면, close()가 버퍼의 내용을 먼저 내보낸 다음 안쪽 스트림을 차례로 닫습니다. 소켓의 출력 스트림을 닫으면 소켓도 함께 닫히므로, 이 경우에는 클라이언트가 bye를 받은 뒤에 연결이 끝납니다.

그래서 직접 닫을 때는 바깥 출력 래퍼, 바깥 입력 래퍼, 소켓 순서로 닫습니다. 자원을 만든 순서(소켓 다음에 스트림)의 역순이며, try-with-resources가 자원을 닫는 순서와도 같습니다. 이 예에서 보듯 출력 스트림의 close()는 마지막 데이터를 내보내다가 실패할 수 있습니다. 그 실패를 그냥 버리면 응답이 전달되지 않았다는 사실까지 함께 놓치게 됩니다.

여러 자원을 하나씩 닫는 closeAll

닫을 자원이 여러 개가 되면 finally 안에서 챙길 일이 두 가지 더 생깁니다. 하나는 자원 생성이 중간에 실패하는 경우입니다. 소켓은 받았지만 getInputStream()에서 예외가 나면 스트림 변수들은 아직 null이므로, finally는 실제로 만들어진 자원만 닫아야 합니다. 다른 하나는 close() 자체가 실패하는 경우입니다. finally에 output.close(), input.close(), socket.close()를 나란히 쓰면, 첫 번째 close()가 예외를 던지는 순간 나머지 두 줄은 실행되지 않습니다.

다음 closeAll()은 이 두 가지를 처리하는 작은 도구입니다. 넘겨받은 자원을 순서대로 닫되 null은 건너뛰고, 각 close()를 따로 try로 감싸서 하나가 IOException으로 실패해도 다음 자원으로 넘어갑니다. 실패한 예외는 버리지 않고 목록에 모아 돌려줍니다. main은 모두 정상적으로 닫히는 경우와, 가운데 자원이 실패하고 null이 섞인 경우를 차례로 보여 줍니다.

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());

        Closeable broken = () -> { throw new IOException("input close 실패"); };
        System.out.println("failures=" + closeAll(
                new Tracked("output"), broken, null, new Tracked("socket")));
    }
}

close= 줄은 Tracked만 찍으므로, 두 번째 호출에서 broken과 null은 출력에 나타나지 않습니다. closeAll()이 돌려준 목록은 main이 failures= 줄로 찍는데, 첫 호출은 개수만, 두 번째 호출은 목록 전체를 찍습니다.

close=output
close=input
close=socket
failures=0
close=output
close=socket
failures=[java.io.IOException: input close 실패]

첫 번째 호출에서는 세 자원이 넘긴 순서대로 닫히고 실패 목록은 비어 있습니다. 두 번째 호출에서는 가운데 자원이 IOException을 던졌는데도 뒤의 null을 건너뛰고 socket까지 닫았으며, 실패한 예외는 목록에 남았습니다. 인자는 앞 절에서 정한 대로 바깥 출력, 바깥 입력, 소켓 순서로 넘깁니다.

closeAll이 다음 자원으로 넘어가는 조건을 구분한다

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

수동 정리 · catch의 범위

closeAll이 다음 자원으로 넘어가는 조건을 구분한다

IOException은 모아 다음 자원으로 진행하지만, RuntimeException과 Error는 이 catch를 통과합니다.

null

closeAll의 처리
건너뜀
뒤 인자의 정리
계속 진행

close 정상 반환

closeAll의 처리
실패 목록에 추가하지 않음
뒤 인자의 정리
계속 진행

IOException 발생

closeAll의 처리
실패 목록에 보관
뒤 인자의 정리
계속 진행

RuntimeException 또는 Error 발생

closeAll의 처리
이 catch의 대상이 아님
뒤 인자의 정리
그 자리에서 전파되어 뒤 정리를 건너뛸 수 있음

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

도표에서 보듯 closeAll()이 잡는 것은 IOException뿐입니다. 어떤 자원의 close()가 RuntimeException이나 Error를 던지면 그 자리에서 closeAll() 밖으로 전파되어, 뒤에 남은 자원은 닫히지 않을 수 있습니다. Closeable.close()가 선언한 예외가 IOException이므로 대부분의 경우는 이것으로 충분하지만, 모든 실패를 막아 주는 도구는 아니라는 점을 기억해 두세요.

돌려받은 실패 목록을 어떻게 쓸지는 주 작업의 결과에 따라 정합니다. 주 작업이 성공했는데 출력 스트림을 닫다가 실패했다면 앞 절처럼 마지막 응답이 전달되지 않았을 수 있으므로, 성공으로 처리하지 말고 오류로 알려야 합니다. 주 작업이 이미 예외로 실패했다면, 정리 중의 실패는 그 예외에 덧붙이는 부가 정보로 기록합니다. 이런 수동 도구는 여러 메서드나 스레드에 걸쳐 사는 세션 객체처럼 try-with-resources를 쓰기 어려운 자원을 위한 것입니다. 한 메서드 안에서 열고 닫는 자원에는 언어 기능을 쓰는 편이 더 안전합니다.

소켓을 넘겨받은 세션이 닫는다

앞 글의 다중 클라이언트 서버에서 소켓을 만든 것은 accept()를 호출한 스레드였지만, 소켓을 닫은 것은 그 소켓을 넘겨받은 세션 작업이었습니다. 이처럼 어떤 자원을 닫을 의무가 누구에게 있는지를 그 자원의 소유권(ownership)이라고 합니다. 소켓을 세션에 넘긴 뒤에는 accept() 쪽에서 그 소켓을 닫지 않고, 세션이 자기 finally에서 닫습니다. 두 곳에서 모두 닫으려 하면 세션이 아직 쓰고 있는 소켓을 다른 스레드가 닫아 버릴 수 있고, 아무도 닫지 않으면 앞에서 본 누수가 생깁니다.

다음 예제의 session()은 try-with-resources 없이 finally와 closeAll()로 소켓을 정리합니다. input과 output을 try 바깥에서 null로 먼저 선언한 것은 finally에서도 이 변수들을 볼 수 있어야 하기 때문입니다. 스트림을 만들다가 실패하면 변수는 null인 채로 남고, closeAll()이 그것을 건너뜁니다.

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 클래스를 사용하므로 두 파일을 함께 컴파일합니다. FinallyEchoSession.java만 컴파일하면 ManualNetworkClose를 찾지 못해 cannot find symbol 오류가 납니다.

javac --release 25 -d out src/ManualNetworkClose.java src/FinallyEchoSession.java
java -cp out FinallyEchoSession

표준 출력에 찍는 것은 클라이언트가 받은 응답 한 줄뿐입니다. 세션에서 IOException이 났다면 session= 줄이 표준 오류에 따로 찍혔을 것입니다.

reply:hello

세션은 클라이언트가 보낸 hello를 읽고 reply:hello로 답했습니다. 읽기나 쓰기에서 IOException이 나면 세션은 예외 클래스 이름을 표준 오류에 기록하고, 어느 경우든 finally에서 출력, 입력, 소켓 순서로 닫습니다. 다만 이 예제는 closeAll()이 돌려준 실패 목록을 기록하지도, 주 예외에 합치지도 않고 버립니다. 실제 서버라면 앞 절에서 말한 기준으로 이 목록을 처리해야 합니다. 또 이 main은 연결 하나만 처리하며, 다음 연결을 다시 받는 반복문은 없습니다.

소유권을 넘기는 과정 자체가 실패할 수도 있습니다. 실행기가 이미 종료되어 작업 제출이 거부되거나 세션 객체를 만드는 도중 예외가 나면, 소켓은 세션에 전달되지 못합니다. 이때는 소유권이 넘어가지 않았으므로 accept()를 호출한 쪽이 즉시 닫아야 합니다. 앞 글의 acceptLoop()에 이 처리를 더하면 다음과 같습니다.

Socket socket = server.accept();
try {
    sessions.execute(() -> session(socket));
} catch (RejectedExecutionException e) {
    socket.close(); // 세션에 넘기지 못했으니 받은 쪽이 닫는다
}

execute()가 정상적으로 반환했다면 소켓은 이제 세션의 것이므로, 이 스레드는 그 소켓을 더 이상 건드리지 않습니다. 반대로 RejectedExecutionException이 났다면 세션 작업은 시작되지도 않았으므로, 여기서 닫지 않으면 아무도 그 소켓을 닫지 않습니다.

리스너 소켓과 세션 소켓의 수명

서버에는 수명이 다른 두 종류의 소켓이 있습니다. 연결을 기다리는 리스너 ServerSocket은 서버가 켜져 있는 동안 계속 살아 있으면서 accept() 반복을 맡고, accept()가 돌려준 세션 소켓은 연결 하나가 이어지는 동안만 존재합니다. 둘을 혼동하면 양쪽으로 문제가 생깁니다. 세션 하나가 끝날 때 리스너를 닫으면 다른 모든 클라이언트의 접속까지 막힙니다. 반대로 서버를 끝낼 때 리스너만 닫으면, 이미 연결된 세션들은 read에서 계속 기다립니다. 앞 글에서 본 것처럼 리스너를 닫아도 이미 수락한 소켓은 닫히지 않기 때문입니다.

그래서 서버의 수명을 관리하는 쪽은 리스너와 함께, 지금 살아 있는 세션의 목록을 따로 보관합니다. 서버를 정상적으로 끝낼 때는 먼저 리스너를 닫아 새 연결을 막습니다. 그다음 목록에 있는 세션들의 소켓을 닫아 read에서 기다리던 스레드를 깨우고, 세션 작업이 끝나기를 정해진 시간만큼 기다립니다. 다른 스레드가 소켓을 닫으면 그 소켓에서 블로킹 중이던 스레드에는 SocketException이 던져지므로, 세션은 이 예외를 받고 자기 정리를 시작합니다.

이 과정에는 경쟁이 하나 숨어 있습니다. 종료 절차가 세션의 소켓을 닫는 순간, 세션도 연결이 끊긴 것을 알아차리고 자기 finally에서 정리를 시작할 수 있습니다. Socket.close() 자체는 이미 닫힌 소켓에 다시 호출해도 아무 일도 하지 않지만, 세션의 정리에는 여러 자원 닫기, 실패 기록, 세션 목록에서 빼기 같은 일이 함께 들어 있습니다. 이것이 두 번 실행되면 같은 실패가 두 번 기록되는 식의 문제가 생기므로, 세션의 close()는 여러 스레드에서 여러 번 호출되어도 실제 정리는 한 번만 하도록 만듭니다. 이런 성질을 멱등성(idempotency)이라고 하며, 연습 1에서 직접 구현해 봅니다. 종료 절차 전체는 활성 세션 관리와 서버 종료와 accept 루프 멈추기와 단계적 서버 종료에서 다룹니다.

close의 예외가 원래 예외를 덮는 문제

finally로 정리할 때 마지막으로 남는 문제는 예외가 사라지는 것입니다. try 본문에서 예외가 난 상태로 finally가 실행되는데 finally 안의 close()까지 예외를 던지면, 자바는 본문의 예외를 버리고 close()의 예외를 던집니다. 다음 프로그램의 masked()가 그 경우이고, preserved()는 본문의 예외를 보관해 두었다가 close()의 예외를 거기에 붙이는 방법입니다.

src/MaskedFailure.java
import java.io.Closeable;
import java.io.IOException;

public final class MaskedFailure {
    static final Closeable BROKEN = () -> { throw new IOException("close 실패"); };

    // finally에서 close를 그냥 호출한다
    static void masked() throws IOException {
        try {
            throw new IOException("read 실패");
        } finally {
            BROKEN.close();
        }
    }

    // 본문 예외를 보관했다가 close 예외를 거기에 붙인다
    static void preserved() throws IOException {
        IOException primary = null;
        try {
            throw new IOException("read 실패");
        } catch (IOException e) {
            primary = e;
            throw e;
        } finally {
            try {
                BROKEN.close();
            } catch (IOException closeError) {
                if (primary == null) throw closeError; // 본문이 성공했다면 close 예외가 주 예외
                primary.addSuppressed(closeError);
            }
        }
    }

    public static void main(String[] args) {
        try {
            masked();
        } catch (IOException e) {
            System.out.println("masked: " + e.getMessage());
        }
        try {
            preserved();
        } catch (IOException e) {
            System.out.println("preserved: " + e.getMessage()
                    + ", suppressed=" + e.getSuppressed()[0].getMessage());
        }
    }
}

두 메서드는 똑같이 본문에서 read 실패를, close()에서 close 실패를 던집니다. 그러니 두 줄의 차이는 예외를 다루는 방식에서만 나옵니다.

masked: close 실패
preserved: read 실패, suppressed=close 실패

masked()를 호출한 쪽이 받은 것은 close 실패뿐이고, 실제 원인인 read 실패는 흔적도 없이 사라졌습니다. 장애를 조사하는 사람은 정리 과정의 부수적인 실패만 보고 엉뚱한 곳을 찾게 됩니다. preserved()는 본문 예외를 primary에 보관하고, close()가 실패하면 addSuppressed()로 그 예외를 억제된 예외(suppressed exception)로 붙입니다. 본문이 성공해서 primary가 null이라면 close()의 예외가 그대로 주 예외가 됩니다.

자원 하나를 닫는 데도 preserved()만큼의 코드가 필요하고, 자원이 늘어날수록 더 복잡해집니다. try-with-resources를 쓰면 자원을 역순으로 닫는 일과 이 처리를 컴파일러가 대신 만들어 줍니다. try-with-resources가 close()를 자동으로 불러 주는 것 이상의 기능인 이유가 여기에 있습니다.

장애 로그를 남길 때도 같은 원칙을 지킵니다. 네트워크 오류 로그에는 연결을 구분하는 ID와 원격 주소, 실패한 처리 단계, 주 예외의 종류를 남깁니다. 정리 중의 실패는 그 기록에 부가 원인으로 연결해 둡니다. 연결 하나가 끊기면 입력, 출력, 소켓이 모두 비슷한 예외를 낼 수 있으므로, 같은 단절로 알림이 여러 번 울리지 않게 묶어 주는 것이 좋습니다. 또 Connection reset 같은 오류 메시지는 운영체제마다 다르므로, 메시지 문자열보다는 예외 타입과 처리 단계로 분류합니다.

정리

  • 정상 흐름의 끝에 둔 close()는 예외가 나면 실행되지 않습니다. 자원을 쓰는 코드 전체를 try로 감싸고 정리는 finally에 둡니다.
  • Socket.close()는 그 소켓의 스트림도 닫지만, 버퍼가 있는 출력 래퍼는 소켓보다 먼저 닫아야 마지막 데이터가 전달됩니다. 직접 닫을 때는 바깥 출력, 바깥 입력, 소켓 순서입니다.
  • 여러 자원을 닫을 때는 null을 건너뛰고, 각 close()의 실패를 따로 잡아 나머지 자원도 닫습니다.
  • 소켓을 세션에 넘겼다면 세션이 닫고, 넘기지 못했다면 받은 쪽이 바로 닫습니다.
  • finally 안의 close() 예외는 본문 예외를 덮을 수 있습니다. 본문 예외를 지키려면 억제된 예외로 붙여야 하고, try-with-resources는 이 일을 자동으로 합니다.

상황별로 누가 어디서 닫는지 표로 정리하면 다음과 같습니다.

상황닫는 쪽닫는 곳
accept() 뒤 세션에 넘기는 데 성공세션세션의 finally
세션에 넘기는 데 실패accept()를 호출한 쪽실패를 확인한 즉시
클라이언트 메서드 안에서 만든 소켓그 메서드그 메서드의 finally
서버 리스너서버 수명 관리자서버 종료 절차
인자로 빌려 받은 OutputStream빌려준 호출자받은 메서드는 닫지 않음

연습 문제

연습 1: 한 번만 정리하는 SessionResources

여러 스레드가 동시에 close()를 호출해도 실제 정리는 한 번만 하는 SessionResources를 작성하세요. 소켓, 입력, 출력 순서로 얻은 자원을 출력, 입력, 소켓 순서로 닫고, 닫다가 난 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());
    }
}

실행 결과:

closed once per slot
closed once per slot
closed once per slot
[]

closed.compareAndSet(false, true)는 closed가 false일 때만 true로 바꾸고 성공을 알리는 원자적 연산으로, CAS와 재시도 루프에서 본 CAS입니다. 여러 스레드가 동시에 호출해도 성공하는 호출은 하나뿐이므로, 정리 코드에 들어가는 호출도 하나뿐입니다. 나머지 호출은 false를 받아 곧바로 반환합니다.

main은 같은 tracked 객체를 세 칸에 모두 넣었기 때문에, 첫 번째 close()에서 그 객체의 close()가 칸마다 한 번씩 세 번 불렸습니다. 두 번째 resources.close()는 CAS에 실패해 아무것도 하지 않았고, 마지막 줄의 []는 닫다가 실패한 자원이 없었다는 뜻입니다. 즉 '한 번만'은 SessionResources 전체의 정리 절차가 한 번이라는 뜻이지, 안에 든 객체 하나하나의 close()가 한 번이라는 뜻이 아닙니다.

close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다

CAS 승자는 닫기 시작 권한을 얻은 다음 세 슬롯을 정리하고 결과를 기록합니다. 두 번째 호출의 즉시 반환과 정리 완료를 구분하는 상태 변화가 핵심입니다.

close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다

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

멱등 닫기 · 실행권과 결과 공개

close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다

CAS 성공은 정리 시작권이며, 다른 close의 반환이나 failures 읽기가 완료를 보장하지 않습니다.

CAS 실행권과 failures 공개를 따로 읽는다

CAS 실행권과 failures 공개를 따로 읽는다첫 CAS 성공 호출만 세 슬롯을 정리하고 failures에 대입한다. CAS 실패자는 기다리지 않는다. 같은 스레드의 반환 뒤 조회와 다른 스레드의 일반 필드 조회를 구분한다. 원 main은 같은 객체의 세 슬롯을 같은 스레드에서 정리해 객체 close를 3회 호출하고 둘째 전체 close는 생략한다.실패성공closed CAS실패 호출즉시 반환성공 호출세 슬롯 정리다른 스레드공개 미보장failures결과 대입반환 뒤 조회동일 스레드
CAS 실행권과 failures 공개를 따로 읽는다첫 CAS 성공 호출만 세 슬롯을 정리하고 failures에 대입한다. CAS 실패자는 기다리지 않는다. 같은 스레드의 반환 뒤 조회와 다른 스레드의 일반 필드 조회를 구분한다. 원 main은 같은 객체의 세 슬롯을 같은 스레드에서 정리해 객체 close를 3회 호출하고 둘째 전체 close는 생략한다.실패성공closed CAS실패 호출즉시 반환성공 호출세 슬롯정리다른스레드공개미보장failures결과 대입반환 뒤조회동일스레드

첫 CAS 성공 호출만 세 슬롯을 정리하고 failures에 대입한다. CAS 실패자는 기다리지 않는다. 같은 스레드의 반환 뒤 조회와 다른 스레드의 일반 필드 조회를 구분한다. 원 main은 같은 객체의 세 슬롯을 같은 스레드에서 정리해 객체 close를 3회 호출하고 둘째 전체 close는 생략한다.

상태별 상세 해설

첫 compareAndSet 성공

현재 구현의 동작
closed를 true로 바꾸고 세 슬롯 정리 시작
완료·공개의 의미
true는 정리 완료 표시가 아님

다른 close의 compareAndSet 실패

현재 구현의 동작
즉시 반환
완료·공개의 의미
먼저 실행 중인 정리가 끝날 때까지 기다리지 않음

정리한 스레드의 failures 대입

현재 구현의 동작
수집한 IOException 목록을 기록
완료·공개의 의미
같은 스레드가 close 반환 뒤 읽으면 결과를 볼 수 있음

다른 스레드의 failures 호출

현재 구현의 동작
일반 필드를 읽음
완료·공개의 의미
완료 대기와 별도 공개 규칙이 없으므로 최신 결과 보장 없음

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

이 예의 조건

  • 원 main은 같은 Closeable을 세 슬롯에 넣고 같은 스레드에서 close를 순서대로 두 번 호출합니다. 동시 호출을 실행하지 않습니다.
  • 다른 스레드의 CAS 패배 반환과 일반 failures 필드의 공개 한계는 독립 경쟁 비교로 원문 전체 표에 보존합니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다 close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다 IdempotentSessionCloseSolution의 CAS, 자원 정리, failures 필드 공개 순서를 구분합니다. 멱등 닫기 · 실행권과 결과 공개 close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다 CAS 성공은 정리 시작권이며, 다른 close의 반환이나 failures 읽기가 완료를 보장하지 않습니다. 첫 compareAndSet 성공 현재 구현의 동작 closed를 true로 바꾸고 세 슬롯 정리 시작 완료·공개의 의미 true는 정리 완료 표시가 아님 다른 close의 compareAndSet 실패 현재 구현의 동작 즉시 반환 완료·공개의 의미 먼저 실행 중인 정리가 끝날 때까지 기다리지 않음 정리한 스레드의 failures 대입 현재 구현의 동작 수집한 IOException 목록을 기록 완료·공개의 의미 같은 스레드가 close 반환 뒤 읽으면 결과를 볼 수 있음 다른 스레드의 failures 호출 현재 구현의 동작 일반 필드를 읽음 완료·공개의 의미 완료 대기와 별도 공개 규칙이 없으므로 최신 결과 보장 없음 main은 같은 Closeable을 세 슬롯에 넣고 close를 순서대로 두 번 호출합니다. 슬롯별 호출은 세 번이며 두 번째 전체 호출은 생략됩니다. 여러 스레드의 동시 호출을 실행하는 main은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 close의 실행권

원 main의 첫 close가 CAS에 성공해 정리 경로에 들어갑니다.

  • closed: false→true
  • 정리 실행권: 첫 close만 획득
  • 슬롯: 같은 Closeable 참조 세 개
  • CAS의 true는 정리 시작권이며 아직 세 슬롯 정리와 failures 대입의 완료가 아닙니다.
세 슬롯 정리와 결과 게시

첫 close가 각 슬롯을 차례로 닫고 수집한 IOException 목록을 대입합니다.

  • 객체 close 호출: 슬롯별 3번
  • 원 tracked 콜백: closed once per slot 3줄 · IOException 없음
  • failures: 빈 목록 대입 뒤 첫 close 반환
  • 같은 객체라도 슬롯마다 호출하고 모든 시도를 마친 뒤 같은 정리 스레드에 결과가 게시됩니다.
둘째 close와 같은 스레드 조회

첫 close가 반환한 뒤 둘째 호출은 CAS에 실패해 생략되고 failures를 읽습니다.

  • 둘째 전체 close: 즉시 반환 · 슬롯 추가 호출 0
  • 원 main 조회: 첫 close 반환 뒤 같은 스레드 · []
  • 동시 호출 비교: 별도 완료 대기·공개 규칙 필요
  • 원 main은 첫 정리 완료 뒤 둘째 호출을 수행하므로 일반 경쟁자의 조기 반환과 같은 시간축으로 합치지 않습니다.

close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다

CAS 승자는 닫기 시작 권한을 얻은 다음 세 슬롯을 정리하고 결과를 기록합니다. 두 번째 호출의 즉시 반환과 정리 완료를 구분하는 상태 변화가 핵심입니다.

close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다

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

멱등 닫기 · 실행권과 결과 공개

close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다

CAS 성공은 정리 시작권이며, 다른 close의 반환이나 failures 읽기가 완료를 보장하지 않습니다.

CAS 실행권과 failures 공개를 따로 읽는다

CAS 실행권과 failures 공개를 따로 읽는다첫 CAS 성공 호출만 세 슬롯을 정리하고 failures에 대입한다. CAS 실패자는 기다리지 않는다. 같은 스레드의 반환 뒤 조회와 다른 스레드의 일반 필드 조회를 구분한다. 원 main은 같은 객체의 세 슬롯을 같은 스레드에서 정리해 객체 close를 3회 호출하고 둘째 전체 close는 생략한다.실패성공closed CAS실패 호출즉시 반환성공 호출세 슬롯 정리다른 스레드공개 미보장failures결과 대입반환 뒤 조회동일 스레드
CAS 실행권과 failures 공개를 따로 읽는다첫 CAS 성공 호출만 세 슬롯을 정리하고 failures에 대입한다. CAS 실패자는 기다리지 않는다. 같은 스레드의 반환 뒤 조회와 다른 스레드의 일반 필드 조회를 구분한다. 원 main은 같은 객체의 세 슬롯을 같은 스레드에서 정리해 객체 close를 3회 호출하고 둘째 전체 close는 생략한다.실패성공closed CAS실패 호출즉시 반환성공 호출세 슬롯정리다른스레드공개미보장failures결과 대입반환 뒤조회동일스레드

첫 CAS 성공 호출만 세 슬롯을 정리하고 failures에 대입한다. CAS 실패자는 기다리지 않는다. 같은 스레드의 반환 뒤 조회와 다른 스레드의 일반 필드 조회를 구분한다. 원 main은 같은 객체의 세 슬롯을 같은 스레드에서 정리해 객체 close를 3회 호출하고 둘째 전체 close는 생략한다.

상태별 상세 해설

첫 compareAndSet 성공

현재 구현의 동작
closed를 true로 바꾸고 세 슬롯 정리 시작
완료·공개의 의미
true는 정리 완료 표시가 아님

다른 close의 compareAndSet 실패

현재 구현의 동작
즉시 반환
완료·공개의 의미
먼저 실행 중인 정리가 끝날 때까지 기다리지 않음

정리한 스레드의 failures 대입

현재 구현의 동작
수집한 IOException 목록을 기록
완료·공개의 의미
같은 스레드가 close 반환 뒤 읽으면 결과를 볼 수 있음

다른 스레드의 failures 호출

현재 구현의 동작
일반 필드를 읽음
완료·공개의 의미
완료 대기와 별도 공개 규칙이 없으므로 최신 결과 보장 없음

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

이 예의 조건

  • 원 main은 같은 Closeable을 세 슬롯에 넣고 같은 스레드에서 close를 순서대로 두 번 호출합니다. 동시 호출을 실행하지 않습니다.
  • 다른 스레드의 CAS 패배 반환과 일반 failures 필드의 공개 한계는 독립 경쟁 비교로 원문 전체 표에 보존합니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다 close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다 IdempotentSessionCloseSolution의 CAS, 자원 정리, failures 필드 공개 순서를 구분합니다. 멱등 닫기 · 실행권과 결과 공개 close 실행권을 한 번 얻는 것과 완료를 기다리는 것은 다르다 CAS 성공은 정리 시작권이며, 다른 close의 반환이나 failures 읽기가 완료를 보장하지 않습니다. 첫 compareAndSet 성공 현재 구현의 동작 closed를 true로 바꾸고 세 슬롯 정리 시작 완료·공개의 의미 true는 정리 완료 표시가 아님 다른 close의 compareAndSet 실패 현재 구현의 동작 즉시 반환 완료·공개의 의미 먼저 실행 중인 정리가 끝날 때까지 기다리지 않음 정리한 스레드의 failures 대입 현재 구현의 동작 수집한 IOException 목록을 기록 완료·공개의 의미 같은 스레드가 close 반환 뒤 읽으면 결과를 볼 수 있음 다른 스레드의 failures 호출 현재 구현의 동작 일반 필드를 읽음 완료·공개의 의미 완료 대기와 별도 공개 규칙이 없으므로 최신 결과 보장 없음 main은 같은 Closeable을 세 슬롯에 넣고 close를 순서대로 두 번 호출합니다. 슬롯별 호출은 세 번이며 두 번째 전체 호출은 생략됩니다. 여러 스레드의 동시 호출을 실행하는 main은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 close의 실행권

원 main의 첫 close가 CAS에 성공해 정리 경로에 들어갑니다.

  • closed: false→true
  • 정리 실행권: 첫 close만 획득
  • 슬롯: 같은 Closeable 참조 세 개
  • CAS의 true는 정리 시작권이며 아직 세 슬롯 정리와 failures 대입의 완료가 아닙니다.
세 슬롯 정리와 결과 게시

첫 close가 각 슬롯을 차례로 닫고 수집한 IOException 목록을 대입합니다.

  • 객체 close 호출: 슬롯별 3번
  • 원 tracked 콜백: closed once per slot 3줄 · IOException 없음
  • failures: 빈 목록 대입 뒤 첫 close 반환
  • 같은 객체라도 슬롯마다 호출하고 모든 시도를 마친 뒤 같은 정리 스레드에 결과가 게시됩니다.
둘째 close와 같은 스레드 조회

첫 close가 반환한 뒤 둘째 호출은 CAS에 실패해 생략되고 failures를 읽습니다.

  • 둘째 전체 close: 즉시 반환 · 슬롯 추가 호출 0
  • 원 main 조회: 첫 close 반환 뒤 같은 스레드 · []
  • 동시 호출 비교: 별도 완료 대기·공개 규칙 필요
  • 원 main은 첫 정리 완료 뒤 둘째 호출을 수행하므로 일반 경쟁자의 조기 반환과 같은 시간축으로 합치지 않습니다.

도표에서 보듯 이 구현을 여러 스레드에서 쓸 때는 알아 둘 한계가 있습니다. CAS가 나눠 주는 것은 정리를 시작할 권리이지, 정리가 끝났다는 표시가 아닙니다. 첫 번째 호출이 아직 자원을 닫는 중일 때 두 번째 호출은 기다리지 않고 바로 반환하므로, 두 번째 호출자는 반환했다는 사실만으로 정리가 끝났다고 판단하면 안 됩니다. 또 failures는 volatile이 아닌 일반 필드라서, 다른 스레드가 읽으면 최신 값이 보인다는 보장이 없습니다(가시성·원자성·임계 영역 참고). 다른 스레드에서 결과를 읽으려면 정리한 스레드를 join()으로 기다리거나, 그 작업의 Future로 완료를 확인한 뒤에 읽어야 합니다. 모든 호출자가 정리가 끝날 때까지 기다려야 한다면 close()와 failures()를 synchronized로 만드는 편이 더 간단합니다.

연습 2: 일부 자원만 실패할 때

연습 1의 SessionResources에 서로 다른 가짜 자원 세 개를 출력, 입력, 소켓 자리에 넣고, 출력과 소켓의 close()만 IOException을 던지게 하세요. 첫 번째 close()가 끝난 뒤 failures()에는 예외가 몇 개, 어떤 순서로 남을까요? 첫 번째 정리가 진행되는 도중에 다른 스레드가 close()를 호출해 반환받았다면, 그 스레드는 정리가 끝났다고 판단해도 될까요?

정답과 해설

출력, 입력, 소켓의 close()가 모두 한 번씩 시도되고, failures()에는 출력의 예외와 소켓의 예외가 그 순서대로 두 개 남습니다. 예를 들어 가짜 자원이 각각 new IOException("output")과 new IOException("socket")을 던지게 하면 [java.io.IOException: output, java.io.IOException: socket]이 출력됩니다. 출력에서 실패해도 catch가 예외를 목록에 담고 반복을 이어 가므로 입력과 소켓도 닫힙니다.

두 번째 질문의 답은 '아니요'입니다. 두 번째 호출은 CAS에 실패해 곧바로 반환할 뿐이므로, 그 반환은 첫 번째 정리가 끝났다는 뜻도, failures()에서 최신 목록을 읽을 수 있다는 뜻도 아닙니다. 또 이 구현은 IOException만 모으므로, 어떤 자원이 RuntimeException이나 Error를 던지면 그 예외가 catch 밖으로 전파되어 뒤 칸의 자원은 닫히지 않을 수 있습니다.

연습 3: flush한 뒤 소켓을 닫으면

BufferedCloseOrder에서 소켓을 먼저 닫는 경우에 accepted.close() 바로 앞에 output.flush()를 넣으면 실행 결과가 어떻게 바뀔까요?

정답과 해설

두 경우 모두 클라이언트가 bye를 받고, output.close() 실패 줄도 사라집니다. 실행 결과는 다음과 같습니다.

[소켓을 먼저 닫을 때]
클라이언트가 받음: bye
[출력 래퍼를 먼저 닫을 때]
클라이언트가 받음: bye

flush()가 소켓이 닫히기 전에 버퍼의 내용을 내보냈으므로 데이터가 사라지지 않습니다. 그 뒤의 output.close()는 비울 내용이 없고, 이미 닫힌 소켓 스트림을 다시 닫는 일은 아무 효과가 없으므로 예외도 나지 않습니다. 결국 중요한 것은 소켓이 닫히기 전에 버퍼가 비워지는 것이고, 가장 바깥 래퍼를 먼저 닫는 것은 이를 지키는 가장 간단한 방법입니다. 그 경우 버퍼를 비우다 난 실패는 close()의 예외로 나타나므로, 그 예외를 삼키지 말아야 합니다.

다음 글

try-with-resources로 소켓 닫기에서는 이 글에서 손으로 짠 정리 코드를 try-with-resources로 바꿉니다. 역순으로 닫기, 하나가 실패해도 나머지를 계속 닫기, 본문 예외를 주 예외로 지키기를 언어가 어떻게 한꺼번에 처리하는지, 그리고 연결이 정상적으로 끝난 경우와 끊긴 경우를 어떻게 구분하는지 살펴봅니다.