본문 바로가기

반환 코드 대신 예외를 쓰는 이유

실패를 문자열 반환 코드로 알리면 왜 검사를 빠뜨리기 쉽고 정상 흐름이 분기 속에 묻히는지 직접 실행해 보고, enum 결과 타입과 예외가 이 문제를 각각 어떻게 푸는지 비교합니다.

게시글을 서버로 보내는 프로그램을 생각해 봅시다. 먼저 서버에 연결하고(connect), 데이터를 보낸 뒤(send), 마지막으로 연결을 끊습니다(disconnect). 모든 일이 잘 풀릴 때만 생각하면 세 줄로 끝나지만, 네트워크는 언제든 실패할 수 있습니다. 그래서 이 코드에는 규칙이 두 가지 더 필요합니다. 연결에 실패했다면 전송을 시도하지 말아야 하고, 중간에 무엇이 실패하든 열어 둔 연결은 끊어야 합니다.

예외를 배우기 전이라면 실패를 알리는 가장 쉬운 방법은 반환값입니다. 성공하면 "SUCCESS", 연결에 실패하면 "CONNECT_ERROR" 같은 문자열을 돌려주는 식입니다. 이렇게 성공과 실패의 종류를 나타내려고 돌려주는 값을 반환 코드(return code) 또는 오류 코드라고 부릅니다. 이 글에서는 반환 코드만으로 위의 두 규칙을 지키려 할 때 어떤 일이 생기는지 직접 실행해 보고, enum 결과 타입과 예외가 그 문제를 각각 어떻게 푸는지 비교합니다.

반환값을 검사하지 않으면 생기는 일

다음 프로그램의 NetworkClient는 실제 네트워크 대신 쓰는 연습용 클라이언트입니다. 생성자에 넘긴 두 boolean 값으로 연결과 전송 가운데 어느 쪽을 실패시킬지 정하고, 실패하면 "CONNECT_ERROR"나 "SEND_ERROR"를, 성공하면 "SUCCESS"를 돌려줍니다. main은 연결이 실패하도록 new NetworkClient(true, false)로 클라이언트를 만든 뒤, 반환값을 변수에 받기만 하고 검사하지 않은 채 세 메서드를 차례로 호출합니다.

lab/IgnoredReturnCodeBug.java
public final class IgnoredReturnCodeBug {
    public static void main(String[] args) {
        NetworkClient client = new NetworkClient(true, false);
        String connectCode = client.connect();
        String sendCode = client.send("create post request");
        client.disconnect();

        System.out.println("connect=" + connectCode);
        System.out.println("send=" + sendCode);
    }

    private static final class NetworkClient {
        private final boolean connectError;
        private final boolean sendError;

        NetworkClient(boolean connectError, boolean sendError) {
            this.connectError = connectError;
            this.sendError = sendError;
        }

        String connect() {
            if (connectError) return "CONNECT_ERROR";
            System.out.println("connected");
            return "SUCCESS";
        }

        String send(String data) {
            if (sendError) return "SEND_ERROR";
            System.out.println("sent=" + data);
            return "SUCCESS";
        }

        void disconnect() { System.out.println("disconnected"); }
    }
}
실행 결과
sent=create post request
disconnected
connect=CONNECT_ERROR
send=SUCCESS

connect()는 connectError가 true이므로 connected를 출력하지 않고 곧바로 "CONNECT_ERROR"를 돌려줍니다. 그런데 main은 이 값을 connectCode에 담아 두기만 하고 다음 줄로 넘어갑니다. 그래서 send()가 그대로 호출되어 첫 줄 sent=create post request가 출력됩니다. 연결되지 않은 서버에 데이터를 보낸 셈입니다.

마지막 두 줄은 더 이상합니다. 연결은 실패했는데 전송은 성공했다고 나옵니다. 연습용 send()가 연결 상태를 확인하지 않고 sendError만 보기 때문인데, 실제 프로그램에서 이런 기록이 남으면 나중에 로그를 읽는 사람은 무엇이 일어났는지 판단할 수 없습니다.

이 버그는 컴파일러가 잡아 주지 않습니다. 반환 타입이 String이면 컴파일러에게 "SUCCESS"와 "CONNECT_ERROR"는 똑같은 문자열일 뿐입니다. 반환값을 받아 두고 검사하지 않는 것도, 아예 받지 않고 버리는 것도 문법상 아무 문제가 없습니다. 실패를 알아채고 다음 동작을 막는 일은 전적으로 호출하는 쪽 프로그래머의 기억에 달려 있습니다. 게다가 "SUCCESS"와 "success", 빈 문자열 ""도 모두 같은 String이므로, 어떤 문자열이 성공을 뜻하는지조차 호출자가 따로 알고 있어야 합니다.

결과를 검사해 다음 호출 막기

첫 번째 개선은 호출할 때마다 결과를 확인하는 것입니다. 다음 프로그램은 connect()의 결과가 성공일 때만 send()를 호출하고, 실패했다면 그 코드를 출력합니다. disconnect()는 어느 분기로 가든 실행되도록 if/else 바깥, 메서드의 맨 끝에 둡니다. 실패한 분기에서 return으로 곧바로 빠져나가게 만들었다면 마지막의 disconnect()를 건너뛰었을 것입니다.

이번 NetworkClient는 생성자 대신 initError(data)로 실패를 정합니다. 보낼 데이터에 error1이 들어 있으면 연결이, error2가 들어 있으면 전송이 실패합니다. main은 "error2"를 보내므로 연결은 성공하고 전송만 실패합니다.

src/ReturnCodeNetworkFlow.java
public final class ReturnCodeNetworkFlow {
    public static void main(String[] args) {
        sendMessage("error2");
    }

    private static void sendMessage(String data) {
        NetworkClient client = new NetworkClient();
        client.initError(data);

        String connectResult = client.connect();
        if (isSuccess(connectResult)) {
            String sendResult = client.send(data);
            if (!isSuccess(sendResult)) {
                System.out.println("network-error=" + sendResult);
            }
        } else {
            System.out.println("network-error=" + connectResult);
        }

        client.disconnect();
    }

    private static boolean isSuccess(String code) {
        return "SUCCESS".equals(code);
    }

    private static final class NetworkClient {
        private boolean connectError;
        private boolean sendError;

        void initError(String data) {
            connectError = data.contains("error1");
            sendError = data.contains("error2");
        }

        String connect() {
            if (connectError) return "CONNECT_ERROR";
            System.out.println("connected");
            return "SUCCESS";
        }

        String send(String data) {
            if (sendError) return "SEND_ERROR";
            System.out.println("sent=" + data);
            return "SUCCESS";
        }

        void disconnect() { System.out.println("disconnected"); }
    }
}
실행 결과
connected
network-error=SEND_ERROR
disconnected

