안동민 개발노트

본문 시작

try·catch·finally 흐름

예상하지 못한 예외가 disconnect를 건너뛰는 자원 누수를 재현하고 try/catch/finally의 실행 순서와 책임을 단계별로 고칩니다.

예외의 가장 큰 장점은 메서드마다 오류 코드를 확인하지 않아도 정상 경로를 연속해서 쓸 수 있다는 점입니다.

그러나 catch만 추가했다고 자원 정리가 자동 보장되지는 않습니다.

정상 종료나 예외 전파 때 필요한 정리는 정상 흐름이나 특정 catch 뒤가 아니라 finally에 놓아야 합니다. 다만 JVM 종료처럼 자바 실행 자체가 끝나는 상황까지 finally 실행을 보장하는 것은 아닙니다.


알 수 없는 예외와 정리 누락

서비스는 네트워크 전용 예외만 잡고 catch 다음에 disconnect를 호출합니다.

전송 중 IllegalStateException이 발생하면 해당 catch와 마지막 문장을 모두 건너뛰고 호출자에게 전파됩니다.

lab/DisconnectSkippedByUnexpectedFailure.java
public final class DisconnectSkippedByUnexpectedFailure {
    public static void main(String[] args) {
        NetworkClient client = new NetworkClient();
        try {
            client.connect();
            client.send();
        } catch (NetworkException error) {
            System.out.println("network=" + error.getMessage());
        }
        client.disconnect();
    }

    private static final class NetworkClient {
        void connect() throws NetworkException {
            System.out.println("connected");
        }

        void send() {
            throw new IllegalStateException("serializer crashed");
        }

        void disconnect() { System.out.println("disconnected"); }
    }

    private static final class NetworkException extends Exception {
        NetworkException(String message) { super(message); }
    }
}
관찰되는 표준 출력과 실패
connected
Exception in thread "main" java.lang.IllegalStateException: serializer crashed

disconnected가 없습니다.

catch가 없어서가 아니라 정리 호출의 위치가 특정 예외 목록에 의존하기 때문입니다.

앞으로 새 예외가 추가될 때마다 모든 catch 뒤 경로를 검토해야 한다면 구조적으로 안전하지 않습니다.


try·catch·finally의 역할

finally는 try 진입 뒤 정상 종료, 일치하는 catch 처리, 처리되지 않은 예외 전파 모두에서 실행됩니다.

연결이 실제로 성립한 뒤에만 닫아야 한다면 client가 내부 상태로 중복·미연결 close를 안전하게 처리하거나, 연결 여부를 서비스가 추적해야 합니다.

src/FinallyProtectedNetworkFlow.java
public final class FinallyProtectedNetworkFlow {
    public static void main(String[] args) {
        NetworkClient client = new NetworkClient();
        try {
            client.connect();
            client.send("error2");
        } catch (NetworkException error) {
            System.out.println("handled=" + error.getMessage());
        } finally {
            client.disconnect();
        }
        System.out.println("normal-end");
    }

    private static final class NetworkClient {
        private boolean connected;

        void connect() throws NetworkException {
            connected = true;
            System.out.println("connected");
        }

        void send(String data) throws NetworkException {
            if (data.contains("error2")) throw new NetworkException("send failed");
            System.out.println("sent=" + data);
        }

        void disconnect() {
            if (connected) {
                connected = false;
                System.out.println("disconnected");
            }
        }
    }

    private static final class NetworkException extends Exception {
        NetworkException(String message) { super(message); }
    }
}
connected
handled=send failed
disconnected
normal-end

이 예제는 catch와 finally가 모두 정상 완료하므로 뒤의 정상 문장까지 진행합니다.

만약 catch에서 처리하지 않은 RuntimeException이 생기면 finally가 정상 완료한 뒤 normal-end는 실행되지 않고 원래 예외가 상위로 전파됩니다.

정리를 보장하는 것과 오류를 복구하는 것은 서로 다른 책임입니다.


try·finally 구성

현재 계층에 복구 방법이 없고 자원만 정리해야 한다면 catch로 잡았다가 다시 던질 필요가 없습니다.

finally가 정상 완료하면 try/finally는 원래 예외를 그대로 전달하면서 정리만 수행합니다.

src/FinallyWithoutCatch.java
public final class FinallyWithoutCatch {
    public static void main(String[] args) {
        try {
            execute();
        } catch (BoardSendException error) {
            System.out.println("boundary=" + error.getMessage());
        }
    }

