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

안동민 개발노트

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

예외 계층과 검사 예외

Throwable 계층을 복구 가능성으로 읽고 처리하지 않은 체크 예외의 컴파일 실패를 재현한 뒤 catch와 throws의 책임 차이를 설계합니다.

자바의 예외는 제어 키워드이기 전에 객체입니다.

메시지와 원인을 가진 객체가 타입 계층을 따라 전달되므로 catch가 어느 범위를 처리할지, throws가 어느 범위를 호출자에게 넘길지 다형성 규칙으로 결정됩니다.

먼저 아무 처리도 하지 않은 체크 예외가 왜 컴파일을 멈추는지 관찰합니다.


검사 예외의 처리 의무

Exception을 직접 상속한 BoardLoadException은 체크 예외입니다.

load()가 이 예외를 던질 수 있다고 선언했는데 main은 catch도 throws도 사용하지 않았습니다.

lab/UnhandledCheckedBoardException.java
public final class UnhandledCheckedBoardException {
    public static void main(String[] args) {
        PostRepository repository = new PostRepository();
        System.out.println(repository.load("missing"));
    }

    private static final class PostRepository {
        String load(String id) throws BoardLoadException {
            throw new BoardLoadException("not found: " + id);
        }
    }

    private static final class BoardLoadException extends Exception {
        BoardLoadException(String message) { super(message); }
    }
}
javac 핵심 진단
error: unreported exception BoardLoadException;
must be caught or declared to be thrown

이 실패는 실행 중 우연히 발생한 것이 아니라 호출 계약을 지키지 않은 소스 오류입니다.

체크 예외를 발생시키는 메서드를 호출하면 현재 메서드가 복구하거나 자신의 계약에 throws를 추가해야 합니다.

컴파일러가 복구 방식을 정해 주지는 않지만, 책임 결정을 생략하는 것은 막습니다.


Throwable 예외 계층

Throwable 아래에는 크게 ErrorException이 있습니다.

Error는 JVM이나 실행 환경이 지속하기 어려운 심각한 문제를 표현하므로 일반 업무 코드가 광범위하게 잡아 계속 진행하는 대상이 아닙니다.

Exception 계열이 애플리케이션 흐름에서 주로 다루는 예외입니다.

Exception 가운데 RuntimeException과 그 하위는 언체크입니다.

그 밖의 Exception 하위는 체크입니다.

어느 쪽이 더 심각한지를 나타내는 서열은 아닙니다.

컴파일러가 catch/throws 결정을 강제하는지의 차이입니다.

부모 타입으로 잡으면 자식 예외까지 포함하므로 catch (Exception)은 생각보다 넓은 실패를 가립니다.

src/ExceptionHierarchyObservation.java
public final class ExceptionHierarchyObservation {
    public static void main(String[] args) {
        inspect(new BoardConnectionException("offline"));
        inspect(new IllegalArgumentException("viewCount"));
    }

    private static void inspect(Exception exception) {
        System.out.println(exception.getClass().getSimpleName()
                + ":checked=" + !(exception instanceof RuntimeException));
    }

    private static final class BoardConnectionException extends Exception {
        BoardConnectionException(String message) { super(message); }
    }
}
BoardConnectionException:checked=true
IllegalArgumentException:checked=false

분류는 상속 관계로 정해집니다.

이름에 Runtime을 넣거나 문서에서 “치명적”이라고 적는다고 바뀌지 않습니다.

사용자 정의 예외의 부모를 고르는 순간 호출자에게 부과할 컴파일 계약도 함께 선택합니다.


구체 예외의 지역 복구

파일에서 조회 수를 읽는 저장소가 일시적으로 실패했을 때 메모리 기본값을 쓸 수 있다고 가정합니다.

이 서비스에는 실제 대안이 있으므로 예외를 잡아 결과로 복구하는 것이 책임에 맞습니다.

src/CheckedExceptionRecovery.java
public final class CheckedExceptionRecovery {
    public static void main(String[] args) {
        BoardService service = new BoardService(new FailingRepository());
        System.out.println("viewCount=" + service.viewCountFor("java"));
    }

    private static final class BoardService {
        private final PostRepository repository;

        BoardService(PostRepository repository) { this.repository = repository; }

        int viewCountFor(String title) {
            try {
                return repository.loadViewCount(title);
            } catch (BoardReadException error) {
                System.out.println("fallback=" + error.getMessage());
                return 0;
            }
        }
    }

    private interface PostRepository {
        int loadViewCount(String title) throws BoardReadException;
    }

    private static final class FailingRepository implements PostRepository {
        public int loadViewCount(String title) throws BoardReadException {
            throw new BoardReadException("store unavailable: " + title);
        }
    }

    private static final class BoardReadException extends Exception {
        BoardReadException(String message) { super(message); }
    }
}
fallback=store unavailable: java
viewCount=0

catch 이후에는 블록 다음 문장으로 정상 흐름이 이어집니다.

따라서 0이 정말 안전한 기본값인지가 중요합니다.

