Spring Validation 정복하기

2024. 5. 7. 22:04Spring

이번에 회사에서 Validation을 도입하면서 추가적으로 배운 Spring Validation에 대해 작성해보겠습니다.

일단 기존의 저희 회사에서는 Application 단에서의 값 검증은 이루어 지지 않았었습니다.

 

일단 업무 자체가 하나의 기능을 개발할때 협업을 하기보단 본인이 해당 기능, 해당 페이지에 대해

Front, API 까지 개발을 혼자 담당하는 경우가 많기 때문이었습니다.

 

이러한 업무 특성상 굳이 데이터 검증할 필요성을 못느끼셨던 것 같습니다.

어차피 화면에서도 체크를하고 DB에서도 데이터 타입이 지정되어 있으면 최소한의 데이터 정합성은 흐트러지지 않으니까요

하지만 제 개인적인 생각으로는 이러한 업무 방식은 좋지 못하다고 판단 되었습니다.

 

첫번째, 클라이언트에서는 값이 변조되기가 쉽습니다. 브라우저에서 개발자모드만 켜도 이런저런 태그나 스크립트를 변조하거나 등의 작업을 거치면 충분히 Application 단으로 유입될 수 있습니다.

 

두번째, DB에서 지원하는 데이터 타입이 모든 데이터의 정합성을 지켜줄 수 없을 뿐더러 DB 데이터 타입이 모든 비지니스 로직에 충족할 수 없는 상황이 발생할 확률이 높습니다.

 

세번째, 최소한 Client <-> Back <-> DB로 레이어가 나누어져있다면 책임 또한 세개로 나누어져야 한다고 생각합니다. 아무리 개인이 혼자 모든 로직을 개발한다고 하더라도 최소한 소프트웨어적으로라도 분리해야 합니다. Client, Back, DB를 하나라고 생각하면서 개발하게 되면 결합도가 높아지는 것은 물론 서비스 안정성, 유지보수성이 떨어지면서 Application의 품질을 저하시키는 요인이 됩니다.

 

Validation?

앞서 말한 문제점을 해결하기 위해서 애플리케이션에서 매번 DTO에 대해서 검증 로직을 추가하다보면 코드 중복이 많아지고 추적이 어려워지기에 오히려 더 유지보수성이 안좋아 지는 경우가 많습니다.

 

이를 해결하기 위해 Java에서는 2009년부터 Bean Validation이라는 데이터 유효성 검사 프레임워크를 제공하고 있습니다.
Bean Validation은 다양한 제약을 도메인 모델에 어노테이션으로 정의할 수 있게 됩니다.
즉, 간단하게 DTO 객체 등 필요한 Object 및 사용하는 곳에다가 어노테이션을 작성하는 것만으로도 데이터의 유효성을 지킬 수 있게 됩니다.


또한 Bean Validation은 인터페이스로 된 명세인것을 명심하셔야 합니다.

해당 어노테이션을 기존으로 Validation을 처리하는 것은 Hibernate Validatior가 담당하게 됩니다.
때문에 spring-boot-starter-validation이 아닌 hibernate-validator 의존성을 잡아도 간단한 Validation은 가능합니다.
(두 validation의 차이는 뒤에서 내용이 나옵니다.)

 

Spring Validation 의존성

// Gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'

// Maven
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

 

일단 DTO에 검증할 로직이 해당하는 어노테이션을 필드 상단에 붙여줍니다.

 

@Getter
@Setter
@Builder
@NoArgsConstructor
public class CommunityDto {

    private String uuid;
    @NotBlank
    private String title;
    @NotBlank
    private String content;
    private Long hits;
    
}

 

지원하는 어노테이션은 다음과 같습니다.

 

