“예외를 정상적인 제어 흐름에서 사용해서는 안 된다.”
// 예외를 완전히 잘못 사용한 예
try {
int i = 0;
while(true)
range[i++].climb();
} catch(ArrayIndexOutOfBoundsException e) {
}
- 위의 예시는 아주 끔찍한 코드임. 무한 루프를 돌다가 배열의 끝에 도달해
ArrayIndexOutOfBoundsException
이 발생하면 끝을 내는 것. - 이 코드는 잘못된 추론을 근거로 성능을 높여보려 한 사례임.
- JVM은 배열에 접근할 때마다 경계를 넘지 않는지 검사하는데, 일반적인 반복문도 배열 경계에 도달하면 종료한다. → 따라서 이 검사를 반복문에도 명시하면 같은 일이 중복되므로 하나를 생략한 것
- 이 추론은 세 가지 면에서 잘못된 추론임.
- 예외는 예외 상황에 쓸 용도로 설계 되었으므로 JVM 구현자 입장에서는 명확한 검사만큼 빠르게 만들어야 할 동기가 약함. (최적화에 별로 신경 쓰지 않았을 가능성이 큼)
- 코드를
try-catch
블록 안에 넣으면 JVM이 적용할 수 있는 최적화가 제한됨. - 배열을 순회하는 표준 관용구는 앞서 걱정한 중복 검사를 수행하지 않음. JVM이 알아서 최적화해 없애줌.
→ 교훈 : 예외는 (그 이름이 말해주듯) 오직 예외 상황에서만 써야 한다. 절대로 일상적인 제어 흐름용으로 쓰여선 안 된다.
→ 잘 설계된 API라면 클라이언트가 정상적인 제어 흐름에서 예외를 사용할 일이 없게 해야 한다.
- 상태 의존적 메서드 ex)
Iterator
인터페이스의next
메서드 - 상태 검사 메서드 ex)
Iterator
인터페이스의hasNext
메서드
- 외부 동기화 없이 여러 스레드가 동시에 접근할 수 있거나 외부 요인으로 상태가 변할 수 있다면 옵셔널이나 특정 값을 사용한다. → 상태 검사 메서드와 상태 의존적 메서드 호출 사이에 객체의 상태가 변할 수 있기 때문
- 성능이 중요한 상황에서 상태 검사 메서드가 상태 의존적 메서드의 작업 일부를 중복 수행한다면 옵셔널이나 특정 값을 선택한다.
- 다른 모든 경우엔 상태 검사 메서드 방식이 조금 더 낫다고 할 수 있다. 가독성이 살짝 더 좋고, 잘못 사용했을 때 발견하기가 쉽다. 상태 검사 메서드 호출을 깜빡 잊었다면 상태 의존적 메서드가 예외를 던져 버그를 확실히 드러낼 것임. → 반면 특정 값은 검사하지 않고 지나쳐도 발견하기가 어렵다(옵셔널은 해당하지 않는 문제).
“복구할 수 있는 상황 → 검사 예외 / 프로그래밍 오류 또는 확실하지 않을 때 → 비검사 예외”
- 일반적으로 복구할 수 있는 조건일 때 발생
- 사용 지침 : 호출하는 쪽에서 복구하리라 여겨지는 상황이라면 검사 예외를 사용하라
- 검사 예외를 던지면 호출자가 그 예외를
catch
로 잡아 처리하거나 더 바깥으로 전파하도록 강제하게 됨.
→ 메서드 선언에 포함된 검사 예외 각각은 그 메서드를 호출했을 때 발생할 수 있는 유력한 결과임을 API사용자에게 알려주는 것. (API 사용자에게 그 상황에서 회복해내라고 요구)
- 비검사 예외는 두 가지로, 각각 런타임 예외와 에러다. 이 둘은 프로그램에서 잡을 필요가 없거나 통상적으로 잡지 말아야 함.
- 프로그램에서 비검사 예외나 에러를 던졌다는 것은 복구가 불가능하거나 더 실행해봐야 득보다는 실이 많다는 뜻.
- 이런
throwable
을 잡지 않은 스레드는 적절한 오류 메시지를 내뱉으며 중단됨. - 사용 지침
- 프로그래밍 오류를 나타낼 때는 런타임 예외를 사용하자
- 런타임 예외의 대부분은 전제조건을 만족하지 못했을 때 발생. ex)
ArrayIndexOutOfBoundsException
- 런타임 예외의 대부분은 전제조건을 만족하지 못했을 때 발생. ex)
- 에러는 보통 JVM이 자원 부족, 불변식 깨짐 등 더이상 수행을 계속할 수 없는 상황을 나타낼 때 사용
- 비검사
throwable
은 모두RuntimeException
의 하위 클래스여야 함. (Error
는 상속하지 말아야 할 뿐 아니라,throw
문으로 직접 던지는 일도 없어야 함.)
- 프로그래밍 오류를 나타낼 때는 런타임 예외를 사용하자
“옵셔널만으로는 상황을 처리하기에 충분한 정보를 제공할 수 없을 때만 검사 예외를 던지자”
- 어떤 메서드가 검사 예외를 던질 수 있다고 선언됐다면, 이를 호출하는 코드에서는
catch
블록을 두어 그 예외를 붙잡아 처리하거나 더 바깥으로 던져 문제를 전파해야 함. → 어느 쪽이든 API 사용자에게 부담을 줌. - 검사 예외가 프로그래머에게 지우는 부담은 메서드가 단 하나의 검사 예외만 던질 때가 특히 큼.
- 검사 예외가 단 하나뿐이라면 오직 그 예외 때문에 API 사용자는
try
블록을 추가해야 하고 스트림에서 직접 사용하지 못하게 됨.
- 검사 예외가 단 하나뿐이라면 오직 그 예외 때문에 API 사용자는
→ 검사 예외를 안 던지는 방법이 없는지 고민해볼 가치가 있다.
- 적절한 결과 타입을 담은 옵셔널을 반환
- 검사 예외를 던지는 대신 단순히 빈 옵셔널을 반환.
- 이 방식의 단점은 예외가 발생한 이유를 알려주는 부가정보를 담을 수 없다는 것.
- 메서드를 2개로 쪼개 비검사 예외로 바꾸기
// 검사 예외를 던지는 메서드 - 리팩터링 전
try {
obj.action(args);
} catch(TheCheckedException e) {
... // 예외 상황에 대처한다.
}
// 상태 검사 메서드와 비검사 예외를 던지는 메서드 - 리팩터링 후
if(obj.actionPermitted(args)) {
obj.action(args);
} else {
... // 예외 상황에 대처한다.
}
“표준 예외를 재사용하면 API가 다른 사람이 익히고 사용하기 쉬워짐”
예외 | 주요 쓰임 |
---|---|
IllegalArgumentException | 허용하지 않는 값이 인수로 건네졌을 때(null은 따로 NullPointerException으로 처리) |
IllegalStateException | 객체가 메서드를 수행하기에 적절하지 않은 상태일 때 |
NullPointerException | null을 허용하지 않는 메서드에 null을 건넸을 때 |
IndexOutOfBoundsException | 인덱스가 범위를 넘어섰을 때 |
ConcurrentModificationException | 허용하지 않는 동시 수정이 발견됐을 때 |
UnsupportedOperationException | 호출한 메서드를 지원하지 않을 때 |
- 상황에 부합한다면 항상 표준 예외를 재사용하자
※ Exception
, RuntimeException
, Throwable
, Error
는 직접 재사용하지 말자
-
위의 표로 정리한 ‘주요 쓰임’이 상호 배타적이지 않은 탓에, 종종 재사용할 예외를 선택하기가 어려울 때도 있음
-
ex) 카드 덱을 표현하는 객체가 있고, 인수로 건넨 수만큼의 카드를 뽑아 나눠주는 메서드를 제공한다고 해보자. 이때 덱에 남아 있는 카드 수보다 큰 값을 건네면 어떤 예외를 던져야 할까?
- 인수의 값이 너무 크다고 본다면
IllegalArgumentException
, 덱에 남은 카드 수가 너무 적다고 보면IllegalStateException
를 선택할 것임.
- 인수의 값이 너무 크다고 본다면
-
위의 상황에서 일반적인 규칙은 아래와 같다.
- 인수 값이 무엇이었든 어차피 실패했을거라면
IllegalStateException
, 그렇지 않으면IllegalArgumentException
을 던지자.
- 인수 값이 무엇이었든 어차피 실패했을거라면
“아래 계층의 예외를 예방하거나 스스로 처리할 수 없고, 그 예외를 상위 계층에 그대로 노출하기 곤란하다면 예외 번역을 사용하라”
- 예외 번역(excepton translation) : 상위 계층에서 저수준 예외를 잡아 자신의 추상화 수준에 맞는 예외로 바꿔 던지는 것
try {
... // 저수준 추상화를 이용한다.
} catch(LowerLevelException e) {
// 추상화 수준에 맞게 번역한다.
throw new HigherLevelException(...);
}
- 예외 연쇄(exception chaining) : 문제의 근본 원인(cause)인 저수준 예외를 고수준 예외에 실어 보내는 방식.
try {
... // 저수준 추상화를 이용한다.
} catch(LowerLevelException cause) {
// 저수준 예외를 고수준 예외에 실어 보낸다.
throw new HigherLevelException(cause);
}
// 예외 연쇄용 생성자
class HigherLevelException extends Exception {
HigherLevelException(Throwable cause) {
super(cause);
}
}
- 고수준 예외의 생성자는 (예외 연쇄용으로 설계된) 상위 클래스의 생성자에 이 ‘원인’을 건네주어, 최종적으로
Throwable(Throwable)
생성자까지 건네지게 한다.
→ 무턱대고 예외를 전파하는 것보다야 예외 번역이 우수한 방법이지만, 그렇다고 남용해서는 곤란함.
- 가능하다면 저수준 메서드가 반드시 성공하도록 하여 아래 계층에서는 예외가 발생하지 않도록 하는 것이 최선
- 때론 상위 계층의 메서드의 매개변수 값을 아래 계층 메서드로 건네기 전에 미리 검사하는 방법으로 이 목적을 달성할 수 있음
- 아래 계층에서 예외를 피할 수 없다면, 상위 계층에서 그 예외를 조용히 처리하여 문제를 API 호출자에까지 전파하지 않는 방법.
- 이 경우 발생한 예외는
java.util.logging
같은 적절한 로깅 기능을 활용하여 기록해두면 좋음.
- 이 경우 발생한 예외는
“발생 가능한 예외를 문서로 남기지 않으면 다른 사람이 그 클래스나 인터페이스를 효과적으로 사용하기 어렵거나 심지어 불가능할 수도 있다.”
- 검사 예외는 항상 따로따로 선언하고, 각 예외가 발생하는 상황을 자바독의
@throw
태그를 사용하여 정확히 문서화하자 public
메서드라면 필요한 전제조건을 문서화해야 하며, 그 수단으로 가장 좋은 것이 바로 비검사 예외들을 문서화하는 것임.- 메서드가 던질 수 있는 예외를 각각
@throw
태그로 문서화하되, 비검사 예외는 메서드 선언의throws
목록에 넣지 말자.- 검사 예외 : 메서드 선언의
thorws
절에 등장 O && 메서드 주석의@throw
태그에 명시 O - 비검사 예외 : 메서드 선언의
thorws
절에 등장 X && 메서드 주석의@throw
태그에 명시 O - 이 조건을 만족하여 작성하면 자바독 유틸리티가 시각적으로 구분해줌.
- 검사 예외 : 메서드 선언의
- 한 클래스에 정의된 많은 메서드가 같은 이유로 같은 예외를 던진다면 그 예외를 (각각의 메서드가 아닌) 클래스 설명에 추가하는 방법도 있음.
- ex) “이 클래스의 모든 메서드는 인수로
null
이 넘어오면NullPointerException
을 던진다.”
- ex) “이 클래스의 모든 메서드는 인수로
“사후 분석을 위해 실패 순간의 상황을 정확히 포착해 예외의 상세 메시지에 담아야 함”
- 예외를 잡지 못해 프로그램이 실패하면 자바 시스템은 그 예외의 스택 추적(stack trace) 정보를 자동으로 출력함.
- 스택 추적은 예외 객체의
toString
메서드를 호출해 얻는 문자열로, 보통은 예외의 클래스 이름 뒤에 상세 메시지가 붙는 형태.
- 스택 추적은 예외 객체의
- 실패 순간을 포착하려면 발생한 예외에 관여된 모든 매개변수와 필드의 값을 실패 메시지에 담아야 함.
- ex)
IndexOutOfBoundsException
의 상세 메시지는 범위의 최솟값과 최댓값, 그리고 그 범위를 벗어났다는 인덱스의 값을 담아야 함. - but, 상세 메시지에 비밀번호나 암호 키 같은 정보까지 담아서는 안 됨.
- ex)
- 예외의 상세 메시지와 최종 사용자에게 보여줄 오류 메시지를 혼동해서는 안 됨.
- 최종 사용자에게는 친절한 메시지로 작성, 예외 메세지는 주 소비층이 프로그래머임을 고려하여 작성.
- 예외는 실패와 관련된 정보를 얻을 수 있는 접근자 메서드를 적절히 제공하는 것이 좋음.
“메서드 명세에 기술한 예외라면 설혹 예외가 발생하더라도 객체의 상태는 메서드 호출 전과 똑같이 유지돼야 한다는 것이 기본 규칙”
- 실패 원자적(failure-atomic) : 호출된 메서드가 실패하더라도 해당 객체는 메서드 호출 전 상태를 유지해야 함.
- 실패 원자적이라면 작업 도중 예외가 발생해도 그 객체는 여전히 정상적으로 사용할 수 있는 상태임.
- 실패 원자적으로 만들 수 없다면 실패 시의 객체 상태를 API 설명에 명시해야 함.
- 불변 객체로 설계
- 불변 객체는 태생적으로 실패 원자적임.
- 메서드가 실패하면 새로운 객체가 만들어지지는 않을 수 있으나 기존 객체가 불안정한 상태에 빠지는 일은 결코 없음. → 불변 객체의 상태는 생성 시점에 고정되어 절대 변하지 않기 때문
- 작업 수행에 앞서 매개변수의 유효성을 검사
- 객체의 내부 상태를 변경하기 전에 잠재적 예외의 가능성을 대부분 걸러낼 수 있는 방법
- 비슷한 취지로 실패할 가능성이 있는 모든 코드를, 객체의 상태를 바꾸는 코드보다 앞에 배치하는 방법도 있음
- 객체의 임시 복사본에서 작업을 수행한 다음, 작업이 성공적으로 완료되면 원래 객체와 교체
- 데이터를 임시 자료구조에 저장해 작업하는 게 더 빠를 때 적용하기 좋은 방식.
- 작업 도중 발생하는 실패를 가로채는 복구 코드를 작성하여 작업 전 상태로 되돌리기
- 주로 (디스크 기반의) 내구성(durability)을 보장해야 하는 자료구조에 쓰임.
“
catch
블록을 비워두지 말자”
- 예외는 문제 상황에 잘 대처하기 위해 존재하는데,
catch
블록을 비워두면 예외가 존재할 이유가 없어짐.- 비유하자면 화재경보를 무시하는 수준을 넘어 아예 꺼버려, 다른 누구도 화재가 발생했음을 알지 못하게 하는 것과 같음.
- 예외를 무시하기로 했다면
catch
블록 안에 그렇게 결정한 이유를 주석으로 남기고 예외 변수의 이름도ignored
로 바꿔놓도록 하자. - 예측할 수 있는 예외 상황이든 프로그래밍 오류든, 빈
catch
블록으로 못 본 척 지나치면 그 프로그램은 오류를 내재한 채 동작하게 됨.
→ 무시하지 않고 바깥으로 전파되게만 놔둬도 최소한 디버깅 정보를 남긴 채 프로그램이 신속히 중단되게는 할 수 있다