상세 컨텐츠

본문 제목

Effective Java 2/E 스터디 4일차

SW/미분류

by 푸로그 2019. 7. 1. 22:03

본문

9장 예외 327 

규칙 57 예외는 예외적 상황에만 사용하라 
규칙 58 복구 가능 상태에는 점검지정 예외를 사용하고, 프로그래밍 오류에는 실행시점 예외를 이용하라 
규칙 59 불필요한 점검지정 예외 사용은 피하라 
규칙 60 표준 예외를 사용하라 
규칙 61 추상화 수준에 맞는 예외를 던져라 
규칙 62 메서드에서 던져지는 모든 예외에 대해 문서를 남겨라 
규칙 63 어떤 오류인지를 드러내는 정보를 상세한 메시지에 담으라 
규칙 64 실패 원자성 달성을 위해 노력하라 
규칙 65 예외를 무시하지 마라 

예외를 효과적으로 활용하기 위한 지침에 대해 살펴본다.

규칙 57 예외는 예외적 상황에만 사용하라

// 예외를 끔찍하게 남용한 사례. 이러면 곤란하다.
try {
    int i = 0;
    while(true)
        range[i++].climb();
} catch(ArrayIndexOutOfBoundsException e) {
}

첫 번째 요소를 참조하는 순간에 발생하는 ArrayIndexOutOfBoundsException 예외를 감지하고 무시하는 과정을 통해 종료된다. 표준적 숙어대로 했으면 아마 모든 자바 프로그래머가 쉽게 이해했을 것이다.

  • 예외는 예외적 상황을 위해 고안된 것이기 때문에, JVM을 구현하는 사람 입장에서 보면 명시적 테스트 만큼 빠르게 만들 이유가 별로 없다.
  • try-catch 블록 안에 넣어둔 코드에는 최신 JVM이 사용하는 최적화 기법 가운데 일부가 적용되지 않는다.
  • 배열을 순회하는 표준 숙어가 중복 검사로 이어지리라는 생각은 틀렸다. 최신 JVM은 그런 부분도 최적화해서 없애버린다.

사실 최신 JVM에서 돌려보면 예외를 통해 구현한 순환문이 표준적 순환문보다 훨씬 느리다. 필자의 컴퓨터에서는 100개 원소를 갖는 배열일 떄 두 배 이상 느렸다.

예외를 사용해 구현한 순환문은 코드의 원래 목적을 흐리고 성능을 떨어뜨릴 뿐 아니라, 올바른 동작을 보장할 수 없다는 문제도 갖고 있다. 관련 없는 버그 때문에 순환문이 조용히 종료되면 버그의 존재는 감춰지므로 디버깅이 어려워진다. 가령 순환문 내부에서 호출하는 메서드가 다른 배열을 이용하다가 그 바깥 영역을 참조하려 했다고 해 보자. 표준적인 순환문을 썼더라면 무점검 예외가 발생하는 순간, 스레드는 완전한 스택 추적 정보를 출력하면서 즉각 종료되었을 것이다. 하지만 앞서 살펴본 예제와 같은 순환문을 사용하게 되면 버그 때문에 예외가 생겼는지, 순환문이 끝나려고 예외가 발생했는지 분간할 수 없으므로 버그는 묻혀버리게 된다.

이 이야기가 주는 교훈은 간단하다. 이름이 말하듯이, 예외는 예외적인 상황에만 사용해야 한다. 평상시 제어 흐름에 이용해서는 안 된다.

규칙 58 복구 가능 상태에는 점검지정 예외를 사용하고, 프로그래밍 오류에는 실행시점 예외를 이용하라

자바는 세 가지 종류의 'throwable'을 제공한다. 점검지정 예외(Try Catch), 실행시점 예외, 그리고 오류다. 프로그래머들은 떄로 어떤 상황에 무엇을 사용해야 하는지 혼란스러워 한다. 딱 떨어지는 기준이 있는 것은 아니지만, 훌륭한 지침으로 삼을 만한 일반 규칙이 몇 가지 있어서 소개한다.

점검지정 예외를 사용할 것인지 아니면 무점검 예외를 사용할 것인지에 대한 가장 기본적인 규칙은, 호출자 측에서 복구할 것으로 여겨지는 상황에 대해서는 점검지정 예외를 사용해야 한다는 것이다. 점검지정 예외를 던지는 메서드를 호출한 클라이언트는 해당 예외를 catch 절 안에서 처리하든지, 아니면 계속 밖으로 던저지도록 놔두든지 해야 한다. 따라서 메서드에 선언된 점검지정 예외는 메서드를 호출하면 해당 예외와 관계된 상황이 발생할 수 있음을 API 사용자에게 알리는 구실을 한다.

