본문 바로가기

try-with-resources로 소켓 닫기

소켓과 스트림을 try-with-resources로 닫을 때의 선언 순서, 넘겨받은 소켓을 닫아도 되는 경우, 닫다가 난 예외를 로그에 남기는 방법, 상대가 연결을 끊었을 때의 예외를 읽는 방법을 배웁니다.

앞 글에서는 finally와 closeAll로 소켓과 스트림을 직접 닫았습니다. 그렇게 해도 동작은 하지만 자원마다 null 검사가 필요하고, close() 하나가 실패해도 나머지를 계속 닫으려면 try와 catch를 겹겹이 둘러야 합니다. 본문에서 난 원래 예외까지 지키려면 코드는 더 길어집니다. try-with-resources 문은 이 일을 언어가 대신 하게 해 줍니다. Socket, ServerSocket, 소켓에서 꺼낸 입출력 스트림은 모두 AutoCloseable을 구현하므로 그대로 자원으로 선언할 수 있습니다.

기본 규칙은 try-with-resources와 공통 예외 처리에서 배웠습니다. 이 글은 그 규칙을 네트워크 코드에 적용할 때 생기는 질문을 차례로 다룹니다. 소켓과 스트림을 어떤 순서로 선언해야 하는지, 인수로 받은 소켓을 닫아도 되는지, 닫다가 난 예외를 어떻게 로그에 남기는지, 그리고 상대가 연결을 끊었을 때 나오는 예외를 어떻게 읽어야 하는지입니다.

finally로 직접 닫을 때 생기는 두 가지 문제

먼저 수동 정리가 어디서 틀어지는지 실행해서 확인해 보겠습니다. 실제 소켓의 close()는 원하는 순간에 실패하게 만들기 어려우므로, 닫을 때 실패하도록 설정할 수 있는 가짜 자원에 socket, input, output이라는 이름을 붙였습니다. 실제로도 버퍼가 있는 출력 스트림은 닫기 직전에 버퍼에 남은 데이터를 내보내는데(flush), 그 사이 연결이 끊겼다면 여기서 IOException이 날 수 있습니다. 아래 코드에서는 output이 그런 자원 역할을 합니다. 본문은 데이터를 읽다가 실패한 상황을 흉내 내려고 read failed 예외를 던집니다.

bad/ManualCloseProblem.java
import java.io.IOException;

public final class ManualCloseProblem {
    static final class Resource implements AutoCloseable {
        private final String name;
        private final boolean failOnClose;

        Resource(String name, boolean failOnClose) {
            this.name = name;
            this.failOnClose = failOnClose;
        }

        @Override public void close() throws IOException {
            System.out.println("close " + name);
            if (failOnClose) throw new IOException(name + " close failed");
        }
    }

    static void exchange() throws IOException {
        Resource socket = null;
        Resource input = null;
        Resource output = null;
        try {
            socket = new Resource("socket", false);
            input = new Resource("input", false);
            output = new Resource("output", true);
            throw new IOException("read failed"); // 본문에서 일어난 진짜 실패
        } finally {
            if (output != null) output.close();
            if (input != null) input.close();
            if (socket != null) socket.close();
        }
    }

    public static void main(String[] args) {
        try {
            exchange();
        } catch (IOException error) {
            System.out.println("caught: " + error.getMessage());
            for (Throwable suppressed : error.getSuppressed()) {
                System.out.println("suppressed: " + suppressed.getMessage());
            }
        }
    }
}

finally는 output, input, socket 순서로 close()를 호출하고, Resource.close()는 예외를 던지기 전에 close 줄부터 찍습니다. 그래서 출력의 close 줄을 세면 실제로 close()가 호출된 자원이 몇 개인지 알 수 있습니다.

close output
caught: output close failed

출력에서 두 가지 문제가 보입니다. 첫째, close input과 close socket이 출력되지 않았습니다. finally 블록에서 output.close()가 예외를 던지는 순간 블록의 나머지 두 줄은 실행되지 않고, 두 자원은 열린 채로 남습니다. 둘째, 호출자가 받은 예외는 output close failed이고 진짜 원인인 read failed는 어디에도 남지 않았습니다. finally에서 새 예외가 나면 그때까지 전달되던 예외는 버려지기 때문입니다. 이 프로그램의 로그만 보는 사람은 '닫기가 실패했다'는 증상만 알게 되고, 읽기가 왜 실패했는지는 알 수 없습니다.

