
📌 [API 계층]에서의 예외 처리
Spring MVC에서는 애플리케이션에서 발생하는 예외를 효율적으로 처리할 수 있는 몇 가지 방법을 제공한다.
클라이언트가 전달받는 Response Body는 애플리케이션에서 예외(Exception)가 발생했을 때, 내부적으로 Spring에서 전송해주는 에러 응답 메시지 중 하나이다. Spring에서의 예외 발생은 애플리케이션에 문제가 발생할 경우 이 문제를 알려서 처리하는 것 뿐만 아니라 유효성 검증에 실패했을 때와 같이 이 실패를 하나의 예외로 간주하여 예외를 던져(throw) 예외 처리를 유도한다.
✔️ @ExceptionHandler(컨트롤러 수준)
@ExceptionHandler 애너테이션을 사용하면 컨트롤러 수준의 예외 처리를 관리할 수 있다.
@ExceptionHandler 애너테이션을 붙인 메서드를 컨트롤러 내부에 정의하면 해당 컨트롤러에 발생하는 에러나 유효성 검증의 예외를 처리할 수 있다. 아래의 예제 코드는 컨트롤러에 ExceptionHadler 메서드를 정의하여 유효성 검증의 예외가 발생하면 응답에 예외를 던져주는 코드의 예시이다.
@RestController
@RequestMapping("/v6/members")
@Validated
@Slf4j
public class MemberController {
...
...
@PostMapping
public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
Member member = mapper.memberPostDtoToMember(memberDto);
...
...
@ExceptionHandler
public ResponseEntity handleException(MethodArgumentNotValidException e) {
// (1)
final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
// (2)
return new ResponseEntity<>(fieldErrors, HttpStatus.BAD_REQUEST);
}
}
다음과 같이 @ExceptionHadler 애너테이션을 붙여 컨트롤러의 예외를 관리하는 메서드임을 명시해주었고, 발생하는 유효성 검증 예외를 리스트로 담아 응답 상태와 같이 클라이언트에게 전송한다.
다음과 같이 email, name, phone 데이터를 POST 방식의 JSON 형태로 보냈고, 각각의 데이터는 정의된 DTO 클래스의 유효성 검증에 따라 유효성 검증을 처리한다.
데이터의 유효성 검증
- email : 올바른 이메일 형식
- name : 이름은 공백이 아니여야함
- phone : 올바른 전화번호 양식
오른쪽 그림과 같이 해당 데이터 별 유효성 검증 시 발생한 예외들을 리스트 형태로 묶어, 헤더에 응답 상태를, 바디에 발생한 예외결과를 보여준다.
그런데 클라이언트의 입장에서 의미를 알 수 없는 정보를 전부 포함한 Response Body 전체 정보를 굳이 다 전달 받을 필요는 없다. 요청 전송 시, Request Body의 프로퍼티 중에서 문제가 된 프로퍼티는 무엇인지 에러 메시지 정도만 받아도 충분하다.
ErrorResponse 클래스 적용
ErrorResponse 클래스를 정의해 원하는 예외 처리 정보만을 클라이언트에게 전달해줄 수 있다.
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.List;
@Getter
@AllArgsConstructor
public class ErrorResponse {
// (1)
private List<FieldError> fieldErrors;
@Getter
@AllArgsConstructor
public static class FieldError {
private String field;
private Object rejectedValue;
private String reason;
}
}
📌 [서비스 계층]에서의 예외처리
✔️ 체크 예외
체크 예외(Checked Exception)는 발생한 예외를 잡아서(catch) 체크한 후에 해당 예외를 복구 혹은 회피와 같은 어떤 구체적인 처리를 해야 하는 예외를 말한다.
대표적인 체크 예외로는 ClassNotFoundException 에러가 있다.
✔️ 언체크 예외
언체크 예외(Uncheked Exception)는 예외를 잡아서(catch) 해당 예외에 대한 어떤 처리를 할 필요가 없는 예외를 의미한다.
대표적인 언체크 예외로는 NullPontException, ArrayIndexOutOfBoundsException 등의 에러가 있다.
흔히 개발자가 코드를 잘못 작성해서 발생하는 이러한 오류들은 모두 RuntimeException을 상속한 예외들이다. 또한 Java나 Spring에서 수많은 RuntimeException을 지원해주지만 이 RuntimeException을 이용해서 개발자가 직접 예외(Exception)를 만들어야하는 경우도 있다.
✔️ 서비스 계층에서의 예외처리
서비스 계층에서 예외를 던지면 어디로 갈까?
서비스 계층의 메서드는 API 계층인 Controller의 핸들러 메서드가 이용하므로 서비스 계층에서 던져진 예외는 Controller의 핸들러 메서드 쪽에서 잡아 처리할 수 있다. 앞서 컨트롤러에서 발생하는 예외를 Exception Advice를 통해 공통적으로 처리할 수 있었듯이 서비스 계층에서의 예외 역시 Exception Advice에서 처리하면 된다.
서비스 계층에서의 예외 던지기(throw)
throw 키워드를 통해 서비스 계층의 예외를 던질 수 있다.
@Service
public class MemberService {
...
...
public Member findMember(long memberId) {
// TODO should business logic
// (1)
throw new RuntimeException("Not found member");
}
다음과 같이 throw 키워드를 사용하여 RuntimeException 객체에 적절한 예외 메시지를 포함한 후에 메서드 밖으로 던질 수 있다.
서비스 계층에서의 예외 던지기(catch)
발 예외는 공통 예외 처리, Exception Advice에서 예외를 잡는다.
@RestControllerAdvice
public class GlobalExceptionAdvice {
...
...
// (1)
@ExceptionHandler
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleResourceNotFoundException(RuntimeException e) {
System.out.println(e.getMessage());
...
...
return null;
}
}
컨트롤러에서 예외를 처리했던 것처럼 서비스 계층에서 발생했던 RuntimeException 오류를 Exception Device에서 잡아(catch) 해당 예외에 대한 예외 처리를 수행한다. Exception Deviced에서 RuntimeException을 잡을 때 앞서 지정한 "Not fount member"라는 문구가 콘솔 창에 출력된다.
그러나 도메인적인 관점에서 봤을 때 RuntimeException은 너무 추상적이다.
회원 등록 시 이미 회원이 존재하거나, 로그인 패스워드 검증에서 패스워드가 일치하지 않는 등, 서비스 계층에서 의도적으로 던질 수 있는 예외는 무수히 많다. 이 모든 예외를 RuntimeException 에러로 포괄하기엔 도메인 관점에서는 바람직하지 않아보인다. 때문에 이러한 예외들을 의도적으로 좀 더 구체적으로 명시할 필요가 있다.
✔️ 사용자 정의 예외의 사용
사용자 정의 예외(Custom Exception)를 사용하여 추상적인 예외를 좀 더 구체화할 수 있다.
1. 예외 코드 정의
서비스 계층에서 던져질 Custom Exception에 사용할 ExceptionCode를 Enum 클래스로 정의한다.
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을 추가해서 사용자가 지정한 message를 사용할 수 있다.
2. BusinessLogicException 구현
서비스 계층에서 사용할 Custum Exception 클래스를 정의한다.
public class BusinessLogicException extends RuntimeException {
@Getter
private ExceptionCode exceptionCode;
public BusinessLogicException(ExceptionCode exceptionCode) {
super(exceptionCode.getMessage());
this.exceptionCode = exceptionCode;
}
}
BusinessLogicException 클래스가 RuntimeException을 상속받음으로 인해 추상화된 RuntimeException을 좀 더 구체화할 수 있게 되었다. 생성자를 통해 정의했었던 Enum 클래스를 생성자를 통해 주입시켜 예외 메시지를 전달해줄 수 있다.
이를 통해 개발자는 예외를 던져야 하는 상황에서 ExceptionCode 정보만 원하는 메시지로 바꿔서 예외를 던질 수 있다.
3. 서비스 계층에서의 BusinessLogicException 적용
정의해둔 BusinessLogicException와 Enum 객체를 통해 예외를 던질 때 정의한 구체적인 예외를 던질 수 있다.
@Service
public class MemberService {
...
...
public Member findMember(long memberId) {
// TODO should business logic
// (1)
throw new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND);
}
...
...
}
기존에 throw에서 RuntimeException을 선언하여 던져주었던 것과 달리, 개발자가 정의한 enum 값을 통해 만들어준 예외 클래스 BusinessLogicException를 예외로 던져준다.
4.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()));
}
}
5. 콘솔 출력 결과
404
Member Not Found
❗️참고
- 한가지 유형만을 예외 처리하는 경우
→ @ResponseStatus - 다양한 유형의 예외 처리를 하는 경우
→ ResponseEntity
✔️ + of 메서드 구현하기(리팩토링)
@RestControllerAdvice에서 예외 처리를 구현하면 대충 다음과 같다.
@RestControllerAdvice
public class GlobalExceptionAdvice {
@ExceptionHandler
public ResponseEntity handleMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
// ---------------- of메서드 구현할 부분
List<ErrorResponse.FieldError> errors =
fieldErrors.stream()
.map(error -> new ErrorResponse.FieldError(
error.getField(),
error.getRejectedValue(),
error.getDefaultMessage()))
.collect(Collectors.toList());
// ---------------- of메서드 구현할 부분
return new ResponseEntity<>(new ErrorResponse(errors), HttpStatus.BAD_REQUEST);
}
}
그런데 여기서 handler 메서드 안에서 에러 메시지를 바인딩해준다면, ErrorResponse 클래스의 역할이 명확하지가 않다.
에러메시지를 바인딩하는 부분을 메서드화해서 핸들러 메서드와 분리해보자.
@Getter
public class ErrorResponse {
private int status;
private String message;
private List<FieldError> fieldErrors;
private ErrorResponse(int status, String message) {
this.status = status;
this.message = message;
}
// ErrorResponse.of() 호출 시 FieldError Collection 주입
private ErrorResponse(final List<FieldError> fieldErrors) {
this.fieldErrors = fieldErrors;
}
// (2) ErrorResponse of 메서드 구현
public static ErrorResponse of(BindingResult bindingResult) {
return new ErrorResponse(FieldError.of(bindingResult));
}
@Getter
public static class FieldError {
private String field;
private Object rejectedValue;
private String reason;
private FieldError(String field, Object rejectedValue, String reason) {
this.field = field;
this.rejectedValue = rejectedValue;
this.reason = reason;
}
// (1) FieldError의 of 메서드 구현
public static List<FieldError> of(BindingResult bindingResult) {
final List<org.springframework.validation.FieldError> fieldErrors =
bindingResult.getFieldErrors();
return fieldErrors.stream()
.map(error -> new FieldError(
error.getField(),
error.getRejectedValue() == null ?
"" : error.getRejectedValue().toString(),
error.getDefaultMessage()))
.collect(Collectors.toList());
}
}
}
(1) FieldError.of()
먼저 GlobalExceptionAdvice 클래스에 있었던 에러메시지 바인딩하는 부분을 FieldError 클래스의 of 메서드로 구현했다.
(2) ErrorResponse.of()
ErrorResponse.of() 메서드는 더 간단하다. 호출 시 FieldError 리스트를 주입받는다.
GlobalExceptionAdvice 클래스에서 사용하기
@Slf4j
@RestControllerAdvice
public class GlobalExceptionAdvice {
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
final ErrorResponse response = ErrorResponse.of(e.getBindingResult());
return response;
}
}
바인딩되는 부분이 사라지니 한결 깔끔해졌다.
댓글