이펙티브 자바 10장) 예외

동동주·2025년 12월 26일

이펙티브 자바

목록 보기
11/13

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

예외는 오직 예외 상황에서만 써야 한다. 절대로 일상적인 제어 흐름용으로 쓰여선 안 된다. 잘 설계된 API라면 클라이언트가 정상적인 제어 흐름에서 예외를 사용할 일이 없게 해야 한다.

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

해당 코드의 문제점

  • 해당 코드는 무슨일을 하는 코드인지 알 수가 없다.
  • 무한루프를 돌다가 배열의 끝에 도달해 ArrayIndexOutOfBoundsException이 발생해야 끝이난다.

첫 번째 예시에서는 왜 Exception을 던져줬을까? 바로 JVM에서 배열에 접근할 때마다 경계를 넘지 않는지 검사하는데, 일반적인 반복문도 배열 경계에 도달하면 종료된다는 희망에 던져졌을 것이라고, 책에서 말해준다. 하지만, 책에서는 세가지 면에서 잘못된 추론이라고 한다.

  • 예외는 예외 상황에 쓸 용도로 설계되었으므로 예외에서는 최적화에 별로 신경 쓰지 않았을 가능성이 크다.
  • 코드를 try-catch 블록 안에 넣으면 JVM이 적용할 수 있는 최적화가 제한 된다.
  • 배열을 순회하는 표준 관용구는 앞서 걱정한 중복 검사를 수행하지 않는다. JVM이 알아서 최적화해 없애준다.

해당 코드를 다시 리팩토링 한다면,

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

아래와 같은 장점을 가지게 된다.

  • 무슨일을 하는 코드인지 직관적이게 확인할 수 있다.
  • forLoop가 끝나면 해당 코드는 끝나기 때문에 불필요한 Exception을 던질 필요가 없다.

요번 아이템은 바로 장점 중에 두번째에 해당하는 Exception에 올바른 쓰임에 대해 2가지 항목으로 정리하며 끝내고 있다.

  • 예외는 오직 예외 상황에서만 써야 한다. 절대로 첫번째 예시처럼, 일상적인 제어 흐름용으로 쓰여선 안된다.
  • 잘 설계된 API라면 클라이언트가 정상적인 제어 흐름에서 예외를 사용할 일이 없게 해야한다.

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

1. 검사 예외(Checked Exception)

  • 반드시 예외처리를 해야한다.
    • throw, try-catch를 이용하여 예외처리를 강제화한다.
    • 호출하는 쪽에서 복구하리라 여겨지는 상황이라면 검사 예외를 사용해야한다.
  • 복구에 필요한 정보를 알려주는 메소드를 정의하는게 좋다.
    • 예를 들어 물건을 구입하는데 잔고가 부족하다면, 잔고가 얼마나 부족한지 알려줘야한다.

2. 비검사 예외 (Unchecked Exception)

  • 종류
    • 런타임 예외
    • 에러
  • 프로그램에서 잡을 필요가 없다.
    • 복구가 불가능하거나 더 실행해봤자 의미가 없기 때문 → 예외처리를 강제화하지 않는다.
    • 프로그래밍 오류를 나타낼 때에는 비검사 예외를 사용해야한다.
  • 우리가 구현하는 비검사 예외는 모두 RuntimeException을 상속 받아야한다.
    • Error는 상속 받지 말아야할 뿐만아니라, 직접 throw 하는 일도 없어야한다. (AssertionError 제외)
    • Exception, RuntimeException, Error를 상속하지 않은 throwble은 만들지 말자
      • 이로운 것이 없다.
      • API 사용자에게 혼동을 줄 수도 있다.

뭘 사용할지 모르겠다면 아마 비검사 예외를 사용하는 것이 더 나을 것이다 (아이템 71 참고)

3. 결론

  • 복구해야하는 상황이면 → 검사 예외
  • 프로그래밍 시 나타나는 오류라면 → 런타임 예외
  • 확실하지 않은 예외라면 → 비검사 예외
  • Error 클래스를 상속해 하위 클래스를 만드는 일 → 하지말자
  • Exception, RuntimeException, Error를 상속하지 않은 throwble만들지 말자

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

