독서이야기/엘레강트 오브젝트 - 새로운 관점에서 바라본 객체지향

[엘레강트 오브젝트] 4.2 체크 예외(checked exception)만 던지세요

사랑꾼이야 2023. 4. 9. 23:03
반응형

이 내용은 엘레강트 오브젝트 를 읽으면서 정리한 내용을 포함하고 있습니다.

  • 체크 예외
  • 언체크 예외
  • 꼭 필요한 경우가 아니라면 예외를 잡지 마세요
  • 항상 예외를 체이닝하세요
  • 단 한번만 복구하세요
  • 관점-지향 프로그래밍을 사용하세요
  • 하나의 예외 타입만으로도 충분합니다
  • 정리

저자 주장
언체크 예외를 사용하는 것은 실수이며, 모든 예외는 체크 예외여야 합니다. 또한 다양한 예외 타입을 만드는 것도 좋지 않은 생각입니다.

체크 예외

메서드의 시그니처의 throws IOException 을 사용한 예시는 다음과 같다.

public byte[] content(File file) throws IOException {
    byte[] byte = new byte[1000];
    new FileInputStream(file).read(array);
    return array;
}
  • 이것은 무슨 일이 있어도 content()를 호출하는 쪽에서 IOException 예외를 잡아야 한다는 것을 의미한다.

메서드는 시그니처에 throws IOException 이라고 선언함으로써 문제를 처리할 책임을 호출하는 쪽으로 넘긴다.

public byte[] content(File file) {
    try {
        return content(file).length();
    } catch (IOException ex) {
        // 이 예외에 대해 어떤 처리를 해야 하며 바로 여기에서 예외를 해결하거나 더 상위 레벨로 전달해야 한다.
    }
}
  • 파일에 문제가 발생했을 때 어떻게 처리할 지에 대한 결정을 호출하는 쪽에서 담당하게 하는 것이다.

다음과 같이 사용할 수 있다.

public int length(File file) throws IOException {
    return content(file).length();
}
  • 문제를 더 높은 상위 레벨로 확대시킨다.
  • 상위 레벨(여기에서는 실제 호출하는 곳)에서는 이 문제를 처리하기 위해서 무언가를 해야 한다.

정리

  • IOException은 catch 구문을 이용해서 반드시 잡아야 하기 때문에 체크(checked) 예외에 속한다.
  • 예외를 잡거나 상위로 전파하기 위해 throws IOException을 메서드 시그니처에 선언해야 한다.
  • 안전하지 않은 메서드를 다룰때에는 이 메서드를 사용하기 위해서 다음 중 하나를 해야 한다.
    • 안전하지 않다고 선언
    • 예외를 잡아서 해결

언체크 예외

무시할 수 있으며 예외를 잡지 않아도 무방합니다. 언체크 예외를 던지면, 누군가 예외를 잡기 전까지는 자동으로 상위로 전파된다.

  • 예를 들어, 파일이 존재하지 않을 경우 던지는 IllegalArgumentException 은 언체크 예외에 속한다.
public int length(File file) throws IOException {
    if (!file.exists()) {
        throw new IllegalArgumentException(
            "File doesn't exist; I can't count its length."
        );
    }
    return content(file).length();
}
  • 언체크 예외의 경우 예외의 타입을 선언하지 않아도 무방한 반면에 체크 예외는 항상 예외의 타입을 공개해야 한다.

꼭 필요한 경우가 아니라면 예외를 잡지 마세요

메서드를 설계할 때 모든 예외를 잡아서 메서드를 안전하게 만들지, 아니면 상위로 문제를 전파할지를 명확하게 선택해야 한다.

꼭 필요한 경우를 제외하고는 예외는 가급적 잡지 않는다.

흐름 제어를 위한 예외 사용

public int length(File file) {
    try {
        return content(file).length();
    } catch (IOException ex) {
        return 0;
    }
}
  • length() 메서드는 더할 나위 없이 안전하다.
  • 파일 시스템에 어떤 일이 발생하더라도 메서드는 종료되지 않는다.
  • 이 코드는 안전하게 실패하기 방법의 전형적인 예라고 할 수 있다.
  • 에러 상황을 무시한 채 0을 반환해 버립니다.

동일하게 수행하는 다른 코드는 다음과 같다.

public int length(File file) {
    if (/* 파일 시스템에 문제가 있으면 */) {
        return 0;
    } else {
        return content(file).length();
    }
}
  • 분기를 처리하기 위해 if 문을 사용하는 것은 적절하지만, 예외를 분기를 위한 도구가 아니다.
  • 예외를 분기를 처리할 목적으로 설계되지 않았다.
    • 오퍼레이션의 정상적인 흐름을 종료시키고 추가적인 조치를 필요로 하는 심각하고 복구 불가능한 상황을 나타내기 위해 설계되었다.

정리

  • 예외를 잡아 상황을 구조하는 일은 매우 정당한 이유가 있을 경우에만 용인되는 매우 중요한 행동이다.

항상 예외를 체이닝하세요

public int length(File file) throws Exception {
    try {
        return content(file).length();
    } catch (IOException ex) {
        throw new Exception("길이를 계산할 수 없다.", ex);
    }
}
  • 예외 체이닝은 원래의 문제를 새로운 문제로 대체함으로써 문제가 발생했다는 사실을 무시하지 않는다.
  • 대신 원래의 문제를 새로운 문제로 감싸서 함께 상위로 던진다.

핵심은 문제를 발생시켰던 낮은 수준의 근본 원인을 소프트웨어의 더 높은 수준으로 이동시킨다는 것이다.

아래는 잘못된 방식의 예이다.

