관리 메뉴

bright jazz music

blog03: 데이터 검증1 (@NotBlank, @Valid, BindingResult) 본문

Projects/blog

blog03: 데이터 검증1 (@NotBlank, @Valid, BindingResult)

bright jazz music 2022. 12. 12. 14:44

이 포스팅에서는 아래와 같은 내용을 다룬다.

 

VO에 @NotBlank 어노테이션을 사용해서 에러를 발생시키고, 그걸 BindingResult에 담는다

BindingResult객체에 담긴 값들을 이용해서 클라이언트에게 에러를 반환시키는 실습이다.

 

과정

----------------------------------------------------------------

VO필드에 @NotBlank 부착 => Controller의 핸들러 메소드의 파라미터에 @Valid 부착

==> 파라미터에 BindingResult 추가 ==> @NotBlank 어노테이션 커스텀

----------------------------------------------------------------

 

 

1. 문제 상황

: 값이 비었는데도 오류 없이 전송/반환됨.

// 데이터를 검증하는 이유

//1. client 개발자가 실수할 수 있다. 값을 안보내거나 잘못 보내거나.
//2. client bug으로 인해서 값이 누락될 수 있다.
//3. 악의적인 의도로 값을 임의로 조작해서 보낼 수 있다.
//4. DB에 값을 저장할 때 의도치 않은 오류가 발생할 수 있다.
//5. 프로세스의 안정감. 

 

VO

//PostCreate.java

package com.endofma.blog.request;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Setter
@Getter
@ToString
public class PostCreate {
    private String title;
    private String content;

}

 

타이틀을 "" 공란으로 비운 채 전송하기

//PostControllerTest.java

    @Test
    @DisplayName("/posts 요청시 title 값은 필수다")
    void test2() throws Exception {
        //expected

        mockMvc.perform(post("/posts")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\": \"\", \"content\": \"내용입니다.\"}") //title 값을 빈 스트링으로 설정하였다.
                )
                .andExpect(status().isOk())
                .andExpect(content().string("hello world"))
                .andDo(print());

    }

결과

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.6)

2022-12-12 14:29:20.557  INFO 15160 --- [    Test worker] c.e.blog.controller.PostControllerTest   : Starting PostControllerTest using Java 11.0.12 on DESKTOP-Q7HBM41 with PID 15160 (started by user in D:\personal\work\blog)
2022-12-12 14:29:20.560  INFO 15160 --- [    Test worker] c.e.blog.controller.PostControllerTest   : No active profile set, falling back to 1 default profile: "default"
2022-12-12 14:29:22.235  INFO 15160 --- [    Test worker] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
2022-12-12 14:29:22.236  INFO 15160 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Initializing Servlet ''
2022-12-12 14:29:22.238  INFO 15160 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Completed initialization in 2 ms
2022-12-12 14:29:22.289  INFO 15160 --- [    Test worker] c.e.blog.controller.PostControllerTest   : Started PostControllerTest in 2.314 seconds (JVM running for 4.808)
2022-12-12 15:18:00.931  INFO 3932 --- [    Test worker] c.e.blog.controller.PostController       : params=PostCreate(title=, content=내용입니다.)


MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /posts
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"44"]
             Body = {"title": "", "content": "내용입니다."}
    Session Attrs = {}

Handler:
             Type = com.endofma.blog.controller.PostController
           Method = com.endofma.blog.controller.PostController#post(PostCreate)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"text/plain;charset=UTF-8", Content-Length:"11"]
     Content type = text/plain;charset=UTF-8
             Body = hello world
    Forwarded URL = null
   Redirected URL = null
          Cookies = []
BUILD SUCCESSFUL in 8s
4 actionable tasks: 3 executed, 1 up-to-date
PM 2:29:23: Execution finished ':test --tests "com.endofma.blog.controller.PostControllerTest.test2"'.

 

현재는 200 코드가 반환된다. 그러나 타이틀이 빠지면 안 된다고 가정하자. 그럼 타이틀이 빠졌을 때 200을 반환하면 안 된다.

 

아래와 같이 막을 수는 있지만 한계가 있으며 실수의 가능성이 있다.

//PostController.java


@Slf4j
@RestController
public class PostController {