1. 검사 예외 (Checked Exception)

  • 검사 예외는 발생한 문제를 프로그래머가 처리하여 안정성을 높힐 수 있다.
  • 물론, 과하게 사용하면 오히려 쓰기 불편한 APi 가 될 수 있다.
    • (1) try/catch 블록, throws

      public class Example {
      
          public void example() {
      
              try {
              
              } catch (IOException e) {
              
              } 
              
              try {
              
              } catch (IOException e) {
              
              } 
                              
              try {
              
              } catch (IOException e) {
              
              } 
      
              ...
          }
      }     
    • (2) Java 8, Stream

      public class Test {
          
          public static void main(String[] args) {
              String[] names = {"자바", "파이썬", "고", "Trigger"};
          
              Arrays.stream(names)
                      .map(Example::verifyName)   // <--- compile error
                      .collect(toSet());           
          }
                
          public static class Example {
      
              public String verifyName(String name) throws Exception {
                  if (name == "trigger") {
                      throw new Exception("fail");
                  }
                  return name;
              }
          }    
      
      }

2. 검사 예외를 회피하는 방법

(1) Optional

  • 검사 예외를 던지는 대신 단순히 빈 옵셔널을 반환하면 된다.
  • 예시
    • AS-IS
      Long userId = 1L;
      try {
         User user = userService.findUserById(userId); 
      } catch (Exception e) {
          log.error("fail to find user, userId: {}", userId)
          user = new User(userId);
      }
    • TO-BE
      Long userId = 1L;
      
      User user = repository.findUserById(userId)
                      .orElseGet(() -> new User(userId)); 

(2) 메서드를 두 개로 분할하여 비검사 예외로 바꾼다.

  • 이 방식에서 첫 번째 메서드는 예외가 던져질지 여부를 boolean 값으로 반환한다.

  • 예시

    • AS-IS
      try {
          obj.action(args);
      } catch (TheCheckedException e) {
          ... // 예외 상황에 대처한다.
      }
    • TO-BE
       if(obj.actionPermitted(args)){
           obj.action(args);
       } else {
           ... // 예외 상황에 대처한다.
       }
  • 하지만 외부 요안에 의하여 상태가 변할 수 있다면, 이 방법은 적절치 않다.

  • 꼭 필요한 곳에만 사용한다면, 검사 예외는 프로그램의 안정성을 높여준다. 하지만 남용하면 쓰기 어려운 API 를 낳는다.
  • 예외 상황에서 복구할 방법이 없다면 비검사 예외를 던지자.
  • 복구가 가능하고 호출자가 그 처리를 해주길 바란다면, 우선 옵셔널을 반환해도 될지 고민하자.
  • 옵셔널만으로는 상황을 처리하기에 충분한 정보를 제공할 수 없을 때만 검사 예외를 던지자.

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

자바 라이브러리는 대부분 API 에서 쓰기에 충분한 수의 예외를 제공한다.
표준 예외를 재사용하면 얻는 게 많다.

  1. API가 다른 사람이 익히고 사용하기 쉬워진다는 것
  2. API를 사용한 프로그램도 낯선 예외를 사용하지 않게 되어 읽기 쉽게 된다는 장점
  3. 예외 클래스 수가 적을수록 메모리 사용량도 줄고 클래스를 적재하는 시간도 적게 걸린다.

자주 재사용하는 예외

  • IllegalArgumentException

    호출자가 인수로 부적절한 값을 넘길 때 던지는 예외

예시

public class MainRunner {
  
    List<Integer> list = new ArrayList<>();
  
    public void positiveAdd(Integer number) {
        if (number < 0) {
            throw new IllegalArgumentException("음수값은 허용하지 않음");
        }
        list.add(number);
    }

    public static void main(String[] args) {
        MainRunner mainRunner = new MainRunner();
        mainRunner.positiveAdd(-1);
    }
}
  • IllegalStateException

    대상 객체의 상태가 호출된 메서드를 수행하기에 적합하지 않을 때 주로 사용한다.