connect()가 "SUCCESS"를 돌려주므로 isSuccess(connectResult)가 참이 되어 send()를 호출합니다. send()는 "SEND_ERROR"를 돌려주므로 안쪽 if가 오류를 출력하고, 마지막으로 disconnect()가 실행됩니다. isSuccess는 성공을 판정하는 규칙을 한곳에 모아 둔 메서드입니다. 문자열 상수 쪽에서 equals를 호출하므로 code가 null이어도 NullPointerException 없이 false가 됩니다.

main의 입력을 "error1 error2"로 바꿔 실행하면 어떻게 될까요? initError는 두 플래그를 모두 true로 만들지만, connect()가 먼저 실패하므로 send()에는 도달하지 않습니다. 출력은 network-error=CONNECT_ERROR와 disconnected 두 줄이고, connected나 sent=로 시작하는 줄은 나오지 않습니다. 전송 실패 플래그가 켜져 있다는 것과 send()를 실제로 호출했다는 것은 다른 일입니다.

아래 그림은 결과를 검사하지 않는 첫 프로그램과 결과를 검사하는 이 프로그램이 각각 어떤 순서로 호출하고 출력하는지 보여 줍니다. 두 프로그램은 입력이 서로 다르므로, 같은 요청을 고치기 전과 후로 비교한 그림은 아닙니다. 첫 프로그램은 연결이 실패했는데도 send()까지 가고, 두 번째 프로그램은 연결이 성공했기 때문에 send()로 진행합니다.

반환값을 받는 것과 다음 호출을 막는 것은 다르다

검사 생략과 error2의 조건부 send를 각 독립 원문으로 추적합니다. connect 반환→send 도달 여부→disconnect의 차이를 순서대로 읽되 입력이 같다고 합치지 않아야 합니다.

반환값을 받는 것과 다음 호출을 막는 것은 다르다

두 원문은 입력이 다르다. 반환값을 검사하지 않는 첫 main은 연결 실패 뒤에도 send를 실행하고, error2 입력의 분기 원문은 연결 성공일 때만 send를 실행한다.

반환 코드 · 후속 호출 조건

반환값을 받는 것과 다음 호출을 막는 것은 다르다

검사 없는 원문은 (true, false), 분기 원문은 "error2" 입력입니다. 다른 입력의 호출과 결과를 비교합니다.

반환값 검사와 실제 후속 호출 두 영역은 입력이 다른 별도 원문입니다. 검사 생략의 연결 실패 뒤 send 성공과 error2 분기 예제의 연결 성공 뒤 send 실패를 구분합니다. 실선은 해당 입력의 실제 호출·출력 순서이며, 서로 다른 영역의 결과가 같은 입력에 대한 전후 개선이라고 주장하지 않습니다. 원문 A · 검사 생략 connect 실패 CONNECT_ERROR 반환 connected 출력 없음 send 그대로 호출 SUCCESS 반환 sent=create post request disconnect 호출 disconnected 출력 main의 결과 출력 connect=CONNECT_ERROR send=SUCCESS 원문 B · error2 입력 connect 성공 SUCCESS 반환 connected 출력 성공 조건이 참 isSuccess가 true 이 입력은 send로 진행 send 실패 보고 SEND_ERROR 반환 network-error=SEND_ERROR disconnect 호출 disconnected 출력 영역마다 입력이 다른 원문 · 실선은 실제 후속 호출·출력 · 예상 밖 예외의 정리 보장 없음
반환값 검사와 실제 후속 호출 두 영역은 입력이 다른 별도 원문입니다. 검사 생략의 연결 실패 뒤 send 성공과 error2 분기 예제의 연결 성공 뒤 send 실패를 구분합니다. 실선은 해당 입력의 실제 호출·출력 순서이며, 서로 다른 영역의 결과가 같은 입력에 대한 전후 개선이라고 주장하지 않습니다. 원문 A · 검사 생략 connect 실패 CONNECT_ERROR 반환 connected 출력 없음 send 그대로 호출 SUCCESS 반환 sent=create post request disconnect 호출 disconnected 출력 main의 결과 출력 connect=CONNECT_ERROR send=SUCCESS 원문 B · error2 입력 connect 성공 SUCCESS 반환 connected 출력 성공 조건이 참 isSuccess가 true 이 입력은 send로 진행 send 실패 보고 SEND_ERROR 반환 network-error=SEND_ERROR disconnect 호출 disconnected 출력 영역마다 입력이 다른 원문 실선은 실제 후속 호출·출력 예상 밖 예외의 정리 보장 없음

반환값을 검사하지 않는 원문

connect
반환 CONNECT_ERROR
connected는 출력하지 않습니다.
send
연결 결과와 무관하게 호출합니다.
반환 SUCCESS
출력 sent=create post request
disconnect 이후
출력 disconnected
그 뒤 main이 두 반환값을 출력합니다.
connect=CONNECT_ERROR
send=SUCCESS

결과에 따라 분기하는 원문

connect
반환 SUCCESS
출력 connected
send
isSuccess(connectResult)가 참이어서 호출합니다.
반환 SEND_ERROR
출력 network-error=SEND_ERROR
disconnect 이후
오류 보고 뒤 disconnected를 출력합니다.
추가 결과 출력은 없습니다.

첫 원문의 send=SUCCESS는 테스트 클라이언트의 반환값이지 실제 연결 성공의 증거가 아닙니다. 분기 원문도 finally를 쓰지 않으므로 예상 밖 예외까지 종료 호출을 보장하지 않습니다.

이 예의 조건

  • A와 B는 입력이 다른 독립 원문이다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보반환값을 받는 것과 다음 호출을 막는 것은 다르다 반환값을 받는 것과 다음 호출을 막는 것은 다르다 두 원문은 입력이 다르다. 반환값을 검사하지 않는 첫 main은 연결 실패 뒤에도 send를 실행하고, error2 입력의 분기 원문은 연결 성공일 때만 send를 실행한다. 반환 코드 · 후속 호출 조건 반환값을 받는 것과 다음 호출을 막는 것은 다르다 검사 없는 원문은 (true, false), 분기 원문은 "error2" 입력입니다. 다른 입력의 호출과 결과를 비교합니다. 반환값을 검사하지 않는 원문 connect 반환 CONNECT_ERROR connected 는 출력하지 않습니다. send 연결 결과와 무관하게 호출합니다. 반환 SUCCESS 출력 sent=create post request disconnect 이후 출력 disconnected 그 뒤 main이 두 반환값을 출력합니다. connect=CONNECT_ERROR send=SUCCESS 결과에 따라 분기하는 원문 connect 반환 SUCCESS 출력 connected send isSuccess(connectResult) 가 참이어서 호출합니다. 반환 SEND_ERROR 출력 network-error=SEND_ERROR disconnect 이후 오류 보고 뒤 disconnected 를 출력합니다. 추가 결과 출력은 없습니다. 첫 원문의 send=SUCCESS 는 테스트 클라이언트의 반환값이지 실제 연결 성공의 증거가 아닙니다. 분기 원문도 finally 를 쓰지 않으므로 예상 밖 예외까지 종료 호출을 보장하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
