210131 Effective Java 10장 정리

2021-01-31

10. 예외

[item 69] 예외는 진짜 예외 상황에만 사용하라

예시) 예외 사용

try {
    int i = 0;
    while(true)
        range[i++].climb();
    } catch (ArrayIndexOutOfBoundsException e){
}
  • 전혀 직관적이지 않다
  • 배열의 원소를 순회한다
    • 무한루프를 돌다가 배열의 끝에 도달하여 ArrayIndexOutOfBoundsException 이 발생하면 끝나는 프로그램
      예외를 사용한 이유
  • 잘못된 추론을 근거로 성능을 높여보려 함
    • JVM은 배열에 접근할 때마다 경계를 넘는지 검사
    • 일반적인 반복문도 배열 경계에 도달하면 종료함
    • 이 검사를 반복문에도 명시한다면 같은 일이 중복되므로 하나를 생략함
      위의 추론이 잘못된 이유
  • 예외는 예외 상황에 쓸 용도로 설계되었으므로, JVM구현자 입장에서는 명확한 검사만큼 빠르게 만들어야 할 동기가 약하다
    • 최적화에 신경쓰지 않았을 가능성이 높다
  • 코드를 try-catch 블록 안에 넣으면 JVM이 적용할 수 있는 최적화가 제한된다
  • 배열을 순회하는 표준 관용구는 앞서 걱정한 중복 검사를 수행하지 않는다. JVM이 알아서 최적화해 없애준다.
  • 실제로 예외를 사용한 쪽이 표준 관용구보다 훨씬 느리다
    for (Mountain m : range)
      m.climb();
    
  • 표준 관용구대로 작성하여 이해가 잘 됨!

    예외는 오직 예외 상황에서만 사용하라

  • 일상적인 제어 흐름용으로 사용 금지
  • 표준적이고 쉽게 이해되는 관용구를 사용하라
  • API설계에도 적용된다
    • 특정 상태에서만 호출할 수 있는 상태 의존적 메서드를 제공하는 클래스는 ‘상태 검사’ 메서드도 함께 제공해야 한다
      • 예시) Iterator 인터페이스의 next와 hasNext

        상태 검사 메서드, 옵셔널, 특정 값 중 하나를 선택하는 지침

  • 외부 동기화 없이 여러 스레드가 동시에 접근할 수 있거나 외부 요인으로 상태가 변할 수 있다
    • 옵셔널, 특정 값
      • 상태 검사 메서드와 상태 의존적 메서드 호출 사이에 객체의 상태가 변할 수 있다
  • 성능이 중요한 산황에서 상태 검사 메서드가 상태 의존적 메서드의 작업 일부를 중복 수행
    • 옵셔널, 특정 값
  • 다른 모든 경우
    • 상태 검사 메서드
      • 가독성이 좋다
      • 잘못 사용시 발견하기가 더 쉽다
      • 호출을 잊었다면 상태 의존적 메서드가 예외를 던져 버그를 확실히 드러낸다

[item 70] 복구할 수 있는 상황에는 검사 예외를, 프로그래밍 오류에는 런타임 예외를 사용하라

