본문 바로가기
스터디/이펙티브 자바

[이펙티브 자바] 09. 예외

by 디토20 2022. 10. 5.
반응형

 

 

 

 

 

[이펙티브 자바] 09. 예외

 

아이템 69. 예외는 진짜 예외 상황에만 사용하라

예외를 완전히 잘못 사용한 예 - 따라하지 말 것!

try {
      int i = 0;
      while(true) {
        range[i++].climb();
      }
    } catch (ArrayIndexOutOfBoundsException e) {
}

 

  • 직관적이지 않다.
  • 예외를 써서 루프를 종료하는 이상한 방식으로 구현

 

 

다음과 같이 표준 관용구대로 작성했다면 누구나 쉽게 이해했을 것이다.

for(Mountain m : range)
  m.climb();

 

예외를 써서 루프를 종료한 이유는 뭘까?

  • 잘못된 추론을 근거로 성능을 높여보려 한 것
    • JVM은 배열에 접근할 때마다 경계를 넘지 않는지 검사
    • 일반적인 반복문도 배열 경계에 도달하면 종료(경계를 넘지 않는지 검사)

-> 따라서 이 검사를 반복문에도 명시하면 같은 일이 중복 되리라 판단하여 하나를 생략한 것이다.

 

 

잘못된 추론인 이유

  1. 예외는 예외 상황에 쓸 용도로 설계되었으므로 명확한 검사만큼 빠르게 만들어야 할 동기가 약하다.(즉, 예외처리는 최적화에 신경 쓰지 않았을 가능성이 크다.)
  2. 코드를 try-catch 블록 안에 넣으면 JVM이 적용할 수 있는 최적화가 제한된다.
  3. 배열을 순회하는 표준 관용구는 앞서 걱정한 중복 검사를 수행하지 않는다. JVM이 알아서 최적화해 없애준다.

추론과는 다르게 예외를 사용한 쪽이 표준 관용구보다 훨씬 느리다.

 

 

잘못된 예외 사용으로 인한 문제점

  • 코드를 헷갈리게 하고 디버깅이 어렵다.
  • 오히려 성능을 떨어뜨릴 가능성이 크다.
  • 코드 자체가 제대로 동작하지 않을 수 있다.
  • 예를 들어 위의 잘못된 코드에서 내부와 관련없는 배열을 사용하다가 ArrayIndexOutOfBoundsException을 일으켰을 경우, 정상적인 반복문 종료 상황으로 오해하고 넘어갈 것이다.

 

교훈

1. 예외는 오직 예외 상황에서만 써야 한다. 절대로 일상적인 제어 흐름용으로 쓰여선 안 된다.

  • 섣부른 최적화를 하지말아야 한다. 당장 성능이 더 좋을 수 있어도 자바가 계속 업그레이드 되며 성능 우위는 영원하지 않을 것이다.
  • 과하게 영리한 기법에 숨겨진 미묘한 버그로 인해 유지보수 문제는 계속 이어질 것이다.

2. 잘 설계된 API라면 클라이언트가 정상적인 제어 흐름에서 예외를 사용할 일이 없게 해야 한다.

  • 특정 상태에서만 호출할 수 있는 '상태 의존적 메서드'를 제공하는 클래스는 '상태 검사 메서드'도 함께 제공해야 한다.
  • Ex. Iterator 인터페이스의 next : '상태 의존적 메서드', hasNext : '상태 검사 메서드'
  • 만약 hasNext가 제공되지 않는다면, 위와 같이 예외를 통해 종료시켜야 할 것이다.
  • '상태 검사 메서드' 외의 선택지도 있다. '빈 옵셔널' 혹은 'null과 같은 특정 값'을 통해 반환하는 방법이다.

 

'상태 검사 메서드', '옵셔널', '특정 값'의 선택 기준

1. '옵셔널'이나 '특정 값' 선택

  • '상태 검사 메서드'와 '상태 의존적 메서드' 호출 사이에 객체의 상태가 변할 수 있는 경우 - 여러 스레드가 동시 접근, 외부 요인으로 상태 변할 수 있는 경우
  • 성능이 중요한 상황에서 '상태 검사 메서드'가 '상태 의존적 메서드'의 작업 일부를 중복 수행

2. 그 외 모든 경우에는 '상태 검사 메서드' 방식 사용

  • 가독성이 좋고, 오류를 발견하기 쉽다.

 

 

 

 

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

