Search
💦

어? 이게 왜? (Exception)

생성일
2022/04/07 21:28
태그

1단계 : 어?, 2단계: 이게 왜?, 3단계: 아...(서론 및 주제)

우리는 코딩을 하며 수없이 많은 예외를 만난다. NullPointerException을 안 만나고 개발하는 사람은 없을 것이다. 그만큼 우리는 예외라는 친구를 자주 만난다. 하지만 우리는 이 친구를 해결하기 위하여 try-catch를 남발하고 있다. 그 덕분에 완성된 코드를 보면 코드 블록이 어마어마하게 쌓인 내 코드를 볼 수 있다. 그렇다면 어떻게 해야 예외를 조금 더 잘 처리할 수 있을까? 본질적으로 “어? 이게 왜?”를 제거할 수 있을까? 그것을 알아보고자 한다.
토비의 스프링 주제에서 예외 처리에 관련한 주제들

예외가 뭘까? (개념)

개념적으로 예외는 일반적 상황에서 프로그램이 처리되는 동안 특정한 문제가 일어나는 것이다. 에러 같은 경우는 부정확하거나 올바르지 않은 동작을 지칭한다.
Search
제목
1열
2열
시스템 자원이 부족하여 오류가 발생
코드로 인해 예외가 발생
오류는 복구 할 수 없음
예외는 복구 가능
프로그램 코드에서 오류를 처리 할 수 있는 방법은 없다.
예외는 3개의 키워드를 사용하여 처리된다. “try-catch, throw”
오류가 감지되면 프로그램이 비정상적으로 종료
예외가 감지되면 throw 및 catch 키워드에 따라 예외가 발생
오류는 검사 되지 않는 유형
예외는 체크 된 또는 언체크된 유형으로 분류
java에서 오류는 java.lang.Error 패키지
java에서 예외는 java.lang.Exception 패키지
OutOfMemory, StackOverFlow
체크 : NoSuchMethodException .. 언체크 : NullPointerException

오류와 예외의 차이점

시스템 리소스가 부족한 경우에만 오류가 발생하지만 코드에 문제가 있는 경우 예외가 발생한다.
오류는 복구 할 수 없지만 예외를 처리 할 코드(try-catch)를 준비하면 예외는 복구 할 수 있다.
오류는 결코 처리 할 수 없다.
오류는 발생하면 프로그램이 비정상적 종료 되지만 예외는 콜스택에서 handler를 찾고 찾지못하면 종료된다(결국 한번에 프로그램이 죽지는 않는다).
오류는 컴파일러가 걸러내지 못한다. 하지만 예외는 걸러낼 수 있다.
위의 내용에서 알 수 있듯이 우리는 에러는 처리할 수 없다. 하지만 예외는 처리할 수 있다. 그럼 “왜 예외를 처리해야 하는 것인가?” 아래의 사진을 보면 알 수 있다. 필자의 경우 예외처리를 개판으로하면 저런 상황이 발생한다고 생각한다. 결국 예외도 아무런 처리를 해주지 않으면 프로그램 자체가 멈추기 때문이다. 하지만 어떠한 처리를 해주기 때문에 의도하지 않은 동작을 만들게 된다. 그래서 의도하지 않은 동작도 잘 동작하게 만들어줄 필요성이 있다.

예외에 대해서 좀 더 알아볼까?