Throwable

  • 문제 상황을 알리는 자바 타입
  • 검사 예외, 비검사 예외(런타임 예외, 에러)

    검사 예외

    호출하는 쪽에서 복구하리라 여겨지는 상황에서 사용
  • 검사/비검사 예외를 구분하는 기본 규칙
  • 검사 예외를 던지면 호출자가 그 예외를 catch로 잡아 처리하거나 더 바깥으로 전파하도록 강제
  • 메서드 선언에 포함된 검사 예외 각각
    • 그 메서드를 호출했을 때 발생할 수 있는 유력한 결과임을 API 사용자에게 알린다
  • API 설계자는 API 사용자에게 검사 예외를 던져주어 그 상황에서 회복을 요구한 것
    • 예외를 던지기만 하고 별 조치를 취하지 않을 수도 있음(좋지 않음)
  • 호출자가 예외 상황에서 벗어나는 데 필요한 정보를 알려주는 메서드를 함께 제공해야 한다

    비검사 예외

  • 프로그램에서 잡을 필요가 없거나, 통상적으로 잡지 않아도 됨
  • 복구가 불가능하거나 더 실행할때 득보다 실이 많음
  • 비검사 Throwable은 모두 RuntimeException의 하위 클래스여야 한다
    런타임 예외
  • 프로그래밍 오류를 나타냄
  • 대부분 전제조건을 만족하지 못했을 때 발생
    • 해당 API의 명세에 기록된 제약을 지키지 못했다
    • 예시)ArrayindexOutOfBoundsException : 배열의 인덱스가 0~(배열크기-1) 을 벗어남
  • 복구할 수 있는 상황인지 / 프로그래밍 오류인지 모호한 상황에서는 비검사 예외를 선택하는 편이 낫다
    에러
  • JVM이 자원 부족, 불변식 깨짐 등 더이상 수행을 계속할 수 없는 상황을 나타냄
  • Error는 상속하지 않아야 하며, throw문으로 직접 던지는 일도 없어야 한다(AssertionError는 예외)
    Exception, RuntimeException, Error를 상속하지 않는 throwable은 사용하지 말라
  • 정상적인 검사 예외보다 나은 점이 없으며, API 사용자를 헷갈리게 한다
  • throwable 클래스들은 대부분 오류 메시지 포맷을 상세히 기술하지 않는다
    • JVM이나 릴리스에 따라 포맷이 달라질 수 있다
    • 메시지 문자열을 파싱해 얻은 코드는 깨지기 쉽고, 다른 환경에서 동작하지 않을 수 있다.

[item 71] 필요 없는 검사 예외 사용은 피하라

검사 예외의 장단점

장점
  • 제대로 활용하면 API와 프로그램의 질을 높일 수 있다
    • 발생한 문제를 프로그래머가 처리하여 안전성을 높일 수 있다
  • 구체적인 예외 타입과 그 타입이 제공하는 메서드들을 활용해 부가 정보를 제공할 수 있다
    단점
  • 검사 예외를 과하게 사용하면 오히려 쓰기 불편한 API가 된다
    • 검사 예외를 던질 수 있는 메서드를 호출한다면, 이 메서드를 호출하는 코드에서는 catch 블록을 두어 그 예외를 붙잡아 처리하거나 더 바깥으로 던져 문제를 전파해야 한다
  • 검사 예외를 던지는 메서드는 스트림 안에서 직접 사용할 수 없다
  • 검사 예외가 단 하나뿐이라면…
    • catch문 하나만 추가
    • API사용자는 오직 이 예외때문에 try블록을 추가해야 하고, 스트림에서 직접 사용할 수 없게 된다

      검사 예외 회피

      ```java } catch(TheCheckedException e) { throw new AssertionError(); // 일어날 수 없음 }

} catch (TheCheckedException e) { e.printStackTrace(); System.exit(1); }

##### 적절한 결과 타입을 담은 옵셔널을 반환한다
- 단순히 빈 옵셔널을 반환한다
- 단점
    - 예외가 발생한 이유를 알려주는 부가 정보를 담을 수 없다
##### 검사 예외를 던지는 메서드를 2개로 쪼개 비검사 예외로 바꾼다
```java
/* 검사 예외를 던지는 메서드 - 리팩터링 전 */
try {
    obj.action(args);
} catch (TheCheckedException e) {
    ...  // 예외 상황에 대처
}

/* 상태 검사 메서드와 비검사 예외를 던지는 메서드 - 리팩터링 후 */
if (obj.actionPermitted(args)) {
    obj.action(args);
} else {
    ...    // 예외 상황에 대처
}
  • 프로그래머가 이 메서드가 성공하리라는 걸 안다거나, 실패 시 스레드를 중단하길 원한다면 다음 한 줄로 리팩터링하라
    • obj.action(args);
    • 외부 동기화 없이 여러 스레드가 동시에 접근할 수 있거나 외부 요인에 의해 상태가 변할 수 있다면 이렇게 리팩터링할 수 없다
      • actionPermitted와 action 호출 사이에 객체의 상태가 변할 수 있다
    • actionPermitted가 action 메서드의 작업 일부를 중복 수행한다면 성능 면에서 손해

