예외를 잡지 못해 프로그램이 실패하면 자바 시스템은 그 예외의 스택 추적(stack trace)정보를 자동으로 출력한다.
스택 추적은 예외 객체의 toString
메서드를 호출해 얻는 문자열로, 보통 예외 클래스 이름 뒤에 상세 메시지가 붙는 형태다. 이러한 정보가 실패 원인을 분석해야 하는 프로그래머들의 유일한 정보가 되기엔 정보가 부족하다.
따라서 사후 분석을 위해 실패 순간의 상황을 정확히 포착해 예외의 상세 메시지에 담아야 한다.
실패 순간을 포착하려면 발생한 예외에 관여된 모든 매개변수와 필드의 값을 실패 메시지에 담아야 한다.
예를 들어 IndexOutOfBoundsException
의 상세 메시지는 범위의 최솟값과 최댓값, 그리고 그 범위를 벗어났다는 인덱스의 값을 담아야 한다.
관련 데이터를 모두 담아야 하지만 장황할 필요는 없고, 보안과 관련된 정보(ex) 비밀번호, 암호 키)는 담지 말아야 한다. 예를 들자면 스택 추적에는 예외가 발생한 파일 이름과 줄 번호는 물론 스택에서 호출한 다른 메서드들의 파일 이름과 줄 번호까지 정확히 기록되어 있는 게 보통이다. 그러니 문서와 소스코드에서 얻을 수 있는 정보는 길게 늘어놔봐야 군더더기가 될 뿐이다.
예외의 상세 메시지와 최종 사용자에게 보여줄 오류 메시지를 혼동해서는 안 된다. 최종 사용자에게는 친절한 안내 메시지를, 예외 메시지는 가독성보다는 담긴 내용이 중요하다.
실패를 적절히 포착하려면 필요한 정보를 예외 생성자에서 모두 받아서 상세 메시지까지 미리 생성해놓는 방법도 괜찮다.
예를 들어 현재의 IndexOutOfBoundsException
생성자는 String
을 받지만, 다음과 같이 구현했어도 좋았을 것이다.
/**
* IndexOutOfBoundsException을 생성한다.
*
* @param lowerBound 인덱스의 최솟값
* @param upperBound 인덱스의 최댓값 + 1
* @param index 인덱스의 실젯값
*/
public IndexOutOfBoundsException(int lowerBound, int upperBound, int index) {
// 실패를 포착하는 상세 메시지를 생성한다.
super(String.format("최솟값: %d, 최댓값: %d, 인덱스: %d", lowerBound, upperBound, index));
// 프로그램에서 이용할 수 있도록 실패 정보를 저장해둔다.
this.lowerBound = lowerBound;
this.upperBoudn = upperBound;
this.index = index;
}
이렇게 해두면 프로그래머가 던지는 예외는 자연스럽게 실패를 더 잘 포착한다.
예외는 실패와 관련한 정보를 얻을 수 있는 접근자 메서드(lowerBound
, upperBound
, index
)를 적절히 제공하는 것이 좋다.
포착한 실패 정보는 예외 상황을 복구하는 데 유용할 수 있으므로 접근자 메서드는 비검사 예외보다는 검사 예외에서 더 빛을 발한다.
호출된 메서드가 실패하더라도 해당 객체는 메서드 호출 전 상태를 유지해야 한다.
이러한 특성을 실패 원자적
이라고 한다.
가장 간단한 방법은 불변 객체로 설계하는 것이다. 불변 객체는 태생적으로 실패 원자적이다. 메서드가 실패하면 새로운 객체가 만들어지지는 않을 수 있으나 기존 객체가 불안정한 상태에 빠지는 일은 결코 없다. 불변 객체의 상태는 생성 시점에 고정되어 절대 변하지 않기 때문이다.
//1. 모든 필드는 final로 선언(상속을 통해 객체의 불변성을 깨뜨릴 수 없다.)
//2. 객체의 모든 메서드는 상태를 변경하지 않도록 설계
//3. 객체를 완전히 초기화한 뒤 변경할 수 없도록 보장
public final class ImmutablePoint {
private final int x;
private final int y;
// 생성자를 통해 초기화
public ImmutablePoint(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
// 기존 객체를 변경하는 대신 새로운 객체를 반환
public ImmutablePoint move(int deltaX, int deltaY) {
return new ImmutablePoint(this.x + deltaX, this.y + deltaY);
}
@Override
public String toString() {
return "ImmutablePoint{x=" + x + ", y=" + y + "}";
}
}
public class Main {
public static void main(String[] args) {
ImmutablePoint point = new ImmutablePoint(0, 0);
System.out.println(point); // ImmutablePoint{x=0, y=0}
ImmutablePoint newPoint = point.move(5, 10);
System.out.println(newPoint); // ImmutablePoint{x=5, y=10}
System.out.println(point); // ImmutablePoint{x=0, y=0} (원본은 그대로 유지)
}
}
// 원본 객체는 절대 변하지 않고, 메서드 실패가 발생해도 원본 객체 상태가 변하지 않으므로
// 실패 원자성을 유지합니다.
가변 객체의 메서드일 경우 작업 수행에 앞서 매개변수의 유효성을 검사하는 것이다. 객체의 내부 상태를 변경하기 전에 잠재적 예외의 가능성 대부분을 걸러낼 수 있는 방법이다.
예시를 살펴보자.
```java
public Object pop() {
if (size == 0) // size값 확인하여 0이면 예외를 던진다.
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
```
위와 비슷한 취지로 실패할 가능성이 있는 모든 코드를 객체의 상태를 바꾸는 코드보다 앞에 배치하는 방법이 있다.
로직을 수행하기 전에 인수의 유효성을 검사하기 어려울 때 사용할 수 있다.
`TreeMap`을 예로 들면 잘못된 타입의 원소를 추가할 때 트리를 변경하기 앞서 해당 원소가 들어갈 위치를 찾는 과정에서 `ClassCastException`을 던진다.