안동민 개발노트

본문 시작

비검사 예외와 복구 경계

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

이 단독 main 예제는 기본 미처리 예외 처리기가 타입·메시지·스택 추적을 표준 오류에 출력하고, main 스레드가 끝나면서 프로세스도 실패 코드로 종료됩니다. 일반적으로는 예외가 난 스레드가 종료되는 것이며, 다른 비데몬 스레드가 남은 프로그램 전체를 즉시 끝내는 것은 아닙니다.

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

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

잡히지 않은 예외 뒤 normal-end는 실행되지 않는다

UncaughtUncheckedBoardException은 repositoryCall에서 PostDataException을 던지고 load와 main에 catch가 없다. normal-end는 출력되지 않으며 해당 main 스레드가 미처리 예외로 끝난다.

UncaughtUncheckedBoardException에서 service.load("corrupt")가 호출된 뒤, 아래는 throw 이후 빠져나오는 순서입니다.

잡히지 않은 예외 뒤 normal-end는 실행되지 않는다
현재 위치발생·전달정상 흐름의 결과
repositoryCallPostDataException 발생
메시지: invalid record: corrupt
정상 반환하지 않습니다.
loadcatch가 없어 같은 예외가 호출자로 전달됩니다.서비스 호출이 정상 완료하지 않습니다.
maincatch가 없어 미처리 예외가 됩니다.normal-end 출력 문장에 도달하지 않습니다.
repositoryCall
발생·전달: PostDataException 발생
메시지: invalid record: corrupt
정상 흐름의 결과: 정상 반환하지 않습니다.
load
발생·전달: catch가 없어 같은 예외가 호출자로 전달됩니다.
정상 흐름의 결과: 서비스 호출이 정상 완료하지 않습니다.
main
발생·전달: catch가 없어 미처리 예외가 됩니다.
정상 흐름의 결과: normal-end 출력 문장에 도달하지 않습니다.

이 단독 main 예제의 기본 미처리 예외 처리에서는 타입·메시지·스택 추적이 표준 오류로 출력됩니다. 예외는 해당 스레드를 끝내는 것이며, 다른 비데몬 스레드가 있는 모든 프로그램의 즉시 종료를 뜻하지 않습니다.


비검사 예외의 문서화

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

서비스 예외의 메시지와 보존한 원인은 별개다

UncheckedExceptionTranslation은 StoreAccessException을 잡아 BoardSystemException으로 바꾸고 cause를 보존한다. main은 새 메시지와 원인의 타입을 각각 출력한다.

store.viewCount(title)의 실패를 서비스가 한 번 변환합니다. main이 잡는 예외와 그 cause는 서로 다른 객체입니다.

서비스 예외의 메시지와 보존한 원인은 별개다
비교 항목main이 잡은 서비스 예외cause로 보존한 저장소 예외
타입BoardSystemExceptionStoreAccessException
각 객체의 메시지board data unavailabledisk read failed: java
main의 출력
public=board data unavailable
root=StoreAccessException
타입
main이 잡은 서비스 예외: BoardSystemException
cause로 보존한 저장소 예외: StoreAccessException
각 객체의 메시지
main이 잡은 서비스 예외: board data unavailable
cause로 보존한 저장소 예외: disk read failed: java
main의 출력
main이 잡은 서비스 예외:
public=board data unavailable
cause로 보존한 저장소 예외:
root=StoreAccessException

서비스 예외 생성자가 super(message, cause)로 원인을 전달합니다. getMessage()는 새 문맥을, getCause()는 보존한 원인 객체를 줍니다. 원문은 원인의 전체 메시지나 스택을 출력한 예제가 아닙니다.


예외 전파의 명시적 결정

중간 서비스가 오류를 복구할 방법이 없다면 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

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

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

추천의 빈 결과와 저장 실패를 같은 성공으로 보지 않는다

SelectiveUncheckedRecoveryExercise는 추천 예외를 서비스에서 빈 목록으로 바꾼다. 저장 실패는 서비스에서 복구하지 않고 main이 잡아 실패를 출력한다.

연습의 두 호출은 서로 다른 책임을 가집니다. 추천은 대체값을 반환하고, 저장은 성공한 것처럼 값을 만들지 않습니다.

추천의 빈 결과와 저장 실패를 같은 성공으로 보지 않는다
호출서비스의 실제 처리main이 관찰하는 결과
recommendations()RecommendationUnavailableException을 잡아 List.of()를 반환합니다.
recommendations=[]
save("exception")BoardWriteFailure를 던집니다.
서비스에는 이를 복구하는 catch가 없습니다.
save-failed=store offline: exception
recommendations()
서비스의 실제 처리: RecommendationUnavailableException을 잡아 List.of()를 반환합니다.
main이 관찰하는 결과:
recommendations=[]
save("exception")
서비스의 실제 처리: BoardWriteFailure를 던집니다.
서비스에는 이를 복구하는 catch가 없습니다.
main이 관찰하는 결과:
save-failed=store offline: exception

main은 추천 결과를 먼저 출력한 뒤 저장을 시도합니다. 두 번째 줄은 저장 성공이 아니라 경계에서 잡은 실패입니다. 이 예제에는 실제 재시도나 저장 성공 경로가 실행되지 않습니다.