자바는 문제 상황을 알리는 타입(throwable)으로 검사 예외, 런타임 예외, 에러 이렇게 세가지를 제공한다.

 

검사 예외 - 호출하는 쪽에서 복구하리라 여겨지는 상황일 때

  • 호출자가 예외를 catch로 잡아 처리하거나 더 바깥으로 전파하도록 강제한다.
  • 호출자가 예외 상황에서 벗어나는데 필요한 정보를 알려주는 메서드를 함께 제공해야 한다.
  • 메서드 선언에 포함된 검사 예외는 그 메서드를 호출했을 때 발생할 수 있는 유력한 결과임을 API 사용자에게 알려준다.

 

 

비검사 예외 - 런타임 예외와 에러, 프로그램에서 잡을 필요가 없거나 혹은 잡지 말아야 한다.

  • 프로그램에서 비검사 예외나 에러를 던졌다는 것은 복구가 불가능하거나 더 실행해봐야 득보다는 실이 많다는 뜻
  • 이런 throwable을 잡지 않은 스레드는 적절한 오류 메시지를 내뱉으며 중단한다.
  • 프로그래밍 오류를 나타낼 때는 런타임 예외를 사용하자.
  • 런타임 예외의 대부분은 전제조건을 만족하지 못했을 때 발생한다.
  • 에러는 보통 JVM이 자원 부족, 불변식 깨짐 등 더이상 수행을 계속 할 수 없는 상황을 나타낼 때 사용한다.
  • 우리가 구현하는 비검사 throwable은 모두 RuntimeException의 하위 클래스여야 한다. Error는 상속하면 안될 뿐만 아니라 throw 문으로 직접 던지는 일도 없어야 한다.

 

 

 

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

  • 검사 예외는 발생한 문제를 프로그래머가 처리하여 안전성을 높일 수 있다.
  • 그러나 API 사용자에게 처리에 대한 부담을 주고, 더구나 검사 예외를 던지는 메서드는 스트림 안에서 직접 사용할 수 없다.
  • API를 제대로 사용해도 발생할 수 있는 예외이거나, 프로그래머가 의미 있는 조취를 취할 수 있는 경우에만 검사 예외를 사용하자
  • 그 외에는 비검사 예외를 사용하자.
  • 검사 예외가 메서드에 하나만 있다면 검사 예외를 던지지 않는 방법이 있는지 고민해보자.

 

검사 예외를 회피하는 가장 쉬운 방법

1. 적절한 결과 타입을 담은 옵셔널을 반환하는 것이다.

  • 검사 예외를 던지는 대신 단순히 빈 옵셔널을 반환하면 된다.
  • 이 방식의 단점은 예외가 발생한 이유를 알려주는 부가 정보를 담을 수 없다.
  • 예외를 사용하면 구체적인 예외 타입과 그 타입이 제공하는 메서드들을 활용해 부가 정보를 제공할 수 있다.

 

2. 검사 예외를 던지는 메서드를 2개로 쪼개 비검사 예외로 바꿀 수 있다.

 

검사 예외를 던지는 메서드 - 리팩터링 전

try {
   obj.action(args);
} catch(TheCheckedException e) {
   ... // 예외 상황에 대처한다.
}

 

 

상태 검사 메서드와 비검사 예외를 던지는 메서드 - 리팩터링 후

if(obj.actionPermitted(args)){
    obj.action(args);
} else {
    ... // 예외 상황에 대처한다.
}

 

  • 리팩터링 후의 API가 딱히 더 아름답지는 않지만 더 유연한것은 확실하다.

 

 

 

 

아이템 72. 표준 예외를 사용하라

  • 다른 코드와 마찬가지로 예외도 재사용하는 것이 좋으며, 자바 라이브러리는 대부분 API에서 쓰기 충분한 수의 예외를 제공한다.
  • 표준 예외를 재사용 하면 API를 다른 사람이 익히고 사용하기 쉬워진다는 것이다.
  • 예외 클래스가 적을수록 메모리 사용량도 줄고 클래스를 적재하는 시간도 적게 걸린다.
  • Exception, RuntimeException, Throwable, Error는 직접 재사용하지 말자. 이 클래스들을 추상 클래스라고 생각하길 바란다.

 

널리 재사용되는 예외들

