안동민 개발노트

본문 시작

예외 계층과 검사 예외

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 아래에는 크게 Error와 Exception이 있습니다.

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을 반환해 viewCountFor를 끝내고, main은 반환받은 값으로 viewCount=0을 출력합니다.

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

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

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


throws를 통한 책임 전달

이 예제의 서비스는 persist가 던진 입력 검증 오류에 저장 문맥을 더하고, CLI 경계는 안내 메시지를 출력합니다.

서비스는 IllegalArgumentException을 BoardWriteException으로 변환해 cause와 함께 전달하고, main이 변환된 예외를 잡습니다.

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에서 값을 반환하는 복구와 새 예외로 전달하는 변환

CheckedExceptionRecovery는 BoardReadException을 잡고 0을 반환한다. 별도 CheckedExceptionPropagation은 IllegalArgumentException을 원인으로 가진 BoardWriteException을 던져 main이 잡는다.

CheckedExceptionRecovery와 CheckedExceptionPropagation은 별도 프로그램입니다. 같은 catch 문법이라도 실제 다음 동작은 다릅니다.

catch에서 값을 반환하는 복구와 새 예외로 전달하는 변환
실행 위치기본값으로 복구하는 원문새 문맥으로 변환하는 원문
실패 발생loadViewCount가 BoardReadException을 던집니다.
메시지: store unavailable: java
persist가 음수 -10을 거부해 IllegalArgumentException을 던집니다.
서비스의 catch실패 메시지를 출력하고 return 0으로 메서드에서 반환합니다.BoardWriteException을 새로 던집니다.
기존 예외는 cause로 연결합니다.
main이 관찰
fallback=store unavailable: java
viewCount=0
retry-guide=could not save exception
cause=IllegalArgumentException
실패 발생
기본값으로 복구하는 원문: loadViewCount가 BoardReadException을 던집니다.
메시지: store unavailable: java
새 문맥으로 변환하는 원문: persist가 음수 -10을 거부해 IllegalArgumentException을 던집니다.
서비스의 catch
기본값으로 복구하는 원문: 실패 메시지를 출력하고 return 0으로 메서드에서 반환합니다.
새 문맥으로 변환하는 원문: BoardWriteException을 새로 던집니다.
기존 예외는 cause로 연결합니다.
main이 관찰
기본값으로 복구하는 원문:
fallback=store unavailable: java
viewCount=0
새 문맥으로 변환하는 원문:
retry-guide=could not save exception
cause=IllegalArgumentException

복구 원문은 catch 블록 뒤의 문장으로 진행하는 것이 아니라 호출자에게 0을 돌려줍니다. 변환 원문의 throws BoardWriteException은 전달 가능한 타입의 선언이며, 실제 새 예외는 throw가 발생시킵니다.


catch와 throws의 선택

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

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

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

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

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

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

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

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

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

반대로 throws Exception처럼 넓게 선언하면 구체적인 체크 예외가 늘어도 이미 선언한 범위에 포함되어 세부 계약 변화가 잘 드러나지 않습니다. 호출자의 catch 또는 throws 의무 자체가 없어지는 것은 아닙니다.


연습 문제

BoardAccessException 아래의 BoardNotFoundException과 BoardPermissionException을 만들고, 서비스는 부모 타입만 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는 향후 새 하위 예외가 추가됐을 때의 안전망입니다.

부모 throws 계약에서도 실제 자식 타입으로 복구한다

CheckedHierarchyExercise의 서비스는 BoardAccessException을 선언한다. missing과 private은 서로 다른 자식 예외가 발생해 CLI의 서로 다른 catch로 들어간다.

서비스의 계약은 throws BoardAccessException입니다. CLI는 원문 순서대로 두 구체 타입을 먼저 잡고 부모 타입을 마지막에 둡니다.

부모 throws 계약에서도 실제 자식 타입으로 복구한다
원문 입력발생 타입과 선택된 catch원문 출력
missingBoardNotFoundException
첫 번째 구체 catch
create-new=missing
privateBoardPermissionException
두 번째 구체 catch
request-access=private
missing
발생 타입과 선택된 catch: BoardNotFoundException
첫 번째 구체 catch
원문 출력:
create-new=missing
private
발생 타입과 선택된 catch: BoardPermissionException
두 번째 구체 catch
원문 출력:
request-access=private

마지막 BoardAccessException catch에는 이 두 입력이 들어가지 않습니다. 앞에서 일치한 catch 하나만 실행합니다. 부모 catch를 먼저 두어 자식 catch를 가리는 순서는 컴파일되지 않습니다.