본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
12장 : 예외 이론과 복구 실습

반환 코드의 흐름 붕괴

연결과 전송 실패를 문자열 반환 값으로 다루면서 생기는 누락과 자원 해제 버그를 관찰하고 정상 값과 실패 신호의 경계를 다시 설계합니다.

외부 시스템과 통신하는 코드는 성공 경로만 읽어서는 완성되지 않습니다.

연결이 실패하면 전송을 시작하지 않아야 하고, 연결 뒤 전송이 실패하더라도 확보한 자원은 닫아야 합니다.

반환 코드만 사용하는 첫 구현에서 이 두 규칙이 어떻게 호출자의 기억에 의존하는지부터 확인합니다.


실패 코드와 흐름 붕괴

다음 클라이언트는 테스트 입력에 따라 실패 문자열을 돌려줍니다.

서비스는 반환값을 받기만 하고 검사하지 않아 연결되지 않은 상태에서 전송까지 진행합니다.

컴파일러는 String이 성공인지 오류인지 알지 못합니다.

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

연결 실패와 전송 성공이 한 결과에 함께 나타납니다.

문제는 실패 문자열의 철자가 아니라 호출 순서를 보호하는 제어 흐름이 없다는 점입니다.

반환 타입이 String이면 "SUCCESS", "success", 빈 문자열도 모두 같은 타입이라 호출자가 매번 해석 규칙을 알아야 합니다.


즉시 분기와 후속 동작 차단

첫 개선은 연결과 전송 뒤에 결과를 확인하는 것입니다.

연결이 실패하면 send를 건너뛰고, 전송이 실패하면 오류를 보고합니다.

그러나 중간 return으로 빠져나가면 마지막 disconnect도 건너뛸 수 있으므로 종료 동작은 분기 바깥에 둡니다.

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

이 버전은 정해진 두 실패에는 올바르게 동작합니다.

핵심 개선은 정상 흐름에서 실패 조건을 분리하고 연결 성공 여부에 따라 전송을 제한한 것입니다.

다만 검사하지 않은 새 반환 코드가 추가되거나 send()가 예상하지 못한 런타임 예외를 던지면 종료 호출이 보장되지 않습니다.


닫힌 결과 타입

예외를 배우기 전에도 문자열 코드는 enum 또는 결과 객체로 개선할 수 있습니다.

호출자는 가능한 상태를 자동 완성으로 확인하고, switch 식으로 모든 상태를 다뤘는지 컴파일 시점에 확인할 수 있습니다.

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

결과 타입은 실패가 흔하고 호출자가 두 결과를 모두 업무적으로 사용해야 할 때 좋은 선택입니다.

예를 들어 검색 결과 없음, 유효성 검사 실패, 결제 거절처럼 예상 가능한 분기는 값으로 모델링할 수 있습니다.

반면 연결 라이브러리 깊은 곳에서 정상 값을 만들 수 없고 호출 스택을 중단해야 한다면 예외가 더 자연스럽습니다.


실패 신호의 선택 기준

반환 값과 예외는 “오류냐 아니냐”만으로 나누지 않습니다.

호출자가 즉시 대안을 선택할 수 있고 실패 빈도가 정상 사용의 일부라면 명시 결과 타입이 읽기 쉽습니다.

호출자가 현재 계층에서 복구할 수 없고 여러 단계를 건너 경계 처리기로 전달해야 한다면 예외가 중복 분기를 줄입니다.

  • 정상적으로 비어 있을 수 있는 조회: Optional이나 sealed 결과 타입
  • 사용자 입력 검증: 오류 목록을 포함한 결과 값
  • 파일·네트워크·DB 접근 실패: 원인과 문맥을 담은 예외
  • 프로그래밍 계약 위반: IllegalArgumentException 같은 언체크 예외
  • 반드시 실행할 정리: 반환 방식과 무관하게 구조적으로 보장

예외가 자동으로 자원을 닫아 주는 것은 아닙니다.

다음 문서들에서 try/catch/finally와 try-with-resources가 각각 어떤 흐름을 보장하는지 분리해서 다룹니다.


연습 문제

OperationResult enum을 사용해 connect가 실패하면 send를 실행하지 않고, 세 가지 입력 ok, connect, send 모두 마지막에 close가 출력되도록 완성하세요.

아직 try/finally는 사용하지 않습니다.

해설 보기
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

SKIPPED는 실패와 “호출하지 않음”을 구분합니다.

단순 boolean 두 개보다 상태 의미가 분명하지만, 예상하지 못한 예외에도 close를 보장하려면 finally 또는 AutoCloseable이 필요합니다.