예외 주요 쓰임
IllegalArgumentException 허용하지 않는 값이 인수로 건네졌을 때 (null은 따로 NullPointerException으로 처리)
IllegalStateException 객체가 메서드를 수행하기에 적절하지 않은 상태일 때
NullPointerException null을 허용하지 않는 메서드에 null을 건넸을 때
IndexOutOfBoundsException 인덱스가 범위를 넘어섰을 때
ConcurrentModificationException 허용하지 않는 동시 수정이 발견됐을 때
UnsupportedOperationException 호출한 메서드를 지원하지 않을 때

 

  • 상황에 부합한다면 항상 표준 예외를 재사용하자.
  • API 문서를 참고해 예외가 어떤 상황에서 던져지는지 꼭 확인하고 이름과 맥락이 부합할 때만 재사용한다.
  • 예외는 직렬화할 수 있어야 하기 때문에 표준 예외 확장시 충분한 고민을 해보자.

 

 

 

아이템 73. 추상화 수준에 맞는 예외를 던지라

  • 메서드가 저수준 예외를 처리하지 않고 바깥으로 전파해버려 수행하려는 일과 관련 없어 보이는 예외가 나오면 당황스럽다.
  • 이는 내부 구현 방식을 드러내어 윗 레벨 API를 오염시킨다.
  • 다음 릴리스에서 구현 방식을 바꾸면 다른 예외가 튀어나와 기존 클라이언트 프로그램을 깨지게 할 수도 있다.
  • 상위 계층에서는 저수준 예외를 잡아 자신의 추상화 수준에 맞는 예외로 바꿔 던져야 한다 - 예외 번역

 

예외 번역

try {
    ... // 저수준 추상화를 이용한다
} catch(LowerLevelException e){
    // 추상화 수준에 맞게 번역한다.
    throw new HigherLevelException(...);
}

 

  • 예외를 번역할 때, 저수준 예외가 디버깅에 도움이 된다면 예외 연쇄를 사용하는 게 좋다.
  • 예외 연쇄란 문제의 근본 원인인 저수준 예외를 고수준 예외에 실어 보내는 방식이다.
  • 그러면 별도의 접근자 메서드(Throwable의 getCause 메서드)를 통해 칠요하면 언제든 저수준 예외를 꺼내 볼 수 있다.

예외 연쇄

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

 

예외 연쇄용 생성자

class HigherLevelException extends Exception {
    HigherLevelException(Throwable cause) {
        super(cause);
    }
}

 

  • 대부분의 표준 예외는 예외 연쇄용 생성자를 갖추고 있고, 그렇지 않더라도 Throwable의 initCause 메서드를 이용해 '원인'을 직접 못박을 수 있다.
  • 무턱대고 예외를 전파하는 것보단 예외 번역이 우수하지만, 그렇다고 남용해서는 안된다.
  • 가능하면 저수준 메서드가 반드시 성공하도록 하자.
  • 매개변수를 미리 검사하거나, 상위계층에서 적절한 로깅을 활용해 예외를 조용히 처리할 수도 있다.

 

 

 

아이템 74. 메서드가 던지는 모든 예외를 문서화하라

  • 검사 예외는 항상 따로따로 선언하고, 각 예외가 발생하는 상황을 자바독의 @throws 태그를 사용하여 정확히 문서화하자.
  • 공통 상위 클래스 하나로 뭉뚱그려 선언하는 일은 삼가자.
  • 비검사 예외도 검사 예외처럼 정성껏 문서화해두면 좋다.
  • public 메서드라면 필요한 전제조건을 문서화해야 하며, 그 수단으로 가장 좋은 것이 바로 비검사 예외들을 문서화 하는 것이다.
  • 메서드가 던질 수 있는 예외를 각 @throws 태그로 문서화하되, 비검사 예외는 메서드 선언의 throws 목록에 넣지 말자.
  • 검사냐 비검사냐에 따라 API 사용자가 해야할 일이 달라지므로 이 둘을 확실히 구분해주자.
  • 한 클래스에 정의된 많은 메서드가 같은 이유로 같은 예외를 던진다면 그 예외를 클래스 설명에 추가하자 - ex) NullPointerException

 

 

아이템 75. 예외의 상세 메시지에 실패 관련 정보를 담으라

  • 예외를 잡지 못해 프로그램이 실패하면 자바 시스템은 그 예외의 스택 추적 정보를 자동으로 출력한다.
  • 스택 추적은 예외 객체의 toString 메서드를 호출해 얻는 문자열로, 보통은 예외의 클래스 이름 뒤에 상세 메세지가 붙는 형태다.
  • 사후 분석을 위해 실패 순간의 상황을 정확히 포착해 예외의 상세 메시지에 담아야 한다.
  • 예외의 상세 메시지와 최종 사용자에게 보여줄 오류 메시지를 혼동하지는 말자.

 