예시

public class MainRunner {

    List<Integer> list = new ArrayList<>();
    
    public void evenIndexAdd(Integer number){
        if(list.size()%2 != 0){
            throw new IllegalStateException("짝수 인덱스에 들어갈 준비가 되지 않음");
        }
        list.add(number);
    }

    public static void main(String[] args) {
        MainRunner mainRunner = new MainRunner();
        mainRunner.evenIndexAdd(0);
        mainRunner.evenIndexAdd(1);
    }
}
  • NullPointerException

    null 값을 허용하지 않는 메서드에 null 을 건네는 경우 사용한다.

예시

public class MainRunner {

    List<Integer> list = new ArrayList<>();

    public void add(Integer number){
        if(Objects.isNull(number)){
            throw new NullPointerException("값이 null 입니다.");
        }
        list.add(number);
    }

    public static void main(String[] args) {
        MainRunner mainRunner = new MainRunner();
        mainRunner.add(null);
    }
}
  • IndexOutOfBoundsException

    시퀀스의 허용범위를 넘는 값을 건널때 사용한다.

예시

public class MainRunner {

    List<Integer> list = new ArrayList<>();

    public void add(Integer number){
        if(list.size()>1){
            throw new IndexOutOfBoundsException("요소를 3개 이상 설정 할 수 없습니다.");
        }
        list.add(number);
    }

    public static void main(String[] args) {
        MainRunner mainRunner = new MainRunner();
        mainRunner.add(1);
        mainRunner.add(2);
        mainRunner.add(3);
    }
}
  • ConcurrentModificationException

    단일 스레드에서 사용하려고 설계한 객체를 여러 스레드가 동시에 수정하려 할 때 사용함.

  • UnsupportedOperationException

    클라이언트가 요청한 동작을 대상 객체가 지원하지 않을 때 던진다. 대부분 객체는 자신이 정의한 메서드를 모두 지원하니 흔히 쓰이는 예외는 아니다.

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

예외 변역이란?

  • 상위 계층에서 저수준 예외를 잡아 자신의 추상화 수준에 맞는 예외로 바꿔 던지는것

  • try {
      ...
    } catch (LowerLevelException e) {
      throw new HigherLevelException(...);
    }
  • /**
    * ...
    * @throws IndexOutOfBoundsException ...
    */
    public E get(int index) {
    	try {
    		return listIterator(index).next();
    	} catch (NoSuchElementException exc) {
    		throw new IndexOutOfBoundsException("Index: " + index);
    	}
    }

예외 번역을 하지 않으면?

  • 수행하려는 일과 관련없어 보이는 예외가 튀어나와 프로그래머를 당황시킨다
  • 내부 구현 방식을 드러내어 윗 레벨 API를 오염시킨다
  • 다음 릴리즈에서 구현 방식을 바꾸면 다른 예외가 튀어나와 기존 클라이언트 프로그램을 깨지게 할 수 있다

예외 연쇄를 사용해서 번역할 수도 있다.

  • 예외 연쇄 : 문제의 근본 원인인 저수준 예외를 고수준 예외에 실어 보내는 방식

  • Throwable.getCause()를 통해 언제든 저수준 예외를 꺼내볼 수 있다

  • 저수준 예외가 디버깅에 도움을 줄 수 있다

  • try {
      ...
    } catch (LowerLevelException cause) {
      throw new HigherLevelException(cause);
    }
    class HigherLevelException extends Exception {
      HigherLevelException(Throwable cause) {
        super(cause);
      }
    }

