
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 는 dispatcher-servlet 을 통해 전달될 HTTP Request 의 매개변수를 Controller Method 의 매개변수로 바인딩 하는 역할을 합니다. Spring 에서는 매개변수가 전달되는 방식에 따라 5개의 ArgumentResolver 구현체를 제공합니다.
@RequestParam 어노테이션이 붙은 파라미터를 처리하며, URL 쿼리 파라미터, 폼 데이터 등을 메서드 인자로 바인딩 합니다.
@PathVariable 어노테이션이 붙은 파라미터를 처리하며, URL 경로의 변수 값을 메서드 인자로 바인딩 합니다.
@RequestBody 어노테이션이 붙은 파라미터를 처리하며, HTTP 요청 본문을 특정 객체로 변환하여 메서드 인자로 바인딩 합니다.
@ModelAttribute 어노테이션이 붙은 파라미터를 처리하며, 요청 파라미터를 객체에 바인딩하고, 해당 객체를 메서드 인자로 바인딩 합니다.
HttpEntity<?> 또는 RequestEntity<?> 타입의 파라미터를 처리하며, 요청 헤더와 본문을 포함하는 객체를 메서드 인자로 바인딩 합니다.
앞에서 설명한 ArgumentResolver 를 활용하여 객체의 제약조건을 검증하는 Java 표준 스펙 어노테이션입니다. 객체 Field 에 어노테이션을 추가하여 검증하는 방식이고, Spring 에서는 LocalValidatorFactoryBean 을 등록하여야 해당 기능들을 활용할 수 있는데, 간단히 spring-boot-starter-validation의 의존성 추가만으로도 사용이 가능합니다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
유효성 검증 시 활용할 수 있는 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.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 어노테이션을 정의합니다. |
그럼 이전에 서비스 로직에 작성했던 유효성 검증 코드를 제거하고, 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 에 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 는 Spring framework 에서 제공하는 유효성 검증 Annotaion 이며, AOP 기반으로 Request Argument 에 대한 유효성 검증 처리를 합니다. Class level 에 @Validated 를 추가하고 method argument 앞에 @Valid 를 붙여주어야 정상적으로 동작합니다.
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 사이여야 합니다"
}
제약 조건 그룹을 생성하여 그룹에 따라 제약조건이 동작하게 구현할 수 있습니다.
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 |
|---|---|---|
| 제공 | JSR-303 표준(Java EE) | Spring Framework |
| 사용 위치 | 필드, 메서드 파라미터, 클래스 레벨 | 클래스, 메서드 레벨 |
| 계층적 유효성 검사 | 지원 | 비지원 |
| 검증 그룹 지원 | 비지원 | 지원 |
| 주요 용도 | 객체 필드 검증, 요청 데이터 검증 | 서비스 레이어 검증, 그룹 검증 |
| 기본 사용 사례 | 빈의 필드 및 파라미터 유효성 검사 | 특정 검증 그룹에 따른 유효성 검사 |
| 발생 Exception | MethodArgumentNotValidException | ConstraintViolationException |