실패 순간을 포착하는 방법

  • 발생한 예외에 관여된 모든 매개변수와 필드의 값을 실패 메시지에 담아야 한다.
  • 예를 들어 IndexOutOfBoundsException의 상세 메시지는 범위의 최솟값과 최댓값, 그리고 그 범위를 벗어났다는 인덱스의 값을 담아야한다.
  • 필요한 예외 정보를 예외 생성자에서 모두 받아서 상세 메시지까지 미리 생성해놓는 방법도 괜찮다.
/**
 * IndexOutOfBoundsException을 생성한다.
 *
 * @param lowerBound 인덱스의 최솟값
 * @param upperBound 인덱스의 최댓값 + 1
 * @param index 인덱스의 실젯값
 */
public IndexOutOfBoundsException(int lowerBound, int upperBound, int index) {
   // 실패를 포착하는 상세 메시지를 생성한다.
   super(String.format("최솟값: %d, 최댓값: %d, 인덱스: %d", lowerBound, upperBound, index));
   
   // 프로그램에서 이용할 수 있도록 실패 정보를 저장해둔다.
   this.lowerBound = lowerBound;
   this.upperBoudn = upperBound;
   this.index = index;
}

 

 

 

 

아이템 76. 가능한 한 실패 원자적으로 만들라

  • 호출된 메서드가 실패하더라도 해당 객체는 메서드 호출 전 상태를 유지해야 한다 - 실패 원자적

 

메서드를 실패 원자적으로 만드는 방법

1. 불변 객체로 설계한다.

  • 불변 객체는 태생적으로 실패 원자적이다.

 

2. 가변 객체는 작업 수행에 앞서 매개변수의 유효성을 검사하자.

  • 객체 내부 상태를 변경하기 전에 잠재적 예외의 가능성 대부분을 걸러낼 수 있는 방법이다.
  • 실패 가능성이 있는 모든 코드를, 객체 상태 변경 코드 앞에 배치하자.
public Object pop() {
   if (size == 0)
      throw new EmptyStackException();
   Object result = elements[--size];
   elements[size] = null; // 다 쓴 참조 해제
   return result;
}

 

3. 객체의 임시 복사본에서 작업을 수행한 후, 작업이 성공하면 원래의 객체와 교체하자.

  • List를 정렬하는 메서드에서 정렬을 수행하기 전에 배열로 옮겨담아 정렬하자.

 

4. 작업 도중 발생하는 실패를 가로채는 복구 코드를 작성하여 작업 전 상태로 되돌리자.

  • 자주 쓰이는 방법은 아니다

 

 

 

아이템 77. 예외를 무시하지 말라

  • API 설계자가 메서드 선언에 예외를 명시하는 까닭은, 그 메서드를 사용할 때 적절한 조취를 취해달라고 말하는 것이다.
// catch 블록을 비워두면 예외가 무시된다. 아주 의심스러운 코드다!.
try {
    ...
} catch(SomeException e){
}

 

  • catch 블록을 비워두면 예외가 존재할 이유가 없어진다.
  • 화재 경보를 꺼버려, 다른 누구도 화재가 발생했음을 알지 못하게 하는 것과 같다.

 

예외를 무시해도 되는 경우

  • FileInputStream과 같은 경우는 예외를 무시해도 좋다. 입력 전용 스트림이므로 파일을 상태를 변경하지 않았으니 복구할 것이 없으며, 스트림을 닫는 다는 것은 필요한 정보는 이미 다 읽었다는 뜻이니 남은 작업을 중단할 이유도 없다.
  • 예외를 무시하기로 했다면 catch 블록 안에 그렇게 결정한 이유를 주석으로 남기고 예외 변수의 이름도 ignored로 바꾸자.

 

int numColors = 4;
try {
    numColors = f.get(1L, TimeUnit.SECONDS);
} catch (TimeoutException | ExecutionException ignored) {
    // 기본 값을 사용한다(색상 수를 최소화하면 좋지만, 필수는 아니다.)
}

 

 

 

 

 

 

 

 

728x90
반응형

댓글