    private static void execute() throws BoardSendException {
        Session session = new Session();
        try {
            session.open();
            session.send();
        } finally {
            session.close();
        }
    }

    private static final class Session {
        void open() { System.out.println("open"); }
        void send() throws BoardSendException { throw new BoardSendException("timeout"); }
        void close() { System.out.println("close"); }
    }

    private static final class BoardSendException extends Exception {
        BoardSendException(String message) { super(message); }
    }
}
open
close
boundary=timeout

서비스는 로그를 남기거나 메시지를 바꾸지 않았습니다.

경계가 한 번만 처리하므로 중복 로그가 없습니다.

단, close 자체가 실패하면 원래 예외가 가려질 수 있습니다.

try-with-resources는 여러 close와 본문 실패를 다룰 때 억제 예외로 원인을 보존합니다.

정리 위치와 예외 처리 경계를 나눠 읽기

정리 호출을 catch 뒤에 둔 첫 예제는 disconnect를 건너뛴다. finally에서 정리하는 두 예제는 각각 지역 catch 후 normal-end로 이어지거나 상위 경계에 timeout을 전달한다.

앞의 세 프로그램은 서로 다른 실패를 사용합니다. 정리 실행 여부와 예외를 처리하는 위치를 구분해 비교합니다.

정리 위치와 예외 처리 경계를 나눠 읽기
원문 구성실패·처리와 정리관찰 순서
catch 뒤 정리send()의 IllegalStateException은 NetworkException catch와 뒤의 disconnect()를 건너뜁니다.
connected
이후 미처리 예외로 종료
disconnected 출력 없음
catch + finallysend("error2")의 예외를 catch가 처리하고, finally의 disconnect()도 정상 완료합니다.
connected
handled=send failed
disconnected
normal-end
try + finallyexecute()는 timeout을 잡지 않고 close()만 수행합니다. main의 catch가 전파된 예외를 처리합니다.
open
close
boundary=timeout
catch 뒤 정리
실패·처리와 정리: send()의 IllegalStateException은 NetworkException catch와 뒤의 disconnect()를 건너뜁니다.
관찰 순서:
connected
이후 미처리 예외로 종료
disconnected 출력 없음
catch + finally
실패·처리와 정리: send("error2")의 예외를 catch가 처리하고, finally의 disconnect()도 정상 완료합니다.
관찰 순서:
connected
handled=send failed
disconnected
normal-end
try + finally
실패·처리와 정리: execute()는 timeout을 잡지 않고 close()만 수행합니다. main의 catch가 전파된 예외를 처리합니다.
관찰 순서:
open
close
boundary=timeout

세 번째 예제의 close()는 정상 완료합니다. finally가 return이나 새 예외로 끝나면 기존 결과를 대체할 수 있으며, JVM 종료까지 정리를 보장하는 뜻은 아닙니다. 첫 예제의 실패 진단은 stderr이고 connected만 stdout입니다.


예외 계층과 catch 범위

연결 실패는 다른 주소로 재시도할 수 있지만 전송 실패는 사용자에게 입력 보존 안내만 할 수 있다고 가정합니다.

하나의 오류 코드 필드보다 하위 예외 타입을 만들면 catch가 타입별 데이터와 복구 정책을 선택할 수 있습니다.

src/NetworkExceptionHierarchyFlow.java
public final class NetworkExceptionHierarchyFlow {
    public static void main(String[] args) {
        run("connect");
        run("send");
    }

    private static void run(String scenario) {
        NetworkClient client = new NetworkClient(scenario);
        try {
            client.connect();
            client.send("board-data");
        } catch (ConnectException error) {
            System.out.println("retry-address=" + error.address());
        } catch (SendException error) {
            System.out.println("preserve-data=" + error.data());
        } finally {
            client.disconnect();
        }
    }

    private record NetworkClient(String scenario) {
        void connect() throws ConnectException {
            if (scenario.equals("connect")) {
                throw new ConnectException("https://board.local");
            }
        }

        void send(String data) throws SendException {
            if (scenario.equals("send")) throw new SendException(data);
        }

        void disconnect() { System.out.println("close=" + scenario); }
    }

    private static class NetworkException extends Exception {
        NetworkException(String message) { super(message); }
    }

