본문 바로가기

try·catch·finally 실행 순서와 정리 코드

예외가 나도 연결 해제 같은 정리 코드가 실행되도록 finally를 쓰는 방법과, 상황마다 try·catch·finally가 어떤 순서로 실행되는지 배웁니다.

네트워크 연결을 열었다면 작업이 성공하든 실패하든 연결을 닫아야 합니다. 반환 코드로 실패를 알리는 방식에서는 호출자가 결과를 하나하나 확인하다가 닫는 코드를 빠뜨리기 쉬웠습니다. 예외를 쓰면 매번 오류 검사를 끼워 넣지 않고도 정상 흐름을 이어서 쓸 수 있고, 실패는 catch가 따로 받아 줍니다.

그렇다고 정리 문제까지 저절로 풀리지는 않습니다. 첫 글의 연습 문제 2에서 catch가 잡지 않는 예외가 나자 disconnected가 출력되지 않았던 것이 그 예입니다. 이 글에서는 그 상황을 다시 재현하고, 어떤 경로로 빠져나가든 실행되는 finally 블록으로 고칩니다.

정리 코드를 catch 뒤에 두면 생기는 일

다음 프로그램은 연결하고(connect), 데이터를 보낸 뒤(send), 마지막으로 연결을 끊습니다(disconnect). 개발자는 네트워크 오류인 NetworkException만 예상해서 그것만 catch로 잡고, try 문이 끝난 다음 줄에 disconnect()를 두었습니다. 그런데 send()가 직렬화 도중 예상하지 못한 IllegalStateException을 던진다고 해 봅시다.

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
	at DisconnectSkippedByUnexpectedFailure$NetworkClient.send(DisconnectSkippedByUnexpectedFailure.java:19)
	at DisconnectSkippedByUnexpectedFailure.main(DisconnectSkippedByUnexpectedFailure.java:6)

첫 줄 connected는 표준 출력으로, 나머지 세 줄은 표준 오류로 나옵니다. 어느 쪽에도 disconnected는 없습니다.

IllegalStateException은 RuntimeException의 자식인 비검사 예외라서, send()에 throws가 없어도 컴파일이 됩니다. 실행 중에 이 예외가 생기면 자바는 예외를 받아 줄 catch를 찾습니다. 그런데 하나뿐인 catch (NetworkException error)는 타입이 맞지 않으므로 건너뛰고, 예외는 try 문을 빠져나가 main을 호출한 쪽으로 올라갑니다. 예외가 전파되는 동안에는 try 문 다음 문장이 실행되지 않으므로 client.disconnect()도 실행되지 않습니다. 결국 main이 그대로 끝나고, JVM의 기본 처리기가 표준 오류에 예외와 스택 트레이스를 출력합니다.

문제는 catch가 부족하다는 데 있지 않습니다. 정리 코드가 '예외가 없었거나, 나열한 예외 가운데 하나가 났을 때'만 실행되는 자리에 있다는 것이 문제입니다. catch를 catch (Exception error)로 넓히면 이 예제에서는 disconnected가 출력되기는 합니다. 하지만 직렬화 버그가 network=serializer crashed라는 네트워크 오류처럼 처리되어 진짜 원인이 묻히고, Exception 계열이 아닌 Error가 나면 여전히 정리를 건너뜁니다. 예외의 종류와 상관없이 실행되는 자리가 따로 필요합니다.

finally는 try를 어떻게 빠져나가든 실행된다

try 문에는 catch 뒤에 finally 블록을 붙일 수 있습니다. finally 블록은 try 블록에 일단 들어갔다면, 그 블록을 어떤 방식으로 빠져나가든 try 문이 끝나기 직전에 실행됩니다. 앞 프로그램의 main을 다음처럼 고쳐 보겠습니다. disconnect()를 finally 안으로 옮기고, try 문 다음까지 실행이 이어지는지 확인하려고 normal-end 출력을 하나 더했습니다.

    public static void main(String[] args) {
        NetworkClient client = new NetworkClient();
        try {
            client.connect();
            client.send();
        } catch (NetworkException error) {
            System.out.println("network=" + error.getMessage());
        } finally {
            client.disconnect();
        }
        System.out.println("normal-end");
    }
실행 결과
connected
disconnected
Exception in thread "main" java.lang.IllegalStateException: serializer crashed
	at DisconnectSkippedByUnexpectedFailure$NetworkClient.send(DisconnectSkippedByUnexpectedFailure.java:21)
	at DisconnectSkippedByUnexpectedFailure.main(DisconnectSkippedByUnexpectedFailure.java:6)

이번에는 disconnected가 예외 메시지보다 먼저 나옵니다. send()에서 예외가 나고 맞는 catch가 없다는 것까지는 같지만, 예외가 try 문을 빠져나가기 직전에 finally 블록이 실행되기 때문입니다. finally가 정상적으로 끝나면 예외는 하던 일을 계속해 main 밖으로 전파되므로 normal-end는 여전히 출력되지 않습니다. 즉 finally는 예외를 처리하지 않습니다. 예외를 멈추지도 바꾸지도 않고, 지나가는 길에 정리 코드만 실행합니다. 스택 트레이스에서 send의 줄 번호가 19에서 21로 바뀐 것은 main에 두 줄이 늘어났기 때문입니다.

