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

안동민 개발노트

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

비검사 예외와 복구 경계

RuntimeException이 선언 없이 전파되어 프로그램을 종료하는 상황을 재현하고 체크 여부를 복구 가능성과 API 안정성에 맞춰 선택합니다.

언체크 예외는 처리하지 않아도 된다는 뜻이지 처리해서는 안 된다는 뜻이 아닙니다.

컴파일러가 catch나 throws를 강제하지 않으므로, 어떤 계층에서 복구하고 어떤 경계에서 공통 처리할지 설계가 더 중요합니다.

먼저 아무도 잡지 않은 예외가 호출 스택을 거슬러 올라가는 실제 결과를 봅니다.


처리되지 않은 런타임 예외

저장소가 처리할 수 없는 상태를 사용자 정의 언체크 예외로 던집니다.

서비스와 main 어느 곳에도 catch가 없습니다.

각 메서드에 throws를 적지 않아도 컴파일은 성공하지만 실행은 정상 종료 문장에 도달하지 못합니다.

lab/UncaughtUncheckedBoardException.java
public final class UncaughtUncheckedBoardException {
    public static void main(String[] args) {
        BoardService service = new BoardService();
        service.load("corrupt");
        System.out.println("normal-end");
    }

    private static final class BoardService {
        void load(String id) { repositoryCall(id); }

        private void repositoryCall(String id) {
            throw new PostDataException("invalid record: " + id);
        }
    }

    private static final class PostDataException extends RuntimeException {
        PostDataException(String message) { super(message); }
    }
}
실행 결과의 핵심
Exception in thread "main" UncaughtUncheckedBoardException$PostDataException:
invalid record: corrupt

JVM은 예외 타입과 메시지, 발생 지점부터 이어진 스택 추적을 표준 오류에 출력하고 프로세스를 실패 코드로 끝냅니다.

normal-end는 출력되지 않습니다.

예외가 발생한 지점 이후의 정상 흐름은 중단되고, 일치하는 catch를 찾을 때까지 호출자가 차례로 빠져나갑니다.


비검사 예외의 문서화

RuntimeException 하위 타입은 throws를 생략할 수 있지만 중요한 실패 계약을 IDE와 API 문서에 드러내려면 선언할 수 있습니다.

선언하더라도 호출자에게 컴파일 강제가 생기지는 않습니다.

src/DocumentedUncheckedContract.java
public final class DocumentedUncheckedContract {
    public static void main(String[] args) {
        Post entry = Post.create("exception", 45);
        System.out.println(entry);

        try {
            Post.create("exception", -1);
        } catch (InvalidPostException error) {
            System.out.println("rejected=" + error.getMessage());
        }
    }

    private record Post(String title, int viewCount) {
        static Post create(String title, int viewCount)
                throws InvalidPostException {
            if (title == null || title.isBlank()) {
                throw new InvalidPostException("blank title");
            }
            if (viewCount <= 0) {
                throw new InvalidPostException("viewCount=" + viewCount);
            }
            return new Post(title, viewCount);
        }
    }

    private static final class InvalidPostException extends RuntimeException {
        InvalidPostException(String message) { super(message); }
    }
}
Post[title=exception, viewCount=45]
rejected=viewCount=-1

이 오류는 같은 인수로 다시 호출해도 성공하지 않습니다.

현재 값이 생성자 계약을 어겼으므로 호출 지점 또는 입력 경계가 값을 고쳐야 합니다.

깊은 계층이 무의미하게 재시도하는 대신 실패를 전달하는 것이 맞습니다.


검사 예외와 비검사 예외

체크 예외는 호출자가 놓치면 안 되는 복구 가능한 조건에 유용합니다.

예를 들어 사용자가 다른 파일을 고를 수 있는 파일 선택 API나, 업무상 반드시 승인/거절을 구분해야 하는 연동은 호출자 결정을 강제할 가치가 있습니다.

하지만 네트워크·DB 장애처럼 대부분의 중간 계층이 해결할 수 없는 예외를 모두 체크로 만들면 throws가 계층마다 반복됩니다.

언체크 예외는 복구 불가능한 시스템 장애, 프로그래밍 계약 위반, 중간 계층이 모르는 인프라 실패를 상위 경계로 보내기 좋습니다.

대신 API 문서, 명확한 예외 계층, 공통 처리기가 필요합니다.

“귀찮아서 RuntimeException”은 선택 기준이 아니며, 실제 호출자가 무엇을 할 수 있는지가 기준입니다.

src/UncheckedExceptionTranslation.java
public final class UncheckedExceptionTranslation {
    public static void main(String[] args) {
        BoardService service = new BoardService(new BrokenStore());
        try {
            service.summary("java");
        } catch (BoardSystemException error) {
            System.out.println("public=" + error.getMessage());
            System.out.println("root=" + error.getCause().getClass().getSimpleName());
        }
    }