    @PostMapping("/posts")
    public String post(@ModelAttribute PostCreate params) throws Exception{
        log.info("params={}", params.toString());

        String title = params.getTitle();
        if (title == null || title.equals("")){
            throw new Exception("타이틀 값이 없습니다.");
            //1. 반복작업
            //2. 누락 가능성
            //3. 검증 종류가 복잡한 경우가 많다.
            //{"title":"        "}
            //{"title":".......수십억 글자....... "}
            
        }
        String content = params.getContent();
        if (content == null || title.equals("")){
            //error
        }

        return "hello world";
    }

}

 

-----------------------------------------------------

 

2. @NotBlank 어노테이션을 VO 필드에 부착

여기서부터 본격적인 검증작업.

VO의 필드에 @NotBlank 적어줌

//PostCreate.java

package com.endofma.blog.request;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import javax.validation.constraints.NotBlank;

@Setter
@Getter
@ToString
public class PostCreate {
    @NotBlank //@Valid를 @RequestBody 옆에 @valid를 붙임. 그러면 바인딩 되면서 빈 값이 넘어오면 에러발생시킴.
    private String title;
    
    @NotBlank
    private String content;
}

 

스프링 버전에 따라 @NotBlank를 찾을 수 없다고 나올 수 있음.

그러면 gradle.build 또는 pom.xml에 의존성을 추가해 주자.

 

/*build.gradle*/

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.6'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'com.endofma'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation' /*validation 추가*/
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

 

3. 핸들러 메소드의 파라미터에 @Valid부착

@RequestBody 옆에 @Valid 추가.

//PostController.java

@Slf4j
@RestController
public class PostController {

    @PostMapping("/posts")
    public String post(@RequestBody @Valid PostCreate params) {
        log.info("params={}", params.toString());

        return "hello world";
    }

}

 

테스트에서 다시 요청해 보자

//PostControllerTest.java
...
    @Test
    @DisplayName("/posts 요청시 title 값은 필수다")
    void test2() throws Exception {
        //expected

        mockMvc.perform(post("/posts")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\": \"\", \"content\": \"내용입니다.\"}") //title 값을 빈 스트링으로 설정하였다.
                )
                .andExpect(status().isOk())
                .andExpect(content().string("hello world"))
                .andDo(print());

    }

}

 

결과

 .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.6)

2022-12-12 15:22:05.281  INFO 21860 --- [    Test worker] c.e.blog.controller.PostControllerTest   : Starting PostControllerTest using Java 11.0.12 on DESKTOP-Q7HBM41 with PID 21860 (started by user in D:\personal\work\blog)
2022-12-12 15:22:05.284  INFO 21860 --- [    Test worker] c.e.blog.controller.PostControllerTest   : No active profile set, falling back to 1 default profile: "default"
2022-12-12 15:22:07.102  INFO 21860 --- [    Test worker] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
2022-12-12 15:22:07.102  INFO 21860 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Initializing Servlet ''
2022-12-12 15:22:07.104  INFO 21860 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Completed initialization in 2 ms
2022-12-12 15:22:07.140  INFO 21860 --- [    Test worker] c.e.blog.controller.PostControllerTest   : Started PostControllerTest in 2.44 seconds (JVM running for 4.987)
2022-12-12 15:22:07.892  WARN 21860 --- [    Test worker] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public java.lang.String com.endofma.blog.controller.PostController.post(com.endofma.blog.request.PostCreate): [Field error in object 'postCreate' on field 'title': rejected value []; codes [NotBlank.postCreate.title,NotBlank.title,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [postCreate.title,title]; arguments []; default message [title]]; default message [must not be blank]] ]

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /posts
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"44"]
             Body = {"title": "", "content": "내용입니다."}
    Session Attrs = {}

Handler:
             Type = com.endofma.blog.controller.PostController
           Method = com.endofma.blog.controller.PostController#post(PostCreate)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = org.springframework.web.bind.MethodArgumentNotValidException

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 400
    Error message = null
          Headers = []
     Content type = null
             Body = 
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

Status expected:<200> but was:<400>
Expected :200
Actual   :400
<Click to see difference>

java.lang.AssertionError: Status expected:<200> but was:<400>
	at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:59)

	...생략...

	at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)


PostControllerTest > /posts 요청시 title 값은 필수다 FAILED
    java.lang.AssertionError at PostControllerTest.java:54
1 test completed, 1 failed
> Task :test FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: file:///D:/personal/work/blog/build/reports/tests/test/index.html
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
* Get more help at https://help.gradle.org
BUILD FAILED in 8s
4 actionable tasks: 2 executed, 2 up-to-date

 

에러코드 400이 반환된다.

또한 @NotBlank가 붙은 필드가 blank여서는 안 된다는 에러를 출력한다.

