관리 메뉴

루시와 프로그래밍 이야기

10장-2 예외(ITEM 73~77) 본문

스터디/이펙티브 자바

10장-2 예외(ITEM 73~77)

Lucy_Ko 2023. 1. 26. 16:21

ITEM73. 추상화 수준에 맞는 예외를 던지라

수행하려는 일과 관련 없어 보이는 예외의 문제

-> 메서드가 저수준 예외를 처리하지 않고 바깥으로 전파해버릴 때 종종 일어남

  • 프로그래머를 당황시킴
  • 내부 구현 방식을 드러내어 윗 레벨 API를 오염시킴
  • 릴리스에서 구현방식을 바꾸면 다른 예외가 튀어나와 기존 클라이언트 프로그램을 깨지게 할 수도 있음

해결방법 : 예외번역(Exception translation) - 상위 계층에서는 저수준 예외를 잡아 자신의 추상화 수준에 맞는 예외로 바꿔 던져야 함

try{
	...
} catch(LowerLevelException e){
	// 추상화 수준에 맞춘다
	throw new HigherLevelException(...);
}
//AbstractSequentialList의 예시

public E get(int index){
	ListIterator<E> i = listIterator(index);
    try{
    	return i.next();
    }catch(NoSuchElementException e){
    	throw new IndexOutOfBoundsException("인덱스 : " + index);
    }
}

예외 연쇄

예외를 번역할 때, 저수준 예외가 디버깅에 도움이 된다면 예외 연쇄를 사용하는 것이 좋다.

예외 연쇄(exception chaning) - 문제의 근본 원인인 저수준 예외를 고수준 예외에 실어 보내는 방식

그러면 별도의 접근자 메서드(Throwable의 getCause())를 통해 필요하면 언제든지 저수준 예외를 꺼낼 수 있다.

try{
	... // 저수준 추상화를 이용한다.
}catch(LowerLevelException cause){
	// 저수준 예외를 고수준 예외에 실어 보낸다.
    throw new HigherLevelException(cause);
}
//예외 연쇄용 생성자
class HigherLevelException extends Exception{
	HigherLevelException(Throwable cause){
    	super(cause);
    }
}

대부분의 표준 예외는 연쇄용 생성자를 가지고 있다.

그렇지 않은 예외라도 Throwable의 initCause 메서드를 이용해 원인을 직접 못박을 수 있다.

예외 연쇄는 문제의 원인을 프로그램에서 잡근할 수 있게 해주고, 원인과 고수준 예외의 스택 추적 정보를 잘 추적해준다.

무턱대고 예외를 전파하는 것보다 예외 번역이 우수한 방법이지만, 그렇다고 남용하면 곤란하다. 

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

 

*아래 계층의 예외를 예방하거나 스스로 처리할 수 없고,

그 예외를 상위 계층에 그대로 노출하기 곤란하다면 예외 번역을 사용하라.

*이떄 예외 연쇄를 이용하면 상위 게층에는 맥락에 어울리는 고수준 예외를 던지면서

근본 원인도 함께 알려주어 오류를 분석하기에 좋다 (ITEM75)


ITEM74. 메서드가 던지는 모든 예외를 문서화하라

메서드가 던지는 예외는 그 메서드를 사용하는데 아주 중요한 정보다.

따라서 예외 하나하나를 문서화하는데 충분한 시간을 사용해야 한다. (ITEM56)

 

 검사 예외는 항상 따로 선언하고, 각 예외가 발생하는 상황을 자바독의 @Throws 태그를 사용해 정확히 문서화 하자.

공통 상위 클래스 하나로 뭉뚱그려 선언하는 일을 삼가자.

-> 극단적인 예로 메서드가 Exception이나 Throwable을 던진다고 선언해서는 안된다.

메서드 사용자에게 예외에 대처를 위한 힌트를 제공하지 못할 뿐더러,

같은 맥락에서 발생할 여지가 있는 다른 예외들까지 삼켜버릴 수 있어 API 사용성을 크게 떨어뜨린다.

*단 main 은 오직 JVM 만이 호출하기 때문에 Exception 을 던져도 괜찮다.

 

비검사 예외도 검사 예외처럼 정성껏 문서화해두면 좋다.

