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하여 처리하는 것 보다, ``c if``로 length check를 하는게 낫다.
괜찮은 경험 법칙이 하나 있다. 예외를 던지는 코드를 프로그램 종료(또는 처리 flow 종료) 코드로 바꿔도 프로그램이 여전히 정상 동작할지를 따져보는 것이다. 정상 동작하지 않을 것 같다면 예외를 사용하지 말라는 신호다. 예외 대신 오류를 검출하여 프로그램을 정상 흐름으로 되돌리게끔 처리해야 한다.
2. API에서 발생하는 예외는 "이렇게 쓰지 마라."를 의미할 수도 있다.
예를 들면 ``java 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에 남는다.)
```java
try {
// IOException 발생 ( checked exception )
} catch (Exception e) {
throw new RuntimeIOException(e) // this is unchecked exception!!!
}
```
```java
/* global exception handler */
@ExceptionHandler(RuntimeIOException.class)
public void handleRuntimeIOException(final RuntimeIOException runtimeIOException)
```
CommonMessageException
Enum에 OK("1000", "성공") 이런 식으로 정의해놓는건... 분명 좋은 방식이긴 하지만,
```java
throw new MyException(dr.getServiceName() + "/" + dr.getCorpName());
```
이런 케이스가 커버가 안됨. 특정 메시지나 데이터를 클라이언트 쪽에 내려주고 싶을 때.
다건 업데이트 같은 작업 도중 실패가 발생한 경우, 어느 항목에서 실패가 발생했는지를 클라이언트 쪽에 내려주어야 하는 경우들이 있음.
해결책은? 이런 경우에 사용할 일반적인 Exception을 하나 정의하는 것이 낫다.
```java
public class CommonMessageException extends RuntimeException {
private String externalMessage;
public boolean isIgnorable() { return this.getCause() == null; }
...
```
- 이런 식으로 외부 메시지를 별도로 받아서 내려주도록 구성하면, 내부에 찍는 RuntimeException.message와 externalMessage를 구분할 수 있음.
- 특정 에러는 로그를 찍도록 하고, 나머지는 안찍도록 구분하고 싶은 경우, cause에 담긴 것이 있느냐 없느냐로 구분할 수 있음.
- ResultCode Enum field 자체에 ignorable을 가지고 있는 것은 좋지 않다.
- "미지원 항목" 응답 코드 처럼 ignorable일 때도, not ignorable일 때도 있을 수 있는 경우 응답 코드를 2개 만들어야 해서 나쁘다.
- 서비스 단에서 Exception을 던질 때 이 에러를 무시할지, 말지를 결정할 수 있도록 하는 것은 좋으나, 굳이 무시 가능/불가능으로 이분법 구분 하지 말고, LEVEL 자체를 명시하는 것이 낫다. (하단 참조)
- 여기서는 Message필드만 있고 Code 필드는 따로 안썼는데, 필요한 경우에는
- Enum을 사용하여 Code, Message를 묶고, 생성자 오버로딩으로 Enum을 받도록 구성할 수 있음.
- 생성자에서 이 Enum을 풀어서 code, message 변수에 할당.
- 내부에서는 String code, String message로 가지고 있으므로, business layer에서 특정 문자열을 넘기도록 할 수 있음.
- Enum만 사용하면 dr.getServiceName() 이런 문자열을 business layer에서 넘겨 받는 것이 불가능하니까... 내부 타입은 String이어야함.
- Enum을 사용하여 Code, Message를 묶고, 생성자 오버로딩으로 Enum을 받도록 구성할 수 있음.
isIgnorable 통해 이분법으로 구분하지 말고, 아예 Level을 받아버리는 것이 낫다.
```kt
class CommonException(
val level: Level,
override val message: String,
override val cause: Throwable? = null
) : RuntimeException(message, cause) {}
```
```kt
@ExceptionHandler(CommonException::class)
fun handleCommonException(e: CommonException): ResponseEntity<Any> {
when (e.level) {
Level.TRACE -> log().trace("### message=${e.message}", e.cause)
Level.DEBUG -> log().debug("### message=${e.message}", e.cause)
Level.INFO -> log().info("### message=${e.message}", e.cause)
Level.WARN -> log().warn("### message=${e.message}", e.cause)
Level.ERROR -> log().error("### message=${e.message}", e.cause)
}
if (e.level.toInt() > Level.INFO.toInt()) {
notifier.notify(e.message)
}
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.contentType(MediaType.APPLICATION_JSON)
.body("[${e.level}] ${e.message}");
}
```
- 분명 이건 오류가 맞긴 맞으니 ExceptionHandler에서 처리하고 싶은데, 심각하지 않은 오류라서 Level.INFO로 기록하고 Notify 하고 싶지 않을 수도 있다.
- 이런 세부적인 처리를 하기 위한 별도 CommonException을 정의하고, 기타 Exception(e.g., WebClientRequestException) 발생 시 CommonException으로 변환해서 던지는 방법을 사용.
- WebClientRequestException 전체에 대해 일괄 처리를 적용하는 것 보다 나은 경우가 많음.
- CommonException은 다음과 같은 상황에서 의미가 있음.
- 위 예제는 단순 log, notify만 하기 때문에 각 사용처에서 직접 찍어주어도 되겠으나 이 보다 처리가 복잡한 경우. (or 확장성 고려)
- return...return 이 아니라 한 번에 GlobalExceptionHandler 까지 탈출하는 용도 (그 밖에 orElseThrow 등)
CommonException에 던지고 싶은 data T를 필드로 두는 것도 고려해볼만 하다
'System Design & Arch' 카테고리의 다른 글
[리팩터링 2판] 1장, 2장 - 성능, 경제적인 효과 (0) | 2019.11.18 |
---|---|
공통 비즈니스 로직 분리(제휴사 인터페이스 통합 및 클래스 설계) (0) | 2019.08.21 |
상속 vs 컴포지션 구분 : delegation, decorator, wrapper (0) | 2019.02.04 |
디자인 패턴 - Singleton (0) | 2018.06.22 |
[코딩 노트] 大 : Design Pattern, 디자인 패턴 (0) | 2018.05.14 |