티스토리 뷰

728x90
반응형

 

 

 

지난 글에서 @ExceptionHandler를 이용한 예외처리를 알아보았다.

 

글 마지막에 @ExceptionHandler를 이용할 때의 단점에 대해 언급했는데, 요약하면

 

  • 코드 중복
  • 유연성 떨어짐

이 그것이었다.

 

이번 글에선 @RestControllerAdvice 애너테이션을 이용해 위 문제들을 해결하는 방법을 보겠다.

 

@RestControllerAdvice는 @ControllerAdvice와 @ResponseBody를 포함하는 애너테이션으로,

 

직접 뜯어보면 아래와 같이 생겼다.

 

즉,

 

  • @ControllerAdvice - 발생한 예외를 한 곳에서 전역적으로 관리하고 처리할 수 있게 하는 모듈화 애너테이션. View 리턴
  • @ResponseBody - 메서드의 리턴 값을 JSON으로 변환해 HTTP Response body에 담음
  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody

라는 뜻이다.

 

구체적으로는 @RestControllerAdvice가 붙은 클래스의 @ExceptionHandler, @InitBinder, @ModelAttribute 메서드를

 

여러 Controller에서 공유할 수 있게 해 주며, 그 결과 예외 처리를 공통화할 수 있게 해 준다.

 

이 중 @InitBinder, @ModelAttribute는 SSR 방식에서 사용하는 방식이기 때문에 생략하고,

 

CSR 방식에서 주로 사용하는 @ExceptionHandler를 중심으로 알아본다.

 

 

MemberController 클래스에서 @ExceptionHandler 로직 제거

 

먼저 지난 글까지 작성했던 MemberController에서 @ExceptionHandler가 적용된 메서드를 지운다.

 

각 Controller 클래스에서 발생하는 예외를 GlobalExceptionAdvice 클래스를 작성해 공통으로 처리할 것이기 때문이다.

 

 

GlobalExceptionAdvice 클래스 정의

 

방금 말한 대로 예외를 공통으로 처리할 GlobalExceptionAdvice 클래스를 정의한다.

 

상위 패키지에 advice 폴더를 생성했다.

package com.gnidinger.advice;

import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionAdvice {
}

@RestControllerAdvice를 적용해 컨트롤러 클래스의 예외를 공통으로 처리하도록 한다.

 

 

GlobalExceptionAdvice 클래스 내에 Exception 핸들러 메서드 구현 

 

예외를 처리할 핸들러 메서드를 GlobalExceptionAdvice 클래스에 작성한다.

package com.gnidinger.advice;

import com.gnidinger.response.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.validation.ConstraintViolationException;
import java.util.List;
import java.util.stream.Collectors;


@RestControllerAdvice
public class GlobalExceptionAdvice {
    @ExceptionHandler
    public ResponseEntity handleMethodArgumentNotValidException(
            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);
    }

    @ExceptionHandler
    public ResponseEntity handleConstraintViolationException(
            ConstraintViolationException e) {
        // TODO should implement for validation

        return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
    }
}

지난 글에서 MemberController에 추가했던 Exception 핸들러 메서드의 로직을 그대로 추가했다.

 

ConstraintViolationException 메서드의 바디는 잠시 후에 작성하기로 하고, 코드가 동작하는 것을 확인하자.

 

CoffeeController에서도 정확하게 동작하는 것을 확인할 수 있다.

 

이처럼 @RestControllerAdvice을 이용해 예외 처리를 공통화하면 중복 코드를 제거하고 코드를 단순화할 수 있다.

 

 

ErrorResponse 클래스 수정

 

계속해서 URI 변수 유효성 검증에 대한 에러(ConstraintViolationException) 처리를 구현해 보자.

 

먼저 ErrorResponse 클래스가 ConstraintViolationException에 대한 Error Response를 생성할 수 있도록 수정한다.

package com.gnidinger.response;

import lombok.Getter;
import org.springframework.validation.BindingResult;

import javax.validation.ConstraintViolation;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Getter
public class ErrorResponse {
    private List<FieldError> fieldErrors; // (1)
    private List<ConstraintViolationError> violationErrors;  // (2)

