Skip to content

Latest commit

 

History

History
290 lines (206 loc) · 15.9 KB

10장 - 예외.md

File metadata and controls

290 lines (206 loc) · 15.9 KB

10장 - 예외


💡 예외는 진짜 예외 상황에만 사용하라

“예외를 정상적인 제어 흐름에서 사용해서는 안 된다.”

1. 예외를 잘못 사용한 예

// 예외를 완전히 잘못 사용한 예
try {
    int i = 0;
    while(true)
        range[i++].climb();
} catch(ArrayIndexOutOfBoundsException e) {
}
  • 위의 예시는 아주 끔찍한 코드임. 무한 루프를 돌다가 배열의 끝에 도달해 ArrayIndexOutOfBoundsException이 발생하면 끝을 내는 것.
  • 이 코드는 잘못된 추론을 근거로 성능을 높여보려 한 사례임.
    • JVM은 배열에 접근할 때마다 경계를 넘지 않는지 검사하는데, 일반적인 반복문도 배열 경계에 도달하면 종료한다. → 따라서 이 검사를 반복문에도 명시하면 같은 일이 중복되므로 하나를 생략한 것
    • 이 추론은 세 가지 면에서 잘못된 추론임.
      1. 예외는 예외 상황에 쓸 용도로 설계 되었으므로 JVM 구현자 입장에서는 명확한 검사만큼 빠르게 만들어야 할 동기가 약함. (최적화에 별로 신경 쓰지 않았을 가능성이 큼)
      2. 코드를 try-catch 블록 안에 넣으면 JVM이 적용할 수 있는 최적화가 제한됨.
      3. 배열을 순회하는 표준 관용구는 앞서 걱정한 중복 검사를 수행하지 않음. JVM이 알아서 최적화해 없애줌.

→ 교훈 : 예외는 (그 이름이 말해주듯) 오직 예외 상황에서만 써야 한다. 절대로 일상적인 제어 흐름용으로 쓰여선 안 된다.

→ 잘 설계된 API라면 클라이언트가 정상적인 제어 흐름에서 예외를 사용할 일이 없게 해야 한다.


2. 상태 검사 메서드, 옵셔널, 특정 값 선택 지침

  • 상태 의존적 메서드 ex) Iterator 인터페이스의 next 메서드
  • 상태 검사 메서드 ex) Iterator 인터페이스의 hasNext 메서드
  1. 외부 동기화 없이 여러 스레드가 동시에 접근할 수 있거나 외부 요인으로 상태가 변할 수 있다면 옵셔널이나 특정 값을 사용한다. → 상태 검사 메서드와 상태 의존적 메서드 호출 사이에 객체의 상태가 변할 수 있기 때문
  2. 성능이 중요한 상황에서 상태 검사 메서드가 상태 의존적 메서드의 작업 일부를 중복 수행한다면 옵셔널이나 특정 값을 선택한다.
  3. 다른 모든 경우엔 상태 검사 메서드 방식이 조금 더 낫다고 할 수 있다. 가독성이 살짝 더 좋고, 잘못 사용했을 때 발견하기가 쉽다. 상태 검사 메서드 호출을 깜빡 잊었다면 상태 의존적 메서드가 예외를 던져 버그를 확실히 드러낼 것임. → 반면 특정 값은 검사하지 않고 지나쳐도 발견하기가 어렵다(옵셔널은 해당하지 않는 문제).


💡 복구할 수 있는 상황에는 검사 예외를, 프로그래밍 오류에는 런타임 예외를 사용하라

“복구할 수 있는 상황 → 검사 예외 / 프로그래밍 오류 또는 확실하지 않을 때 → 비검사 예외”

1. 검사 예외

  • 일반적으로 복구할 수 있는 조건일 때 발생
  • 사용 지침 : 호출하는 쪽에서 복구하리라 여겨지는 상황이라면 검사 예외를 사용하라
  • 검사 예외를 던지면 호출자가 그 예외를 catch로 잡아 처리하거나 더 바깥으로 전파하도록 강제하게 됨.

→ 메서드 선언에 포함된 검사 예외 각각은 그 메서드를 호출했을 때 발생할 수 있는 유력한 결과임을 API사용자에게 알려주는 것. (API 사용자에게 그 상황에서 회복해내라고 요구)