예외는 기본적으로 체크와 언체크로 나뉜다. 일반적으로 자바에서는 체크예외라고 하면 Exception클래스의 서브클래스 중 RuntimeException을 상속하지 않은 것을 말한다. 또한 체크 예외는 복구가 가능한 예외이며 복구를 해줘야한다. 언체크예외라고 하면 RuntimeException을 상속한 것들이다. 언체크예외는 복구가 불가능한 예외이다. 만약 체크 예외를 throws로 던진다면 처리를 해줘야한다(복구가 가능하기 때문에). 하지만 언체크의 경우에는 처리를 해줄 필요없다(복구가 불가능하기 때문에). 표를 보며 자세히 알아보자.
Search
제목
1열
2열
check Exception
uncheck Exception
컴파일 시점
런타임 시점
반드시 예외처리를 해줘야한다. 복구 가능
명시적으로는 하지 않아도 된다. 복구 불가능
예외 발생 시 롤백 하지 않는다. (복구가 가능하기 때문에 개발자가 여부를 만들어야 하는 개념 때문에 기본 정책에서 빠진 것 같다.)
예외 발생 시 롤백한다. (명시적으로 처리할 필요 없는 것도 존재하기 때문에 퍼시스턴트 영역에 무슨일이 발생할지 모르기 때문에 기본정책으로 정한 것 같다.)
SQLException, IOException, ClassNotFoundException 등
NullPointerException, ClassCastException
그럼 체크만 신중히 처리해주면 될까? 결론은 절대 아니다. 언체크도 처리해줘야 할 상황이 있다는 것이다. 단지 프로그램 개념적으로 체크 예외는 복구가 가능하고 언체크는 복구가 불가능하다는 것이지 처리를 하지 않아도 된다는 의미는 아니다.

그럼 처리 방법은 뭐가 있을까?

기본적으로 예외 처리는 3가지로 들 수 있다.

예외 복구 (수정 필요)

예외 회피

예외 전환

아리송한 주제 체크만 잡아서 복구, 회피, 전환을 해야 할까? 아니면 언체크를 잡아서 복구, 회피, 전환을 해도될까? 아니면 둘다 적용해야 하는 개념일까? 그건 “모두 해야한다”가 될 것 같다. 근데 조금 찾아보면서 느낀 점은 대부분의 예외는 런타임도중에는 거의 복구가 불가능하다. 그래서 언체크의 처리(전환, 회피 등)를 무시할 수 있다. 하지만 아래의 동영상에서도 알 수 있듯 해야할 때가 존재한다. 그래서 어디 한 곳에 국한되게 생각하지 말자.

그렇다면 스프링은 어떻게 체크, 언체크를 대하고 있을까?(방법)

스프링의 SQLException은 추상화된 DataAccessException으로 포장되어 전달하고 있다. 왜 일까? 우리는 앞서 checkExceptionuncheckException의 차이를 알았다. 가장 중요한 차이점은 복구 가능과 불가능이었다. 그렇다면 DataAccessException은 무슨 예외일까? 아래의 그림에서 볼 수 있듯 RuntimeException의 한 종류다. 왜 스프링은 SqlExceptionRuntimeException(uncheckException)으로 포장하는 것일까? 잘 생각해보면 당연하다. 우리가 데이터베이스를 사용한다면 거기서 발생하는 예외는 복구가 가능할까? 우리의 예외 복구를 살펴보자 catch블록 안에서 처리할 수 있어야 예외를 복구 할 수 있다. 그런데 데이터베이스의 SQLExceptionDuplicateUserIdException은 catch 블록 안에서 처리가 불가능하다(결국 사용자와 상호작용이 필요하다. 즉, catch블록을 벗어났다가 다시 돌아오는 것. 그럼 일반적인 코드 흐름이 아니다). 그래서 거의 99%에 해당하는 SQLExceptionRuntimeException(uncheckException)을 상속한 DataAccessException으로 포장해서 사용하는 것이다.
이러한 맥락으로 스프링은 DataAccessException이라는 추상화된 SQLException을 정의해서 사용하고 있다. 이 덕분에 SQLException(checkException)을 사용할 때 보다 데이터베이스의 종류에 종속적이지 않게 되었고 다양한 처리가 가능해진 것이다.
어? 그럼 다 Runtime, uncheck 라면 처리할 필요 없는 거 아니야? 처리를 하지 않기 위한 목적으로 Runtime을 상속받은 DataAccessException을 사용하는게 아니다. DataAccessException의 태생 목적은 SQLException의 추상화이다. 추상화의 목적은 종속성의 제거이다. 그렇다면 처리는 다른 관점에서 생각해야 하는 것이다. a few moments later 조금 시간이 지나고 다시 정리해보는 중에 발견했다. 종속성 제거의 목적도 있지만 생각해보면 Spring자체가 서버환경에서 주로 많이 쓰인다. 그렇다면 CheckException은 의미가 없어진다는 것을 조금 느낄 수 있었다. 그 이유는 CheckException의 확인 시점은 컴파일 시점이다. 그렇다면 진짜 유저, 사용자가 이용하는 Spring의 서버 환경은 컴파일 이후에 이루어 진다. 그래서 당연하게도 예외가 발생하면 복구할 방법이 없다. 당연히 잘못된 값이 들어갈 수 있는 DB는 더더욱 그렇다. 그래서 UncheckException으로 만들어주는 것 같다. 좀 많이 복합적이라 헷갈린다.
참 어렵다.