이 두 문제를 손으로 막으려면 close()마다 try와 catch를 두르고, 원래 예외를 변수에 보관해 두었다가 닫기 예외를 addSuppressed()로 붙이는 코드를 직접 써야 합니다. 이런 코드를 서버와 클라이언트 양쪽에 복사하다 보면 한쪽에만 수정이 반영되는 일도 생깁니다.

try-with-resources로 바꾸면 달라지는 것

같은 자원을 try-with-resources로 선언해 보겠습니다. ManualCloseProblem에서 exchange() 메서드만 아래 코드로 바꾸고 다시 실행하세요. Resource 클래스와 main은 그대로 둡니다.

static void exchange() throws IOException {
    try (var socket = new Resource("socket", false);
         var input = new Resource("input", false);
         var output = new Resource("output", true)) {
        throw new IOException("read failed"); // 본문에서 일어난 진짜 실패
    }
}

Resource와 main이 그대로이므로, close 줄의 개수와 caught: 뒤의 메시지를 앞의 실행 결과와 비교하면 됩니다. 앞에서는 없던 suppressed: 줄이 나오는지도 확인해 보세요.

close output
close input
close socket
caught: read failed
suppressed: output close failed

이번에는 세 자원이 모두 닫혔습니다. try-with-resources는 자원을 선언한 순서의 반대로 close()를 호출하고, 그중 하나가 실패해도 나머지를 계속 닫습니다. 호출자가 받은 예외도 본문의 read failed입니다. 본문에서 예외가 났다면 그 예외가 주 예외(primary exception)가 되고, 닫는 중에 난 예외는 버려지지 않고 주 예외의 억제된 예외(suppressed exception) 목록에 추가됩니다. 마지막 줄은 main이 getSuppressed()로 그 목록을 꺼내 출력한 것입니다.

본문이 아니라 다른 곳에서 실패하는 경우도 있습니다. 본문은 성공했는데 닫기만 실패하면, 역순으로 닫다가 처음 실패한 close()의 예외가 주 예외가 되고 그 뒤의 닫기 실패는 거기에 억제된 예외로 붙습니다. 자원을 만드는 도중에 실패하면 본문은 실행되지 않고, 그때까지 만든 자원만 닫힙니다. 이 두 경우는 글 끝의 연습 문제에서 직접 실행해 봅니다.

소켓과 스트림은 어떤 순서로 선언할까

이제 실제 소켓으로 넘어가겠습니다. 아래 프로그램은 한 프로세스 안에서 서버와 클라이언트를 함께 실행합니다. 서버 쪽 handle()은 소켓에서 문자열 하나를 읽고 앞에 ok:를 붙여 돌려보내며, 클라이언트는 hello를 보내고 받은 응답을 출력합니다. new ServerSocket(0, 1, InetAddress.getLoopbackAddress())의 첫 인수 0은 운영체제에 비어 있는 포트를 골라 달라는 뜻이고, 세 번째 인수는 서버가 같은 컴퓨터 안에서만 쓰는 루프백 주소(127.0.0.1)에서만 접속을 받게 합니다.

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

클라이언트의 readUTF()는 서버의 응답이 도착할 때까지, handled.get()은 서버 작업이 끝날 때까지 기다립니다. 그래서 두 스레드가 함께 움직여도 두 줄의 순서는 바뀌지 않습니다.

ok:hello
handled=hello

첫 줄은 클라이언트가 받은 서버의 응답이고, 둘째 줄은 handle()이 돌려준 값을 main이 Future의 get()으로 꺼내 출력한 것입니다. 포트 번호는 실행할 때마다 달라지지만 출력에는 나오지 않으므로 결과는 매번 같습니다. 참고로 ExecutorService를 try-with-resources로 닫을 수 있게 된 것은 Java 19부터입니다.

