목차
들어가기 전에
요청값이 유효한지 검사하는 것은 굉장히 중요한 일이다. 아무 요청값이나 마구잡이로 받아들이면 오류나 비정상적인 동작을 유발할 수 있기 때문이다.
스프링에서는 유효성 검사 중, 입력값의 형태가 유효한지 쉽게 확인할 수 있도록 다양한 어노테이션을 제공한다. 스프링에서 제공하는 유효성 검사를 위한 어노테이션의 종류와 사용방법에 대해 알아보자.
의존성 라이브러리 추가
스프링부트 2.3 이상의 버전부터는 빌드 환경에 따라 아래 의존성 라이브러리를 추가해줘야 한다.
Gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
Gradle(Kotlin)
implementation("org.springframework.boot:spring-boot-starter-validation")
Maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
기본적인 사용법
요청이 들어오는 방법에 따라 사용법이 조금 다르다. 사용되는 어노테이션은 동일하며 이 종류는 후술한다.
메소드 파라미터가 DTO 등의 객체일 때
요청으로 받을 DTO의 필드 중, 유효성 검사를 할 필드에 해당하는 어노테이션을 추가한다. 각 어노테이션의 message
속성은 해당 유효성 검사에 실패했을 때 예외로 던질 메시지를 작성하면 된다. 작성하지 않을 경우 기본값으로 지정된 메시지가 전달된다.
Java
public record TestRequest(
@Email(message = "이메일 형식이 아닙니다.")
String email,
@Pattern(regexp = "^[a-zA-Z0-9!@#$%^&*]{8,16}$", message = "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자로 이루어져야 합니다.")
String password,
String name,
@Positive(message = "나이는 0보다 커야 합니다.")
int age
) { }
Record 클래스가 아닌 일반 클래스도 동일한 방식으로 사용하면 된다.
public class TestRequest {
@Email(message = "이메일 형식이 아닙니다.")
private String email;
@Pattern(regexp = "^[a-zA-Z0-9!@#$%^&*]{8,16}$", message = "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자로 이루어져야 합니다.")
private String password;
private String name;
@Positive(message = "나이는 0보다 커야 합니다.")
private int age;
}
Kotlin
코틀린에서는 단순히 프로퍼티에 어노테이션만 달아주면 동작하지 않는다. field:
나 get:
중 하나를 붙여줘야 정상적으로 어노테이션을 이용한 유효성 검사가 동작한다.
data class TestRequest(
@field:Email(message = "이메일 형식이 아닙니다.")
val email: String,
@field:Pattern(regexp = "^[a-zA-Z0-9!@#$%^&*]{8,16}$", message = "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자로 이루어져야 합니다.")
val password: String,
val name: String,
@field:Positive(message = "나이는 0보다 커야 합니다.")
val age: Int
)
또는
data class TestRequest(
@get:Email(message = "이메일 형식이 아닙니다.")
val email: String,
@get:Pattern(regexp = "^[a-zA-Z0-9!@#$%^&*]{8,16}$", message = "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자로 이루어져야 합니다.")
val password: String,
val name: String,
@get:Positive(message = "나이는 0보다 커야 합니다.")
val age: Int
)
이는 코틀린의 클래스 작성 방식에 따른 문제인데, 기본적으로 코틀린에서 클래스를 작성할 때는 기본 생성자에 프로퍼티를 선언하게 된다. 이로 인해 어노테이션을 적용할 대상을 명시하지 않으면 프로퍼티의 필드가 아니라 생성자의 파라미터에 어노테이션이 적용된다.
실제로 위 코드에서 field
또는 get
을 제거한 후 자바 코드로 변환해보면 필드가 아니라 생성자 파라미터에 어노테이션이 붙어있는 것을 볼 수 있다.
이로 인해 코틀린에서는 어노테이션을 적용할 대상을 명시할 수 있으며 이를 필드 또는 Getter에 적용하기 위해 예시 코드처럼 작성하는 것이다. 코틀린 공식 문서
첫번째 예시처럼 field:
를 붙이게 되면 아래와 같이 생성자 파라미터 대신 필드에 어노테이션이 붙는 것을 볼 수 있다.
이후 아래와 같이 해당 메소드의 파라미터로 지정된 해당 클래스 객체의 앞에 @Valid
를 붙여주면 된다. 요청을 JSON형태의 body가 아니라 쿼리 스트링으로 받을 경우에는 @RequestBody
가 빠지는 것 외에는 동일하다.
@RestController
class TestController {
@PostMapping("/object-validation")
fun objectValidation(@RequestBody @Valid request: TestRequest): TestRequest {
return request
}
}
이렇게 설정하고 포스트맨으로 유효하지 않은 요청값을 담아 API를 호출하면 자동으로 400 Bad Request가 반환되는 것을 볼 수 있다.
메소드 파라미터가 원시타입이나 문자열일 때
유효성 검사를 진행할 파라미터에 어노테이션을 추가해주면 된다. 다만 이 경우에는 메소드가 포함된 클래스에 @Validated
어노테이션을 붙여줘야 한다는 차이가 있다.
@Validated
@RestController
class TestController {
@GetMapping("/primitive-validation")
fun primitiveValidation(@NotBlank(message = "이름은 공백일 수 없습니다.") name: String): String {
return "hello $name"
}
}
포스트맨으로 유효하지 않은 요청값을 담아 API를 호출하면 위의 경우와는 달리 500 Internal Server Error가 반환되는 것을 볼 수 있다.
어노테이션 종류
javax.validation.constraints
(또는 jakarta.validation.constraints
)에 포함된 유효성 검사 어노테이션의 종류는 다음과 같다.
어노테이션 | 제약 조건 |
@AssertFalse |
반드시 false여야 함 |
@AssertTrue |
반드시 true여야 함 |
@DecimalMax |
지정된 값 이하여야 함(null은 유효한 것으로 간주) |
@DecimalMin |
지정된 값 이상이어야 함(null은 유효한 것으로 간주) |
@Digits(integer = ,fraction = ) |
정수부(integer)와 소수부(fraction)이 지정된 자리수 이하여야 함 |
@Email |
올바른 형식의 이메일 주소여야 함 |
@Future |
현재보다 미래여야 함 |
@FutureOrPresent |
현재 또는 미래여야 함 |
@Past |
현재보다 과거여야 함 |
@PastOrPresent |
현재 또는 과거여야 함 |
@Negative |
음수여야 함 |
@NegativeOrZero |
음수 또는 0이어야 함 |
@Positive |
양수여야 함 |
@PositiveOrZero |
양수 또는 0이어야 함 |
@Max |
지정된 값 이하여야 함(null은 유효한 것으로 간주) |
@Min |
지정된 값 이상이어야 함(null은 유효한 것으로 간주) |
@NotNull |
null이 아니어야 함 |
@Null |
null이어야 함 |
@NotBlank |
null이 아니고 공백이 아닌 문자가 하나 이상이어야 함 |
@NotEmpty |
null이 아니고 빈 문자열이 아니어야 함 |
@Pattern(regexp = ) |
지정된 정규식을 만족해야 함 |
@Size(min = , max = ) |
문자열의 길이 또는 컬렉션의 크기가 min 이상, max 이하여야 함(null은 유효한 것으로 간주) |
여기서 @DecimalMax
, @DecimalMin
과 @Max
, @Min
이 나눠져있는데, 전자의 경우엔 지정값을 문자열로 설정하고 문자열에도 적용할 수 있는 반면에 후자의 경우엔 long
타입으로 값을 지정해야 하고 BigDecimal, BigInteger 및 정수형에만 적용할 수 있다.
이외에도 org.hibernate.validator.constraints
패키지에 포함된 아래 어노테이션들도 있다. 그 중 자주 사용할만한 어노테이션을 추리면 다음과 같은 종류가 있다.
어노테이션 | 제약 조건 |
@CreditCardNumber(ignoreNonDigitCharacters = ) |
Luhn 알고리즘으로 유효한 카드 번호인지 확인(삼성카드 법인카드는 불가). ignoreNonDigitCharacters가 true면 숫자가 아닌 문자를 무시(기본값 false) |
@ISBN(type = ) |
유효한 ISBN이어야 함(null은 유효한 것으로 간주). type이 ISBN_10이면 10자리, ISBN_13이면 13자리여야 하며, ANY를 사용하면 양쪽 모두 가능(기본값은 ISBN_13) |
@Length(min = , max = ) |
문자열의 길이가 min 이상, max 이하여야 함 |
@Range(min = , max = ) |
값이 min 이상, max 이하여야 함(min과 max는 long 타입) |
@UniqueElements |
컬렉션에 중복 값이 없어야 함 |
이외에도 여러가지 어노테이션이 있긴 하지만 주로 사용되는 것은 이정도가 있다.
응답 예외 처리
하는 김에 일관적인 응답을 위해 예외 처리를 하면 좋을 것이다.
일관적인 에러응답을 달라! - 지마켓 블로그
이전에 API를 호출했던 사진을 보면 메소드 파라미터로 DTO 등의 객체를 사용할 때는 MethodArgumentNotValidException이, 원시 타입이나 문자열을 사용할 때는 ConstraintViolationException이 발생하는 것을 볼 수 있다.
이런 경우에는 일관적인 형태의 응답을 반환하는 것이 좋은데, 양측 모두 스프링 기본 예외 응답을 반환하고 있다. 심지어 ConstraintViolationException은 클라이언트가 요청값을 잘못 보낸 것임에도 불과하고 4xx 에러가 아니라 500 에러를 반환하고 있다.
이런 경우에 @RestControllerAdvice
와 @ExceptionHandler
를 사용하여 일관적인 형태의 응답을 반환할 수 있다.
API 호출 시 API 종류에 상관없이 아래와 같은 형태의 JSON 응답이 반환되는 경우를 생각해볼 수 있다.
{
"status": "string",
"message": "string",
"data": {
...
}
}
status
는 API의 호출 성공여부를, message
는 실패 시 오류 메시지를, data
는 각 API 호출 결과에 따른 JSON을 나타낸다.
이 구조를 유효성 검사에 실패했을 때에도 적용해보자. data
에는 유효성 검사에 실패한 필드명을 key로 하고, 응답 메시지를 value로 하는 JSON을 담을 것이다. 즉, 아래와 같은 형태의 응답을 반환할 것이다.
MethodArgumentNotValidException의 경우
DTO 등의 객체에 @Valid
를 붙여서 수행하는 유효성 검사에 실패하면 발생하는 MethodArgumentNotValidException은
getFieldErrors()
를 통해 유효성 검사를 통과하지 못한 필드와 에러 메시지를 포함한 정보가 담긴 FieldError 객체의 리스트를 받아올 수 있다.
즉, 아래와 같은 코드를 통해 위와 같은 응답을 받을 수 있다.
Kotlin
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleInvalidRequestException(e: MethodArgumentNotValidException): ResponseEntity<CommonResponse> {
val errors = HashMap<String, String>()
e.fieldErrors.forEach { errors[it.field] = it.defaultMessage!! }
return ResponseEntity.badRequest().body(CommonResponse.error("요청값을 확인해주세요.", errors))
}
}
Java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<CommonResponse> handleInvalidRequestException(MethodArgumentNotValidException e) {
Map<String, String> errors = new HashMap<>();
e.getFieldErrors().forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
return ResponseEntity.badRequest().body(CommonResponse.error("요청값을 확인해주세요.", errors));
}
}
아래와 같이 테스트 코드를 실행해보면 테스트를 잘 통과하고, 포스트맨으로 직접 응답 JSON을 확인해도 원하는 결과가 반환되는 것을 확인할 수 있다.
actions.andExpect
내부의 메소드들은 모두 MockMvcResultMatchers 클래스의 메소드를 static import한 것이다.
ConstraintViolationException의 경우
클래스에 @Validated
를 붙이고 메소드 파라미터에 유효성 검사를 위한 어노테이션을 적용하여 수행하는 유효성 검사에 실패하면 발생하는 ConstraintViolationException은 getConstraintViolations()
를 통해 실패한 유효성 검사에 대한 정보가 담긴 ConstraintViolation 객체의 Set을 얻을 수 있다.
ConstraintViolation 객체는 FieldError 객체와는 구조가 달라서 아래와 같은 코드를 사용해야 위의 JSON 형태와 같은 응답을 얻을 수 있다.
Kotlin
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(ConstraintViolationException::class)
fun handleInvalidRequestException(e: ConstraintViolationException): ResponseEntity<CommonResponse> {
val errors = HashMap<String, String>()
e.constraintViolations.forEach { errors[it.propertyPath.last().name] = it.message }
return ResponseEntity.badRequest().body(CommonResponse.error("요청값을 확인해주세요.", errors))
}
}
Java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<CommonResponse> handleInvalidRequestException(ConstraintViolationException e) {
Map<String, String> errors = new HashMap<>();
e.getConstraintViolations().forEach(error -> errors.put(getParameterName(error.getPropertyPath()), error.getMessage()));
return ResponseEntity.badRequest().body(CommonResponse.error("요청값을 확인해주세요.", errors));
}
private String getParameterName(Path path) {
Path.Node last = null;
for (Path.Node node : path) last = node;
return last != null ? last.getName() : "";
}
}
자바는 파라미터의 이름을 얻어내기 위해 별도의 메소드를 작성해야 한다.
이 경우도 동일하게 테스트 코드를 통과하고, 포스트맨으로 받은 응답 JSON에서 원하는 결과가 나오는 것을 볼 수 있다.
마치며
이상으로 SpringBoot Validation을 사용한 유효성 검사와 이를 활용한 에러 응답까지 정리해 보았다. 이 내용은 컨트롤러 뿐만 아니라 다른 계층의 메소드에서도 동일하게 적용할 수 있다.
유효성 검사는 애플리케이션이 의도치 않은 동작을 수행하지 않도록 막아주는 첫 관문이다. 그런만큼 빠져서는 안되는 절차인 유효성 검사를 편하게 진행할 수 있다는 점은 굉장히 유용하다고 생각한다.
입사 초기에 진행했던 프로젝트에서는 요청로 유효성 검사를 위한 메소드를 직접 작성하고 메소드마다 호출해서 유효성 검사를 구현했는데 지금 돌이켜보면 상당히 비효율적이지 않았나 싶다. 물론 중복값 체크와 같은 유효성 검사는 직접 구현해야 하겠지만...
참조 링크
'Development > Spring & Spring Boot' 카테고리의 다른 글
[Spring Boot] @Value 대신 생성자 바인딩을 통해 프로퍼티 값을 바인딩하고 관리해보자 (0) | 2023.12.21 |
---|---|
[Spring Boot] H2 DB Embedded Mode 사용하기 (0) | 2023.10.04 |
[Spring Security] 스프링 시큐리티를 적용하고 예외가 발생했을 때 403 Forbidden이 발생하는 원인과 처리 방안 (1) | 2023.08.05 |
[JWT] Access Token의 한계와 Refresh Token의 필요성 (12) | 2023.07.19 |
[Spring] WebClient로 외부 API를 호출하여 받은 JSON을 객체로 역직렬화할 때 대문자 필드가 바인딩되지 않는 문제 (0) | 2023.06.29 |
댓글