catch로 처리하고 finally로 정리하기

예외를 catch에서 처리할 수 있는 경우에도 정리 코드는 finally에 둡니다. 다음 프로그램의 send()는 데이터에 error2가 들어 있으면 NetworkException을 던지고, 이번에는 그 예외를 catch가 잡습니다. disconnect()가 connected 필드를 확인하는 이유는 실행 결과 뒤에 설명합니다.

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

connect()가 connected를 출력한 뒤 send("error2")가 NetworkException을 던집니다. 이번에는 타입이 맞는 catch가 있으므로 그 블록이 실행되어 handled=send failed가 나옵니다. catch가 끝나면 finally가 실행되어 disconnected가 출력되고, 예외는 catch에서 처리가 끝났으므로 실행은 try 문 다음의 normal-end까지 이어집니다. 이처럼 실행 순서는 언제나 try, 타입이 맞는 catch, finally 순입니다.

connected 필드가 필요한 이유는 finally가 연결 성공 여부와 상관없이 실행되기 때문입니다. connect() 자체가 실패하면 연결한 적이 없는데도 finally에서 disconnect()가 호출됩니다. 예를 들어 connect()가 NetworkException("connect refused")를 던지도록 바꾸면 출력은 handled=connect refused와 normal-end 두 줄뿐이고, connected가 false이므로 disconnect()는 아무것도 하지 않습니다. 그래서 정리 메서드는 연결하지 않은 상태에서 불리거나 두 번 불려도 문제가 없게 만들어 두는 것이 좋습니다. 두 번째 호출부터는 아무 효과가 없는 이런 성질을 멱등성(idempotency)이라고 합니다.

상황별 실행 순서

지금까지 본 경우를 표로 정리하면 다음과 같습니다. 표의 마지막 줄처럼 return, break, continue로 try 블록을 빠져나갈 때도 finally는 빠져나가기 직전에 실행됩니다.

try 블록이 끝나는 방식실행되는 catchfinallytry 문 다음 문장
예외 없이 끝까지 실행없음실행실행
타입이 맞는 catch가 있는 예외맞는 것 하나catch 다음에 실행catch가 정상적으로 끝나면 실행
타입이 맞는 catch가 없는 예외없음실행건너뜀(예외가 계속 전파됨)
return, break, continue없음빠져나가기 직전에 실행그 문장이 정한 곳으로 이동

catch 블록 안에서 새 예외가 나는 경우도 같은 원리를 따릅니다. finally가 먼저 실행되고, 그다음 새 예외가 바깥으로 전파됩니다.

더 깊이: finally가 실행되지 않는 경우와 반환값이 정해지는 시점

finally는 try 블록에 들어간 스레드가 그 블록을 빠져나갈 때 실행됩니다. 따라서 스레드가 블록을 빠져나가지 못하고 멈추면 실행되지 않습니다. try 블록에서 System.exit(0)을 호출하면 JVM이 그 자리에서 종료 절차에 들어가므로 finally는 실행되지 않습니다. 운영체제가 프로세스를 강제로 끝내거나 try 블록이 무한 반복에 빠진 경우도 마찬가지입니다. 그러니 finally는 '자바 코드의 흐름이 이어지는 한 실행되는 정리 장소'로 이해하는 것이 정확합니다.

try 블록의 return과 finally의 순서도 헷갈리기 쉽습니다. return count;를 만나면 자바는 반환할 값을 먼저 계산해 따로 보관하고, finally를 실행한 다음 보관해 둔 값을 돌려줍니다. 다음 프로그램으로 확인할 수 있습니다.

src/FinallyAndReturnValue.java
public final class FinallyAndReturnValue {
    public static void main(String[] args) {
        System.out.println("count=" + count());
        System.out.println("text=" + text());
    }

    private static int count() {
        int count = 10;
        try {
            return count;
        } finally {
            count = 20;
            System.out.println("finally count=" + count);
        }
    }

    private static StringBuilder text() {
        StringBuilder text = new StringBuilder("A");
        try {
            return text;
        } finally {
            text.append("B");
        }
    }
}
finally count=20
count=10
text=AB

count()는 finally에서 지역 변수 count를 20으로 바꿨는데도 10을 반환합니다. 반환값 10은 finally가 실행되기 전에 이미 계산되어 보관되어 있었기 때문입니다. 반면 text()는 AB를 반환합니다. 보관된 반환값은 StringBuilder 객체를 가리키는 참조이고, finally는 변수가 아니라 그 객체의 내용을 바꿨기 때문입니다. 기본형 값을 복사하는 것과 참조를 복사하는 것의 차이가 여기서도 그대로 드러납니다.

잡지 않고 정리만 할 때는 try-finally

catch와 finally는 하는 일이 다릅니다. catch는 실패에 대응하는 코드이고, finally는 성공하든 실패하든 해야 하는 정리 코드입니다. 그래서 지금 메서드에서 실패에 대응할 방법이 없다면 catch 없이 try와 finally만 쓸 수 있습니다. 다음 예제의 execute()는 세션을 열고 메시지를 보내는데, 전송이 시간 초과로 실패해도 이 메서드가 할 수 있는 일은 세션을 닫는 것뿐입니다. 실패를 사용자에게 어떻게 알릴지는 호출한 main이 정합니다.

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

