Chap.10 유효성 검사와 예외 처리
애플리케이션의 비즈니스 로직(Service Layer)이 올바르게 동작하려면 사전에 검증하는 작업이 필요합니다.
이를 유효성 검사 또는 데이터 검증이라고 합니다. 유효성 검사의 예로는 여러 계층에서 들어오는 데이터에 대해 의도한 형식대로 값이 등러오는지 체크하는 과정이 잇습니다. 자바와 스프링 부트 프레임워크에서는 NPE(Null Pointer Exception)이 있습니다.
10.1 일반적인 애플리케이션 유효성 감사의 문제점
일반적으로 사용하는 데이터 검증 로직에는 몇가지가 있는데, 이는 계층별로 진행하는 로직이 전부 클래스별로 분산되어 있어서 예외 처리를 위한 클래스를 관리하는 것이 어렵습니다. 또한, 의외로 중복되는 코드가 많아 여러 곳에 유사한 기능을 수행하는 코드가 존재할 수 있습니다.
이러한 문제점들을 극복하기 위해서 2009년 Bean Validation이라는 데이터 유효성 검사 프레임워크를 제공합니다. 이 프레임워크는 어노테이션을 통해 다양한 데이터를 검증하는 기능을 제공합니다. 이를 통해 유효성 검사를 위한 로직을 DTO같은 도메인 모델과 묶어서 각 계층에서 사용하면서 검증 자체를 도메인 모델에 얹는 방식으로 수행한다는 의미입니다.
10.2 Hibernate Validator
이는 Bean Validation 명세의 구현체입니다. 스프링 부트에서는 Hibernate Validation을 유효성 감사 표준으로 채택해서 사용하고 있습니다.
10.3 스프링 부트에서의 유효성 검사
원래 스프링 부트의 유효성 검사 기능은 spring-boot-starter-web 내부에 포함되어 있었습니다. 하지만 스프링 부트 2.3 버전 이후로 별도의 라이브러리를 제공하고있습니다.
dependencies {
...
// validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
}
이렇게 build.gradle에 작성하고 Gradle Build를 하면 의존성이 추가됩니다.
유효성 검사는 가계층으로 데이터가 넘어오는 시점에서 해당 데이터에 대한 검사를 실시합니다.
스프링 부트 프로젝트에서는 계층 간 데이터 전송에 대체로 DTO 객체를 활용하고 있기 때문에, 아래와 같이 유효성 검사를 DTO 객체를 대상으로 수행하는 것이 일반적입니다.
유효성 검사 Annotation
문자열 검증
- @Null: null값만 허용합니다.
- @NotNull: null을 허용하지 않습니다.
- @NotEmpty: null, ""을 허용하지 않습니다. " "는 허용합니다.
- @NotBlank: null, "", " "을 허용하지 않습니다.
최댓값/최솟값 검증
- @DemicalMax(value = "$numberString") : $numberString보다 작은 값을 허용합니다.
- @DemicalMin(value = "$numberString") : $numberString보다 큰 값을 허용합니다.
- @Max(value = $number) : $number 이하의 값을 허용합니다.
- @Min(value = $number) : $number 이상의 값을 허용합니다.
값의 범위 검증
- @Positive: 양수를 허용합니다.
- @Negative: 음수를 허용합니다.
- @PositiveOrZero: 0을 포함한 양수를 허용합니다.
- @NegativeOrZero: 0을 포함한 음수를 허용합니다.
시간에 대한 검증
- @Future : 현재보다 미래의 날짜를 허용합니다.
- @FutureOrPresent : 현재 또는 미래의 날짜를 허용합니다.
- @Past : 현재보다 과거의 날짜를 허용합니다.
- @PastOrPresent : 현재 또는 과거의 날짜를 허용합니다.
이메일 검증
- @Email: 이메일을 검증합니다. ""는 허용합니다.
자릿수 범위 검증
- @Digits(integer = $number1, fraction = $number2) : $number1의 정수 자릿수와 $number2의 소수 자릿수를 허용합니다.
Boolean 검증
- @AssertTrue : true인지 체크합니다. null값은 체크하지 않습니다.
- @AssertFalse : false인지 체크합니다.
문자열 길이 검증
- @Size(min = $number1, max = $number2) : $number1 이상 $number2 이하의 범위를 허용합니다.
정규식 검증
- @Pattern(regexp = "$expression) : 정규식을 검사합니다.
위의 어노테이션을 DTO 객체의 필드에 적용시켜서 클라이언트로부터 받은 값을 검증하게 됩니다.
만약, 이러한 형식을 지키지 않았을 시에는 다음과 같은 오류 로그를 볼 수 있습니다.
@Validated 활용
이전에는 유효성 검사를 하기 위해서 @Valid라는 어노테이션을 사용했습니다. @Valid는 자바에서 지원하는 어노테이션이며, 스프링도 @Validated라는 어노테이션을 통해서 별도의 유효성 검사를 진행할 수 있습니다.
@Validated는 @Valid 어노테이션의 기능을 포함하고 있기때문에, @Valid를 @Validated로 변경할 수 있습니다. 또한, @Validated는 유효성 검사를 그룹으로 묶어 대상을 특정할 수 있는 기능이 있습니다.
그룹으로 지정하는 방법은 다음과 같습니다.
특정 패키지 내부에 어떻게 그룹화할 것인지에 따라서 Interface를 선언해줍니다. 내부 코드는 작성하지 않고 개념을 그룹화하는 방식으로 그룹화를 진행합니다.
이렇게 검증 어노테이션을 적용할 때, group = $groupname을 붙혀서 사용할 수 있습니다.
만약 컨트롤러단에서 클라이언트로부터 받아오는 DTO 객체에 @Validated만 사용할지, 그룹이 지정되지 않은 필드에 대해서만 검증을 진행합니다. 그룹을 지정하여 @Validated(ValidationGroup1.class)로 사용했다면, 해당 그룹의 필드들만 검증을 진행합니다.
하지만 이렇게 그룹화를 지정하여 사용하는 경우, 유효성 검사가 필요한 부분에 group 절이 빠지는 휴먼 에러가 발생될 수 있는 확률이 높기때문에, 어떤 상황에서 사용하지를 적절하게 설계해야 의도대로 유효성 감사를 실시할 수 있습니다. 그렇지 못한 경우, 비효율적이거나, 생산적이지 못한 패턴을 의미하는 안티패턴이 발생하게 됩니다. 개인적으로는 사용하지 않는 것이 좋아보입니다.
Custom Validation 추가
실무에서는 스프링이나 자바에서 공식적으로 제공하는 유효성 검사 라이브러리가 수행하지 못하는 경우를 다룰 수 있습니다.
예를 들면 전화번호같은 경우, 특정 케이스가 존재하나, 지역과 나라에 따라 달라지기때문에, 공식적으로 제공하지 않습니다.
이런 경우에는 이전에 작성한 @Pattern을 사용해서 정규표현식으로 검증을 진행해야합니다.
하지만, 입력해야하는 @Pattern 필드가 많은 경우, 중복되는 코드의 양이 많아지게 됩니다.
따라서 이런 경우를 명료하게 표현할 수 있도록 Custom Interface를 만들고 이를 통해서 @Telephone과 같은식으로 처리할 수 있습니다.
ConstraintValidator 인터페이스를 구현하는 TelephoneValidator 클래스를 만들어줍니다.
CustomValidator를 사용하기 위해서는 @Override를 통해서 해당 검증 시 어떻게 적용할 것인지에 대한 구체적인 구현이 필요합니다.
해당 코드의 5 ~ 8번 라인을 통해서 전화번호의 패턴에 따라 true와 false값을 반환하는 로직인 것을 알 수 있습니다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = TelephoneValidator.class)
public @interface Telephone {
String message() default "Invalid telephone number";
Class[] groups() default {};
Class[] payload() default {};
}
Telephone @Interface에서는 이전에 작성한 TelephoneValidator를 통해서 검증을 수행하게 됩니다.
@Target 어노테이션은 해당 어노테이션을 어디서 선언할 것인지 정의할 수 있습니다.
@Retention 어노테이션은 해당 어노테이션이 실제로 적용되고 유지되는 범위를 의미합니다.
@Constatint 어노테이션은 이전에 작성한 TelephoneValidator와 매핑하는 작업을 수행합니다.
10.4 예외 처리
애플리케이션을 개발할 때는 불가피하게 많은 오류들이 생기게 됩니다. 자바에서는 이런 오류들을 try~catch, throw를 통해서 처리합니다. 스프링 부트에서는 더욱 편리하게 예외를 처리할 수 있는 기능을 제공합니다.
예외 클래스들은 모두 위의 상속구조를 따르게 됩니다.
해당 구조를 보면 CheckException과 UncheckedException이 있다는 것을 알 수 있습니다.
CheckedException과 UncheckedException의 가장 큰 차이점은 반드시 예외처리를 해야하는가?입니다.
CheckedException은 반드시 예외처리를 해야하지만, UncheckException은 명시적으로 처리를 강제하지는 않습니다.
그 중 0으로 어떤 숫자를 나누게 되면 ArithmeticException이 발생하는 데 해당 예외는 UncheckedException이어서, 실행시점에 예외가 발생하는 것이 아니라, 실행하게 됐을 때, 발생하게 됩니다.
간단히 분류하자면, Runtime Exception을 상속받는 예외들은 UncheckedException, 이 외에는 CheckException입니다.
스프링 부트에서의 예외 처리 방식
웹 서비스 애플리케이션에서는 외부에서 등러오는 요청에 담긴 데이터를 처리하는 경우가 많습니다. 그중 예외가 발생했을 때 클라이언트에 오류 메시지를 전달하려면 각 레이어에서 발생한 예외를 엔트포인트 레벨인 컨트롤러로 전달해야합니다. 이렇게 전달받은 예외를 스프링 부트에서 처리하는 방식으로 크게 2가지가 있습니다.
- @(Rest)ControllerAdvise와 @ExceptionHandler를 통해 모든 컨트롤러의 예외를 처리
- @ExceptionHandler를 통해 특정 컨트롤러의 예외를 처리
해당 클래스 위에 @RestControllerAdvice를 적용시켜서, 컨트롤러에서 발생하는 예외에 대해서 어떻게 컨트롤러로 메시지와 에러 코드, 에러 타입들을 반환할지 명시적으로 선언합니다.
CustomException
애플리케이션을 개발하다보면 점점 예외로 처리할 영역이 늘어나고 다양해지면서 사용하는 예외의 타입도 많아지게 됩니다. 대부분의 상황에서 자바에서 이미 적절한 상황에서 사용할 수 있도록 예외들을 많이 만들어두었습니다. 하지만 왜 커스텀 예외를 만들어야 할까요?
커스텀 예외를 만들어서 사용하면 개발자의 의도를 빠르게 파악할 수 있기 때문에, 이름만 보고도 어떤 예외 상황이 발생했는지 짐작할 수 있습니다. 기존에 있던 예외로 처리해도 되지만, 이 경우에는 어디서 어떻게 예외가 발생했는지 찾기 힘들다는 단점이 존재하기 때문입니다.
또한, 개발자가 예외를 직업 관리하기가 수월해집니다. 보통 사용자에게 에러 상황, 코드, 메시지를 전달할 때, 위의 코드처럼 Map 자료구조에 넣어서 보내는데, Enum Class를 통해서 Type 형식으로 제공한다면, 더욱 편리하고 유지보수 관점에서 용이하다고 생각합니다.
Custom Exception을 만들기 위해서는 기존에 있던 예외 클래스의 상속 구조를 살펴봐야합니다.
하지만 우리가 하려고 하는 것은 실행시점에서 발생하는 예외을 처리하는 것에 더욱 가깝기 때문에 굳이 Throwable을 상속하는 것이 아니라 UncheckedException인 RuntimeException을 상속받아서 만들면 더 간단합니다.
기본적으로 모든 Exception은 필드 변수로 message가 존재하고 생성자에서 해당 message를 선언해줘야하기때문에, 다음과 같이 선언합니다.
@Getter
public class BusinessException extends RuntimeException{
private final ExceptionsType exceptionType;
public BusinessException(ExceptionsType exceptionType){
super(exceptionType.getMessage());
this.exceptionType = exceptionType;
}
}
해당 코드에서는 RuntimeException을 상속받으면서 ExceptionType을 필드 변수로 갖는 BusinessException을 선언했습니다.
@Getter
@AllArgsConstructor
public enum ExceptionsType {
CANNOT_FIND_USER_BY_EMAIL(HttpStatus.BAD_REQUEST,"AU001","해당 이메일을 가지는 유저를 찾을 수 없습니다."),
ALREADY_EXIST_EMAIL(HttpStatus.BAD_REQUEST,"AU002","이미 존재하는 이메일입니다."),
DONT_MATCH_PASSWORD(HttpStatus.BAD_REQUEST,"AU003","비밀번호가 일치하지 않습니다."),
CANNOT_FIND_USER(HttpStatus.BAD_REQUEST,"AU004","해당 유저를 찾을 수 없습니다."),
CANNOT_FIND_STORE(HttpStatus.BAD_REQUEST,"AU005","해당 가게를 찾을 수 없습니다."),
CANNOT_FIND_RESERVATION(HttpStatus.BAD_REQUEST,"AU006","해당 예약을 찾을 수 없습니다."),
;
private final HttpStatus status;
private final String code;
private final String message;
}
ExceptionType은 위와 같이, HttpStatus와 커스텀 에러 코드, 메세지를 갖는 enum class입니다.
이를 통해서 커스텀 예외가 발생했을 때, ExceptionType을 넣어주면 내부적으로 BusinessException을 상속받았기 때문에 HttpStatus와 ErrorCode, message가 사용자에게 전달되게 됩니다.
'책' 카테고리의 다른 글
[Book] 스프링 부트 핵심 가이드 Chap.12 (0) | 2024.08.05 |
---|---|
[Book] 스프링 부트 핵심 가이드 Chap.11 (0) | 2024.08.05 |
[Book] 스프링 부트 핵심 가이드 Chap.9 (1) | 2024.07.21 |
[Book] 스프링 부트 핵심 가이드 Chap.8 (0) | 2024.07.14 |
[Book] 스프링 부트 핵심 가이드 Chap.6 (0) | 2024.07.02 |