handle()의 try (socket; ...)에서 첫 번째 자원은 새로 선언한 변수가 아니라 인수로 받은 socket입니다. Java 9부터는 final이거나 사실상 final인 변수, 즉 한 번 대입한 뒤 다시 바꾸지 않는 변수를 이렇게 괄호 안에 이름만 적어 자원으로 쓸 수 있습니다. 선언 순서가 소켓, 입력 스트림, 출력 스트림이므로 닫는 순서는 그 반대인 출력 스트림, 입력 스트림, 소켓입니다. 바깥에 감싼 스트림부터 닫고, 그 바탕이 되는 소켓을 마지막에 닫는 셈입니다.

사실 소켓은 첫 번째 close()에서 이미 닫힙니다. Socket의 공식 문서는 getInputStream()이나 getOutputStream()이 돌려준 스트림을 닫으면 소켓도 함께 닫힌다고 설명합니다. DataOutputStream을 닫으면 그 아래의 소켓 출력 스트림이 닫히고, 그것이 다시 소켓을 닫는 것입니다. 그래서 뒤이은 입력 스트림과 소켓의 close()는 이미 닫힌 자원을 다시 닫는 호출이 됩니다. Closeable의 규칙상 이미 닫힌 자원의 close()는 아무 일도 하지 않으므로 문제가 되지 않습니다.

그렇다면 소켓을 자원 목록에 따로 적을 필요가 있을까요? 있습니다. 스트림을 만드는 도중에 실패하는 경우가 있기 때문입니다. socket.getInputStream()은 소켓이 이미 닫혔거나 shutdownInput()으로 입력 방향을 닫은 뒤라면 IOException을 던집니다. ObjectInputStream처럼 생성자 안에서 상대가 보낸 헤더를 읽는 래퍼는 생성 단계에서 실패하거나 상대가 헤더를 보낼 때까지 멈춰 있기도 합니다. 소켓을 첫 번째 자원으로 적어 두면 그 뒤의 어느 자원이 만들어지다 실패해도 소켓은 닫힙니다. 가장 바깥 래퍼 하나만 선언하는 방식은 정상 경로에서는 똑같이 소켓을 닫지만, 래퍼를 만들다 실패하면 소켓을 닫을 코드가 없어 연결이 그대로 남습니다.

넘겨받은 소켓을 닫아도 되는 경우

앞의 handle()은 자기가 만들지 않은 소켓을 닫습니다. 자원은 연 쪽이 닫는다는 원칙에 어긋나 보이지만 여기서는 괜찮습니다. main은 server.accept()로 받은 소켓을 handle()에 넘긴 뒤 다시는 쓰지 않기 때문입니다. 이렇게 자원을 쓸 권리와 닫을 책임을 함께 넘기는 것을 소유권(ownership)을 넘긴다고 합니다. 서버의 accept 루프가 연결마다 소켓을 세션에 넘기고 자기는 다음 연결을 기다리는 구조가 대표적인 예입니다.

반대로 호출한 쪽이 그 소켓을 계속 쓸 생각이라면 받은 쪽은 닫으면 안 됩니다. 한 연결에서 요청을 여러 번 주고받는 프로그램이 요청 하나를 처리하는 메서드에 소켓을 빌려준다고 해 봅시다. 그 메서드가 try (socket)으로 소켓을 닫으면 다음 요청은 보낼 수 없게 됩니다. 빌려 받은 소켓에서 꺼낸 스트림도 마찬가지입니다. 그 스트림을 감싼 DataInputStream을 try-with-resources로 닫으면 앞에서 본 것처럼 소켓까지 닫힙니다. 문법은 이 두 경우를 구별해 주지 않으므로, 받은 소켓을 닫는 메서드인지 아닌지를 메서드 이름이나 문서 주석으로 분명히 밝혀 두는 것이 좋습니다.

닫다가 난 예외까지 로그에 남기기