API 사용자에게 점검지정 예외를 준다는 것은, 그 상태를 복구할 권한을 준다는 뜻이다. 사용자는 그 권한을 무시할 수 있다. catch로 예외를 받되 다른 처리를 하지 않아도 된다는 것이다. 하지만 일반적으로는 그렇게 하면 곤란하다(규칙 65).

무점검 'throwable'에는 실행시점 예외와 오류 두 가지가 있으며, 동작 방식은 같다. 둘 다 catch로 처리할 필요가 없으며, 일반적으로는 처리해서도 안 된다. 프로그램이 무점검 예외나 오류를 던진다는 것은 복구가 불가능한 상황에 직면했다는 뜻으로, 더 진행해 봐야 득보다 실이 더 크다는 뜻이다. 이런 throwable을 catch하지 않는 스레드는 적절한 오류 메시지를 내면서 중단해야 한다.

> 자바 언어 명세에 강제된 사항은 아니지만, 보통 오류는 JVM이 자원 부족이나 불변식 위반 등, 더 이상 프로그램을 실행할 수 없는 상태에 도달했음을 알기기 위해 사용한다. 이 관습은 거의 보편적으로 받아들여지고 있어서, Error의 하위 클래스는 새로 만들지 않는 것이 최선이다. 따라서 사용자 정의 무점검 throwable은 RuntimeException의 하위 클래스로 만들어야한다(직접적이건, 아니면 간접적이건 간에).
> Exception이나 RuntimeException, 또는 Error의 하위 클래스가 아닌 throwable도 정의할 수 있다. 자바 언어 명세에는 그런 throwable에 대한 직접적 언급은 없지만, 그런 throwable은 일반적인 점검지정 예외(RuntimeException이 아니라 Exception의 하위 클래스들)와 동일하게 동작한다는 것이 암시되어 있다. 그럼 그런 기능은 언제 사용하는 것이 좋을까? 간단히 말해서, 절대로 활용하면 안 된다. 일반적인 점검지정 예외보다 나은 점이 없을 뿐 아니라, API 사용자를 혼란스럽게 할 뿐이다.

요약하자면, 복구 가능한 상태에는 점검지정 예외를 사용하고, 프로그래밍 오류를 나타내고 싶을 때는 실행시점 예외를 사용하라는 것이다.

규칙 59 불필요한 점검지정 예외 사용은 피하라

API를 제대로 이용해도 예외 상황이 벌어지는 것을 막을 수 없을 때, 그리고 API 사용자가 예외 상황에 대한 조치를 취할 수 있을 때는, 그 정도의 부담은 참을 수 있을 것이다. 하지만 이 조건 가운데 어디에도 해당되지 않을 떄는 무점검 예외를 이용하는 것이 좋다. 잘 모르겠다면, 프로그래머가 예외를 어떻게 처리하게 될지 생각해 보자.

} catch(TheCheckedException e) {
    throw new AssertionError(); // 이런 일이 생길 리 없어요!
}

이렇게 하는 것이 최선인가? 아래 코드는 어떤가?

} catch(TheCheckedException e) {
    e.printStackTrace(); // 그래요. 졌습니다.    
    System.exit(1);
}

API 사용자가 이보다 좋은 코드를 만들 수 없다면, 무점검 예외가 적당하다. 이 테스트를 통과하지 못하는 예외의 사례로는 CloneNotSupportedException이 있다. 이 예외는 Cloneable 인터페이스를 구현하지 않은 객체에 Object.clone 메서드를 호출하면 발생하는 예외다(규칙 11). 실제로는, 이 예외를 처리하는 catch 블록이 실행되었다는 것은, 확증이 실패했다는 것이나 마찬가지다. 그런 특성에 어울리지 않게 점검지정 예외로 선언되어 있다는 것이 문제인데, 프로그래머 입장에서는 반갑지 않은 일이다. 프로그램만 복잡해지기 떄문이다.