[item 72] 표준 예외를 사용하라

표준 예외 재사용시 장점

  • 다른 사람들이 익히고 사용하기 쉬워진다
    • 많은 프로그래머에게 이미 익숙해진 규약을 그대로 따른다
  • 예외 클래스 수가 적을수록 메모리 사용량도 줄고, 클래스를 적재하는 시간도 적게 걸린다

    표준 예외들

    IllegalArgumentException
  • 호출자가 인수로 부적절한 값을 넘길 때 던지는 예외
  • 예시) 반복 횟수를 지정하는 매개변수에 음수를 건넬 때
  • null값을 허용하지 않는 메서드에 null을 건네는 경우는 NullPointerException을 던진다
  • 어떤 시퀀스의 허용 범위를 넘는 값을 넘긴다면 IndexOutOfBoundsException을 던진다
    IllegalStateException
  • 대상 객체의 상태가 호출된 메서드를 수행하기에 적합하지 않을 때
  • 예시) 제대로 초기화되지 않은 객체를 사용하려 할 때
  • ** 인수 값이 무엇이었든 어차피 실패했을 거라면 이 예외를 던진다 **
    ConcurrentModificationException
  • 단일 스레드/외부 동기화 방식에서 사용하려고 설계한 객체를 여러 스레드가 동시에 수정하려 할 때
    UnsupportedOperationException
  • 클라이언트가 요청한 동작을 대상 객체가 지원하지 않을 때
  • 예시) 원소를 넣을수만 있는 List 구현체에 누군가 remove 메서드를 호출하는 경우
    Exception, RuntimeException, Throwable, Error는 직접 재사용하지 말자
  • 이 클래스들은 추상 클래스라고 생각하자
  • 이 예외들은 다른 예외들의 상위 클래스이므로 안정적으로 테스트할 수 없다

[item 73] 추상화 수준에 맞는 예외를 던지라

예외 번역

try {
    ... // 저수준 추상화를 이용한다
} catch (LowerLevelException e) {
    // 추상화 수준에 맞게 번역한다
    throw new HigherLevelException(...);
}
  • 상위 계층에서 저수준 예외를 잡아, 자신의 추상화 수준에 맞는 예외로 바꿔 던진다
  • 무턱대고 예외를 전파하는 것보다는 낫지만, 남용하지는 않아야 한다
    • 가능하다면 저수준 메서드가 반드시 성공하도록 하여 아래 계층에서는 예외가 발생하지 않도록 해야 한다
    • 상위 계층 메서드의 매개변수 값을 아래 계층 메서드로 전달하기 전에 미리 검사한다
    • 아래 계층에서의 예외를 피할 수 없다면, 상위 계층에서 그 예외를 조용히 처리하여 문제를 API호출자에게까지 전파하지 않는다
      • 이 경우 발생한 예외는 java.util.logging과 같은 적절한 로깅 기능을 활용하여 기록하라

예외 연쇄

try {
    ... // 저수준 추상화를 이용한다
} catch (LowerLevelExeption cause) {
    // 저수준 예외를 고수준 예외에 실어 보낸다
    throw new HigherLevelException(cause);
}

/* 예외 연쇄용 생성자 */
class HigherLevelException extends Exception {
    HigherLevelException(Throwable cause) {
        super(cause);
    }
}
  • 문제의 근본 원인인 저수준 예외를 고수준 예외에 실어 보내는 방식
    • 별도의 접근자 메서드(Throwable의 getCause 메서드)를 통해 필요하면 언제든지 저수준 예외를 꺼내볼 수 있다
  • 예외를 번역할 때, 저수준 예외가 디버깅에 도움이 된다면 예외 연쇄를 사용하는 게 좋다

[item 74] 메서드가 던지는 모든 예외를 문서화하라

예외 문서화 원칙