2022-12-12 15:22:07.892  WARN 21860 --- [    Test worker] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public java.lang.String com.endofma.blog.controller.PostController.post(com.endofma.blog.request.PostCreate): [Field error in object 'postCreate' on field 'title': rejected value []; codes [NotBlank.postCreate.title,NotBlank.title,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [postCreate.title,title]; arguments []; default message [title]]; default message [must not be blank]] ]

MockHttpServletResponse 부분을 보면 "Body = " 로 되어 있다. 이는 스프링에서 해당 요청이 컨트롤러로 넘어오기 전에 처리를 하고 반환했다는 뜻이다. 컨트롤러 내부의 코드에 브레이크 포인트를 설정해 보면, 걸리지 않는 것을 볼 수 있다.

 

4. BindingResult 객체를 핸들러 메소드 파라미터에 추가

그런데 만약 경고와 같은 값을 반환하고 싶은 경우는 어떨까? 컨트롤러 내부로 넘어오지 않기 때문에 해결이 어렵다. 이럴 때는 파라미터로 BindingResult를 추가해 주면 바인딩에 관한 내용이 BindingResult 객체에 담기게 된다.

 

BindingResult result를 파라미터로 추가.

//PostController.java

@Slf4j
@RestController
public class PostController {

    @PostMapping("/posts")
    public String post(@RequestBody @Valid PostCreate params, BindingResult result) {
        log.info("params={}", params.toString());

        return "hello world";
    }
}

 

이제는 컨트롤러 내부까지 들어온다.

 

 에러 필드와  에러 내용을 반환하도록 수정

//PostController.java

@Slf4j
@RestController
public class PostController {

    @PostMapping("/posts")
    public Map<String, String> post(@RequestBody @Valid PostCreate params, BindingResult result) {
        log.info("params={}", params.toString());

        if(result.hasErrors()) {
            List<FieldError> fieldErrors =  result.getFieldErrors();
            FieldError firstFieldError = fieldErrors.get(0); //첫 번째 에러 가져오기
            String fieldName = firstFieldError.getField(); //필드 값이 아니라 필드 이름을 가져온다. 이 예제에서는 "title"
            String errorMessage = firstFieldError.getDefaultMessage(); // 에러메시지

            Map<String, String> error = new HashMap<String, String>(); //new HashMap<>();
            error.put(fieldName, errorMessage);
            return error;
        }

        return Map.of();
    }

}

 

요청 결과

.   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.6)

2022-12-12 15:55:56.806  INFO 20616 --- [    Test worker] c.e.blog.controller.PostControllerTest   : Starting PostControllerTest using Java 11.0.12 on DESKTOP-Q7HBM41 with PID 20616 (started by user in D:\personal\work\blog)
2022-12-12 15:55:56.808  INFO 20616 --- [    Test worker] c.e.blog.controller.PostControllerTest   : No active profile set, falling back to 1 default profile: "default"
2022-12-12 15:55:58.592  INFO 20616 --- [    Test worker] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
2022-12-12 15:55:58.592  INFO 20616 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Initializing Servlet ''
2022-12-12 15:55:58.594  INFO 20616 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Completed initialization in 2 ms
2022-12-12 15:55:58.637  INFO 20616 --- [    Test worker] c.e.blog.controller.PostControllerTest   : Started PostControllerTest in 2.398 seconds (JVM running for 4.866)
2022-12-12 15:55:59.391  INFO 20616 --- [    Test worker] c.e.blog.controller.PostController       : params=PostCreate(title=, content=내용입니다.)

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /posts
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"44"]
             Body = {"title": "", "content": "내용입니다."}
    Session Attrs = {}

Handler:
             Type = com.endofma.blog.controller.PostController
           Method = com.endofma.blog.controller.PostController#post(PostCreate, BindingResult)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"title":"must not be blank"}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

Response content expected:<hello world> but was:<{"title":"must not be blank"}>
Expected :hello world
Actual   :{"title":"must not be blank"}
<Click to see difference>

java.lang.AssertionError: Response content expected:<hello world> but was:<{"title":"must not be blank"}>
	at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:59)
	.. 생략
	at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)


PostControllerTest > /posts 요청시 title 값은 필수다 FAILED
    java.lang.AssertionError at PostControllerTest.java:55
1 test completed, 1 failed
> Task :test FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: file:///D:/personal/work/blog/build/reports/tests/test/index.html
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
* Get more help at https://help.gradle.org
BUILD FAILED in 6s
4 actionable tasks: 1 executed, 3 up-to-date

 

