북 스터디/스프링 부트 핵심 가이드

[스프링 부트] 10장. 유효성 검사와 예외 처리

dbssk 2023. 6. 25. 23:16

이 글은 '스프링 부트 핵심 가이드 - 스프링 부트를 활용한 애플리케이션 개발 실무' 책을 통해 학습한 내용을 정리한 글입니다.

10장. 유효성 검사와 예외 처리

Bean Validation

  • 어노테이션을 통해 데이터를 검증하는 기능을 제공한다.
  • 유효성 검사를 위한 로직을 DTO 같은 도메인 모델과 묶어서 각 계층에서 사용하면서 검증 자체를 도메인 모델에 얹는 방식으로 수행한다.
  • 코드의 간결함을 유지할 수 있고, 가독성이 좋아진다.

Hibernate Validator

  • Bean Validation 명세의 구현체
  • 스프링 부트에서 채택하여 사용하고 있다.
  • JSR-303 명세의 구현체로서 도메인 모델에서 어노테이션을 통한 필드값 검증을 가능하도록 도와준다.

의존성 추가

  • gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
  • maven
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>

스프링 부트의 유효성 검사

각 계층으로 데이터가 넘어오는 시점에 해당 데이터에 대한 검사를 수행한다. 일반적으로 DTO 객체를 대상으로 유효성 검사를 수행한다.

@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
public class UserSignUpDto {

    @NotBlank(message = "이메일은 필수 항목 입니다.")
    @EmailValidation(message = "이메일 형식에 맞게 입력해주세요.")
    private String email;

    @NotBlank(message = "이름은 필수 항목 입니다.")
    private String name;

    @NotBlank(message = "비밀번호는 필수 항목 입니다.")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{6,}$",
            message = "비밀번호는 알파벳, 숫자, 특수문자를 각각 하나 이상 포함하여 6자 이상으로 설정해주세요.")
    private String password;

    @NotBlank(message = "연락처는 필수 항목 입니다.")
    @Pattern(regexp = "\\d{3}-\\d{3,4}-\\d{4}",
            message = "올바른 형식으로 연락처를 입력해주세요. 01X-XXX(X)-XXXX")
    private String phone;

    private UserType userType;

}

유효성 검사를 위한 대표적인 어노테이션

  • 문자열 검증
    • @Null : null 값만 허용
    • @NotNull : null 값 불허, "", " " 허용
    • @NotEmpty : null 값, "" 불허, " " 허용
    • @NotBlank : null 값, "", " " 불허

  • 최댓값/최솟값 검증
    • BigDecimal, BigInteger, int, long 타입 지원
    • @DecimalMax(value = "$numberString") : $numberString보다 작은 값 허용
    • @DecimalMin(value = "$numberString") : $numberString보다 큰 값 허용
    • @Min(value = $number) : $number 이상의 값 허용
    • @Max(value = $number) : $number 이하의 값 허용

  • 값의 범위 검증
    • BigDecimal, BigInteger, int, long 타입 지원
    • @Positive : 양수 허용
    • @PositiveOrZero : 0을 포함한 양수 허용
    • @Negative : 음수 허용
    • @NegativeOrZero : 0을 포함한 음수 허용

  • 시간에 대한 검증
    • Date, LocalDate, LocalDateTime 등의 타입 지원
    • @Future : 현재보다 미래의 날짜 허용
    • @FutureOrPresent : 현재를 포함한 미래의 날짜 허용
    • @Past : 현재보다 과거의 날짜 허용
    • @PastOrPresent : 현재를 포함한 과거의 날짜 허용

  • 이메일 검증
    • @Email : 이메일 형식 검사, "" 허용 → 그러나 @가 없어진 것만 인식하고, @ 뒤의 형식은 검사할 수 없다.

  • 자릿수 범위 검증
    • BigDecimal, BigInteger, int, long 타입 지원
    • @Digits(integer = $number1, fraction = $number2) : $number1의 정수 자릿수와 $number2의 소수 자릿수 허용

  • Boolean 검증
    • @AssertTrue : true인지 체크, null 값 체크 X
    • @AssertFalse : falser인지 체크, null 값 체크 X

  • 문자열 길이 검증
    • @Size(min = $number1, max = $number2) : $number1 이상 $number2 이하의 범위 허용

  • 정규식 검증
    • @Pattern(regexp = "$expression") : 정규식 검사, java.util.regex.Pattern 패키지의 컨벤션을 따른다.

Controller 에서의 유효성 검사

  • @Valid
    • 자바에서 지원하는 어노테이션
    • @Valid 어노테이션을 지정해야 DTO 객체에 대해 유효성 검사를 수행한다.