검사 예외는 항상 따로따로 선언하고, 각 예외가 발생하는 상황을 자바독의 @throws 태그를 사용하여 정확히 문서화하자
  • 공통 상위 클래스 하나로 뭉뚱그려 선언하는 일은 삼가하라
    • 예시) 메서드가 Exception이나 Throwable을 던진다고 선언하는 일은 삼가해야 한다
    • 메서드 사용자에게 각 예외에 대처할 수 있는 힌트를 주지 못한다
    • 같은 맥락에서 발생할 여지가 있는 다른 예외들까지 삼켜버릴 수 있다
    • 예외: main 메서드
      • main은 오직 JVM만이 호출하므로 Exception을 던지도록 선언해도 좋다
        비검사 예외도 검사 예외처럼 정성껏 문서화해두면 좋다
  • 비검사 예외는 일반적으로 프로그래밍 오류를 뜻한다
  • 자신이 일으킬 수 있는 오류들이 무엇인지 알려주게 되면 프로그래머는 자연스럽게 해당 오류가 발생하지 않도록 코딩하게 된다
  • 잘 정비된 비검사 예외의 문서는 사실상 그 메서드를 성공적으로 수행하기 위한 전제조건
  • public 메서드라면 필요한 전제조건(비검사 예외들)을 문서화해야 한다
  • 인터페이스 메서드라면 발생 가능한 비검사 예외를 꼭 문서화한다
    • 이 조건이 인터페이스의 일반 규약에 속하게 되어 인터페이스를 구현한 모든 구현체가 일관되게 동작하도록 한다
      메서드가 던질 수 있는 예외를 각각 @throws 태그로 문서화하되, 비검사 예외는 메서드 선언의 throws 목록에 넣지 말자
  • 검사 예외만 메서드 선언의 throws문에 일일히 선언
    한 클래스에 정의된 많은 메서드가 같은 이유로 같은 예외를 던진다면 그 예외를 클래스 설명에 추가하는 방법도 있다
  • 예시) NullPointerException
    • 클래스의 문서화 주석에 “이 클래스의 모든 메서드는 인수로 null이 넘어오면 NullPointerException을 던진다” 라고 적는다

[item 75] 예외의 상세 메시지에 실패 관련 정보를 담으라

스택 추적(stack trace)

  • 예외를 잡지 못해 프로그램이 실패하면 자바 시스템은 그 예외의 스택 추적 정보를 자동으로 출력한다
    • 스택 추적은 예외 객체의 toString 메서드를 호출해 얻는 문자열
    • 보통은 예외 클래스 이름 뒤에 상세 메시지가 붙는 형태
    • 예외의 toString 메서드에 실패 원인에 관한 정보를 가능한 한 많이 담아 반환하라
    • 사후 분석을 위해 실패 순간의 상황을 정확히 포착해 예외의 상세 메시지에 담아야 한다
  • 실패 순간을 포착하려면 발생한 예외에 관여된 모든 매개변수와 필드의 값을 실패 메시지에 담아야 한다
    • 하지만 보안과 관련된 정보는 주의해서 다뤄야 한다
      • 상세 메시지에 비밀번호나 암호 키 같은 정보까지 담아서는 안 된다
    • 예시) IndexOutOfBoundsException의 상세 메시지
      • 범위의 최솟값과 최댓값, 그리고 그 범위를 벗어났다는 인덱스의 값을 담아야 함
  • 예외는 실패와 관련한 정보를 얻을 수 있는 접근자 메서드를 제공해야 한다
    • 비검사 예외보다는 검사 예외에서 빛을 발한다
    • 하지만 비검사 예외라도 상세 정보를 알려주는 접근자 메서드를 제공하여야 한다

[item 76] 가능한 한 실패 원자적으로 만들라

