10장 - 예외
69. 예외는 진짜 예외 상황에만 사용하라
운이 없다면 언젠가 다음과 같은 코드와 마주칠지도 모른다.
try {
int i = 0;
while(true) {
range[i++].climb();
}
} catch(ArrayIndexOutOfBoundsException e) {
}
무슨 일을 하는 코드인지 알겠는가? 전혀 직관적이지 않다는 사실 하나만으로도 코드를 이렇게 작성하면 안 되는 이유는 충분하다.
이 코드는 배열의 원소를 순회하는데, 아주 끔찍한 방식으로 하고 있다.
무한루프를 돌다가 배열에 끝에 도달에 ArrayIndexOutOfBoundsException이 발생하면 끝을 내는 것이다.
이 코드를 다음과 같이 표준적인 관용구대로 작성했다면 모든 자바 프로그래머가 곧바로 이해했을 것이다.
for(Mountain m : range) {
m.climb();
}
그런데 예외를 써서 루프를 종료한 이유는 도대체 뭘까?
잘못된 추론을 근거로 성능을 높여보려 한 것이다.
JVM은 배열에 접근할 때마다 경계를 넘지 않는지 검사하는데, 일반적인 반복문도 배열 경계에 도달하면 종료한다.
따라서 이 검사를 반복문에도 명시하면 같은 일이 중복되므로 하나를 생략한 것이다.
하지만 세 가지 면에서 잘못된 추론이다.
- 예외는 예외 상황에 쓸 용도로 설계되었으므로 JVM 구현자 입장에서는 명확한 검사만큼 빠르게 만들어야 할 동기가 약하다 (최적화에 별로 신경쓰지 않았을 가능성이 크다)
- 코드를 try-catch 블록안에 넣으면 JVM이 적용할 수 있는 최적화가 제한된다.
- 배열을 순회하는 표준 관용구는 앞서 걱정한 중복 검사를 수행하지 않는다. JVM이 알아서 최적화해 없애준다.
실상은 예외를 사용한 쪽이 표준 관용구보다 훨씬 느리다. 내 컴퓨터에서 원소 100개짜리 배열로 테스트해보니 2배 정도 느렸다.
예외를 사용한 반복문의 해악은 코드를 헷갈리게 하고 성능을 떨어뜨리는 데서 끝나지 않는다.
심지어 제대로 동작하지 않을 수도 있다.
반복문 안에 버그가 숨어 있다면 흐름 제어에 쓰인 예외가 이 버그를 숨겨 디버깅을 훨씬 어렵게 할 것이다.
이 이야기의 교훈은 간단하다.
예외는 (그 이름이 말해주듯) 오직 예외 상황에서만 써야 한다. 절대로 일상적인 제어 흐름용으로 쓰여선 안 된다.
이 원칙은 API 설계에도 적용된다.
잘 설계된 API라면 클라이언트가 정상적인 제어 흐름에서 예외를 사용할 일이 없게 해야 한다.
특정 상태에서만 호출할 수 있는 ‘상태 의존적’ 메서드를 제공하는 클래스는 ‘상태 검사’ 메서드도 함께 제공해야 한다.
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) {
}
70. 복구할 수 있는 상황에는 검사 예외를, 프로그래밍 오류에는 런타임 예외를 사용하라
자바는 문제 상황을 알리는 타입(throwable)으로 검사 예외, 런타임 예외, 에러, 이렇게 세 가지를 제공하는데, 언제 무엇을 사용해야 하는지 헷갈려 하는 프로그래머들이 종종 있다.
언제나 100% 명확한 건 아니지만 이럴 때 참고하면 좋은 멋진 지침들이 있으니 함께 살펴보자
호출하는 쪽에서 복구하리라 여겨지는 상황이라면 검사 예외를 사용하라.
이것이 검사와 비검사 예외를 구분하는 기본 규칙이다.
검사 예외를 던지면 호출자가 그 예외를 catch로 잡아 처리하고나 더 바깥으로 전파하도록 강제하게 된다.
따라서 메서드 선언에 포함된 검사 예외 각각은 그 메서드를 호출했을 때 발생할 수 있는 유력한 결과임을 API 사용자에게 알려주는 것이다.
달리 말하면, API 설계자는 API 사용자에게 검사 예외를 던져주어 그 상황에서 회복해내라고 요구한 것이다.
물론 사용자는 예외를 잡기만 하고 별다른 조치를 취하지 않을 수도 있지만, 이는 보통 좋지 않은 생각이다.
비검사 throwable은 두 가지로, 바로 런타임 예외와 에러다. 둘 다 동작 측면에서는 다르지 않다.
이 둘은 프로그램에서 잡을 필요가 없거나 혹은 통상적으로 잡지 말아야 한다.
프로그램에서 비검사 예외나 에러를 던졌다는 것은 복구가 불가능하거나 더 실행해봐야 득보다는 실이 많다는 뜻이다.
이런 throwable을 잡지 않은 스레드는 적절한 오류 메시지를 내뱉으며 중단된다.
프로그래밍 오류를 나타낼 때는 런타임 예외를 사용하자.
런타임 예외의 태부분은 전제조건을 만족하지 못했을 때 발생한다.
전제조건 위배란 단순히 클라이언트가 해당 API의 명세에 기록된 제약을 지키지 못했다는 뜻이다.
에러는 보통 JVM이 자원 부족, 불변식 깨짐 등 더 이상 수행을 계속할 수 없는 상황을 나타낼 때 사용한다.
자바 언어 명세가 요구하는 것은 아니지만 업계에 널리 퍼진 규약이니, Error 클래스를 상속해 하위 클래스를 만드는 일은 자제하기 바란다.
다시 말해 여러분이 구현하는 비검사 throwable은 모두 RuntimeException의 하위 클래스여야 한다 (직접적이든 간접적이든).
Error는 상속하지 말아야 할 뿐 아니라, throw 문으로 직접 던지는 일도 없어야 한다.
71. 필요 없는 검사 예외 사용은 피하라
검사 예외를 싫어하는 자바 프로그래머가 많지만 제대로 활용하면 API와 프로그램의 질을 높일 수 있다.
결과를 코드로 반환하거나 비검사 예외를 던지는것과 달리, 검사 예외는 발생한 문제를 프로그래머가 처리하여 안전성을 높이게끔 해준다.
물론, 검사 예외를 과하게 사용하면 오히려 쓰기 불편한 API가 된다.
어떤 메서드가 검사 예외를 던질 수 있다고 선언됐다면, 이를 호출하는 코드에서는 catch 블록을 두어 그 예외를 붙잡아 처리하거나 더 바깥으로 던져 문제를 전파해야만 한다.
어느 쪽이든 API 사용자에게 부담을 준다. 더구나 검사 예외를 던지는 메서드는 스트림 안에서 직접 사용할 수 없기 때문에 자바 8부터는 부담이 더욱 커졌다.
API를 제대로 사용해도 발생할 수 있는 예외이거나, 프로그래머가 의미 있는 조치를 취할 수 있는 경우라면 이 정도 부담쯤은 받아들일 수 있을 것이다.
그러나 둘 중 어디에도 해당하지 않는다면 비검사 예외를 사용하는게 좋다.
검사 예외와 비검사 예외 중 어느 것을 선택해야 할지는 프로그래머가 그 예외를 어떻게 다룰지 생각해보면 알 수 있다.
검사 예외가 프로그래머에게 지우는 부담은 메서드가 단 하나의 검사 예외만 던질 때가 특히 크다.
이미 다른 검사 예외도 던지는 상황에서 또 다른 검사 예외를 추가하는 경우라면 기껏해야 catch문 하나 추가하는 선에서 끝이다.
하지만 검사 예외가 단 하나뿐이라면 오직 그 예외 때문에 API 사용자는 try 블록을 추가해야 하고 스트림에서 직접 사용하지 못하게 된다.
그러니 이런 상황이라면 검사 예외를 안 던지는 방법이 없는지 고민해볼 가치가 있다.
검사 예외를 회피하는 가장 쉬운 방법은 적절한 결과 타입을 담은 옵셔널을 반환하는 것이다.
검사 예외를 던지는 대신 단순히 빈 옵셔널을 반환하면 된다.
이 방식의 단점이라면 예외가 발생한 이유를 알려주는 부가 정보를 담을 수 없다는 것이다.
반면, 예외를 사용하면 구체적인 예외 타입과 그 타입이 제공하는 메서드들을 활용해 부가 정보를 제공할 수 있다.
또 다른 방법으로, 검사 예외를 던지는 메서드를 2개로 쪼개 비검사 예외로 바꿀 수 있다.
이 방식에서 첫 번째 메서드는 예외가 던져질지 여부를 boolean 값으로 반환한다.
72. 표준 예외를 사용하라
숙련된 프로그래머는 그렇지 못한 프로그래머보다 더 많은 코드를 재사용한다.
예외도 마찬가지로 재사용하는 것이 좋으며, 자바 라이브러리는 대부분 API에서 쓰기에 충분한 수의 예외를 제공한다.
표준 예외를 재사용하면 얻는 게 많다. 그중 최고는 여러분의 API가 다른 사람이 익히고 사용하기 쉬워진다는 것이다.
많은 프로그래머에게 이미 익숙해진 규약을 그대로 따르기 때문이다.
여러분의 API를 사용한 프로그램도 낯선 예외를 사용하지 않게 되어 읽기 쉽게 된다는 장점도 크다.
마지막으로, 예외 클래스 수가 적을수록 메모리 사용량도 줄고 클래스를 적재하는 시간도 적게 걸린다.
가장 많이 재사용되는 예외는 IllegalArgumentException이다.
호출자가 인수로 부적절한 값을 넘길 때 던지는 예외로, 예를 들어 반복 횟수를 지정하는 매개변수에 음수를 건넬 때 쓸 수 있다.
IllegalStateException도 자주 재사용된다.
이 예외는 대상 객체의 상태가 호출된 메서드를 수행하기에 적합하지 않을 때 주로 던진다.
예컨대 제대로 초기화되지 않은 객체를 사용하려 할 때 던질 수 있다.
Exception, RuntimeException, Throwable, Error는 직접 재사용하지 말자.
이 클래스들은 추상 클래스라고 생각하길 바란다.
이 예외들은 다른 예외들의 상위 클래스이므로, 즉 여러 성격의 예외들을 포괄하는 클래스이므로 안정적으로 테스트할 수 없다.
다음 표에 지금까지 설명한 널리 재사용되는 예외들을 정리해보았다.
| 예외 | 주요 쓰임 |
|---|---|
| IllegalArgumentException | 허용하지 않는 값이 인수로 건네졌을때(null은 따로 NullPointerException으로 처리) |
| IllegalStateException | 객체가 메서드를 수행하기에 적절하지 않은 상태일때 |
| NullPointerException | null을 허용하지 않는 메서드에 null을 건넸을때 |
| IndexOutOfBoundsException | 인덱스가 범위를 넘어섰을 때 |
| ConcurrentModificationException | 허용하지 않는 동시 수정이 발견됐을 때 |
| UnsupportedOperationException | 호출한 메서드를 지원하지 않을 때 |
73. 추상화 수준에 맞는 예외를 던지라
수행하려는 일과 관련 없어 보이는 예외가 튀어나오면 당황스러울 것이다.
메서드가 저수준 예외를 처리하지 않고 바깥으로 전파해버릴 때 종종 일어나는 일이다.
사실 이는 단순히 프로그래머를 당황시키는 데 그치지 않고, 내부 구현 방식을 드러내어 윗 레벨 API를 오염시킨다.
다음 릴리스에서 구현 방식을 바꾸면 다른 예외가 튀어나와 기존 클라이언트 프로그램을 깨지게 할 수도 있는 것이다.
이 문제를 피하려면 상위 계층에서는 저수준 예외를 잡아 자신의 추상화 수준에 맞는 예외로 바꿔 던져야 한다.
이를 ‘예외 번역’이라 한다.
예외를 번역할 때, 저수준 예외가 디버깅에 도움이 된다면 예외 연쇄를 사용하는게 좋다.
예외 연쇄란 문제의 근본 원인인 저수준 예외를 고수준 예외에 실어 보내는 방식이다.
그러면 별도의 접근자 메서드(Throwable의 getCause 메서드)를 통해 필요하면 언제든 저수준 예외를 꺼내 볼 수 있다.
try {
...
} catch (LowerLevelException cause) {
throw new HigherLevelException(cause);
}
고수준 예외의 생성자는 (예외 연쇄용으로 설계된) 상위 클래스의 생성자에 이 ‘원인’을 건네주어, 최종적으로 Throwable(Throwable) 생성자까지 건네지게 된다.
대부분의 표준 예외는 예외 연쇄용 생성자를 갖추고 있다.
그렇지 않은 예외라고 Throwable의 initCause 메서드를 이용해 ‘원인’을 직접 못박을 수 있다.
예외 연쇄는 문제의 원인을 (getCause 메서드로) 프로그램에서 접근할 수 있게 해주며, 원인과 고수준 예외의 스택 추적 정보를 잘 통합해준다.
무턱대고 예외를 전파하는 것보다야 예외 번역이 우수한 방법이지만, 그렇다고 남용해서는 곤란하다.
가능하다면 저수준 메서드가 반드시 성공하도록하여 아래 계층에서는 예외가 발생하지 않도록 하는 것이 최선이다.
때론 상위 계층 메서드의 매개변수 값을 아래 계층 메서드로 건네기 전에 미리 검사하는 방법으로 이 목적을 달성할 수 있다.
74. 메서드가 던지는 모든 예외를 문서화하라
메서드가 던지는 예외는 그 메서드를 올바로 사용하는 데 아주 중요한 정보다.
따라서 각 메서드가 던지는 예외 하나하나를 문서화하는 데 충분한 시간을 쏟아야 한다.
검사 예외는 항상 따로따로 선언하고, 각 예외가 발생하는 상황을 자바독의 @throws 태그를 사용하여 정확히 문서화하자.
공통 상위 클래스 하나로 뭉뚱그려 선언하는 일은 삼가자.
극단적인 예로 메서드가 Exception이나 Throwable을 던진다고 선언해서는 안 된다.
이 규칙에 유일한 예외가 있다면 바로 main 메서드다. main은 오직 JVM만이 호출하므로 Exception을 던지도록 선언해도 괜찮다.
자바 언어가 요구하는 것은 아니지만 비검사 예외도 검사 예외처럼 정성껏 문서화해두면 좋다.
75. 예외의 상세 메시지에 실패 관련 정보를 담으라
예외를 잡지 못해 프로그램이 실패하면 자바 시스템은 그 예외의 스택 추적 정보를 자동으로 출력한다.
스택 추적은 예외 객체의 toString 메서드를 호출해 얻는 문자열로, 보통은 예외의 클래스 이름 뒤에 상세 메시지가 붙는 형태다.
이 정보가 실패 원인을 분석해야 하는 프로그래머 혹은 사이트 신뢰성 엔지니어가 얻을 수 있는 유일한 정보인 경우가 많다.
따라서 예외의 toString 메서드에 실패 원인에 관한 정보를 가능한 한 많이 담아 반환하는 일은 아주 중요하다.
달리 말하면, 사후 분석을 위해 실패 순간의 상황을 정확히 포착해 예외의 상세 메시지에 담아야 한다.
실패 순간을 포착하려면 발생한 예외에 관여된 모든 매개변수와 필드의 값을 실패 메시지에 담아야 한다.
76. 가능한 한 실패 원자적으로 만들라
작업 도중 예외가 발생해도 그 객체는 여전히 정상적으로 사용할 수 있는 상태라면 멋지지 않은가?
검사 예외를 던진 경우라면 호출자가 오류 상태를 복구할 수 있을 테니 특히 더 유용할 것이다.
일반화해 이야기하면, 호출된 메서드가 실패하더라도 해당 객체는 메서드 호출 전 상태를 유지해야 한다.
이러한 특성을 실패 원자적이라고 한다.
메서드를 실패 원자적으로 만드는 방법은 다양하다.
가장 간단한 방법은 불변 객체로 설계하는 것이다.
불변 객체는 태생적으로 실패 원자적이다.
메서드가 실패하면 새로운 객체가 만들어지지 않을 수 있으나 기존 객체가 불안정한 상태에 빠지는 일은 결코 없다.
불변 객체의 상태는 생성 시점에 고정되어 절대 변하지 않기 때문이다.
가변 객체의 메서드를 실패 원자적으로 만드는 가낭 흔한 방법은 작업 수행에 앞어 매개변수의 유효성을 검사하는 것이다.
객체 내부 상태를 변경하기 전에 잠재적 예외의 가능성 대부분을 걸러낼 수 있는 방법이다.
세 번재 방법은 객체의 임시 복사본에서 작업을 수행한 다음, 작업이 성공적으로 완료되면 원래 객체와 교체하는 것이다.
마지막 방법은 작업 도중 발생하는 실패를 가로채는 복구 코드를 작성하여 작업 전 상태로 되돌리는 방법이다.
주로 (디스크 기반의) 내구성을 보장해야 하는 자료구조에 쓰이는데, 자주 쓰이는 방법은 아니다.
77. 예외를 무시하지 말라
예외는 문제 상황에 잘 대처하기 위해 존재하는데 catch 블록을 비워두면 예외가 존재할 이유가 없어진다.
운이 좋아 별 탈이 없으면 다행이지만 끔찍한 참사로 이어질 수도 있으니, 빈 catch 블록을 목격한다면 여러분 머릿속에 사이렌을 울려야 한다.
물론 예외를 무시해야 할 때도 있다. (예 : FileInputStream을 닫을 때)