71. 필요 없는 검사 예외 사용은 피하라

  1. 검사 예외(Checked Exception)

    • 컴파일러가 확인하는 예외

  2. 비검사 예외(Unchecked Exception)

    • 컴파일러가 확인하지 않는 예외

검사 예외는 프로그램이 실행되기 전에 컴파일러가 확인하는 예외

이런 예외가 발생할 수 있는 코드를 작성할 때는 반드시 예외 처리를 해야 하고, 그렇지 않으면 프로그램이 컴파일되지 않는다

검사 예외의 문제점

검사 예외는 안전한 프로그램을 만드는 데 도움이 될 수 있지만, 과도하게 사용하면 문제가 생긴다

1. 코드가 복잡해진다

// 검사 예외를 사용하는 코드
try {
    readFile("데이터.txt");
    processData();
    saveResults();
} catch (IOException e) {
    // 예외 처리 코드
    System.out.println("파일 처리 중 오류 발생: " + e.getMessage());
}

여러 개의 검사 예외를 처리해야 한다면 코드는 더 복잡해진다

try {
    readFile("데이터.txt");      // IOException 발생 가능
    processData();              // DataException 발생 가능
    saveResults();              // DatabaseException 발생 가능
} catch (IOException e) {
    // 파일 관련 오류 처리
} catch (DataException e) {
    // 데이터 처리 오류 처리
} catch (DatabaseException e) {
    // 데이터베이스 오류 처리
}

2. Java 8 이후의 스트림과 함께 사용하기 어렵다

// 검사 예외를 던지는 메서드는 스트림에서 직접 사용할 수 없다
List<String> fileNames = Arrays.asList("파일1.txt", "파일2.txt", "파일3.txt");

// 컴파일 오류 발생! - 스트림에서 검사 예외를 처리할 수 없음
fileNames.stream()
         .map(this::readFile)  // readFile이 IOException을 던진다면
         .forEach(System.out::println);

검사 예외를 피하는 방법

방법 1: Optional 사용하기

검사 예외 대신 Optional을 반환하면 예외 처리 없이 코드를 작성할 수 있다

// 검사 예외를 사용하는 코드
public User findUser(Long id) throws UserNotFoundException {
    if (!userExists(id)) {
        throw new UserNotFoundException("사용자를 찾을 수 없습니다: " + id);
    }
    return getUserById(id);
}

// 사용 예시
try {
    User user = findUser(1L);
    System.out.println("사용자 찾음: " + user.getName());
} catch (UserNotFoundException e) {
    System.out.println(e.getMessage());
    createNewUser(1L);
}

Optional을 사용하면 다음과 같이 바꿀 수 있다

// Optional을 사용하는 코드
public Optional<User> findUser(Long id) {
    if (!userExists(id)) {
        return Optional.empty();
    }
    return Optional.of(getUserById(id));
}

// 사용 예시
User user = findUser(1L)
            .orElseGet(() -> createNewUser(1L));
System.out.println("사용자: " + user.getName());

방법 2: 메서드 분할하기

검사 예외를 던지는 메서드를 두 개로 나누는 방법도 있다

  1. 첫 번째 메서드: 작업이 가능한지 boolean으로 확인

  2. 두 번째 메서드: 실제 작업 수행

// 검사 예외를 사용하는 코드
public void transferMoney(Account from, Account to, double amount)
                         throws InsufficientFundsException {
    if (from.getBalance() < amount) {
        throw new InsufficientFundsException("잔액 부족");
    }
    from.withdraw(amount);
    to.deposit(amount);
}

// 사용 예시
try {
    transferMoney(account1, account2, 1000);
} catch (InsufficientFundsException e) {
    System.out.println(e.getMessage());
    // 잔액 부족 처리
}

메서드를 분할하면 다음과 같이 바꿀 수 있다

// 메서드를 분할한 코드
public boolean canTransferMoney(Account from, double amount) {
    return from.getBalance() >= amount;
}

public void transferMoney(Account from, Account to, double amount) {
    // 호출 전에 canTransferMoney로 확인했다고 가정
    from.withdraw(amount);
    to.deposit(amount);
}

// 사용 예시
if (canTransferMoney(account1, 1000)) {
    transferMoney(account1, account2, 1000);
} else {
    System.out.println("잔액 부족");
    // 잔액 부족 처리
}

검사 예외와 비검사 예외 비교

검사 예외 (Checked)
비검사 예외 (Unchecked)

컴파일러가 확인함

컴파일러가 확인하지 않음

반드시 처리해야 함

처리는 선택사항

API 호출자에게 부담

더 유연한 코드 작성 가능

예: IOException

예: NullPointerException

스트림에서 사용 어려움

스트림에서 사용 가능

어떤 예외를 사용해야 할까?

  1. 호출자가 예외 상황에서 복구할 수 있을까?

    • YES: 옵셔널을 고려해볼 수 있다

    • NO: 비검사 예외(RuntimeException)를 사용한다

  2. 옵셔널로 충분한 정보를 제공할 수 있을까?

    • YES: 옵셔널을 사용한다

    • NO: 검사 예외를 고려한다

예시로 배우는 예외 선택

은행 송금 시스템 예시

// 1. 검사 예외 사용
public void transfer(Account from, Account to, double amount)
                       throws InsufficientFundsException {
    if (from.getBalance() < amount) {
        throw new InsufficientFundsException("잔액 부족: " + (amount - from.getBalance()));
    }
    from.withdraw(amount);
    to.deposit(amount);
}

// 2. 옵셔널 사용
public Optional<TransferResult> transfer(Account from, Account to, double amount) {
    if (from.getBalance() < amount) {
        return Optional.empty();
    }
    from.withdraw(amount);
    to.deposit(amount);
    return Optional.of(new TransferResult(true, "이체 성공"));
}

// 3. 비검사 예외 사용
public void transfer(Account from, Account to, double amount) {
    if (from.getBalance() < amount) {
        throw new InsufficientFundsRuntimeException("잔액 부족");
    }
    from.withdraw(amount);
    to.deposit(amount);
}

// 4. 메서드 분할 사용
public boolean canTransfer(Account from, double amount) {
    return from.getBalance() >= amount;
}

public void transfer(Account from, Account to, double amount) {
    from.withdraw(amount);
    to.deposit(amount);
}


🧩 어려웠던 점

처음에는 검사 예외와 비검사 예외의 차이점과 각각의 장단점을 이해하는 데 어려움이 있었다

메서드를 분할하는 방식이 항상 더 좋은 대안인지도 의문이었다

💡 느낀 점

예외 처리는 단순히 오류를 잡아내는 것이 아니라 API 설계의 중요한 부분임을 깨달았다

호출자가 실제로 복구할 수 있는 상황인지, 그리고 그 복구에 필요한 정보를 충분히 제공하고 있는지를 고민하는 것이 좋은 API 설계의 핵심임을 배웠다

Last updated