open이 출력된 뒤 send()가 BoardSendException을 던집니다. execute()에는 catch가 없으므로 예외는 메서드 밖으로 나가려 하고, 그 직전에 finally가 실행되어 close가 출력됩니다. 그다음 예외가 main에 도착하면 main의 catch가 boundary=timeout을 출력합니다. BoardSendException은 검사 예외이므로 execute()에는 throws BoardSendException 선언이 여전히 필요합니다. finally가 예외를 처리하지 않고 그대로 내보내기 때문입니다.

execute()에 catch를 넣어 로그를 남기고 다시 던질 수도 있습니다. 하지만 그러면 같은 실패가 execute()와 main에서 두 번 기록되고 코드만 길어집니다. 대응은 대응할 수 있는 곳에서 한 번만 하고, 정리는 자원을 연 메서드가 finally로 하도록 나누면 메서드마다 할 일이 분명해집니다.

지금까지 본 세 프로그램을 나란히 놓으면 차이가 분명합니다. 첫 프로그램은 정리 코드를 catch 뒤에 두어 disconnect를 건너뛰었습니다. 둘째는 catch로 처리한 뒤 finally에서 정리하고 normal-end까지 이어졌고, 셋째는 예외를 잡지 않고 finally에서 정리만 한 뒤 예외를 main에 넘겼습니다.

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

catch 불일치로 정리 생략, catch+finally 복구, finally 후 경계 전파라는 세 독립 실행 순서입니다. 각 프로그램의 도달·미도달·정상 완료를 단계별로 대조하며 정리와 복구의 차이를 확인할 수 있습니다.

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

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

정리 책임 · 세 구현 비교

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

서로 다른 실패를 사용하는 세 프로그램입니다. 정리의 위치와 예외를 처리하는 경계를 나눠 비교합니다.

정리와 복구 경계를 구분하는 세 프로그램 영역마다 다른 프로그램과 실패입니다. 첫 예제의 IllegalStateException은 NetworkException catch 및 뒤 disconnect를 건너뜁니다. 둘째는 지역 catch 후 finally의 disconnect와 normal-end로 이어집니다. 셋째는 timeout을 처리하지 않고 finally의 close가 정상 완료한 뒤 main의 경계가 처리합니다. finally의 새 실패나 return, JVM 종료까지 포함한 절대 보장으로 일반화하지 않습니다. 원문 A · catch 뒤 정리 connect 뒤 실패 connected 출력 IllegalStateException catch가 안 맞음 NetworkException만 잡음 실제 예외는 통과 뒤 정리 호출 건너뜀 disconnect 호출 안 함 미처리 예외로 스레드 끝 원문 B · catch + finally connect 뒤 send 실패 connected 출력 NetworkException catch의 지역 복구 handled=send failed finally 뒤 정상 끝 disconnected 출력 normal-end 출력 원문 C · try + finally open 뒤 timeout open 출력 예외는 아직 처리 안 함 finally의 정리 close 출력 정리가 정상 완료 main의 경계 catch boundary=timeout 서로 다른 프로그램·실패 · 실선은 실제 전달·정리 순서 · finally가 정상 완료한 경로
정리와 복구 경계를 구분하는 세 프로그램 영역마다 다른 프로그램과 실패입니다. 첫 예제의 IllegalStateException은 NetworkException catch 및 뒤 disconnect를 건너뜁니다. 둘째는 지역 catch 후 finally의 disconnect와 normal-end로 이어집니다. 셋째는 timeout을 처리하지 않고 finally의 close가 정상 완료한 뒤 main의 경계가 처리합니다. finally의 새 실패나 return, JVM 종료까지 포함한 절대 보장으로 일반화하지 않습니다. 원문 A · catch 뒤 정리 connect 뒤 실패 connected 출력 IllegalStateException catch가 안 맞음 NetworkException만 잡음 실제 예외는 통과 뒤 정리 호출 건너뜀 disconnect 호출 안 함 미처리 예외로 스레드 끝 원문 B · catch + finally connect 뒤 send 실패 connected 출력 NetworkException catch의 지역 복구 handled=send failed finally 뒤 정상 끝 disconnected 출력 normal-end 출력 원문 C · try + finally open 뒤 timeout open 출력 예외는 아직 처리 안 함 finally의 정리 close 출력 정리가 정상 완료 main의 경계 catch boundary=timeout 서로 다른 프로그램·실패 실선은 실제 전달·정리 순서 finally가 정상 완료한 경로
정리 위치와 예외 처리 경계를 나눠 읽기
원문 구성실패·처리와 정리관찰 순서
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입니다.

이 예의 조건

  • 서로 다른 세 프로그램이며 finally가 정상 완료하는 경로다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보정리 위치와 예외 처리 경계를 나눠 읽기 정리 위치와 예외 처리 경계를 나눠 읽기 정리 호출을 catch 뒤에 둔 첫 예제는 disconnect를 건너뛴다. finally에서 정리하는 두 예제는 각각 지역 catch 후 normal-end로 이어지거나 상위 경계에 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 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입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원 실패