예외 처리 전략 (스프링의 런타임 예외의 보편화)

일반적으로 체크 예외 보다 언체크 예외를 사용하는 것을 좀 더 지지하는 사람이 많다. 왜 일까? 런타임 예외의 보편화는 종속성을 제거하는데 큰 도움이 되기 때문이다. 체크예외를 그대로 사용할 경우 해당 메서드를 사용하는 클라이언트 코드는 throw코드를 처리하기 위한 코드가 강제된다. 하지만 언체크의 경우 throw코드가 강제되지 않을 뿐더러 오히려 하나의 예외로 보편화시킬 수 있다는 장점이 있다. 그래서 스프링의 SQLException을 언체크 즉, 런타임 예외로 포장는 전략을 사용하는 것이다.
현재는 JdbcTemplate을 이용하기 때문에 스프링이 자동으로 DataAccessException이라는 예외 구조 안에 있는 예외 중 하나로 SQLException을 변경해주지만, 사실 스프링 프레임워크가 없더라도 얼마든지 스스로 구현할 수 있는 부분이다. 스프링의 기능을 사용할 수 없는 경우라도 SQLException을 굳이 그대로 두어 의미 없는 throws만을 작성하지 말고, 우리가 배웠던 예외 전환과 예외 감싸기를 활용하여 의미 있는 RuntimeException으로 바꿔주면 소프트웨어의 품질이 한층 높아질 수 있다. 또한 우리가 직접 스프링의 DataAccessException계층의 예외로 전환해줄 수도 있음을 기억하자.

좀 더 궁금한 주제 예외 처리의 Best Practice는 없는가?

catch 블록에서 예외를 삼키지 말라
메서드가 던질 수 있는 세부적인 체크 예외를 선언하라
예외 클래스를 포착하지 말고 특정 하위 클래스를 포착하라
던질 수 있는 클래스를 절대 잡지 말라
스택 추적이 손실되지 않도록 항상 사용자 정의 예외에서 예외를 올바르게 랩핑하라
예외를 기록하거나 throw 하지만 둘 다 수행하지 마라
finally 블록에서 예외를 던지지 말라
항상 실제로 처리할 수 있는 예외만 catch하라
printStackTrace() 문 또는 유사한 방법을 사용하지 마라
예외를 처리하지 않으려면 catch 블록 대신 finally 블록을 사용하라
Throw early catch late 원칙을 기억하라 (이건 잘 모르겠다.)
예외 처리 후 항상 정리
메서드에서 관련 예외만 throw
프로그램에서 흐름 제어에 예외를 사용하지 마라
요청 처리 초기에 불리한 조건을 포착하기 위해 사용자 입력을 검증하라
단일 로그 메시지에 예외에 대한 모든 정보를 항상 포함하라
가능한 많은 정보를 제공하기 위해 모든 관련 정보를 예외에 전달하라
반복적인 try-catch는 템플릿 메서드 패턴을 활용하라
javadoc을 사용하여 애플리케이션의 모든 예외 문서화

이펙티브 자바에서 바라보는 예외 처리 관점