2. 비검사 예외

  • 비검사 예외는 두 가지로, 각각 런타임 예외에러다. 이 둘은 프로그램에서 잡을 필요가 없거나 통상적으로 잡지 말아야 함.
  • 프로그램에서 비검사 예외나 에러를 던졌다는 것은 복구가 불가능하거나 더 실행해봐야 득보다는 실이 많다는 뜻.
  • 이런 throwable을 잡지 않은 스레드는 적절한 오류 메시지를 내뱉으며 중단됨.
  • 사용 지침
    1. 프로그래밍 오류를 나타낼 때는 런타임 예외를 사용하자
      • 런타임 예외의 대부분은 전제조건을 만족하지 못했을 때 발생. ex) ArrayIndexOutOfBoundsException
    2. 에러는 보통 JVM이 자원 부족, 불변식 깨짐 등 더이상 수행을 계속할 수 없는 상황을 나타낼 때 사용
    3. 비검사 throwable은 모두 RuntimeException의 하위 클래스여야 함. (Error는 상속하지 말아야 할 뿐 아니라, throw 문으로 직접 던지는 일도 없어야 함.)


💡 필요 없는 검사 예외 사용은 피하라

“옵셔널만으로는 상황을 처리하기에 충분한 정보를 제공할 수 없을 때만 검사 예외를 던지자”

1. 검사 예외의 사용

  • 어떤 메서드가 검사 예외를 던질 수 있다고 선언됐다면, 이를 호출하는 코드에서는 catch 블록을 두어 그 예외를 붙잡아 처리하거나 더 바깥으로 던져 문제를 전파해야 함.어느 쪽이든 API 사용자에게 부담을 줌.
  • 검사 예외가 프로그래머에게 지우는 부담은 메서드가 단 하나의 검사 예외만 던질 때가 특히 큼.
    • 검사 예외가 단 하나뿐이라면 오직 그 예외 때문에 API 사용자는 try블록을 추가해야 하고 스트림에서 직접 사용하지 못하게 됨.

→ 검사 예외를 안 던지는 방법이 없는지 고민해볼 가치가 있다.


2. 검사 예외의 대체재

  1. 적절한 결과 타입을 담은 옵셔널을 반환
    • 검사 예외를 던지는 대신 단순히 빈 옵셔널을 반환.
    • 이 방식의 단점은 예외가 발생한 이유를 알려주는 부가정보를 담을 수 없다는 것.
  2. 메서드를 2개로 쪼개 비검사 예외로 바꾸기
// 검사 예외를 던지는 메서드 - 리팩터링 전
try {
    obj.action(args);
} catch(TheCheckedException e) {
    ... // 예외 상황에 대처한다.
}
// 상태 검사 메서드와 비검사 예외를 던지는 메서드 - 리팩터링 후
if(obj.actionPermitted(args)) {
    obj.action(args);
} else {
    ... // 예외 상황에 대처한다.
}


💡 표준 예외를 사용하라

“표준 예외를 재사용하면 API가 다른 사람이 익히고 사용하기 쉬워짐”

1. 대표적인 표준 예외

예외 주요 쓰임
IllegalArgumentException 허용하지 않는 값이 인수로 건네졌을 때(null은 따로 NullPointerException으로 처리)
IllegalStateException 객체가 메서드를 수행하기에 적절하지 않은 상태일 때
NullPointerException null을 허용하지 않는 메서드에 null을 건넸을 때
IndexOutOfBoundsException 인덱스가 범위를 넘어섰을 때
ConcurrentModificationException 허용하지 않는 동시 수정이 발견됐을 때
UnsupportedOperationException 호출한 메서드를 지원하지 않을 때
  • 상황에 부합한다면 항상 표준 예외를 재사용하자

Exception, RuntimeException, Throwable, Error는 직접 재사용하지 말자


2. IllegalArgumentException vs IllegalStateException

  • 위의 표로 정리한 ‘주요 쓰임’이 상호 배타적이지 않은 탓에, 종종 재사용할 예외를 선택하기가 어려울 때도 있음

  • ex) 카드 덱을 표현하는 객체가 있고, 인수로 건넨 수만큼의 카드를 뽑아 나눠주는 메서드를 제공한다고 해보자. 이때 덱에 남아 있는 카드 수보다 큰 값을 건네면 어떤 예외를 던져야 할까?

    • 인수의 값이 너무 크다고 본다면 IllegalArgumentException, 덱에 남은 카드 수가 너무 적다고 보면 IllegalStateException 를 선택할 것임.
  • 위의 상황에서 일반적인 규칙은 아래와 같다.

    • 인수 값이 무엇이었든 어차피 실패했을거라면 IllegalStateException , 그렇지 않으면 IllegalArgumentException 을 던지자.