하지만 예외 번역을 남용해서는 안된다.

  • 가능하다면 저수준 메서드가 반드시 성공하도록 하여 아래 계층에서는 예외가 발생하지 않도록 하는 것이 최선이다

    • 어떻게? 상위 계층 메서드의 매개변수 값을 하위 계층 메서드로 건네기 전에 미리 검사해서 예외가 발생할 만한 매개변수를 하위 계층 메서드로 전달되지 않게끔 해서.

    • public void function1(...) {
        //미리 검사
        function2(...);
      }
  • 하위 계층에서의 예외를 피할 수 없다면?

    • 상위 계층에서 조용히 처리하여 예외를 API 클라이언트에까지 전파하지 않고 로그를 남길 수도 있다

    • 이렇게 하면 클라이언트 코드에 문제를 전파하지 않음 + 로그분석을 통해 조치를 취할 수 있음

    • public void function1(...) {
        ...
        try {
          function2(...);
        } catch(...) {
          //로그 기록
        }
      }

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

체크 예외는 항상 개별적으로 선언하라, 또한 어떠한 조건에서 예외가 발생하는지 기재하라

  • javaDoc의 @throws 태그를 달아서 예외가 발생하는 조건을 설명하면 된다.
  • 여러 예외를 하나의 슈퍼 클래스 예외로 선언하지 마라. (Exception, Throwable 이 가장 대표적임)
  • 단 하나의 예외 사항이 있다면, main 함수에서 던지는 Exception 이나 Throwable 이 있다.

언체크 예외는 다룰 수가 없다. 어떻게 문서화 해야할까?

  • 잘 문서화된 언체크 예외는 사전조건으로 설명하라.(아이템 56번)
  • 아이템 56번에 따르면, 모든 public 메서드는 사전조건(precondtions)를 설명해야 한다.
  • 특히, 인터페이스의 메서드가 던질 수 있는 언체크 예외를 문서화하는 것이 중요하다.

Javadoc의 @throws 태그를 사용하여 체크 예외에 대한 설명을 하라, 다만 언체크 예외에 throws 키워드를 사용하면 안된다.

  • 만약 언체크 예외를 Javadoc의 @throws 태그로 명시하였다면, 예외가 체크되지 않았다는 강력한 시각적 암시를 나타낸다.

하나의 클래스의 여러 메서드가 던지는 예외가 같은 이유로 설명된다면, 클래스 수준의 문서화 주석을 남겨라

  • 가장 대표적인 예는 NullpointerException 일 것이다.
  • 예를 들어, "이 클래스의 모든 메서드는 NullPointerException을 던질 수 있음" 이라고 설명하면 된다.

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

  • 실패 순간을 정확히 포착하기위해선 발생한 예외에 관여된 모든 매개변수와 필드의 값을 실패 메세지에 담아야 한다.

예시를 살펴보자.

잘못된 예외 메세지

public static void main(String[] args) {
    Member member = Member.create("kjj", "재준", 99);
}

누군가 만든 Member.create() 호출하여 새로운 회원을 생성하고자 했을때

만약 다음과 같은 메세지를 만나게 된다면 해당 메서드를 사용한 클라이언트는 왜 오류가나는지 전혀 알수없다.

결국에는 소스코드를 타고들어가서 예외를 발생시키는 원인을 분석하게된다.

이러한 행동은 결국 나의 리소스를 낭비하게 됩니다.

잘 설계된 예외 메세지

public static void main(String[] args) {
    Member member = Member.create("kjj", "재준", 99);
}

Member.create() 을 호출했을때 예외가 발생한다면

이처럼 메세지, 대상필드, 입력값 등을 포함하여 예외를 발생시킨다면 다음과 같은 장점이 존재합니다.

  1. 왜 예외가 발생하는지 코드를 살펴보지 않아도 알 수 있음
  2. 비지니스 로직 파악이 쉬움(객체의 불변식을 한눈에 볼 수 있음)

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

실패 원자적 (failure-atomic)인 메서드

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

어떻게 실패 원자적인 메서드를 만들까?

1. 불변 객체로 만들기
  • 불변 객체는 태생적으로 실패 원자적이다.
  • 메서드가 실패하면 새로운 객체가 만들어지지는 않을 수 있으나 기존 객체가 불완전한 상태에 빠지는 일은 결코 없다. 불변 객체의 상태는 생성 시점에 고정되어 절대 변하지 않기 때문이다!
