애플리케이션에서 발생하는 예외는 체크 예외와 언체크 예외로 구분할 수 있다.
체크 예외는 발생한 예외를 잡아(catch) 체크하고 해당 예외를 복구하거나 회피하는 등의 구체적인 처리를 하는 예외다.
대표적인 Java의 체크 예외는 ClassNotFoundException
등이 있다.
언체크 예외는 예외를 잡아서 해당 예외에 대한 처리를 할 필요가 없는 예외를 말한다. 대표적인 언체크 예외로는 NullPointerException
, ArrayIndexOutOfBoundsException
등이 있다.
개발자가 코드를 잘못 작성하여 발생하는 오류들은 모두 RuntimeException
을 상속한 예외이다.
그러나 Java나 Spring에서 수많은 RuntimeException
을 지원해주지만 이를 이용하여 개발자가 직접 예외를 만들어야 하는 경우도 존재한다.
백엔드 서버와 외부 시스템과의 연도에서 발생하는 에러 처리
은행거래 서비스를 만든다고 상상해보자. 안드로이드나 ios 기반의 서비스가 될 수도 있고 데스크탑의 애플리케이션이 될 수도 있다.
만약 사용자 A가 사용자 B에게 10,000원을 전송하려 했는데, A의 통장 잔고가 부족하다는 메시지를 전달 받고 프로세스가 중단되었다.
백엔드 서버에서 해당 예외가 발생한 경우, 이 예외를 복구할 수 있는 방법은 없다. 잔고가 부족한 상황을 클라이언트에게 알려 클라이언트가 잔고를 채우는 것이 해결 방법이 될 것이다.
이러한 경우 백엔드 서버에서 예외를 의도적으로 던져 클라이언트 쪽에 에러가 발생한 정보를 알려줄 수 있다.
시스템 내부에서 조회하려는 Resource가 없는 경우
지금 학습하고 있는 샘플 애플리케이션의 커피 주문 애플리케이션을 떠올려보자.
회원 정보를 조회하기 위해 클라이언트가 Controller의 getMember()
핸들러 메서드에 요청을 보냈다. 그런데 조회를 해보니 요청 받은 회원의 정보가 없을 수가 있다. 이러한 경우 서비스 계층에서 해당 회원 정보가 없다는 예외를 의도적으로 전송해 클라이언트에게 알려줄 수 있다.
Java에서는 throw
키워드를 사용해 예외를 메서드 바깥으로 던질 수 있다.
던진 예외는 메서드 바깥인 메서드를 호출한 지점으로 던져진다.
서비스 계층에서 예외를 던진 경우 Controller의 핸들러 메서드 쪽에서 잡아서 처리할 수 있다. 서비스 계층의 메서드는 API 계층인 Controller의 핸들러 메서드가 이용하기 때문이다.
서비스 계층에서 예외 던지기 (throw)
@Service
public class MemberService {
...
public Member findMember(long memberId) {
// (1)
throw new RuntimeException("Not found member");
}
...
}
회원 정보를 조회했을 때 조회된 회원이 없다는 가정하에 throw
키워드를 사용해 RuntimeException
객체에 적절한 예외 메시지를 포함한 후 메서드 밖으로 던졌다.
GlobalExceptionAdvice 예외 잡기 (catch)
@RestControllerAdvice
public class GlobalExceptionAdvice {
...
// (1)
@ExceptionHandler
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleResourceNotFoundException(RuntimeException e) {
System.out.println(e.getMessage());
...
return null;
}
}
(1)과 같이 RuntimeException
을 잡아 처리하기 위한 handleResourceNotFoundException()
메서드를 추가했다.
postman에서 MemberController의 getMember()
핸들러 메서드에 요청을 보내면 MemberService에서 RuntimeException
을 던지고, GlobalExceptionAdvice의 handleResourceNotFoundException()
메서드가 해당 RuntimeException
을 잡아 "Not found member"
예외 메시지를 콘솔에 출력한다.
서비스 계층에서 의도적으로 던질 수 있는 예외 상황은 다양하게 존재할 수 있어 handleResourceNotFoundException()
의 메서드명은 적절하지 않다.
그리고 추상적인 RuntimeException
을 그대로 전달 받는 것 또한 바람직하지 않다.
서비스 계층에서 RuntimeException
을 그대로 던지고 Exception Advice에서 RuntimeException
을 그대로 잡는 것은 예외 의도가 명확하지 않고, 구체적으로 어떤 예외가 발생했는지 예외 정보를 얻기가 어렵다.
앞선 예시로 잔고 부족으로 인한 에러 메시지를 백엔드 서버 쪽에 전송한 경우를 다시 떠올려보자.
서버 쪽에서 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;
}
}
위와 같이 서비스 계층에 던질 Custom Exception에 사용할 ExceptionCode를 enum
으로 정의한다.
ExceptionCode를 enum
으로 정의하면 비즈니스 로직에서 발생하는 다양한 유형의 예외를 enum
에 추가하여 사용할 수 있다.
public class BusinessLogicException extends RuntimeException {
@Getter
private ExceptionCode exceptionCode;
public BusinessLogicException(ExceptionCode exceptionCode) {
super(exceptionCode.getMessage());
this.exceptionCode = exceptionCode;
}
}
서비스 계층에서 사용할 Custom Exception인 BusinessLogicException
을 정의한다.
BusinessLogicException
은 RuntimeException
을 상속하고 있고, ExceptionCode
를 멤버 변수로 지정하여 생성자를 통해서 조금 더 구체적인 예외 정보들을 제공할 수 있다.
또한, 서비스 계층에서 개발자가 의도적으로 예외를 던져야 하는 다양한 상황에서 ExceptionCode
정보만 바꿔가며 던질 수 있다.
@Service
public class MemberService {
...
public Member findMember(long memberId) {
// (1)
throw new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND);
}
...
}
서비스 계층에서 RuntimeException
을 던지던 것을 BusinessLogicException에 구체적인 예외 정보(ExceptionCode
)를 던지도록 변경한다.
회원 정보가 존재하지 않는다는 MEMBER_NOT_FOUND
를 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()));
}
}
변경된 부분은 아래와 같다.
메서드명 변경
메서드명이 서비스 계층의 비즈니스 로직 처리에서 발생하는 예외를 처리하는 것을 목적으로 하므로 메서드명을 handleBusinessLogicException
으로 변경했다.
메서드 파라미터 변경
RuntimeExceptio
n을 파라미터로 전달 받던 것을 BusinessLogicException
을 전달 받는 것으로 변경했다.
@ResponseStatus
(HttpStatus.NOT_FOUND
) 제거
@ResponseStatus
애너테이션은 고정된 HttpStatus를 지정하기 때문에 BusinessLogicException
과 같이 다양한 Status를 동적으로 처리할 수 없어, ResponseEntity
를 사용해서 HttpStatus를 동적으로 지정하도록 변경했다.
404
Member Not Found
변경된 코드를 테스트하기 위해 postman에서 MemberController의 getMember()
핸들러 메서드에 요청을 전송하면 위과 같은 출력 결과를 확인할 수 있을 것이다.
@ResponseStatus vs ResponseEntity
하나의 유형으로 고정된 예외를 처리할 경우
@ResponseStatus
로 HttpStatus를 지정하여 사용하면 된다.
BusinessLogicException
처럼 다양한 유형의 Custom Exception을 처리하고자 할 경우ResponseEntity
를 사용하면 된다.