각 프로그램의 작업에서 실패가 발생합니다.

  • A: IllegalStateException
  • B: NetworkException
  • C: timeout
  • 실제 예외 타입과 처리 경계를 비교한다.
지역 처리/정리

A는 catch 불일치, B는 catch 뒤 finally, C는 finally만 진행합니다.

  • A: disconnect 미호출
  • B: handled=send failed → disconnected
  • C: close
  • 정리가 예외 처리와 동일 동작은 아니다.
호출 종료

각 실패가 서로 다른 종료로 이어집니다.

  • A: 미처리 스레드 종료
  • B: normal-end
  • C: boundary=timeout
  • finally 정상 완료는 전달 중인 예외를 자동 복구하지 않는다.

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

catch 불일치로 정리 생략, catch+finally 복구, finally 후 경계 전파라는 세 독립 실행 순서입니다. 각 프로그램의 도달·미도달·정상 완료를 단계별로 대조하며 정리와 복구의 차이를 확인할 수 있습니다.

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

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

정리 책임 · 세 구현 비교

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

서로 다른 실패를 사용하는 세 프로그램입니다. 정리의 위치와 예외를 처리하는 경계를 나눠 비교합니다.

정리와 복구 경계를 구분하는 세 프로그램 영역마다 다른 프로그램과 실패입니다. 첫 예제의 IllegalStateException은 NetworkException catch 및 뒤 disconnect를 건너뜁니다. 둘째는 지역 catch 후 finally의 disconnect와 normal-end로 이어집니다. 셋째는 timeout을 처리하지 않고 finally의 close가 정상 완료한 뒤 main의 경계가 처리합니다. finally의 새 실패나 return, JVM 종료까지 포함한 절대 보장으로 일반화하지 않습니다. 원문 A · catch 뒤 정리 connect 뒤 실패 connected 출력 IllegalStateException catch가 안 맞음 NetworkException만 잡음 실제 예외는 통과 뒤 정리 호출 건너뜀 disconnect 호출 안 함 미처리 예외로 스레드 끝 원문 B · catch + finally connect 뒤 send 실패 connected 출력 NetworkException catch의 지역 복구 handled=send failed finally 뒤 정상 끝 disconnected 출력 normal-end 출력 원문 C · try + finally open 뒤 timeout open 출력 예외는 아직 처리 안 함 finally의 정리 close 출력 정리가 정상 완료 main의 경계 catch boundary=timeout 서로 다른 프로그램·실패 · 실선은 실제 전달·정리 순서 · finally가 정상 완료한 경로
정리와 복구 경계를 구분하는 세 프로그램 영역마다 다른 프로그램과 실패입니다. 첫 예제의 IllegalStateException은 NetworkException catch 및 뒤 disconnect를 건너뜁니다. 둘째는 지역 catch 후 finally의 disconnect와 normal-end로 이어집니다. 셋째는 timeout을 처리하지 않고 finally의 close가 정상 완료한 뒤 main의 경계가 처리합니다. finally의 새 실패나 return, JVM 종료까지 포함한 절대 보장으로 일반화하지 않습니다. 원문 A · catch 뒤 정리 connect 뒤 실패 connected 출력 IllegalStateException catch가 안 맞음 NetworkException만 잡음 실제 예외는 통과 뒤 정리 호출 건너뜀 disconnect 호출 안 함 미처리 예외로 스레드 끝 원문 B · catch + finally connect 뒤 send 실패 connected 출력 NetworkException catch의 지역 복구 handled=send failed finally 뒤 정상 끝 disconnected 출력 normal-end 출력 원문 C · try + finally open 뒤 timeout open 출력 예외는 아직 처리 안 함 finally의 정리 close 출력 정리가 정상 완료 main의 경계 catch boundary=timeout 서로 다른 프로그램·실패 실선은 실제 전달·정리 순서 finally가 정상 완료한 경로
정리 위치와 예외 처리 경계를 나눠 읽기
원문 구성실패·처리와 정리관찰 순서
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입니다.

이 예의 조건

  • 서로 다른 세 프로그램이며 finally가 정상 완료하는 경로다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보정리 위치와 예외 처리 경계를 나눠 읽기 정리 위치와 예외 처리 경계를 나눠 읽기 정리 호출을 catch 뒤에 둔 첫 예제는 disconnect를 건너뛴다. finally에서 정리하는 두 예제는 각각 지역 catch 후 normal-end로 이어지거나 상위 경계에 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 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입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원 실패

각 프로그램의 작업에서 실패가 발생합니다.

  • A: IllegalStateException
  • B: NetworkException
  • C: timeout
  • 실제 예외 타입과 처리 경계를 비교한다.
지역 처리/정리

A는 catch 불일치, B는 catch 뒤 finally, C는 finally만 진행합니다.

  • A: disconnect 미호출
  • B: handled=send failed → disconnected
  • C: close
  • 정리가 예외 처리와 동일 동작은 아니다.
호출 종료