억제된 예외는 기록되지 않으면 소용이 없습니다. 흔한 실수는 error.getMessage()로 메시지 한 줄만 로그에 남기는 것입니다. 그러면 주 예외의 메시지만 남고, 같은 순간에 출력 스트림이나 소켓을 닫다가 난 실패는 사라집니다. 로그에는 예외 객체를 통째로 넘기는 편이 좋습니다. printStackTrace()처럼 예외 전체를 출력하는 방식은 스택 트레이스 아래에 Suppressed:로 시작하는 줄을 덧붙여 억제된 예외까지 보여 줍니다. 형식을 직접 정해 기록한다면 앞의 main처럼 getSuppressed()로 목록을 꺼내 하나씩 남기되, 메시지뿐 아니라 예외 타입도 함께 적어 두면 원인을 찾기 쉽습니다.

본문은 성공했는데 닫기가 실패한 경우는 특히 주의해야 합니다. 이때는 닫기 예외가 주 예외가 되어 메서드 밖으로 던져지므로, 본문의 return 값은 호출자에게 전달되지 않습니다. 네트워크 코드에서는 오히려 바람직한 동작입니다. 버퍼가 있는 출력 스트림이 닫히면서 마지막 데이터를 내보내다 실패했다면, 상대는 응답의 끝부분을 받지 못했을 수 있기 때문입니다. 그런데 호출자가 이 예외를 잡아 놓고 '응답 전송 성공'으로 기록해 버리면 그 사실이 묻힙니다. 닫는 단계의 실패도 요청 실패로 다뤄야 합니다.

더 깊이: 예외를 직접 펼쳐 기록할 때

예외의 원인(cause)과 억제된 예외는 각각 또 다른 원인과 억제된 예외를 가질 수 있으므로, 예외 하나는 사실 나무 모양의 구조입니다. 드물지만 두 예외가 서로를 억제된 예외로 가리키는 순환도 만들 수 있습니다. 표준 printStackTrace()는 이미 출력한 예외를 다시 만나면 [CIRCULAR REFERENCE: ...]라고 표시하고 더 따라가지 않습니다. 하지만 이 구조를 재귀로 따라가며 JSON 같은 형식으로 직접 기록하는 코드에는 그런 장치가 없습니다. 방문한 예외를 기억해 두고 따라갈 깊이와 개수에 상한을 두어야 로그 한 건이 끝없이 커지지 않습니다.

상대가 연결을 끊었을 때의 예외 읽기

서버 로그에는 상대가 연결을 끊어서 생긴 예외가 많이 찍힙니다. 이 예외들을 모두 장애로 보면 알림이 쏟아지고, 모두 무시하면 진짜 장애를 놓칩니다. 그래서 먼저 연결이 끊기는 두 가지 방식을 구분해야 합니다. TCP에서 정상적으로 연결을 닫는 쪽은 FIN이라는 신호로 '더 보낼 데이터가 없다'고 알립니다. 받는 쪽은 이미 도착한 데이터를 끝까지 읽은 다음 스트림의 끝(EOF, end of file)을 만납니다. 바이트 단위의 read()는 이때 -1을 돌려주고, DataInputStream의 readUTF()처럼 정해진 바이트 수가 필요한 메서드는 다 읽지 못하므로 EOFException을 던집니다. 반면 RST(reset)는 연결을 그 자리에서 버리겠다는 신호입니다. RST를 받은 연결에서는 읽기나 쓰기가 대개 SocketException으로 끝납니다.

어떤 상황에서 어느 신호가 오는지는 생각보다 단순하지 않습니다. RST는 한쪽이 일부러 연결을 즉시 끊었을 때, 읽지 않은 데이터가 남은 채로 소켓을 닫았을 때, 상대가 이미 닫은 연결에 계속 데이터를 보낼 때, 방화벽 같은 중간 장비가 연결을 정리할 때 생길 수 있습니다. 상대 프로세스가 강제로 종료된 경우는 조금 다릅니다. 프로세스가 죽으면 운영체제가 그 프로세스의 소켓을 대신 닫는데, 아직 읽지 않은 수신 데이터가 없다면 보통 FIN이 나가므로 이쪽에서는 처음에 정상적인 EOF처럼 보입니다. 그 사실을 모른 채 응답을 계속 쓰면 상대 쪽 운영체제가 RST로 답하고, 그다음 쓰기가 SocketException으로 실패합니다. Linux의 JDK 21에서는 이때 Broken pipe라는 메시지가 나옵니다. 두 신호를 직접 만들어 보는 실습은 TCP 연결 종료: EOF, 반쪽 닫기, RST에서 합니다.

