Language/Java

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

JOYERIM 2023. 7. 31. 15:13

 

 

 

 

 

 

 

 

 

 

예시 1

 

코드 1 - 예외 처리를 제어 흐름 방식으로 사용
// 예외를 완전히 잘못 사용한 예 - 훨씬 느리다.
try {    // 성능이 2배 정도 느리다
	int i = 0;
    while(true)
    	range[i++].climb();
} catch (ArrayIndexOutOfBoundsException e) {
}

 

  • 배열의 원소를 순회하는데, 무한루프를 돌다가 배열의 끝에 도달해 ArrayIndexOutOfBoundsException이 발생하면 끝을 내는 코드이다.

 

코드 2 - 표준적인 관용구
// 표준적인 관용구로 작성한 예
for (Mountain m : range)
	m.climb();

 

코드 1의 문제점  
  1. 예외 처리는 비용이 많이 드는 작업이다.
    • 예외는 코드의 정상적인 실행 흐름을 제어하기 위한 수단이 아니라, 예외적인 상황에 대응하기 위한 수단이다.
    • 그러므로 JVM 구현자들은 예외 처리에 대한 성능 최적화를 크게 고려하지 않는다.
    • 따라서, 예외를 일반적은 제어 흐름 제어로 사용하면 성능 저하를 초래할 수 있다.
  2. 코드를 try-catch 블록 안에 넣으면 JVM이 적용할 수 있는 최적화가 제한된다.
    • JVM은 코드를 실행하면서 자동으로 여러 가지 최적화를 수행한다.
    • 그러나 예외를 던지는 코드는 JVM이 최적화하기 어렵다.
    • 이는 예외를 던지는 작업 자체가 비용이 크고, 예외가 발생하면 실행 흐름이 예측하기 어려워지기 때문이다.
  3. 배열을 순회하는 표준 관용구는 범위 검사를 알아서 처리한다.
    • for-each 루프는 JVM에서 특별히 최적화되어, 루프의 범위 검사를 알아서 처리한다.
    • 따라서 따로 범위 검사를 추가할 필요가 없다.
  4. 흐름 제어를 위한 예외가 다른 버그를 숨겨 디버깅을 훨씬 어렵게 한다.

 

 

 

 

 

 

 

교훈

 

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

 

표준적이고 쉽게 이해되는 관용구를 사용하고, 성능 개선을 목적으로 과하게 머리를 쓴 기법은 자제하라.

 

잘 설계된 API라면 클라이언트가 정상적인 제어 흐름에서 예외를 사용할 일이 없게 해야 한다.
: 특정 상태에서만 호출할 수 있는 "상태 의존적" 메소드를 제공하는 클래스는 "상태 검사" 메소드도 함께 제공해야 한다.
방법 1. 상태 검사 메소드
  • ex. Iterator 인터페이스의 next(상태 의존적 메소드)와 hasNext(상태 검사 메소드)
  • for-each도 내부적으로 hasNext 메소드를 사용한다.
for (Iterator<Foo> i = collections.iterator(); i.hasNext(); ) {
	Foo foo = i.next();
}
  • 만약 Iterator가 hasNext를 제공하지 않을 경우, 클라이언트가 대신해야만 한다.
    • 앞서 다뤘듯, 반복문에 예외를 사용하면 가독성이 떨어지고, 속도도 느려지며, 버그가 숨겨져 디버깅이 어렵다.
try {
	Iterator<Foo> i = collection.iterator();
    while(true) {
    	Foo foo = i.next();
        ...
} catch (NoSuchElementException e) {
}

 

방법 2. 빈 옵셔널(item 55) 혹은 null같은 특수한 값
  • 상태 검사 메소드 대신, 올바르지 않은 상태일 때 빈 옵셔널(item 55) 혹은 null같은 특수한 값을 반환하는 방법도 있다.

 

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

1. 외부 동기화 없이 여러 스레드가 동시에 접근 가능하거나 상태가 변할 수 있을 때 👉 옵셔널 또는 특정 값

  • 상태 검사와 상태 의존적 메소드 호출 사이에 객체의 상태가 변할 가능성이 있다.
  • 따라서 옵셔널이나 특정 값을 사용한다.
// 상태 검사 메소드 사용 예
if (obj.isAvailable()) {
    obj.doSomething(); // obj의 상태가 isAvailable() 호출 이후에 변경될 수 있다면 문제 발생
}

// 옵셔널 사용 예
Optional<ResultType> result = obj.tryToDoSomething();
result.ifPresent(r -> ...);

 

2. 성능이 중요한 상황에서 상태 검사 메소드가 중복 작업을 수행할 때 👉 옵셔널 또는 특정 값

  • 상태 검사와 상태 의존적 메소드의 동작 사이에 중복된 계산이 발생할 수 있다.
  • 이럴 경우 성능 최적화를 위해 옵셔널 또는 특정 값을 사용하는 게 좋다.
// 상태 검사 메소드 사용 예 (비효율적)
if (obj.computeValue() > 10) {
    int value = obj.computeValue();
    ...
}

 

3. 다른 모든 경우 👉 상태 검사 메소드

  • 대부분 상황에서는 상태 검사 메소드가 가독성과 안정성 면에서 더 좋다

 

상태 검사 메소드의 이점
  • 가독성
    • 상태 검사 메소드는 직관적이다.
    • 상태를 먼저 확인하고 상태에 따라 동작을 수행한다.
if (stack.isEmpty()) {
    System.out.println("Stack is empty!");
} else {
    stack.pop();
}

 

  • 버그 발견 용이성
    • 상태 검사 메소드를 사용하면, 만약 개발자가 상태 검사를 누락하게 되면, 상태 의존적 메소드에서 예외가 발생하게 될 확률이 높다.
    • 따라서 버그를 빨리 발견할 수 있다. 
// 깜빡하고 isEmpty 검사를 빼먹었다면 pop()에서 예외 발생
stack.pop();

 

특정 값의 문제점
  • 특정 값을 반환하는 메소드는 종종 오류 상황을 암묵적으로 나타낸다.
  • ex. 리스트에서 특정 항목을 찾지 못했을 때 -1을 반환하는 메소드는 -1이 실제 의미하는 값인지, 오류를 나타내는 값인지 구분하기 어렵다.
int position = list.find("item");
if (position != -1) {
    // 항목 발견
} else {
    // 항목 없음
}