각 실패가 서로 다른 종료로 이어집니다.

  • A: 미처리 스레드 종료
  • B: normal-end
  • C: boundary=timeout
  • finally 정상 완료는 전달 중인 예외를 자동 복구하지 않는다.

정리 코드가 실패하면 원래 예외가 사라진다

try-finally에도 약점이 있습니다. finally 안의 정리 코드가 예외를 던지면, 그 새 예외가 전파되던 원래 예외를 대신합니다. 다음 프로그램은 전송이 send timeout으로 실패한 상태에서 close()마저 실패하는 경우입니다.

lab/CloseFailureHidesCause.java
public final class CloseFailureHidesCause {
    public static void main(String[] args) {
        try {
            execute();
        } catch (IllegalStateException error) {
            System.out.println("caught=" + error.getMessage());
        }
    }

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

    private static final class Session {
        void send() { throw new IllegalStateException("send timeout"); }
        void close() { throw new IllegalStateException("close failed"); }
    }
}
caught=close failed

main이 받은 예외는 close failed이고, 먼저 일어난 send timeout은 출력 어디에도 없습니다. 원래 예외는 버려졌고 새 예외에 원인으로 연결되지도 않습니다. 운영 중인 서비스라면 '닫기 실패'라는 기록만 보고 실제 원인을 찾아야 하는 셈입니다. 이 문제는 try-with-resources를 다루는 글에서 억제된 예외(suppressed exception)로 해결합니다.

실패 종류별로 다르게 처리하고 정리는 한곳에서

실패마다 대응이 다른 경우가 많습니다. 연결 실패는 다른 주소로 다시 시도할 수 있지만, 전송 실패는 사용자가 쓴 내용을 잃지 않게 보관하라고 안내하는 정도가 최선일 수 있습니다. 이럴 때는 실패 종류마다 예외 클래스를 나누고 공통 부모 아래에 둡니다. 다음 예제에서 ConnectException과 SendException은 둘 다 NetworkException을 상속하고, 각자 대응에 필요한 값을 필드로 가집니다. 연결 실패는 접속하려던 주소를, 전송 실패는 보내려던 데이터를 담습니다.

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

run("connect")에서는 connect()가 ConnectException을 던지므로 send()는 실행되지 않습니다. 자바는 catch를 위에서부터 차례로 보면서 예외 객체의 타입과 맞는 첫 번째 블록 하나만 실행합니다. 그래서 첫 번째 catch가 retry-address=https://board.local을 출력하고 두 번째 catch는 건너뛴 뒤, finally가 close=connect를 출력합니다. run("send")에서는 connect()가 성공한 뒤 send()가 SendException을 던지므로 두 번째 catch가 실행되고, 역시 finally가 close=send를 출력합니다. 대응은 두 갈래로 나뉘었지만 정리 코드는 finally 한 곳에만 있습니다. 이 예제는 실제로 재시도하거나 데이터를 저장하지 않고, 어떤 대응을 할지와 거기에 쓸 값만 출력합니다.

catch가 받은 예외 객체에서 error.address()나 error.data()를 바로 꺼내 쓴다는 점도 눈여겨보세요. 오류 코드 문자열 하나로 실패를 알렸다면 문자열을 비교한 뒤 필요한 값을 따로 구해야 했을 것입니다. 예외 타입을 나누면 어떤 실패인지와 대응에 필요한 정보가 한 객체에 함께 담깁니다. 반대로 두 실패를 똑같이 처리한다면 자식을 따로 잡을 필요 없이 catch (NetworkException error) 하나로 충분합니다. 부모 타입의 catch는 자식 예외도 모두 잡기 때문입니다. 자식과 부모의 catch를 함께 쓴다면 예외 계층을 다룬 글에서 본 것처럼 자식을 먼저 써야 하고, 예외 클래스를 어떤 기준으로 나눌지는 다음 글에서 다룹니다.

서로 다른 catch와 공통 disconnect

같은 main의 connect/send 호출이 각각 catch 뒤 finally로 자원을 닫습니다. 실제 네 출력의 순서를 추적하면 오류 안내와 실제 재시도를 구분할 수 있습니다.

서로 다른 catch와 공통 disconnect

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

구체 catch · 공통 정리

서로 다른 catch와 공통 disconnect

연결과 전송 실패는 서로 다른 catch에서 문맥을 읽습니다. 두 호출 모두 finally에서 disconnect를 실행합니다.