비검사 예외는 일반적으로 프로그래밍 오류를 뜻하는데(ITEM70), 자신이 일으킬 수 있는 오류들이 무엇인지 알려주면 프로그래머는 자연스럽게 해당 오류가 나지 않도록 코딩하게 된다.

잘 정비된 비검사 예외 문서는 사실상 그 메서드를 성공적으로 수행하기 위한 전제조건이 된다.

public 메서드라면 피요한 전제조건을 무서화해야 하며(ITEM56), 그 수단으로 가장 좋은 것이 바로 비검사 예외들을 문서화 하는 것이다.

 

메서드가 던질 수 있는 예외를 각각 @throws 태그로 문서화하되, 비검사 예외는 메서드 선언의 throws목록에 넣지 말자.

검사냐 비검사냐에 따라 사용자의 대처가 달라지기 때문에 이 둘을 구분해야 한다.

자바독은 @throws와 throws 목록에 모두에 명시한 예외와, @throws 태그에만 명시한 예외를 시각적으로 구분한다.

 

한 클래스에 정의된 많은 메소드가 같은 이유로 같은 예외를 던진다면 그 예외를 (각각의 메서드가 아닌) 클래스 설명에 추가하는 방법도 있다. NullPointerException이 가장 흔한 사례이다.

 


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

예외를 잡지못해 프로그램이 실패하면 자바 시스템은 그 예외의 스택 추적(stack trace) 정보를 자동으로 출력한다.

스택 추적은 예외 객체의 toString()메서드를 호출해서 얻는 문자열로, 보통은 예외의 클래스 이름 뒤에 상세 메시지가 붙는 형태다.

따라서 예외의 toString() 메서드에 실패 원인에 관한 정보를 가능한 한 많이 담아 반환하는 일은 아주 중요하다.

달리 말하면, 사후 분석을 위해 실패 순간의 상황을 정확히 포착해 예외의 상세 메시지에 담아야 한다.

 

실패 순간을 포착하려면 발생한 예외에 관여된 모든 매개변수와 필드의 값을 실패 메시지에 담아야 한다.

예컨대, IndexOutOfBoundsException의 상세 메시지는 범위의 최대, 최소값과 범위를 벗어난 인덱스의 값을 담아야 한다.

예를들어 IndexOutOfBoundsException생성자는 String을 받지만(ex.IndexOutOfBoundException“Source does not file in dest”), 다음과 같이 구현했어도 좋았을 것이다.

public IndextOutOfBoundsException(int lowerBound, int upperBound, int index){
	// 실패를 포착하는 상세 메시지를 생성한다.
    super(String.format(
    		"최솟값: %d, 최댓값: %d, 인덱스: %d",
            lowrBound, upperBound, index));
	
    // 프로그램에서 이용할 수 있도록 실패 정보를 저장한다
    this.lowerBound = lowerBound;
    this.upperBound = upperBound;
    this.index = index;
}

자바9에서는 드디어 정수 인덱스 값을 받는 생성자가 추가되었다. 하지만 아쉽게도 최솟값과 최댓값까지 받지는 않는다.

이처럼 자바 라이브러리에서는 이 조언을 적극 수용하지는 않지만, 나는 강력히 권장하는 바이다.

 

ITEM70에서 제안하였듯, 예외는 실패와 관련한 정보를 얻을 수 있는 접근자 메서드를 적절히 제공하는 것이 좋다.

포착한 실패 정보는 예외 상황을 복구하는 데 유용할 수 있으므로 접근자 메서드는 비검사 예외보다는 검사 예외에서 더 빛을 발한다.

비검사 예외의 상세 정보에 프로그램적으로 접근하길 원하는 프로그래머는 드물 것이기 때문이다.

하지만, 'toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하자' 하는 일반 원칙(ITEM12.p75)을 따른다는 관점에서는 비검사 예외라도 상세 정보를 알려주는 접근자 메서드를 제공하라고 권하고 싶다.


ITEM76. 가능한 한 실패 원자적으로 만들라

작업 도중 예외가 발생해도 그 객체는 여전히 정상적으로 사용할 수 있는 상태라면 멋질 것이다.

검사 예외를 던진 경우라면, 호출자가 오류 상태를 복구할 수 있을 테니 특히 더 유용할 것이다.

