규칙 57. 예외는 예외적 상황에만 사용하라
try {
for (Mountain m : range)
m.climb();
} catch (ArrayIndexOutOfBoundsException e) {
}
예외를 사용해 구현한 순환문은 코드의 원래 목적을 흐리고 성능을 떨어뜨릴 뿐 아니라, 올바른 동작을 보장할 수 없다는 문제도 갖고 있다. 관련 없는 버그 때문에 순환문이 조용히 종료되면 버그의 존재는 감춰지므로 디버깅이 어려워진다. 위 예제와 같은 순환문을 사용하게 되면 버그 때문에 예외가 생겼는지, 순환문이 끝나려고 예외가 발생했는지 분간할 수 없으므로 버그는 묻혀버리게 된다.
이 이야기가 주는 교훈은 간단하다. 이름이 말하듯이, 예외는 예외적인 상황에만 사용해야 한다. 평상시 제어 흐름(ordinary control flow)에 이용해서는 안 된다.
이 원칙은 API 설계에도 적용된다. 잘 설계된 API는 클라이언트에게 평상시 제어 흐름의 일부로 예외를 사용하도록 강요해서는 안 된다. 특정한 예측 불가능 조건이 만족될 때만 호출할 수 있는 "상태 종속적(state-dependent)" 메서드를 가진 클래스에는 보통 해당 메서드를 호출해도 되는지를 알기 위한 "상태 검사(state-testing)" 메서드가 별도로 갖춰져 있다. 예를 들어, Iterator 인터페이스에는 상태 종속적 메서드 next가 있고, 상태 검사 메서드 hasNext가 있다. 그 덕에, 아래와 같은 표준적 for 숙어를 사용할 수 있다.
for (Iterator<Foo> i = collection.iterator(); i.hasNext(); ) {
Foo foo = i.next();
}
Iterator에 hasNext 메서드가 없었다면 클라이언트는 어쩔 수 없이 아래와 같은 코드를 만들어야 했을 것이다.
// 물론 이렇게 하면 곤란
try {
Iterator<Foo> i = collection.iterator();
while (true) {
Foo foo = i.next();
...
}
} catch (NoSuchElementException e) {
}
상태 검사 메서드를 제공하기 싫다면, 부적절한 상태의 객체에 상태 종속적 메서드를 호출하면 null 같은 특이값(distinguished value)이 반환되도록 구현하는 방법도 있다. 그러나 이 기법은 Iterator에는 사용할 수 없는데, null은 next 메서드의 정상적 반환값 가운데 하나이기 때문이다.
규칙 58. 복구 가능 상태에는 점검지정 예외를 사용하고, 프로그래밍 오류에는 실행시점 예외를 이용하라
자바는 세 가지 종류의 'throwable'을 제공한다. 점검지정 예외(checked exception), 실행시점 예외(runtime exception), 그리고 오류(error)다.
점검지정 예외를 사용할 것인지 아니면 무점건 예외(unchecked exception)를 사용할 것인지에 대한 가장 기본적인 규칙은, 호출자(caller) 측에서 복구할 것으로 여겨지는 상황에 대해서는 점검지정 예외를 이용해야 한다는 것이다. 점검지정 예외를 던지는 메서드를 호출한 클라이언트는 해당 예외를 catch 절 안에서 처리하든지, 아니면 계속 밖으로 던져지도록 놔두든지 해야 한다. 따라서 메서드에 선언된 점검지정 예외는 메서드를 호출하면 해당 예외와 관계된 상황이 발생할 수 있음을 API 사용자에게 알리는 구실을 한다.
API 사용자에게 점검지정 예외를 준다는 것은, 그 상태를 복구할 권한을 준다는 뜻이다. 사용자는 그 권한을 무시할 수 있다. catch로 예외를 받되(catch) 다른 처리를 하지 않아도 된다는 것이다. 하지만 일반적으로 그렇게 하면 곤란하다(규칙 65).
무점검(unchecked) 'throwable'에는 실행시점 예외와 오류 두 가지가 있으며, 동작 방식은 같다. 둘 다 catch로 처리할 필요가 없으며, 일반적으로는 처리해서는 안 된다. 프로그램이 무점검 예외나 오류를 던진다는 것은 복구가 불가능한 상황에 직면했다는 뜻으로, 더 진행해 봐야 득보다 실이 더 크다는 뜻이다. 이런 throwable을 catch하지 않는 스레드는 적절한 오류 메시지를 내면서 중단(halt)된다.
프로그래밍 오류를 표현할 때는 실행시점 예외를 사용하라. 대부분의 실행시점 예외는 선행조건 위반(precondition violation)을 나타낸다. 클라이언트가 API 명세에 기술된 규약(contract)을 지키지 않았다는 뜻이다. 예를 들어, 배열 이용에 관한 규약에는 배열 첨자의 값이 0부터 배열 길이 -1 까지라고 되어 있다. 이 규약이 위반되면 ArrayIndexOutOfBoundsException이 발생한다.
Error는 JVM이 자원 부족(resource deficiency)이나 불변식 위반(invariant failure) 등, 더 이상 프로그램을 실행할 수 없는 상태에 도달했음을 알리기 위해 사용된다. 따라서 Error의 하위 클래스는 새로 만들지 말고, 사용자 정의 무점검 throwable은 RuntimeException의 하위 클래스로 만들어야 한다.
요약
복구 가능한 상태에는 점검지정 예외를 사용하고, 프로그래밍 오류를 나타내고 싶을 때는 실행시점 예외를 사용하라.
규칙 59. 불필요한 점검지정 예외 사용은 피하라
점검지정 예외는 예외적인 상황을 처리하도록 강제함으로써 안정성(reliability)를 높여준다. 하지만 남발하면 사용하기 불편한 API가 될 수 있다. 하나 이상의 점검지정 예외를 던지는 메서드를 호출할 때는 예외를 받아 처리하는 catch 블록을 하나 이상 만들든가, 아니면 예외를 다시 밖으로 던진다고 선언하고는 외부로 전파되도록 내버려둬야 한다.
메서드가 던지는 점검지정 예외가 하나뿐일 때 프로그래머가 느끼게 되는 부담은 큰 편이다. 예외가 여러 개라면, 하나의 try 블록에 달리는 catch 블록도 여러 개일 것이다. 반면 예외가 하나뿐이라면 catch 블록도 하나뿐일 것이다. 그 하나의 catch 블록 때문에 try 블록 안에서 메서드를 호출해야 하는 것이다. 이런 상황에 처하면 점검지정 예외를 없앨 방법이 없을지 고민해봐야 한다.
점검지정 예외를 무점검 예외로 바꾸는 한 가지 방법은, 예외를 던지는 메서드를 둘로 나눠서 첫 번째 메서드가 boolean 값을 반환하도록 만드는 것이다.
// 예외를 점검하도록 지정된 메서드 호출
try {
obj.action(args);
} catch (TheCheckedException e) {
// 예외적 상황 처리
}
앞서 설명한 대로 메서드를 리팩터링하면 다음과 같다.
// 상태 검사 메서드를 거쳐서 무점검 예외 메서드 호출
if (obj.actionPermitted(args)) {
obj.action(args);
} else {
// 예외적 상황 처리
}
action 호출이 항상 성공하리라고 확신하거나, 설사 실패해서 스레드가 죽어도 상관없다면 obj.action(args)
코드 한줄로 줄일 수 있다.
규칙 60. 표준 예외를 사용하라
자바 플랫폼 라이브러리에는 대부분의 API가 필요로 하는 기본적인 무점검 예외들이 갖추어져 있다. 이미 있는 예외들을 재사용하면 좋은 점이 많다. 배우기 쉽고 사용하기 편리한 API를 만들 수 있다. 그리고 그렇게 구현된 API는 가독성이 높다. 마지막으로, 예외 클래스 개수를 줄이면 프로그램의 메모리 요구량이 줄어들고, 클래스를 로딩하는 시간도 줄어든다.
가장 널리 재사용되는 예외는 IllegalArgumentException이다. 잘못된 값을 인자로 전달했을 때 발생하는 예외다. 예를 들어, 어떤 동작의 실행 횟수를 나타내는 인자에 음수가 전달되면 이 예외를 던져야 한다.
널리 쓰이는 또 다른 예외는 IllegalStateException이 있다. 현재 객체 상태로는 호출할 수 없는 메서드를 호출했을 때 발생하는 예외다. 예를 들어, 아직 적절히 초기화되지 않은 객체를 사용하려고 시도하면 이 예외가 발생할 것이다.
null 인자를 받으면 안되는 메서드에 null을 전달한 경우, IllegalArgumentException 대신 NullPointerException이 발생해야 한다. 이와 비슷하게, 어떤 순서열(sequence)의 첨자를 나타내는 인자에 참조 가능 범위를 벗어난 값이 전달되었을 때 IllegalArgumentException 대신 IndexOutOfBoundsException이 발생해야 한다.
ConcurrentModificationException은 하나의 스레드만 사용하도록 설계된 객체나, 외부적인 동기화 수단과 함께 이용되어야 하는 객체를 여러 스레드가 동시에 변경하려 하는 경우에 발생해야 하는 예외다.
UnsupportedOperationException은 어떤 객체가 호출된 메서드를 지원하지 않을 때 발생하는 예외다. 이 예외는 인터페이스에 정의된 선택적 메서드 가운데 하나 이상을 구현하지 않을 경우에 사용된다. 예를 들어, 객체를 추가하는 것만 가능한 리스트는 누군가 리스트에서 원소를 삭제하려고 하면 이 예외를 발생시킬 것이다.
규칙 61. 추상화 수준에 맞는 예외를 던져라
추상화 수준이 낮은 곳에서 발생한 예외를 그대로 밖으로 전달하면 관련성이 없는 예외가 발생하는 일이 생긴다. 그리고 추상화 수준이 높은 API가 구현 세부사항으로 오염되는 일까지 벌어진다.
이 문제를 피하려면 상위 계층에서는 하위 계층에서 발생하는 예외를 반드시 받아서 상위 계층 추상화 수준에 맞는 예외로 바꿔서 던져야 한다. 이를 예외 변환(exception translation)이라 부른다.
// 예외 변환
try {
// 낮은 수준의 추상화 계층 이용
...
} catch(LowerLevelException e) {
throw new HigherLevelException(...);
}
예외 연결(exception chaining)은 예외 변환의 특별한 사례다. 하위 계층에서 발생한 예제 정보가 상위 계층 예외를 발생시킨 문제를 디버깅하는 데 유용할 때 사용한다. 하위 계층 예외(원인 cause)는 상위 계층 예외를 전달되는데, 상위 계층 예외에 있는 접근자 메서드(Throwable.getCause)를 호출하면 해당 정보를 꺼낼 수 있다.
// 예외 연결
try {
... // 낮은 수준의 추상화 계층 이용
} catch (LowerLevelException cause) {
throw new HigherLevelException(cause);
}
상위 계층 예외 HigherLevelException의 생성자는 문제의 '원인'을 예외 연결을 지원하는(chaining-aware) 상위 클래스 생성자에게 넘긴다. 해당 인자는 결국 Throwable의 예외 연결 지원 생성자에 전달된다. Throwable(Throwable)로 선언되어 있었을 것이다.
// 예외 연결 지원 생성자를 갖춘 예외
class HigherLevelException extends Exception {
HigherLevelException(Throwable cause) {
super(cause);
}
}
대부분의 표준 예외들은 예외 연결 지원 생성자를 구비하고 있다. 그런 생성자가 없는 예외에는 Throwable.initCause 메서드를 호출하면 하위 계층 예외를 연결할 수 있다. 예외 연결 기능을 사용하면 프로그램 안에서 예외의 원인에 접근할 수 있을 뿐 아니라(getCause를 이용해서), 최초에 발생한 예외의 스택 추적 정보(stack trace)를 상위 계층 예외에 통합할 수 있게 된다.
아무 생각 없이 아래 계층에서 생긴 예외를 밖으로 전달하기만 하는 것보다야 예외 변환 기법이 낫지만, 남용하면 안 된다. 가능하다면 제일 좋은 방법은 하위 계층에서 예외가 생기지 않도록 하는 것이다. 하위 계층 메서드가 예외 없이 수행될 수 있도록 하는 것이다.
하위 계층 메서드에서 예외가 발생하는 것을 막을 수 없다면, 좋은 방법으로는 하위 계층에서 생기는 문제를 상위 계층 메서드 호출자로부터 격리시키는 것이다. 하위 계층에서 발생하는 예외를 어떤 식으로든 처리해 버리는 것이다.
요약
하위 계층에서 발생하는 예외를 막거나 처리할 수 없다면, 상위 계층에 보여주면 곤란한 예외는 예외 변환을 통해 처리하라.
규칙 62. 메서드에서 던져지는 모든 예외에 대해 문서를 남겨라
점검지정 예외는 독립적으로 선언하고, 해당 예외가 발생하는 상황은 Javadoc@throws 태그를 사용해서 정확하게 밝혀라. 하위 예외 클래스를 여럿 거느린 상위 예외 클래스의 이름을 메서드 선언부에 나열하면 안 된다는 것이다.
메서드가 던질 가능성이 있는 모든 무점검 예외까지 선언할 필요는 없으나, 점검지정 예외들과 마찬가지로 주의해서 문서로 남겨 놓으면 좋다. 무점검 예외는 보통 프로그래밍 오류를 나타낸다(규칙 58). 메서드에서 발생할 수 있는 무점검 예외들에 대한 상세한 문서는 결국, 메서드를 성공적으로 실행하기 위한 선행조건(precondition)이 무엇인지를 효과적으로 밝힌다.
특히 인터페이스 메서드에 대한 문서는, 각 문서에서 발생할 수 있는 무점검 예외들을 밝혀야 한다. 인터페이스의 일반 규약(general contract) 일부가 될 뿐 아니라, 다양한 구현들이 같은 방식으로 동작할 수 있도록 한다.
Javadoc @throws 태그를 사용해서 메서드에서 발생 가능한 모든 무점검 예외에 대한 문서를 남겨라. 하지만 메서드 선언부의 throws 뒤에 무점검 예외를 나열하진 마라. 그래야 API 사용자가 무엇이 점검지정 예외이고 무엇이 무점검 예외인지 알 수 있다.
메서드가 던질 가능성이 있는 모든 무점검 예외에 대해 문서를 남기면 이상적이지만, 실제로는 불가능할 때도 있다. 클래스를 고쳐서 또 다른 무점검 예외를 던지도록 메서드를 수정해도, 소스 호환성(source compatibility)나 이진 호환성은 그대로 유지된다.
같은 이유로 동일한 예외를 던지는 메서드가 많다면, 메서드마다 문서를 만드는 대신, 해당 예외에 대한 문서는 클래스의 문서화 주석(documentation comment)에 남겨도 된다. 그런 예외로 가장 흔한 것은 NullPointerException이다. 클래스의 문서화 주석에 "이 클래스에 있는 모든 메서드는 인자로 null이 전달되면 NullPointerException을 발생시킨다"라고 적어도 된다는 것이다.
요약
- 메서드가 먼질 가능성이 있는 모든 예외를 문서로 남겨라.
- 점검지정 예외뿐만 아니라, 무점검 예외에도 문서를 만들라.
- 점검지정 예외는 메서드의 throws 절에 나열하고, 무점검 예외는 throws 절에는 적지 마라.
규칙 63. 어떤 오류인지를 드러내는 정보를 상세한 메시지에 담으라
오류 정보를 포착해 내기 위해서는, 오류의 상세 메시지에 "예외에 관계된" 모든 인자와 필드의 값을 포함시켜야 한다. 예를 들어, IndexOutOfBoundsException의 예외 메시지에는 첨자의 하한과 상한, 그리고 그 범위를 벗어난 첨자값이 포함되어야 한다.
관련된 모든 "실제 데이터(hard data)"를 담는 것이 중요한 반면, 중언부언 떠드는 것은 별로 도움되지 않는다. 스택 추적 정보는 원래 소스 파일과 함께 분석하는 것이다. 그 안에는 보통 예외가 발생한 지점의 파일 이름과 행 번호(line number)가 담길 뿐 아니라, 스택상의 모든 메서드가 호출된 지점의 파일 이름과 행 번호도 담긴다.
예외의 상세 메시지를 사용자 레벨 오류 메시지와 혼동해서는 안 된다. 사용자 레벨 오류 메시지는 최종 사용자가 이해할 수 있어야 한다. 하지만 예외에 대한 상세 메시지는 프로그래머나 서비스 담당자가 오류 원인을 분석하기 위한 것이다. 따라서 가독성보다는 내용이 훨씬 중요하다.
오류를 적절히 포착하는 정보를 상세 메시지에 담는 한 가지 방법은, 상세한 정보를 요구하는 생성자를 만드는 것이다. 상세 미시지는 생성자에 전달된 정보를 통해 자동 생성할 수 있다. 예를 들어, IndexOutOfBoundsException의 생성자는 String 대신 세 개의 첨자값을 인자로 받도록 구현할 수 있다.
// 주석은 생략
public IndexOutOfBoundsException(int lowerBound, int upperBound, int index) {
// 오류를 포착하는 상세 미시지 생성
super("Lower bound: " + lowerBound + ", Upper bound: " + upperBound +
", Index: " + index);
// 프로그램에서 이용할 수 있도록 오류 정보 보관
this.lowerBound = lowerBound;
this.upperBound = upperBound;
this.index = index;
}
규칙 58에서 제안했던 대로, 예외 객체에 오류 포착 정보(위 예제에서 lowerBound, upperBound, index 등)를 제공하는 접근자 메서드를 두어도 좋을 것이다. 그런 정보는 오류를 복구할 때 유용하므로, 무점검 예외보다는 점검지정 예외에 더 필요한 메서드다.
규칙 64. 실패 원자성 달성을 위해 노력하라
예외를 던지고 난 뒤에도 객체의 상태가 잘 정의된, 사용 가능한 상태로 남아 있으면 좋다. 메서드 호출이 정상적으로 처리되지 못한 객체의 상태는, 메서드 호출 전 상태와 동일해야 한다. 이 속성을 만족하는 메서드는 실패 원자성(failure atomicity)을 갖추었다고 한다.
실패 원자성을 달성하는 방법은 여러 가지다. 가장 간단한 방법은 변경 불가능 객체로 설계하는 것이다(규칙 15). 변경 불가능한 객체의 경우, 실패 원자성은 덤이다. 메서드 호출이 실패하면 새로운 객체가 만들어지지 못할 수는 있겠지만 기존 객체의 일관성이 깨지진 않는다.
변경 가능한 객체의 경우에는 실제 연산을 수행하기 전에 인자 유효성(validity)을 검사하는 것이 가장 보편적인 방법이다(규칙 38). 객체를 변경하는 도중에 예외가 발생하는 것을 막아준다. 일례로, 규칙 6에서 두러었던 Stack, pop의 코드를 다시 살펴보자.
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object reulst = elements[--size];
elements[size] = null; // 만기(obsolete) 참조 제거
return result;
}
물론 빈 스택에 뭔가를 뽑아내려 하면, 굳이 첫 두줄이 없어도 예외가 나긴한다. 하지만 첫 두줄이 없으면 size 필드의 일관성이 깨져서 음수로 바뀌게 된다. 그러니 이 메서드를 호출하면 계속 문제가 생길 것이다. 게다가, 첫 두줄이 없을 때 발생하는 예외는 해당 클래스에는 어울리지 않는다(규칙 61).
이와 비슷한 또 다른 접근법 하나는, 실패할 가능성이 있는 코드를 전부 객체 상태를 바꾸는 코드 앞에 배치하는 것이다. 계산을 실제로 수행해 보기 전에는 인자를 검사할 수 없을 때 이용 가능한 방법으로, 앞서 살펴본 접근법을 자연스럽게 확장한 것이다. 예를 들어 TreeMap에 추가할 원소는 해당 TreeMap의 순서(ordering)대로 비교가 가능한 자료형이어야 한다. 엉뚱한 자료형의 원소를 넣으려고 하면, 트리를 실제로 변경하기 전에 트리 안에서 해당 원소를 찾다가 ClassCastException이 발생할 것이다.
사용 빈도가 훨씬 낮은 세 번째 접근은 연산 수행 도중에 발생하는 오류를 가로채는 복구 코드(recovery code)를 작성하는 것이다. 이 복구 코드는 연산이 시작되기 이전 상태로 객체를 되돌린다(roll back). 디스크 기반의 지속성(durable) 자료 구조에 주로 사용되는 기법이다.
마지막 접근법은, 객체의 임시 복사본상에서 필요한 연산을 수행하고, 연산이 끝난 다음에 임시 복사본의 내용으로 객체 상태를 바꾸는 것이다. 데이터를 임시 자료 구조에 복사한 다음에 훨씬 신속하게 실행될 수 있는 연산이라면 이 접근법이 자연스럽다.
실패 원자성은 일반적으로 권장되는 덕목이지만 언제나 달성할 수 있는 것은 아니다. 예를 들어, 같은 객체를 여러 스레드가 적절한 동기화 없이 동시에 변경할 경우, 객체 상태의 일관성은 깨질 수 있다. 그러니 ConcurrentModificationException이 발생한 뒤에는 객체 상태는 망가져 있으리라 보는 것이 좋다. 명심할 것은, 예외와 달리 오류(error)는 복구가 불가능하며, 오류를 던지는 경우에는 실패 원자성을 보존하려 애쓸 필요가 없다는 것이다.
규칙 65. 예외를 무시하지 마라
무시하지 마라! 호출 대상 메서드를 빈 catch 블록이 붙은 try 문으로 감사면, 예외를 쉽게 무시할 수 있다.
// catch 블록을 비워 놓으면 예외는 무시된다
try {
...
} catch (SomeException e) {
}
빈 catch 블록은 예외를 선언한 목적, 그러니까 예외적 상황을 반드시 처리하도록 강제한다는 목적에 배치된다. 이렇게 빈 catch 블록을 만나면, 머릿속에 경고 신호를 우려야 한다. 적어도 catch 블록 안에는 예외를 무시해도 괜찮은 이유라도 주석으로 남겨 두어야 한다.
이번 절에 설명한 지침은 점검지정 예외와 무점검 예외에 똑같이 적용된다. 예측 가능한 예외적 상황을 나타내는 예외건, 아니면 프로그래밍 오류를 나타내는 예외건 간에, 빈 catch 블록으로 예외를 무시하는 프로그램은 오류가 생겨도 조용히 실행을 계속한다. 그러다 아무 상관 없는 지점에서 불시에 죽어버린다. 예외를 적절히 처리하면 오류로 프로그램이 죽는 일은 피할 수 있다. 게다가 예외가 바깥쪽으로 나가도록 그저 놓아두기만 해도, 적어도 프로그램이 오류를 발생한 즉시 종료되도록 만들 수 있다.