질문, 피드백 등 모든 댓글 환영합니다.
스프링에서 HTTP 요청을 처리할 때 컨트롤러에서는 HTTP 요청이 정상 요청인지를 검증해야 합니다.
예를 들어 HTTP Form 으로 전송된 값이 영문만 가능한데 한글이 넘어오는 경우에는 컨트롤러에서 비정상 요청으로 처리해야 합니다.
스프링은 이러한 검증로직을 크게 두 가지 방식으로 제공합니다.
스프링은 검증 오류를 처리하기 위해 BindingResult
를 제공합니다.
@Getter @Setter
public class User{
String name; // 필수
String nickName // 3자 이상
}
컨트롤러에서 위 객체에 대한 요청을 BindingResult
를 이용하여 검증해보겠습니다.
addForm.html
에서 /user/add
경로로 잘못된 형식의 post 요청이 발생한다면
@Controller
public class ExController{
@PostMapping("/user/add")
public String addUser(@ModelAttribute User user, BindingResult bindingResult) {
if (!StringUtils.hasText(user.getName))
bindingResult.rejectValue("name", "required", null, null, "필수");
if (user.getNickName < 3)
bindingResult.rejectValue("age", "range", null, null, "3글자 이상");
if (bindingResult.hasErrors())
return "addForm";
...
}
}
위와 같이 처리 가능하고 BindingResult
의 위치는 반드시 검증해야 할 대상 바로 뒤에 위치해야 합니다.
rejectValue()
는 필드 에러를 처리하는 메서드로 FieldError
를 생성하고 모델에 해당 에러를 저장하는 기능을 제공합니다.
뷰 템플릿으로 타임리프를 사용한다면 #{fields.}
로 에러에 접근할 수 있습니다.
rejectValue()
파라미터 목록은 아래와 같습니다.
rejectValue(@Nullable String field, String errorCode)
rejectValue(@Nullable String field, String errorCode, String defaultMessage)
rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable Strign defaultMessage)
field
는 오류가 발생한 필드명이고 errorCode
는 messageCodesResolver
에 쓰이는 code
입니다.(메시지 소스에 사용되는 code
와는 다름, messageCodesResolver
는 밑에서 설명합니다.)
특정 필드에 관한 오류가 아니라면 reject()
로 처리 가능합니다.
reject(String errorCode)
reject(String errorCode, String defaultMessage)
reject(String errorCode, @Nullable Object[] errorArgs, @Nullable Strign defaultMessage)
검증 오류 처리 후 에러 메시지를 출력하는 경우에도 메시지 소스를 활용하여 처리합니다.
하지만 BindingResult
의 rejectValue
나 reject
에선 메시지 소스의 코드를 지정하지 않습니다.
스프링이 검증 오류시 메시지 코드를 사용하는 방법을 알아보겠습니다.
우선 메시지 소스를 간단히 작성하겠습니다.
range=글자 수 오류입니다.
range.user.nickName=별명은 3글자 이상 가능합니다.
required=필수입니다.
required.user.name=이름은 필수입니다.
스프링은 MessageCodesResolver
(인터페이스, 기본 구현체는 DefaultMessageCodesResolver
)로 검증 에러메시지를 처리합니다.
MessageCodesResolver
는 아래의 규칙으로 메시지 소스에 접근하여 사용합니다.
필드 에러의 경우
객체 에러의 경우
순서대로 에러 메시지 코드에 접근하고 없다면 defaultMessage
를 사용합니다.
예제에서 user.name
의 경우엔
순서대로 접근하고 메시지 소스에 require.user.name
이 존재하므로 이름은 필수입니다.
가 출력됩니다.
만약 메시지 소스에서 MessageCodesResolver로 매칭되는 코드가 없다면 defaultMessage인 필수
가 출력되고 defaultMessage마저 지정하지 않았다면 스프링이 기본으로 등록한 defaultMessage가 사용됩니다.
타입 미스매치
필드 검증 문제는 BindingResult
의 reject()
나 rejectValue()
를 통해 해결했습니다.
만약 int age;
필드에 "A"
가 입력된다면 어떻게 될까요?
이 경우 타입미스매치 에러가 발생하고 이 또한 BingdingResult
를 이용하여 해결합니다.
@Getter @Setter
public class User{
String name; // 필수
int age;
}
@Controller
public class ExController{
@PostMapping("/user/add")
public String addUser(@ModelAttribute User user, BindingResult bindingResult) {
...
}
}
User 클래스로 name=user&age=A
요청이 오면 별도로 코드를 작성하지 않아도 스프링(BingdingResult
)이 FieldError
를 생성하고 typeMismatch
를 에러코드로 등록하여 사용합니다.
(만약 컨트롤러가 파라미터로 BingdingResult
를 사용하지 않는다면 컨트롤러 호출 전 서블릿이 400 예외를 발생시킴)
이 경우도 FieldError
가 생성되므로 네 가지 규칙에 따라 메시지 소스를 참고하기 때문에 typeMismatch...
형식의 메시지 코드를 등록하여 사용할 수 있습니다.
컨트롤러에서 필드를 검증하는 로직은 값 존재 여부나 값의 범위 등 그 주제가 대부분 비슷한 경우가 많습니다.
때문에 Bean Validation
이라는 검증 기술 표준이 존재합니다.(javax 표준)
Bean Validation
은 매우 편리한 어노테이션 기반의 검증 기능을 제공합니다.(예제는 아래에서 확인하겠습니다.)
그 중 구현체로 hibernate validator
를 사용합니다.
Bean Validation
을 사용하기 위해선 build.gradle
에 아래와 같이 의존관계를 추가해야 합니다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
이제 본격적으로 Bean Validation
을 사용해보겠습니다.
@Getter @Setter
public class User{
@NotEmpty
String name;
@Range(min=20, message="20살 이상만 가입할 수 있습니다.")
int age;
}
@Controller
public class ExController{
@PostMapping("/user/add")
public String addUser(@Valided @ModelAttribute User user) {
...
}
}
어노테이션만으로 일반적인 필드 검증 로직은 한 번에 해결할 수 있습니다.
검증할 필드 앞에 @Validated
(@Valid, @Validated는 스프링이 제공하는 어노테이션으로 javax 표준인 @Valid에 groups
기능이 추가 된 것)를 사용하면 타입 변환에 성공한 필드에 대해서만 검증 로직이 작동하며 타입 변환에 실패하거나 검증 오류시 BindingResult
에 해당 에러를 저장합니다.
Bean Validation
도 BindingResult
를 사용하여 에러를 처리하므로 에러 메시지 또한 같은 방식으로 처리합니다.
errorCode
는 어노테이션 이름이 지정되고, defaultMessage
는 어노테이션의 message
속성을 활용하여 지정할 수 있습니다.
만약 특정 필드가 아닌 객체 레벨의 검증을 해야할 경우에는 @ScriptAssert
를 사용하여 검증할 수 있으나..... 해당 어노테이션은 한 객체 레벨에만 적용 가능하므로 범용성이 떨어지기 때문에 필드 검증은 Bean Validation
으로, 그 외의 검증은BindingResult
의 reject()
등을 사용하여 직접 코드로 작성하는 것을 권장드립니다.
해당 기능은 어노테이션 목록을 참조해주세요.
만약 상황에 따라 검증 조건이 바뀌는 경우엔 위와 같은 방법으론 한계가 있습니다
예를들어 등록할 때는 id 값이 없지만 수정할 때는 id 값이 필수로 받을 수 있고 조건에 따라(회원 등급 등) 검증 세부 로직이 다를 수 있습니다.
@Validated
의 groups
라는 속성을 사용하여 해당 문제를 해결할 수 있으나..... 잘 사용하지 않는 기능입니다.
스프링에서 보통 User와 같이 핵심적인 객체를 검증하게 되는데 이 때 groups
를 사용하면 해당 클래스의 코드가 지저분해지기도 하고 특히 핵심 클래스를 컨트롤러에서 직접적으로 사용하면 큰 문제가 발생할 수 있습니다.
때문에 groups
를 사용하기 보단 경우에 따라 DTO를 사용하거나 용도에 따라 객체를 분리하여 사용합니다.(이에 관한 내용은 후에 JPA 파트에서 기술하겠습니다.)
해당 기능은 어노테이션 목록을 참조해주세요.
컨트롤러에서 HTTP 요청을 받아 객체로 매핑할 때는 요청용 객체를 따로 만들어 해당 객체에서 검증 로직을 처리하고 이를 핵심 객체로 다시 변환하여 사용합니다.
@Getter
public class User{
String name;
int age;
}
핵심 클래스에서 Bean Validation 관련 어노테이션을 제거하고 Form 객체를 만들어 주겠습니다.
@Getter
public class UserForm { // 등록용, 수정용 등 분리하여 사용
@NotEmpty
String name;
@Range(min=20, message="20살 이상만 가입할 수 있습니다.")
int age;
}
해당 예제는 너무 간단하기에 잘 와닿지 않을 수 있지만 실제 서비스에선 User 클래스에 주민번호, 개인정보 처리 약관 같이 등록할 때만 필요하거나 서버 내에서만 관리하는 정보들이 많이 포함되어 있습니다. 이들을 항상 사용하는 것은 자원의 낭비가 발생할 뿐더러 보안상으로도 위험할 수 있기에 별도의 클래스를 사용하는 것이 좋습니다.
Http Message Converter로 API 요청 검증
HTTP 요청 데이터가 쿼리파라미터나 Form 형식이 아닌 메시지 바디를 통해 전달되는 경우에도 @Validate를 적용할 수 있습니다.
@ModelAttribute를 사용할 때는 각 필드마다 바인딩이 따로 이루어지므로 세밀하게 검증을 할 수 있지만 @RequestBody를 사용하게 되면 Http Message Converter가 사용되어 검증 시 더 많은 것을 고려해야 합니다. 또한 Http Message Converter에서 검증 실패 시 error가 아닌 exception이 발생합니다. 이 부분은 예외를 처리하는 파트에서 더 자세히 기술하겠습니다.