Validation using @Valid @Validated

GEONNY·2024년 8월 7일
0

Building-API

목록 보기
18/28
post-thumbnail

API Request arguments 에 대한 유효성 검증 방법을 알아보겠습니다.
이전에 Custom Exception 을 구현할 때, Service 코드에 유효성 검증 코드를 추가했었습니다.
domain.member.MemberService

@Override
public MemberCreateResponse createMember(MemberCreateRequest parameter) {
    if (memberRepository.existsById(parameter.memberId())) {
        throw new EntityExistsException("이미 존재하는 ID 입니다. -> " 
        	+ parameter.memberId());
    }
    if (StringUtils.isEmpty(parameter.memberName())
            || !Pattern.compile("^[가-힣a-zA-Z]+$").matcher(
            		parameter.memberName()).matches()) {
        throw new ServiceException(
        	ErrorCode.INVALID_PARAMETER, "이름은 한글/영문만 가능합니다.");
    }
    //이하생략...

이러한 유효성 검증은 일반적으로 비지니스 로직과는 별개로 데이터의 무결성을 확인하는 작업입니다. 비지니스 로직과는 성격 자체가 다른 작업이기 때문에 코드를 분리하는게 여러 측면에서 효율적입니다. 그렇다면 검증 로직을 분리하여 어느 시점에 처리하는게 좋을까요?

📌ArgumentResolver

ArgumentResolver 는 dispatcher-servlet 을 통해 전달될 HTTP Request 의 매개변수를 Controller Method 의 매개변수로 바인딩 하는 역할을 합니다. Spring 에서는 매개변수가 전달되는 방식에 따라 5개의 ArgumentResolver 구현체를 제공합니다.

📍RequestParamMethodArgumentResolver

@RequestParam 어노테이션이 붙은 파라미터를 처리하며, URL 쿼리 파라미터, 폼 데이터 등을 메서드 인자로 바인딩 합니다.

📍PathVariableMethodArgumentResolver

@PathVariable 어노테이션이 붙은 파라미터를 처리하며, URL 경로의 변수 값을 메서드 인자로 바인딩 합니다.

📍RequestBodyMethodArgumentResolver

@RequestBody 어노테이션이 붙은 파라미터를 처리하며, HTTP 요청 본문을 특정 객체로 변환하여 메서드 인자로 바인딩 합니다.

📍ModelAttributeMethodArgumentResolver

@ModelAttribute 어노테이션이 붙은 파라미터를 처리하며, 요청 파라미터를 객체에 바인딩하고, 해당 객체를 메서드 인자로 바인딩 합니다.

📍HttpEntityMethodProcessor

HttpEntity<?> 또는 RequestEntity<?> 타입의 파라미터를 처리하며, 요청 헤더와 본문을 포함하는 객체를 메서드 인자로 바인딩 합니다.

📌@Valid

앞에서 설명한 ArgumentResolver 를 활용하여 객체의 제약조건을 검증하는 Java 표준 스펙 어노테이션입니다. 객체 Field 에 어노테이션을 추가하여 검증하는 방식이고, Spring 에서는 LocalValidatorFactoryBean 을 등록하여야 해당 기능들을 활용할 수 있는데, 간단히 spring-boot-starter-validation의 의존성 추가만으로도 사용이 가능합니다.

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

유효성 검증 시 활용할 수 있는 Annotation 은 아래의 표를 참고하세요.

📍validation.constraints annotation

어노테이션 유형 설명
AssertFalse 어노테이션이 달린 요소는 false여야 합니다.
AssertFalse.List 동일한 요소에 여러 AssertFalse 어노테이션을 정의합니다.
AssertTrue 어노테이션이 달린 요소는 true여야 합니다.
AssertTrue.List 동일한 요소에 여러 AssertTrue 어노테이션을 정의합니다.
DecimalMax 어노테이션이 달린 요소는 지정된 최대값 이하인 숫자여야 합니다.
DecimalMax.List 동일한 요소에 여러 DecimalMax 어노테이션을 정의합니다.
DecimalMin 어노테이션이 달린 요소는 지정된 최소값 이상인 숫자여야 합니다.
DecimalMin.List 동일한 요소에 여러 DecimalMin 어노테이션을 정의합니다.
Digits 어노테이션이 달린 요소는 허용된 범위 내의 숫자여야 합니다. 지원되는 유형: BigDecimal, BigInteger, CharSequence, byte, short, int, long, 및 해당 래퍼 타입들.
Digits.List 동일한 요소에 여러 Digits 어노테이션을 정의합니다.
Email 문자열은 잘 형식화된 이메일 주소여야 합니다.
Email.List 동일한 요소에 여러 @Email 제약 조건을 정의합니다.
Future 어노테이션이 달린 요소는 미래의 순간, 날짜 또는 시간이어야 합니다.
Future.List 동일한 요소에 여러 Future 어노테이션을 정의합니다.
FutureOrPresent 어노테이션이 달린 요소는 현재 또는 미래의 순간, 날짜 또는 시간이어야 합니다.
FutureOrPresent.List 동일한 요소에 여러 FutureOrPresent 어노테이션을 정의합니다.
Max 어노테이션이 달린 요소는 지정된 최대값 이하인 숫자여야 합니다.
Max.List 동일한 요소에 여러 Max 어노테이션을 정의합니다.
Min 어노테이션이 달린 요소는 지정된 최소값 이상인 숫자여야 합니다.
Min.List 동일한 요소에 여러 Min 어노테이션을 정의합니다.
Negative 어노테이션이 달린 요소는 엄격히 음수여야 합니다.
Negative.List 동일한 요소에 여러 Negative 제약 조건을 정의합니다.
NegativeOrZero 어노테이션이 달린 요소는 음수 또는 0이어야 합니다.
NegativeOrZero.List 동일한 요소에 여러 NegativeOrZero 제약 조건을 정의합니다.
NotBlank 어노테이션이 달린 요소는 null이 아니어야 하며 적어도 하나의 공백이 아닌 문자를 포함해야 합니다.
NotBlank.List 동일한 요소에 여러 @NotBlank 제약 조건을 정의합니다.
NotEmpty 어노테이션이 달린 요소는 null이 아니어야 하며 비어 있지 않아야 합니다.
NotEmpty.List 동일한 요소에 여러 @NotEmpty 제약 조건을 정의합니다.
NotNull 어노테이션이 달린 요소는 null이 아니어야 합니다.
NotNull.List 동일한 요소에 여러 NotNull 어노테이션을 정의합니다.
Null 어노테이션이 달린 요소는 null이어야 합니다.
Null.List 동일한 요소에 여러 Null 어노테이션을 정의합니다.
Past 어노테이션이 달린 요소는 과거의 순간, 날짜 또는 시간이어야 합니다.
Past.List 동일한 요소에 여러 Past 어노테이션을 정의합니다.
PastOrPresent 어노테이션이 달린 요소는 과거 또는 현재의 순간, 날짜 또는 시간이어야 합니다.
PastOrPresent.List 동일한 요소에 여러 PastOrPresent 어노테이션을 정의합니다.
Pattern 어노테이션이 달린 CharSequence는 지정된 정규 표현식과 일치해야 합니다.
Pattern.List 동일한 요소에 여러 Pattern 어노테이션을 정의합니다.
Positive 어노테이션이 달린 요소는 엄격히 양수여야 합니다.
Positive.List 동일한 요소에 여러 Positive 제약 조건을 정의합니다.
PositiveOrZero 어노테이션이 달린 요소는 양수 또는 0이어야 합니다.
PositiveOrZero.List 동일한 요소에 여러 PositiveOrZero 제약 조건을 정의합니다.
Size 어노테이션이 달린 요소의 크기는 지정된 경계 내에 있어야 합니다.
Size.List 동일한 요소에 여러 Size 어노테이션을 정의합니다.

참고 - jakarta.ee

📌@Valid 를 활용한 유효성 검증

그럼 이전에 서비스 로직에 작성했던 유효성 검증 코드를 제거하고, Controller에서 처리 할 수 있도록 코드를 수정해보겠습니다.
domain.member.MemberServiceImpl의 createMember method 에서 기존 유효성 검증 로직을 제거합니다.

@Override
public MemberCreateResponse createMember(MemberCreateRequest parameter) {
    if (memberRepository.existsById(parameter.memberId())) {
        throw new EntityExistsException("이미 존재하는 ID 입니다. -> "
        		+ parameter.memberId());
    }
    /* 제거
    if (StringUtils.isEmpty(parameter.memberName())
            || !Pattern.compile("^[가-힣a-zA-Z]+$").matcher(
            		parameter.memberName()).matches()) {
        throw new ServiceException(
        	ErrorCode.INVALID_PARAMETER, "이름은 한글/영문만 가능합니다.");
    }*/
    Member newMember = Member.builder()
            .memberId(parameter.memberId())
            .password(parameter.password())
            .memberName(parameter.memberName())
            .useYn(parameter.useYn())
            .build();
    memberRepository.save(newMember);
    return MemberCreateResponse.builder()
            .memberId(newMember.getMemberId())
            .memberName(newMember.getMemberName())
            .useYn(newMember.getUseYn())
            .createDate(newMember.getCreateDate())
            .updateDate(newMember.getUpdateDate())
            .build();
}

domain.member.MemberController 의 createMember method 의 매개변수 앞에 @Valid 를 추가해줍니다.

@PostMapping(value = "/member"
        , consumes = MediaType.APPLICATION_JSON_VALUE
        , produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ItemResponse<MemberCreateResponse>> createMember(
		@RequestBody @Valid MemberCreateRequest parameter) {
    return ResponseEntity.ok()
            .body(ItemResponse.<MemberCreateResponse>builder()
                    .status(messageConfig.getCode(NormalCode.CREATE_SUCCESS))
                    .message(messageConfig.getMessage(NormalCode.CREATE_SUCCESS))
                    .item(memberService.createMember(parameter))
                    .build());
    }

@RequestBody를 확인하고 RequestBodyMethodArgumentResolver 가 동작하여 HTTP 요청 Body를 method 매개변수에 바인딩 합니다. 바인딩 되는 객체에 유효성 검증 Annotation이 있을 경우 검증 과정을 거칩니다.
MemberCreateRequest 에 유효성 검증 관련 Annotation을 추가합니다. 기존 검증 로직이 정규식으로 되어있었으므로 @Pattern 을 활용합니다.
domain.member.record.MemberCreateRequest

public record MemberCreateRequest(
        String memberId,
        String password,
        String useYn,
        @Pattern(regexp = "^[가-힣a-zA-Z]+$")
        String memberName
) {
}

테스트를 진행해 봅니다.

console

org.springframework.web.bind.MethodArgumentNotValidException: ..
//이하 생략...

MethodArgumentNotValidException 이 발생하였고, 클라이언트에게는 400 에러가 전달되었습니다. @Valid 는 검증 오류가 있다면 MethodArgumentNotValidException 이 발생하고, dispatcher-servlet 에 기본으로 등록된 DefaultHandlerExceptionResolver 에 의해 400 (BadRequest) 에러가 클라이언트에게로 전달되게 됩니다.

📌GlobalExceptionHandler 추가

GlobalExceptionHandler 에 MethodArgumentNotValidException 에 대한 @ExceptionHandler 를 추가해 관리하도록 합니다.

@ExceptionHandler(value = MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentInValid(
		MethodArgumentNotValidException e) {
    StringJoiner stringJoiner = new StringJoiner(", ");
    e.getFieldErrors().forEach(fieldError -> {
        stringJoiner.add(fieldError.getField() + ": " + fieldError.getDefaultMessage());
    });
    return generateErrorResponse(ErrorCode.INVALID_PARAMETER, 
    	new InvalidParameterException(stringJoiner.toString()));
}

MethodArgumentNotValidException은 getFieldErrors method 로 검증실패한 정보를 리턴해 줍니다. 응답 객체의 detailMessage 에 검증실패 메시지가 리턴되도록 generateErrorResponse method 에 ErrorCode 와 StringJoiner 로 만든 검증 실패 메시지를 전달합니다.

memberId 에 @NotEmpty 를 추가하고 검증이 실패 하도록 전송한 뒤 결과를 확인해봅니다.

public record MemberCreateRequest(
		@NotEmpty
        String memberId,
        String password,
        String useYn,
        @Pattern(regexp = "^[가-힣a-zA-Z]+$")
        String memberName
) {
}

',' 로 연결된 검증 실패 메시지가 전달됩니다.

{
    "status": "ERR_CE_01",
    "message": "적합하지 않은 값이 전달되었습니다.",
    "detailMessage": "memberId: 비어 있을 수 없습니다
    				, memberName: \"^[가-힣a-zA-Z]+$\"와 일치해야 합니다"
}

검증 실패 메시지를 커스텀 하려면 검증 확인 Annotation에 message 속성을 추가면 됩니다.

public record MemberCreateRequest(
        @NotEmpty(message = "id 는 필수 입니다.")
        String memberId,
        String password,
        String useYn,
        @Pattern(regexp = "^[가-힣a-zA-Z]+$", message = "이름은 한글/영문만 가능합니다.")
        String memberName
) {
}

📌@Validated

@Validated 는 Spring framework 에서 제공하는 유효성 검증 Annotaion 이며, AOP 기반으로 Request Argument 에 대한 유효성 검증 처리를 합니다. Class level 에 @Validated 를 추가하고 method argument 앞에 @Valid 를 붙여주어야 정상적으로 동작합니다.

📌@Validated 를 활용한 유효성 검증

MemberController getMemberById 메서드에 memberId 검증 로직을 추가합니다.
MemberController class level 에 @Validated 를 추가하고 argument 앞에 @Pattern@Length 로 검증을 하겠습니다. 영어와 숫자, 길이는 5~30자리로 설정합니다.

@GetMapping(value = "/members/{memberId}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ItemResponse<MemberSearchResponse>> getMemberById(
		@PathVariable("memberId")
        @Pattern (regexp = "^[zA-Z0-9]+$")
        @Length(min = 5, max = 30) String memberId) {
    return ResponseEntity.ok()
            .body(ItemResponse.<MemberSearchResponse>builder()
                    .status(messageConfig.getCode(NormalCode.SEARCH_SUCCESS))
                    .message(messageConfig.getMessage(NormalCode.SEARCH_SUCCESS))
                    .item(memberService.getMemberById(memberId))
                    .build());
}

정상적으로 동작하는지 확인해보도록 하죠.

한글 을 전달하니 500 에러 (Internal Server Error)가 발생했습니다. console 을 확인해보니 @Valid 와 다르게 jakarta.validation.ConstraintViolationException 이 발생했습니다.

jakarta.validation.ConstraintViolationException: getMemberById.memberId: 길이가 5에서 30 사이여야 합니다
, getMemberById.memberId: "^[zA-Z0-9]+$"와 일치해야 합니다
//이하 생략...

@Valid 와 같이 ConstraintViolationException 에 대한 @ExceptionHandler 를 GlobalExceptionHandler 에 추가해 줍니다.

@ExceptionHandler(value = ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(
		ConstraintViolationException e) {
    return generateErrorResponse(ErrorCode.INVALID_PARAMETER,
            new InvalidParameterException(e.getMessage()));
}

저는 exception 의 message 를 전달하도록 구현했지만 디버깅을 통해 Exception 객체의 정보를 확인하고 응답 메시지를 커스텀해도 됩니다.

{
    "status": "ERR_CE_01",
    "message": "적합하지 않은 값이 전달되었습니다.",
    "detailMessage": "getMemberById.memberId: \"^[zA-Z0-9]+$\"와 일치해야 합니다
    				, getMemberById.memberId: 길이가 5에서 30 사이여야 합니다"
}

📌제약조건 Grouping

제약 조건 그룹을 생성하여 그룹에 따라 제약조건이 동작하게 구현할 수 있습니다.
domain.member.validtion package 에 UserValidationGroup class 를 생성하고 그 내에 User와 Admin 두 Maker interface 를 생성합니다.

public class MemberValidationGroup {
    public interface User{};
    public interface Admin{}
}

그리고 createMember method 에 group 설정을 합니다.

    @PostMapping(value = "/member"
            , consumes = MediaType.APPLICATION_JSON_VALUE
            , produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<ItemResponse<MemberCreateResponse>> createMember(
            @RequestBody 
            @Validated(MemberValidationGroup.User.class) 
            @Valid MemberCreateRequest parameter) {
        return ResponseEntity.ok()
                .body(ItemResponse.<MemberCreateResponse>builder()
                        .status(messageConfig.getCode(NormalCode.CREATE_SUCCESS))
                        .message(messageConfig.getMessage(NormalCode.CREATE_SUCCESS))
                        .item(memberService.createMember(parameter))
                        .build());

@Valid 다음에 @Validated(MemberValidationGroup.User.class) 를 추가합니다. @Validated 에 group interface를 설정하면 해당 그룹으로 설정된 유효성 체크만 동작하게 됩니다. 그럼 매개변수에 그룹을 설정하겠습니다.
domain.member.record.MemberCreateRequest

public record MemberCreateRequest(
        @NotEmpty(message = "id 는 필수 입니다."
                , groups = MemberValidationGroup.Admin.class)
        String memberId,
        String password,
        String useYn,
        @Pattern(regexp = "^[가-힣a-zA-Z]+$", message = "이름은 한글/영문만 가능합니다."
        	   , groups = MemberValidationGroup.User.class)
        String memberName
) {
}

이렇게 설정할 경우 Controller 에 User만 동작하게 설정했기 때문에 이름 유효성 검증만 동작하게 됩니다.
공통적인 유효성 검증을 하면서 특정 그룹에 추가적으로 동작하게 하기 위해선 Default.class 를 사용합니다. (jakarta.validation.groups.Default)
Controller @Validated 에 Default.class 를 추가합니다.

@Validated({Default.class, MemberValidationGroup.User.class}) 

record 에도 공통적으로 유효성 검증할 유효성 검증은 groups 설정을 제거합니다.

public record MemberCreateRequest(
        @NotEmpty(message = "id 는 필수 입니다.")
        String memberId,
        String password,
        String useYn,
        @Pattern(regexp = "^[가-힣a-zA-Z]+$", message = "이름은 한글/영문만 가능합니다."
        	   , groups = MemberValidationGroup.User.class)
        String memberName
) {
}

groups 설정이 없으면 Default 그룹에 속하게 됩니다. 그래서 위와 같이 설정할 경우 두 유효성 체크가 모두 동작하게 됩니다.

@PathVariable 의 유효성 체크를 하기 위해선 Class level 에 @Validated({MemberValidationGroup.User.class})을 추가해야 합니다.
결국 한 클래스 내의 @PathVariable 에 대한 그룹 유효성 검증은 하나의 설정을 공유합니다.

📚참고

📕@Valid, @Validated 차이

특징 @Valid @Validated
제공 JSR-303 표준(Java EE) Spring Framework
사용 위치 필드, 메서드 파라미터, 클래스 레벨 클래스, 메서드 레벨
계층적 유효성 검사 지원 비지원
검증 그룹 지원 비지원 지원
주요 용도 객체 필드 검증, 요청 데이터 검증 서비스 레이어 검증, 그룹 검증
기본 사용 사례 빈의 필드 및 파라미터 유효성 검사 특정 검증 그룹에 따른 유효성 검사
발생 Exception MethodArgumentNotValidException ConstraintViolationException
profile
Back-end developer

0개의 댓글