    private static final class BoardService {
        private final BoardStore store;
        BoardService(BoardStore store) { this.store = store; }

        String summary(String title) {
            try {
                return title + "=" + store.viewCount(title);
            } catch (StoreAccessException cause) {
                throw new BoardSystemException("board data unavailable", cause);
            }
        }
    }

    private interface BoardStore { int viewCount(String title); }

    private static final class BrokenStore implements BoardStore {
        public int viewCount(String title) {
            throw new StoreAccessException("disk read failed: " + title);
        }
    }

    private static final class StoreAccessException extends RuntimeException {
        StoreAccessException(String message) { super(message); }
    }

    private static final class BoardSystemException extends RuntimeException {
        BoardSystemException(String message, Throwable cause) { super(message, cause); }
    }
}
public=board data unavailable
root=StoreAccessException

서비스는 저장 장치 예외를 업무 경계의 예외로 변환합니다.

이런 예외 변환 덕분에 호출자는 디스크 구현을 몰라도 됩니다.

catch 후 같은 추상화의 예외를 메시지만 바꿔 다시 던지는 것은 가치가 적지만, 계층 경계를 넘으며 안정된 타입과 의미를 제공한다면 변환이 결합을 줄입니다.


예외 전파의 명시적 결정

중간 서비스가 오류를 복구할 방법이 없다면 catch하지 않고 전달합니다.

로그는 최종 처리 경계에서 한 번 남기는 것이 보통 낫습니다.

모든 계층이 같은 예외를 기록하면 한 장애가 여러 건처럼 보이고, 스택 추적이 반복되어 원인 파악이 어려워집니다.

다만 선택 기능만 포기하면 핵심 흐름을 계속할 수 있는 경우에는 지역 복구가 가능합니다.

추천 게시글 제목을 불러오지 못해도 사용자가 직접 입력할 수 있다면 추천 예외만 잡고 빈 추천 목록을 보여 줄 수 있습니다.

DB 저장 실패를 잡아 성공으로 위장해서는 안 됩니다.

복구 뒤 시스템 불변식이 유지되는지 확인해야 합니다.


예외 타입 선택 기준

  1. 호출자가 이 실패를 발견하면 즉시 다른 선택으로 성공을 만들 수 있는가?
  2. 거의 모든 중간 계층이 같은 throws를 전달만 하게 되는가?
  3. 이 실패가 외부 환경 문제인가, 호출자의 계약 위반인가?
  4. 공개 API가 구현체의 구체 예외에 종속되는가?
  5. 경계 처리기가 사용자 응답과 운영 로그를 만들 수 있는가?

첫 질문이 명확히 예이고 호출자별 복구가 다르면 체크 예외나 결과 타입을 검토합니다.

두 번째가 예라면 언체크 변환과 공통 경계 처리가 더 단순할 수 있습니다.

입력 계약 위반은 IllegalArgumentException 계열, 객체 상태 위반은 IllegalStateException 계열이 기본 후보입니다.

구체 도메인 의미가 필요할 때만 사용자 정의 타입을 추가합니다.


연습 문제

추천 저장소는 RecommendationUnavailableException을 던질 수 있습니다.

추천 실패는 빈 목록으로 복구하되 게시글 저장 실패는 상위로 계속 전달하도록 서비스 코드를 작성하세요.

해설 보기
src/SelectiveUncheckedRecoveryExercise.java
import java.util.List;

public final class SelectiveUncheckedRecoveryExercise {
    public static void main(String[] args) {
        BoardService service = new BoardService();
        System.out.println("recommendations=" + service.recommendations());
        try {
            service.save("exception");
        } catch (BoardWriteFailure error) {
            System.out.println("save-failed=" + error.getMessage());
        }
    }

    private static final class BoardService {
        List<String> recommendations() {
            try {
                return loadRecommendations();
            } catch (RecommendationUnavailableException error) {
                return List.of();
            }
        }

        void save(String title) {
            throw new BoardWriteFailure("store offline: " + title);
        }

        private List<String> loadRecommendations() {
            throw new RecommendationUnavailableException("model offline");
        }
    }

    private static final class RecommendationUnavailableException extends RuntimeException {
        RecommendationUnavailableException(String message) { super(message); }
    }

    private static final class BoardWriteFailure extends RuntimeException {
        BoardWriteFailure(String message) { super(message); }
    }
}
recommendations=[]
save-failed=store offline: exception

추천은 부가 기능이라 빈 값으로도 핵심 사용이 가능하지만 저장은 성공을 만들 수 없어 경계로 전달합니다.

체크 여부보다 복구 후 의미가 보존되는지가 더 중요한 판단입니다.