connect 호출

A는 실패하고 B error2는 성공합니다.

  • A: CONNECT_ERROR
  • B: SUCCESS / connected
  • 비교의 입력이 다르므로 같은 요청 개선 전후로 묶지 않는다.
send 도달

A는 검사 없이 호출하고 B는 isSuccess를 통과해 호출합니다.

  • A send: SUCCESS / sent=create post request
  • B send: SEND_ERROR / network-error
  • 후속 호출 조건과 실제 전송 결과는 별개다.
disconnect 후 결과

두 프로그램이 각 정리와 관찰을 마칩니다.

  • A: disconnected; connect=CONNECT_ERROR,send=SUCCESS
  • B: disconnected
  • 정상 반환 코드 경로만 보여 주며 예상 밖 예외의 정리 보장을 추가하지 않는다.

반환값을 받는 것과 다음 호출을 막는 것은 다르다

검사 생략과 error2의 조건부 send를 각 독립 원문으로 추적합니다. connect 반환→send 도달 여부→disconnect의 차이를 순서대로 읽되 입력이 같다고 합치지 않아야 합니다.

반환값을 받는 것과 다음 호출을 막는 것은 다르다

두 원문은 입력이 다르다. 반환값을 검사하지 않는 첫 main은 연결 실패 뒤에도 send를 실행하고, error2 입력의 분기 원문은 연결 성공일 때만 send를 실행한다.

반환 코드 · 후속 호출 조건

반환값을 받는 것과 다음 호출을 막는 것은 다르다

검사 없는 원문은 (true, false), 분기 원문은 "error2" 입력입니다. 다른 입력의 호출과 결과를 비교합니다.

반환값 검사와 실제 후속 호출 두 영역은 입력이 다른 별도 원문입니다. 검사 생략의 연결 실패 뒤 send 성공과 error2 분기 예제의 연결 성공 뒤 send 실패를 구분합니다. 실선은 해당 입력의 실제 호출·출력 순서이며, 서로 다른 영역의 결과가 같은 입력에 대한 전후 개선이라고 주장하지 않습니다. 원문 A · 검사 생략 connect 실패 CONNECT_ERROR 반환 connected 출력 없음 send 그대로 호출 SUCCESS 반환 sent=create post request disconnect 호출 disconnected 출력 main의 결과 출력 connect=CONNECT_ERROR send=SUCCESS 원문 B · error2 입력 connect 성공 SUCCESS 반환 connected 출력 성공 조건이 참 isSuccess가 true 이 입력은 send로 진행 send 실패 보고 SEND_ERROR 반환 network-error=SEND_ERROR disconnect 호출 disconnected 출력 영역마다 입력이 다른 원문 · 실선은 실제 후속 호출·출력 · 예상 밖 예외의 정리 보장 없음
반환값 검사와 실제 후속 호출 두 영역은 입력이 다른 별도 원문입니다. 검사 생략의 연결 실패 뒤 send 성공과 error2 분기 예제의 연결 성공 뒤 send 실패를 구분합니다. 실선은 해당 입력의 실제 호출·출력 순서이며, 서로 다른 영역의 결과가 같은 입력에 대한 전후 개선이라고 주장하지 않습니다. 원문 A · 검사 생략 connect 실패 CONNECT_ERROR 반환 connected 출력 없음 send 그대로 호출 SUCCESS 반환 sent=create post request disconnect 호출 disconnected 출력 main의 결과 출력 connect=CONNECT_ERROR send=SUCCESS 원문 B · error2 입력 connect 성공 SUCCESS 반환 connected 출력 성공 조건이 참 isSuccess가 true 이 입력은 send로 진행 send 실패 보고 SEND_ERROR 반환 network-error=SEND_ERROR disconnect 호출 disconnected 출력 영역마다 입력이 다른 원문 실선은 실제 후속 호출·출력 예상 밖 예외의 정리 보장 없음

반환값을 검사하지 않는 원문

connect
반환 CONNECT_ERROR
connected는 출력하지 않습니다.
send
연결 결과와 무관하게 호출합니다.
반환 SUCCESS
출력 sent=create post request
disconnect 이후
출력 disconnected
그 뒤 main이 두 반환값을 출력합니다.
connect=CONNECT_ERROR
send=SUCCESS

결과에 따라 분기하는 원문

connect
반환 SUCCESS
출력 connected
send
isSuccess(connectResult)가 참이어서 호출합니다.
반환 SEND_ERROR
출력 network-error=SEND_ERROR
disconnect 이후
오류 보고 뒤 disconnected를 출력합니다.
추가 결과 출력은 없습니다.

첫 원문의 send=SUCCESS는 테스트 클라이언트의 반환값이지 실제 연결 성공의 증거가 아닙니다. 분기 원문도 finally를 쓰지 않으므로 예상 밖 예외까지 종료 호출을 보장하지 않습니다.

이 예의 조건

  • A와 B는 입력이 다른 독립 원문이다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보반환값을 받는 것과 다음 호출을 막는 것은 다르다 반환값을 받는 것과 다음 호출을 막는 것은 다르다 두 원문은 입력이 다르다. 반환값을 검사하지 않는 첫 main은 연결 실패 뒤에도 send를 실행하고, error2 입력의 분기 원문은 연결 성공일 때만 send를 실행한다. 반환 코드 · 후속 호출 조건 반환값을 받는 것과 다음 호출을 막는 것은 다르다 검사 없는 원문은 (true, false), 분기 원문은 "error2" 입력입니다. 다른 입력의 호출과 결과를 비교합니다. 반환값을 검사하지 않는 원문 connect 반환 CONNECT_ERROR connected 는 출력하지 않습니다. send 연결 결과와 무관하게 호출합니다. 반환 SUCCESS 출력 sent=create post request disconnect 이후 출력 disconnected 그 뒤 main이 두 반환값을 출력합니다. connect=CONNECT_ERROR send=SUCCESS 결과에 따라 분기하는 원문 connect 반환 SUCCESS 출력 connected send isSuccess(connectResult) 가 참이어서 호출합니다. 반환 SEND_ERROR 출력 network-error=SEND_ERROR disconnect 이후 오류 보고 뒤 disconnected 를 출력합니다. 추가 결과 출력은 없습니다. 첫 원문의 send=SUCCESS 는 테스트 클라이언트의 반환값이지 실제 연결 성공의 증거가 아닙니다. 분기 원문도 finally 를 쓰지 않으므로 예상 밖 예외까지 종료 호출을 보장하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
connect 호출

A는 실패하고 B error2는 성공합니다.

  • A: CONNECT_ERROR
  • B: SUCCESS / connected
  • 비교의 입력이 다르므로 같은 요청 개선 전후로 묶지 않는다.
send 도달

