백앤드(스프링)

Bean Validation 심화 내용

유승혁 2022. 9. 4. 18:42

프론트에서 아무리 입력에 대한 예외 처리들을 잘 한다고 하더라도, 백엔드에서 검증하지 않으면 의미가 없다. 그렇기에 백에서는 많은 검증들을 거쳐야 한다. 주로 validation.constraints 어노테이션들을 (예를 들면 @Min, @NotNull 등등) 활용하여 검증한다. 이러한 검증에 대한 이야기는 다른 블로그에도 많고 어렵지 않은 내용이니 생략하기로 하고 내가 다룰 이야기는 중첩 객체와 Collection 객체 검증들에 대해 이야기 해볼까 한다.

0. 검증을 생략하는 문제
앞서, 검증 위치는 다양한 곳이 있지만 입력에 대한 검증 처리를 다룰 것이기 때문에 입력 DTO들에 대한 검증 처리에 대한 이야기이다.
일단 Collection Bean 검증에 대한 이야기부터 할까 한다. 아래와 같은 Controller가 있다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class ValidationItemApiController {

    @PostMapping("/add")
    public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult){
        log.info("API 컨트롤러 호출");

        if(bindingResult.hasErrors()){
            log.error("검증 오류 발생 {}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return form;
    }

}

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;
    @NotNull
    @Range(min=1000, max = 10000)
    private Integer price;
    @NotNull
    @Max(value=999)
    private Integer quantity;

}

위와 같이 itemSaveForm에 대한 검증 처리를 잘 할 컨트롤러 코드이다.

하지만 만약에 List<ItemSaveForm>에 대한 검증은 어떻게 할까?

 @PostMapping("/list")
public Object addList(@RequestBody @Validated List<PatternDto> request, BindingResult bindingResult){
    log.info("list valid");

    if(bindingResult.hasErrors()){
        log.error("검증 오류 발생 {}", bindingResult);
        return bindingResult.getAllErrors();
    }

    log.info("성공 로직 실행");
    return request;
}

@Data
@EqualsAndHashCode
@NoArgsConstructor
public class PatternDto {

    @Pattern(regexp = "^01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$", message ="잘못된 번호 형식입니다.")
    private String phoneNumber;

}

위처럼 @Validated만 List 앞에 띡! 하고 붙이면 끝인가? 막상 코드를 돌려보면 검증이 전혀 되지 않는다. 이 부분에 대한 고민을 나 뿐만 아니라 많은 개발자가 했고 어느정도 규칙이 있었다. 하지만 내가 원하는건 그보다 더 한 것이기에 기대 바란다.

1. Validator 구현
다짜고짜 코드부터 날려뿌리기~

@Component
@Slf4j
public class ElementValidator implements Validator {
    private SpringValidatorAdapter validator;

    public ElementValidator() {
        this.validator = new SpringValidatorAdapter(
                Validation.buildDefaultValidatorFactory().getValidator()
        );
    }
    @Override
    public boolean supports(Class<?> clazz) {
        return true;
    }

    @Override
    public void validate(Object target, Errors errors) {
        log.info("target: {}, errors: {}", target, errors);
        if(target instanceof Collection){
            Collection collection = (Collection) target;

            for (Object o : collection) {
                validator.validate(o,errors);
            }
        }
        else {validator.validate(target,errors);}
    }
}