실제 실패 문맥과 공통 finally 정리 같은 main이 connect와 send 시나리오를 차례로 호출합니다. 연결 실패의 ConnectException catch는 주소를 읽고 전송 실패의 SendException catch는 데이터를 읽습니다. 각 호출의 finally가 정상 완료하며 close=connect 또는 close=send를 출력합니다. 정책 문자열을 출력할 뿐 재시도나 저장을 실행하지 않습니다. 첫 호출 · connect 입력 connect에서 던짐 ConnectException send에는 도달 안 함 연결 실패 catch 주소 문맥을 읽음 retry-address= https://board.local finally의 disconnect close=connect 다음 호출 · send 입력 send에서 던짐 connect는 정상 완료 SendException 전송 실패 catch 데이터 문맥을 읽음 preserve-data=board-data finally의 disconnect close=send 한 main의 서로 다른 두 호출 · 실선은 각 호출의 실제 순서 · 재시도·데이터 저장 실행 아님
실제 실패 문맥과 공통 finally 정리 같은 main이 connect와 send 시나리오를 차례로 호출합니다. 연결 실패의 ConnectException catch는 주소를 읽고 전송 실패의 SendException catch는 데이터를 읽습니다. 각 호출의 finally가 정상 완료하며 close=connect 또는 close=send를 출력합니다. 정책 문자열을 출력할 뿐 재시도나 저장을 실행하지 않습니다. 첫 호출 · connect 입력 connect에서 던짐 ConnectException send에는 도달 안 함 연결 실패 catch 주소 문맥을 읽음 retry-address= https://board.local finally의 disconnect close=connect 다음 호출 · send 입력 send에서 던짐 connect는 정상 완료 SendException 전송 실패 catch 데이터 문맥을 읽음 preserve-data=board-data finally의 disconnect close=send 한 main의 서로 다른 두 호출 실선은 각 호출의 실제 순서 재시도·데이터 저장 실행 아님

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

출력 박스의 줄은 원문 stdout과 같습니다. 실제 재시도나 저장은 수행하지 않고 정책에 필요한 값만 출력합니다. 메서드는 각각 throws ConnectException, throws SendException을 선언하며 NetworkException은 공통 부모입니다.

이 예의 조건

  • 정책 출력만 수행하며 실제 재시도·데이터 저장은 없다.
  • 두 시나리오는 동일 main의 서로 다른 호출이다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보서로 다른 catch와 공통 disconnect 서로 다른 catch와 공통 disconnect NetworkExceptionHierarchyFlow의 connect 시나리오는 ConnectException 주소를 출력한 뒤 close=connect로 끝나며 send 시나리오는 SendException 데이터를 출력한 뒤 close=send로 끝난다. 구체 catch · 공통 정리 서로 다른 catch와 공통 disconnect 연결과 전송 실패는 서로 다른 catch에서 문맥을 읽습니다. 두 호출 모두 finally에서 disconnect를 실행합니다. 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 출력 박스의 줄은 원문 stdout과 같습니다. 실제 재시도나 저장은 수행하지 않고 정책에 필요한 값만 출력합니다. 메서드는 각각 throws ConnectException, throws SendException을 선언하며 NetworkException은 공통 부모입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
연결 시나리오

connect 실패로 send를 건너뜁니다.

  • 예외: ConnectException
  • send: 미도달
  • 연결 실패가 후속 전송 허가를 없앤다.
연결 안내와 정리

주소 문맥을 출력하고 finally가 닫습니다.

  • 정책: retry-address=https://board.local
  • 정리: close=connect
  • 실패를 안내한 뒤 이 호출의 자원을 정리한다.
전송 시나리오

다음 호출의 connect 성공 뒤 send가 실패합니다.

  • 예외: SendException
  • 문맥: board-data
  • 연결 성공과 전송 성공은 서로 다른 조건이다.
전송 안내와 정리

데이터 문맥 안내 뒤 finally가 닫습니다.

  • 정책: preserve-data=board-data
  • 정리: close=send
  • 실패 문맥을 보존하는 정책 문자열과 실제 저장을 구분한다.

서로 다른 catch와 공통 disconnect

같은 main의 connect/send 호출이 각각 catch 뒤 finally로 자원을 닫습니다. 실제 네 출력의 순서를 추적하면 오류 안내와 실제 재시도를 구분할 수 있습니다.

서로 다른 catch와 공통 disconnect

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

구체 catch · 공통 정리

서로 다른 catch와 공통 disconnect

연결과 전송 실패는 서로 다른 catch에서 문맥을 읽습니다. 두 호출 모두 finally에서 disconnect를 실행합니다.

실제 실패 문맥과 공통 finally 정리 같은 main이 connect와 send 시나리오를 차례로 호출합니다. 연결 실패의 ConnectException catch는 주소를 읽고 전송 실패의 SendException catch는 데이터를 읽습니다. 각 호출의 finally가 정상 완료하며 close=connect 또는 close=send를 출력합니다. 정책 문자열을 출력할 뿐 재시도나 저장을 실행하지 않습니다. 첫 호출 · connect 입력 connect에서 던짐 ConnectException send에는 도달 안 함 연결 실패 catch 주소 문맥을 읽음 retry-address= https://board.local finally의 disconnect close=connect 다음 호출 · send 입력 send에서 던짐 connect는 정상 완료 SendException 전송 실패 catch 데이터 문맥을 읽음 preserve-data=board-data finally의 disconnect close=send 한 main의 서로 다른 두 호출 · 실선은 각 호출의 실제 순서 · 재시도·데이터 저장 실행 아님
실제 실패 문맥과 공통 finally 정리 같은 main이 connect와 send 시나리오를 차례로 호출합니다. 연결 실패의 ConnectException catch는 주소를 읽고 전송 실패의 SendException catch는 데이터를 읽습니다. 각 호출의 finally가 정상 완료하며 close=connect 또는 close=send를 출력합니다. 정책 문자열을 출력할 뿐 재시도나 저장을 실행하지 않습니다. 첫 호출 · connect 입력 connect에서 던짐 ConnectException send에는 도달 안 함 연결 실패 catch 주소 문맥을 읽음 retry-address= https://board.local finally의 disconnect close=connect 다음 호출 · send 입력 send에서 던짐 connect는 정상 완료 SendException 전송 실패 catch 데이터 문맥을 읽음 preserve-data=board-data finally의 disconnect close=send 한 main의 서로 다른 두 호출 실선은 각 호출의 실제 순서 재시도·데이터 저장 실행 아님

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

