10장 예외
예외를 제대로 활용한다면 프로그램의 가독성, 신뢰성, 유지보수성이 높아지지만, 잘못 사용하면 반대의 효과만 나타난다.
목차
아이템69. 예외는 진짜 예외 상황에만 사용하라
69-1. 예외를 잘못 사용하는 경우
try {
int i = 0;
while (true)
range[i++].climb();
} catch (ArrayIndexOutOfBoundsException e) {
...
}
무한루프를 돌다가 배열의 끝에서 ArrayIndexOutOfBoundsException
이 발생하면 끝을 내는 아주 끔찍한 방식이다.
JVM 은 배열에 접근할 때마다 경계를 넘지 않는지 검사하는데, 일반적인 반복문도 배열경계에 도달하면 종료한다.
for (Mountain m : range)
m.climb();
위와 같이 작성하면 된다.
예외는 (그 이름이 말해주듯) 오직 예외 상황에서만 써야 한다. 절대로 일상적인 제어 흐름용으로 쓰여선 안 된다. 즉, 표준적이고 쉽게 이해되는 관용구를 사용하고, 성능 개선을 목적으로 과하게 머리를 쓴 기법은 자제하라.
이 원칙은 API 설계에도 적용된다. 잘 설계된 API 라면 클라이언트가 정상적인 제어 흐름에서 예외를 사용할 일이 없게 해야 한다.
69-2. 상태 검사 메서드, 옵셔널, 특정 값 중 하나를 선택하는 지침
-
외부 동기화 없이 여러 스레드가 동시에 접근할 수 있거나 외부 요인으로 상태가 변할 수 있다면 옵셔널이나 특정 값을 사용한다. 상태 검사 메서드와 상태 의존적 메서드 호출 사이에 객체의 상태가 변할 수 있기 때문이다.
-
성능이 중요한 상황에서 상태 검사 메서드가 상태 의존적 메서드의 작업 일부를 중복 수행한다면 옵셔널이나 특정 값을 선택해야 한다.
-
다른 모든 경우엔 상태 검사 메서드 방식이 조금 더 낫다고 할 수 있다. 가독성이 살짝 더 좋고, 잘못 사용했을 때 발견하기가 쉽다. 상태 검사 메서드 호출을 깜빡 잊었다면 상태 의존적 메서드가 예외를 던져 버그를 확실히 드러낼 것이다. 반면 특정 값은 검사하지 않고 지나쳐도 발견하기가 어렵다(옵셔널에는 해당하지 않는 문제다.)
아이템70. 복구할 수 있는 상황에는 검사 예외를, 프로그래밍 오류에는 런타임 예외를 사용하라
자바는 문제 상황을 알리는 타입(throwable)으로 검사 예외, 런타임 예외, 에러를 제공한다. 하지만 이것들을 헷갈려하는 프로그래머가 종종 있다.
70-1. 구별법
-
호출하는 쪽에서 복구하리라 여겨지는 상황이라면 검사 예외를 사용하라. 검사 예외를 던지면
try-catch
로 처리하거나throw
를 이용하여 더 바깥으로 전파하도록 강제하게 된다. 따라서 메서드를 호출했을 때 발생할 수 있는 유력한 결과임을 API 사용자에게 알려주는 것이다. -
비검사 예외는 런타임 에러와 에러가 있다. 프로그램에서 잡을 필요가 없거나 잡아도 득보다 실이 많은 경우다. 아예 복구가 불가능할 수도 있다.
❗Exception
, RuntimeException
, Error
클래스를 상속하지 않는 throwable
구현은 절대 만들지 말자!
구분 | Checked Exception | Unchecked Exception |
---|---|---|
확인 시점 | 컴파일(Compile) 시점 | 런타임(Runtime) 시점 |
처리 여부 | 반드시 예외 처리해야 한다. | 명시적으로 하지 않아도 된다. |
트랜잭션 처리 | 예외 발생시 롤백(rollback)하지 않음 | 예외 발생시 롤백(rollback)해야 함. |
종류 | IOException , ClassNotFoundException 등 |
NullPointerException ,ClassCastException 등 |
아이템71. 필요 없는 검사 예외 사용은 피하라
검사 예외는 발생한 문제를 개발자가 처리하여 안전성을 높인다. 따라서 제대로 활용하면 API 와 프로그램의 질을 높일 수 있다. 하지만 과하게 사용하는 경우 오히려 사용하기 불편한 API 가 될 수 있다.
71-1. 검사 예외를 회피하는 방법
-
적절한 결과 타입을 담은 옵셔널을 반환하는 것이다.
- 예외가 발생한 이유를 알려주는 부가 정보를 담을 수 없음.
-
검사 예외를 던지는 메서드를 2개로 쪼개 비검사 예외로 바꿀 수 있다.
try {
obj.action(args);
} catch (TheCheckedException e) {
// 예외 상황에 대처한다.
}
위의 코드를 리팩토링하면
if (obj.actionPermitted(args)) {
obj.action(args);
} else {
// 예외 상황에 대처한다.
}
리팩토링 후의 API 가 딱히 더 아름답진 않지만, 더 유연하다.
결론: 꼭 필요한 곳에만 사용한다면 검사 예외는 프로그램의 안전성을 높여주지만, 남용하면 쓰기 고통스러운 API 를 낳는다. API 호출자가 예외 상황에서 복구할 방법이 없다면 비검사 예외를 던지자. 복구가 가능하고 호출자가 그 처리를 해주길 바란다면, 우선 옵셔널을 반환해도 될지 고민하자. 옵셔널만으로는 상황을 처리하기에 충분한 정보를 제공할 수 없을 때만 검사 예외를 던지자.
아이템72. 표준 예외를 사용하라
72-1. 표준 예외를 재사용할 때 장점
- 여러분의 API 가 다른 사람이 익히고 사용하기 쉬워진다
- API 를 사용한 프로그램도 낯선 예외를 사용하지 않게 되어 읽기 쉽게 된다.
- 예외 클래스 수가 적을수록 메모리 사용량도 줄고 클래스를 적재하는 시간도 적게 걸린다.
72-2. 자주 재사용되는 표준 예외
-
IllegalArgumentException
- 호출자가 인수로 부적절한 값을 넘길 때 던지는 예외
null
을 건네면 관례상NullPointerException
을 던진다.
-
IllegalStateException
- 객체의 상태가 호출된 메서드를 수행하기에 적합하지 않을 때 주로 던진다.
- 어떤 시퀀스의 허용 범위를 넘는다면
IndexOoutOfBoundsException
을 던진다.
-
ConcurrentModificationException
- 단일 스레드에서 사용하려고 설계한 객체를 여러 스레드가 동시에 수정하려 할 때 던진다.
-
UnsupportedOperationException
- 클라이언트가 요청한 동작을 대상 객체가 지원하지 않을 때 던진다.
Exception
, RuntimeException
, Throwable
, Error
는 직접 재사용하지 말자. 이 클래스들은 추상 클래스라고 생각하길 바란다.
아이템73. 추상화 수준에 맞는 예외를 던져라
수행하려는 일과 관련 없어 보이는 예외가 튀어나오면 당황스러울 것이다. 메서드가 저수준 예외를 처리하지 않고 바깥으로 전파해버릴 때 종종 일어나는 일이다. 이는 예외 번역 기법으로 해결할 수 있다.
73-1. 예외번역
예외 번역: 상위 계층에서는 저수준 예외를 잡아 자신의 추상화 수준에 맞는 예외로 바꿔 던지는 기법
try {
// 저수준 추상화를 이용한다.
} catch (LowerLevelException e) {
// 추상화 수준에 맞게 번역한다.
throw new HigherLevelException(...);
}
예외를 번역할 때, 저수준 예외가 디버깅에 도움이 된다면 예외 연쇄(exception chaining)를 사용하는 게 좋다.
73-2. 예외연쇄
예외 연쇄: 문제의 근본 원인(cause)인 저수준 예외를 고수준 예외에 실어 보내는 방식
- 별도의 접근자 메서드(
Throwable
의getCause
메서드)를 통해 필요하면 언제든 저수준 예외를 꺼내 볼 수 있다.
try {
// 저수준 추상화를 이용한다.
} catch (LowerLevelException cause) {
// 추상화 수준에 맞게 번역한다.
throw new HigherLevelException(cause);
}
고수준 예외의 생성자는 (예외 연쇄용으로 설계된) 상위 클래스의 생성자에 이 원인을 건네주어, 최종적으로 Throwable 생성자까지 건네지게 한다.
무턱대고 예외를 전파하는 것보다야 예외 번역이 우수한 방법이지만, 그렇다고 남용해서는 곤란하다. 가능하다면 저수준 메서드가 반드시 성공하도록 하여 아래 계층에서는 예외가 발생하지 않도록 하는 것이 최선이다.
아이템74. 메서드가 던지는 모든 예외를 문서화하라
메서드가 던지는 예외는 그 메서드를 올바로 사용하는 데 아주 중요한 정보다. 따라서 각 메서드가 던지는 예외 하나하나를 문서화하는 데 충분한 시간을 쏟아야 한다.(아이템56)
-
검사 예외는 항상 따로따로 선언하고, 각 예외가 발생하는 상황을 자바독의
@throws
태그를 사용하여 정확히 문서화하자.- 비검사 예외도 정성껏 문서화해두면 좋다. => 프로그래머는 자연스럽게 해당 오류가 나지 않도록 코딩하게 된다.
-
메서드가 던질 수 있는 예외를 각각
@throws
태그로 문서화하되, 비검사 예외는 메서드 선언의 throws 목록에 넣지 말자. -
한 클래스에 정의된 많은 메서드가 같은 이유로 같은 예외를 던진다면 그 예외를(각각의 메서드가 아닌) 클래스 설명에 추가하는 방법도 있다.
-
클래스의 문서화 주석에 "이 클래스의 모든 메서드는
null
이 넘어오면NullPointerException
을 던진다"라고 적어도 좋다.
-
아이템75. 예외의 상세 메시지에 실패 관련 정보를 담으라
예외를 잡지 못해 프로그램이 실패하면 자바 시스템은 그 예외의 스택 추적 정보를 자동으로 출력한다.
- 스택 추적: 예외 객체의
toString
메서드를 호출해 얻는 문자열(예외 클래스 이름 뒤에 상세 메시지가 붙는 형태)
스택 추적 정보는 유일한 정보인 경우가 많다 즉, 사후 분석을 위해 실패 순간의 상황을 정확히 포착해 예외의 상세 메시지에 담아야 한다.
75-1. 스택 추적 정보 담기
실패 순간을 포착하려면 발생한 예외에 관여된 모든 매개변수와 필드의 값을 실패 메시지에 담아야 한다.
- ex)
IndexOutOfBoundsException
의 상세메시지: 범위의 최솟값과 최댓값, 범위를 벗어났다는 인덱스의 값을 담아야함.
❗ 보안과 관련한 정보는 주의해서 다뤄야한다. 스택 추적 정보는 많은 사람이 볼 수 있으므로 상세 메시지에 비밀번호나 암호 키 같은 정보까지 담아서는 안된다.
관련 데이터를 모두 담아야 하지만 장황할 필요는 없다.
- 스택 추적에는 예외가 발생한 파일 이름과 줄번호는 물론 스택에서 호출한 다른 메서드들의 파일 이름과 줄번호까지 정확히 기록되어 있는 게 보통이다. 즉, 문서와 소스코드에서 얻을 수 있는 정보는 길게 늘어놔봐야 군더더기가 될 뿐이다.
예외의 상세 메시지와 최종사용자에게 보여줄 오류 메시지를 혼동해서는 안된다.
- 최종사용자에게 보여줄 오류메시지: 친절한 안내 메시지 및 현지어 번역
- 예외 메시지: 가독성보다는 담긴 내용이 훨씬 중요하며, 현지어 변역하는 경우는 거의 없다.
실패를 적절히 포착하려면 필요한 정보를 예외 생성자에서 모두 받아서 상세 메시지까지 미리 생성해놓는 방법도 괜찮다.
public IndexOutOfBoundsException(int lowerBound, int upperBound, int index){
// 실패를 포착하는 상세 메시지를 생성한다.
super(String.format("최솟값: %d, 최댓값: %d, 인덱스: %d", lowerBound, upperBound, index));
// 프로그램에서 이용할 수 있도록 실패 정보를 저장해둔다.
this.lowerBound = lowerBound;
this.upperBound = upperBound;
this.index = index;
}
현재의 IndexOutOfBoundsException
생성자는 String
을 받지만, 위와 같이 구현했어도 좋았을 것이다.
- 자바9에서는 정수 인덱스 값을 받는 생성자가 추가되었지만, 최솟값과 최댓값까지 받지는 않는다.
포착한 실패 정보는 예외 상황을 복구하는 데 유용할 수 있으므로 접근자 메서드는 비검사 예외보다는 검사 예외에서 더 빛을 발한다.
아이템76. 가능한 한 실패 원자적으로 만들라
실패원자적 - 호출된 메서드가 실패하더라도 해당 객체는 메서드 호출 전 상태를 유지해야 한다.
76-1. 메서드를 실패 원자적으로 만드는 방법
- 불변객체로 설계
- 불변객체는 태생적으로 실패 원자적이다.
- 메서드가 실패하면 새로운 객체가 만들어지지는 않을 수 있으나 기존 객체가 불안정한 상태에 빠지는 일은 결코 없다. 불변 객체의 상태는 생성 시점에 고정되어 절대 변하지 않기 때문이다.
- 작업 수행에 앞서 매개변수의 유효성을 검사하는 것
- 객체의 내부 상태를 변경하기 전에 잠재적 예외의 가능성 대부분을 걸러낼 수 있는 방법이다.
public Object pop(){
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
위 코드에서 size를 체크하지 않더라도 스택이 비었다면 여전히 예외를 던진다. 다만 size의 값이 음수가 되어 다음번 호출도 실패하게 만들며, 이 때 던지는 ArrayIndexOutOfBoundsException
은 추상화 수준이 상황에 어울리지 않다고 볼 수 있다.(아이템73)
- 객체의 임시 복사본에서 작업을 수행한 다음, 작업이 성공적으로 완료되면 원래 객체와 교체하는 것
- 데이터를 임시 자료구조에 저장해 작업하는 게 더 빠를 때 적용하기 좋은 방식이다.
- 작업 도중 발생하는 실패를 가로채는 복구 코드를 작성하여 작업 전 상태로 되돌리는 방법
- 주로 (디스크 기반의) 내구성(durability)을 보장해야 하는 자료구조에 쓰이는데, 자주 쓰이는 방법은 아니다.
✔ 실패 원자성은 항상 달성할 수 있는 것은 아니다. 더불어, 만들 수 있더라도 항상 그리 해야 하는 것도 아니다.
아이템77. 예외를 무시하지 말라
API 설계자가 메서드 선언에 예외를 명시하는 까닭은, 그 메서드를 사용할 때 적절한 조치를 취해달라고 말하는 것이다.
// catch 블록을 비워두면 예외가 무시된다. 아주 의심스러운 코드다!
try{
...
} catch (SomeException e){
}
catch 블록을 비워두면 예외가 존재할 이유가 없어진다.
77-1. 예외를 무시해야 할 때
ex) FileInputStream
닫을 때
- (입력 전용 스트림이므로) 파일의 상태를 변경하지 않았으니 복구할 것이 없음.
- (스트림을 닫는다는 건) 필요한 정보는 이미 다 읽었다는 뜻이니 남은 작업을 중단할 이유도 없다.
Future<Integer> f = exec.submit(planarMap::chromatidNumber);
int numColors = 4; // 기본값. 어떤 지도라도 이 값이면 충분하다.
try{
numcolors = f.get(1L, TimeUnit.SECONDS);
}catch(TimeoutException | ExecutionException ignored){
// 기본값을 사용한다(색상 수를 최소화하면 좋지만, 필수는 아니다.)
}
예외를 무시하기로 했다면 catch
블록 안에 그렇게 결정한 이유를 주석으로 남기고 예외 변수의 이름도 ignored
로 바꿔놓도록 하자.