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

오늘내일·2023년 11월 26일
0

책 리뷰

목록 보기
9/11

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

유효성 검사로 인해 코드가 복잡해지고 가독성이 떨어질 수 있다. 이런 문제를 극복하기 위해 자바는 Bean Validation이라는 유효성 검사 프레임워크를 제공한다. Bean Validation은 어노테이션을 통해 유효성 검사를 위한 로직을 DTO 같은 도메인 모델과 묶어서 각 계층에서 사용한다.

10.2 Hibernate Validator

Hibernate Validator는 Bean Validation 명세의 구현체이다.

10.3 스프링 부트에서의 유효성 검사

10.3.2 스프링 부트용 유효성 검사 관련 의존성 추가
gradle 기준 아래와 같이 의존성을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

10.3.3 스프링 부트의 유효성 검사
보통 DTO 객체를 대상으로 유효성 검사를 수행하는 것이 일반적이다. DTO 객체의 필드에 어노테이션을 지정하여 유효성 검사를 할 수 있다.

  • 문자열 검증
    - @Null : null 값만 허용한다.

    • @NotNull : null을 허용하지 않는다. "", " "는 허용한다.
    • @NotEmpty : null, ""을 허용하지 않는다.
    • @NotBlank : null, "", " "을 허용하지 않는다.
  • 최대값/최솟값 검증
    - @DecimalMax(value = "$numberString")

    • @DecimalMin(value = "$numberString")
    • @Min(value = $number)
    • @Max(value = $number)
  • 값의 범위 검증
    - @Positive

    • @PositiveOrZero
    • @Negative
    • @NegativeOrZero
  • 시간에 대한 검증
    - @Future

    • @FutureOrPresent
    • @Past
    • @PastOrPresent
  • 이메일 검증
    - @Email

  • 자릿수 범위 검증
    - @Digits(integer = $number1, fraction = $number2) : $number1의 정수 자릿수와 $number2의 소수 자릿수를 허용한다.

  • Boolean 검증
    - @AssertTrue

    • @AssertFalse
  • 문자열 길이 검증
    - @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);
    }
  • @Validated 어노테이션에 특정 그룹을 설정하지 않은 경우에는 groups가 설정되지 않은 필드에 대해 유효성 검사를 수행한다.
  • @Validated 어노테이션에 특정 그룹을 설정하는 경우에는 지정된 그룹으로 설정된 필드에 대해서만 유효성 검사를 수행한다.

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은 다음과 같다.

  • 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() : 유효성 검사가 실패할 경우 반환되는 메시지를 의미한다.
  • group() : 유효성 검사를 사용하는 그룹으로 설정한다.
  • payload() : 사용자가 추가 정보를 위해 전달하는 값이다.

10.4 예외 처리

10.4.1 예외와 에러
예외는 개발자가 직접 처리할 수 있고, 에러는 코드에서 처리할 수 있는 것이 거의 없다. 에러의 대표적인 예로 메모리 부족(OutOfMemory), 스택 오버플로(StackOverFlow) 등이 있다.

10.4.2 예외 클래스

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

10.4.3 예외 처리 방법

  • 예외 복구
  • 예외 처리 회피
  • 예외 전환 방법

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

  • @(Rest)ControllerAdvice와 @ExceptionHandler를 통해 모든 컨트롤러의 예외를 처리
  • @ExceptionHandler를 통해 특정 컨트롤러의 예외를 처리
// 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;
}
profile
다시 시작합니다.

0개의 댓글