메서드가 던지는 점검지정 예외가 하나뿐일 때 프로그래머가 느끼게 되는 부담은 큰 편이다. 예외가 여러 개라면, 하나의 try 블록에 달리는 catch 블록도 여러 개일 것이다. 반면 예외가 하나뿐이라면 catch 블록도 하나뿐일 것이다. 그 하나의 catch 블록 때문에 try 블록 안에서 메서드를 호출해야 하는 것이다. 이런 상황에 처하면, 점검지정 예외를 없앨 방법이 없는지 고민해보는 것이 좋다.

점검지정 예외를 무점검 예외로 바꾸는 한 가지 방법은, 예외를 던지는 메서드를 둘로 나눠서 첫 번쨰 메서드가 boolean 값을 반환하도록 만드는 것이다. 이 값은 예외가 던져질 상황이 생겼는지를 나타낸다. 예를 들어 아래와 같은 코드가 있었다고 하자.

// 예외를 점검하도록 지정된 메서드 호출
try {
    obj.actuon(args);
} catch (TheCheckedException e) {
    // 예외적 상황 처리
    ...
}

앞서 설명한 대로 메서드를 리팩터링하면 이 코드는 아래와 같이 바뀐다.

// 상태 검사 메서드를 거쳐서 무점검 예외 메서드 호출
if (obj.actionPermitted(args)) {
    obj.action(args);
} else {
    // 예외적 상황 처리
    ...
}

항상 이렇게 리팩터링 할 수 있는 것은 아니나, 만일 가능하다면 API는 좀 더 사용하기 편리해질 것이다. 방금 본 코드의 메서드 호출 순서가 이전 방식에 비해 더 깔끔하다고 말하기는 어려우나, 더 유연한 API가 되었음은 사실이다. action 호출이 항상 성공하리라고 확신하거나, 설사 실패해서 스레드가 죽어도 상관없다면 위의 코드는 아래의 한 줄로 줄일 수도 있다.

obj.action(args);

이런 코드가 정상이라는 생각이 들면, 방금 설명한 대로 API를 리팩터링 하는 것이 바람직하다. 그 결과로 만들어지는 API는 규칙 57에서 설명한 상태 검사 메서드와 본질적으로 같기 때문에, 동일한 문제를 갖는다. 외부적인 동기화 수단 없이 병렬적으로 이용될 가능성이 있는 객체거나, 외부에서 그 상태를 바꿀 가능성이 있는 객체라면 방금 설명한 리팩터링 기법은 적용될 수 없다. actionPermitted을 호출하고 action을 미처 호출하기 전에 객체의 상태가 바뀔 수도 있기 떄문이다. actionPermitted가 하는 일이 action이 하는 일과 중복될 필요가 있을 때는, 성능 문제 때문에 리팩터링을 할 수 없을 수도 있다.

규칙 60 표준 예외를 사용하라

이미 있는 예외들을 재사용하면 좋은 점이 많다. 가장 중요한 장점은, 배우기 쉽고 사용이 편리한 API를 만들 수 있다는 것이다. 다른 프로그래머들도 친숙한, 널리 퍼진 관습을 따르기 때문이다. 그에 필적하는 두 번째 장점은, 그렇게 구현된 API는 가독성이 높다는 것이다. 잘 모르는 예외가 뒤섞여 있지 않기 때문이다. 비교적 덜 중요한 마지막 장점은, 예외 클래스 개수를 줄이면 프로그램의 메모리 요구량이 줄어들고, 클래스를 로딩하는 시간도 줄어든다는 것이다.

가장 널리 재사용되는 예외는 IllegalArgumentException이다. 잘못된 값을 인자로 전달했을 때 일반적으로 발생하는 예외다. 예를 들어, 어떤 동작의 실행 횟수를 나타내는 인자에 음수가 전달되면 이 예외를 던져야 할 것이다.

널리 쓰이는 또 다른 예외로는 IllegalStateException이 있다. 현재 객체 상태로는, 호출할 수 없는 메서드를 호출했을 떄 일반적으로 발생되는 예외다. 예를 들어, 아직 적절히 초기화되지 않은 객체를 사용하려고 시도하면 이 예외가 발생해야 할 것이다.

논쟁의 여지가 있긴 하지만, 모든 잘못된 메서드 호출은 결국 잘못된 인자나 잘못된 상태에 관계된 것이라 이해할 수 있다. 하지만 특정한 부류의 잘못된 인자나 상태에 표준적으로 이용되는 예외들도 있다. null 인자를 받으면 안되는 메서드에 null을 전달한 경우, 관습적으로는 IllegalArgumentException 대신 NullPointerException이 발생해야 한다. 이와 비슷하게, 어떤 순서열의 철자를 나타내는 인자에 참조 가능 범위를 벗어난 값이 전달되었을 때는 관습적으로 IllegalArgumentException 대신 IndexOutOfBoundsException이 발생해야 한다.