💡 추상화 수준에 맞는 예외를 던지자

“아래 계층의 예외를 예방하거나 스스로 처리할 수 없고, 그 예외를 상위 계층에 그대로 노출하기 곤란하다면 예외 번역을 사용하라”

1. 예외 번역

  • 예외 번역(excepton translation) : 상위 계층에서 저수준 예외를 잡아 자신의 추상화 수준에 맞는 예외로 바꿔 던지는 것
try {
    ... // 저수준 추상화를 이용한다.
} catch(LowerLevelException e) {
    // 추상화 수준에 맞게 번역한다.
    throw new HigherLevelException(...);
}

2. 예외 연쇄

  • 예외 연쇄(exception chaining) : 문제의 근본 원인(cause)인 저수준 예외를 고수준 예외에 실어 보내는 방식.
try {
    ... // 저수준 추상화를 이용한다.
} catch(LowerLevelException cause) {
		// 저수준 예외를 고수준 예외에 실어 보낸다.
    throw new HigherLevelException(cause);
}

// 예외 연쇄용 생성자
class HigherLevelException extends Exception {
    HigherLevelException(Throwable cause) {
        super(cause);
    }
}
  • 고수준 예외의 생성자는 (예외 연쇄용으로 설계된) 상위 클래스의 생성자에 이 ‘원인’을 건네주어, 최종적으로 Throwable(Throwable) 생성자까지 건네지게 한다.

3. 권장

무턱대고 예외를 전파하는 것보다야 예외 번역이 우수한 방법이지만, 그렇다고 남용해서는 곤란함.

  • 가능하다면 저수준 메서드가 반드시 성공하도록 하여 아래 계층에서는 예외가 발생하지 않도록 하는 것이 최선
    • 때론 상위 계층의 메서드의 매개변수 값을 아래 계층 메서드로 건네기 전에 미리 검사하는 방법으로 이 목적을 달성할 수 있음
  • 아래 계층에서 예외를 피할 수 없다면, 상위 계층에서 그 예외를 조용히 처리하여 문제를 API 호출자에까지 전파하지 않는 방법.
    • 이 경우 발생한 예외는 java.util.logging 같은 적절한 로깅 기능을 활용하여 기록해두면 좋음.


💡 메서드가 던지는 모든 예외를 문서화하라

“발생 가능한 예외를 문서로 남기지 않으면 다른 사람이 그 클래스나 인터페이스를 효과적으로 사용하기 어렵거나 심지어 불가능할 수도 있다.”

  • 검사 예외는 항상 따로따로 선언하고, 각 예외가 발생하는 상황을 자바독의 @throw 태그를 사용하여 정확히 문서화하자
  • public 메서드라면 필요한 전제조건을 문서화해야 하며, 그 수단으로 가장 좋은 것이 바로 비검사 예외들을 문서화하는 것임.
  • 메서드가 던질 수 있는 예외를 각각 @throw 태그로 문서화하되, 비검사 예외는 메서드 선언의 throws 목록에 넣지 말자.
    • 검사 예외 : 메서드 선언의 thorws 절에 등장 O && 메서드 주석의 @throw 태그에 명시 O
    • 비검사 예외 : 메서드 선언의 thorws 절에 등장 X && 메서드 주석의 @throw 태그에 명시 O
    • 이 조건을 만족하여 작성하면 자바독 유틸리티가 시각적으로 구분해줌.
  • 한 클래스에 정의된 많은 메서드가 같은 이유로 같은 예외를 던진다면 그 예외를 (각각의 메서드가 아닌) 클래스 설명에 추가하는 방법도 있음.
    • ex) “이 클래스의 모든 메서드는 인수로 null이 넘어오면 NullPointerException을 던진다.”


💡 예외의 상세 메시지에 실패 관련 정보를 담으라