public int length(File file) throws Exception {
    try {
        return content(file).length();
    } catch (IOException ex) {
        // 여기에서는 문제 'ex'를 무시하고, 새로운 메시지를 가지는 새로운 타입의 새로운 문제를 생성한다.
        throw new Exception("계산할 수 없다.");
    }
}
  • 문제를 발생시킨 근본 원인에 관한 매우 가치있는 정보가 손실되기 때문에 매우 방법이다.

항상 예외를 체이닝하고 절대로 원래 예외를 무시하면 안된다. 예외 체이닝은 의미론적으로 문제와 관련된 문맥을 풍부하게 만들기 위해 필요하다.

  • 첫 번째 예외는 열린 파일이 너무 많다고 이야기하고
  • 두 번째 예외는 파일의 길이를 계산할 수 없다고 이야기하고
  • 마지막 예외는 이미지 내용을 읽을 수 없다고 이야기하도록 예외를 체이닝하는 편이 더 좋다.

정리

  • 항상 예외를 체이닝하고 절대로 원래 예외를 무시하지 마세요.

단 한번만 복구하세요

예외 후 복구는 흐름 제어를 위한 예외 사용으로 알려진 안티패턴의 또 다른 이름이다.

아래는 예외 후 복구 방식을 적용한 안티패턴의 하나의 예이다.

int age;
try {
    age = Integer.parseInt(text);
} catch (NumberFormatException ex) {
    // 여기에서 발생한 예외를 `복구`한다
    age = -1;
}

무조건 예외를 잡아서는 안되다는 주장은 전적으로 옮지 않으며 딱 한번은 복구해야 한다.

  • 모든 메서드가 예외를 던진 후 해당 예외를 잡아서는 안된다.
  • 모든 예외를 애플리케이션의 가장 높은 곳까지 전파될 것이다.

다음은 터미널을 통해 실행되는 커맨트 라인 도구의 진입점 예시이다.

public class App {
    public static void main(String... args) {
        try {
            System.out.println(new App().run());
        } catch (Exception ex) {
            System.err.println("죄송하지만 문제가 발생했습니다." + ex.getLocalizedMessage());
        }
    }
}
  • 코드에서 알 수 있는 것처럼 catch 문 내부에서는 어떤 것도 다시 던지지 않는다.
  • 그 자리에서 즉시 문제를 해결하고 있다.
  • 여기에서 해결방법이란 사용자에게 문제를 보여주는 것이 전부이다.

main에서 예외를 잡지 않는다면 런타임 환경으로 예외가 전달되고 결국 Java 가상 머신이 예외를 잡게 된다. 이 경우 사용자에게는 사용자 친화적인 메시지가 전달되지 않는다.

정리

  • 어떤 소프트웨어에서든 복구에 적합한 몇 개의 장소를 제외하고는 예외를 잡아서 다시 던지거나, 또는 절대로 예외를 잡지 말아야 한다.

관점-지향 프로그래밍을 사용하세요

HTTP 요청을 전송해서 웹 페이지를 다운로드하는 경우를 가정하겠다.

  • 가끔씩 네트워크 연결이 실패할 가능성이 있다.
  • 이럴 때마다 사용자에게 오류 메시지를 표시하고 애플리케이션을 재실행하라고 요청해야 한다면 그것은 안타까운 일이다.
public String content() throws IOException {
    int attempt = 0;
    while (true) {
        try {
            return http();
        } catch (IOException ex) {
            if (attempt >= 2) {
                throw ex;
            }
        }
    }
}

관점-지향 프로그래밍을 통해 해결할 수 있는 방법

  • 단 한번의 메서드 호출을 재시도하기 위해 10줄의 코드를 작성해야 한다는 사실을 알 수 있다.

이 코드는 매우 장황하면서도 원시적이다. 제대로 구현하려면 코드의 길이는 더 길어질 것이다.

@RetryOnFilure(attempts = 3) 
public String content() throws IOException {
    return http();
}
  • 실패 재시도 코드 블록을 관점이라고 부른다.
  • 기술적으로 관점이란 제어를 위임받아 content() 를 언제, 어떻게 호출할지 결정하는 객체를 의미한다.

정리

  • AOP의 장점을 통해 핵심 클래스로부터 덜 중요한 기술과 매커니즘을 분리해서 코드 중복을 제거할 수 있다.

하나의 예외 타입만으로도 충분합니다

  • 예외를 다시 던질 것이기 때문에 잡은 예외의 실제 타입에 대해서는 신경 쓸 필요가 없다.
  • 예외를 사용할 일이 없기 때문에 예외의 타입 정보는 필요하지 않는다.
  • 예외가 상위로 전파되는 도중에는 예외를 잡을 일이 없다.
  • 예외를 잡을 때 조차도, 오직 한 가지 목적을 위해서만 잡아야 한다.

정리

  • IOException 은 catch 구문을 이용해서 반드시 잡아야 하기 때문에 체크(checked) 예외에 속한다.
  • 예외를 잡거나 상위로 전파하기 위해 throws IOException 을 메서드 시그니처에 선언해야 한다.
  • 반드시 예외를 잡아야 하는 이유가 있거나 다른 선택의 여지가 없는 경우가 아니라면 예외를 잡아서는 안된다.
  • 어떤 소프트웨어에서든 복구에 적합한 몇 개의 장소를 제외하고는 예외를 잡아서 다시 던지거나, 또는 절대로 예외를 잡지 말아야 한다.
  • AOP의 장점을 통해 핵심 클래스로부터 덜 중요한 기술과 매커니즘을 분리해서 코드 중복을 제거할 수 있다.
  • 하나의 예외 타입만으로 충분한 이유는 예외를 상위로 다시 던질것이기 때문에 타입 정보는 필요하지 않다.
반응형