A는 검사 없이 호출하고 B는 isSuccess를 통과해 호출합니다.

  • A send: SUCCESS / sent=create post request
  • B send: SEND_ERROR / network-error
  • 후속 호출 조건과 실제 전송 결과는 별개다.
disconnect 후 결과

두 프로그램이 각 정리와 관찰을 마칩니다.

  • A: disconnected; connect=CONNECT_ERROR,send=SUCCESS
  • B: disconnected
  • 정상 반환 코드 경로만 보여 주며 예상 밖 예외의 정리 보장을 추가하지 않는다.

반환 코드 방식에 남는 문제

결과를 검사하는 버전은 정해 둔 두 가지 실패를 올바르게 처리합니다. 하지만 대가가 있습니다. 이 메서드가 하는 일은 '연결하고, 보내고, 끊는다'는 세 단계인데, 코드에서는 단계마다 붙은 if/else가 그 흐름을 감싸고 있습니다. 단계가 다섯 개, 열 개로 늘어나면 중첩이 그만큼 깊어지고, 어느 else가 어느 실패를 처리하는지 따라가기 어려워집니다. 반환값 자리를 오류 코드가 차지한다는 것도 문제입니다. send()가 서버의 응답 내용을 돌려줘야 한다면 응답과 오류 코드를 함께 담을 방법을 따로 만들어야 합니다.

검사가 여전히 선택 사항이라는 점도 그대로입니다. send()를 호출하는 곳이 열 군데라면 열 군데 모두에서 결과를 확인해야 하고, 한 곳이라도 빠뜨리면 첫 번째 프로그램의 버그가 그대로 돌아옵니다. 컴파일러는 이번에도 도와주지 않습니다.

마지막으로, 반환 코드로는 알릴 수 없는 실패가 있습니다. send() 안에서 NullPointerException 같은 예상하지 못한 예외가 발생하면 반환값 자체가 만들어지지 않고, 실행은 sendMessage를 그대로 빠져나갑니다. 그러면 메서드 끝의 disconnect()도 실행되지 않아 연결이 열린 채로 남습니다. 반환값을 아무리 꼼꼼히 검사해도 이 경우는 막을 수 없습니다.

문자열 대신 enum으로 결과 나타내기

예외를 쓰지 않고도 고칠 수 있는 부분이 있습니다. 문자열 대신 enum으로 가능한 결과를 미리 정해 두면, 오타나 대소문자 차이로 생기는 실수가 사라집니다. 또 결과를 빠짐없이 처리했는지 컴파일러가 검사하게 만들 수도 있습니다.

다음 프로그램은 결과를 NetworkResult enum의 네 상수 SUCCESS, CONNECT_ERROR, SEND_ERROR, SKIPPED로 나타냅니다. SKIPPED는 연결이 실패해서 전송을 아예 시도하지 않았다는 뜻이고, 전송을 시도했다가 실패한 SEND_ERROR와는 다른 상태입니다. 클라이언트는 연결은 성공하고 전송만 실패하도록 new NetworkClient(false, true)로 만듭니다. 두 필드와 생성자를 짧게 쓰려고 NetworkClient는 레코드(record)로 선언했습니다.

src/TypedNetworkResult.java
public final class TypedNetworkResult {
    public static void main(String[] args) {
        NetworkClient client = new NetworkClient(false, true);
        NetworkResult connect = client.connect();
        NetworkResult send = connect == NetworkResult.SUCCESS
                ? client.send("typed result")
                : NetworkResult.SKIPPED;

        System.out.println(render(connect));
        System.out.println(render(send));
        client.disconnect();
    }

    private static String render(NetworkResult result) {
        return switch (result) {
            case SUCCESS -> "success";
            case CONNECT_ERROR -> "cannot connect";
            case SEND_ERROR -> "cannot send";
            case SKIPPED -> "not attempted";
        };
    }

    private enum NetworkResult { SUCCESS, CONNECT_ERROR, SEND_ERROR, SKIPPED }

    private record NetworkClient(boolean connectError, boolean sendError) {
        NetworkResult connect() {
            return connectError ? NetworkResult.CONNECT_ERROR : NetworkResult.SUCCESS;
        }

        NetworkResult send(String data) {
            return sendError ? NetworkResult.SEND_ERROR : NetworkResult.SUCCESS;
        }

        void disconnect() { System.out.println("closed"); }
    }
}
실행 결과
success
cannot send
closed

connect()가 SUCCESS를 돌려주므로 조건 연산자가 client.send("typed result")를 호출하고, 그 결과는 SEND_ERROR입니다. render는 switch 식으로 상수마다 사람이 읽을 문장을 고르므로 첫 두 줄은 success와 cannot send가 됩니다. 마지막 줄 closed는 disconnect()가 출력한 것입니다. 클라이언트를 new NetworkClient(true, false)로 바꾸면 send 변수에는 SKIPPED가 들어가고, 출력은 cannot connect, not attempted, closed로 바뀝니다.

이 방식의 장점은 switch 식에서 드러납니다. switch 식은 들어올 수 있는 모든 값을 처리해야 컴파일되므로, default 없이 enum 상수를 나열했다면 상수를 하나도 빠뜨리면 안 됩니다. 나중에 누군가 NetworkResult에 TIMEOUT을 추가하고 render를 고치지 않으면 다음 오류가 납니다.