어노테이션 설명 예시
@AssertTrue true인지 확인 @AssertTrue(message = "필수 동의사항입니다.")
@AssertFalse false인지 확인 @AssertFalse(message = "동의하셔야 합니다.")
@DecimalMax 최대값을 포함하여 비교 @DecimalMax(value = "100.00", inclusive = true, message = "최대 100.00까지만 입력 가능합니다.")
@DecimalMin 최소값을 포함하여 비교 @DecimalMin(value = "10.00", inclusive = true, message = "최소 10.00 이상 입력하세요.")
@Digits 지정된 자릿수의 숫자인지 확인 @Digits(integer = 3, fraction = 2, message = "정수 3자리, 소수 2자리까지 입력하세요.")
@Email 이메일 주소의 유효성 확인 @Email(message = "올바른 이메일 주소를 입력하세요.")
@Future 미래의 날짜인지 확인 @Future(message = "미래의 날짜만 입력 가능합니다.")
@FutureOrPresent 현재 또는 미래의 날짜인지 확인 @FutureOrPresent(message = "현재 또는 미래의 날짜만 입력 가능합니다.")
@Max 최대값을 비교 @Max(value = 100, message = "최대값은 100입니다.")
@Min 최소값을 비교 @Min(value = 10, message = "최소값은 10입니다.")
@NotBlank null이 아니고, 비어있지 않은 문자열인지 확인 @NotBlank(message = "비어있지 않아야 합니다.")
@NotEmpty null이 아니고, 길이가 0이 아닌 컬렉션이나 배열인지 확인 @NotEmpty(message = "비어있지 않은 배열이나 컬렉션을 입력하세요.")
@NotNull null이 아닌지 확인 @NotNull(message = "필수 값입니다.")
@Null null인지 확인 @Null(message = "null이어야 합니다.")
@Past 과거의 날짜인지 확인 @Past(message = "과거의 날짜만 입력 가능합니다.")
@PastOrPresent 현재 또는 과거의 날짜인지 확인 @PastOrPresent(message = "현재 또는 과거의 날짜만 입력 가능합니다.")
@Pattern 정규 표현식에 매칭되는지 확인 @Pattern(regexp = "[a-zA-Z0-9]+", message = "알파벳과 숫자만 입력 가능합니다.")
@Size 크기를 비교 @Size(min = 2, max = 10, message = "2에서 10까지의 크기만 허용합니다.")
@Valid 중첩된 객체에 대한 검증을 활성화 @Valid
@CreditCardNumber 신용카드 번호의 유효성 확인 @CreditCardNumber(message = "올바른 신용카드 번호를 입력하세요.")

 

※  이외에도 지원하는 어노테이션이 또 있으며 커스텀 어노테이션을 Validator 쪽에 등록하여 사용 가능합니다.

 

이후 사용하고자 하는 곳에서 @Valid 혹은 @Validated 어노테이션을 붙여주면 ArgumentResolver 쪽에서 Validation을 수행하게 됩니다.

 

@PostMapping(value = "") 
@Responsestatus(HttpStatus.CREATED)
@Operation(summary = "글 작성")
public CommunityDto create(
        @PathVariable String type,
        @PathVariable String version,
        @ModelAttribute @Valid CommunityDto.CreateDto dto
) {
    return ServiceUtil.getService(services, type, version).create(dto);
}

 

@Valid vs @Validated

일단 @Valid 어노테이션은 Java Bena Validation에 사용되는 어노테이션입니다. 때문에 기본적인 검증 어노테이션은 @Valid로 수행 가능하게 됩니다.

 

@Validated는 Spring Validation에서 사용되는 어노테이션입니다. Validation Group을 활용하여 그때마다 다른 검증로직을 수행하거나 여러 Layer에서 수행하고 싶을때 사용하게 됩니다.

 

앞에서 말씀드린것처럼 Controller에서는 ArgumentResolver를 통해서 Validation이 수행되기 때문에 @Valid 어노테이션 만으로 수행이 가능합니다.

 

하지만 Service Layer같이 ArgumentResolver를 거치지 않는 곳에선 검증로직을 수행하기 쉽지 않습니다.

물론 Validator를 주입받아서 사용가능하나 매번 Validator를 주입받고 검증로직을 수행하는것은 코드가 계속 중복되기에 불편한 점이 많습니다.

 

