본문 바로가기
독서/스프링 부트 핵심 가이드

[스프링 부트 핵심 가이드] Chapter 10장. 유효성 검사와 예외 처리

by JayAlex07 2023. 10. 1.

스프링 부트 핵심 가이드

img

 

 

[스프링 부트 핵심 가이드] Chapter 10장. 유효성 검사와 예외 처리

 

 

어플리케이션의 비즈니스 로직이 올바르게 동작하기 위해서 데이터를 사전 검증하는 작업이 필요하고, 이것을 유효성 검사 또는 데이터 검증이라고 한다

  • 유효성 검사는 Validation으로 프로그래밍에서 매주 중요한 부분이고, 자바에서 가장 신경 써야 하는 것 중 하나가 NullPointException 예외이다

 


 

 

일반적인 어플리케이션 유효성 검사의 문제점

 

일반적으로 사용되는 데이터 검증 로직에서, 계층별로 진행하게 되면 각 클래스별로 분산되어 있어 관리하기 어렵고, 검증 로직에 중복이 많아 유사한 기능의 코드가 존재할 수 있으며, 검증해야 할 값이 많으면, 코드가 길어진다는 문제점이 있다

 

이를 해결하기 위해 자바는 Bean Validation이라는 데이터 유효성 검사 프레임워크를 제공하기 시작했다

  • 이 Bean Validation은 어노테이션을 통해 다양한 데이터를 검증하는 기능을 제공한다

  • 유효성 검사는 각 계층으로 데이터가 넘어오는 시점에 해당 데이터에 대한 검사를 실시한다
  • 스프링 부트 프로젝트에서는 계층 간 데이터 전송에 대체로 DTO 객체를 활용하고 있어, 유효성 검사를 DTO 객체를 대상으로 수행한다
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidRequestDto {
    @NotBlank
    String name;

    @Email
    String email;

    @Pattern(regexp = "^01(?:0|1|[6-9])-(?:\d{3}|\d{4})-\d{4}$")
    String phoneNumber;

    @Min(value = 20) @Max(value = 40)
    int age;

    @Size(min = 0, max = 40)
    String description;

    @Positive
    int count;

    @AssertTrue
    boolean booleanCheck;
}

 

문자열 검증

  • @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 값은 체크하지 않는다
  • @AssertFalse : false인지 체크한다. null 값은 체크하지 않는다

 

문자열 길이 검증

  • @Size(min = $number1, max = $number2) : $number1 이상, $number2 이하의 범위를 허용한다

 

정규식 검증

  • @Pattern(Regexp = "$expression") : 정규식을 검사한다. 정규식은 자바의 java.util.regex.Pattern 패키지의 컨벤션을 따른다

 

@Valid

Controller에서 메서드의 파라미터에 @Valid 어노테이션을 넣어, DTO 객체에 대한 유효성 검사를 수행할 수 있게 한다.
- DTO 객체에 대해 유효성 검사가 완벽히 수행되었을 경우 200 OK 가 뜬다
- 반대로, 검사하다 문제가 발생했을 경우 400 에러가 발생하며, 로그에 문제가 발생한 지점을 확인할 수 있다

 

@Validated

  • @Valid 어노테이션 기능을 포함하고 있어 @Validated를 사용해도 된다
  • @Validated 어노테이션에 특정 그룹을 설정하지 않은 경우에는 groups가 설정되지 않은 필드에 대해 유효성 검사를 수행
  • @Validated 어노테이션에 특정 그룹을 설정하는 경우에는 지정된 그룹으로 설정된 필드에 대해서만 유효성 검사를 수행한다

 

커스텀 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-9])-(?:\d{3}|\d{4})-\d{4}$");
    }
}
  • TelephoneValidator 클래스를 ConstraintValidator 인터페이스의 구현체로 정의한다
  • 인터페이스를 선언할 때는 어떤 어노테이션 인터페이스인지 타입을 지장한다
  • false가 리턴되면 MethodArgumentNotValidException 예외가 발생한다
// 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.PACKAGE
    • ElementType.TYPE
    • ElementType.CONSTRUCTOR
    • ElementType.FIELD
    • ElementType.METHOD
    • ElementType.ANNOTATION_TYPE
    • ElementType.LOCAL_VARIABLE
    • ElementType.PARAMETER
    • ElementType.TYPE_PARAMETER
    • ElementType.TYPE_USE
  • @Retention 어노테이션은 이 어노테이션이 실제로 적용되고 유지되는 범위를 의미한다
    • RetentionPolicy.RUNTIME : 컴파일 이후에도 JVM에 의해 계속 참조한다. 리플렉션이나 로깅에 많이 사용되는 정책이다
    • RetentionPolicy.CLASS : 컴파일러가 클래스를 참조할 때까지 유지한다
    • RetentionPolicy.SOURCE : 컴파일 전까지만 유지한다. 컴파일 이후에 사라진다
  • @Constraint 어노테이션을 활용해 TelephoneValidator와 매핑하는 작업을 수행한다
    • @Telephone 인터페이스 내부에는 message(), groups(), payload() 요소를 정의해야 한다
    • message() : 유효성 검사가 실패할 경우 반환되는 메세지를 의미한다
    • groups() : 유효성 검사를 사용하는 그룹으로 설정한다
    • payload() : 사용자가 추가 정보를 위해 전달하는 값이다

 

이렇게 하면 @Pattern() 대신 @Telephone 어노테이션을 사용할 수 있게 된다


 

 

예외 처리

 