javac 오류
TypedNetworkResult.java:15: error: the switch expression does not cover all possible input values
        return switch (result) {
               ^
1 error

반환 코드가 문자열이었다면 새 코드가 생겨도 컴파일러는 아무 말도 하지 않았을 것입니다. 하지만 enum도 결국 반환값입니다. 호출자가 결과를 검사하지 않고 다음 줄로 넘어가는 것은 여전히 막을 수 없고, 단계마다 결과를 확인하는 분기도 그대로 필요합니다. 예상하지 못한 예외가 나면 disconnect()를 건너뛴다는 점도 바뀌지 않습니다.

예외로 바꾸면 달라지는 것

예외(exception)는 실패를 반환값이 아닌 별도의 경로로 알리는 방법입니다. 메서드가 실패를 만나면 throw 문으로 예외 객체를 던집니다. 그러면 그다음 문장들은 실행되지 않고, 실행은 그 예외를 받을 catch 블록을 찾아 이동합니다. 던진 메서드 안에 맞는 catch가 없으면 예외는 호출한 쪽으로 전달되고, 그 호출을 감싼 try 블록의 나머지 문장도 건너뛴 채 예외 타입이 맞는 catch 블록으로 곧바로 이동합니다. try/catch의 기본 동작은 3장에서 잘못된 숫자 입력을 붙잡을 때 이미 보았습니다.

다음은 같은 클라이언트를 예외로 다시 쓴 것입니다. connect()와 send()는 아무것도 반환하지 않고, 실패하면 NetworkException을 던집니다. 메서드 선언 끝의 throws NetworkException은 '이 메서드는 NetworkException을 던질 수 있다'는 표시입니다. main은 연결이 실패하는 경우와 전송이 실패하는 경우를 차례로 실행하고, 두 실행 사이에 ---를 출력합니다.

src/ExceptionNetworkFlow.java
public final class ExceptionNetworkFlow {
    public static void main(String[] args) {
        sendMessage("error1");
        System.out.println("---");
        sendMessage("error2");
    }

    private static void sendMessage(String data) {
        NetworkClient client = new NetworkClient();
        client.initError(data);

        try {
            client.connect();
            client.send(data);
        } catch (NetworkException error) {
            System.out.println("network-error=" + error.getMessage());
        }

        client.disconnect();
    }

    private static final class NetworkClient {
        private boolean connectError;
        private boolean sendError;

        void initError(String data) {
            connectError = data.contains("error1");
            sendError = data.contains("error2");
        }

        void connect() throws NetworkException {
            if (connectError) throw new NetworkException("connect failed");
            System.out.println("connected");
        }

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

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

    private static final class NetworkException extends Exception {
        NetworkException(String message) { super(message); }
    }
}
실행 결과
network-error=connect failed
disconnected
---
connected
network-error=send failed: error2
disconnected

첫 번째 실행에서는 connect()가 예외를 던지므로 try 블록의 다음 줄인 client.send(data)는 실행되지 않고, 곧바로 catch 블록이 network-error=connect failed를 출력합니다. 연결에 실패하면 전송하지 않는다는 규칙을 if 없이 지킨 것입니다. 두 번째 실행에서는 연결이 성공해 connected가 출력되고, send()가 던진 예외를 같은 catch가 받습니다. 두 경우 모두 try/catch가 끝난 뒤 disconnect()가 실행됩니다.

sendMessage의 try 블록을 다시 보면, 모든 일이 성공했을 때의 흐름이 connect()와 send() 두 줄로 이어져 있고 실패 처리는 catch 한곳에 모여 있습니다. 반환 코드 버전에서 if/else로 뒤섞여 있던 정상 흐름과 실패 처리가 분리된 것입니다. 실패의 이유는 예외 객체의 메시지에 담기므로, 반환값 자리는 원래 돌려줘야 할 결과를 위해 비워 둘 수 있습니다.

검사를 빠뜨리는 문제도 사라집니다. NetworkException처럼 Exception을 직접 상속한 예외는 처리하지 않으면 컴파일되지 않습니다. try/catch를 지우고 client.connect();와 client.send(data); 두 줄만 남기면 다음 오류가 납니다.

javac 오류
ExceptionNetworkFlow.java:12: error: unreported exception NetworkException; must be caught or declared to be thrown
        client.connect();
                      ^
ExceptionNetworkFlow.java:13: error: unreported exception NetworkException; must be caught or declared to be thrown
        client.send(data);
                   ^
2 errors

반환값은 쓰지 않고 버려도 컴파일되지만, 이런 예외는 그 자리에서 잡을지(catch) 호출한 쪽으로 넘길지(throws)를 정하기 전에는 컴파일조차 되지 않습니다. 이렇게 컴파일러가 처리 여부를 검사하는 예외를 검사 예외(checked exception)라고 하며, 다음 글에서 자세히 다룹니다.

다만 예외를 썼다고 모든 문제가 풀린 것은 아닙니다. 이 코드도 disconnect()를 try/catch 다음에 두었기 때문에, catch가 잡지 않는 종류의 예외가 나면 그 줄까지 가지 못합니다. 연습 문제 2에서 이 경우를 직접 확인해 보세요. 어떤 예외가 나든 정리 코드를 실행하게 만드는 finally는 이 장의 try·catch·finally를 다루는 글에서 배웁니다.

결과 값과 예외 중 무엇을 쓸까

반환 코드의 문제를 봤다고 해서 모든 실패를 예외로 알려야 하는 것은 아닙니다. 기준은 '오류냐 아니냐'가 아니라 호출한 쪽이 그 결과로 무엇을 하느냐입니다. 검색 결과가 없거나, 사용자가 입력한 값이 형식에 맞지 않거나, 결제가 거절되는 일은 프로그램이 평소에 늘 만나는 결과입니다. 호출자가 그 자리에서 다른 안내를 보여 주거나 다시 입력받는 식으로 대응한다면, 이런 결과는 TypedNetworkResult처럼 값으로 돌려주는 편이 읽기 쉽습니다.

반대로 파일이나 네트워크, 데이터베이스 접근이 실패해서 지금 메서드가 정상적인 값을 아예 만들 수 없고, 바로 위의 호출자도 할 수 있는 일이 없다면 예외가 자연스럽습니다. 예외는 처리할 수 있는 곳까지 여러 호출 단계를 한 번에 거슬러 올라가므로, 중간 메서드마다 결과를 검사하고 다시 반환하는 코드를 쓰지 않아도 됩니다.

자주 쓰는 선택을 정리하면 다음과 같습니다. 아직 배우지 않은 도구도 있지만, 상황마다 어떤 방식이 어울리는지 감을 잡는 용도로 보세요.

  • 결과가 없을 수도 있는 조회: Optional이나 결과를 나타내는 전용 타입
  • 사용자 입력 검증: 무엇이 틀렸는지 담은 오류 목록을 결과 값으로
  • 파일·네트워크·DB 접근 실패: 원인과 상황 정보를 담은 예외
  • 음수 개수처럼 호출하는 쪽의 실수: IllegalArgumentException처럼 컴파일러가 처리를 강제하지 않는 비검사 예외(이 장의 세 번째 글에서 다룹니다)
  • 반드시 실행해야 하는 정리 작업: 결과를 값으로 알리든 예외로 알리든 finally나 try-with-resources로

마지막 항목을 따로 둔 이유는 앞에서 본 대로 예외를 쓴다고 해서 정리 코드가 저절로 실행되지는 않기 때문입니다. 예외는 실패를 알리는 방법일 뿐, 열어 둔 연결을 닫아 주지는 않습니다.

더 깊이: 흔한 결과를 예외로 알리지 않는 이유

예외는 일반 객체보다 만드는 비용이 큽니다. Throwable의 공개 생성자는 fillInStackTrace()를 호출해, 예외가 만들어진 순간의 호출 스택 정보를 객체 안에 기록합니다. 실패를 추적할 때 큰 도움이 되는 그 스택 트레이스가 바로 이 기록입니다. 한 번 일어나는 실패라면 문제가 되지 않지만, '검색 결과 없음'처럼 매번 일어날 수 있는 결과를 예외로 알리면 이 비용이 호출마다 쌓입니다.

읽기에도 좋지 않습니다. 결과 값은 메서드의 반환 타입만 봐도 무엇이 돌아오는지 알 수 있지만, 예외로 처리하는 흐름은 throw와 그것을 받는 catch를 찾아 코드를 오가며 읽어야 합니다. 그래서 예외는 지금 메서드가 약속한 일을 해낼 수 없는 상황을 위해 남겨 두고, 평소에 늘 일어나는 분기는 값으로 표현하는 것이 일반적인 원칙입니다.

정리

  • 반환 코드는 무시해도 컴파일되므로, 실패를 알아채고 다음 동작을 막는 일이 모두 호출자의 기억에 달려 있습니다.
  • 결과를 검사하는 분기를 넣으면 정해 둔 실패는 처리할 수 있지만, 정상 흐름이 if/else 속에 묻히고 예상하지 못한 예외에는 대응하지 못합니다.
  • 문자열 대신 enum을 쓰면 오타가 사라지고, switch 식이 모든 결과를 다뤘는지 컴파일러가 검사합니다. 그래도 결과를 확인할지는 여전히 호출자에게 달려 있습니다.
  • 예외를 쓰면 실패한 지점 이후의 문장을 자동으로 건너뛰고, 정상 흐름과 실패 처리를 try와 catch로 나눌 수 있습니다. 검사 예외는 처리하지 않으면 컴파일되지 않습니다.
  • 예상할 수 있고 호출자가 바로 대응하는 결과는 값으로, 현재 메서드가 정상 값을 만들 수 없는 실패는 예외로 알리는 것이 기본 기준입니다.

연습 문제

  1. OperationResult enum을 사용해 ReturnCodeFlowExercise를 완성하세요. 아직 try/finally는 쓰지 않습니다.

OperationResult의 값은 SUCCESS, CONNECT_ERROR, SEND_ERROR, SKIPPED입니다. 시나리오 문자열을 받는 Client는 시나리오가 connect이면 연결에, send이면 전송에 실패하고 그 밖에는 모두 성공합니다. 이 연습의 메서드는 예외를 던지지 않고 정해진 enum 값만 돌려준다고 가정하며, 시나리오는 null이 아닙니다. 연결에 성공했을 때만 send()를 호출하고, 호출하지 않았다면 전송 결과는 SKIPPED로 둡니다.

ok, connect, send 세 시나리오 모두 마지막에 close()가 호출되어야 합니다. close()는 close=시나리오를 출력하고, 그 뒤에 시나리오=연결결과/전송결과 형식의 결과 줄을 출력합니다. 다 작성했다면 시나리오 other를 추가로 실행했을 때 send()가 호출되는지, 어떤 두 줄이 출력되는지도 먼저 예상해 보세요.

해설 보기
src/ReturnCodeFlowExercise.java
public final class ReturnCodeFlowExercise {
    public static void main(String[] args) {
        run("ok");
        run("connect");
        run("send");
    }

    private static void run(String scenario) {
        Client client = new Client(scenario);
        OperationResult connected = client.connect();
        OperationResult sent = OperationResult.SKIPPED;
        if (connected == OperationResult.SUCCESS) {
            sent = client.send();
        }
        client.close();
        System.out.println(scenario + "=" + connected + "/" + sent);
    }

    private enum OperationResult { SUCCESS, CONNECT_ERROR, SEND_ERROR, SKIPPED }

    private record Client(String scenario) {
        OperationResult connect() {
            return scenario.equals("connect")
                    ? OperationResult.CONNECT_ERROR
                    : OperationResult.SUCCESS;
        }

        OperationResult send() {
            return scenario.equals("send")
                    ? OperationResult.SEND_ERROR
                    : OperationResult.SUCCESS;
        }

        void close() { System.out.println("close=" + scenario); }
    }
}
실행 결과
close=ok
ok=SUCCESS/SUCCESS
close=connect
connect=CONNECT_ERROR/SKIPPED
close=send
send=SUCCESS/SEND_ERROR

sent를 SKIPPED로 먼저 초기화해 두고, 연결에 성공했을 때만 send()의 결과로 바꿉니다. 그래서 connect 시나리오의 전송 결과는 SKIPPED이고, 전송을 시도했다가 실패한 send 시나리오의 SEND_ERROR와 구별됩니다. close()는 if 바깥에 있으므로 세 시나리오 모두에서 실행되고, 결과 줄보다 먼저 출력됩니다.

other는 connect나 send와 같지 않으므로 두 호출이 모두 SUCCESS를 돌려줍니다. 출력은 close=other 다음에 other=SUCCESS/SUCCESS입니다. 실패로 정해 둔 두 이름이 아니면 어떤 시나리오든 성공으로 처리된다는 뜻입니다.

연결 실패의 SKIPPED는 전송 실패와 다르다

연결 결과 검사 뒤 send 실행 또는 SKIPPED 유지, close 출력 뒤 결과 출력의 실제 순서입니다. 세 입력 영역을 고정하고 현재 호출/미도달을 단계로 강조하기 좋습니다.

연결 실패의 SKIPPED는 전송 실패와 다르다

ReturnCodeFlowExercise의 ok, connect, send 세 입력은 연결 반환값에 따라 send 호출 여부가 달라진다. 세 원문 경로는 close를 먼저 출력한 뒤 결과를 출력한다.

반환 결과 · 세 입력

연결 실패의 SKIPPED는 전송 실패와 다르다

연결 성공일 때만 send를 호출합니다. 연결 실패의 SKIPPED는 전송을 시도한 SEND_ERROR와 다른 상태입니다.

호출 여부와 반환 결과의 분기 세 알려진 입력의 connect 결과를 검사합니다. SUCCESS일 때만 send가 실행되어 ok는 SUCCESS, send는 SEND_ERROR를 받습니다. connect 입력은 send를 호출하지 않아 SKIPPED를 유지합니다. 두 분기 뒤 close 출력과 결과 출력으로 합류합니다. 예상 밖 예외를 포함한 finally 보장의 그림은 아닙니다. 세 입력 중 하나 ok / connect / send connect 결과 connect만 CONNECT_ERROR 나머지는 SUCCESS 연결 성공? 반환값을 검사 send 호출 ok → SUCCESS send → SEND_ERROR send 호출 안 함 SKIPPED 그대로 connect 입력의 경로 close와 결과 출력 close 출력 먼저 결과 출력은 그 뒤 참 거짓 실선은 검사와 후속 호출 · 두 분기 뒤 close로 합류 · SKIPPED는 호출 결과가 아님
호출 여부와 반환 결과의 분기 세 알려진 입력의 connect 결과를 검사합니다. SUCCESS일 때만 send가 실행되어 ok는 SUCCESS, send는 SEND_ERROR를 받습니다. connect 입력은 send를 호출하지 않아 SKIPPED를 유지합니다. 두 분기 뒤 close 출력과 결과 출력으로 합류합니다. 예상 밖 예외를 포함한 finally 보장의 그림은 아닙니다. 세 입력 중 하나 ok / connect / send connect 결과 connect만 CONNECT_ERROR 나머지는 SUCCESS 연결 성공? 반환값을 검사 send 호출 ok → SUCCESS send → SEND_ERROR send 호출 안 함 SKIPPED 그대로 connect 입력의 경로 close와 결과 출력 close 출력 먼저 결과 출력은 그 뒤 참 거짓 실선은 검사와 후속 호출 두 분기 뒤 close로 합류 SKIPPED는 호출 결과가 아님
세 입력의 실제 호출·반환값과 원문 출력 순서
입력connect 반환send 호출·반환원문 출력 순서
okSUCCESSsend 호출: SUCCESS
close=ok
ok=SUCCESS/SUCCESS
connectCONNECT_ERRORsend 호출 안 함: SKIPPED 유지
close=connect
connect=CONNECT_ERROR/SKIPPED
sendSUCCESSsend 호출: SEND_ERROR
close=send
send=SUCCESS/SEND_ERROR

ok

실제 호출과 반환값
connect: SUCCESS
send 호출: SUCCESS
원문 출력 순서
close=ok
ok=SUCCESS/SUCCESS

connect

실제 호출과 반환값
connect: CONNECT_ERROR
send 호출 안 함: SKIPPED 유지
원문 출력 순서
close=connect
connect=CONNECT_ERROR/SKIPPED

send

실제 호출과 반환값
connect: SUCCESS
send 호출: SEND_ERROR
원문 출력 순서
close=send
send=SUCCESS/SEND_ERROR

SKIPPED는 전송을 시도하지 않았다는 값입니다. SEND_ERROR는 전송 메서드를 호출해 받은 실패 값입니다. 표의 close 도달은 이 세 반환 코드 경로의 결과이며 예상 밖 예외의 정리까지 보장하지 않습니다.

이 예의 조건

  • 알려진 ok/connect/send 입력만 추적하며 예상 밖 예외의 finally 보장이 아니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보연결 실패의 SKIPPED는 전송 실패와 다르다 연결 실패의 SKIPPED는 전송 실패와 다르다 ReturnCodeFlowExercise의 ok, connect, send 세 입력은 연결 반환값에 따라 send 호출 여부가 달라진다. 세 원문 경로는 close를 먼저 출력한 뒤 결과를 출력한다. 반환 결과 · 세 입력 연결 실패의 SKIPPED는 전송 실패와 다르다 연결 성공일 때만 send를 호출합니다. 연결 실패의 SKIPPED는 전송을 시도한 SEND_ERROR와 다른 상태입니다. 세 입력의 실제 호출·반환값과 원문 출력 순서 입력 connect 반환 send 호출·반환 원문 출력 순서 ok SUCCESS send 호출: SUCCESS close=ok ok=SUCCESS/SUCCESS connect CONNECT_ERROR send 호출 안 함: SKIPPED 유지 close=connect connect=CONNECT_ERROR/SKIPPED send SUCCESS send 호출: SEND_ERROR close=send send=SUCCESS/SEND_ERROR ok 실제 호출과 반환값 connect: SUCCESS send 호출: SUCCESS 원문 출력 순서 close=ok ok=SUCCESS/SUCCESS connect 실제 호출과 반환값 connect: CONNECT_ERROR send 호출 안 함: SKIPPED 유지 원문 출력 순서 close=connect connect=CONNECT_ERROR/SKIPPED send 실제 호출과 반환값 connect: SUCCESS send 호출: SEND_ERROR 원문 출력 순서 close=send send=SUCCESS/SEND_ERROR SKIPPED 는 전송을 시도하지 않았다는 값입니다. SEND_ERROR 는 전송 메서드를 호출해 받은 실패 값입니다. 표의 close 도달은 이 세 반환 코드 경로의 결과이며 예상 밖 예외의 정리까지 보장하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
연결 결과

세 입력이 connect 결과를 받습니다.

  • connect 입력: CONNECT_ERROR
  • ok/send 입력: SUCCESS
  • send 최초 상태: SKIPPED
  • 반환값을 검사하여 후속 호출 허가를 정한다.
전송 허가

성공 경로만 send를 실행합니다.

  • ok: send SUCCESS
  • send: send SEND_ERROR
  • connect: send 미호출 / SKIPPED
  • SKIPPED는 호출 결과가 아니라 실행되지 않은 상태다.
정리와 관찰

분기 후 close를 출력한 다음 결과를 출력합니다.

  • 출력 순서: close → 결과
  • 세 결과: SUCCESS / CONNECT_ERROR·SKIPPED / SEND_ERROR
  • 후속 관찰 전에 알려진 경로의 정리 호출이 합류한다.

연결 실패의 SKIPPED는 전송 실패와 다르다

연결 결과 검사 뒤 send 실행 또는 SKIPPED 유지, close 출력 뒤 결과 출력의 실제 순서입니다. 세 입력 영역을 고정하고 현재 호출/미도달을 단계로 강조하기 좋습니다.

연결 실패의 SKIPPED는 전송 실패와 다르다

ReturnCodeFlowExercise의 ok, connect, send 세 입력은 연결 반환값에 따라 send 호출 여부가 달라진다. 세 원문 경로는 close를 먼저 출력한 뒤 결과를 출력한다.

반환 결과 · 세 입력

연결 실패의 SKIPPED는 전송 실패와 다르다

연결 성공일 때만 send를 호출합니다. 연결 실패의 SKIPPED는 전송을 시도한 SEND_ERROR와 다른 상태입니다.

호출 여부와 반환 결과의 분기 세 알려진 입력의 connect 결과를 검사합니다. SUCCESS일 때만 send가 실행되어 ok는 SUCCESS, send는 SEND_ERROR를 받습니다. connect 입력은 send를 호출하지 않아 SKIPPED를 유지합니다. 두 분기 뒤 close 출력과 결과 출력으로 합류합니다. 예상 밖 예외를 포함한 finally 보장의 그림은 아닙니다. 세 입력 중 하나 ok / connect / send connect 결과 connect만 CONNECT_ERROR 나머지는 SUCCESS 연결 성공? 반환값을 검사 send 호출 ok → SUCCESS send → SEND_ERROR send 호출 안 함 SKIPPED 그대로 connect 입력의 경로 close와 결과 출력 close 출력 먼저 결과 출력은 그 뒤 참 거짓 실선은 검사와 후속 호출 · 두 분기 뒤 close로 합류 · SKIPPED는 호출 결과가 아님
호출 여부와 반환 결과의 분기 세 알려진 입력의 connect 결과를 검사합니다. SUCCESS일 때만 send가 실행되어 ok는 SUCCESS, send는 SEND_ERROR를 받습니다. connect 입력은 send를 호출하지 않아 SKIPPED를 유지합니다. 두 분기 뒤 close 출력과 결과 출력으로 합류합니다. 예상 밖 예외를 포함한 finally 보장의 그림은 아닙니다. 세 입력 중 하나 ok / connect / send connect 결과 connect만 CONNECT_ERROR 나머지는 SUCCESS 연결 성공? 반환값을 검사 send 호출 ok → SUCCESS send → SEND_ERROR send 호출 안 함 SKIPPED 그대로 connect 입력의 경로 close와 결과 출력 close 출력 먼저 결과 출력은 그 뒤 참 거짓 실선은 검사와 후속 호출 두 분기 뒤 close로 합류 SKIPPED는 호출 결과가 아님
세 입력의 실제 호출·반환값과 원문 출력 순서
입력connect 반환send 호출·반환원문 출력 순서
okSUCCESSsend 호출: SUCCESS
close=ok
ok=SUCCESS/SUCCESS
connectCONNECT_ERRORsend 호출 안 함: SKIPPED 유지
close=connect
connect=CONNECT_ERROR/SKIPPED
sendSUCCESSsend 호출: SEND_ERROR
close=send
send=SUCCESS/SEND_ERROR

ok

실제 호출과 반환값
connect: SUCCESS
send 호출: SUCCESS
원문 출력 순서
close=ok
ok=SUCCESS/SUCCESS

connect

실제 호출과 반환값
connect: CONNECT_ERROR
send 호출 안 함: SKIPPED 유지
원문 출력 순서
close=connect
connect=CONNECT_ERROR/SKIPPED

send

실제 호출과 반환값
connect: SUCCESS
send 호출: SEND_ERROR
원문 출력 순서
close=send
send=SUCCESS/SEND_ERROR

SKIPPED는 전송을 시도하지 않았다는 값입니다. SEND_ERROR는 전송 메서드를 호출해 받은 실패 값입니다. 표의 close 도달은 이 세 반환 코드 경로의 결과이며 예상 밖 예외의 정리까지 보장하지 않습니다.

이 예의 조건

  • 알려진 ok/connect/send 입력만 추적하며 예상 밖 예외의 finally 보장이 아니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보연결 실패의 SKIPPED는 전송 실패와 다르다 연결 실패의 SKIPPED는 전송 실패와 다르다 ReturnCodeFlowExercise의 ok, connect, send 세 입력은 연결 반환값에 따라 send 호출 여부가 달라진다. 세 원문 경로는 close를 먼저 출력한 뒤 결과를 출력한다. 반환 결과 · 세 입력 연결 실패의 SKIPPED는 전송 실패와 다르다 연결 성공일 때만 send를 호출합니다. 연결 실패의 SKIPPED는 전송을 시도한 SEND_ERROR와 다른 상태입니다. 세 입력의 실제 호출·반환값과 원문 출력 순서 입력 connect 반환 send 호출·반환 원문 출력 순서 ok SUCCESS send 호출: SUCCESS close=ok ok=SUCCESS/SUCCESS connect CONNECT_ERROR send 호출 안 함: SKIPPED 유지 close=connect connect=CONNECT_ERROR/SKIPPED send SUCCESS send 호출: SEND_ERROR close=send send=SUCCESS/SEND_ERROR ok 실제 호출과 반환값 connect: SUCCESS send 호출: SUCCESS 원문 출력 순서 close=ok ok=SUCCESS/SUCCESS connect 실제 호출과 반환값 connect: CONNECT_ERROR send 호출 안 함: SKIPPED 유지 원문 출력 순서 close=connect connect=CONNECT_ERROR/SKIPPED send 실제 호출과 반환값 connect: SUCCESS send 호출: SEND_ERROR 원문 출력 순서 close=send send=SUCCESS/SEND_ERROR SKIPPED 는 전송을 시도하지 않았다는 값입니다. SEND_ERROR 는 전송 메서드를 호출해 받은 실패 값입니다. 표의 close 도달은 이 세 반환 코드 경로의 결과이며 예상 밖 예외의 정리까지 보장하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
연결 결과

세 입력이 connect 결과를 받습니다.

  • connect 입력: CONNECT_ERROR
  • ok/send 입력: SUCCESS
  • send 최초 상태: SKIPPED
  • 반환값을 검사하여 후속 호출 허가를 정한다.
전송 허가

성공 경로만 send를 실행합니다.

  • ok: send SUCCESS
  • send: send SEND_ERROR
  • connect: send 미호출 / SKIPPED
  • SKIPPED는 호출 결과가 아니라 실행되지 않은 상태다.
정리와 관찰

분기 후 close를 출력한 다음 결과를 출력합니다.

  • 출력 순서: close → 결과
  • 세 결과: SUCCESS / CONNECT_ERROR·SKIPPED / SEND_ERROR
  • 후속 관찰 전에 알려진 경로의 정리 호출이 합류한다.

boolean 두 개로 결과를 나타내는 것보다 상태의 의미가 분명해졌지만, close()가 실행되는 것은 메서드들이 약속대로 enum 값을 돌려줄 때뿐입니다. 예상하지 못한 예외가 나도 close()를 실행하려면 finally나 AutoCloseable이 필요합니다.

  1. 본문의 ExceptionNetworkFlow에서 send()의 throw 문을 throw new IllegalStateException("serializer crashed");로 바꾸면 출력은 어떻게 될까요? 특히 두 번째 실행에서 disconnected가 출력되는지 예상해 보세요.
해설 보기

첫 번째 실행은 connect()에서 이미 실패하므로 바뀐 send()까지 가지 않고, 출력도 그대로입니다. 두 번째 실행에서는 connected가 출력된 뒤 send()가 IllegalStateException을 던집니다. 이 예외는 NetworkException이 아니므로 catch (NetworkException error)가 잡지 않습니다. 예외는 sendMessage와 main을 차례로 빠져나가고, 프로그램은 스택 트레이스를 출력하며 끝납니다.

실행 결과
network-error=connect failed
disconnected
---
connected
Exception in thread "main" java.lang.IllegalStateException: serializer crashed
	at ExceptionNetworkFlow$NetworkClient.send(ExceptionNetworkFlow.java:37)
	at ExceptionNetworkFlow.sendMessage(ExceptionNetworkFlow.java:14)
	at ExceptionNetworkFlow.main(ExceptionNetworkFlow.java:5)

두 번째 실행의 disconnected는 출력되지 않습니다. disconnect()가 try/catch 다음에 있어서, 잡히지 않은 예외가 메서드를 빠져나가면 그 줄에 도달하지 못하기 때문입니다. send()에는 여전히 throws NetworkException이 붙어 있지만 컴파일은 문제없이 됩니다. IllegalStateException은 컴파일러가 처리 여부를 검사하지 않는 비검사 예외이고, 선언한 예외를 실제로 던지지 않는 메서드도 허용되기 때문입니다.

다음 글

다음 글에서는 NetworkException처럼 처리하지 않으면 컴파일되지 않는 검사 예외를 자세히 살펴봅니다. 자바의 예외 클래스가 어떤 계층을 이루는지, 그리고 예외를 그 자리에서 잡을지(catch) 호출한 쪽으로 넘길지(throws)를 어떻게 정하는지 다룹니다.