예외 메시지는 운영체제와 JDK 버전에 따라 달라집니다. 같은 RST라도 Connection reset, Connection reset by peer, Broken pipe처럼 다르게 보일 수 있으므로, 메시지 문자열을 비교해 정상 종료인지 판단하는 코드는 다른 환경에서 쉽게 깨집니다. 대신 프로토콜이 어느 단계에 있었는지를 기준으로 판단합니다. 클라이언트가 약속된 exit 메시지를 보낸 뒤이거나 응답을 모두 받은 뒤에 연결이 끊겼다면 정상적인 이탈입니다. 요청을 처리하거나 응답을 쓰는 도중에 끊겼다면 실패입니다. 같은 EOFException이라도 요청과 요청 사이에 쉬고 있던 연결에서 났다면 정보 수준의 로그로 충분하고, 응답 일부가 전달되지 못했다면 실패 지표에 포함해야 합니다.

메서드 하나로 끝나지 않는 세션

지금까지 본 자원은 수명이 메서드 하나 안에서 끝났습니다. 그런데 서버의 세션은 그렇지 않은 경우가 많습니다. 세션은 작업자 스레드에서 오래 실행되는데, 서버를 종료하는 다른 스레드가 그 세션의 소켓을 닫아야 할 수 있기 때문입니다. 이럴 때는 소켓을 메서드의 지역 자원으로 두는 대신 세션 객체가 소켓을 필드로 갖고 AutoCloseable을 직접 구현하게 합니다. 세션의 run()은 finally에서 자기 close()를 호출하고, 종료 스레드도 같은 close()를 호출합니다. 두 호출이 겹칠 수 있으므로 close()는 여러 번 불려도 실제 정리는 한 번만 하도록, 즉 멱등(idempotent)하게 만들어야 합니다.

이런 세션들을 모아 관리하는 쪽에도 규칙이 필요합니다. 새 세션은 관리 목록에 먼저 등록한 다음 실행하고, 실행이 끝나면 finally에서 반드시 목록에서 빼야 합니다. 종료가 이미 시작됐다면 새 등록을 거절하고, 방금 받은 소켓은 그 자리에서 닫습니다. 다음 글에서 이 규칙을 코드로 옮깁니다.

자원의 수명에 맞는 닫는 방법

자원닫는 방법이유
메서드 안에서 열고 끝내는 클라이언트 소켓그 메서드의 try-with-resources자원의 수명이 메서드와 같음
accept로 받아 세션에 넘긴 소켓세션의 try (socket; ...)소유권이 세션으로 넘어옴
서버 전체가 쓰는 ServerSocket서버의 시작·종료 코드서버가 사는 동안 계속 accept함
빌려 쓰는 소켓이나 스트림닫지 않음빌려준 쪽이 계속 씀
다른 스레드가 종료시킬 수 있는 오래 사는 세션AutoCloseable 구현과 멱등한 close()정리 호출이 여러 곳에서 겹침

정리

  • try-with-resources는 자원을 선언의 역순으로 모두 닫고, 본문의 예외를 주 예외로 지키며, 닫다가 난 예외를 억제된 예외로 붙입니다. 수동 finally에서는 close() 하나의 실패 때문에 나머지 정리와 원래 예외를 함께 잃을 수 있습니다.
  • 소켓을 첫 번째 자원으로, 그 위의 스트림을 뒤에 선언합니다. 스트림을 닫으면 소켓도 닫히지만, 스트림을 만들다 실패하는 경우까지 대비하려면 소켓을 따로 적어야 합니다.
  • 인수로 받은 소켓은 소유권을 넘겨받았을 때만 닫습니다. 빌린 소켓의 스트림을 닫아도 소켓이 닫힙니다.
  • 로그에는 예외 객체를 통째로 남겨야 억제된 예외가 사라지지 않습니다. 본문이 성공해도 닫기가 실패했다면 그 요청은 실패로 다룹니다.
  • 정상 종료는 EOF로, 연결 재설정은 SocketException으로 나타나지만, 결과를 정하는 기준은 프로토콜의 어느 단계에서 끊겼는가입니다. 예외 메시지 문자열로 판단하지 않습니다.