일반적 용도의 예외 가운데 알아둘 만한 것으로는 ConcurrentModificationException도 있다. 하나의 스레드만 사용하도록 설계된 객체나, 외부적인 동기화 수단과 함꼐 이용되어야 하는 객체를 여러 스레드가 동시에 변경하려 하는 경우에 발생해야 하는 예외다.

마지막으로, 일반적 용도의 예외 가운데 UnsupportedOperationException도 알아두면 좋다. 어떤 객체가 호출된 메서드를 지원하지 않을 때 발생하는 예외다. 이번 절에서 언급한 다른 예외들에 비해 사용 빈도가 아주 낮은데, 대부분의 객체는 자기가 구현하는 메서드를 지원하는 것이 보통이기 때문이다. 이 예외는 인터페이스에 정의된 선택적 메서드 가운데 하나 이상을 구현하지 않을 경우에 사용한다. 예를 들어, 객체를 추가하는 것만 가능한 리스트는 누군가 리스트에서 원소를 삭제하려고 하면 이 예외를 발생시킬 것이다.

가장 널리 재사용되는 예외를 아래의 표에 요약하였다.

   예외                         사용법 
 IllegalArgumentException  null이 아닌 인자의 값이 잘못되었을 때
 IllegalStateException  객체 상태가 메서드 호출을 처리하기에 적절치 않을 때
 NullPointException  null 값을 받으면 안 되는 인자에 null이 전달되었을 때
 IndexOutOfBoundsException  인자로 주어진 첨자가 허용 범위를 벗어났을 때
 ConcurrentModificationException  병렬적 사용이 금지된 객체에 대한 병렬 접근이 탐지 되었을 때
 UnsupportedOperationException  객체가 해당 메서드를 지원하지 않을 때

재사용 할 수 있는 예외라 생각된다면, 사용하도록 하라. 하지만 예외를 발생시키는 조건이 해당 예외의 문서에 기술된 것과 일치해야 한다. 이름만 보고 재사용한다면 곤란하다. 의미적으로도 재사용이 가능해야 한다는 뜻이다. 예외가 발생한 상황을 좀 더 자세히 설명하는 정보를 추가할 필요가 있을 때는 기존 예외의 하위 클래스를 자유로이 만들어 사용해도 좋다(규칙 63).

규칙 61 추상화 수준에 맞는 예외를 던져라

메서드가 하는 일과 뚜렷한 관련성이 없는 예외가 메서드에서 발생한다면 당혹스럽다. 추상화 수준이 낮은 곳에서 발생한 예외를 그대로 밖으로 전달하면 이런 일이 생긴다. 당혹스러울 뿐더러, 추상화 수준이 높은 API가 구현 세부사항으로 오염되는 일까지 벌어진다. 다음번 릴리스에 상위 계층 구현이 바뀌면 해당 계층에서 발생하는 예외도 바뀔 것이고, 결국 클라이언트 프로그램이 깨질 가능성이 높아진다.

이 문제를 피하려면 상위 계층에서는 하위 계층에서 발생하는 예외를 반드시 받아서 상위 계층 추상화 수준에 맞는 예외로 바꿔서 던져야 한다. 이 숙어를 예외 변환이라 부른다.

예외 연결은 예외 변환의 특별한 사례다. 하위 계층에서 발생한 예외 정보가 상위 계층 예외를 발생시킨 문제를 디버깅하는 데 유용할 떄 사용한다. 하위 계층 예외(원인)는 상위 계층 예외로 전달되는데, 상위 계층 예외에 있는 접근자 메서드를 호출하면 해당 정보를 꺼낼 수 있다.

// 예외 연결
try {
    ... // 낮은 수준의 추상화 계층 이용
} catch (LowerLevelException cause) {
    throw new HigherLevelException(cause);
}

상위 계층 예외 HigherLevelException의 생성자는 문제의 '원인'을 예외 연결을 지원하는 상위 클래스 생성자에 넘긴다. 해당 인자는 결국 Throwable의 예외 연결 지원 생성자에 전달된다. Throwable(Throwable)로 선언되어 있을 것이다.

