@Valid
는 JSR-303표준 스펙이다.
org.hibernate.validator.internal.constraintvalidators
안에 구현된
여러 Validator
구현체들로 인해 값을 검증해준다.
이의 핵심은 LocalValidatorFactoryBean
이며, 나는 스프링 부트를
사용하였기 때문에 자동으로 구성이 된다.
기본적으로 컨트롤러에서 @Valid
가 없더라도
유효성 검증을 처리하는 로직을 지나간다.
이유는??
InvocableHandlerMethod
는 적절한 파라미터 처리기를 찾으려고
HandlerMethodArgumentResolverComposite
로 보낸다.
얘가 처리해줄 resolver를 찾는데 getArgumentResolver();
private final Map<MethodParameter, HandlerMethodArgumentResolver>argumentResolverCache = new ConcurrentHashMap<>(256);
이 인자에서 들어있는 RequestResponseBodyMethodProcessor
를 통해
validation을 진행한다.
RequestResponseBodyMethodProcessor
는
AbstractMessageConverterMethodArgumentResolver
를 상속받고 있는데
상속받는 이 클래스의 validateIfApplicable에서 어노테이션 for 루프를 돌면서
@Valid
가 있는지 검색한다.
있으면 DataBinder
객체에 넘겨서 validate를 수행한다.
여기서 검증에 오류가 있으면 MethodArgumentNotValidException
이 발생하고,
이는 스프링 ExceptionResolver
의 DefaultHandlerExceptionResolver
덕분에
400 에러를 뱉게된다.
@Validated (전역 컨트롤러에 붙임)
위의 @Valid와 다르게 cglib 그러니까 AOP기반으로 메소드 요청을
MethodValidationInterceptor
가 받아서 처리해준다.
왜 cglib이냐면 SampleController
는 일반 클래스이므로
인터페이스처럼 JDK 동적 프록시가 아닌 Cglib proxy를 사용한다.
그리고선 이 프록시가 요청을 가로채서 유효성 검증을 진행해준다.
검증을 수행하고서는 Set<ConstraintViolation<Object>>result;
가 비어있는 값이 아니라면 ConstraintViolationException
을 던져주는데
에러 메시지의 기본값은 javax.validation.constraints.XXX.message
properties에 정의되어있다.
이는 위처럼 DefaultHandlerExceptionResolver
에 등록되어 있는 객체가 아니기에
500에러와 함께 밖으로 뱉어주게 된다. 별도의 ExceptionHandler
를 같이 구현해주어야 할것이다.
아래는 내가 구현한 예제 소스이다.
SampleController.java
import javax.validation.Valid;
import javax.validation.constraints.Min;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@Validated
public class SampleController {
@PostMapping("/hello")
public String hello(@Valid @RequestBody MessageRequest messageRequest) {
log.info(messageRequest.getMessage());
return "hello";
}
@GetMapping("/hi")
public String hi(@Min(value = 1) int value) {
log.info(String.valueOf(value));
return "hi";
}
}
MessageRequest.java
import javax.validation.constraints.NotNull;
import lombok.Getter;
@Getter
public class MessageRequest {
@NotNull(message = "message는 null일 수 없습니다.")
private String message;
}
SampleControllerTest.java
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.lsj8367.web.request.MessageRequest;
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.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
@WebMvcTest(SampleController.class)
class SampleControllerTest {
private MockMvc mockMvc;
@Autowired
private WebApplicationContext ctx;
@Autowired
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.webAppContextSetup(ctx)
.alwaysDo(print())
.build();
}
@Test
@DisplayName("Post @Valid 테스트")
void test() throws Exception {
final String obj = objectMapper.writeValueAsString(new MessageRequest());
mockMvc.perform(post("/hello")
.content(obj)
.contentType(MediaType.APPLICATION_JSON_VALUE))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("Get @Validated 테스트")
void hiTest() throws Exception {
mockMvc.perform(get("/hi")
.param("value", "0")
.contentType(MediaType.APPLICATION_JSON_VALUE)
)
.andExpect(status().isInternalServerError());
}