왜 쓰는가
유효성 검증에 실패했을 때 단순히 404 같은 에러를 띄우게 된다면
어느 부분에서 왜 틀린 건지 제대로 알 수가 없다.
따라서 클라이언트 측에서 좀 더 구체적으로 에러 메시지의 확인을 할 수 있도록
예외처리를 통해 메시지를 전달한다.
문제가 발생하면
예외를 던진다
예외를 잡아서
가공하고
클라이언트에 깔끔한 메시지로 돌려준다
@ExceptionHandler
Controller 단계에서 @ExceptionHandler를 이용해 예외처리를 할 수 있다.
Controller내부의 핸들러 메서드에서 예외가 발생했을 때 해당 예외를 @ExceptionHandler
예외 처리 메서드에서 호출하게 된다.
@PostMapping
public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto)
{
Member member = mapper.memberPostDtoToMember(memberDto);
Member response = memberService.createMember(member);
return new ResponseEntity<>(mapper.memberToMemberResponseDto(response),
HttpStatus.CREATED);
}
@ExceptionHandler
public ResponseEntity handleException(MethodArgumentNotValidException e) {
final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
return new ResponseEntity<>(fieldErrors, HttpStatus.BAD_REQUEST);
}
postMember 핸들러 메서드를 예로 들어보면,
클라의 POST 요청을 핸들러 메서드가 받고, 유효성 검증을 먼저 한다 (@Valid). 만약 유효하지 않은
데이터가 들어왔다면 MethodArgumentNotValidException 예외가 발생한다.
해당 예외를 @ExceptionHandler가 붙은 handleException 메서드가 전달받고,
MethodArgumentNotValidException객체에서 getBindingResult(). getFieldErrors()을 통해
에러 정보를 확인 가능하게 된다.
그리고 해당 정보를 ResponseEntity를 통해서 Response Body로 다시 반환한다.
개선법
다만 위의 코드는 Response Body 전체 정보를 전달하므로 에러 메시지 이외에는 딱히 필요 없는
정보라 할 수 있다. 이를 해결하기 위해서, 에러 정보를 담는 클래스를 추가로 만들어서
클라이언트 쪽으로 다시 전달하면 된다.
//Error 정보를 담을 'ErrorResponse' 클래스
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.List;
@Getter
@AllArgsConstructor
public class ErrorResponse {
private List<FieldError> fieldErrors;
@Getter
@AllArgsConstructor
public static class FieldError {
private String field;
private Object rejectedValue;
private String reason;
}
}
에러 정보를 담을 새 클래스 (ErrorResponse)를 만들고, 그 안에 정적 클래스(FieldError)를
추가로 만들어 필드의 에러 정보를 담도록 되어있다.
해당 클래스를 통해서 실패한 필드(Error)에 대한 정보만 골라 담을 수 있다.
@ExceptionHandler
public ResponseEntity handleException(MethodArgumentNotValidException e) {
final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
List<ErrorResponse.FieldError> errors =
fieldErrors.stream()
.map(error -> new ErrorResponse.FieldError(
error.getField(),
error.getRejectedValue(),
error.getDefaultMessage()))
.collect(Collectors.toList());
return new ResponseEntity<>(new ErrorResponse(errors), HttpStatus.BAD_REQUEST);
}
그리고 다시 Controller에 돌아와 에러 정보를 담을 클래스 ErrorResponse를 연결한다.
스트림을 만들어 필요한 에러 정보들을 ErrorResponse클래스 안의 FieldError정적 클래스의 필드에
담은 뒤, 다시 List로 바꾼 뒤에, ResponseEntity에 담아서 반환하도록 변경되었다.
사용 시 단점
- Controller 클래스마다 코드의 중복이 발생
- 각각의 Controller클래스에서 @ExceptionHandler를 사용해 Request Body에 대해
유효성 검증 실패에 대한 예외처리를 해야 한다.
- 각각의 Controller클래스에서 @ExceptionHandler를 사용해 Request Body에 대해
- 여러 가지 예외를 처리해야 할 경우 핸들러 메서드가 늘어남
- Controller에서 처리해야 되는 예외는 위의 MethodArgumentNotValidException
말고도 여러 가지가 있다. - 다른 예외의 처리를 위해서 해당 처리를 위한 핸들러 메서드를 또 만들어야 한다.
- 예) URI 변수가 다르면 ConstraintViolationException이 뜬다.
- Controller에서 처리해야 되는 예외는 위의 MethodArgumentNotValidException
@RestControllerAdvice
@ExceptionHandler에서 중복 코드의 문제를 해결하기 위해 @RestControllerAdvice를 사용할 수 있다.
특정 클래스에 @RestControllerAdvice를 추가하면 여러 개의 컨트롤러 클래스에서
@ExceptionHandler, @InitBinder 또는 @ModelAttribute가 추가된 메서드를 공유해 사용 가능하다.
즉, @RestControllerAdvice가 추가된 클래스를 사용하면, 예외 처리를 공통화할 수 있다.
선 요약 포인트
* @RestControllerAdvice를 추가한 클래스를 사용해 예외처리를 공통적으로 할 수 있다.
* @RestControllerAdvice를 사용하면 JSON형식의 데이터를 Response Body로
다시 보내기 위해 ResponseEntity로 추가적으로 래핑 할 필요가 없다.
* @ResponseStatus로 HTTP status를 대신 표현 가능하다.
ExceptionAdvice 클래스
먼저 ExceptionAdvice클래스를 구축한다.
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ExceptionAdvice {}
@RestControllerAdvice로 인하여, ExceptionAdvice클래스는 앞으로
Controller클래스에서 발생하는 예외들을 공통적으로 도맡아 처리할 수 있다.
Exception핸들러 메서드
GlobalExceptionAdvice클래스를 만든 후 Exception 핸들러 메서드를 구현한다.
해당 클래스는 크게 아래의 2가지 파트로 나눠져 있다고 볼 수 있다. (예시)
//GlobalExceptionAdvice 클래스 상단부
@RestControllerAdvice
public class GlobalExceptionAdvice {
- 유효하지 않는 데이터의 요청 시 작동할 handleMethodArgumentNotValidException()
//핸들러 메서드 1 handleMethodArguementNotValidException()
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleMethodArguementNotValidException(
MethodArgumentNotValidException e)
{
final ErrorResponse response = ErrorResponse.of(e.getBindingResult());
return response;
}
- 유효하지 않는 URI변수로 넘어올 때 작동할 handleConstraintViolationException()
//핸들러 메서드 2 handleConstraintViolationException()
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleConstraintViolationException(
ConstraintViolationException e)
{
final ErrorResponse response = ErrorResponse.of(e.getConstraintViolations());
return response;
}
@ExceptionHandler만 사용했을 때는 ResponseEntity 객체에 에러 정보와 HTTP상태 코드를
한번 더 래핑 해서 전달했다.
하지만 @RestControllerAdvice를 사용한 현재 클래스에서는 ErrorResponse클래스의 객체를 그대로
리턴하고 있다. 또 @ResponseStatus를 통해 HTTP상태 코드를 따로 설정했다.
따라서 코드가 상대적으로 더 간결해졌다.
이는 @RestControllerAdvice의 특성 때문이기도 한데, 해당 애너테이션은
@ControllerAdvice + @ResponseBody의 기능을 포함하고 있다.
따라서,
JSON형식의 데이터를 Response Body로 보내기 위해 ResponseEntity로
한번 더 래핑 할 필요가 없어졌다.
ErrorResponse 클래스
ErrorResponse 클래스에 두 가지의 예외에 대한 예외 정보를 생성할 수 있도록 한다.
- handleMethodArguementNotValidException
- handleConstraintViolationException
2가지의 예외에 대한 경우이기 때문에 각각의 경우를 클래스 안에 구현해야 한다.
크게 3파트로 구분이 된다고 볼 수 있다.
- 에러 정보를 담는 멤버 변수 파트와 각각의 예외에 따른 객체를 각각 맞는 타입으로 생성
- 유효하지 않는 데이터의 요청
- 유효하지 않는 URI변수 요청
1.
에러 정보를 담는 멤버 변수 파트와 각각의 예외에 따른 객체를 각각 맞는 타입으로 생성
@Getter
public class ErrorResponse {
//각각의 예외 정보를 담을 멤버 변수 선언
private List<FieldError> fieldErrors;
private List<ConstraintViolationError> violationErrors;
//생성자를 통해서 초기화
private ErrorResponse(final List<FieldError> fieldErrors,
final List<ConstraintViolationError> violationErrors) {
this.fieldErrors = fieldErrors;
this.violationErrors = violationErrors;
}
//(4) BindingResult에 대한 ErrorResponse 객체 생성
// 유효하지 않는 데이터의 요청
public static ErrorResponse of(BindingResult bindingResult)
{
return new ErrorResponse(FieldError.of(bindingResult), null);
}
//(5) Set<ConstraintViolation<?>> 객체에 대한 ErrorResponse 객체 생성
// 유효하지 않는 URI변수의 요청
public static ErrorResponse of(Set<ConstraintViolation<?>> violations)
{
return new ErrorResponse
(null, ConstraintViolationError.of(violations));
}
여기에서 생성자의 접근 제어자가 public이 아닌 private로 되어있는데 이렇게 되면 기존의
new를 통해 객체를 생성할 수 없게 된다.
이때 사용하는 것이 of() 메서드이며, of메서드를 사용한 덕에 ErrorResponse객체에 에러 정보를
담는 역할을 명확하게 분리할 수 있게 되었다.
of( ) 메서드는 '정적 팩토리 메서드 - Static Factory Method'이다.
여러 개의 매개변수를 받아서 객체를 생성하는 기능을 갖고 있으며 간단하게
1. 객체 생성의 캡슐화
2. 호출시마다 새롭게 객체 생성이 필요하지 않음
3. 메서드 이름에 객체의 생성 목적을 담아낼 수 있음
4. 하위 자료형의 객체 반환
등의 장점을 갖고 있다.
[출처]: https://tecoble.techcourse.co.kr/post/2020-05-26-static-factory-method/
2.
유효하지 않는 데이터의 요청
handleMethodArguementNotValidException
//(6) FieldError 가공
@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;
}
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());
}
}
3.
유효하지 않는 URI변수 요청
handleConstraintViolationException
//(7) ConstraintViolationError 가공
@Getter
public static class ConstraintViolationError{
private String propertyPath;
private Object rejectedValue;
private String reason;
public ConstraintViolationError
(String propertyPath, Object rejectedValue, String reason) {
this.propertyPath = propertyPath;
this.rejectedValue = rejectedValue;
this.reason = reason;
}
public static List<ConstraintViolationError> of(
Set<ConstraintViolation<?>> constraintViolations)
{
return constraintViolations.stream()
.map(constraintViolation -> new ConstraintViolationError(
constraintViolation.getPropertyPath().toString(),
constraintViolation.getInvalidValue().toString(),
constraintViolation.getMessage()))
.collect(Collectors.toList());
}
}
URI변수 값은 예를 들어 @Positive가 있어 주소에 음수 값이 들어올 수 없는데
음수 값이 들어올 경우 예외 발생
비즈니스 로직의 예외처리
애플리케이션에서 발생하는 예외는 크게 2가지로 구분할 수 있다.
- 체크 예외(Checked Exception)
- 발생한 예외를 잡아서(catch) 체크를 한 다음, 해당 예외에 대한 구체적인
처리를 해야 하는 예외이다. (복구 / 회피 / 재시도 등)
- 발생한 예외를 잡아서(catch) 체크를 한 다음, 해당 예외에 대한 구체적인
-
- 대표적으로 ClassNotFoundException, SQLException 같은 것들이 있다.
- 대표적으로 ClassNotFoundException, SQLException 같은 것들이 있다.
- 언체크 예외(Unchecked Exception)
- 발생한 예외를 잡아서(catch) 해당 예외에 대한 어떠한 처리도 필요 없는 예외
- 대표적으로 NullPointerException, ArrayIndexOutOfBoundsException 등이 있다.
- 흔히 개발자의 실수로 발생하는 모든 오류들은 모두 RuntimeException을 상속한 예외이다.
- 발생한 예외를 잡아서(catch) 해당 예외에 대한 어떠한 처리도 필요 없는 예외
Java나 Spring에서 수많은 RuntimeException을 지원하지만 이 런타임 예외를 이용해서 개발자가
직접 예외를 만들어야 하는 경우도 있다.
의도적인 예외 설정
자바에서는 throw 키워드를 사용해서 예외는 메서드의 바깥(메서드를 호출한 지점)으로 던질 수 있다.
만약 서비스 계층에서 throw로 예외를 던지게 되면, API계층인 Controller의 핸들러 메서드 쪽에서
잡아서(catch) 처리할 수 있다.
1.
서비스 계층에서 예외 던지기
//MemberService.java
public Member findMember(long memberId) {
// business logics
throw new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND);
}
DB에 회원정보를 조회했지만 조회되는 회원이 없다고 가정하고 throw 키워드를 사용해서
BusinessLogicException이라는 사용자 정의 예외를 메서드 밖으로 던진다.
여기서 회원정보가 존재하지 않는다는 MEMBER_NOT_FOUND를 파라미터로 전달한다.
2.
던져진 예외를 잡아서 처리
//GlobalExceptionAdvice.java
@ExceptionHandler
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleBusinessLogicException(BusinessLogicException e) {
System.out.println(e.getExceptionCode().getStatus());
System.out.println(e.getMessage());
final ErrorResponse response = ErrorResponse.of(ExceptionCode.MEMBER_NOT_FOUND);
return response;
}
모든 예외를 공통적으로 하기 위해 만든 GlobalExceptionAdvice 클래스가 서비스 계층에서
던져진 예외를 잡아서(catch) handleBusinessLogicException메서드를 이용해 처리하게 된다.
Custom Exception
위의 예제에서 BusinessLogicException이라는 사용자 정의 예외를 썼다.
RuntimeException 같은 이미 정의된 예외를 사용할 수도 있지만 추상적이고 구체적이지 않기
때문에, 사용자가 직접 예외를 만들어서 조금 더 구체적으로 표현할 수 있다.
먼저 어떤 예외 코드를 쓸지 Enum클래스로 정의를 한다.
ExceptionCode.java [ENUM class]
import lombok.Getter;
public enum ExceptionCode {
MEMBER_NOT_FOUND(404, "Member Not Found"),
METHOD_NOT_ALLOWED(405, "Method Not Allowed"),
Internal_Server_Error(500, "Internal Server Error");
@Getter
private int status;
@Getter
private String message;
ExceptionCode(int code, String message) {
this.status = code;
this.message = message;
}
}
위처럼 Enum으로 정의를 하게 되면 비즈니스 로직에서 발생하는 다양한 유형의 예외를
열거형으로 추가하여 사용 가능하다. 위의 MEMBER_NOT_FOUND / METHOD_NOT_ALLOWED
Internal_Server_Error 등이 직접 사용하게 될 에러 코드이다.
서비스 계층에서 사용할 BusinessLogicException이라는 사용자 정의 예외 작성
//BusinessLogicException.java
import lombok.Getter;
public class BusinessLogicException extends RuntimeException {
@Getter
private ExceptionCode exceptionCode;
public BusinessLogicException(ExceptionCode exceptionCode) {
super(exceptionCode.getMessage());
this.exceptionCode = exceptionCode;
}
}
BusinessLogicException클래스를 만들고, RuntimeException을 상속한다.
그리고 서비스 계층에서 던질 사용자 정의 예외에 사용할 ExceptionCode 열거 클래스를 사용한다.
에러 코드들이 담긴 ExceptionCode를 멤버 변수로 활용하여 생성자를 통해 좀 더 구체적인
예외 정보들을 넘겨줄 수 있다.
예외를 던질 다양한 상황에서 ExceptionCode의 정보만 바꿔서 유동적으로 예외를 던질 수 있다.
'programming > SPRING' 카테고리의 다른 글
Spring Data JDBC 간략 요약 (0) | 2022.06.30 |
---|---|
JDBC 간단 요약 (0) | 2022.06.30 |
Entity개념 복습 (0) | 2022.06.28 |
구글 로그인 구현 이후 사용자 이름이 뜨지 않는 문제 (0) | 2022.06.23 |
핸들러 메서드의 응답 데이터 순서 변경 (0) | 2022.06.23 |