그동안 Spring에서 RequestBody 검증을 할 때 습관처럼 @Validated를 사용해왔습니다. 그런데 문득, RequestBody를 List로 받을 때 검증이 어떻게 동작하는지 궁금해졌습니다.
예를 들어, 아래와 같이 List 타입의 RequestBody를 받으면 어떻게 검증이 이루어질까요?
@PostMapping("/test")
public ResponseEntity<?> validatedTest(@Validated @RequestBody List<TestCreate> testCreates) {
// List 내부의 각 요소가 개별적으로 검증됨
return ResponseEntity.ok("success");
}
저는 직관적으로 List 객체 검증 시, 내부 요소를 재귀적으로 검사하기보다는 List가 비었는지 여부만 확인할 것이라 생각했습니다. 그런데 실제 동작은 달랐습니다.
이번 포스트에서는 @Validated와 @Valid를 사용했을 때 List 객체 검증 방식이 어떻게 달라지는지 비교하고, 올바른 검증 방법에 대해 알아보겠습니다.
간단히 테스트 코드를 작성해 실제 동작을 확인해봤습니다.
public record TestCreate(
@NotNull(message = "id는 필수입니다.")
Long id,
String name
) {}
@NotNull 애노테이션이 붙은 id 필드는 null이 들어오면 검증에 실패합니다.
name은 별도의 제약조건이 없으므로 null이어도 검증에 통과합니다.
List 내부 요소에 대해 재귀적으로 검증하는 두 가지 방법을 소개합니다.
@PostMapping("/test")
public ResponseEntity<?> validatedTest(@Valid @RequestBody List<TestCreate> testCreates) {
return ResponseEntity.ok("success");
}
또는, 제네릭 요소에 직접 @Valid를 붙이는 방식도 있습니다.
@PostMapping("/test")
public ResponseEntity<?> validatedTest(@RequestBody List<@Valid TestCreate> testCreates) {
return ResponseEntity.ok("success");
}
두 방식 모두 List 내부에 있는 모든 객체에 대해 검증을 수행합니다.
@PostMapping("/test")
public ResponseEntity<?> validatedTest(@Validated @RequestBody List<TestCreate> testCreates) {
// 컬렉션(List) 자체만 검증 대상으로 삼아, 내부 요소에 대한 재귀적 검증은 수행되지 않습니다.
return ResponseEntity.ok("success");
}
테스트를 통해 확인한 결과, @Valid를 사용하면 List 내부 요소까지 재귀적으로 검증되지만, @Validated를 사용하면 컬렉션 자체만 검증 대상으로 인식되어 내부 요소 검증은 이루어지지 않습니다.
List 객체 검증에 있어서는 @Validated 대신 @Valid를 반드시 사용해야 합니다.
왜냐하면, @Validated는 컬렉션 자체만 검증 대상으로 삼아 내부 요소에 대한 재귀적 검증을 수행하지 않기 때문입니다.
아래는 다양한 케이스에 대해 List 내부 요소 검증을 확인할 수 있는 테스트 코드 예시입니다.
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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
public class TestControllerTest {
@Autowired
private MockMvc mockMvc;
// 1. 빈 리스트일 때 (객체가 하나도 없음)
@Test
void whenEmptyList_thenSuccess() throws Exception {
String json = "[]";
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isOk())
.andExpect(content().string("success"));
}
// 2. TestCreate가 1개이고 id값만 있을 때
@Test
void whenOneObjectIdOnly_thenSuccess() throws Exception {
String json = "[{\"id\": 1, \"name\": null}]";
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isOk())
.andExpect(content().string("success"));
}
// 3. TestCreate가 1개이고 name값만 있을 때 (id 누락 → 검증 실패)
@Test
void whenOneObjectNameOnly_thenFail() throws Exception {
String json = "[{\"name\": \"Name Only\"}]";
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isBadRequest());
}
// 4. TestCreate가 1개이고 모든 값이 정상일 때
@Test
void whenOneObjectAllFields_thenSuccess() throws Exception {
String json = "[{\"id\": 1, \"name\": \"Test Name\"}]";
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isOk())
.andExpect(content().string("success"));
}
// 5. TestCreate가 2개이고 모든 값이 정상일 때
@Test
void whenTwoObjectsAllFields_thenSuccess() throws Exception {
String json = "[{\"id\": 1, \"name\": \"Name1\"}, {\"id\": 2, \"name\": \"Name2\"}]";
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isOk())
.andExpect(content().string("success"));
}
// 6. TestCreate가 2개이고 id값만 있을 때
@Test
void whenTwoObjectsIdOnly_thenSuccess() throws Exception {
String json = "[{\"id\": 1, \"name\": null}, {\"id\": 2, \"name\": null}]";
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isOk())
.andExpect(content().string("success"));
}
// 7. TestCreate가 2개이고 name값만 있을 때 (id 누락 → 검증 실패)
@Test
void whenTwoObjectsNameOnly_thenFail() throws Exception {
String json = "[{\"name\": \"Name1\"}, {\"name\": \"Name2\"}]";
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isBadRequest());
}
// 7. TestCreate가 2개이고 1개는 정상 1개는 name값만 있을 때 (id 누락 → 검증 실패)
@Test
void whenTwoObjectsOneValidOneInvalid_thenFail() throws Exception {
// 첫 번째 객체는 정상: id와 name 모두 있음.
// 두 번째 객체는 id가 누락되어 있음.
String json = "[{\"id\": 1, \"name\": \"Name1\"}, {\"name\": \"Name2\"}]";
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isBadRequest());
}
}
이번 테스트를 통해 알 수 있는 점은 다음과 같습니다.
@Valid @RequestBody List<TestCreate> 또는 @RequestBody List<@Valid TestCreate> 방식 모두 List 내부 요소에 대해 재귀적으로 검증이 수행됩니다.
@Validated @RequestBody List<TestCreate>는 컬렉션 자체만 검증 대상으로 인식되며, 내부 요소에 대한 검증은 이루어지지 않습니다.
결국, List 내부 요소까지 검증을 원한다면 @Valid를 사용해야 합니다.
Spring에서 제공하는 어노테이션이라 @Validated도 내부 요소까지 검증을 해줄 것 같았는데, 직접 테스트해보니 아니란 걸 확인할 수 있었습니다.
테스트 코드를 통해 검증 메커니즘을 직접 확인해보고, 프로젝트에 맞는 올바른 방법을 선택하시기 바랍니다.
끝!