코드스테이츠 45일차
학습 목표
- 서비스 계층에서 의도적으로 예외를 던지는 방법과 상황을 이해할 수 있다.
- 사용자 정의 예외(Custom Exception)를 만들 수 있다.
- 서비스 계층에서 던져진 예외를 API 계층에서 처리할 수 있다.
비즈니스적인 예외 던지기(throw) 및 예외 처리
체크 예외(Checked Exception)와 언체크 예외(Unchecked Exception)
애플리케이션에서 발생하는 예외(Exception)는 크게 체크 예외(Checked Exception)와 언체크 예외(Unchecked Exception)로 구분할 수 있다.
체크 예외는 말 그대로 발생한 예외를 잡아서(catch) 체크한 후에 해당 예외를 복구 하든가 아니면 회피 하든가 등의 어떤 구체적인 처리를 해야 하는 예외이다.(Ex. ClassNotFoundException)
언체크 예외는 예외를 잡아서(catch) 해당 예외에 대한 어떤 처리를 할 필요가 없는 예외를 의미한다. 따라서 언체크 예외는 명시적으로 잡아서(catch) 어떤 처리를 할 필요가 없다. (Ex. NullPointerException, ArrayIndexOutOfBoundsException)
흔히 개발자가 코드를 잘못 작성해서 발생하는 이런 오류들은 모두 RuntimeException을 상속한 예외들이다.
그런데 Java나 Spring에서 수많은 RuntimeException을 지원해주지만 이 RuntimeException을 이용해서 개발자가 직접 예외(Exception)를 만들어야 할 경우도 있다.
개발자가 의도적으로 예외를 던질 수(throw) 있는 상황
- 백엔드 서버와 외부 시스템과의 연동에서 발생하는 에러 처리
Ex)
암호화폐를 예를들어 보자
만일 A라는 사용자가 B라는 사용자에게 코인을 전송하기 위해 백엔드 서버가 블록체인과 API 통신을 하는 과정에서 블록체인으로부터 A 사용자의 코인 잔고가 부족하다는 메시지를 전달 받고, 프로세스가 중단되었다.
백엔드 서버 쪽에서 이런 예외가 발생했다면 이 예외를 복구하려고 시도해봤자 할 수 있는게 없다.
이럴 땐 잔고가 부족한 상황을 클라이언트 쪽에 즉시 알려서 클라이언트가 지갑에 잔고를 채우는게 최선의 방법일 것이다.
이 경우, 백엔드 서버 쪽에서 예외를 의도적으로 던져서 클라이언트 쪽에 에러가 발생한 정보를 알려줄 수 있다.
- 시스템 내부에서 조회하려는 리소스(자원, Resource)가 없는 경우
Ex)
회원 정보를 조회하려고 클라이언트 쪽에서 Controller의 getMember() 핸들러 메서드에 요청을 보낼 때 DB에 조회를 하니 해당하는 회원 정보가 없을 수 있다.
이런 경우 서비스 계층에서 해당 회원 정보가 없다는 예외를 의도적으로 전송해서 클라이언트 쪽에 알려줄 수 있다.
의도적인 예외 던지기/받기(throw/catch)
Java에서는 throw 키워드를 사용해서 예외를 메서드 바깥으로 던질 수 있다.
던져진 예외는 메서드 바깥 즉, 메서드를 호출한 지점으로 던져지게 되는 것이다.
서비스 계층에서 예외를 던지면 이 예외는 어디로 던져질까?
서비스 계층의 메서드는 API 계층인 Controller의 핸들러 메서드가 이용하므로 서비스 계층에서 던져진 예외는 Controller의 핸들러 메서드 쪽에서 잡아서 처리할 수 있다.
Controller에서 발생하는 예외를 Exception Advice에서 처리하도록 공통화 해두었다먼 서비스 계층에서 던진 예외 역시 Exception Advice에서 처리하면 된다.
✔ 서비스 계층에서 예외(Exception) 던지기(throw)
public Member findMember(long memberId) {
// TODO should business logic
// (1)
throw new RuntimeException("Not found member");
}
DB에서 회원 정보를 조회했는데 조회 되는 회원이 없다고 가정했을 떄 (1)에서 throw 키워드를 사용하여 RuntimeException 객체에 적절한 예외 메시지를 포함한 후에 메서드 밖으로 던졌다.
✔ GlobalExceptionAdvice 예외 잡기(catch)
@ExceptionHandler
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleResourceNotFoundException(RuntimeException e) {
System.out.println(e.getMessage());
return null;
}
서비스 계층에서 던져진(throw) RuntimeException을 잡아서 처리하는 예이다.
Postman으로 MemberController의 getMember() 핸들러 메서드에 요청을 보내면 MemberService에서 RuntimeException을 던지고, GlobalExceptionAdvice의 handleResourceNotFoundException() 메서드가 이 RuntimeException을 잡아서 예외 메시지인 “Not found member”를 콘솔에 출력할 것이다.
✔ handleResourceNotFoundException() 메서드의 문제점
서비스 계층에서 의도적으로 던질 수 있는 예외는 회원 정보가 존재하지 않는 경우 외에도 다양하게 있다.
- 회원 등록 시 이미 존재하는 회원일 경우
- 로그인 패스워드 검증에서 패스워드가 일치하지 않는 경우 등
이처럼 서비스 계층에서 의도적으로 던질 수 있는 예외 상황은 다양하게 존재할 수 있기때문에 handleResourceNotFoundException() 의 메서드 이름은 적절하지 않고, 추상적인 RuntimeException을 그대로 전달 받는 것 역시 바람직하지 않다.
정리하자면 서비스 계층에서 RuntimeException을 그대로 던지고(throw), Exception Advice에서 RuntimeException을 그대로 잡는 것(catch)은 예외의 의도가 명확하지 않으며, 구체적으로 어떤 예외가 발생했는지에 대한 예외 정보를 얻는 것이 어렵다.
사용자 정의 예외(Custom Exception) 사용
아까 살펴봤던 암호화폐 간의 코인 전송 예를 다시 보자
이 경우, 서버 쪽에서는 RuntimeException과 같은 추상적인 예외가 아닌 InsufficentBalanceException 같은 해당 예외를 조금 더 구체적으로 표현할 수 있는 Custom Exception을 만들어서 예외를 던질 수 있다.
✔ 예외 코드 정의
public enum ExceptionCode {
MEMBER_NOT_FOUND(404, "Member Not Found");
@Getter
private int status;
@Getter
private String message;
ExceptionCode(int status, String message) {
this.status = status;
this.message = message;
}
}
이처럼 ExceptionCode를 enum으로 정의하면 비즈니스 로직에서 발생하는 다양한 유형의 예외를 enum에 추가해서 사용할 수 있다.
✔ BusinessLogicException 구현
public class BusinessLogicException extends RuntimeException {
@Getter
private ExceptionCode exceptionCode;
public BusinessLogicException(ExceptionCode exceptionCode) {
super(exceptionCode.getMessage());
this.exceptionCode = exceptionCode;
}
}
BusinessLogicException은 RuntimeException을 상속하고 있으며 ExceptionCode를 멤버 변수로 지정하여 생성자를 통해서 조금 더 구체적인 예외 정보들을 제공해줄 수 있다.
그리고 상위 클래스인 RuntimeException의 생성자(super)로 예외 메시지를 전달해준다.
BusinessLogicException은 서비스 계층에서 개발자가 의도적으로 예외를 던져야 하는 다양한 상황에서 ExceptionCode 정보만 바꿔가며 던질 수 있다.
✔ 서비스 계층에 BusinessLogicException 적용
@Service
public class MemberService {
...
...
public Member findMember(long memberId) {
// TODO should business logic
// (1)
throw new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND);
}
...
...
}
여기서는 회원 정보가 존재하지 않는다는 MEMBER_NOT_FOUND를 BusinessLogicException 생성자의 파라미터로 전달했다.
✔ Exception Advice에서 BusinessLogicException 처리
이제 서비스 계층에서 던진 BusinessLogicException을 Exception Advice에서 처리하면 된다.
@RestControllerAdvice
public class GlobalExceptionAdvice {
...
...
@ExceptionHandler
public ResponseEntity handleBusinessLogicException(BusinessLogicException e) {
System.out.println(e.getExceptionCode().getStatus());
System.out.println(e.getMessage());
return new ResponseEntity<>(HttpStatus.valueOf(e.getExceptionCode().getStatus()));
}
}
GlobalExceptionAdvice에서 변경된 부분은?
- 메서드 명 변경
먼저 메서드 명이 서비스 계층의 비즈니스 로직 처리에서 발생하는 예외를 처리하는 것을 목적으로 하기 때문에 메서드 명이 handleBusinessLogicException으로 변경되었다.
- 메서드 파라미터 변경
RuntimeException을 파라미터로 전달 받던 것을 BusinessLogicException을 전달 받는 것으로 변경되었다.
- @ResponseStatus(HttpStatus.NOT_FOUND) 제거
@ResponseStatus 애너테이션은 고정된 HttpStatus를 지정하기 때문에 BusinessLogicException과 같이 다양한 Status를 동적으로 처리할 수 없으므로 ResponseEntity를 사용해서 HttpStatus를 동적으로 지정하도록 변경되었다.
@RestControllerAdvice에서 @ResponseStatus 를 쓸까? ResponseEntity 를 쓸까?
한가지 유형으로 고정된 예외를 처리할 경우에는 @ResponseStatus 로 HttpStatus를 지정해서 사용하면 되고, BusinessLogicException 처럼 다양한 유형의 Custom Exception을 처리하고자 할 경우에는 ResponseEntity 를 사용하면 된다.
총정리
- 체크 예외(Checked Exception)는 예외를 잡아서(catch) 체크한 후에 해당 예외를 복구 하든가 아니면 회피를 하든가 등의 어떤 구체적인 처리를 해야하는 예외이다.
- 언체크 예외(Unchecked Exception)는 예외를 잡아서(catch) 해당 예외에 대한 어떤 처리를 할 필요가 없는 예외를 의미한다.
- RuntimeException을 상속한 예외는 모두 언체크 예외(Unchked Exception)이다.
- RuntimeException을 상속해서 개발자가 직접 사용자 정의 예외(Custom Exception)를 만들 수 있다.
- 사용자 정의 예외(Custom Exception)를 정의해서 서비스 계층의 비즈니스 로직에서 발생하는 다양한 예외를 던질 수 있고, 던져진 예외는 Exception Advice에서 처리할 수 있다.
- @ResponseStatus 애너테이션은 고정된 예외를 처리할 경우에 사용할 수 있다.
- HttpStatus가 동적으로 변경되는 경우에는 ResponseEntity 를 사용한다.