Java - Exception ( Feat. Effective Java)

이전 글 에서 Error 와 Exception 에 대해서 정리해보았다.

이번 글에서는 Java 개발자라면 읽어야 한다는 Effective JAVA Third Edition 기준으로 예외 핸들링에 대해서 정리해보겠다.


책에서는 이렇게 말하고있다. 

"예외를 제대로 활용한다면 프로그램의 가독성, 신뢰성, 유지보수성이 높아지지만, 잘못 사용하면 반대의 효과만 나타낸다."

그렇다면, 제대로 활용하는 방법에 대해서 책이 알려주는 내용을 정리해보겠다.


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

책에서는 운이 없다면 언젠가 다음과 같은 코드를 마주할지 모른다고 한다. 나는 운이 없는가 보다.. ㅎㅎ

해당 코드를 보고 무슨 일은 하는 코드인지 알기 어렵다. 전혀 직관적이지 않다. 해당 이유만으로 코드를 이렇게 작성하면 안된다는 이유가 분명하다.

해당 코드를 분석하면, 아주 끔찍한 방식으로 배열을 순회하고 있다. 무한루프를 돌다가 배열의 끝에 도달하여 ArrayIndexOutOfBoundsException 이 발생하면 끝이 난다. 

해당 코드를 표준적인 관용구대로 작성했다면 바로 이해할 수 있을것이다. 


왜 예외를 써서 끔찍한 코드를 만들어 냈을까? 
책에서는 잘못된 추론을 근거로 성능을 높이려 시도할 경우 발생할 수 있다고 한다.
나의 개인적인 생각으로는 아마 해당 코드 작성자가 정확한 배열 순회 메커니즘을 이해하지 못한다거나, 혹은 겉멋(?)이 들어서가 아닐까 의심이 된다.

ArrayIndexOutOfBoundsException 을 사용한 코드가 표준 관용구보다 성능이 안좋다. try-catch 블록 안에 코드를 넣으면 JVM 이 적용할 수 있는 최적화가 제한 된다.

try catch block 사용은 소량의 데이터 케이스에서 성능 차이는 미비하여, 하드웨어 성능으로 어느정도 커버가 가능하다고 말할 수 있다. 

하지만 제일 중요한 점은 가독성을 해치는 것이라고 나는 생각한다. 이는 유지보수에 상당한 어려움을 유발한다. try catch block 안에 버그가 숨어 있다면 흐름 제어에 쓰인 예외가 이 버그를 숨겨 디버깅을 매우매우매우매우 어렵게 만든다.

try catch block 을 사용하지 않고, 아래와 같이 표준 관용구를 사용했더라면 버그는 즉시 스택 추적 정보를 남기고 해당 스레드를 종료 시킬 것이다. 

반대로, 전자와 같이 예외를 사용한 반복문은 버그 때문에 발생한 엉뚱한 예외를 정상적인 반복문 종료 상황으로 오해하고 넘어가고, 이는 유지 보수에 끔찍함을 더해준다.

따라서, 예외는 오직 예외 상황에서만 써야 한다. 절대로 일상적인 제어 흐름용으로 쓰여선 안 된다.
잘 설계된 API 라면 클라이언트가 정상적인 제어 흐름에서 예외를 사용할 일이 없게 해야 한다.


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

책에서 말하는 검사 예외는 Checked Exception 을 의미하며, 런타임 예외는 Unchecked Exception 을 의미한다. 

1. 호출하는 쪽에서 복구할 것이라 여겨지는 상황이라면 검사 예외를 사용해라.





Checked Exception 을 던지면 호출자가 해당 예외를 catch 로 잡아 처리하거나 더 바깥으로 전파하도록 강제하게 된다. 따라서 메서드 선언에 포함된 Checked Exception 각각은 해당 메서드를 호출했을 때 발생할 수 있는 유력한 결과임을 해당 API 사용자에게 알려주는 것이다.

일반적으로 복구할 수 있는 조건일 때 발생한다. 따라서 호출자가 예외 상황에서 벗어나는 데 필요한 정보를 알려주는 메서드를 함께 제공하는 것이 중요하다.

Class.forName() 메서드를 통해서 간단한 예제를 작성했다.
첫 번째로 getClassCatch() 메서드는 Class.forName() 메서드를 호출하고 바로 해당 부분에서 catch 로 잡아서 처리하는 방법이다.
두 번째로 getClassThrowException() 메서드는 호출한 해당 메서드 보다 바깥인 main() 메서드로 전파하여 main() 메서드에서 catch 로 잡아 처리하도록 하였다.
catch 블락에서 복구의 로직이 없고 printStackTrace() 를 넣은 점은 단적인 예라서 양해를 구한다.


2. 프로그래밍 오류를 나타낼 때는 런타임 예외를 사용해라.

런타임 예외의 대부분은 전제조건을 만족하지 못했을 때 발생한다. 이는 단순히 클라이언트가 해당 API 명세에 기록된 제약을 지키지 못했다는 뜻이다. 단적인 예로 array 의 index 는 '0 ~ array length - 1' 여야 한다. ArrayIndexOutOfBoundsException 이 발생했다는 것은 클라이언트가 해당 전제조건을 지키지 못했다는 뜻이다.