    // (3)
    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 객체 생성
    public static ErrorResponse of(Set<ConstraintViolation<?>> violations) {
        return new ErrorResponse(null, ConstraintViolationError.of(violations));
    }

    // (6) Field Error 가공
    @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());
        }
    }

    // (7) ConstraintViolation Error 가공
    @Getter
    public static class ConstraintViolationError {
        private String propertyPath;
        private Object rejectedValue;
        private String reason;

        private 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());
        }
    }
}

DTO 클래스의 유효성 검증과 URI의 변수 값 검증에 대한 Error Response를 포함할 수 있게 바뀌었다.

 

(1) - MethodArgumentNotValidException(DTO 클래스 검증 에러)에서 발생하는 에러 정보를 담는 변수

(2) - ConstraintViolationException(URI 검증 에러)에서 발생하는 에러 정보를 담는 멤버 변수

(3) - 생성자 앞에 private 제한자 지정, 강한 결합(new를 통한 객체 생성) 방지 및 ErrorResponse의 역할 특정

(4) - of()를 통해 MethodArgumentNotValidException에 대한 ErrorResponse 객체 생성

  • BindingResult - MethodArgumentNotValidException에서 에러 정보를 얻기 위해 필요한 객체
  • 따라서 of() 메서드를 호출하는 쪽에서 BindingResult 객체를 파라미터로 넘겨줌
  • BindingResult의 에러 정보를 추출하고 가공하는 일은 같은 클래스의 static 멤버 클래스인 FieldError에게 위임

(5) - of()를 통해 ConstraintViolationException에 대한 ErrorResponse 객체 생성

 

  • Set<ConstraintViolation<?>> - ConstraintViolationException에서 에러 정보를 얻기 위해 필요한 객체
  • 따라서 of() 메서드를 호출하는 쪽에서 Set<ConstraintViolation<?>> 객체를 파라미터로 넘겨줌
  • Set<ConstraintViolation<?>>의 에러 정보를 추출하고 가공하는 일은 같은 클래스의 ConstraintViolationError에게 위임

(4)와 (5)를 통해서 ErrorResponse 객체에 에러 정보를 담는 역할이 명확하게 분리된다.

 

(6) - DTO 클래스 변수의 유효성 검증에서 발생하는 에러 정보 가공

(7) - URI 변수 유효성 검증에서 발생하는 에러 정보 가공

 

*of() - 객체 생성 시 매개변수로 주어지는 값들의 객체를 생성한다는 의미를 가진 네이밍 컨벤션

 

클래스의 구현 복잡도가 늘어나긴 했지만, 에러 유형에 따른 정보 생성 역할을 분리해 사용하기엔 편해진 것을 확인할 수 있다.

 

 

GlobalExceptionAdvice 클래스 내에 Exception 핸들러 메서드 수정 

 

위에서 작성한 ErrorResponse 클래스의 메서드를 사용하도록 메서드를 수정해 보자.

package com.gnidinger.advice;

import com.gnidinger.response.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolationException;

@RestControllerAdvice
public class GlobalExceptionAdvice {
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleMethodArgumentNotValidException(
            MethodArgumentNotValidException e) {
        final ErrorResponse response = ErrorResponse.of(e.getBindingResult());

        return response;
    }

    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleConstraintViolationException(
            ConstraintViolationException e) {
        final ErrorResponse response = ErrorResponse.of(e.getConstraintViolations());

        return response;
    }
}

에러 정보를 만드는 역할을 ErrorResponse 클래스가 대신해주기 때문에 코드가 간결해진 것을 확인할 수 있다.

 

또한 ErrorResponse 객체를 ResponseEntity로 래핑 했던 것이 ErrorResponse 객체를 바로 리턴하도록 변경되었는데,

 

이는 처음에 알아봤듯이 @RestControllerAdvice 안에 포함된 @ResponseBody 덕분이다.

 

추가로 @ResponseStatus 애너테이션을 이용해 HTTP Status를 HTTP Response에 포함시키는 것도 볼 수 있다.

 

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/06   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30
글 보관함