장애를 실제 조회 수 0과 혼동하면 조용한 데이터 오류가 됩니다.

기본값이 의미를 왜곡한다면 복구하지 말고 상위 경계로 전달해야 합니다.


throws를 통한 책임 전달

하위 저장소의 오류를 서비스가 해결할 수 없지만 CLI 경계는 재시도 안내를 보여 줄 수 있습니다.

서비스는 throws로 전달하고 경계가 한 번만 잡습니다.

throw는 예외 객체를 발생시키는 문장이고, throws는 메서드가 밖으로 보낼 수 있는 타입을 선언합니다.

src/CheckedExceptionPropagation.java
public final class CheckedExceptionPropagation {
    public static void main(String[] args) {
        BoardService service = new BoardService();
        try {
            service.save("exception", -10);
        } catch (BoardWriteException error) {
            System.out.println("retry-guide=" + error.getMessage());
            System.out.println("cause=" + error.getCause().getClass().getSimpleName());
        }
    }

    private static final class BoardService {
        void save(String title, int viewCount) throws BoardWriteException {
            try {
                persist(title, viewCount);
            } catch (IllegalArgumentException cause) {
                throw new BoardWriteException("could not save " + title, cause);
            }
        }

        private void persist(String title, int viewCount) {
            if (viewCount < 0) throw new IllegalArgumentException("negative viewCount");
        }
    }

    private static final class BoardWriteException extends Exception {
        BoardWriteException(String message, Throwable cause) { super(message, cause); }
    }
}
retry-guide=could not save exception
cause=IllegalArgumentException

원인을 cause로 연결하면 새 문맥을 추가하면서도 최초 실패를 잃지 않습니다.

문자열에 원인 메시지만 붙이면 스택 위치와 구체 타입이 사라집니다.

예외 변환은 다음 계층이 이해할 수 있는 추상화로 바꿀 때 사용하고, 단순히 throws를 감추려는 목적으로 모든 예외를 새로 포장하지 않습니다.


catch와 throws의 선택

현재 메서드가 성공에 준하는 대안을 실제로 만들 수 있는지 묻습니다.

재시도 횟수와 지연 정책을 알고 있거나, 사용자가 수정 가능한 입력 오류로 변환하거나, 선택 기능만 포기해도 핵심 작업을 계속할 수 있으면 catch가 후보입니다.

그저 로그 한 줄을 남기고 같은 예외를 다시 던지는 중간 계층은 책임이 중복될 수 있습니다.

catch 순서는 구체 타입에서 넓은 타입으로 배치합니다.

부모를 먼저 잡으면 자식 catch에는 도달할 수 없어 컴파일되지 않습니다.

다중 catch는 처리 방식이 완전히 같은 형제 타입에 사용합니다.

Error까지 포함하는 Throwable catch는 종료 훅이나 프레임워크 경계처럼 매우 제한된 곳이 아니면 피합니다.

throws에는 실제 호출자가 알아야 할 안정적인 예외 계약을 적습니다.

구현 세부 예외가 공개 API 밖으로 그대로 새면 저장소 교체가 호출자 변경으로 이어집니다.

반대로 최상위 Exception 하나로 뭉개면 중요한 체크 예외 누락을 컴파일러가 찾지 못합니다.


연습 문제

BoardAccessException 아래의 BoardNotFoundExceptionBoardPermissionException을 만들고, 서비스는 부모 타입만 throws 하세요.

CLI에서는 not-found는 새 기록 생성 안내, permission은 권한 안내로 구분해 출력합니다.

해설 보기
src/CheckedHierarchyExercise.java
public final class CheckedHierarchyExercise {
    public static void main(String[] args) {
        open("missing");
        open("private");
    }

    private static void open(String id) {
        BoardService service = new BoardService();
        try {
            service.open(id);
        } catch (BoardNotFoundException error) {
            System.out.println("create-new=" + error.id());
        } catch (BoardPermissionException error) {
            System.out.println("request-access=" + error.id());
        } catch (BoardAccessException error) {
            System.out.println("unexpected-access=" + error.getMessage());
        }
    }

    private static final class BoardService {
        void open(String id) throws BoardAccessException {
            if (id.equals("missing")) throw new BoardNotFoundException(id);
            if (id.equals("private")) throw new BoardPermissionException(id);
            System.out.println("opened=" + id);
        }
    }

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

    private static final class BoardNotFoundException extends BoardAccessException {
        private final String id;
        BoardNotFoundException(String id) { super("missing " + id); this.id = id; }
        String id() { return id; }
    }

    private static final class BoardPermissionException extends BoardAccessException {
        private final String id;
        BoardPermissionException(String id) { super("denied " + id); this.id = id; }
        String id() { return id; }
    }
}
create-new=missing
request-access=private

서비스 계약은 부모 타입으로 안정적으로 유지하면서 경계는 실제 자식 타입의 정보로 다른 복구를 수행합니다.

마지막 부모 catch는 향후 새 하위 예외가 추가됐을 때의 안전망입니다.