연습 문제

  1. 자원 두 개를 선언하는데 first는 정상적으로 만들어지고, second를 만드는 메서드는 init-second 예외를 던집니다. first.close()도 예외를 던진다면 본문, second.close(), first.close() 가운데 무엇이 실행되고 어떤 예외가 주 예외가 될까요? 해설의 코드를 실행하기 전에 출력을 먼저 예상해 보세요.
  2. 닫을 때 예외를 던지는 자원 두 개를 선언하고, 한 번은 본문이 성공하게, 한 번은 본문도 실패하게 실행하세요. 두 경우의 주 예외 메시지와 억제된 예외 개수를 record로 돌려주는 메서드를 작성합니다.
  3. 채팅 서버 로그에 EOFException이 하루 수백 번 찍힙니다. 대부분 사용자가 앱을 닫은 순간에 찍힌 것이었습니다. 이 예외를 모두 오류 알림으로 보내야 할까요? 어떤 경우를 실패로 셀지 기준을 세워 보세요.
1번 해설
src/PartialResourceInitialization.java
public final class PartialResourceInitialization {
    static final class Resource implements AutoCloseable {
        private final String name;
        Resource(String name) { this.name = name; }
        @Override public void close() throws Exception {
            System.out.println("close=" + name);
            throw new Exception("close-" + name);
        }
    }
    static Resource failSecond() throws Exception { throw new Exception("init-second"); }

    public static void main(String[] args) {
        try (var first = new Resource("first"); var second = failSecond()) {
            System.out.println("body ran");
        } catch (Exception primary) {
            System.out.println("primary=" + primary.getMessage());
            for (Throwable error : primary.getSuppressed()) {
                System.out.println("suppressed=" + error.getMessage());
            }
        }
    }
}

실행 결과:

close=first
primary=init-second
suppressed=close-first

body ran이 출력되지 않은 것처럼 본문은 실행되지 않습니다. 본문은 모든 자원이 준비된 다음에만 실행되기 때문입니다. second.close()도 호출되지 않습니다. second는 만들어지지 않았으므로 닫을 대상이 없습니다. 이미 만들어진 first는 닫아야 하므로 first.close()만 호출되고, 그 실패는 자원 생성 단계의 예외인 init-second에 억제된 예외로 붙습니다. JLS의 try-with-resources 규칙은 이렇게 자원 초기화가 실패한 경우, 본문이 실패한 경우, 본문이 성공한 뒤 닫기가 실패한 경우를 나누어 정의합니다.

2번 해설
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));
    }
}

실행 결과:

bodySuccess=Result[primary=second-close, suppressed=1]
bodyFailure=Result[primary=body, suppressed=2]

본문이 성공한 첫 호출에서도 return new Result("unreachable", 0)의 값은 돌아오지 않습니다. 본문이 끝난 뒤 자원을 역순으로 닫다가 second가 먼저 실패하므로 second-close가 주 예외가 되고, 이어서 닫은 first의 실패가 억제된 예외 1개로 붙습니다. 그 주 예외를 catch가 받아 Result로 바꾼 것입니다. 본문이 실패한 둘째 호출에서는 body가 주 예외이고, 두 닫기 실패가 닫힌 순서대로 second-close, first-close 2개의 억제된 예외로 붙습니다. 두 경우 모두 앞의 닫기가 실패해도 뒤의 자원은 닫힌다는 점이 같습니다.

본문 성공·실패에 따른 주 예외와 억제 예외

본문 실패 유무에 따라 second-close 또는 body 예외가 주 예외가 되고 나머지 close 예외가 억제 목록에 쌓입니다. 두 독립 호출을 나란히 추적하면 우선순위가 명확합니다.

본문 성공·실패에 따른 주 예외와 억제 예외

본문 성공·실패에 따른 주 예외와 억제 예외

자원 예외 · 본문 성공과 실패

본문 성공·실패에 따른 주 예외와 억제 예외

본문이 성공하면 second-close가, 본문이 실패하면 body가 주 예외가 됩니다.

본문 성공과 실패가 주 예외를 결정한다

