Spring does not check valid when list data is databinding
다음과 같은 예시가 있습니다.
스프링에서 Controller 로 데이터가 넘어올 때 [java.util.List] 형태의 데이터만 넘어와서
해당 List에 맞게 DTO를 만들어서 List로 데이터를 받는데,
List 안의 데이터에 대해서 분명히 조건 체크를 하게 했는데 Null로 데이터가 넘어온 데이터로 작업하게 되어 에러가 발생한다 ?????
위 문제로 인해 저도 여러 해결 방법을 찾아봤는데요
특히, "Custom Validator를 만들어서 체크하면 된다" 하는 글이 대부분이었습니다.
위 Custom Validator가 아닌 방법으로 좀 더 손쉽게 해결 가능하도록 해봅시다.
우선 이유부터 알아봅시다.
왜 ? 도대체 왜? Controller에서 @Valid를 붙여서 조건을 확인하도록 했는데 확인을 하지 않을까요?
다음과 같은 예시를 작성해서 확인해봅시다.
Controller
DTO
Test Code
자 조건을 확인해 봅시다.
- Controller에서는 RequestBody로 body에 있는 데이터를 List로 받고 valid 체크하도록 되어 있습니다.
- DTO에서는 num과 name를 받도록 되어 있고 Min과 NotNull 체크를 하네요
- TestCode에서는 우리가 받을 list의 데이터로 작성한 num과 name은 없는 전혀 이상한 productData만 넘겨주도록 되어 있습니다.
과연 잘 넘어올까요??
실행해보니 위와 같은 결과 나왔습니다.
- 우선 list가 null은 아니라네요?
- 그렇기 때문에 for 문이 실행됐습니다. 그런데 ?????
name : null
num : 0
?????????????????????????? - 가장 큰 문제는 메소드 파라미터 부터 Valid에 걸려 에러가 발생했음을 알려줘야 합니다.
위 문자들이 한 글자도 적히면 안된다는 것입니다.
List로 데이터를 받을 경우 위와 같이 Valid 체크가 되지 않고 null 데이터들이 우리의 서버를 마구잡이로 돌아다니고 있는 것을 알 수 있습니다.
이러면 안되겠죠!?
그렇다면 해결 방법은 ??????
해결 방법으로 Custom Validator를 생성해서 확인하는 방법도 있는데요
하지만 좀 더 가시적? 쉽게 ? 만들어 볼까요 ?
방법은 바로, List를 직접 받지 않고
Request용 DTO Class에 선언되는 변수 목록에
List<>를 받도록 포함하면 된다는 것입니다.
다음과 같이 만들어 봅시다.
PathVariable은 그냥 붙여봤....
Controller
DTO
보시면 List가 포함되어 있는 것을 볼 수 있습니다.
DTO를 작성할 때 유의할 점이 있습니다.
다음과 같이 List 자체에 대해서 @valid를 붙이지 않았는데요
List에 있는 데이터에 대해서 @NotNull or @Min or @Max 와 같은 조건을 체크하도록 list 안에서 어노테이션을 붙여도
@Valid 유효한 조건이 체크가 되지 않을까요?
정답은 체크 되지 않는다 입니다. 그렇다면 어떻게?
List 객체 자체의 NotNull 체크 그리고 List 안에 있는 데이터에 대해 Valid 체크하기 위해
Controller + 여기서도 @Valid를 사용해줍니다.
위에서 말한 것처럼 웃기게도 여기서는 Valid Check가 됩니다.
Test Code
list에 들어가는 데이터 세팅
위와 같이 준비해서 테스트를 돌려봅시다.
다음과 같이 데이터가 잘 통과돼서 데이터를 반환해준 것을 볼 수 있습니다.
그렇다면 지금까지 이렇게 한 이유인 null 체크가 되는지 확인해보겠습니다.
다음과 같이 list에 들어가는 productData에 null을 넣어 테스트 코드를 변경하고 실행해보겠습니다.
사진으로 다 담기는 힘들어서 결과를 문자로 그대로 복,붙 해보겠습니다.
아래 전체 문자를 보기 전에 조금 나눠서 먼저 설명하겠습니다.
- Request 데이터는 null 포함해서 Body에 잘 들어가있네요!
-
MockHttpServletRequest: HTTP Method = PUT Request URI = /example/databind/list/100 Parameters = {} Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"71"] Body = {"name":"홍길동","databindList":[{},{"productData":"닭가슴살"}]} Session Attrs = {}
-
- 첫 번째 줄을 보시면 알겠지만 HandlerException이 발생한 것을 볼 수 있습니다
-
org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [1] Validation failed for argument가 있네요! Valid 체크가 잘 된 것을 볼 수 있습니다.
-
2021-12-04 10:23:37.693 WARN 23420 --- [ main] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [1] in public java.lang.String com.example.demo.databind_list.DatabindListController.databindListExample2(java.lang.Integer,com.example.demo.databind_list.DatabindListDTO$DatabindRequest): [Field error in object 'databindRequest' on field 'databindList[0].productData': rejected value [null]; codes [NotNull.databindRequest.databindList[0].productData,NotNull.databindRequest.databindList.productData,NotNull.databindList[0].productData,NotNull.databindList.productData,NotNull.productData,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [databindRequest.databindList[0].productData,databindList[0].productData]; arguments []; default message [databindList[0].productData]]; default message [must not be null]] ]
-
- 마지막에 결국에는 Response 반환 값을 줄 때 400 Error를 함께 주네요
-
MockHttpServletResponse: Status = 400 Error message = null Headers = [X-Content-Type-Options:"nosniff", X-XSS-Protection:"1; mode=block", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"] Content type = null Body = Forwarded URL = null Redirected URL = null Cookies = []
-
2021-12-04 10:23:37.693 WARN 23420 --- [ main] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [1] in public java.lang.String com.example.demo.databind_list.DatabindListController.databindListExample2(java.lang.Integer,com.example.demo.databind_list.DatabindListDTO$DatabindRequest): [Field error in object 'databindRequest' on field 'databindList[0].productData': rejected value [null]; codes [NotNull.databindRequest.databindList[0].productData,NotNull.databindRequest.databindList.productData,NotNull.databindList[0].productData,NotNull.databindList.productData,NotNull.productData,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [databindRequest.databindList[0].productData,databindList[0].productData]; arguments []; default message [databindList[0].productData]]; default message [must not be null]] ]
MockHttpServletRequest:HTTP Method = PUT
Request URI = /example/databind/list/100
Parameters = {}
Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"71"]
Body = {"name":"홍길동","databindList":[{},{"productData":"닭가슴살"}]}
Session Attrs = {}
Handler:
Type = com.example.demo.databind_list.DatabindListController
Method = com.example.demo.databind_list.DatabindListController#databindListExample2(Integer, DatabindRequest)
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 = [X-Content-Type-Options:"nosniff", X-XSS-Protection:"1; mode=block", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
Content type = null
Body =
Forwarded URL = null
Redirected URL = null
Cookies = []
MockHttpServletRequest:
HTTP Method = PUT
Request URI = /example/databind/list/100
Parameters = {}
Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"71"]
Body = {"name":"홍길동","databindList":[{},{"productData":"닭가슴살"}]}
Session Attrs = {}
Handler:
Type = com.example.demo.databind_list.DatabindListController
Method = com.example.demo.databind_list.DatabindListController#databindListExample2(Integer, DatabindRequest)
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 = [X-Content-Type-Options:"nosniff", X-XSS-Protection:"1; mode=block", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
Content type = null
Body =
Forwarded URL = null
Redirected URL = null
Cookies = []
java.lang.AssertionError: Status expected:<200> but was:<400>
Expected :200
Actual :400
<Click to see difference>
at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:59)
at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:122)
at org.springframework.test.web.servlet.result.StatusResultMatchers.lambda$matcher$9(StatusResultMatchers.java:627)
at org.springframework.test.web.servlet.MockMvc$1.andExpect(MockMvc.java:212)
at com.example.demo.databind_list.DatabindListControllerTest.databindListExample2(DatabindListControllerTest.java:71)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:566)
at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688)
at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:131)
at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:149)
at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:140)
at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:84)
at org.junit.jupiter.engine.execution.ExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(ExecutableInvoker.java:115)
at org.junit.jupiter.engine.execution.ExecutableInvoker.lambda$invoke$0(ExecutableInvoker.java:105)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:106)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:64)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:45)
at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:37)
at org.junit.jupiter.engine.execution.ExecutableInvoker.invoke(ExecutableInvoker.java:104)
at org.junit.jupiter.engine.execution.ExecutableInvoker.invoke(ExecutableInvoker.java:98)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$6(TestMethodTestDescriptor.java:210)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:206)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:131)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:65)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$5(NodeTestTask.java:139)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$7(NodeTestTask.java:129)
at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:127)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:126)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1541)
at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$5(NodeTestTask.java:143)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$7(NodeTestTask.java:129)
at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:127)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:126)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1541)
at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$5(NodeTestTask.java:143)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$7(NodeTestTask.java:129)
at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:127)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:126)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84)
at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:32)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:51)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:108)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:88)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:54)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:67)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:52)
at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:96)
at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:75)
at com.intellij.junit5.JUnit5IdeaTestRunner.startRunnerWithArgs(JUnit5IdeaTestRunner.java:71)
at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33)
at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:235)
at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:54)
위와 같이 List의 데이터에 대해 Valid 체크가 잘 되는 것을 확인 할 수 있습니다.
설명이 길었을 뿐! 생각보다는 쉽죠?
모두 문제가 잘 해결되시길 바랍니다 ^_^
전체 코드
Controller
package com.example.demo.databind_list;
import org.springframework.web.bind.annotation.*;
import com.example.demo.databind_list.DatabindListDTO.DatabindResponse;
import com.example.demo.databind_list.DatabindListDTO.DatabindRequest;
import com.example.demo.databind_list.DatabindListDTO.DatabindList;
import javax.validation.Valid;
import java.util.List;
@RestController
@RequestMapping("/example/databind")
public class DatabindListController {
@PutMapping("/list")
public void databindListExample1(@Valid @RequestBody List<DatabindList> list){
System.out.println("data가 있을까요? " + list.isEmpty());
for (DatabindList data : list){
System.out.println("name : " + data.getName());
System.out.println("num : " + data.getNum());
System.out.println("------- 오! 들어왔네?");
}
System.out.println("과연 데이터가 있었을까요?");
}
@PutMapping("/list/{api}")
public String databindListExample2(
@PathVariable("api") Integer api, @Valid @RequestBody DatabindRequest request){
DatabindResponse response = new DatabindResponse();
response.setApi(api);
response.setData1(request.getDatabindList().get(0).getProductData());
response.setData2(request.getDatabindList().get(1).getProductData());
response.setName(request.getName());
return response.toString();
}
}
DTO
package com.example.demo.databind_list;
import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;
public class DatabindListDTO {
@Data
public static class DatabindList{
@Min(1)
private int num;
@NotNull
private String name;
}
@Data
public static class DatabindRequest{
@NotEmpty
private String name;
@NotNull @Valid
private List<DatabindExampleList> databindList;
@Data
public static class DatabindExampleList{
@NotNull
private String productData;
}
}
@Data
public static class DatabindResponse{
private String name;
private Integer api;
private String data1;
private String data2;
}
}
Test Code
package com.example.demo.databind_list;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import com.example.demo.databind_list.DatabindListDTO.DatabindResponse;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class DatabindListControllerTest {
@Autowired
private MockMvc mockMvc;
private JSONArray arr;
@BeforeEach
public void setting() throws JSONException {
arr = new JSONArray();
arr.put(new JSONObject().put("productData",null));
arr.put(new JSONObject().put("productData","닭가슴살"));
}
@Test
@DisplayName("리스트로 데이터 받을 경우 validation 체크 여부 확인")
public void databindListExample1() throws Exception {
mockMvc.perform(
put("/example/databind/list")
.content(arr.toString())
.contentType(MediaType.APPLICATION_JSON_VALUE)
)
.andDo(print());
}
@Test
@DisplayName("데이터 바인드시 리스트 데이터 validation 체크")
public void databindListExample2() throws Exception {
JSONObject result = new JSONObject();
result.put("name", "홍길동");
result.put("databindList", arr);
DatabindResponse response = new DatabindResponse();
response.setData1("고구마");
response.setData2("닭가슴살");
response.setName("홍길동");
response.setApi(100);
mockMvc.perform(
put("/example/databind/list/100")
.content(result.toString())
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().string(response.toString()))
;
}
}
'Programming > Backend' 카테고리의 다른 글
1.Flask 웹 서버 구축 시작 (0) | 2022.04.20 |
---|---|
MariaDB + SQLAlchemy + Flask에서 database 연결 끊김 현상 (0) | 2022.03.21 |
협업을 위한 snake_case -> camelCase 변환 방법 @JsonNaming, @JsonProperty (0) | 2021.11.22 |
객체 지향, 오브젝트와 의존 관계의 이해 (0) | 2021.10.09 |
API 명세서 뜯어보기 - StringTokenizer Class (0) | 2021.10.05 |