어플리케이션을 개발할 때에 많은 오류가 발생하게 된다

  • 자바에서는 이러한 오류를 try/catch, throw 구문을 활용해 처리한다

 

예외와 에러

  • 예외 (Exception)은 입력 값의 처리가 불가능하거나 참조된 값이 잘못된 경우 등 어플리케이션이 정상적으로 동작하지 못하는 상황을 의미한다
  • 에러 (Error)는 예외와 비슷한 의미로 사용하고 있지만, 주로 자바의 가상머신에서 발생시키는 것으로서 예외와 달리 어플리케이션 코드에서 처리할 수 있는 것이 거의 없다
    • 대표적으로 메모리 부족, 스택 오버플로 등이 있다

 

예외 클래스

  • 모든 예외 클래스는 Throwable 클래스를 상속받는다
  • Exception 클래스는 다양한 자식 클래스를 가지고 있고, 이 클래스는 크게 Checked Exception과 Unchecked Exception으로 구분할 수 있다
    • Checked Exception은 컴파일 단계에서 확인 가능한 예외 상황이다
      • IDE에서 캐치해서 반드시 예외 처리를 할 수 있게 표시해준다
    • Unchecked Exception은 런타임 단계에서 확인되는 예외 상황을 나타낸다 (RuntimeException을 상속 받은 클래스)
      • 문법상 문제는 없지만 프로그램이 동작하는 도중 예기치 않은 상황이 생겨 발생하는 예외다

 

  Checked Exception Unchecked Exception
처리 여부 반드시 예외 처리 필요 명시적 처리를 강제하지 않음
확인 시점 컴파일 단계 실행 중 단계
대표적인 예외 클래스 IOException / SQLException RuntimeException / NullPointerException / IllegalArgumentException / IndexOutOfBoundException / SystemException

 

 

예외 처리 방법

  • 예외가 발생했을 때 크게 세 가지 방법이 있다
    • 예외 복구
      • 예외 상황을 파악해서 문제를 해결
      • try/catch 구분
    • 예외 처리 회피
      • 예외가 발생한 시점에서 바로 처리하는 것이 아닌, 예외가 발생한 메서드를 호출한 곳에서 에러 처리를 할 수 있게 전가하는 방식
      • throw 키워드를 사용해 어떤 예외가 발생했는지 호출부에 내용을 전달할 수 있다
    • 예외 전환
      • 위의 두 방법을 적절하게 섞은 방식이다
      • 예외가 발생했을 때 어떤 예외가 발생했느냐에 따라 호출부로 예외 내용을 전달하면서 좀 더 적합한 예외 타입으로 전달을 하기 위함이다
// =============== 예외 복구 ===============
int a = 1;
String b = "a";

try {
    System.out.println(a + Integer.parseInt(b));
} catch (NumberFormatException e) {
    b = "2";
    System.out.println(a + Integer.parseInt(b));
}

//  =============== 예외 처리 회피 ===============
a = 1;
b = "a";

try {
    System.out.println(a + Integer.parseInt(b));
} catch (NumberFormatException e) {
    throw new NumberFormatException("숫자가 아닙니다");
}

 

 

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

  • 예외가 발생했을 때 클라이언트에 오류 메세지를 전달하기 위해 각 레이어에서 발생한 예외를 엔드포인트 레벨인 컨트롤러로 전달해야 한다
    • @(Rest)ControllerAdvice와 @ExceptionHandler를 통해 모든 컨트롤러의 예외를 처리
      • @ControllerAdvice 대신 @RestControllerAdvice를 사용하면 결과값을 JSON 형태로 반환할 수 있다
    • @ExceptionHandler를 통해 특정 컨트롤러의 예오 처리
// CustomExceptionHandler 클래스

@RestControllerAdvice
public class CustomExceptionHandler {

    private final Logger LOGGER = LoggerFactory.getLogger(CustomExceptionHandler.class);

    @ExceptionHandler(value = RuntimeException.class)
    public ResponseEntity<Map<String, String>> handleException(
        RuntimeException e, HttpServletRequest request) {
        HttpHeader responseHeader = new HttpHeader();
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        LOGGER.error("Advice 내 handleException 호출, {}, {}", request.getRequestURI(), e.getMessage);

        Map<String, String> map = new HashMap<>();
        map.put("error type", httpStatus.getReasonPhrase());
        map.put("code", "400");
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, httpStatus);
    }
}
  • @ExceptionHandler는 @Controller 또는 @RestController가 적용된 빈에서 발생하는 예외를 잡아 처리하는 메서드를 정의할 때 사용한다
    • 어떤 예외 클래스를 처리할지는 value 속성으로 등록할 수 있다
    • value 속성은 배열 형식으로 전달받을 수 있어서 여러 예외 클래스를 등록할 수 있다

 

커스텀 예외

  • 어플리케이션을 개발하다 보면 예외로 처리할 영역이 늘어나고, 예외 상황이 다양해지면서 사용하는 예외 타입도 많아진다
    • 자바에서 이미 제공하는 표준 예외를 사용하면 해결이 되지만, 커스텀 예외를 만들어 사용하는 경우가 있다
      • 커스텀 예외 네이밍에 개발자의 의도를 담을 수 있다 (이름으로만으로도 어느 정도 예외 상황을 짐작할 수 있다)
      • 어플리케이션에서 발생하는 예외를 개발자가 직접 관리하기 수월해진다
      • 예외 상황에 대한 처리도 용이하다

커스텀 예외 코드