    private static final class ConnectException extends NetworkException {
        private final String address;
        ConnectException(String address) { super("connect " + address); this.address = address; }
        String address() { return address; }
    }

    private static final class SendException extends NetworkException {
        private final String data;
        SendException(String data) { super("send " + data); this.data = data; }
        String data() { return data; }
    }
}
retry-address=https://board.local
close=connect
preserve-data=board-data
close=send

이 예제의 NetworkException은 공통 부모이고, 실제 메서드는 각각 throws ConnectException, throws SendException을 선언합니다. 자식은 복구에 필요한 문맥을 제공합니다.

모든 자식을 같은 방식으로 처리하면 부모 하나를 잡아도 되지만, 구체 정책이 다르면 자식 catch를 먼저 둡니다.

서로 다른 catch와 공통 disconnect

NetworkExceptionHierarchyFlow의 connect 시나리오는 ConnectException 주소를 출력한 뒤 close=connect로 끝나며 send 시나리오는 SendException 데이터를 출력한 뒤 close=send로 끝난다.

NetworkExceptionHierarchyFlow의 두 호출입니다. 구체 catch의 문맥 정보와 공통 finally 정리는 서로 다른 역할입니다.

서로 다른 catch와 공통 disconnect
원문 시나리오도달한 실패와 catch원문 출력
run("connect")connect()가 ConnectException을 던져 주소를 받습니다. send()는 도달하지 않습니다.
retry-address=
https://board.local
close=connect
run("send")connect()가 끝난 뒤 send()가 SendException을 던져 데이터를 받습니다.
preserve-data=
board-data
close=send
run("connect")
도달한 실패와 catch: connect()가 ConnectException을 던져 주소를 받습니다. send()는 도달하지 않습니다.
원문 출력:
retry-address=
https://board.local
close=connect
run("send")
도달한 실패와 catch: connect()가 끝난 뒤 send()가 SendException을 던져 데이터를 받습니다.
원문 출력:
preserve-data=
board-data
close=send

retry-address=와 주소는 한 출력 줄, preserve-data=와 데이터도 한 출력 줄입니다. 실제 코드는 재시도나 저장을 수행하지 않고 정책에 필요한 값을 출력합니다. 메서드 선언은 각각 throws ConnectException, throws SendException이며 NetworkException은 공통 부모입니다.


finally에 부적합한 코드

finally에는 자원 해제, 잠금 해제, 임시 상태 복구처럼 성공 여부와 관계없이 필요한 최소 동작을 둡니다.

return을 넣으면 try의 반환값이나 진행 중인 예외를 덮을 수 있으므로 피합니다.

정리 중 발생한 예외를 무조건 삼키는 것도 장애 증거를 없앱니다.

긴 업무 로직을 finally에 넣으면 원래 실패와 정리 실패의 우선순위가 모호해집니다.

닫기 작업은 가능하면 멱등적으로 설계하고, 미연결 상태 close가 안전한지 계약을 정합니다.

여러 자원을 역순으로 닫아야 하거나 close 실패까지 보존해야 하면 AutoCloseable과 try-with-resources를 선택합니다.


연습 문제

Connection을 먼저 열고 Channel을 나중에 열었습니다.

전송 중 예외가 발생해도 Channel, Connection 순서로 닫히고 경계에서 원래 메시지를 확인하도록 중첩 try/finally를 작성하세요.

해설 보기
src/NestedFinallyExercise.java
public final class NestedFinallyExercise {
    public static void main(String[] args) {
        try {
            useResources();
        } catch (IllegalStateException error) {
            System.out.println("boundary=" + error.getMessage());
        }
    }

    private static void useResources() {
        Resource connection = new Resource("connection");
        connection.open();
        try {
            Resource channel = new Resource("channel");
            channel.open();
            try {
                throw new IllegalStateException("send failed");
            } finally {
                channel.close();
            }
        } finally {
            connection.close();
        }
    }

    private record Resource(String name) {
        void open() { System.out.println("open=" + name); }
        void close() { System.out.println("close=" + name); }
    }
}
open=connection
open=channel
close=channel
close=connection
boundary=send failed

나중에 확보한 자원을 먼저 반환해야 의존 관계가 깨지지 않습니다.

중첩 구조가 복잡해지는 순간 try-with-resources가 같은 순서를 더 안전하고 간결하게 표현합니다.