2. 가변 객체의 메서드의 경우 작업 수행 전 매개변수의 유효성을 검사하기
  • 객체의 내부 상태를 변경하기 전에 잠재적 예외의 가능성 대부분을 걸러낼 수 있는 방법이다.
public Object pop() {
    if (size == 0)
        throw new EmptyStackException();
    Object result = elements[--size];
    elements[size] = null; // 다 쓴 참조 해제
    return result;
}
  • 실패 가능성이 있는 모든 코드를, 객체의 상태를 바꾸는 코드보다 앞에 배치하기
    • 계산을 수행해보기 전에는 인수의 유효성을 검사해볼 수 없을 때 앞의 방식에서 덧붙여 쓸 수 있는 기법
3. 객체의 임시 복사본에서 작업을 수행한 다음, 작업이 성공적으로 완료되면 원래 객체와 교체하는 것
  • 데이터를 임시 자료구조에 저장해 작업하는 게 더 빠를 때 적용하기 좋은 방식
    • 예를 들어, 어떤 정렬 메서드에서는 정렬을 수행하기 전에 입력 리스트의 원소들을 배열로 옮겨 담는다. 배열을 사용하면 정렬 알고리즘의 반복문에서 원소들에 훨씬 빠르게 접근할 수 있기 때문이다.
    • 물론, 정렬에 실패하더라도 입력 리스트는 변하지 않는 효과를 덤으로 얻게 된다.
4. 작업 도중 발생하는 실패를 가로채는 복구 코드를 작성하여 작업 전 상태로 되돌리는 방법
  • 주로 (디스크 기반의) 내구성(durability)을 보장해야 하는 자료구조에 쓰이는데, 자주 쓰이는 방법은 아니다.

실패 원자성은 항상 달성할 수 있을까?

실패 원자성은 일반적으로 권장되는 덕목이지만 항상 달성할 수 있는 것은 아니다. 예를 들어 두 스레드가 동기화 없이 같은 객체를 동시에 수정한다면 그 객체의 일관성이 깨질 수 있다.

따라서 ConcurrentModificationException을 잡아냈다고 해서 그 객체가 여전히 쓸 수 있는 상태라고 가정해서는 안 된다.

한편, Error는 복구할 수 없으므로 AssertionError에 대해서는 실패 원자적으로 만들려는 시도조차 할 필요가 없다.

실패 원자성을 항상 보장해야 할까?

실패 원자적으로 만들 수 있더라도 항상 그리 해야 하는건 아니다. 실패 원자성을 달성하기 위한 비용이나 복잡도가 아주 큰 연산도 있기 때문이다. 그래도 문제가 무엇인지 알고 나면 실패 원자성을 공짜로 얻을 수 있는 경우가 더 많다.

메서드 명세에 기술한 예외라면 설혹 예외가 발생하더라도 객체의 상태는 메서드 호출 전과 똑같이 유지돼야 하는 것이 기본 규칙이다. 이 규칙을 지키지 못한다면 실패 시의 객체 상태를 API 설명에 명시해야 한다. 이것이 이상적이나, 아쉽게도 지금의 API 문서 상당 부분이 잘 지키지 않고 있다.

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

예외를 무시하지 말라

API 설계자가 메서드 선언에 예외를 명시하는 까닭은 그 메서드를 사용할 때 적절한 조치를 취해달라고 말하는 것
→ 예외를 무시하는 것은 화재 경보를 무시하는 수준을 넘어 아예 꺼버리는 것고 같은 것이다
→ 예외를 절!대! 무시하지 말자 = try-catch의 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) {
	// 기본값을 사용한다(색상 수를 최소화하면 좋지만, 필수는 아니다)
}

정리

검사 예외든, 비검사 예외든 예외를 무시하지 말자!
예외를 적절히 처리해서 오류를 피하거나, 최소한 무시하지 않고 바깥으로 전파되게라도 두어 디버깅 정보를 남긴 채 프로그램이 신속히 중단되게 하라.


https://github.com/Meet-Coder-Study/book-effective-java

0개의 댓글