// 예외 연결 지원 생성자를 갖춘 예외
class HigherLevelException extends Exception {
    HigherLevelException(Throwable cause) {
        super(cause);
    }
}

대부분의 표준 예외들은 예외 연결 지원 생성자를 구비하고 있다. 그런 생성자가 없는 예외에는 Throwable.initCase 메서드를 호출하면 하위 계층 예외를 연결할 수 있다. 예외 연결 기능을 사용하면 프로그램 안에서 예외의 원인에 접근할 수 있을 뿐 아니라(getCause를 이용해서), 최초에 발생한 예외의 스택 추적 정보를 상위 계층 예외에 통합할 수 있게 된다.

아무 생각 없이 아래 계층에서 생긴 예외를 밖으로 전달하기만 하는 것보다야 예외 변환 기법이 낫지만, 남용하면 안 된다. 가능하다면 제일 좋은 방법은 하위 계층에서 예외가 생기지 않도록 하는 것이다.

요약하자면, 하위 계층에서 발생하는 예외를 막거나 처리할 수 없다면, 상위 계층에 보여주면 곤란한 예외는 예외 변환을 통해 처리하라는 것이다. 예외 연결 패턴을 활용하면 적절한 상위 계층 예외를 보여주면서도 하위 계층에서 실제로 발생한 문제까지 확인할 수 있으므로 오류를 분석하기 좋다(규칙 63).

규칙 62 메서드에서 던져지는 모든 예외에 대해 문서를 남겨라

점검지정 예외는 독립적으로 선언하고, 해당 예외가 발생하는 상황은 Javadoc @throws 태그를 사용해서 정확하게 밝혀라. 하위 예외 클래스를 여럿 거느린 상위 예외 클래스의 이름을 메서드 선언부에 나열하면 안된다는 것이다. 그 극단적인 예로 "throws Exception" 이라고 선언하거나, "throws Throwable" 이라고 선언하는 것은 절대로 피해야 한다. 어떤 예외가 발생하는지 아무런 힌트도 주지 못할 뿐 아니라, 메서드 이용을 방해한다. 같은 문맥에서 발생할 수 있는 다른 예외들까지 모호하게 만들어 버리기 때문이다.

동일한 예외를 던지는 메서드가 많다면, 메서드마다 문서를 만드는 대신, 해당 예외에 대한 문서는 클래스의 문서화 주석에 남겨도 된다. 그런 예외로 가장 흔한 것은 NullPointerException이다. 클래스의 문서화 주석에 "이 클래스에 있는 모든 메서드는 인자로 null이 전달되면 NullPointerException을 발생시킨다"라고 적어도 된다는 것이다.

요약하자면, 메서드가 던질 가능성이 있는 모든 예외를 문서에 남겨라. 점검지정 예외 뿐 아니라, 무점검 예외도 문서를 만들라. 일반 메서드뿐 아니라, abstract 메서드에도 문서를 만들라. 점검지정 예외는 메서드의 throws 절에 나열하고, 무점검 예외는 throws 절에는 적지 마라. 메서드가 던질 가능성이 있는 예외에 대해 문서를 남기지 않으면, 다른 사람이 효과적으로 사용할 수 있는 클래스나 인터페이스를 만들기 힘들거나, 만들 수 없을 것이다.

규칙 63 어떤 오류인지를 드러내는 정보를 상세한 메시지에 담으라

무점검 예외 때문에 프로그램이 죽으면, 시스템은 자동적으로 해당 예외의 스택 추적 정보를 출력한다. 이 정보는 해당 예외 객체의 toString 메서드가 예외 정보를 문자열로 변환한 결과다. 스택 추척 정보는 예외 객체의 클래스명 뒤에 상세 메시지가 오는 형태로 구성되어 있다.
서비스 담당 직원이나 프로그래머가 소프트웨어 오류 원인을 알아내기 위해 사용할 수 있는 정보는 이것뿐일 때가 많다. 쉽게 제한할 수 없는 오류였다면, 더 이상의 정보를 얻기란 어렵거나 불가능할 것이다. 따라서 toString 메서드가 반환하는 문자열에 오류 원인에 관계된 정보를 최대한 많이 담는 것이 아주 중요하다. 다시 말해서, 예외의 상세 메시지에는 원인 분석에 이용될 오류 정보가 포착되어 있어야 한다는 것이다.

