학습 배경
지하철 노선도 미션에서 다음과 같은 리뷰를 받았습니다.
DTO를 만든 후에, Spring bean validation으로 필수값들에 대한 검증을 해보면 어떨까요?
이를 적용하기 위해 우선 Bean Validation이 무엇인지에 대해 알아보았습니다.
사전 지식
JavaBean이란?
일반적으로 정보를 표현할 때에 사용하는 클래스입니다. 예를 들어, 회원정보 게시판 글 등의 정보를 출력할 때 정보를 저장하고 있는 자바빈 객체를 사용하게 됩니다. 즉, 데이터를 표현하는 것을 목적으로 하는 자바 클래스입니다. (ex. DTO)
규약
- 반드시 클래스는 패키지화 되어야 한다.
- 멤버변수는 property(프로퍼티)라고 부른다.
- property접근제한자는 private
- 외부접근은 게터세터로 접근한다.
- 프로퍼티가 boolean이면 get이 아니라 is를 사용해도 된다.
Bean Validation의 필요성
- 직접 validation 로직을 짜고, Validator 등의 클래스를 만드는 과정이 번거롭다.
- Validator에 대한 모든 로직을 직접 개발자가 작성해야 한다.
Bean Validation이란?
Bean Validation은 자바빈(JavaBean) 유효성 검증을 위한 메타데이터 모델과 API에 대한 정의입니다. JavaBean은 직렬화가 가능하고 매개변수가 없는 생성자를 가지며, Getter와 Setter Method를 사용하여 프로퍼티에 접근이 가능한 객체를 의미합니다.
쉽게 얘기하면 Bean Validation은 스프링에서 유효성 검증 로직을 구현하기 위한 사실상 표준입니다. 특정 데이터 (주로 사용자 또는 다른 서버의 Request)의 값이 유효한지, 잘못된 내용이 있는지 확인하는 단계를 뜻합니다. 예를 들어, 사람 나이에 대한 값을 받아올 때, 기본적으로 사람의 나이는 음수일 수 없으므로 이를 사전에 처리해 해당 값의 저장을 막을 수 있습니다.
스프링 부트에서는 다음과 같은 의존성만 추가해주면 간단하게 사용이 가능합니다.
build.gradle 설정
implementation 'org.springframework.boot:spring-boot-starter-validation'
Bean Validation을 사용하면 @NotNull, @Min, @Max 등과 같은 어노테이션을 사용해 메타데이터를 정의하고, 이를 통해 JavaBean의 유효성 검증을 실시합니다. 이외에도 많은 검증 애노테이션이 있는데, 해당 포스팅에서는 다루지 않겠습니다.
미션에서 실제로 사용했던 DTO의 Bean Validation은 다음과 같습니다.
SectionRequest.java
public class SectionRequest {
@NotNull(message = "구간의 상행 아이디는 공백일 수 없습니다.")
private Long upStationId;
@NotNull(message = "구간의 하행 아이디는 공백일 수 없습니다.")
private Long downStationId;
@Positive
@NotNull(message = "구간의 거리는 null이거나 음수일 수 없습니다.")
private int distance;
public SectionRequest(final Long upStationId, final Long downStationId, final int distance) {
this.upStationId = upStationId;
this.downStationId = downStationId;
this.distance = distance;
}
...
}
현재 코드에서는 Bean Validation을 이용해 stationId 값에 null이 들어오지 않게 방지하며, distance는 null이거나 음수일 수 없다고 명시해주고 있습니다.
@RequestBody 에서의 Bean Validation
@RequestBody 로 어노테이션을 설정하면 RequestResponseBodyMethodProcessor 를 통해서 메서드 파라미터가 바인딩 됩니다. 이때, @Valid 어노테이션을 설정하면 Validation을 처리할 수 있게 되는데, 만약 유효하지 않은 값이라면 MethodArgumentNotValidException 이 발생하게 됩니다.
LineController.java
@RestController
@RequestMapping("/lines")
public class LineController {
private final LineService lineService;
private final SectionService sectionService;
//생성자 생략
@PostMapping("/sections")
public void addSection(@RequestBody @Valid final SectionRequest sectionRequest) {
sectionService.addSection(sectionRequest);
}
...
}
그렇다면 이에 대한 예외처리는 어떻게 할까요?
스프링에서는 기본적으로 @Controller 나 @RestController 에서 발생한 예외를 한 곳에서 관리하고 처리할 수 있게 도와주는 어노테이션인 @ControllerAdvice 를 이용합니다. 이를 통해 스프링에서 전역적으로 예외를 핸들링할 수 있는데, 자세한 사용 방법에 대해서는 여기서 다루지 않습니다.
ControllerAdvice.java
@RestControllerAdvice
public class ControllerAdvice {
@ExceptionHandler(ExpectedException.class) // 일반적인 예외
public ResponseEntity<String> handleExceptedException(final ExpectedException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
...
@ExceptionHandler(MethodArgumentNotValidException.class) // Bean Validation 예외
public ResponseEntity<String> handleBindException(final MethodArgumentNotValidException e) {
return ResponseEntity.badRequest().body(extractErrorMessage(e));
}
private String extractErrorMessage(final MethodArgumentNotValidException e) {
return e.getBindingResult()
.getAllErrors()
.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.joining(","));
}
}
최상단의 ExpectedException 에 대한 예외처리는 간단하게 body에 해당 예외에 대한 메시지를 넣어주면 됩니다. 하지만, bean validation에 대한 예외처리는 부가적인 작업이 필요합니다.
MethodArgumentNotValidException 는 BindingResult 에 대한 정보를 가지고 있으며 여기서 필요한 오류 정보와 메시지 코드를 확인할 수 있습니다. 스프링은 기본적으로 오류 메시지 코드 관리를 위해 MessageCodesResolver 를 사용하는데, 이에 대한 구현체인 DefaultMessageCodesResolver 를 기본으로 사용해 메시지를 추출할 수 있습니다. 추출 방법은 extractErrorMessage() 메서드에서 확인할 수 있습니다.
@ModelAttribute 에서의 Bean Validation
@ModelAttribute 는 클라이언트가 전송하는 multipart/form-data 형태의 HTTP Body 내용과 HTTP 파라미터의 값들을 생성자나 Setter를 통해 주입하기 위해 사용됩니다. @ModelAttribute에는 매핑시키는 파라미터의 타입이 객체의 타입과 일치하는지를 포함한 다양한 검증(Validiation) 작업 또한 추가적으로 진행됩니다.
@RequestBody 와의 주요 차이점은 @ModelAttribute는 바인딩하는 값들을 주입해주는 생성자나 Setter가 없다면 매핑이 되지 않습니다. 하지만, @RequestBody는 요청받은 데이터를 변환시키는 것이기 때문에, 생성자나 Setter함수 없어도 값을 매핑할 수 있습니다.
참고: @ModelAttribute 를 붙이지 않고 생략했을 때는 setter가 생략 가능합니다!
@ModelAttribute 또한 @RequestBody 와 같이 validation 시, MethodArgumentNotValidException 이 발생할 것이라고 생각했는데, @ModelAttribute 에서는 BindException 이 발생합니다.
이에 따른 코드의 변경은 다음과 같습니다.
@RestControllerAdvice
public class ControllerAdvice {
...
@ExceptionHandler(BindException.class) // Bean Validation 예외
public ResponseEntity<String> handleBindException(final BindException e) {
return ResponseEntity.badRequest().body(extractErrorMessage(e));
}
...
}
MethodArgumentNotValidException 은 BindException 을 상속받기 때문에 다음과 같이 변경할 수 있습니다.
무슨 차이일까?
두 validation은 의도적으로 다른 예외를 던지도록 설계되어 있습니다.
@ModelAttribute 에서 발생하는 BindException
- 데이터 바인딩과 검증을 거치며 바인딩 요청 속성 또는 결과 값 유효성에 대한 포괄적인 예외를 발생시키기 때문에 BindException 을 발생시킵니다.
@RequestBody 에서 발생하는 MethodArgumentNotValidException
- @RequestBody 는 요청의 body를 HttpMessageConverter를 통해 변환합니다. 그 과정에서 유효성 검증을 하기 때문에 만일 검증에 실패한다면 변환 관련 예외인 MethodArgumentNotValidException 를 발생시킵니다.
MethodArgumentNotValidException 은 기본적으로 @ExceptionHandler 에서 처리하며, BindException 은 주로 각 컨트롤러에서 개별적으로 처리한다고 합니다.
(참고: https://github.com/spring-projects/spring-framework/issues/14790)
'Spring' 카테고리의 다른 글
OAuth 2.0이란? (0) | 2022.09.07 |
---|
댓글