@PostMapping("/signUp")
    public ResponseEntity<?> signUpUser(
            @RequestBody @Valid UserSignUpDto userSignUpDto,
            Errors errors
) { ...
}

규칙에 맞게 요청하면 http 응답으로 200 OK 메세지가 뜨고, 아닌 경우 400 BAD REQUEST 가 뜬다.

  • @Validated
    • 스프링에서 지원하는 어노테이션
    • @Valid 어노테이션의 기능을 포함하고 있다.
    • 유효성 검사를 그룹으로 묶어 대상을 턱정할 수 있는 기능이 있다.

  • Custom Validation
    • 자바 또는 스프링의 어노테이션에서 제공하지 않는 기능을 유효성 검사에 사용해야 하는 경우 
    • 아래 코드와 같이 ConstraintValidator 과 커스텀 어노테이션을 조합해 별도의 유효성 검사 어노테이션을 생성할 수 있다.
@NotBlank(message = "이메일은 필수 항목 입니다.")
@EmailValidation(message = "이메일 형식에 맞게 입력해주세요.")
private String email;
public class EmailValidator implements ConstraintValidator<EmailValidation, String> {

    private static final String EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$";
    private static final String EMAIL_DOMAIN = ".com";

    @Override
    public void initialize(EmailValidation constraintAnnotation) {
    }

    @Override
    public boolean isValid(String email, ConstraintValidatorContext context) {

        ...
    }
}
@Documented
@Constraint(validatedBy = EmailValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface EmailValidation {

    String message() default "이메일 형식에 맞게 입력해주세요.";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

}

예외 처리

예외 (Exception)

입력 값의 처리가 불가능하거나 참조된 값이 잘못된 경우 등 애플리케이션이 정상적으로 동작하지 못하는 상황이다. 예외는 개발자가 직접 처리할 수 있으므로 미리 코드 설계를 통해 처리할 수 있다.

에러 (Error)

주로 자바의 가상머신에서 발생시키는 것으로 예외와 달리 애플리케이션 코드에서 처리할 수 있는 것이 거의 없다. 대표적인 예로 OutOfMemory, StackOverFlow 등이 있다. 이러한 에러는 미리 문제가 발생하지 않도록 예방해서 원천적으로 차단해야 한다.

예외 처리 방법

  • 예외 복구
    • 예외 상황을 파악해서 문제를 해결하는 방식
    • try-catch 구문 활용

  • 예외 처리 회피
    • 예외가 발생한 메서드를 호출한 곳에서 에러 처리를 할 수 있게 전가하는 방식
    • throw 키워드 사용

  • 예외 전환
    • try-catch 방식을 사용하면서 catch 블록에서  throw 키워드를 사용하여 다른 예외 타입으로 전달하는 방식
    • 커스텀 예외를 만드는 과정에서 사용되는 방법

스프링 부트의 예외 처리 방식

  • @(Rest)ControllerAdvice 와 @ExceptionHandler 를 통해 모든 컬트롤러의 예외 처리
    • @ControllerAdvice 대신 @RestControllerAdvice 를 사용하면 결괏값을 JSON 형태로 반환할 수 있다.
  • @ExceptionHandler 를 통해 특정 컨트롤러의 예외 처리
@RestControllerAdvice
public class CustomExceptionHandler {

    @ExceptionHandler(value = RuntimeException.class)
    public ResponseEntity<?> handleException(RuntimeException e, HttpServletRequest request) {
    	...
    }

}

위 코드 처럼 클래스에 @RestControllerAdvice를 사용하면 @Controller 나 @RestController 에서 발생하는 예외를 한 클래스에서 관리하고 처리할 수 있으며, 패키지를 지정해서 범위를 지정할 수도 있다. 그리고 @ExceptionHandler에 처리하고 싶은 예외를 value 로 지정해주면 지정한 예외를 처리하는 메서드를 생성할 수 있다.

커스텀 예외

  • 네이밍에 개발자의 의도를 담을 수 있기 때문에 이름만으로도 어느 정도 예외 상황을 짐작할 수 있다.
  • 애플리케이션에서 발생하는 예외를 개발자가 직접 관리하기 수월해 진다.
  • 예외 상황에 대한 처리도 용이하다.
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class CustomException extends RuntimeException {

    private ErrorCode errorCode;
    private String message;
    private HttpStatus httpStatus;

    public CustomException(ErrorCode errorCode) {
        this.errorCode = errorCode;
        this.message = errorCode.getMessage();
        this.httpStatus = errorCode.getHttpStatus();
    }
}
@Getter
@RequiredArgsConstructor
public enum ErrorCode {

    ALREADY_EXISTS_EMAIL("이미 가입된 이메일입니다.", BAD_REQUEST),
    ...
    ;
    
    private final String message;
    private final HttpStatus httpStatus;

}