이러한 부분 때문에 Spring에선 @Valid 어노테이션을 여러 Layer에서 활용하기 위해 @Validated를 사용하게 됩니다.

@Service
@Validated
public class CommunityService {
	public CommunityDto create(@Valid CommunityDto dto) {
		...
	}
}

 

이렇게 Class 단 혹은 Method 단에서 적용하게 되면 @Valid를 Controller외에서도 사용할 수 있게 됩니다.

 

※ 여기서 눈치 빠른 분들은 알아채셨겠지만 @Validated는 AOP를 이루어져 수행되게 됩니다.

때문에 내부 메소드 호출에 대해서는 적용되지 않다는 점을 주의 하셔야 합니다.

 

※ 저는 Validated와 Valid를 혼용하는 것이 너무 혼란을 야기한다고 판단되어 모든 Layer와 Collection에서도 @Validated으로도 Validation이 동작하도록 AOP를 추가 하였습니다.

    /**
     * 기존 @Validated의 문제점인 Collection 계열의 객체에 대한
     * 검증을 수행하지 못하는 문제를 해결하기 위한 Aspect
     * Collection 계열 외의 validation은 Spring에 위임함
     *
     * @param joinPoint JoinPoint
     */
    @Before("execution(* *.*(.., @org.springframework.validation.annotation.Validated (java.util.Collection+), ..))")
    public void collectionValidation(JoinPoint joinPoint) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();

        Parameter[] parameters = method.getParameters();
        int targetIndex = -1;
        Validated targetAnnotation = null;

        for (int i = 0; i < parameters.length; i++) {
            Validated annotation = parameters[i].getAnnotation(Validated.class);

            if (annotation != null) {
                targetIndex = i;
                targetAnnotation = annotation;
                break;
            }
        }

        Object targetObject = joinPoint.getArgs()[targetIndex];
        Class<?>[] groups = targetAnnotation.value();

        if (Collection.class.isAssignableFrom(targetObject.getClass())) {
            ((Collection<?>) targetObject).forEach(o -> {
                Set<ConstraintViolation<Object>> validated = groups.length > 0 ? validator.validate(o, groups) : validator.validate(o);

                validated.forEach(v -> {
                    throw new ConstraintViolationException(v.getMessage(), validated);
                });
            });
        }
    }

 

 

또한 @Validated는 Validation을 그룹별로 처리할 수 있도록 합니다.

Validation Group?

Validation Group이란 특정 상황 및 조건에 따라 다양한 유효성 검증을 진행하기 위한 개념입니다.

예를 들어 커뮤니티 서비스를 운영하고 있고 데이터 조작을 위해 아래와 같은 Entity와 DTO가 있다고 해봅시다.

@Getter
@Setter
@Entity
public class Community {
	
    @Id
    private String uuid;
    private String title;
    private String content;
    private Long hits;
     
    @PrePersist
    public void prePersist() {
        uuid = NanoIdUtils.randomNanoId();
    }
}
@Getter
@Setter
@Builder
@NoArgsConstructor
public class CommunityDto {

    private String uuid;
    @NotBlank
    private String title;
    @NotBlank
    private String content;
    private Long hits;
    
}

 

여기서 CommunityDto를 통해서 Entity를 CRUD 한다고 가정하였을때

Update시에는 Entity를 식별하기 위해 UUID가 필수적으로 필요하게 됩니다.

하지만 UUID는 Create진행시에 처리되기 때문에 굳이 검증이필요 없습니다.

이렇게 상황마다 Validation이 다르게 이루어지도록 하기 위해 Spring Validation에서는 Validation Group이라는 기능을 지원합니다.

 

Validation Group은 인터페이스를 활용해서 Validation을 진행할 목록들을 Group화 하고 Validation시 Group 별로 Validation을 진행할 수 있게 해줍니다.

 

Group 할당은 빈 마커 인터페이스로 진행하게 됩니다.

Update시 UUID의 존재유무를 위해 인터페이스를 하나 생성해줍니다.

public interface Update {}

 

이후 Validation 어노테이션에 group을 할당해 줍니다.