“사후 분석을 위해 실패 순간의 상황을 정확히 포착해 예외의 상세 메시지에 담아야 함”

  • 예외를 잡지 못해 프로그램이 실패하면 자바 시스템은 그 예외의 스택 추적(stack trace) 정보를 자동으로 출력함.
    • 스택 추적은 예외 객체의 toString 메서드를 호출해 얻는 문자열로, 보통은 예외의 클래스 이름 뒤에 상세 메시지가 붙는 형태.
  • 실패 순간을 포착하려면 발생한 예외에 관여된 모든 매개변수와 필드의 값을 실패 메시지에 담아야 함.
    • ex) IndexOutOfBoundsException 의 상세 메시지는 범위의 최솟값과 최댓값, 그리고 그 범위를 벗어났다는 인덱스의 값을 담아야 함.
    • but, 상세 메시지에 비밀번호나 암호 키 같은 정보까지 담아서는 안 됨.
  • 예외의 상세 메시지와 최종 사용자에게 보여줄 오류 메시지를 혼동해서는 안 됨.
    • 최종 사용자에게는 친절한 메시지로 작성, 예외 메세지는 주 소비층이 프로그래머임을 고려하여 작성.
  • 예외는 실패와 관련된 정보를 얻을 수 있는 접근자 메서드를 적절히 제공하는 것이 좋음.


💡 가능한 한 실패 원자적으로 만들라

“메서드 명세에 기술한 예외라면 설혹 예외가 발생하더라도 객체의 상태는 메서드 호출 전과 똑같이 유지돼야 한다는 것이 기본 규칙”

1. 실패 원자적이란?

  • 실패 원자적(failure-atomic) : 호출된 메서드가 실패하더라도 해당 객체는 메서드 호출 전 상태를 유지해야 함.
  • 실패 원자적이라면 작업 도중 예외가 발생해도 그 객체는 여전히 정상적으로 사용할 수 있는 상태임.
  • 실패 원자적으로 만들 수 없다면 실패 시의 객체 상태를 API 설명에 명시해야 함.

2. 실패 원자적을 얻는 방법

  1. 불변 객체로 설계
    • 불변 객체는 태생적으로 실패 원자적임.
    • 메서드가 실패하면 새로운 객체가 만들어지지는 않을 수 있으나 기존 객체가 불안정한 상태에 빠지는 일은 결코 없음. → 불변 객체의 상태는 생성 시점에 고정되어 절대 변하지 않기 때문
  2. 작업 수행에 앞서 매개변수의 유효성을 검사
    • 객체의 내부 상태를 변경하기 전에 잠재적 예외의 가능성을 대부분 걸러낼 수 있는 방법
    • 비슷한 취지로 실패할 가능성이 있는 모든 코드를, 객체의 상태를 바꾸는 코드보다 앞에 배치하는 방법도 있음
  3. 객체의 임시 복사본에서 작업을 수행한 다음, 작업이 성공적으로 완료되면 원래 객체와 교체
    • 데이터를 임시 자료구조에 저장해 작업하는 게 더 빠를 때 적용하기 좋은 방식.
  4. 작업 도중 발생하는 실패를 가로채는 복구 코드를 작성하여 작업 전 상태로 되돌리기
    • 주로 (디스크 기반의) 내구성(durability)을 보장해야 하는 자료구조에 쓰임.


💡 예외를 무시하지 말라

catch 블록을 비워두지 말자”

  • 예외는 문제 상황에 잘 대처하기 위해 존재하는데, catch 블록을 비워두면 예외가 존재할 이유가 없어짐.
    • 비유하자면 화재경보를 무시하는 수준을 넘어 아예 꺼버려, 다른 누구도 화재가 발생했음을 알지 못하게 하는 것과 같음.
  • 예외를 무시하기로 했다면 catch 블록 안에 그렇게 결정한 이유를 주석으로 남기고 예외 변수의 이름도 ignored 로 바꿔놓도록 하자.
  • 예측할 수 있는 예외 상황이든 프로그래밍 오류든, 빈 catch 블록으로 못 본 척 지나치면 그 프로그램은 오류를 내재한 채 동작하게 됨.

무시하지 않고 바깥으로 전파되게만 놔둬도 최소한 디버깅 정보를 남긴 채 프로그램이 신속히 중단되게는 할 수 있다