예외는 진짜 예외 상황에만 사용하라.
복구할 수 있는 상황에는 check Exception을, 프로그래밍 오류에는 Uncheck Exception을 사용하라.
필요 없는 check 예외 사용은 피하라.
표준 예외를 사용하라.
추상화 수준에 맞는 예외를 던져라.
메서드가 던지는 모든 예외를 문서화하라.
예외의 상세 메시지에 실패 관련 정보를 담아라.
가능한 한 실패원자적으로 만들라.
예외를 무시하지 말라.

예외 처리에 대한 생각 정리(읽어보면 좋은 이야기)

자바 컴스텀 예외의 4가지 Best Practices

정리

예외는 검사, 비검사, 에러로 나눌 수 있다.
복구 가능한 경우 검사 예외를, 복구가 불가능한 경우 비검사 예외를 사용한다.
예외의 처리 방법에는 복구, 회피, 전환이 존재한다. 적절하다고 판단되는 걸 선택하여 사용한다.
스프링의 경우 대부분의 SQLException을 DataAccessException으로 포장한다. 그 이유는 종속성 제거, 서버환경의 이유를 들 수 있다.
검사 예외를 사용하는 경우보다 비검사 예외를 선호하는 경우가 더 많아지고 지지하는 사람이 더 많다.
예외처리의 Best Practice를 보며 예외처리의 기준을 잡는 것이 좋다.

필자의 예외처리 관점 체크 리스트(당연히 틀릴 수도 맞을 수도 있다. 태클을 환영합니다.)

1.
최대한 예외가 발생하지 않게 만든다.
예시) 검사메서드를 통하여 파라미터 등을 확인한 이후 실행한다.
2.
예외가 발생한다면 코드의 흐름을 제어하는 방향이 아닌 예외로써 받아들인다.
예시) 배열에 담긴 문자들을 숫자로 변환하는 과정에서 예외가 발생한다면 멈추게 만든다. 하지만 배열의 중간 정도에서 예외가 발생했고 모든 배열의 요소를 일단은 전부 변환해야 한다면 검사 메서드를 만들어 가능 여부를 판단한다.
3.
예외의 처리 방법 중 복구, 회피, 전환의 경우 우선적으로는 전환을 선택한다.
비검사 예외로의 전환을 통하여 우선적으로 종속성을 제거할 수 있다. 종속성의 제거는 코드의 영향을 적게 미치기 때문에 3개중에 선택해도 손해 볼 이유는 없다고 판단된다.
손해 이유
복구 : 복구가 가능하다고 판단 된 경우는 검사예외를 사용한다. 결국 예외가 발생할 법한 메서드에 throw 키워드가 붙는다. 코드를 조금 어렵게 만들 수 있다. 그리고 서버환경이라면 복구가 99% 불가능한 경우가 존재한다. 왜냐하면 컴파일러에 의해 검사예외를 잡아야하는데 서버는 이미 런타임이기 때문이다.
회피 : 회피는 내가 아닌 나를 호출한 상위 레이어 또는 호출자로 예외를 던진다. 그런데 잘 생각해보면 전환이랑 같은 맥락인데 키워드만 추가한 꼴이다. 그렇다면 복구 불가능하다고 판단된다면 비검사로 전환하는게 더 유리할 수 있다는 생각을 했다.
4.
호출의 끝에는 로그를 남길 수 있는 구문을 추가하자.
전환을 고려했기 때문에 가능하다. 예외의 손실이 없기 때문에 레이어 최상단에는 로그를 남길 수 있다.
5.
사용자와 맞닿는 레이어는 사용자에게 예외가 발생했음을 알릴 수 있는 구문을 추가하자.
추상화 수준에 맞는 메시지, 예외를 주는 것과 같은 맥락이다. 사용자는 내가 사용하는 예외를 던져도 알 수 없다. 레이어의 수준에 따라 알아 먹을 수 있는 예외와 메시지를 전달하자.