API에서 특정 파라미터를 받을 때 그 파라미터가 유효한지 검증을 해야하는 경우는 굉장히 빈번하다. 예를 들면 한 번에 10개 이상 구매 못한 상품의 구매요청을 11개를 넣는다면 해당 코드에 대한 예외 처리를 해주어야 한다.
또 핸드폰 번호를 입력하는데 핸드폰 번호의 양식이 맞지 않는다던가, 닉네임을 설정하는데 10자 이상의 닉네임을 지정하게 하고 싶지 않다던가 클라이언트로부터 오는 인풋을 검증할 때가 많다.
이 과정을 Validation이라고 한다. 예를 들어
public class Order {
Long itemId;
int count;
}
라는 객체가 있다고 하자. 그리고 주문은 한번에 10개 이하로만 해야하고 어떤 제품을 주문하는지 알아야 하기 때문에, itemId는 null이면 안된다고 하자.
public ResponseEntity makeOrder(OrderRequest request) {
Long itemId = request.getItemId();
int count = request.getCount();
if (itemId == null) {
throw new RuntimeException();
}
if (count > 10) {
throw new RuntimeException();
}
}
그렇다면 대략 이런식으로 코드를 작성하게 된다. 이런식으로 처리를 했을 때 구현에는 문제가 없지만 유지보수성에서 다른 문제들이 발생하게 된다.
하나는 Validation 로직이 어플리케이션 전반적으로 분산되어 존재하게 된다. 이곳저곳에 Validation 로직이 산재되어 관리가 어려워진다.
또 코드의 중복이 생긴다. OrderRequest 객체를 다른 곳에서도 사용한다고 하면
if (itemId == null) {
throw new RuntimeException();
}
if (count > 10) {
throw new RuntimeException();
}
이 과정을 다른 곳에서도 겪어야 하며 수정한다면 둘 다 수정해주어야 한다.
또 다른 문제로는 이 검증 비즈니스 로직이 다른 비즈니스 로직과 섞이게 되면 코드가 길어진다는 단점이 있다.
물론 이러한 Validation을 따로 분리하여 코드를 작성해서 관리하면 되지만 Spring 에서는 해당 내용을 어노테이션으로 처리하게 해주므로 이 부분에 대해서 간단히 알아보았다.
을 통해 위 문제점을 어노테이션으로 해결한 공식 라이브러리를 사용할 수 있다. 사용을 위해선 디펜던시를 추가해주어야 한다. Spring Boot 2.3 버전 이전까지는 spring-web에 포함되어 있었지만 이 후에 분리가 되었기 때문에 이 후 버전부터는 추가가 필요하다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
이제 예시로 사용할 객체와 메소드를 살펴보자.
public class CreatePokemonRequest {
private String serialNumber;
private String name;
private String type;
private Integer hp;
}
새로운 포켓몬 생성을 위한 객체이다. serialNumber는 id 같은 개념으로 알파벳과 하이픈 그리고 3자리 숫자로 이루어져 있다 예)A-001, name은 포켓몬의 이름이고 타입은 불, 물, 전기 등.. hp는 체력이다.
@PostMapping("/pokemons")
public ResponseEntity createPokemon(
@RequestBody CreatePokemonRequest request
) {
// request 검증
return ResponseEntity.status(HttpStatus.CREATED).build();
}
메소드는 이런식이다. 요구사항이 아래와 같다고 생각하고 validation 코드를 작성해보자.
먼저 serialNumber는 아래와 같이 가능하다.
public class CreatePokemonRequest {
@Pattern(
regexp = "^[A-Z]+-[0-9]{3}$",
message = "SerialNumber의 형태가 잘못됐습니다."
)
@NotNull
private String serialNumber;
private String name;
private String type;
private Integer hp;
이렇게 Pattern 어노테이션을 사용하면 정규 표현식을 통해 값을 검증할 수 있다. 만약 저 패턴을 지키지 않고 요청을 보내면 MethodArgumentNotValidException
가 뜬다.
NotNull 어노테이션도 추가해야 검증할 때 null 체크도 해주므로 해당 어노테이션도 추가해주어야 한다. 만약 NotNull을 추가하지 않으면 해당 파라미터에 대해 검증을 실행하지 않는다.
추가로 메소드에도
@PostMapping("/pokemons/create")
public ResponseEntity createPokemon(
@RequestBody @Valid CreatePokemonRequest request
) {
System.out.println(request);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
이렇게 검증을 원하는 객체 앞에 @Valid
어노테이션을 붙여줘야 활성화가 된다.
다음은 name의 글자 수 제한이다. 글자수 제한은
@NotNull
@Size(min = 1, max = 10)
private String name;
이렇게 Size 어노테이션을 사용하면 된다. Size 어노테이션은 문자열에 사용하면 문자열 길이를 계산하지만 컬렉션을 사용하면 컬렉션의 길이로 작동한다.
다음은 최소 10, 최대 300의 hp 관련이다.
@Min(value = 10, message = "최소 체력은 10입니다.")
@Max(value = 300, message = "최대 체력은 300입니다.")
private Integer hp;
이 경우엔 Min, Max를 통해서 사용할 수 있다.
이번엔 type에 관련된 요구사항을 만족해보자. 현재 구현된 속성은 불, 물, 얼음, 노말 뿐이라고 가정한다. 구현을 위해서는 커스텀 어노테이션을 만들고 해당 어노테이션을 ConstraintValidator
와 연결해주면 된다.
@Constraint(validatedBy = PokemonTypeValidator.class)
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface PokemonType {
String message() default "잘못된 타입입니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
먼저 어노테이션을 작성해준다. @Constraint
에 우리가 추가로 만들 Validator가 들어간다. 어노테이션 코드의 구체적인 내용은 여기서는 스킵하겠다. 대강 이 어노테이션을 어느 범위, 언제, 어떻게 사용할지에 대한 코드이다.
public class PokemonTypeValidator implements ConstraintValidator<PokemonType, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
List<String> validTypes = List.of("fire", "water", "ice", "normal");
return validTypes.contains(value);
}
}
이제 코드 검증을 실제로 실행하는 Validator 코드이다. 현재 구현된 4가지 속성이 값이 포함되는지를 boolean으로 반환해주면 된다. 이쪽에 비즈니스 로직이 들어간다.
@NotNull
@PokemonType
private String type;
최종적으로 이렇게 타입 위에 어노테이션을 붙여주면 Validator가 들어오는 값에 대해서 검사를 해준다.
이제 전체적인 검증 코드가 비즈니스 로직에 섞여 들어가는게 아니라 객체 안에 내장되어 분리되므로 조금 더 유연한 코드가 완성되었다. 마지막으로 한 가지만 다듬어주면 좋은데 만약 유효성 검증에 실패하면 어떻게 되는지에 대해서이다.
아래는 실제로 위 코드를 일부러 실패시킨 포스트맨에서의 응답이다.
전반적으로 작동하지만 이 API를 실제로 사용하는 클라이언트가 알아보기에 깔끔하진 않다. 또 현업에서 일을 하다보면 상태코드 400으로 퉁쳐서 validation을 처리하면 클라이언트 개발자가 미워하는 백엔드 개발자가 된다.
지금까지 작성한 Validation 코드에 일부러 에러를 내면 MethodArgumentNotValidException
이라는 공통적인 에러가 뜨는데, ControllerAdvice를 통해 이 에러를 글로벌하게 잡고 처리하는 방법에 대해서 알아보자.
ControllerAdvice가 뭔지 모른다면 이전에 작성했던 이 글을 참고해보자.
@RestControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, Object> response = new HashMap<>();
Map<String, Map<String, String>> errors = new HashMap<>();
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
Map<String, String> errorDetails = new HashMap<>();
errorDetails.put("errorCode", fieldError.getCode());
errorDetails.put("message", fieldError.getDefaultMessage());
errors.put(fieldError.getField(), errorDetails);
}
response.put("errors", errors);
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
}
이런식으로 RestControllerAdvice 어노테이션을 통해 에러를 중간에서 캐치하는 빈을 등록해준다. 그리고 ExceptionHandler 내에 Spring Validation이 뿜는 에러인 MethodArgumentNotValidException을 등록해준다.
내부에서는 MethodArgumentNotValidException의 FieldErrors를 읽어와서 getCode, getMessage를 통해 어떤 어노테이션인지, 어노테이션 내부의 message를 읽어 온 후 Map에 매핑해서 에러의 형태를 다듬어 준다.
그 후 에러를 일부러 내보면 이렇게 각 필드에 적절한 에러를 보여줄 수 있다. 실제로는 조금 더 다듬을 수 있지만 각 팀의 API 설계에 맞게 내부를 작성해주면 된다.
필자라면 API 설계를 확인하고 Map 대신 따로 데이터 클래스를 만들어서 설계를 하는 식으로 실무에 적용한다면 적절할 것으로 보인다.
전체 코드는 여기에서 확인할 수 있다.