출력 박스의 줄은 원문 stdout과 같습니다. 실제 재시도나 저장은 수행하지 않고 정책에 필요한 값만 출력합니다. 메서드는 각각 throws ConnectException, throws SendException을 선언하며 NetworkException은 공통 부모입니다.

이 예의 조건

  • 정책 출력만 수행하며 실제 재시도·데이터 저장은 없다.
  • 두 시나리오는 동일 main의 서로 다른 호출이다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보서로 다른 catch와 공통 disconnect 서로 다른 catch와 공통 disconnect NetworkExceptionHierarchyFlow의 connect 시나리오는 ConnectException 주소를 출력한 뒤 close=connect로 끝나며 send 시나리오는 SendException 데이터를 출력한 뒤 close=send로 끝난다. 구체 catch · 공통 정리 서로 다른 catch와 공통 disconnect 연결과 전송 실패는 서로 다른 catch에서 문맥을 읽습니다. 두 호출 모두 finally에서 disconnect를 실행합니다. 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 출력 박스의 줄은 원문 stdout과 같습니다. 실제 재시도나 저장은 수행하지 않고 정책에 필요한 값만 출력합니다. 메서드는 각각 throws ConnectException, throws SendException을 선언하며 NetworkException은 공통 부모입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
연결 시나리오

connect 실패로 send를 건너뜁니다.

  • 예외: ConnectException
  • send: 미도달
  • 연결 실패가 후속 전송 허가를 없앤다.
연결 안내와 정리

주소 문맥을 출력하고 finally가 닫습니다.

  • 정책: retry-address=https://board.local
  • 정리: close=connect
  • 실패를 안내한 뒤 이 호출의 자원을 정리한다.
전송 시나리오

다음 호출의 connect 성공 뒤 send가 실패합니다.

  • 예외: SendException
  • 문맥: board-data
  • 연결 성공과 전송 성공은 서로 다른 조건이다.
전송 안내와 정리

데이터 문맥 안내 뒤 finally가 닫습니다.

  • 정책: preserve-data=board-data
  • 정리: close=send
  • 실패 문맥을 보존하는 정책 문자열과 실제 저장을 구분한다.

finally에서 return하면 결과가 바뀐다

finally에는 성공 여부와 상관없이 꼭 해야 하는 짧은 정리 동작만 둡니다. 자원 닫기, 잠금 해제, 잠시 바꿔 둔 상태 되돌리기 같은 것입니다. 특히 finally 안에서 return을 쓰면 안 되는데, 다음 프로그램이 그 이유를 보여 줍니다. returnCase()는 원래 10을 반환하려 했고, exceptionCase()는 예외를 호출자에게 전달하려 했습니다.

lab/FinallyOutcomeOverride.java
public final class FinallyOutcomeOverride {
    public static void main(String[] args) {
        System.out.println("return=" + returnCase());
        System.out.println("exception=" + exceptionCase());
    }

    private static int returnCase() {
        try {
            return 10;
        } finally {
            return 20;
        }
    }

    private static int exceptionCase() {
        try {
            throw new IllegalStateException("work failed");
        } finally {
            return 20;
        }
    }
}
return=20
exception=20

두 메서드 모두 20을 반환했습니다. returnCase()에서는 return 10으로 보관해 둔 반환값이 finally의 return 20으로 바뀌었습니다. 더 심각한 쪽은 exceptionCase()입니다. IllegalStateException이 전파되던 중에 finally가 return 20으로 끝나면서 예외가 흔적 없이 사라졌고, 호출자는 작업이 성공한 줄 압니다. 예외를 처리한 것이 아니라 실패했다는 사실을 지워 버린 것입니다.

이 동작은 Java 언어 명세(JLS) 14.20.2절에 정해져 있습니다. finally 블록이 return, throw, break, continue로 끝나면 try 문 전체가 그 이유로 끝나고, 앞서 진행 중이던 반환이나 예외는 버려집니다. 앞에서 close()의 예외가 원래 예외를 덮은 것도 같은 규칙 때문입니다. 정리한 뒤 원래 결과를 그대로 유지하려면 finally가 끝까지 정상적으로 실행되어야 합니다. 참고로 javac는 기본 설정으로는 이 코드에 경고를 내지 않고, javac -Xlint:finally로 컴파일해야 finally clause cannot complete normally라는 경고를 보여 줍니다.