일반화해 이야기하면, 호출된 메서드가 실패하더라도 해당 객체는 메서드 호출 전 상태를 유지해야 한다.

이러한 특성을 실패 원자적(failure-atomic)이라고 한다.

 

메서드를 실패 원자적으로 만드는 방법은 다양하다.

  • 불변 객체(ITME17_ex static)로 설계하는 것
    • 가장 간단한 방법
    • 불변객체는 태생적으로 실패 원자적이다.
    • 메서드가 실패하면 새로운 객체가 만들어지지는 않을 수 있으나, 기존 객체가 불안정한 상태에 빠지는 일은 결코 없다.
    • 불변 객체의 상태는 생성 시점에 고정되어 절대 변하지 않기 때문이다.
  • 작업 수행에 앞서 매개변수의 유효성을 검사하는 것(ITEM49)
    • 가장 흔한 방법
    • 객체 내부 상태를 변경하기 전 잠재적 예외 가능성 대부분을 걸러낼 수 있는 방법
public Object pop(){
	if(size == 0) 
    	throw new EmptyStackException(); //size의 값을 확인하여 0이면 예외를 던진다.
        //사실 이 부분을 제거하더라도 스택이 비었다면 여전히 예외를 던진다.
    Object result = elements[--size]; //다만 size의 값이 음수가 되어 다음번 호출도 실패하게 만들며, 이때 던지는 ArrayIndexOutOfBoundsException은 추상화 수준이 상황에 어울리지 않다.(ITEM73)
    elements[size] = null; //다 쓴 참조 해제
    return result;
}

 

  • 실패할 가능성이 있는 모든 코드를, 객체의 상태를 바꾸는 코드보다 앞에 배치하는 방법
    • 위 예시와 비슷한 취지
    • Ex. TreeMap의 ClassCastException
  • 객체의 임시 복사본에서 작업으 수행한 다음, 작업이 성공적으로 완료되면 원래 객체와 교체하는 방법이 있다.
    • 데이터를 임시 자료구조에 저장하고 작업하는 게 더 빠를 때 좋은 방식이다.
    • 예를 들어서, 어떤 정렬 메서드에서는 정렬을 수행하기 전에 입력 리스트의 원소들을 배열로 옮겨 담는다. 배열을 사용하면 정렬 알고리즘의 반복문에서 원소들에 훨씬 빠르게 접근할 수 있기 때문이다. 물론 이는 성능을 높이고자 하는 결정이지만 혹시나 정렬에 실패하더라도 입력 리스트는 변하지 않는 효과를 덤으로 얻는다.
  • 작업 도중 발생하는 실패를 가로채는 복구 코드를 작성해서 작업 전 상태로 되돌리는 방법이다.
    • 주로 내구성을 보장해야 하는 (디스크 기반의)자료구조
    • 자주 쓰이는 방법은 아님

실패 원자성은 일반적으로 권장되지만 항상 달성할 수 있는 것은 아니다.

예를 들어서 두 쓰레드가 동기화없이 같은 객체를 동시에 수정한다면 그 객체의 일관성이 깨질 수 있다.

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

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

실패 원자적으로 만들수 있다고 해도 항상 그리 해야 하는 것도 아니다. 실패 원자성을 달성하기 위해서 비용이나 복잡도가 아주 큰 연산도 있기 때문이다.


ITEM77. 예외를 무시하지 말라

API 설계자가 메서드 선언에 예외를 명시하는 까닭은 그 메서드를 사용할 때 적절한 조치를 취해달라고 말하는 것이다. 안타깝지만 예외를 무시하는 것은 아주 쉽다.

해당 메서드 호출을 try 블록으로 감싸고 catch블록에서 아무 일도 하지 않으면 된다.

try {
	// ...
} catch (SomeException e) {
}

예외는 문제 상황에 잘 대처하기 위해서 사용하는 건데 catch블록을 비워두면 예외가 존재할 이유가 없다.

화재경보를 무시하는 수준을 넘어서 아예 꺼버리는 행동과 같다.

InputStream를 닫을 때처럼 예외를 무시해야 할 때도 있지만, 어쨋든 예외를 무시하기로 했다면 catch블록 안에 그렇게 결정한 이유를 남기고 예외 변수 이름도 ignored로 바꿔놓도록 하자.

 

 

Comments