여기서 까다로운 점은, 복구할 수 있는 상황인지 프로그래밍 오류인지가 항상 명확하지 않다는 점이다.
예를 들어, 자원 고갈의 경우 말도 안되는 크기의 배열 ( 1000억 ) 을 할당해 생긴 프로그래밍 오류일 수도 있고, 진짜로 자원이 부족해서 생겼을 수도 있다.

에러는 보통  JVM 이 자원 부족, 불변식 깨짐 등 더 이상 수행이 불가능한 상황을 나타낼 때 사용한다. Java 언어 명세가 요구하는 것은 아니지만, 업계에 널리 퍼진 규약이다. 따라서 Error 를 상속하는 일은 없어야 한다.

정리 하면, 개발자가 구현하는 비검사 throwable 은 모두 RuntimeException 의 하위 클래스여야 한다.


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

해당 섹션 item 71 에서 내 얘기를 하고 있다. 검사 예외(Checked Exception) 을 싫어하는 자바 프로그래머가 많지만 제대로 활용하면 API 프로그램의 질을 높일 수 있다. 바로 내 얘기다. 나는 Checked Exception 을 싫어한다. 내가 싫어하는 이유도 책에서는 명확하게 기술했다.

1. 검사 예외를 과하게 사용하면 오히려 쓰기 불편한 API가 된다. 검사 예외를 던지는 메서드는 호출하는 코드에서 catch 블록으로 처리하거나 더 바깥으로 전파시켜야 한다. 이는 해당 API 사용자에게 부담이 된다.

2. 검사 예외를 던지는 메서드는 스트림 안에서 직접 사용할 수 없다. JDK 1.8 기준으로 부담이 더욱 커졌다.
(나는 stream 을 좋아한다.)


아래의 간단한 예제를 보자. 아래의 코드가 최선일까? 아니다. 검사 예외(Checked Exception)를 이렇게 사용할 것이라면 비검사 예외(Unchecked Exception)를 사용해야 한다.




검사예외를 사용해야하는 경우로 3가지를 언급하고 있다. 아래 3가지 중 어디에도 해당하지 않는다면, 비검사 예외를 사용하라고 한다.

1. API 를 제대로 사용해도 발생할 수 있는 예외가 있는 경우

2. 프로그래머가 의미 있는 조치를 취할 수 있는 경우

3. 메서드에서 두 개 이상의 검사 예외만 던지는 경우 -> 이미 다른 검사 예외도 던지는 상황에서 또 다른 검사 예외를 추가하는 경우라면 기껏해야 catch 문 하나 추가하는 선에서 끝이지만, 0 개 에서 1개가 되는 경우 프로그래머에게는 상당한 부담감이 지우게 된다.

검사 예외가 부담일 경우에 취할 수 있는 방법은 적절한 결과 타입을 담은 Optional 을 반환하는 것이다. 해당 방식의 단점은 예외가 발생한 이유를 담을 수 없다라는 점이다.
반면에, 예외를 사용하면 구체적인 예외 타입과 타입이 제공하는 메서드를 통해 부가 정보를 제공할 수 있다.


API 호출자가 예외 상황에서 복구할 방법이 없다면 비검사 예외(Unchecked Exception) 를 던지자. 복구가 가능하고 호출자가 그 처리를 해주길 바란다면, 두 가지 선택지 중 선택하자. 

1. Optional 을 반환

2. 해당 상황을 처리하기에 충분한 정보가 필요하다면 검사 예외를 던지자.

표준 예외를 사용하라

표준 예외를 재사용하면 얻는 게 많다. 

1. 다른 사람이 익히고 사용하기 쉬워진다. 많은 프로그래머에게 이미 익숙해진 규약을 그대로 따르기 때문이다. 내가 작성한 API 를 다른 사람이 사용할 때 읽기 쉽다.

2. 예외 클래스 수가 적을수록 메모리 사용량도 줄고 클래스를 적재하는 시간도 적게 걸린다.

가장 많이 재사용 되는 예외

1. IllegalArgumentException - 호출자가 인수로 부적절한 값을 넘길 때
해당 예외로 모두 뭉뚱그릴 수 있지만, 특수한 일부는 따로 구분해서 쓰는 게 보통이다. null 의 경우 NPE, index 허용 범위 초과시 IndexOutOfBoundsException

2. IllegalStateException - 대상 객체의 상태가 호출된 메서드를 수행하기에 적합하지 않을 때 
ex) 초기화 되지 않은 객체를 사용할 때

3. ConcurrentModificationException - 단일 스레드에서 사용하려고 설계한 객체를 여러 스레드가 동시 수정하려 할 때

4. UnsupportedOperationException - 호출한 메서드를 지원하지 않을 때

5. ArithmeticException, NumberFormatException - 복소수, 유리수 핸들링 시

Exception, RuntimeException, Throwable, Error 는 직접 재사용하지 말자. 이들은 추상클래스라고 생각해야 한다.

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