본문 성공과 실패가 주 예외를 결정한다본문 성공인 false에서도 close 실패 때문에 본문 return은 반환되지 않는다. second-close가 주 예외이며 first-close 한 개가 억제된다. true면 body가 주이고 두 close 실패가 억제된다.falsetruebodyFails 조건false본문 성공truebody 실패주: second억제: first주: body억제: 두 개second → first 모두 closecatch의 Result 반환
본문 성공과 실패가 주 예외를 결정한다본문 성공인 false에서도 close 실패 때문에 본문 return은 반환되지 않는다. second-close가 주 예외이며 first-close 한 개가 억제된다. true면 body가 주이고 두 close 실패가 억제된다.falsetruebodyFails 조건false본문 성공truebody 실패주: second억제:first주: body억제: 두 개second → first 모두 closecatch의 Result 반환

본문 성공인 false에서도 close 실패 때문에 본문 return은 반환되지 않는다. second-close가 주 예외이며 first-close 한 개가 억제된다. true면 body가 주이고 두 close 실패가 억제된다.

상태별 상세 해설

bodyFails=false

주 예외 메시지
second-close
억제 예외
first-close 1개
종료 순서
second, first 모두 close 시도

bodyFails=true

주 예외 메시지
body
억제 예외
second-close, first-close 2개
종료 순서
second, first 모두 close 시도

CloseFailureMatrixSolution의 두 호출을 언어 규칙으로 해석한 표입니다. 본문의 return도 자원 정리가 실패하면 반환되지 않습니다. 별도 실행 관측값은 아닙니다.

이 예의 조건

  • 언어 규칙 해석이며 새 실행 관측이 아닙니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보본문 성공·실패에 따른 주 예외와 억제 예외 본문 성공·실패에 따른 주 예외와 억제 예외 본문 성공·실패에 따른 주 예외와 억제 예외 자원 예외 · 본문 성공과 실패 본문 성공·실패에 따른 주 예외와 억제 예외 본문이 성공하면 second-close가, 본문이 실패하면 body가 주 예외가 됩니다. bodyFails=false 주 예외 메시지 second-close 억제 예외 first-close 1개 종료 순서 second, first 모두 close 시도 bodyFails=true 주 예외 메시지 body 억제 예외 second-close, first-close 2개 종료 순서 second, first 모두 close 시도 CloseFailureMatrixSolution의 두 호출을 언어 규칙으로 해석한 표입니다. 본문의 return도 자원 정리가 실패하면 반환되지 않습니다. 별도 실행 관측값은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
본문 결과

bodyFails=false는 성공, true는 body 예외입니다.

  • false: return 준비
  • true: 주 예외 body
  • 본문 완료 방식이 뒤 close 예외 우선순위를 결정합니다.
역순 정리

second 다음 first를 모두 close합니다.

  • close 예외: second-close, first-close
  • 정리: 둘 다 시도
  • 앞 close 실패가 다음 close 시도를 생략하지 않습니다.
예외 결과

본문 성공에서는 second-close가 주 예외입니다.

  • 성공 본문: suppressed first-close 1개
  • 실패 본문: body 주, suppressed 2개
  • 정리 실패가 있으면 본문의 return도 반환되지 않습니다.

본문 성공·실패에 따른 주 예외와 억제 예외

본문 실패 유무에 따라 second-close 또는 body 예외가 주 예외가 되고 나머지 close 예외가 억제 목록에 쌓입니다. 두 독립 호출을 나란히 추적하면 우선순위가 명확합니다.

본문 성공·실패에 따른 주 예외와 억제 예외

본문 성공·실패에 따른 주 예외와 억제 예외

자원 예외 · 본문 성공과 실패

본문 성공·실패에 따른 주 예외와 억제 예외

본문이 성공하면 second-close가, 본문이 실패하면 body가 주 예외가 됩니다.

본문 성공과 실패가 주 예외를 결정한다

