유효성 검사로 인해 코드가 복잡해지고 가독성이 떨어질 수 있다. 이런 문제를 극복하기 위해 자바는 Bean Validation이라는 유효성 검사 프레임워크를 제공한다. Bean Validation은 어노테이션을 통해 유효성 검사를 위한 로직을 DTO 같은 도메인 모델과 묶어서 각 계층에서 사용한다.
Hibernate Validator는 Bean Validation 명세의 구현체이다.
10.3.2 스프링 부트용 유효성 검사 관련 의존성 추가
gradle 기준 아래와 같이 의존성을 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
10.3.3 스프링 부트의 유효성 검사
보통 DTO 객체를 대상으로 유효성 검사를 수행하는 것이 일반적이다. DTO 객체의 필드에 어노테이션을 지정하여 유효성 검사를 할 수 있다.
문자열 검증
- @Null : null 값만 허용한다.
최대값/최솟값 검증
- @DecimalMax(value = "$numberString")
값의 범위 검증
- @Positive
시간에 대한 검증
- @Future
이메일 검증
- @Email
자릿수 범위 검증
- @Digits(integer = $number1, fraction = $number2) : $number1의 정수 자릿수와 $number2의 소수 자릿수를 허용한다.
Boolean 검증
- @AssertTrue
문자열 길이 검증
- @Size(min = $number1, max = $number2)
정규식 검증
- @Pattern(regexp = "$expression")
위와 같은 검증을 위한 어노테이션을 지정하였다면, Controller에 아래와 같이 @Valid를 지정하면 유효성 검사가 가능하다.
@PostMapping("/partner")
public SignInDto.Response partnerSignIn(@Valid @RequestBody SignInDto.Request request){
return signInService.partnerSignIn(request);
}
유효성 검사를 통과 못 하면 400 에러가 발생한다.
10.3.4 @Validated 활용
// 인터페이스로 유효성 검사할 그룹 2개 생성
public interface ValidationGroup1{
}
public interface ValidationGroup2{
}
// DTO 객체에서 아래와 같이 그룹으로 유효성 검사 가능
@Min(value = 20, groups = ValidationGroup1.class)
@Max(value = 40, groups = ValidationGroup2.class)
private int age;
// 컨트롤러에서 그룹별 검사 가능
@PostMapping("/partner")
public SignInDto.Response partnerSignIn(@Validated(ValidationGroup1.class) @RequestBody SignInDto.Request request){
return signInService.partnerSignIn(request);
}
10.3.5 커스텀 Validation 추가
// TelephoneValidator 클래스
public class TelephoneValidator implements ConstraintValidator<Telephone, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return false;
}
return value.matches("^01([0|1|6|7|8|9]?)-?([0-9]{3,4})-?([0-9]{4})$");
}
}
// Telephone 어노테이션 인터페이스 생성
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = TelephoneValidator.class)
public @interface Telephone {
String message() default "전화번호 형식 불일치";
Class[] groups() default {};
Class[] payload() default {};
}
@Target 어노테이션은 ElementType을 통해 어노테이션 선언할 수 있는 위치를 설정한다. ElementType은 다음과 같다.
@Retention 어노테이션은 이 어노테이션이 실제로 적용되고 유지되는 범위를 의미한다.
@Constraint 어노테이션은 TelephoneValidator와 매핑하는 작업을 수행한다. @Telephone 인터페이스 내부는 아래와 같은 의미를 가진다.
10.4.1 예외와 에러
예외는 개발자가 직접 처리할 수 있고, 에러는 코드에서 처리할 수 있는 것이 거의 없다. 에러의 대표적인 예로 메모리 부족(OutOfMemory), 스택 오버플로(StackOverFlow) 등이 있다.
10.4.2 예외 클래스
10.4.3 예외 처리 방법
10.4.4 스프링 부트의 예외 처리 방식
// GlobalExceptionHandler 클래스 예시
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleMethodArgumentNotValidException(
MethodArgumentNotValidException e){
log.error("MethodArgumentNotValidException is occurred.", e);
Map<String, String> errors = new HashMap<>();
e.getBindingResult().getAllErrors()
.forEach(error -> errors.put(((FieldError) error).getField(),
error.getDefaultMessage()));
return ResponseEntity.badRequest().body(errors);
}
@ExceptionHandler(CustomException.class)
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
public CustomErrorResponse handleCustomException(CustomException e) {
log.error("{} is occurred.", e.getErrorCode());
return new CustomErrorResponse(e.getErrorCode(), e.getErrorMessage());
}
}
컨트롤러 클래스 내에 @ExceptionHandler 어노테이션을 사용한 메서드를 선언하면 해당 클래스에 국한해서 예외 처리를 할 수 있다.
10.4.5 커스텀 예외
사용자에게 보다 명확하게 예외를 인지시키기 위해 커스텀 예외를 사용한다.
10.4.6 커스텀 예외 클래스 생성하기
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class CustomException extends RuntimeException {
private ErrorCode errorCode;
private String errorMessage;
public CustomException(ErrorCode errorCode){
this.errorCode = errorCode;
this.errorMessage = errorCode.getDescription();
}
}
// 에러코드 enum 으로 정리 예제
@Getter
@AllArgsConstructor
public enum ErrorCode {
// 토큰 관련
INVALID_TOKEN("유효하지 않은 토큰입니다."),
ACCESS_DENIED("접근 권한이 없습니다."),
// 회원가입 관련
ALREADY_EMAIL_EXIST("이미 존재하는 이메일입니다."),
NOT_FOUND_USER("존재하지 않는 회원입니다."),
INCORRECT_PASSWORD("패스워드가 일치하지 않습니다."),
// 점포 등록 관련
ALREADY_REGISTERED_STORENAME("이미 존재하는 점포명입니다."),
NOT_FOUND_STORE("존재하지 않는 점포입니다."),
// 예약 관련
RESERVATION_DATE_MUST_BE_IN_A_MONTH("예약은 한달 이내만 가능합니다."),
TOO_MANY_NUMBER_OF_PEOPLE("예약인원을 수용할 테이블이 없습니다. 점포로 문의해주세요."),
RESERVATION_NOT_FOUND("해당 예약을 찾을 수 없습니다."),
CANNOT_UPDATE_STORE("점포를 수정할 수 없습니다. 해당 점포로 새로운 예약을 진행해 주세요."),
ACCESS_ONLY_REQUESTED_CUSTOMER("예약정보 조회는 예약을 요청한 고객만 가능합니다."),
ACCESS_ONLY_STORE_OWNER("예약 승인은 해당 점포 점주만 가능합니다.");
private final String description;
}