수행하려는 일과 관련 없어 보이는 예외가 튀어나오면 당황할 것이다. 이는 메서드가 저수준 예외를 처리하지 않고 바깥으로 전파할 때 일어난다. 더욱이, 내부 구현 방식을 드러내어 캡슐화가 깨지며, 윗 레벨 API 를 오염시킨다. 

상위 계층에서는 저수준 예외를 잡아 자신의 추상화 수준에 맞는 예외로 바꿔 던져야 한다. 이를 예외 번역(exception translation) 이라 한다.

아래의 AbstractSequentialList 의 예외 번역이다. 저수준 NoSuchElementException 을 고수준 IndexOutOfBoundsException 으로 바꿔 던진다.


아래 계층의 예외를 예방하거나 스스로 처리할 수 없고, 해당 예외를 상위 계층에 그대로 노출하기 곤란할 경우 예외 번역을 사용하라. 예외 연쇄를 이용하면 상위 계층에는 맥락에 어울리는 고수준 예외를 던지면서 근본 원인도 함께 알려주어 오류를 분석하기 좋다.


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

해당 섹션은 자바독을 잘 활용 해야한다는 내용이다.

검사 예외는 항상 따로따로 선언하고, 각 예외가 발생하는 상황을 자바독의 @throws 태그를 사용하자.

메서드가 던질 수 있는 예외를 각각 @throws 태그로 문서화하되, 비검사 예외는 머서드 선언의 throws 목록에 넣지 말자.

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

사후 분석을 위해 실패 순간의 상황을 정확히 포착해 예외의 상세 메시지에 담아야 한다.
실패 순간을 포작하려면 발생한 예외에 관여된 모든 매개변수와 필드의 값을 실패 메시지에 담아야 한다.

ex ) IndexOutOfBoundsException 의 경우 index 의 최소값, 최대값 그리고 범위를 벗어난 index 값을 상세 메시지에 담아야 한다.




주의할 점은 비밀번호나 암호 키 같은 정보는 담아서는 안된다. 또한 관련 데이터를 모두 담아야 하지만 장황할 필요는 없다.


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

호출된 메서드가 실패하더라도 해당 객체는 메서드 호출 전 상태를 유지해야 한다. 이러한 특정을 실패 원자적(failure-atomic)이라고 한다.

1. 불변 객체로 설계하기 - 메서드가 실패하면 새로운 객체가 생성되지 않을 수 있으나, 기존 객체가 불안정한 생테에 빠질 일이 없다.

2. 매개변수의 유효성 검사하기 - 아래는 ArrayDeque 의 addFirst() 메서드이다. 객체 내부를 변경하기 전에 E e 가 null 인지 유효성 검사를 통해서 예외의 가능성을 걸러내고 있다.



3. 작업 도중 발생하는 실패를 가로채는 복구 코드 작성 - 디스크 기반의 내구성을 보장해야 하는 자료구조에 쓰인다고 한다. 이부분은 내 실력에서 전혀 모르겠다. ^^ 자주 쓰이지 않는다고 한다.

실패 원자성은 일반적으로 권장되는 덕목이지만 항상 달성할 수 있는 것은 아니다. 
두 스레드가 동기화 없이 같은 객체를 수정한다면 해당 객체의 일관성이 깨질 수 있다. 따라서 ConcurrentModificationException 을 잡아냈다고 해서 해당 객체가 여전히 쓸 수 있는 상태라고 가정해서는 안된다.

또한 실패 원자성이 가능하더라도 항상 그리해야하는 것은 아니다. 실패 원자성을 달성하기 위한 비용 및 복잡성이 아주 큰 경우도 있기 때문이다. 따라서 Trade Off 관계를 잘 따져보아야 한다.

예외를 무시하지 말라

API 설계자가 메서드 선언에 예외를 명시한 까닭은 해당 메서드를 사용할 때 적절한 조치가 필요하다는 의미이다. try 로 감싸고 catch 블록에서 아무 일도 하지 않는 일은 절대로 하지말자..



catch 블록을 비워두면 예외가 존재할 이유가 있을까?

물론 예외를 무시해야 할 때도 있다. 예외를 무시하기로 했다면 반드시 catch 블록에 결정 사유를 주석으로 남기고 예외 변수를 ignored 로 바꾸자.




빈 catch 블록으로 못 본 척 지나치면 해당 프로그램은 오류를 내재한 채 동작하게 된다. 그러다 어느 순간 문제의 원인과 아무 상관없는 곳에서 갑자기 죽어버릴 수도 있다. 예외를 적절히 처리하면 오류를 완전히 피할 수 있다. 무시하지 않고 바깥으로 전파되게 놔둬도 최소한 디버깅 정보를 남긴 채 프로그램이 신속히 중단되게 할 수 있다.

댓글

이 블로그의 인기 게시물

About JVM Warm up

About idempotent

About Kafka Basic

About ZGC

sneak peek jitpack

Spring Boot Actuator readiness, liveness probes on k8s

About Websocket minimize data size and data transfer cost on cloud

About G1 GC

대학생 코딩 과제 대행 java, python, oracle 네 번째