@Getter
@Setter
@Builder
@NoArgsConstructor
public class CommunityDto {
	
    @NotBlank(groups = {Update.class})
    private String uuid;
    @NotBlank
    private String title;
    @NotBlank
    private String content;
    private Long hits;
    
}

 

이후 검증을 진행할 메소드에서 @Validated를 진행해주시고 Validation을 진행할 Group을 진행해 주시면 됩니다.

@Override
@Caching(
    put = {
        @CachePut(value = "board", key = "#dto.uuid"),
    },
    evict = {
        @CacheEvict(value = "board", key = "0"),
    }
)
public CommunityDto update(@Validated(Update.class) CommunityDto dto) {
    ...
}

 

 

Custom Validator

Java or Spring에서 지원하지 않는 Validation 어노테이션을 자유롭게 비지니스 로직을 섞어서 커스텀도 가능합니다.

일단 어노테이션을 생성합니다.

문자열의 총 Byte를 검증하는 어노테이션

 

※ 어노테이션 설명

  • Target: 어노테이션이 붙을 곳으로 필드, 메소드 등을 지정하여 어노테이션이 붙일 수 있는 영역을 제어합니다.
  • Retention: 어노테이션 라이플 사이클로, 소스코드, 클래스 파일, 런타임 등 어노테이션이 적용되는 구간을 선택합니다.
  • Constraint: Validation 어노테이션 전용으로 해당 어노테이션이 어떤 Validator를 통해 검증이 진행될 것인지 지정합니다.

이를 어노테이션을 검증할 Validator를 생성합니다.

이때 생성하는 Validator는 ConstraintValidator를 상속해야 합니다.

 

ConstraintValidator에 해당 Validator에서 처리할 어노테이션과 타입을 지정합니다.

해당 코드는 ByteLength 어노테이션이 붙은 String Type만을 검증 하도록 합니다.

 

initialize 메서드는 ConstraintValidator를 오버라이드 하여 어노테이션의 속성을 초기화 하고 어노테이션에 할당된 정보들을 기반으로 초기화 하게 됩니다.

 

isValid는 실제 검증이 진행되는 곳으로 true이면 검증이 완료되어 패스하게 되고 false이면 ConstraintVaiolation Exception이 발생하게 됩니다.

 

context.disableDefaultConstraintViolation()는 기본 검증 메시지를 비활성화합니다.

context.buildConstraintViolationWithTemplate(...)를 통하여 커스텀 메시지를 설정합니다. 기존 javax 쪽의 messageTemplate를 지정하여 재사용 할 수 있습니다.

이렇게 CustomValidator 및 어노테이션을 생성하여 문자열의 byte값을 특정 인코딩 계열로 검증하여 처리하도록 할 수 있습니다.

 

Exception 처리

Validation 관련 예외는 크게 세개가 존재합니다.

각각 적용되는 범위가 다르니 ExceptionHandler로 핸들링 하실때 참고하시면 됩니다.

 

Exception 발생 위치 Default HTTP Status Code
ConstraintViloationException @PathVariable, @RequestParam 500
BindException @ModelAttribute 400
MethodArgumentNotValidException @RequestBody 400

 

참고로 MethodArgumentNotValidException는 BindException을 확장하여 만들어진 예외이기 때문에 ExceptionHandler에서 공통적으로 처리할 수 있습니다.

 

추가적으로 저희 회사에서는 글로벌 서비스를 위해서 메시지 국제화를 진행하고 있는데요

ConstraintViolation, BindException에러에서 FieldError를 받아 에러를 핸들링 할때 message의 순서는

어노테이션의 속성 이름의 사전적 순서에 따라 주입되게 됩니다.

 

'Spring' 카테고리의 다른 글

Spring 에서 전략패턴 적용해보기  (1) 2024.04.14
Spring의 Event  (0) 2024.03.31
Annotaion  (0) 2023.06.18
Servlet과 PSA  (0) 2023.05.29
스프링의 3대 핵심 요소  (0) 2023.05.21