오류 정보를 포착해 내기 위해서는, 오류의 상세 메시지에 "예외와 관련된" 모든 인자와 필드의 값을 포함시켜야 한다. 예를 들어, IndexOutOfBoundsException의 예외 메시지에는 첨자의 하한과 상한, 그리고 그 범위를 벗어난 첨자값이 포함되어야 한다. 그 정보를 보면 많을 것을 알 수 있다. 세 값의 일부 또는 전부가 틀렸을 수도 있다. 첨자값이 하한보다 하나 작거나, 상한과 같거나 하는 "담장 오류"가 있을 수도 있다. 아니면 터무니없이 크거나 작은, 아주 엉뚱한 값일 수도 있다. 이들 각각은 전부 다른 종류의 문제에 대한 증상이다. 어떤 종류의 오류를 찾아야 하는지 안다면, 문제 진단 과정은 한결 쉬워질 것이다.

예외의 상세 메시지를 사용자 레벨 오류 메시지와 혼동해서는 안 된다. 사용자 레벨 오류 메시지는 최종 사용자가 이해할 수 있어야 한다. 하지만 예외에 대한 상세 메시지는 프로그래머나 서비스 담당자가 오류 원인을 분석하기 위한 것이다. 따라서 가독성보다는 내용이 훨씬 중요하다.

오류를 적절히 포착하는 정보를 상세 메시지에 담는 한 가지 방법은, 상세한 정보를 요구하는 생성자를 만드는 것이다. 상세 메시지는 생성자에 전달된 정보를 통해 자동 생성할 수 있다. 예를 들어, IndexOutOfBoundsException의 생성자는 String 대신 세 개의 첨자값을 인자로 받도록 구현할 수도 있었을 것이다. 아래의 코드를 보자.

/**
 * Construct an IndexOutOfBoundsException.
 *
 * @param lowerBound the lowest legal index value.
 * @param upperBound the highest legal index value plus one.
 * @param index the actual index value.
 */
public IndexOutOfBoundsException(int lowerBound, int upperBound, int index) {
    // 오류를 포착하는 상세 메시지 생성
    super("Lower bound: " + lowerBound +
            ", Upper bound: " + upperBound +
            ", Index: " + index);

    // 프로그램에서 이용할 수 있도록 오류 정보 보관
    this.lowerBound = lowerBound;
    this.upperBound = upperBound;
    this.index = index;
}

규칙 64 실패 원자성 달성을 위해 노력하라

예외를 던지고 난 뒤에도 객체의 상태가 잘 정의된, 사용 가능한 상태로 남아 있으면 좋다. 설사 어떤 연산을 수행하는 도중에 문제가 생겼다 해도 말이다. 호출자 측에서 오류 상태를 복구할 가능성이 있는 점검지정 예외의 경우에는 특히 더 그렇다. 일반적으로 이야기해서, 메서드 호출이 정상적으로 처리되지 못한 객체의 상태는, 메서드 호출 전 상태와 동일해야 한다. 이 속성을 만족하는 메서드는 실패 원자성을 갖추었다고 한다.

실패 원자성을 달성하는 방법은 여러 가지다. 가장 간단한 방법은 변경 불가능 객체로 설계하는 것이다(규칙 15). 변경 불가능한 객체의 경우, 실패 원자성은 덤이다. 메서드 호출이 실패하면 새로운 객체가 만들어지지 못할 수는 있겠지만, 기존 객체의 일관성이 깨지진 않는다. 일단 객체가 만들어지고 나면, 그 상태를 변경할 수 없기 때문이다.

변경 가능한 객체의 경우에는 실제 연산을 수행하기 전에 인자 유효성을 검사하는 것이 가장 보편적인 방법이다(규칙 38). 객체를 변경하는 도중에 예외가 발생하는 것을 막아준다. 일례로, 규칙 6에서 다루었던 Stackpop의 코드를 다시 살펴보자.

public Object pop() {
    if (size == 0)
        throw new EmptyStackException();
    Object result = elements[--size];
    elements[size] = null;
    return result;
}

이와 밀접한 관련이 있는 또 다른 접근법 하나는, 실패할 가능성이 있는 코드를 전부 객체 상태를 바꾸는 코드 앞에 배치하는 것이다. 계산을 실제로 수행해 보자. 전에는 인자를 검사할 수 없을 때 이용 가능한 방법으로, 앞서 살펴본 접근법을 자연스럽게 확장한 것이다.