정리 코드에서 난 예외를 빈 catch로 무조건 삼키는 것도 피해야 합니다. 원래 예외는 지킬 수 있지만, 정리에 실패했다는 정보가 통째로 사라지기 때문입니다. 업무 로직을 finally에 길게 넣는 것도 좋지 않습니다. finally 안의 코드가 많을수록 그 안에서 새 예외가 날 가능성이 커지고, 그러면 앞에서 본 것처럼 원래 실패가 가려집니다. 닫기 메서드는 앞의 disconnect()처럼 연결하지 않았거나 이미 닫힌 상태에서 불려도 안전하게 만듭니다.

자원 여러 개를 연 순서의 반대로 닫아야 하거나, 정리 실패와 원래 실패를 둘 다 남겨야 한다면 finally를 직접 쓰기보다 AutoCloseable과 try-with-resources를 쓰는 편이 낫습니다. 아래 연습 문제에서 finally로 자원 두 개를 직접 닫아 본 다음, try-with-resources를 다루는 글에서 같은 일을 더 짧고 안전하게 하는 방법을 봅니다.

정리

  • 정리 코드를 try 문 다음이나 catch 안에 두면, 나열하지 않은 예외가 났을 때 실행되지 않습니다.
  • finally는 try 블록에 들어간 뒤라면 정상 종료, catch 처리, 처리되지 않은 예외, return 가운데 어떤 경로로 빠져나가도 실행됩니다. System.exit()처럼 JVM이 종료되는 경우는 예외입니다.
  • finally는 예외를 처리하지 않습니다. finally가 정상적으로 끝나면 전파 중이던 예외는 그대로 계속 전파됩니다.
  • 실패에 대응할 방법이 없는 메서드는 catch 없이 try-finally로 정리만 하고 예외를 호출자에게 넘깁니다.
  • finally가 return이나 새 예외로 끝나면 원래 반환값이나 예외를 덮어씁니다. finally에는 짧고 실패할 일이 적은 정리 코드만 둡니다.

연습 문제

  1. Connection을 먼저 열고 Channel을 나중에 엽니다. 전송 중 예외가 나도 Channel, Connection 순서로 닫히고, main에서 원래 예외 메시지를 확인할 수 있도록 중첩된 try-finally를 작성하세요. 자원의 open()과 close()는 각각 open=이름, close=이름을 출력하고 정상적으로 끝납니다. 두 자원을 연 뒤 전송 단계에서 IllegalStateException("send failed")가 발생하고, main은 이 예외를 잡아 boundary=메시지를 출력합니다.
  2. 1번 해답에서 전송 단계가 예외를 던지는 대신 sent를 출력하고 정상적으로 끝나도록 바꾸면 출력이 어떻게 될지 예상하세요. boundary=로 시작하는 줄이 나오는지도 답하세요.
  3. FinallyOutcomeOverride의 두 finally에서 return 20;을 지우고 그 자리에 System.out.println("cleanup");을 넣으면 출력이 어떻게 달라질지 예상하세요.
해설 보기

1번은 자원 하나에 try-finally 하나를 두고, 나중에 연 자원의 try-finally를 먼저 연 자원의 try 블록 안에 넣으면 됩니다.

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에서 생기므로 안쪽 finally가 먼저 실행되어 close=channel이 나옵니다. 예외가 계속 전파되면서 바깥 finally가 close=connection을 출력하고, 마지막으로 main의 catch가 원래 메시지 send failed를 출력합니다. 나중에 연 자원이 먼저 연 자원에 기대고 있을 수 있으므로, 닫을 때는 연 순서의 반대로 닫아야 안전합니다. connection.open()을 바깥 try 앞에 둔 것도 의도한 것입니다. 열기에 실패했다면 닫을 것이 없고, 일단 열린 뒤에는 channel을 여는 단계가 실패하더라도 바깥 finally가 connection을 닫아 줍니다.

2번의 출력은 다음과 같습니다.

open=connection
open=channel
sent
close=channel
close=connection

정리 순서는 1번과 똑같습니다. finally는 예외가 없을 때도 실행되기 때문입니다. 반면 boundary= 줄은 나오지 않습니다. catch는 타입이 맞는 예외가 있을 때만 실행되는데, 이번에는 아무 예외도 생기지 않았습니다.

3번의 출력은 다음과 같습니다.

cleanup
return=10
cleanup
Exception in thread "main" java.lang.IllegalStateException: work failed
	at FinallyOutcomeOverride.exceptionCase(FinallyOutcomeOverride.java:17)
	at FinallyOutcomeOverride.main(FinallyOutcomeOverride.java:4)

returnCase()는 finally가 cleanup을 출력하고 정상적으로 끝나므로, 보관해 둔 10을 그대로 반환합니다. exceptionCase()도 finally가 정상적으로 끝나므로 IllegalStateException이 계속 전파되고, 아무도 잡지 않아 프로그램이 예외와 함께 끝납니다. 그래서 exception=으로 시작하는 줄은 출력되지 않습니다. finally가 결과를 바꾸지 않을 때 원래 반환값과 예외가 그대로 살아남는다는 것을 확인할 수 있습니다.

다음 글

다음 글에서는 직접 만든 예외 클래스를 부모와 자식으로 나누는 기준을 봅니다. 예외 객체에 대응에 필요한 값을 담는 방법과, 예외 계층을 다룬 글에서 처음 본 예외 변환을 어느 계층에서 어떻게 해야 하는지도 다룹니다.