본문 성공과 실패가 주 예외를 결정한다본문 성공인 false에서도 close 실패 때문에 본문 return은 반환되지 않는다. second-close가 주 예외이며 first-close 한 개가 억제된다. true면 body가 주이고 두 close 실패가 억제된다.falsetruebodyFails 조건false본문 성공truebody 실패주: second억제: first주: body억제: 두 개second → first 모두 closecatch의 Result 반환
본문 성공과 실패가 주 예외를 결정한다본문 성공인 false에서도 close 실패 때문에 본문 return은 반환되지 않는다. second-close가 주 예외이며 first-close 한 개가 억제된다. true면 body가 주이고 두 close 실패가 억제된다.falsetruebodyFails 조건false본문 성공truebody 실패주: second억제:first주: body억제: 두 개second → first 모두 closecatch의 Result 반환

본문 성공인 false에서도 close 실패 때문에 본문 return은 반환되지 않는다. second-close가 주 예외이며 first-close 한 개가 억제된다. true면 body가 주이고 두 close 실패가 억제된다.

상태별 상세 해설

bodyFails=false

주 예외 메시지
second-close
억제 예외
first-close 1개
종료 순서
second, first 모두 close 시도

bodyFails=true

주 예외 메시지
body
억제 예외
second-close, first-close 2개
종료 순서
second, first 모두 close 시도

CloseFailureMatrixSolution의 두 호출을 언어 규칙으로 해석한 표입니다. 본문의 return도 자원 정리가 실패하면 반환되지 않습니다. 별도 실행 관측값은 아닙니다.

이 예의 조건

  • 언어 규칙 해석이며 새 실행 관측이 아닙니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보본문 성공·실패에 따른 주 예외와 억제 예외 본문 성공·실패에 따른 주 예외와 억제 예외 본문 성공·실패에 따른 주 예외와 억제 예외 자원 예외 · 본문 성공과 실패 본문 성공·실패에 따른 주 예외와 억제 예외 본문이 성공하면 second-close가, 본문이 실패하면 body가 주 예외가 됩니다. bodyFails=false 주 예외 메시지 second-close 억제 예외 first-close 1개 종료 순서 second, first 모두 close 시도 bodyFails=true 주 예외 메시지 body 억제 예외 second-close, first-close 2개 종료 순서 second, first 모두 close 시도 CloseFailureMatrixSolution의 두 호출을 언어 규칙으로 해석한 표입니다. 본문의 return도 자원 정리가 실패하면 반환되지 않습니다. 별도 실행 관측값은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
본문 결과

bodyFails=false는 성공, true는 body 예외입니다.

  • false: return 준비
  • true: 주 예외 body
  • 본문 완료 방식이 뒤 close 예외 우선순위를 결정합니다.
역순 정리

second 다음 first를 모두 close합니다.

  • close 예외: second-close, first-close
  • 정리: 둘 다 시도
  • 앞 close 실패가 다음 close 시도를 생략하지 않습니다.
예외 결과

본문 성공에서는 second-close가 주 예외입니다.

  • 성공 본문: suppressed first-close 1개
  • 실패 본문: body 주, suppressed 2개
  • 정리 실패가 있으면 본문의 return도 반환되지 않습니다.
3번 해설

모두 알림으로 보내면 안 됩니다. 사용자가 앱을 닫으면 운영체제가 연결을 정상적으로 닫으므로 서버의 다음 읽기가 EOF를 만나는 것은 자연스러운 일이고, 그때마다 알림이 가면 진짜 장애 알림이 그 속에 묻힙니다. 기준은 예외의 종류가 아니라 연결이 끊긴 시점의 프로토콜 상태로 세웁니다. 요청과 요청 사이에 쉬고 있던 연결이 EOF로 끝났거나 exit을 주고받은 뒤에 끊겼다면 정상 이탈로 보고 정보 수준으로 기록합니다. 반대로 요청을 읽는 도중에 메시지가 잘렸거나 서버가 응답을 쓰는 도중에 SocketException이 났다면, 상대가 결과를 받았는지 알 수 없으므로 실패로 세어 지표에 포함합니다. 예외 메시지 문자열은 환경마다 다르므로 이 판단에 쓰지 않습니다.

다음 글

활성 세션 관리와 서버 종료에서는 오래 사는 세션들을 목록으로 관리하면서, 서버를 종료할 때 이미 연결된 세션까지 빠짐없이 닫는 방법을 만듭니다.