위 코드를 보면 Validator를 ElementValidator가 구현을 했다. validate 메소드를 보면 target이 Collection인지 아닌지에 따라 Collection일 경우 반복문을 돌면서 SpringValidatorAdaptor의 validate를 시행한다.
또한 @Component로 등록을 반드시 해야 한다.
이후 컨트롤러에서는 아래와 같은 작업이 필요해진다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class ValidationItemApiController {

    private final ElementValidator validator;

    @PostMapping("/list")
    public Object addList(@RequestBody List<PatternDto> request, BindingResult bindingResult){
        validator.validate(request, bindingResult);
        log.info("list valid");

        if(bindingResult.hasErrors()){
            log.error("검증 오류 발생 {}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return request;
    }

}

코드를 살펴보면 위에서 구현한 ElementValidator 를 주입받아서 validate 메서드에 검증할 인자 값이랑 bindingResult를 넘겨주었다.
이 코드를 보고 굉장히 괜찮다 생각했지만, 나는 여기서 마음에 들지 않는 부분이 있었다. 이렇게 되면 컬렉션 객체를 넘겨 받을 때 마다 ElementValidator를 주입 받아서 validate 메서드를 검증해야 한다는 것과 만약 Body에 여러 Collection 객체가 있을 경우 validate 메서드에 전부 다 명시해 주어야 하는 일은 여간 귀찮은 일이 아니다.
즉 중복된 코드들에 대한 정리가 필요해진 시점이다.

2. 어떻게 해결할까?
물론 여러가지 방법이 있고 아마 대부분 AOP!를 떠올렸을 테지만, AOP 말고 어노테이션을 통해서 해결하거나 인터셉터에서 검증하고 싶었다. 내가 해결한 방식은 어노테이션을 통해 해결하고자 했다. 해결 방식은 아래와 같다.

@Documented
@Constraint(validatedBy = CollectionValidator.class)
@Target({ElementType.TYPE_USE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomValid {
    String message() default "전화번호! 저놔버노!";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

위와 같은 어노테이션을 만들어 주었다. 이 어노테이션을 검증 어노테이션으로서 등록하고 싶다면 아래와 같이 하면 된다.

@Component
@Slf4j
@RequiredArgsConstructor
public class CollectionValidator implements ConstraintValidator<CustomValid, Object> {

    private final ElementValidator validator;

    @Override
    public void initialize(CustomValid constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }

    @Override
    public boolean isValid(Object values, ConstraintValidatorContext context) {
        log.info("validator 로직 실행 {}", values.toString());

        DataBinder dataBinder = new DataBinder(values);
        BindingResult bindingResult = dataBinder.getBindingResult();
        validator.validate(values,bindingResult);
        if(bindingResult.hasErrors())return false;
        return true;
    }
}

ConstraintValidator의 구현체를 만들어서 @Component로 등록하면 되는데, 대게 이 구현체를 사용할 때는 특정 class에 대한 복합 검증(예를 들면 두 개의 필드(가격, 수량)을 곱한 값이 1000 이상 되어야 한다)을 주로 할때 수행한다. 그래서 implements ConstraintValidator<CustomValid, Item>과 같이 하지만, 우리는 컬렉션이다 보니 구분 없이 수행할 것이기에 Object를 넣어놓았다.

아무쪼록 isValid 메서드를 살펴보게 되면, ElementValidator를 주입 받아 위에서 구현했던 validate 메서드를 시행한다. 기억이 안나실까봐 말해주자면 이 validate 메서드는 검증할 값이 Collection이면 반복문 돌아서 검증하고 아니면 그냥 검증해라 라는 메서드이다.
또한 validate 메서드에는 errors가 필요하기 때문에 DataBinder를 통해서 Errors를 상속받은 BindingResult를 만들어서 넣었다.

3. 연결
1번 단락(컬렉션 bean 검증) 과 2번 단락(검증 어노테이션 만들기) 를 합치게 되면 아래와 같이 된다.

@Slf4j
@RestController
@RequiredArgsConstructor
@Validated//이 어노테이션을 반드시 추가해야함
public class ValidationItemApiController {

    private final ElementValidator validator;

    @PostMapping("/list")
    public Object addList(@RequestBody @CustomValid List<PatternDto> request, BindingResult bindingResult){
        log.info("list valid");

        if(bindingResult.hasErrors()){
            log.error("검증 오류 발생 {}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return request;
    }
}

위와 같이 우리가 만들어놓은 @CustomValid를 파라미터에 넣어야 한다. 파라미터에서 검증 어노테이션을 붙이게 되면 반드시 클래스에 @Validated를 넣어야 한다.
아무튼 동작 방식은 @CustomValid를 통해 해당 파라미터가 CollectionValidator의 isValid를 거치게 되고 isValid는 컬렉션에 대한 검증을 해주는 ElementValidator에게 넘겨주면, ElementValidator는 반복문을 돌거나 객체 그 자체에 대한 검증을 진행한다.

이러한 패턴에 또 하나의 장점은 List 뿐만 아니라 중첩된 객체에 대해서도 적용이 된다는 것이다.

@PostMapping("/nested")
public Object nested(@RequestBody @Validated Item item){
    return item;
}

이와 같은 요청 매핑에 대해

@Data
public class Item {

    @CustomValid
    private PatternDto pattern;

    public Item() {
    }
}

이와 같이 Item 객체 안에 PatternDto가 있고 @CustomValid 어노테이션을 붙여준다면~ pattern에 대한 검증 또한 가능하다.

4. 한계
아마 눈치 챘듯이 굳이 ElementValidator와 CollectionValidator 두 개를 구현하지 않고 CollectionValidator 하나로도 처리할 수 있다. 하지만 Validator에 대한 공부도 하고 ConstraintValidator<>에대한 공부를 같이 할 수 있어서 좋았다.
ㅋㅋㅋㅋ 느낀점은 생략하고 한계는 다음과 같다.

@Data
@NoArgsConstructor
public class ItemChild {
    @Min(10)
    private Long value;
}

이런 객체를 선언하고

@Data
@EqualsAndHashCode
@NoArgsConstructor
public class PatternDto {

    @Pattern(regexp = "^01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$", message ="잘못된 번호 형식입니다.")
    private String phoneNumber;

    @CustomValid
    private ItemChild itemChild;

}

이렇게 되어있을때 위의 POST: /nested 를 호출하게 되면... itemChild에 대한 검증 처리가 되지 않는다. 검증 처리 뿐만 아니라 아예 코드가 실행되지 않고(value가 10보다 크건 작건) 아래와 같은 에러가 난다.

이 문제를 해결하는데 꽤나 노력했지만,, 아직 잘 되지 않는다. 즉 정리하자면 2계층(Collection 밑에 검증할 객체, 검증할 객체 밑에 검증할 객체) 까지는 잘 되지만 3계층 부터는 검증이고 나발이고 코드가 돌아가지 않는다.

어떻게 해결해야 할까? 아직도 못찾았다.. 찾게 되면 잘 정리해서 올리도록 하겠담.

5. 해결책
정말 간단한 방법이면 되었다. 추석이 지나고 코드를 다시 천천히 살펴보았다.
CollectionValidator의 init이 문제라고 하는데,, 딱히 문제 될건 없어 보였고... ElementValidator의 validate메서드가 오류가 날 경우는 SpringValidatorAdapter 요녀석이 문제일 터이니. 주입이 문제인가 싶어 ElementValidator의 생성자 함수에서 주입 하는게 눈에 띄어 지워 버리고 @RequiredArgsConstructor 붙이니 해결 되었다.
뭐지?
이유를 탐색해 나가야 겠다. spring 공식문서가 점점 익숙해 져간달까...ㅜ