오늘은 저번 글에 이어서, DTO 및 비즈니스 로직의 검증과 그 테스트에 대해 다뤄보려고 한다.
이 부분은 나도 학습을 하면서 함께 포스팅하는 부분이라,
일반적인 관습과 다르거나 아예 틀린 코드가 있을 수 있다.
만약 발견하신다면 지적을 부탁드린다.
이전 포스팅에서, 도서에 대해 요청을 보내 생성/목록 조회/삭제가 가능한 서버를 만들어 보았다.
그러나 저번 프로젝트는 너무 허술했던 터라, 보시는 많은 분들이 당황했으리라 생각한다.
테스트까지 하면서 만든 건데, 예외 처리가 하나도 되어있지 않다니? 라는 느낌이 팍 드니까..
당장 간단한 예를 하나 들어보자.
이전에 생성 부분을 구현하면서, BookCreateRequest라는 DTO를 통해 이름 값을 전달받았다.
그런데 그 값으로 null을 주면 어떻게 될까?
그렇다. 막지 못한다. 만약 이 상황에서 이 도서에 대해 특정한 액션을 취한다면?
예상하지 못한 예외가 발생하고, 심한 경우 사용자에게 500 에러가 던져질 것이다.
하지만 이를 막기 위해 Controller나 Service 중 어디에 검증 코드를 둬야 할지,
만약 예외를 발생시킨다면 어떻게 대응하는게 옳을지...처리하기 매우 까다롭다고 느껴질 것이다.
그래서, spring boot에서는 Spring Boot Validation이라는 검증 어노테이션을 추가하여 사용할 수 있다.
간단히 설명하자면 레이어에서 검증 로직을 돌리는 대신,
도메인 모델이나 DTO에서 어노테이션을 통해 제약사항을 정의하여 검증하는 것!!
스프링의 validation은 Java에서 2009년부터 제공한 Bean Validation에 기초하니 참고해 봐도 좋을 것이다.
일단, BookCreateRequest의 코드를 다음처럼 변경해보자.
package com.booksdiary.domain;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotBlank;
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class BookCreateRequest {
@NotBlank(message = "도서 생성 요청시 유효한 이름을 입력해야 합니다.")
String name;
public Book toBook() {
return Book.builder()
.name(this.name)
.build();
}
}
@NotBlank라는 어노테이션이 추가되었다!!
Spring의 Validation은 간단하다. 내가 검증하고 싶은 필드 위에 어노테이션을 붙이면, 그 필드에 해당 검증 로직이 적용된다.
이 상태에서 컨트롤러에 @Valid 어노테이션을 사용할 경우, 해당 DTO가 매핑되는 과정에서 검증을 실행한다.
이 과정에서 검증을 통과하지 못할 경우, 해당 어노테이션에 있는 에러 메시지를 포함하여 400 Bad Request를 반환한다.
@PostMapping("/api/books")
public ResponseEntity<BookResponse> create(@RequestBody @Valid final BookCreateRequest request) {
final BookResponse response = bookService.create(request);
final URI uri = URI.create("/api/books/" + response.getId());
return ResponseEntity.created(uri)
.body(response);
}
위처럼 Controller의 메서드에 @Valid 어노테이션을 추가하고, 다시 null을 보내보자.
반환되는 값은 다음과 같다.
{
"timestamp": "2021-02-09T02:10:53.040+00:00",
"status": 400,
"error": "Bad Request",
"trace": "org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public org.springframework.http.ResponseEntity<com.booksdiary.domain.BookResponse> com.booksdiary.controller.BookController.create(com.booksdiary.domain.BookCreateRequest): [Field error in object 'bookCreateRequest' on field 'name': rejected value [null]; codes [NotBlank.bookCreateRequest.name,NotBlank.name,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [bookCreateRequest.name,name]; arguments []; default message [name]]; default message [도서 생성 요청시 유효한 이름을 입력해야 합니다.]] \n\tat org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.resolveArgument(RequestResponseBodyMethodProcessor.java:139)\n\tat org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:121)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:170)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:137)\n\tat org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:106)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:894)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808)\n\tat org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)\n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1061)\n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:961)\n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)\n\tat org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:652)\n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:733)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202)\n\tat org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97)\n\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:542)\n\tat org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:143)\n\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)\n\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78)\n\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343)\n\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:374)\n\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)\n\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:888)\n\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1597)\n\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)\n\tat java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)\n\tat java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)\n\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)\n\tat java.base/java.lang.Thread.run(Thread.java:830)\n",
"message": "Validation failed for object='bookCreateRequest'. Error count: 1",
"errors": [
{
"codes": [
"NotBlank.bookCreateRequest.name",
"NotBlank.name",
"NotBlank.java.lang.String",
"NotBlank"
],
"arguments": [
{
"codes": [
"bookCreateRequest.name",
"name"
],
"arguments": null,
"defaultMessage": "name",
"code": "name"
}
],
"defaultMessage": "도서 생성 요청시 유효한 이름을 입력해야 합니다.",
"objectName": "bookCreateRequest",
"field": "name",
"rejectedValue": null,
"bindingFailure": false,
"code": "NotBlank"
}
],
"path": "/api/books"
}
이렇게 반환된다면, 해당 예외의 메시지를 바탕으로 프론트엔드에서도 대응하기 편할 것이다.
최소한 500 에러가 뜨는 것보다는 낫다는 게 보장된다.
이전에 프로젝트를 하면서 많이 사용했던 validation 어노테이션들을 소개한다.
여기에 없는 내용은, 위의 링크를 참조하여 알아보시기를 추천한다.
- @NotNull : null이 들어갈 경우 예외를 발생시킨다.
- @NotEmpty : 문자열에 null이나 ""가 들어가면 예외를 발생시킨다. NotNull의 문자열 버전 상위호환.
- @NotBlank : 문자열에 null, "", " "가 들어가면 예외를 발생시킨다. NotEmpty의 상위호환.
- @Length(max = number) : 문자열의 최대 길이를 설정하고, 길이가 초과될 경우 예외를 발생시킨다.
- @DecimalMin/Max(value = number, inclusive = boolean) : 숫자의 최소/최대값과 그 값을 포함하는지 여부를 설정한다.
- @Positive/Negative : 숫자가 양수나 음수인지 검증하여 잘못된 경우 예외를 발생시킨다.
- @Email : 이메일의 형태를 띄고 있는지 검사하고, 잘못된 포맷일 경우 예외를 발생시킨다.
이제 컨트롤러에서 @valid 처리가 제대로 되고 있는지 예외 테스트를 추가해보자.
컨트롤러 테스트에 다음 코드를 추가한다.
@DisplayName("예외 테스트: 생성 요청 시 비어있는 이름이 전달되면 예외를 발생시킨다.")
@ParameterizedTest
@NullAndEmptySource
void createWithNullAndEmptyNameTest(String name) throws Exception {
BookCreateRequest request = new BookCreateRequest(name);
String requestAsString = OBJECT_MAPPER.writeValueAsString(request);
this.mockMvc.perform(post(API + "/books")
.content(requestAsString)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(result -> {
Exception exception = result.getResolvedException();
Objects.requireNonNull(exception);
assertTrue(exception.getClass().isAssignableFrom(MethodArgumentNotValidException.class));
}).
andDo(print());
}
@ParameterizedTest 어노테이션을 @Test 어노테이션 대신 사용하였다!!
@ParameterizedTest는 테스트 메서드의 파라미터로 특정한 값을 넣어 테스트할때 사용된다.
그 아래에 다음 어노테이션을 달아 활용할 수 있다.
ValueSource : (strings = {"프란츠", "카프카"}), (longs = {65536, 1048576}) 등과 같이 활용한다.
@ParameterizedTest
@ValueSource(strings = {"", " "})
void isBlank_ShouldReturnTrueForNullOrBlankStrings(String input) {
assertTrue(Strings.isBlank(input));
}
CsvSource : 여러 개의 값을 넣을 수 있다. 보통 입력값과 기대값을 넣는다.
@ParameterizedTest
@CsvSource({"test,TEST", "tEst,TEST", "Java,JAVA"})
void toUpperCase_ShouldGenerateTheExpectedUppercaseValue(String input, String expected) {
String actualValue = input.toUpperCase();
assertEquals(expected, actualValue);
}
EnumSource : Enum 클래스 전체나 일부의 값을 반복하여 전달한다.
@ParameterizedTest
@EnumSource(Month.class) // passing all 12 months
void getValueForAMonth_IsAlwaysBetweenOneAndTwelve(Month month) {
int monthNumber = month.getValue();
assertTrue(monthNumber >= 1 && monthNumber <= 12);
}
@ParameterizedTest
@EnumSource(value = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"})
void someMonths_Are30DaysLong(Month month) {
final boolean isALeapYear = false;
assertEquals(30, month.length(isALeapYear));
}
NullSource / EmptySource : null값 / 빈 값을 전달한다. 둘을 합쳐 NullAndEmptySource로 사용할 수 있다.
@ParameterizedTest
@NullSource
void isBlank_ShouldReturnTrueForNullInputs(String input) {
assertTrue(Strings.isBlank(input));
}
@ParameterizedTest
@EmptySource
void isBlank_ShouldReturnTrueForEmptyStrings(String input) {
assertTrue(Strings.isBlank(input));
}
@ParameterizedTest
@NullAndEmptySource
void isBlank_ShouldReturnTrueForNullAndEmptyStrings(String input) {
assertTrue(Strings.isBlank(input));
}
더 자세한 내용은 이 링크를 참조하기를 추천한다.
다시 테스트로 돌아와서, 우리는 Null값과 빈 값("")을 넣어 실제로 예외가 나는지 확인할 수 있다.
그리고 그 결과, 예외가 올바르게 작동하는 것이 확인되었다!
오늘 포스팅한 내용은 양도 방대하고, 하나하나가 중요한 내용이다.
다만 이 포스팅을 보면서, 다음과 같은 생각을 하는 분들이 많으리라 생각된다.
"예외를 띄워줄 뿐 너무 난잡한 값을 던져주는 거 아니야?"
"서비스나 도메인에서 예외가 발생하면 어떻게 되는거야? 그건 500 에러가 나지 않아?"
맞다. 이번 포스팅에서는 validation과 테스트만 쓰다가 진이 빠져서 그 부분에 대해 포스팅하지 않았다.
다음 포스팅에서는, Spring의 ControllerAdvisor와 ExceptionHandler에 대해 포스팅하려고 한다.
지금 도메인 설계도 끝났고...인수테스트 얘기도 할게 많아서 포스팅이 쭉쭉 밀린다. 이해해 주시길.
학습하며 참고한 감사한 분들의 링크를 남긴다.
내 글보다 더 좋으니 꼭 읽어보시길.
TOAST 개발 블로그의 Validation : 링크
jojoldu님의 Validation 모듈 만들기(심화) : 링크
잘못된 내용이 있다면 지적 부탁드립니다.
'프로그래밍 > 프로젝트' 카테고리의 다른 글
7. 도메인과 조회 로직 다시 만들기 (0) | 2021.02.26 |
---|---|
6. 프로젝트 요구사항 정리와 ERD (0) | 2021.02.18 |
4. TDD 개발 : Read/delete 기능 만들어보기 (0) | 2021.02.05 |
3. TDD 개발 : Create 기능 만들어보기 (0) | 2021.02.02 |
2. Lombok 사용과 고민 (1) | 2021.01.31 |
댓글