ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 중첩된 DTO을 Validation할 때는 DTO 필드에 @Valid 어노테이션을 붙이기
    Today I Learned 2022. 12. 14. 01:51

     

    게시글을 올리는 Controller의 구조를 리팩터링하고 있었다. 기존의 게시글 작성 API 요청에 대응하는 PostController에서 기존에 받고 있던 요청 DTO는 다음와 같이 하나의 DTO 객체에 모든 입력 필드 값이 있는 형태로 구성되어 있었다.

     

    // dtos/PostAndGameRequestDto.java
    
    import javax.validation.constraints.NotBlank;
    import javax.validation.constraints.NotNull;
    
    public class PostAndGameRequestDto {
        @NotBlank(message = "운동을 입력해주세요.")
        private String gameExercise;
    
        @NotBlank(message = "운동 날짜를 입력해주세요.")
        private String gameDate;
    
        @NotBlank(message = "시작시간 오전/오후 구분을 입력해주세요.")
        private String gameStartTimeAmPm;
    
        @NotBlank(message = "시작 시간을 입력해주세요.")
        private String gameStartHour;
    
        @NotBlank(message = "시작 분을 입력해주세요.")
        private String gameStartMinute;
    
        @NotBlank(message = "종료시간 오전/오후 구분을 입력해주세요.")
        private String gameEndTimeAmPm;
    
        @NotBlank(message = "종료 시간을 입력해주세요.")
        private String gameEndHour;
    
        @NotBlank(message = "종료 분을 입력해주세요.")
        private String gameEndMinute;
    
        @NotBlank(message = "운동 장소 이름을 입력해주세요.")
        private String placeName;
    
        @NotNull(message = "사용자 수를 입력해주세요.")
        private Integer gameTargetMemberCount;
    
        @NotBlank(message = "게시물 상세 내용을 입력해주세요.")
        private String postDetail;
    
        public PostAndGameRequestDto() {
    
        }
        
        // Constructor, Getters
        // DTO의 Constructor는 모든 필드 값을 받아 객체를 생성하는 생성자를,
        // Getters는 전통적인 Java 객체의 getXXX 형태의 getter를 나타낸다.
    }

     

    PostController에서는 DTO에서 발생한 에러가 있는지 확인한 뒤, 없으면 DTO의 모든 필드 값을 꺼내 인자로 전달하고 있었다.

     

    // controllers/PostController.java
    
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public CreatePostAndGameResultDto createPost(
        @RequestAttribute("userId") Long accessedUserId,
        @Validated @RequestBody PostAndGameRequestDto postAndGameRequestDto,
        BindingResult bindingResult
    ) {
        if (bindingResult.hasErrors()) {
            String errorMessage = bindingResult.getAllErrors()
                    .stream()
                    .findFirst().get()
                    .getDefaultMessage();
    
            throw new CreatePostFailed(errorMessage);
        }
    
        return createPostService.createPost(
            accessedUserId,
            postAndGameRequestDto.getGameExercise(),
            postAndGameRequestDto.getGameDate(),
            postAndGameRequestDto.getGameStartTimeAmPm(),
            postAndGameRequestDto.getGameStartHour(),
            postAndGameRequestDto.getGameStartMinute(),
            postAndGameRequestDto.getGameEndTimeAmPm(),
            postAndGameRequestDto.getGameEndHour(),
            postAndGameRequestDto.getGameEndMinute(),
            postAndGameRequestDto.getPlaceName(),
            postAndGameRequestDto.getGameTargetMemberCount(),
            postAndGameRequestDto.getPostDetail()
        );
    }
    
    @ExceptionHandler(CreatePostFailed.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public String createPostFailed(CreatePostFailed exception) {
        return exception.errorMessage();
    }

     

    이런 식으로 많은 인자들을 한 번에 파라미터로 펼쳐서 전달하는 것보다는 DTO 안에서 의미를 갖는 그룹들끼리 데이터를 묶을 수 있다면 가독성 측면에서 도움이 될 것이라 판단해 요청 DTO 안에 DTO를 중첩해 요청을 보내는 방식으로 리팩터링을 시도했다.

     

    리팩터링을 위해 Validation 로직을 중첩시킨 DTO의 내부로 옮겼다.

     

    // dtos/PostCreateRequestDto.java
    
    public class PostCreateRequestDto {
        private PostForPostCreateRequestDto post;
    
        private GameForPostCreateRequestDto game;
    
        private ExerciseForPostCreateRequestDto exercise;
    
        private PlaceForPostCreateRequestDto place;
    
        public PostCreateRequestDto() {
    
        }
        
        // Constructor, Getters
    }
    // dtos/PostForPostCreateRequestDto.java
    
    public class PostForPostCreateRequestDto {
        @NotBlank(message = "게시글 상세 내용을 입력해주세요.")
        private String detail;
    
        public PostForPostCreateRequestDto() {
    
        }
        
        // Constructor, Getters
    }
    // dtos/PlaceForPostCreateRequestDto.java
    
    public class PlaceForPostCreateRequestDto {
        @NotBlank(message = "운동 장소 이름을 입력해주세요.")
        private String name;
    
        public PlaceForPostCreateRequestDto() {
    
        }
        
        // Constructor, Getters   
    }

     

    PostController에서 CreatePostService에 전달하는 인자로 요청 DTO 내 중첩된 DTO를 전달하는 방식으로 컨트롤러 메서드를 리팩터링하고, Service Layer에서는 데이터를 가져오는 것만 중첩시킨 DTO에서 꺼내는 방식으로 수정하고 동작은 동일하게 수행하도록 했다.

     

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public CreatePostAndGameResultDto createPost(
        @RequestAttribute("userId") Long currentUserId,
        @Validated @RequestBody PostCreateRequestDto postCreateRequestDto,
        BindingResult bindingResult
    ) {
        // If bindingResult has errors, it throws CreatePostFailed Exception
    
        return createPostService.createPost(
            currentUserId,
            postCreateRequestDto.getPost(),
            postCreateRequestDto.getGame(),
            postCreateRequestDto.getExercise(),
            postCreateRequestDto.getPlace()
        );
    }
    
    @ExceptionHandler(CreatePostFailed.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public String createPostFailed(CreatePostFailed exception) {
        return exception.errorMessage();
    }

     

    동작 검증을 시도했을 때 테스트 코드에서 문제가 발생했다. 중첩된 DTO 내에서 에러가 발생한 경우 예외처리를 수행하는 상황을 테스트하는 테스트 케이스에서 에러가 발생하는 상황을 정의하고 테스트를 시도했는데도 HTTP Status를 400이 아닌 정상적으로 동작을 수행한 201이 발생하는 상황이 나타났다.

     

    @Test
    void createPostWithBlankGameExercise() throws Exception {
        Long userId = 1L;
        String blankGameExercise = "";
        String errorMessage = "운동을 입력해주세요.";
    
        ExerciseForPostCreateRequestDto wrongExerciseForPostCreateRequestDto
            = new ExerciseForPostCreateRequestDto(
            ""
        );
        given(createPostService.createPost(
            userId,
            postForPostCreateRequestDto,
            gameForPostCreateRequestDto,
            wrongExerciseForPostCreateRequestDto,
            placeForPostCreateRequestDto
        )).willThrow(new CreatePostFailed(errorMessage));
    
        String token = jwtUtil.encode(userId);
    
        mockMvc.perform(MockMvcRequestBuilders.post("/posts")
                .header("Authorization", "Bearer " + token)
                .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON)
                .content("{" +
                    "\"post\":{" +
                    "\"detail\":\"" + postForPostCreateRequestDto.getDetail() + "\"" +
                    "}," +
                    "\"game\":{" +
                    "\"date\":\"" + gameForPostCreateRequestDto.getDate() + "\"," +
                    "\"startTimeAmPm\":\"" + gameForPostCreateRequestDto.getStartTimeAmPm() + "\"," +
                    "\"startHour\":\"" + gameForPostCreateRequestDto.getStartHour() + "\"," +
                    "\"startMinute\":\"" + gameForPostCreateRequestDto.getStartMinute() + "\"," +
                    "\"endTimeAmPm\":\"" + gameForPostCreateRequestDto.getEndTimeAmPm() + "\"," +
                    "\"endHour\":\"" + gameForPostCreateRequestDto.getEndHour() + "\"," +
                    "\"endMinute\":\"" + gameForPostCreateRequestDto.getEndMinute() + "\"," +
                    "\"targetMemberCount\": " + gameForPostCreateRequestDto.getTargetMemberCount() +
                    "}," +
                    "\"exercise\":{" +
                    "\"name\":\"" + wrongExerciseForPostCreateRequestDto.getName() + "\"" +
                    "}," +
                    "\"place\":{" +
                    "\"name\":\"" + placeForPostCreateRequestDto.getName() + "\"" +
                    "}" +
                    "}"))
            .andExpect(MockMvcResultMatchers.status().isBadRequest())
            .andExpect(MockMvcResultMatchers.content().string(
                containsString(errorMessage)
            ))
        ;
    }

     

    문제 해결을 위해 관련된 영역을 디버깅했을 때, bindingResult에 error가 발생하지 않아 hasErrors 메서드가 false를 반환한 사실을 확인했다. 최상단의 DTO에서 Validation을 하지 않는 것으로 짐작되어 구글에 'spring nested request body validate'과 같이 관련된 키워드들을 검색했을 때 해결책을 찾을 수 있었다.

     

    https://www.baeldung.com/spring-valid-vs-validated#using-valid-annotation-to-mark-nested-objects

     

    상단 웹 페이지의 글에 따르면, Validation이 중첩된 객체 내 요소에 이루어져야 할 때는 @Valid 어노테이션을 사용할 수 있다.

    The @Valid annotation is used to mark nested attributes, in particular. This triggers the validation of the nested object. To ensure validation of this nested object, we'll decorate the attribute with the @Valid annotation.

     

     

    Valid.java 파일 내 주석에는 @Valid의 역할이 다음과 같이 정의되어 있다.

    Marks a property, method parameter or method return type for validation cascading.

    Constraints defined on the object and its properties are be validated when the property, method parameter or method return type is validated.

    This behavior is applied recursively.

     

    마지막 줄의 내용을 바탕으로 유효성 검증이 중첩된 객체에 대해서도 반복적으로 적용될 것인지 확인하기 위해 최상위 DTO의 검증을 수행하는 객체에 @Valid 어노테이션을 붙이고 테스트를 다시 진행헀다.

     

    public class PostCreateRequestDto {
        @Valid
        private PostForPostCreateRequestDto post;
    
        @Valid
        private GameForPostCreateRequestDto game;
    
        @Valid
        private ExerciseForPostCreateRequestDto exercise;
    
        @Valid
        private PlaceForPostCreateRequestDto place;
    
        public PostCreateRequestDto() {
    
        }
        
        // Constructors, Getter
    }

     

    어노테이션을 붙이고 Controller 테스트를 다시 수행한 결과 에러가 발생하는 상황에서는 정상적으로 응답하도록 한 400번대 에러 Status를 응답으로 반환하는 것을 확인할 수 있었다.

     

     

     

    References

    - https://www.baeldung.com/spring-valid-vs-validated

     

     

     

     

    댓글

Designed by Tistory.