사용 빈도가 훨씬 낮은 세 번쨰 접근법은 연산 수행 도중에 발생하는 오류를 가로채는 복구 코드를 작성하는 것이다. 이 복구 코드는 연산이 시작되기 이전 상태로 객체를 되돌린다. 디스크 기반의 지속성 자료 구조에 사용되는 기법이다.

마지막 접근법은, 객체의 임시 복사본상에서 필요한 연산을 수행하고, 연산이 끝난 다음에 임시 복사본의 내용으로 객체 상태를 바꾸는 것이다. 데이터를 임시 자료 구조에 복사한 다음에 훨씬 신속하게 실행될 수 있는 연산이라면 이 접근법이 자연스럽다. 예를 들어 Collections.sort는 원소들을 참조하는 비용을 줄이기 위해, 인자로 주어진 리스트를 정렬 전에 배열에 복사한다. 성능 문제 때문에 내린 조치인데, 그 덕에 정렬이 실패해도 원래 리스트에는 아무런 손상이 가지 않는다.

실패 원자성은 일반적으로 권장되는 덕목이지만 언제나 달성할 수 있는 것은 아니다. 예를 들어, 같은 객체를 여러 스레드가 적절한 동기화 없이 동시에 변경할 경우, 객체 상태의 일관성은 깨질 수 있다. 그러니 ConcurrentModificationException이 발생한 뒤에는 객체 상태는 망가져 있으리라 보는 것이 좋다. 명심할 것은, 예외와는 달리 오류는 복구가 불가능하며, 오류를 던지는 경우에는 실패 원자성을 보존하려 애쓸 필요가 없다는 것이다.

실패 원자성을 달성할 수 있다고 치더라도, 바람직하지 않은 경우도 있다. 실패 원자성을 달성하려면 비용이나 복잡성이 심각하게 늘어나느 경우가 있다는 것이다. 그러나 이슈가 무엇인지 파악하고 나면 거의 공짜로, 그리고 쉽게 실패 원자성을 달성할 수 있는 경우도 많다.

명심할 규칙은, 메서드 명세에 포함된 예외가 발생하더라도 객체 상태는 메서드 호출 이전과 동일하게 유지되어야 한다는 것이다. 이 규칙을 지키지 못할 경우에는 객체 상태가 어떻게 변하는지를 API 문서에 명확하게 서술해야 한다. 불행히도, 현존하는 API 문서 상당수는 이 기준을 만족하지 못하고 있다.

규칙 65 예외를 무시하지 마라

API 설계자가 예외가 발생할 수 있다고 선언했다면, 그것은 API를 이용할 프로그래머에게 무언가를 알리려는 것이다. 호출 대상 메서드를 빈 catch 블록이 붙은 try 문으로 감싸면, 예외를 쉽게 무시할 수 있다.

// catch 블록을 비워 놓으면 예외는 무시된다 - 심히 의심스러운 코드!
try {
    ...
} catch (SomeException e) {
}

빈 catch 블록은 예외적 상황을 반드시 처리하도록 강제한다는 목적에 배치된다. 적어도 catch 블록 안에는 예외를 무시해도 괜찮은 이유라도 주석으로 남겨 두어야 한다.

예외를 무시해도 괜찮은 경우를 하나 예로 들자면, FileInputStream을 닫는 경우일 것이다. 파일 상태를 바꾸지 않았고 그래서 복구 작업을 할 필요도 없으며, 필요한 정보는 파일에서 모두 읽었으니 진행 중인 연산을 중단할 이유도 없다. 하지만 그렇더라도 로그는 남겨두는 것이 좋다. 그래야 예외가 자주 발생하는 것을 알았을 떄 그 원인을 분석해 볼 수 있기 때문이다.

7월 5일 2장 객체의 생성과 소멸

2장 객체의 생성과 삭제 

규칙 1 생성자 대신 정적 팩터리 메서드를 사용할 수 없는지 생각해 보라 
규칙 2 생성자 인자가 많을 때는 Builder 패턴 적용을 고려하라. 
규칙 3 private 생성자나 enum 자료형은 싱글턴 패턴을 따르도록 설계하라 
규칙 4 객체 생성을 막을 때는 private 생성자를 사용하라 
규칙 5 불필요한 객체는 만들지 말라 
규칙 6 유효기간이 지난 객체 참조는 폐기하라 
규칙 7 종료자 사용을 피하라 

 

관련글 더보기

댓글 영역