실패 원자적(failure-atomic)

  • 호출된 메서드가 실패하더라도 해당 객체는 메서드 호출 전 상태를 유지해야 한다
  • 실패 원자성은 일반적으로 권장되는 덕목이지만 항상 달성할 수 있는 것은 아니다
    • 예시) 두 스레드가 동기화 없이 같은 객체를 동시에 수정하다면 그 객체의 일관성이 깨질 수 있다
      • ConcurrentModificationException을 잡아냈다고 해서 그 객체가 여전히 사용 가능한 상태라고 가정해서는 안된다
    • Error는 복구할 수 없으므로 AssertionError에 대해서는 실패 원자적으로 만들려는 시도조차 할 필요가 없다
  • 메서드 명세에 기술한 예외라면 설혹 예외가 발생하더라도 객체의 상태는 메서드 호출 전과 똑같이 유지되어야 한다
    • 이 규칙을 지킬 수 없는 경우 실패시의 객체 상태를 API 설명에 명시해야 한다
메서드를 실패 원자적으로 만드는 법
  • 불변 객체로 설계
    • 태생적으로 실패 원자적
      • 불변 객체의 상태는 생성 시점에 고정되어 절대 변하지 않는다
    • 메서드가 실패하면 새로운 객체가 만들어지지는 않을 수 있으나 기존 객체가 불안정한 상태에 빠지는 일은 결코 없다
  • 가변 객체의 메서드인 경우
    • 작업 수행에 앞서 매개변수의 유효성을 검사
      • 객체의 내부 상태를 변경하기 전에 잠재적 예외의 가능성 대부분을 걸러낼 수 있음
    • 실패할 가능성이 있는 모든 코드를 객체의 상태를 바꾸는 코드보다 앞에 배치
      • 계산을 수행하기 전에는 인수의 유효성을 검사할 수 없는 경우
      • 예시) TreeMap
        • 원소를 추가할 때 그 원소는 TreeMap의 기준에 따라 비교할 수 있는 타입이어야 한다
        • 엉뚱한 타입의 원소를 추가하려 들면 트리를 변경하기 앞서 해당 원소가 들어갈 위치를 찾는 과정에서 ClassCastException
    • 객체의 임시 복사본에서 작업을 수행한 다음, 작업이 성공적으로 완료되면 원래 객체와 교체
      • 데이터를 임시 자료구조에 저장해 작업하는 게 더 빠를 때 적용하기 좋은 방식
      • 예시) 정렬 메서드에서 정렬을 수행하기 전 입력 리스트의 원소들을 배열로 옮겨담는 경우
        • 배열을 사용하면 정렬 알고리즘의 반복문에서 원소들에 훨씬 빠르게 접근할 수 있다
        • 성능 향상+정렬에 실패하더라도 입력 리스트는 변하지 않음
    • 작업 도중 발생하는 실패를 가로채는 복구 코드를 작성하여 작업 전으로 되돌린다
      • 디스크 기반 내구성(durability)을 보장해야 하는 자료구조에 쓰인다

[item 77] 예외를 무시하지 말라

/* catch 블록을 비워두면 예외가 무시된다 */
try {
    ...
} catch (SomeException e) {
}
  • API설계자가 메서드 선언에 예외를 명시? -> 그 메서드를 사용할 때 적절한 조치를 취해야 함
  • catch 블록을 비워둔다면 예외가 존재할 이유가 없다
    • 화재경보를 무시하는 수준을 넘어 아예 꺼버려, 다른 누구도 화재가 발생했음을 알 수 없게 하는것과 같다

예외를 무시해야 할 때

  • 예시: FileInputStream을 닫을 때
    • 입력 전용 스트림이므로 파일의 상태를 변경하지 않았으니 복구할 것이 없다
    • 스트림을 닫는 것은 필요한 정보는 이미 다 읽었다는 뜻이니 남은 작업을 중단할 이유도 없다
  • 예외를 무시하기로 했다면 catch블록 안에 그렇게 결정한 이유를 주석으로 남기고, 에외 변수의 이름도 ignored로 바꾼다
Future<Integer> f = exec.submit(planarMap::chromaticNumber);
int numColors = 4; // 기본값
try {
    numColors = f.get(1L, TimeUnit.SECONDS);
} catch (TimeoutException | ExecutionException ignored){
    // 기본값을 사용한다(색상 수를 최소화하면 좋지만, 필수는 아니다)
}