// 예외를 끔찍하게 남용한 사례. 이러면 곤란하다.
try {
int i = 0;
while(true)
range[i++].climb();
}catch(ArrayIndexOutOfBoundsException e){ }
range 배열의 원소를 순회하는데 무한 루프를 돌다가 배열 범위 밖에 있는 첫 번째 요소를 참조하는 순간에 발생하는 ArrayIndexOutOfBoundsException 예외를 감지하고 무시하는 과정을 통해 순회를 종료시킨다. 이 코드를 다음과 같이 표준적인 관용구대로 작성했다면 모든 자바 프로그래머가 곧바로 이해했을 것이다.
for (Mountain m : range) {
m.climb();
}
그런데 예외를 써서 루프를 종료한 이유는 뭘까? 잘못된 추론을 근거로 성능을 높여보려 한 것이다. JVM은 배열에 접근할 때마다 경계를 넘지 않는지 검사하는데, 일반적인 반복문도 배열 경계에 도달하면 종료한다. 따라서 이 검사를 반복문에도 명시하면 같은 일이 중복되므로 하나를 생략한 것이다. 하지만 세 가지 면에서 잘못된 추론이다.
- 예외는 예외 상황에 쓸 용도로 설계되었으므로 JVM 구현자 입장에서는 명확한 검사만큼 빠르게 만들어야 할 동기가 약하다(최적화에 별로 신경 쓰지 않았을 가능성이 크다).
- 코드를 try-catch 블록 안에 넣으면 JVM이 적용할 수 있는 최적화가 제한된다.
- 배열을 순회하는 표준 관용구는 앞서 걱정한 중복 검사를 수행하지 않는다. JVM이 알아서 최적화해 없애준다.
실상은 예외를 사용한 쪽이 표준 관용구보다 훨씬 느리다. 예외를 사용한 반복문의 해악은 코드를 헷갈리게 하고 성능을 떨어뜨리는데서 끝나지 않는다. 심지어 제대로 동작하지 않을 수도 있다. 반복문 안에 버그가 숨어 있다면 흐름 제어에 쓰인 예외가 이 버그를 숨겨 디버깅을 훨씬 어렵게 할 것이다. 예를 들어 위의 코드 climb 메서드에서 내부적으로 다른 배열을 사용하다가 ArrayIndexOutOfBoundsException을 일으켰다고 해보자. 표준 관용구였다면 이 버그는 예외를 잡지 않고 (stack trace 정보를 남기고) 해당 스레드를 즉각 종료시킬 것이다. 반면 예외를 사용한 반복문은 버그 때문에 발생한 엉뚱한 예외를 정상적인 반복문 종료 상황으로 오해하고 넘어간다.
예외는 예외적인 상황에만 사용해야지, 평상시 제어 흐름에 이용해서는 안된다.
이 원칙은 API 설계에도 적용된다. 잘 설계된 API라면 클라이언트가 정상적인 제어 흐름에서 예외를 사용할 일이 없게 해야 한다. 특정 상태에서만 호출할 수 있는 '상태 의존적' 메서드를 제공하는 클래스는 '상태 검사' 메서드도 함께 제공해야 한다. 예를 들어 Iterator 인터페이스에는 상태 의존적 메서드 next가 있고, 상태 검사 메서드 hasNext가 있다. Iterator에 hasNext 메서드가 없었다면 클라이언트는 어쩔 수 없이 아래와 같은 코드를 만들어야 했을 것이다.
// 컬렉션을 이런 식으로 순회하지 말 것!
try {
Iterator<Foo> i = collection.iterator();
while(true) {
Foo foo = i.next();
...
}
} catch (NoSuchElementException e) { }
별도의 상태 검사 메서드 덕분에 다음과 같은 표준 for 관용구를 사용할 수 있다(for-each도 내부적으로 hasNext를 사용한다).
for (Iterator<Foo> i = collection.iterator(); i.hasNext();) {
Foo foo = i.next();
...
}
상태 검사 메서드 대신 사용할 수 있는 선택지도 있다. 올바르지 않은 상태일 때 empty optional 혹은 null 같은 특수한 값을 반환하는 방법이다. 상태 검사 메서드, optional, 특정 값 중 하나를 선택하는 지침을 몇 개 소개하겠다.
- 외부 동기화 없이 여러 스레드가 동시에 접근할 수 있거나 외부 요인으로 상태가 변할 수 있다면 optional이나 특정 값을 사용한다. 상태 검사 메서드와 상태 의존적 메서드 호출 사이에 객체의 상태가 변할 수 있기 때문이다.
- 성능이 중요한 상황에서 상태 검사 메서드가 상태 의존적 메서드의 작업 일부를 중복 수행한다면 optional이나 특정 값을 선택한다.
- 다른 모든 경우엔 상태 검사 메서드 방식이 조금 더 낫다고 할 수 있다. 가독성이 살짝 더 좋고, 잘못 사용했을 때 발견하기가 쉽다. 상태 검사 메서드 호출을 깜빡 잊었다면 상태 의존적 메서드가 예외를 던져 버그를 확실히 드러낼 것이다. 반면 특정 값은 검사하지 않고 지나쳐도 발견하기가 어렵다(optional에는 해당하지 않는 문제다).
자바는 세 가지 종류의 ‘throwable’을 제공한다. 점검지정 에외(checked exception), 실행시점 예외(runtime exception), 그리고 오류(error)다. 점검지정 예외를 사용할 것인지 아니면 무점검 예외를 사용할 것인지에 대한 가장 기본적인 규칙은, 호출자(caller) 측에서 복구할 것으로 여겨지는 상황에 대해서는 점검지정 예외를 이용해야 한다는 것이다. 점검지정 예외를 던지는 메서드를 호출한 클라이언트는 해당 예외를 catch 절 안에서 처리하든지, 아니면 계속 밖으로 던져지도록 놔두든지 해야 한다. 따라서 메서드에 선언된 점검지정 예외는 메서드를 호출하면 해당 예외와 관계된 상황이 발생할 수 있음을 API 사용자에게 알리는 구실을 한다.
무점검(unchecked) ‘throwable’에는 실행시점 에외와 오류 두 가지가 있으며, 동작 방식은 같다. 프로그램이 무점검 예외나 오류를 던진다는 것은 복구가 불가능한 상황에 직면했다는 뜻으로, 더 진행해 봐야 득보다 실이 더 크다는 뜻이다.
프로그래밍 오류를 표현할 때는 실행시점 예외를 사용하라 대부분의 실시점 예외는 선행조건 위반을 나타낸다. 즉, 클라이언트가 API 명세에 기술된 규약을 지키지 않았다는 뜻이다. 요약하자면 복구 가능한 상태에는 점검지정 예외를 사용하고, 프로그래밍 오류를 나타내고 싶을 때는 실행시점 예외를 사용하라.
점검지정 예외는 프로그래머로 하여금 예외적인 상황을 처리하도록 강제함으로써 안정성을 높인다. 하지만 너무 남발하면 사용하기 불편한 API가 될 수 있다는 뜻이기도 하다.
API를 제대로 이용해도 예외 상황이 벌어지는 것을 막을 수 없을 때, 그리고 API 사용자가 예외 상황에 대한 조치를 취할 수 있을 때는, 그 정도의 부담은 참을 수 있을 것이다. 하지만 이 조건 가운데 어디에도 해당되지 않을 때는 무점검 예외를 이용하는 것이 좋다.
} catch (TheCheckedException e) {
throw new AssertionError(); // 이런 에러가 생길 리 없어요 !
}
이렇게 하는 것이 최선인가? 아래 코드는 어떤가?
} catch (TheCheckedException e) {
e.printStrackTrace(); // 그래요. 졌습니다.
System.exit(1);
}
API 사용자가 이보다 좋은 코드를 만들 수 없다면, 무점검 예외가 적당하다. 이 테스트를 통과하지 못하는 예외의 사례로는 CloneNotSupportedException이 있다. 이 예외는 Cloneable 인터페이스를 구현하지 않은 객체에 Object.clone 메서드를 호출하면 발생하는 예외다. 실제로는, 이 예외를 처리하는 catch 블록이 실행되었다는 것은, 확증이 실패했다는 것이나 마찬가지다. 그런 특성에 어울리지 않게 점검지정 예외로 선언되어 있다는 것이 문제인데, 프로그래머 입장에서는 반갑지 않은 일이다. 프로그램만 복잡해지기 때문이다.
메서드가 던지는 점검지정 예외가 하나뿐일 때 프로그래머가 느끼게 되는 부담은 큰 편이다. 그 하나의 catch 블록 때문에 try 블록 안에서 메서드를 호출해야 하는 것이다. 이런 상황에 처하면, 점검지정 예외를 없앨 방법이 없을지 고민해보는 것이 좋다. 점검지정 예외를 무점검 예외로 바꾸는 한 가지 방법은, 예외를 던지는 메서드를 둘로 나눠서 첫 번째 메서드가 boolean 값을 반환하도록 만드는 것이다.
// 예외를 점검하도록 지정된 메서드 호출
try {
obj.action(args);
} catch(TheCheckedException e){
// 예외적 상황 처리
…
}
앞서 설명한 대로 메서드를 리팩토링하면 이 코드는 아래와 같이 바뀐다.
// 상태 검사 메서드를 거쳐서 무점검 예외 메서드 호출
if (obj.actionPermitted(args)) {
obj.action(args);
} else {
// 예외적 상황 처리
...
}
메서드 호출 순서가 이전 방식에 비해 더 깔끔하다고 말하기는 어려우나, 더 유연한 API가 되었음은 사실이다. action 호출이 항상 성공하리라고 확신하거나, 설사 실패해서 스레드가 죽어도 상관없다면 위의 코드는 obj.action(args)
한 줄로 줄일 수 있다. 하지만 그 결과로 만들어지는 API 규칙 57에서 설명한 상태 검사 메서드와 본질적으로 같기 때문에 동일한 문제를 갖는다. 외부적인 동기화 수단 없이 병렬적으로 이용될 가능성이 있는 객체거나, 외부에서 그 상태를 바꿀 가능성이 있는 객체라면 방금 설명한 리팩토링 기법은 적용할 수 없다. actionPermitted를 호출하고 action을 미처 호출하기 전에 객체의 상태가 바뀔 수도 있기 때문이다.
가장 널리 재사용 되는 예외는 IllegalArgumentException이다. 잘못된 값을 인자로 전달했을 때 일반적으로 발생하는 예외다. 널리 쓰이는 또 다른 예외로는 IllegalStateException이 있다. 현재 객체 상태로는 호출 할 수 없는 메서드를 호출했을 때 일반적으로 발생하는 예외다. 예를 들어 아직 적절히 초기화되지 않은 객체를 사용하려고 시도하면 이 예외가 발생해야 할 것이다.
모든 잘못된 메서드 호출은 결국 잘못된 인자나 잘못된 상태에 관계된 것이라 이해할 수 있다. 하지만 특정 부류의 잘못된 인자나 상태에 표준적으로 이용되는 예외들도 있다. null 인자를 받으면 안되는 메서드에 null을 전달한 경우, 관습적으로는 IllegalArgumentException 대신 NullPointerException이 발생해야 한다. 이와 비슷하게, 어떤 sequence의 첨자를 나타내는 인자에 참조 가능 범위를 벗어난 값이 전달되었을 때는 IndexOutOfBoundsException이 발생해야 한다.
일반적 용도의 예외 가운데 알아둘 만한 것으로는 ConcurrentModificationException도 있다. 하나의 스레드만 사용하도록 설계된 객체나, 외부적인 동기화 수단과 함께 이용되어야 하는 객체를 여러 스레드가 동시에 변경하려 하는 경우에 발생해야 하는 예외다. 또 UnsupportedOperationException도 알아두면 좋다. 어떤 객체가 호출된 메서드를 지원하지 않을 때 발생하는 예외다. 다른 예외들에 비해 사용 빈도가 아주 낮은데, 대부분의 객체는 자기가 구현하는 메서드를 지원하는 것이 보통이기 때문이다. 이 예외는 인터페이스에 정의된 선택적 메서드 가운데 하나 이상을 구현하지 않을 경우에 사용한다.
재사용 할 수 있는 예외라 생각된다면, 사용하도록 하라. 하지만 예외를 발생시키는 조건이 해당 예외의 문서에 기술된 것과 일치해야 한다. 마지막으로 어떤 예외를 재사용하면 좋을 지 결정하는 것은 엄밀한 과학적 절차를 따르지 않는다. 위의 표에 나열한 용례조차도 상호 배제적이지 않다.
상위 계층에서는 하위 계층에서 발생하는 예외를 반드시 받아서 상위 계층 추상화 수준에 맞는 예외로 바꿔서 던져야 한다. 이 숙어를 ‘예외 변환’이라 부른다(exception translation 토비에서는 예외 전환).
// 예외 변환
try {
// 낮은 수준의 추상화 계층 이용
...
} catch (LowerLevelException e) {
throw new HigherLevelException(…);
}
토비의 스프링 exception translation 예제
public void add() throws DuplicateUserIdException {
try {
// JDBC를 이용해 user 정보를 DB에 추가하는 코드 또는
// 그런 기능이 있는 다른 SQLException을 던지는 메서르르 호출하는 코드
}catch (SQLException e){
if(e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY)
throw new DuplicateUserIdException(e);
}
}
위의 코드를 보면 예외 연결(exception chaining)도 포함되어 있다. 하위 계층에서 발생한 예제 정보가 상위 계층 예외를 발생시킨 문제를 디버깅하는 데 유용할 때 사용한다.
// 예외 연결
try {
// 낮은 수준의 추상화 계층 이용
...
} catch (LowerLevelException cause) {
throw new HigherLevelException(cause);
}
상위 계층 예외 HigherLevelException의 생성자는 문제의 ‘원인’을 예외 연결을 지원하는 상위 클래스 생성자에 넘긴다. 해당 인자는 결국 Throwable의 예외 연결 지원 생성자에 전달된다.
// 예외 연결 지원 생성자를 갖춘 예외
class HigherLevelException extends Exception {
HigherLevelException (Throwable cause){
super(cause);
}
}
대부분의 표준 예외들은 예외 연결 지원 생성자를 구비하고 있다.
예외 처리의 제일 좋은 방법은 하위 계층에서 예외가 생기지 않도록 하는 것이다. 하위 계층 메서드에서 예외가 발생하는 것을 막을 수 없다면 그 다음으로 좋은 방법은 하위 계층에서 생기는 문제를 상위 계층 메서드 호출자로부터 격리시키는 것이다. 하위 계층에서 발생하는 예외를 어떤 식으로든 처리해 버리는 것이다. 그래야 하는 상황이라면 java.util.logging 같은 기능을 활용해서 로그를 남기면 좋을 것이다. 클라이언트나 최종 사용자에게는 문제를 감추지만, 관리자는 나중에 분석할 수 있도록 하는 것이다.
점검지정 예외는 독립적으로 선언하고, 해당 예외가 발생하는 상황은 Javadoc @throws 태그를 사용해서 정확하게 밝혀라. 메서드가 던질 가능성이 있는 모든 예외를 문서로 남겨라. 점검지정 예외뿐만 아니라, 무점검 예외에도 문서를 만들라(무점검 예외는 보통 프로그래밍 오류를 나타낸다). 점검지정 예외는 메서드의 throws 절에 나열하고, 무점검 예외는 throws 절에는 적지마라.
쉽게 재현할 수 없는 오류라면, stack trace 이상의 정보를 얻기 어렵거나 불가능하다. 그래서 예외의 상세 메세지에는 원인 분석에 이용될 오류 정보가 포착되어 있어야 한다. 오류 정보를 포착해 내기 위해서는, 오류의 상세 메세지에 “예외에 관계된” 모든 인자와 필드의 값을 포함시켜야 한다.
오류를 적절히 포착하는 정보를 상세 메세지에 담는 한 가지 방법은, 상세한 정보를 요구하는 생성자를 만드는 것이다.
public IndexOutOfBoundsException(int lowerBound, int upperBound, int index) {
// 오류를 포착하는 상세 메세지 생성
supper(lowerBound, upperBound, index);
//프로그램에서 이용할 수 있도록 오류 정보 보관
this.lowerBound = lowerBound;
this.upperBound = upperBound;
this.index = index;
}
그리고 예외 객체에 오류 포착 정보를 제공하는 접근자 메서드를 두어도 좋다.
일반적으로 이야기해서 호출이 정상적으로 처리되지 못한 객체의 상태는, 메서드 호출 전 상태와 동일해야 한다. 이 속성을 만족하는 메서드는 실패 원자성을 갖추었다고 한다.
실패 원자성을 달성하는 방법은 여러 가지다. 가장 간단한 방법은 변경 불가능 객체로 설계하는 것이다. 변경 불가능한 객체의 경우, 실패 원자성은 덤이다. 변경 가능한 객체의 경우에는 실제 연산을 수행하기 전에 인자 유효성(validity)을 검사하는 것이 가장 보편적인 방법이다. 객체를 변경하는 도중에 예외가 발생하는 것을 막아준다.
public Object pop() {
if(size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null;
return result;
}
위 코드를 보면, 빈 스택에서 뭔가를 뽑아내려 하면, 굳이 첫 두줄이 없어도 예외가 나긴 한다. 하지만 첫 두 줄이 없으면 size 필드의 일관성이 깨져서 음수로 바뀌게 된다. 그러니 이 메서드를 다시 호출하면 계속 문제가 생길 것이다. 게다가, 첫 두 줄이 없을 때 발생하는 예외는 해당 클래스에는 어울리지 않는다.
이와 밀접한 관련이 있는 또 다른 접근법 하나는, 실패할 가능성이 있는 코드를 전부 객체 상태를 바꾸는 코드 앞에 배치하는 것이다. 예를 들어 TreeMap에 추가할 원소는 해당 TreeMap의 순서대로 비교가 가능한 자료형이어야 한다. 엉뚱한 자료형의 원소를 넣으려고 하면, 트리를 실제로 변경하기 전에 트리 안에서 해당 원소를 찾다가 ClassCastException이 발생할 것이다.
사용 빈도가 훨씬 낮은 세 번째 접근법은 연산 수행 도중에 발생하는 오류를 가로채는 복구 코드를 작성하는 것이다. 이 복구 코드는 연산이 시작되기 이전 상태로 객체를 되돌린다(roll back)
마지막 접근법은, 객체의 임시 복사본상에서 필요한 연산을 수행하고, 연산이 끝난 다음에 임시 복사본의 내용으로 객체 상태를 바꾸는 것이다. 예를 들어 Collections.sort는 원소들을 참조하는 비용을 줄이기 위해, 인자로 주어진 리스트를 정렬하기 전에 배열에 복사한다. 성능 문제 때문에 내린 조치인데, 그 덕에 정렬이 실패해도 원래 리스트에는 아무런 손상이 가지 않는다.
실패 원자성은 일반적으로 권장되는 덕목이지만 언제나 달성할 수 있는 것은 아니다. 명심할 것은, 예외와는 달리 오류(error)는 복구가 불가능하며, 오류를 던지는 경우에는 실패 원자성을 보존하려 애쓸 필요가 없다는 점이다.
// catch 블록을 비워 놓으면 예외는 무시된다 - 심히 의심스런 코드!
try {
...
}catch(SomeException e){
}
빈 catch 블록은 예외를 선언한 목적, 그러니까 예외적 상황을 반드시 처리하도록 강제한다는 목적에 배치된다.
예외를 무시해도 괜찮은 경우를 하나 예로 들자면, FileInputStream을 닫는 경우일 것이다. 파일 상태를 바꾸지 않았고 그래서 복구 작업을 할 필요도 없으며, 필요한 정보는 파일에서 모두 읽었으니 진행 중인 연산을 중단할 이유도 없다. 하지만 그렇더라도 로그는 남겨두는 것이 좋다. 그래야 예외가 자주 발생하는 것을 알았을 때 그 원인을 분석해 볼 수 있기 때문이다.