Exception 처리, 어떻게 하는게 좋을까?
Error와 Exception의 차이
- 에러 : 애초에 예상이 불가능한 것.
- 예외 : 발생을 예상할 수 있는 것. 그리고 예상할 수 있기 때문에 그에 대한 대비로 try-catch가 있는 것.
- checked exception
- 컴파일 타임에 경고를 해주는 예외. 예외 처리가 안되어 있으면 컴파일이 안된다.
- 컴파일 타임에 발생하는 Exception이라고 말하기는 좀 그렇다.
- 정확히는 Exception은 Runtime에 발생하며 컴파일 타임에 경고를 해주는거지.
- 대표적인게
IOException, SQLException
- unchecked exception
- 컴파일 타임에 경고를 안해주는 예외. 예외 처리가 안되어 있어도 컴파일이 된다.
- 대표적인 것이
NPE, IndexOutOfBoundsException
- 자바에서는
RuntimeException
과 그 하위 예외들이 unchecked exception, 나머지는 checked exception이다.
- checked exception
예외를 어떻게 처리할지 생각하기 전에, 예외를 마주쳤다는 것은 무엇을 의미하는가?
1. try-catch는 조건문이 아니다. try-catch를 if처럼 쓰지 마라.
예외는 조건의 의미가 들어있는게 아니다.
의미 상 조건에 맞게 처리해야 하는 경우는 if, 예상치 못한 상황이 왔을 때 어떻게 행동할 것인가는 try - except로 처리한다.
조건문은 예상된 flow대로 흘러가는 반면 예외는 try 안의 코드를 실행하다 언제 실행 흐름이 except로 넘어갈지 모른다는 점도 생각해 보면 둘을 구분하는데 도움이 된다.
예를 들어 ‘argv의 개수가 적다’는 외부에서 어떻게 넘기느냐에 달려 있으므로 충분히 예상할 수 있다.
이렇게 예상할 수 있는 상황은 그냥 일단 argv[0]에 접근하고 안되면 발생하는 Exception을 catch하여 처리하는 것 보다, if
로 length check를 하는게 낫다.
[!tip] 괜찮은 경험 법칙이 하나 있다. 예외를 던지는 코드를 프로그램 종료(또는 처리 flow 종료) 코드로 바꿔도 프로그램이 여전히 정상 동작할지를 따져보는 것이다. 정상 동작하지 않을 것 같다면 예외를 사용하지 말라는 신호다. 예외 대신 오류를 검출하여 프로그램을 정상 흐름으로 되돌리게끔 처리해야 한다.
2. API에서 발생하는 예외는 “이렇게 쓰지 마라.”를 의미할 수도 있다.
예를 들면 JdbcTemplate.queryForObject()
는 DB에서 가져온 데이터가 없으면(null) 예외를 발생시킨다.
근데 이 메서드는 애초에, 꼭 이 데이터를 null 없이 받아야 한다라고 설계되어 있는 메서드다.
그래서 이렇게 없는 데이터를 가져올 수 있는 케이스가 예상이 된다? 라면 예외가 발생하지 않는 다른 메서드를 사용하고 if
로 처리하는 것이 자연스러울 수 있다.
Global Exception Handling은 좋은 패턴이지만 모든 예외를 Global Exception Handler에서만 처리하는 것은 좋지 않다.
- 예를 들어 위 2번과 같은 상황에서, “어떤 입력에 대해서는
queryForObject()
에서RuntimeException
이 발생할 것임을 알지만 그 것을 감안하고서라도 이 메서드를 꼭 사용해야 한다.”와 같은 케이스를 생각해보자.- 이미 Global RuntimeException Handler가 등록 되어 있다고 하더라도, 굳이 이 Exception을 그리로 보내야 할 이유가 있는가?
- 오히려 여기서 잡아서 pass하고 null을 리턴하는게 맞을지도 모른다.
- 이런 경우 해당 메서드 내에서 try-catch로 예외에 대한 적절한 처리를 하는게 맞을 수 있다.
- 즉, Global Exception Handling은 한 가지의 선택지가 될 수 있는 것이지 모든 처리를 반드시 여기서 하라는 얘기는 아니다.
Global Exception Handler를 사용하면 좋은 케이스는 다음과 같다.
RuntimeException
에 대한 DefaultHandler를 지정할 때. (handleDefaultRuntimeException) NPE같은 일일히 예상할 수 없는 unchecked exception에 대해 DefaultHandler를 지정하게 되면 일관된 응답을 기대할 수 있다. 로깅도 용이하고 갑자기 프로그램이 뻗는 상황도 방지할 수 있을 것이다.- 여러 곳에서 비슷한 예외가 발생하고 이에 대해 일관된 처리가 필요하거나, 일관된 응답이 필요할 때.
- 최상위 unchecked인 RuntimeException에 대한 핸들러가 이미 존재한다고 해도, 그 자식인 다른 unchecked에 대한 핸들러를 지정해서 조금 더 구체적으로 예외 상황을 처리하는 것이 필요할 때가 많다.
- 물론 checked에 대해서도 가능함.
- checked exception과 unchecked exception에 대한 처리 로직을 한 곳으로 모으고 싶을 때.
- try-catch를 하는 부분이 지저분하거나, throws를 해서 메서드 시그니처가 지저분해지는 것을 막을 때
로깅, GlobalExceptionHandler에서? 각 사용처에서?
- GlobalExceptionHandler 들어가기 전에 중간에 잡아서 처리해야 하는 경우가 있는지? 없는지? 로 구분 할 수 있다. (중간에 잡아서 처리하는 경우가 있다면 각 사용처에서 로깅 필수.)
- 중요한 실패 로그라면 각 사용처에서 로깅해줘야 누락이 없다.
checked exception도 Global Exception Handler에서 처리할 수 있다.
checked exception은 반드시 try-catch나 상위로 throws 둘 중 하나를 해야 하기 때문에, checked exception을 처리하는 척 하면서 unchecked exception으로 래핑해서 다시 던지면 Global Handler로 들어가게 할 수 있다.
RuntimeException을 상속 받은 RuntimeIOException 클래스를 하나 만들고, try-catch로 감싼 다음 RuntimeIOException에 checked exception을 담아서 던지도록 한다. (stack trace에 남는다.)
1
2
3
4
5
try {
// IOException 발생 ( checked exception )
} catch (Exception e) {
throw new RuntimeIOException(e) // this is unchecked exception!!!
}
1
2
3
/* global exception handler */
@ExceptionHandler(RuntimeIOException.class)
public void handleRuntimeIOException(final RuntimeIOException runtimeIOException)
CommonMessageException
실패해도 return VS 실패이면 throw?
return을 사용해야 하는 경우)
- 실패 임에도 유용한 데이터를 상위로 전달해야 하는 경우, Exception으로는 불가능하고 반드시 return을 써야 한다.
- caller에게 즉각적인 처리를 요구하고 싶은 경우. (throw는 caller가 그냥 pass해버릴 수 있다)
throw을 사용해야 하는 경우)
- 리턴 타입에 구애받지 않고, CustomException에 정형화된 파라미터(code, message)를 넣어서 던지고 싶은 경우
- ExceptionHandler 까지 한 번에 탈출하고 싶은 경우. (Return 기반은 call stack 상 몇 단계 위에서 처리하고 싶은 경우 해당 Return type을 상위 메서드 까지 끌고가야 한다.)
클린 코드 : ErrorCode 리턴 보다는 Exception을 던지는 것이 좋다. (return vs throw)
- 간단한 메서드라면 boolean 등을 리턴해도 되지만, 이러한 메서드 들을 조합하는 메인 public 메서드(유형1)인 경우 리턴 타입이 애매해진다.
- 메서드 내에서 실패 사유가 여러가지라, {성공 여부, 실패 사유, T} 를 같이 리턴해야 한다.
- 이렇게 에러와 관련된 정보를 return하게되면, return type이 Result<T, R>와 같은 타입으로 강제될 수 밖에 없다.
https://nesoy.github.io/articles/2018-02/CleanCode-ErrorHandle
위 링크에서 맨 위에 있는 예제를 보면, if 중첩을 try-catch 구조로 변경함. try-catch로 변경하면서 꽤 많은 추상화 작업을 했다는게 주목할만함.
- 먼저 DeviceShutDownError를 하나 만들어야 하고,
- getHandle() 함수도 throw하도록 수정해야하고,
- retrieveDeviceRecord()함수도 throw하도록 수정해야 한다.
- 로직을 tryToShutDown 이라는 별도 메서드로 빼서 sendShutDown은 try-catch를 하는 임무, tryToShutDown은 자잘한 메서드를 호출하는 임무로 나눴다.(이건 굳이 안해도 되는 작업인 것 같다. 단, catch가 여러개가 된다면 빼는게 좋긴 하겠지)
Exception을 담아서 던지는게 좀 복잡해져서 단순 catch문에서 이들을 구분하기가 어려워졌다면 다음과 같이 구분.
1
2
Throwable e = ExceptionUtils.getRootCause(_e);
if (!(e instanceof SocketTimeoutException)) {
참고
NullPointerException, ArithmeticException 등 일부 Exception의 stacktrace가 비어있는 현상
분명히 stacktrace를 로그로 찍게 되어 있는데, 막상 찍어보면 ""
빈 스트링이 들어 있는 경우가 있다.
When does JVM start to omit stack traces?
요약하면, 유저 코드에서 던지는 Exception이 아니라 JVM 자체적으로 던지는 Exception 중 일부 케이스에 대해서는, JVM에서 최적화 한다고 stacktrace를 제거해서 던진다 !
단, 첫 번째 Exception 부터 stacktrace를 제거하지는 않고, 반복해서 던지는 경우 2번째 Exception 부터 제거하게 되므로 최초 발생 로그를 찾아보면 stacktrace가 남아 있다.
런타임에 checked exception도 잡힐 수 있다.
https://www.baeldung.com/java-undeclaredthrowableexception
1
2
3
4
5
6
7
8
9
try {
decrypt.process(it) // which is not a spring proxy call.
} catch (ex: RuntimeException) {
// passed
} catch (ex: UndeclaredThrowableException) {
// passed
} catch (ex: IllegalBlockSizeException) {
// !! catched !!
}
IllegalBlockSizeException
는 checked exception이다. Spring 영역을 거치지 않는 한, checked exception이 그대로 throw 된다.- Spring 영역을 거치게 되는 경우, Aspect가 잡아서,
UndeclaredThrowableException
로 wrapping해서 던지게 된다. (이는RuntimeException
이다)
=> RuntimeException
으로 잡는 것 보다, Exception
으로 잡는게 더 안전하다.