#spring, #validation

Spring Boot에서 DTO 검증하기

검증이 왜 필요할까?

나이를 입력해야 하는 데 이름을 입력한다면? 전화번호를 입력해야 하는 데 이메일을 입력한다면? 데이터를 저장할 때 당신은 아무 값이나 저장할 것인가?

아니다. 입력 값이 유효한 값인지 확인을 하고 저장할 것이다. 그래서 제목에서 알 수 있듯이 객체의 유효성 검사를 어떻게 하는지, 그리고 Test 코드는 어떻게 작성하는지 알아볼 것이다.

일단 의존성부터 추가해보자.

implementation 'org.springframework.boot:spring-boot-starter-validation'

다음으로 DTO를 작성할 건데, “title은 빈 값이 될 수 없다.”라고 가정해보자.

public class PostRequest {

    @NotEmpty
    private String title;
    private String content;
    
    // ...
}

Validation를 제공하는 어노테이션은 많다. (@Email, @URL 등… 참조) 그중 @NotEmpty는 title이 빈 값("")인지 검증해 주는 어노테이션이다.

다음으로 Controller를 작성해보자.

@RequestMapping("/posts")
@RestController
public class PostController {

    private final PostService postService;

    public PostController(final PostService postService) {
        this.postService = postService;
    }

    @PostMapping
    public ResponseEntity<PostResponse> create(@RequestBody @Valid final PostRequest postRequest) {
        final PostResponse postResponse = postService.create(postRequest);
        return ResponseEntity.created(URI.create("/posts/" + postResponse.getId())).build();
    }
    
    // ...
}

create메서드에 @Valid가 보이는가? DTO 맴버변수에 @NotEmpty를 했다고 해서 Spring이 알아서 검증해주는 것이 아니다. 위의 create와 같이 검증할 파라미터 앞에 @Valid를 추가해야 검증을 할 수 있다.

유효성 검사를 한다는 것은 잘못된 입력이 들어올 수 있다는 이야기이다. 따라서 유효성 검사 오류에 대한 처리도 해야 할 것이다.

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(
    MethodArgumentNotValidException exception) {
    Map<String, String> errors = new HashMap<>();
    exception.getBindingResult().getAllErrors().forEach((error) -> {
        String fieldName = ((FieldError) error).getField();
        String errorMessage = error.getDefaultMessage();
        errors.put(fieldName, errorMessage);
    });
    return ResponseEntity.badRequest().body(errors);
}

오류에 대한 ExceptionHandler를 작성하였다. 유효성 검사에 대한 예외는 MethodArgumentNotValidException를 발생한다. 또한, 잘못된 요청에 대한 응답이기 때문에 상대 코드는 400(Bad Request)이다. 상황에 따라 어떤 상태코드를 넘겨줘야 할지 모르시는 분들은 이 글(우의 글이 올라가면 링크로 달 예정)을 참고하길 바란다.

이것으로 여러분은 Controller에서 인자를 받을 때 검증을 할 수 있게되었다. 그러면 당연히 Test 해봐야하지 않겠나.

@AutoConfigureMockMvc
@SpringBootTest
public class PostControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void create_success() throws Exception {
        this.mockMvc.perform(post("/posts")
                .content("{\"title\": \"title\", \n\"content\": \"content\"}")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isCreated());
    }

    @Test
    void create_fail() throws Exception {
        this.mockMvc.perform(post("/posts")
                .content("{\"title\": \"\", \n\"content\": \"content\"}")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.title", Is.is("must not be empty")));
    }
}

비교를 위해 정상적인 요청과 비정상적인 요청 둘 다 작성하였다. create_fail은 입력 title에 빈값이기 때문에 검증이 실패할 것이고, ExceptionHandler에서 상태 코드로 400(Bad Request)을 보내기 때문에 status().isBadRequest()이렇게 설정한 것이다. 마지막 jsonPathExceptionHandler의 반환 값 errors를 생각하시면 되고, 우리는 title이 검증에 실패하기 때문에 errors에는 title이 있을 것이다. Is.is("must not be empty")이 부분은 검증 실패 시 보여주는 메시지를 검증하는 것이다.

“must not be empty”는 default message이다. 만약 바꾸고 싶다면,

@NotEmpty(message = "Post's title must not be empty")

이런식으로 바꿀 수 있다.

Controller말고 DTO에서 테스트할 순 없을까?

public class PostRequestTest {

    @Test
    void notEmpty_validation() {
        PostRequest postRequest = new PostRequest("", "content");
		// ...
    }
}

이렇게 작성해도 Controller처럼 검증이 실패되지 않을 것이다. 이 DTO가 검증이 실패했는지 판단하려면 추가되는 로직이 있다.

public class PostRequestTest {

    private static ValidatorFactory factory;
    private static Validator validator;

    @BeforeAll
    public static void init() {
        factory = Validation.buildDefaultValidatorFactory();
        validator = factory.getValidator();
    }

    @Test
    void notEmpty_validation() {
        PostRequest postRequest = new PostRequest("", "content");
        Set<ConstraintViolation<PostRequest>> violations = validator.validate(postRequest);
    }
}

validator.validate(postRequest)postRequest가 유효한지 판단해 준다. 만약 유효하다면 violations는 빈 값일 것이고, 유효하지 않다면 값을 가지고 있을 것이다.

violations가 가지고있는 에러 메시지를 확인하고 싶다면 다음과 같이 하면된다.

for (ConstraintViolation<PostRequest> violation : violations) {
    System.err.println(violation.getMessage());
}

마무리

검증 관련 어노테이션을 많이 사용했지만, 테스트까지 한 적은 없었다. 이번 프로젝트를 통해 검증에 대해 테스트를 하는 방법을 알게 되었고 적용하였다. 또 그것을 나누기 위해 글을 작성해보았다. 꼼꼼하게 Test Code를 작성하며 우리 모두 안전하고 신뢰성 있는 코드를 작성하기 위해 노력하자.

참고자료

Validation in Spring Boot

Java Bean Validation Basics