스프링에서 @Valid 어노테이션을 이용해서 객체를 검증할 수 있다. 객체에 정의된 제약사항(@NotBlank, @Max 등)을 이용하여 검증할 수 있으며 객체 뿐 아니라 메서드의 파라미터, 반환값도 검증할 수 있다.
그런데 객체를 검증하기 위해 사용할 수 있는 @Validated라는 어노테이션도 있다. 이 어노테이션은 @Valid와 어떻게 다를까? 이에 대해서 조금 조사해보았다.
@Valid와 @Validated의 가장 큰 차이점은 검증 항목을 그룹으로 나눠서 검증할 수 있는지, 즉 Group Validation이 가능한 지 여부다. 자바에서 객체를 검증할 때 사용하도록 구현된 것이 javax.validation의 @Valid라면 여기서 Group Validation까지 가능하도록 구현된 것이 스프링 프레임워크의 @Validated라고 할 수 있다. 전자는 JSR-303 스펙을 따르고 후자는 그렇지 않지만 스프링 프레임워크에서는 둘 다 혼용해서 사용할 수 있다.
그럼 이 Group Validation이란 무엇일까? 말 그대로 검증 항목들을 그룹화해서 필요에 따라 나눠서 검증하는 것이다. 현재 진행중인 SimpleBBS를 예로 들어보자.
프로젝트에는 계정의 권한을 관리하는 기능이 있다. MVC 환경이므로 GET으로 접근할 때는 파라미터로 전달된 아이디의 사용자의 권한을 관리하는 페이지를 전달하고 POST로 접근할 때는 계정의 권한을 변경하는 요청을 처리하도록 정의했다.
이 리소스에 접근할 때는 다음과 같은 Command Object를 사용한다.
@Getter
@Setter
public class AccountLevelManagementCommand {
@NotBlank(message = "Account ID cannot be empty.")
private String id;
@NotNull(message = "Level cannot be null.")
private ManagerLevel levelName;
@NotNull(message = "Operation cannot be null.")
private AccountLevelManagementOperation operation;
}
권한이 변경될 계정의 아이디(String id)와 변경할 권한(ManagerLevel levelName), 변경 상태(AccountLevelManagementOperation operation)를 파라미터로 받는다. 세 가지 모두 필수 요소기 때문에 @NotBlank, @NotNull 등의 제약사항이 붙어 있는데 GET과 POST로 접근 할 때 항상 제약사항을 준수할 수 있을까? 그렇지 않다.
GET으로 접근할 때는 특정 계정의 권한을 관리하기 위한 것이기 때문에 계정의 아이디(String)만 제공된다. 그러므로 ManagerLevel과 AccountLevelManagementOperation의 @NotNull 조건에 맞지 않아 예외가 발생할 것이다. 실제로 BindingResult를 확인하는 코드를 아래처럼 작성해보면 바인딩이 실패하는 것을 볼 수 있다.
@GetMapping("/console/account/level")
public String manageAccountLevels(Model model,
@ModelAttribute("command")
@Valid
AccountLevelManagementCommand command,
BindingResult bindingResult) {
if(bindingResult.hasErrors()){
log.error("NOT VALID PARAMETERS");
return "redirect:/manage/console/account";
}
...
그렇다고 URL 파라미터에 아무 값이나 전달해서 @NotNull 제약조건을 피하기만 하거나 GET과 POST의 Command Object를 각각 정의하는 것은 번거롭고 나중에 파라미터가 변경되었을 때 둘 다 수정해줘야 한다는 단점이 있다.
그렇다면 이를 어떻게 해결할 수 있을까? 결국 필요한 것은 GET으로 접근할 때는 계정의 아이디만 검증하고 POST로 접근할 때는 모든 파라미터를 검증하는 것이다. 이를 '요청' 그룹(GET)과 '제출' 그룹(POST)으로 나눠서 검증할 수 있는 것이 Group Validation이다.
검증 항목을 그룹화하려면 groups 속성을 활용한다. 일단 코드를 보면 다음과 같다.
@Getter
@Setter
public class AccountLevelManagementCommand {
@NotBlank(
message = "Account ID cannot be empty.",
groups = {AccountLevelManagementRequestValidationGroup.class,
AccountLevelManagementSubmitValidationGroup.class})
private String id;
@NotNull(
message = "Level cannot be null.",
groups = {AccountLevelManagementSubmitValidationGroup.class})
private ManagerLevel levelName;
@NotNull(
message = "Operation cannot be null.",
groups = {AccountLevelManagementSubmitValidationGroup.class})
private AccountLevelManagementOperation operation;
}
어떤 검증 대상 필드를 그룹에 포함시키려면 위처럼 Bean Validation의 groups 속성에 그룹 클래스를 등록할 수 있다. 위의 코드에서는 AccountLevelManagementRequestValidationGroup, AccountLevelManagementSubmitValidationGroup 인터페이스를 정의해두고 필드에 등록해서 각각 그룹에 포함시켰다. 이 인터페이스는 단순히 그룹을 구분하기 위한 식별자 역할로 아무런 메서드도 정의되지 않은 빈 클래스다.
한 필드는 여러 그룹에 포함될 수 있으며 비슷하게 한 그룹에 여러 필드가 포함될 수 있다. 이 그룹을 @Validated 어노테이션에서 지정해서 사용하면 지정된 그룹에 속한 필드만 선택적으로 검증할 수 있는 것이다.
이제는 GET과 POST 요청에 따라 검증할 부분을 나눴기 때문에 아래처럼 GET, POST 핸들러 메서드에서 @Validated 어노테이션을 사용하여 어떤 파라미터를 검증할 지 그룹으로 지정할 수 있다.
@GetMapping("/console/account/level")
public String manageAccountLevels(
Model model,
@ModelAttribute("command")
@Validated(AccountLevelManagementRequestValidationGroup.class)
AccountLevelManagementCommand command,
BindingResult bindingResult) { ... }
@PostMapping("/console/account/level")
public String submitAccountLevelManagement(
Model model,
@ModelAttribute("command")
@Validated(AccountLevelManagementSubmitValidationGroup.class)
AccountLevelManagementCommand command,
BindingResult bindingResult) { ... }
GET 요청에서는 AccountLevelManagementRequestValidationGroup 그룹에 해당되는 요소들만 검증하고 있다. Command Object에서 해당 그룹에 포함된 필드는 아이디 뿐이므로 GET 요청 때는 사용자의 아이디만 검증한다.
POST 요청에서는 AccountLevelManagementSubmitValidationGroup 그룹에 해당되는 요소들만 검증하고 있다. Command Object에서 해당 그룹에 포함된 필드는 아이디, 변경할 권한, 변경 상태 즉 모든 필드이므로 POST 요청 때는 모든 필드를 검증한다.
이전처럼 다시 요청을 보내보면 바인딩이 실패하지 않고 관리 페이지가 정상적으로 출력되는 것을 볼 수 있다.
그룹 인터페이스는 언급했듯이 빈 클래스며 정말 다음처럼 정의만 해줘도 동작한다.
public interface AccountLevelManagementRequestValidationGroup {
}
Group Validation은 상속 관계의 인터페이스에 대해서도 동작한다. 다음처럼 상속 관계의 인터페이스를 정의해보았다.
public interface SubmitValidation {
}
public interface AccountLevelSubmit1 extends SubmitValidation {
}
public interface AccountLevelSubmit2 extends SubmitValidation{
}
그리고 컨트롤러에서 아래처럼 부모 인터페이스에 대해 @Validated를 적용하면 두 그룹 인터페이스를 모두 검증하게 된다.
@PostMapping("/console/account/level")
public String submitAccountLevelManagement(
...
@Validated(SubmitValidation.class)
AccountLevelManagementCommand command,
BindingResult bindingResult) { ... }
필요에 따라 검증 그룹을 상속 관계로 분리하는 것도 좋은 방법일 것이다.
단순히 모든 필드를 검증할 때는 @Valid 어노테이션, 몇 단계에 걸쳐 검증하거나 일부분만 검증해야 하는 경우 @Validated 어노테이션과 그룹 인터페이스 클래스를 활용하여 Group Validation을 적용할 수 있다.