210131 Effective Java 10장 정리
2021-01-31
10. 예외
[item 69] 예외는 진짜 예외 상황에만 사용하라
예시) 예외 사용
try {
int i = 0;
while(true)
range[i++].climb();
} catch (ArrayIndexOutOfBoundsException e){
}
- 전혀 직관적이지 않다
- 배열의 원소를 순회한다
- 무한루프를 돌다가 배열의 끝에 도달하여 ArrayIndexOutOfBoundsException 이 발생하면 끝나는 프로그램
예외를 사용한 이유
- 무한루프를 돌다가 배열의 끝에 도달하여 ArrayIndexOutOfBoundsException 이 발생하면 끝나는 프로그램
- 잘못된 추론을 근거로 성능을 높여보려 함
- JVM은 배열에 접근할 때마다 경계를 넘는지 검사
- 일반적인 반복문도 배열 경계에 도달하면 종료함
- 이 검사를 반복문에도 명시한다면 같은 일이 중복되므로 하나를 생략함
위의 추론이 잘못된 이유
- 예외는 예외 상황에 쓸 용도로 설계되었으므로, JVM구현자 입장에서는 명확한 검사만큼 빠르게 만들어야 할 동기가 약하다
- 최적화에 신경쓰지 않았을 가능성이 높다
- 코드를 try-catch 블록 안에 넣으면 JVM이 적용할 수 있는 최적화가 제한된다
- 배열을 순회하는 표준 관용구는 앞서 걱정한 중복 검사를 수행하지 않는다. JVM이 알아서 최적화해 없애준다.
- 실제로 예외를 사용한 쪽이 표준 관용구보다 훨씬 느리다
for (Mountain m : range) m.climb();
- 표준 관용구대로 작성하여 이해가 잘 됨!
예외는 오직 예외 상황에서만 사용하라
- 일상적인 제어 흐름용으로 사용 금지
- 표준적이고 쉽게 이해되는 관용구를 사용하라
- API설계에도 적용된다
- 특정 상태에서만 호출할 수 있는 상태 의존적 메서드를 제공하는 클래스는 ‘상태 검사’ 메서드도 함께 제공해야 한다
- 예시) Iterator 인터페이스의 next와 hasNext
상태 검사 메서드, 옵셔널, 특정 값 중 하나를 선택하는 지침
- 예시) Iterator 인터페이스의 next와 hasNext
- 특정 상태에서만 호출할 수 있는 상태 의존적 메서드를 제공하는 클래스는 ‘상태 검사’ 메서드도 함께 제공해야 한다
- 외부 동기화 없이 여러 스레드가 동시에 접근할 수 있거나 외부 요인으로 상태가 변할 수 있다
- 옵셔널, 특정 값
- 상태 검사 메서드와 상태 의존적 메서드 호출 사이에 객체의 상태가 변할 수 있다
- 옵셔널, 특정 값
- 성능이 중요한 산황에서 상태 검사 메서드가 상태 의존적 메서드의 작업 일부를 중복 수행
- 옵셔널, 특정 값
- 다른 모든 경우
- 상태 검사 메서드
- 가독성이 좋다
- 잘못 사용시 발견하기가 더 쉽다
-
호출을 잊었다면 상태 의존적 메서드가 예외를 던져 버그를 확실히 드러낸다
- 상태 검사 메서드
[item 70] 복구할 수 있는 상황에는 검사 예외를, 프로그래밍 오류에는 런타임 예외를 사용하라
Throwable
- 문제 상황을 알리는 자바 타입
- 검사 예외, 비검사 예외(런타임 예외, 에러)
검사 예외
호출하는 쪽에서 복구하리라 여겨지는 상황에서 사용
- 검사/비검사 예외를 구분하는 기본 규칙
- 검사 예외를 던지면 호출자가 그 예외를 catch로 잡아 처리하거나 더 바깥으로 전파하도록 강제
- 메서드 선언에 포함된 검사 예외 각각
- 그 메서드를 호출했을 때 발생할 수 있는 유력한 결과임을 API 사용자에게 알린다
- API 설계자는 API 사용자에게 검사 예외를 던져주어 그 상황에서 회복을 요구한 것
- 예외를 던지기만 하고 별 조치를 취하지 않을 수도 있음(좋지 않음)
- 호출자가 예외 상황에서 벗어나는 데 필요한 정보를 알려주는 메서드를 함께 제공해야 한다
비검사 예외
- 프로그램에서 잡을 필요가 없거나, 통상적으로 잡지 않아도 됨
- 복구가 불가능하거나 더 실행할때 득보다 실이 많음
- 비검사 Throwable은 모두 RuntimeException의 하위 클래스여야 한다
런타임 예외
- 프로그래밍 오류를 나타냄
- 대부분 전제조건을 만족하지 못했을 때 발생
- 해당 API의 명세에 기록된 제약을 지키지 못했다
- 예시)ArrayindexOutOfBoundsException : 배열의 인덱스가 0~(배열크기-1) 을 벗어남
- 복구할 수 있는 상황인지 / 프로그래밍 오류인지 모호한 상황에서는 비검사 예외를 선택하는 편이 낫다
에러
- JVM이 자원 부족, 불변식 깨짐 등 더이상 수행을 계속할 수 없는 상황을 나타냄
- Error는 상속하지 않아야 하며, throw문으로 직접 던지는 일도 없어야 한다(AssertionError는 예외)
Exception, RuntimeException, Error를 상속하지 않는 throwable은 사용하지 말라
- 정상적인 검사 예외보다 나은 점이 없으며, API 사용자를 헷갈리게 한다
- throwable 클래스들은 대부분 오류 메시지 포맷을 상세히 기술하지 않는다
- JVM이나 릴리스에 따라 포맷이 달라질 수 있다
-
메시지 문자열을 파싱해 얻은 코드는 깨지기 쉽고, 다른 환경에서 동작하지 않을 수 있다.
[item 71] 필요 없는 검사 예외 사용은 피하라
검사 예외의 장단점
장점
- 제대로 활용하면 API와 프로그램의 질을 높일 수 있다
- 발생한 문제를 프로그래머가 처리하여 안전성을 높일 수 있다
- 구체적인 예외 타입과 그 타입이 제공하는 메서드들을 활용해 부가 정보를 제공할 수 있다
단점
- 검사 예외를 과하게 사용하면 오히려 쓰기 불편한 API가 된다
- 검사 예외를 던질 수 있는 메서드를 호출한다면, 이 메서드를 호출하는 코드에서는 catch 블록을 두어 그 예외를 붙잡아 처리하거나 더 바깥으로 던져 문제를 전파해야 한다
- 검사 예외를 던지는 메서드는 스트림 안에서 직접 사용할 수 없다
- 검사 예외가 단 하나뿐이라면…
- catch문 하나만 추가
- API사용자는 오직 이 예외때문에 try블록을 추가해야 하고, 스트림에서 직접 사용할 수 없게 된다
검사 예외 회피
```java } catch(TheCheckedException e) { throw new AssertionError(); // 일어날 수 없음 }
} catch (TheCheckedException e) { e.printStackTrace(); System.exit(1); }
##### 적절한 결과 타입을 담은 옵셔널을 반환한다
- 단순히 빈 옵셔널을 반환한다
- 단점
- 예외가 발생한 이유를 알려주는 부가 정보를 담을 수 없다
##### 검사 예외를 던지는 메서드를 2개로 쪼개 비검사 예외로 바꾼다
```java
/* 검사 예외를 던지는 메서드 - 리팩터링 전 */
try {
obj.action(args);
} catch (TheCheckedException e) {
... // 예외 상황에 대처
}
/* 상태 검사 메서드와 비검사 예외를 던지는 메서드 - 리팩터링 후 */
if (obj.actionPermitted(args)) {
obj.action(args);
} else {
... // 예외 상황에 대처
}
- 프로그래머가 이 메서드가 성공하리라는 걸 안다거나, 실패 시 스레드를 중단하길 원한다면 다음 한 줄로 리팩터링하라
obj.action(args);
- 외부 동기화 없이 여러 스레드가 동시에 접근할 수 있거나 외부 요인에 의해 상태가 변할 수 있다면 이렇게 리팩터링할 수 없다
- actionPermitted와 action 호출 사이에 객체의 상태가 변할 수 있다
-
actionPermitted가 action 메서드의 작업 일부를 중복 수행한다면 성능 면에서 손해
[item 72] 표준 예외를 사용하라
표준 예외 재사용시 장점
- 다른 사람들이 익히고 사용하기 쉬워진다
- 많은 프로그래머에게 이미 익숙해진 규약을 그대로 따른다
- 예외 클래스 수가 적을수록 메모리 사용량도 줄고, 클래스를 적재하는 시간도 적게 걸린다
표준 예외들
IllegalArgumentException
- 호출자가 인수로 부적절한 값을 넘길 때 던지는 예외
- 예시) 반복 횟수를 지정하는 매개변수에 음수를 건넬 때
- null값을 허용하지 않는 메서드에 null을 건네는 경우는 NullPointerException을 던진다
- 어떤 시퀀스의 허용 범위를 넘는 값을 넘긴다면 IndexOutOfBoundsException을 던진다
IllegalStateException
- 대상 객체의 상태가 호출된 메서드를 수행하기에 적합하지 않을 때
- 예시) 제대로 초기화되지 않은 객체를 사용하려 할 때
- ** 인수 값이 무엇이었든 어차피 실패했을 거라면 이 예외를 던진다 **
ConcurrentModificationException
- 단일 스레드/외부 동기화 방식에서 사용하려고 설계한 객체를 여러 스레드가 동시에 수정하려 할 때
UnsupportedOperationException
- 클라이언트가 요청한 동작을 대상 객체가 지원하지 않을 때
- 예시) 원소를 넣을수만 있는 List 구현체에 누군가 remove 메서드를 호출하는 경우
Exception, RuntimeException, Throwable, Error는 직접 재사용하지 말자
- 이 클래스들은 추상 클래스라고 생각하자
-
이 예외들은 다른 예외들의 상위 클래스이므로 안정적으로 테스트할 수 없다
[item 73] 추상화 수준에 맞는 예외를 던지라
예외 번역
try {
... // 저수준 추상화를 이용한다
} catch (LowerLevelException e) {
// 추상화 수준에 맞게 번역한다
throw new HigherLevelException(...);
}
- 상위 계층에서 저수준 예외를 잡아, 자신의 추상화 수준에 맞는 예외로 바꿔 던진다
- 무턱대고 예외를 전파하는 것보다는 낫지만, 남용하지는 않아야 한다
- 가능하다면 저수준 메서드가 반드시 성공하도록 하여 아래 계층에서는 예외가 발생하지 않도록 해야 한다
- 상위 계층 메서드의 매개변수 값을 아래 계층 메서드로 전달하기 전에 미리 검사한다
- 아래 계층에서의 예외를 피할 수 없다면, 상위 계층에서 그 예외를 조용히 처리하여 문제를 API호출자에게까지 전파하지 않는다
- 이 경우 발생한 예외는 java.util.logging과 같은 적절한 로깅 기능을 활용하여 기록하라
예외 연쇄
try {
... // 저수준 추상화를 이용한다
} catch (LowerLevelExeption cause) {
// 저수준 예외를 고수준 예외에 실어 보낸다
throw new HigherLevelException(cause);
}
/* 예외 연쇄용 생성자 */
class HigherLevelException extends Exception {
HigherLevelException(Throwable cause) {
super(cause);
}
}
- 문제의 근본 원인인 저수준 예외를 고수준 예외에 실어 보내는 방식
- 별도의 접근자 메서드(Throwable의 getCause 메서드)를 통해 필요하면 언제든지 저수준 예외를 꺼내볼 수 있다
-
예외를 번역할 때, 저수준 예외가 디버깅에 도움이 된다면 예외 연쇄를 사용하는 게 좋다
[item 74] 메서드가 던지는 모든 예외를 문서화하라
예외 문서화 원칙
검사 예외는 항상 따로따로 선언하고, 각 예외가 발생하는 상황을 자바독의 @throws 태그를 사용하여 정확히 문서화하자
- 공통 상위 클래스 하나로 뭉뚱그려 선언하는 일은 삼가하라
- 예시) 메서드가 Exception이나 Throwable을 던진다고 선언하는 일은 삼가해야 한다
- 메서드 사용자에게 각 예외에 대처할 수 있는 힌트를 주지 못한다
- 같은 맥락에서 발생할 여지가 있는 다른 예외들까지 삼켜버릴 수 있다
- 예외: main 메서드
- main은 오직 JVM만이 호출하므로 Exception을 던지도록 선언해도 좋다
비검사 예외도 검사 예외처럼 정성껏 문서화해두면 좋다
- main은 오직 JVM만이 호출하므로 Exception을 던지도록 선언해도 좋다
- 비검사 예외는 일반적으로 프로그래밍 오류를 뜻한다
- 자신이 일으킬 수 있는 오류들이 무엇인지 알려주게 되면 프로그래머는 자연스럽게 해당 오류가 발생하지 않도록 코딩하게 된다
- 잘 정비된 비검사 예외의 문서는 사실상 그 메서드를 성공적으로 수행하기 위한 전제조건
- public 메서드라면 필요한 전제조건(비검사 예외들)을 문서화해야 한다
- 인터페이스 메서드라면 발생 가능한 비검사 예외를 꼭 문서화한다
- 이 조건이 인터페이스의 일반 규약에 속하게 되어 인터페이스를 구현한 모든 구현체가 일관되게 동작하도록 한다
메서드가 던질 수 있는 예외를 각각 @throws 태그로 문서화하되, 비검사 예외는 메서드 선언의 throws 목록에 넣지 말자
- 이 조건이 인터페이스의 일반 규약에 속하게 되어 인터페이스를 구현한 모든 구현체가 일관되게 동작하도록 한다
- 검사 예외만 메서드 선언의 throws문에 일일히 선언
한 클래스에 정의된 많은 메서드가 같은 이유로 같은 예외를 던진다면 그 예외를 클래스 설명에 추가하는 방법도 있다
- 예시) NullPointerException
-
클래스의 문서화 주석에 “이 클래스의 모든 메서드는 인수로 null이 넘어오면 NullPointerException을 던진다” 라고 적는다
-
[item 75] 예외의 상세 메시지에 실패 관련 정보를 담으라
스택 추적(stack trace)
- 예외를 잡지 못해 프로그램이 실패하면 자바 시스템은 그 예외의 스택 추적 정보를 자동으로 출력한다
- 스택 추적은 예외 객체의 toString 메서드를 호출해 얻는 문자열
- 보통은 예외 클래스 이름 뒤에 상세 메시지가 붙는 형태
- 예외의 toString 메서드에 실패 원인에 관한 정보를 가능한 한 많이 담아 반환하라
- 사후 분석을 위해 실패 순간의 상황을 정확히 포착해 예외의 상세 메시지에 담아야 한다
- 실패 순간을 포착하려면 발생한 예외에 관여된 모든 매개변수와 필드의 값을 실패 메시지에 담아야 한다
- 하지만 보안과 관련된 정보는 주의해서 다뤄야 한다
- 상세 메시지에 비밀번호나 암호 키 같은 정보까지 담아서는 안 된다
- 예시) IndexOutOfBoundsException의 상세 메시지
- 범위의 최솟값과 최댓값, 그리고 그 범위를 벗어났다는 인덱스의 값을 담아야 함
- 하지만 보안과 관련된 정보는 주의해서 다뤄야 한다
- 예외는 실패와 관련한 정보를 얻을 수 있는 접근자 메서드를 제공해야 한다
- 비검사 예외보다는 검사 예외에서 빛을 발한다
- 하지만 비검사 예외라도 상세 정보를 알려주는 접근자 메서드를 제공하여야 한다
[item 76] 가능한 한 실패 원자적으로 만들라
실패 원자적(failure-atomic)
- 호출된 메서드가 실패하더라도 해당 객체는 메서드 호출 전 상태를 유지해야 한다
- 실패 원자성은 일반적으로 권장되는 덕목이지만 항상 달성할 수 있는 것은 아니다
- 예시) 두 스레드가 동기화 없이 같은 객체를 동시에 수정하다면 그 객체의 일관성이 깨질 수 있다
- ConcurrentModificationException을 잡아냈다고 해서 그 객체가 여전히 사용 가능한 상태라고 가정해서는 안된다
- Error는 복구할 수 없으므로 AssertionError에 대해서는 실패 원자적으로 만들려는 시도조차 할 필요가 없다
- 예시) 두 스레드가 동기화 없이 같은 객체를 동시에 수정하다면 그 객체의 일관성이 깨질 수 있다
- 메서드 명세에 기술한 예외라면 설혹 예외가 발생하더라도 객체의 상태는 메서드 호출 전과 똑같이 유지되어야 한다
- 이 규칙을 지킬 수 없는 경우 실패시의 객체 상태를 API 설명에 명시해야 한다
메서드를 실패 원자적으로 만드는 법
- 불변 객체로 설계
- 태생적으로 실패 원자적
- 불변 객체의 상태는 생성 시점에 고정되어 절대 변하지 않는다
- 메서드가 실패하면 새로운 객체가 만들어지지는 않을 수 있으나 기존 객체가 불안정한 상태에 빠지는 일은 결코 없다
- 태생적으로 실패 원자적
- 가변 객체의 메서드인 경우
- 작업 수행에 앞서 매개변수의 유효성을 검사
- 객체의 내부 상태를 변경하기 전에 잠재적 예외의 가능성 대부분을 걸러낼 수 있음
- 실패할 가능성이 있는 모든 코드를 객체의 상태를 바꾸는 코드보다 앞에 배치
- 계산을 수행하기 전에는 인수의 유효성을 검사할 수 없는 경우
- 예시) TreeMap
- 원소를 추가할 때 그 원소는 TreeMap의 기준에 따라 비교할 수 있는 타입이어야 한다
- 엉뚱한 타입의 원소를 추가하려 들면 트리를 변경하기 앞서 해당 원소가 들어갈 위치를 찾는 과정에서 ClassCastException
- 객체의 임시 복사본에서 작업을 수행한 다음, 작업이 성공적으로 완료되면 원래 객체와 교체
- 데이터를 임시 자료구조에 저장해 작업하는 게 더 빠를 때 적용하기 좋은 방식
- 예시) 정렬 메서드에서 정렬을 수행하기 전 입력 리스트의 원소들을 배열로 옮겨담는 경우
- 배열을 사용하면 정렬 알고리즘의 반복문에서 원소들에 훨씬 빠르게 접근할 수 있다
- 성능 향상+정렬에 실패하더라도 입력 리스트는 변하지 않음
- 작업 도중 발생하는 실패를 가로채는 복구 코드를 작성하여 작업 전으로 되돌린다
- 디스크 기반 내구성(durability)을 보장해야 하는 자료구조에 쓰인다
- 작업 수행에 앞서 매개변수의 유효성을 검사
[item 77] 예외를 무시하지 말라
/* catch 블록을 비워두면 예외가 무시된다 */
try {
...
} catch (SomeException e) {
}
- API설계자가 메서드 선언에 예외를 명시? -> 그 메서드를 사용할 때 적절한 조치를 취해야 함
- catch 블록을 비워둔다면 예외가 존재할 이유가 없다
- 화재경보를 무시하는 수준을 넘어 아예 꺼버려, 다른 누구도 화재가 발생했음을 알 수 없게 하는것과 같다
예외를 무시해야 할 때
- 예시: FileInputStream을 닫을 때
- 입력 전용 스트림이므로 파일의 상태를 변경하지 않았으니 복구할 것이 없다
- 스트림을 닫는 것은 필요한 정보는 이미 다 읽었다는 뜻이니 남은 작업을 중단할 이유도 없다
- 예외를 무시하기로 했다면 catch블록 안에 그렇게 결정한 이유를 주석으로 남기고, 에외 변수의 이름도 ignored로 바꾼다
Future<Integer> f = exec.submit(planarMap::chromaticNumber);
int numColors = 4; // 기본값
try {
numColors = f.get(1L, TimeUnit.SECONDS);
} catch (TimeoutException | ExecutionException ignored){
// 기본값을 사용한다(색상 수를 최소화하면 좋지만, 필수는 아니다)
}