리스펀스 바디에 제이슨 형식으로 필드와 에러 내용이 담긴 것을 볼 수있다.

MockHttpServletResponse: Status = 200 Error message = null Headers = [Content-Type:"application/json"] Content type = application/json Body = {"title":"must not be blank"} Forwarded URL = null Redirected URL = null Cookies = []

 

5. @NotBlank 어노테이션 커스텀

만약 커스텀 메시지를 반환하고 싶다면. @NotBlank 어노테이션에 파라미터를 넘겨준다.

//PostCreate.java

@Setter
@Getter
@ToString
public class PostCreate {
    @NotBlank(message = "타이틀을 입력하세요!")
    //@Valid를 @RequestBody 옆에 @valid를 붙임. 그러면 바인딩 되면서 빈 값이 넘어오면 에러발생시킴.
    private String title;

    @NotBlank(message = "내용을 입력하세요!")
    private String content;
}

 

PostControllerTest.test2를 통해 다시 요청을 날려보자.

위처럼 커스텀 메시지가 반환된다.

빈 문자열 ""이 아니라 아래 처럼  null을 보내더라도 @NotBlank는 동일한 오류 메시지를 반환한다.

.content("{\"title\": null, \"content\": \"내용입니다.\"}") // null에 따옴표 없는 것에 주의
//title을 null값으로 지정

 

즉, @NotBlank  어노테이션을 사용하면 빈 문자열 값과 null 값이 들어오는 것을 막아준다.

 

json값을 검증해보자.

json 값은 jasonPath를 사용해서 쉽게 검증이 가능하다.

 

MockMvc를 이용한 REST API의 Json Response 검증

MockMvc를 이용하여 API의 Json Response를 JsonPath 표현식을 사용해 검증하는 예제를 정리하였습니다. 테스트 예제를 보기 앞서 테스트에 필요한 기본적인 지식들을 간단히 서술 해보겠습니다. 의존성

ykh6242.tistory.com

//PostControllerTest.java

    @Test
    @DisplayName("/posts 요청시 title 값은 필수다")
    void test2() throws Exception {
        //expected

        mockMvc.perform(post("/posts")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\": null, \"content\": \"내용입니다.\"}") //title 값을 빈 스트링으로 설정하였다.
                )
                .andExpect(status().isOk())
//                .andExpect(content().string(""))
                .andExpect(MockMvcResultMatchers.jsonPath("$.title").value("타이틀을 입력하세요!"))
                //json 검증. json 응답값의 필드 값이 value로 내려 오는지 여부 확인
                //강의에서는 jsonPath만으로 사용 가능했음.
                .andDo(print());

    }

}

 

테스트 결과 테스트가 통과함을 알 수 있다.

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.6)

2022-12-12 18:33:13.506  INFO 19828 --- [    Test worker] c.e.blog.controller.PostControllerTest   : Starting PostControllerTest using Java 11.0.12 on DESKTOP-Q7HBM41 with PID 19828 (started by user in D:\personal\work\blog)
2022-12-12 18:33:13.509  INFO 19828 --- [    Test worker] c.e.blog.controller.PostControllerTest   : No active profile set, falling back to 1 default profile: "default"
2022-12-12 18:33:15.311  INFO 19828 --- [    Test worker] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
2022-12-12 18:33:15.311  INFO 19828 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Initializing Servlet ''
2022-12-12 18:33:15.313  INFO 19828 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Completed initialization in 1 ms
2022-12-12 18:33:15.350  INFO 19828 --- [    Test worker] c.e.blog.controller.PostControllerTest   : Started PostControllerTest in 2.436 seconds (JVM running for 4.916)
2022-12-12 18:33:16.098  INFO 19828 --- [    Test worker] c.e.blog.controller.PostController       : params=PostCreate(title=null, content=내용입니다.)

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /posts
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"46"]
             Body = {"title": null, "content": "내용입니다."}
    Session Attrs = {}

Handler:
             Type = com.endofma.blog.controller.PostController
           Method = com.endofma.blog.controller.PostController#post(PostCreate, BindingResult)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"title":"타이틀을 입력하세요!"}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []
BUILD SUCCESSFUL in 7s
4 actionable tasks: 2 executed, 2 up-to-date
PM 6:33:16: Execution finished ':test --tests "com.endofma.blog.controller.PostControllerTest.test2"'.

 

 

 

 

Comments