본 게시글은 '스프링 부트 핵심 가이드' 책의 내용을 정리한 것입니다.
저자 : 장정우
출판사 : 위키북스
10.1 일반적인 애플리케이션 유효성 검사의 문제점
- 검증 로직이 각 클래스별로 분산돼 있어 관리가 어렵다.
- 검증 로직에 중복이 많아 여러 곳에 유사한 기능의 코드가 존재할 수 있다.
- 검증해야 할 값이 많다면 검증하는 코드가 길어진다.
이러한 문제로 코드가 복잡해지고 가독성이 떨어지게 된다.
이러한 문제 해결을 위해 자바 진영에서는 Bean Validation이라는 데이터 유효성 검사 프레임워크를 제공한다. 유효성 검사를 위한 로직을 DTO 같은 도메인 모델과 묶어서 각 계층에서 사용하면서 검증 자체를 도메인 모델에 얹는 방식으로 수행한다. 또한 어노테이션을 사용한 검증이기에 코드의 간결함도 유지할 수 있다.
10.2 Hibernate Validator
Hibernate Validator는 Bean Validation 명세의 구현체이다. 스프링 부트에서 유효성 검사의 표준으로 채택해서 사용하며, 도메인 모델에서 어노테이션을 통한 필드값 검증을 가능하게 도와준다.
10.3 스프링 부트에서의 유효성 검사
10.3.1 프로젝트 생성
책에서 실습을 위해 구성한 프로젝트 설정은 다음과 같다.
- groupId : com.springboot
- artifactId : valid_exception
- name : valid_exception
- Developer Tools : Lombok, Spring Configuration Processor
- Web : Spring Web
- SQL : Spring Data JPA, MariaDB Driver
+ 7장에서 작성한 자바 파일들
+ SwaggerConfiguration 클래스 생성 & pom.xml에 의존성 추가
10.3.2 스프링 부트용 유효성 검사 관련 의존성 추가
pom.xml에 유효성 검사 라이브러리를 의존성으로 추가한다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
10.3.3 스프링 부트의 유효성 검사
유효성 검사는 각 계층으로 데이터가 넘어오는 시점에 해당 데이터에 대한 검사를 실시한다. 스프링 부트에서는 계층 간 데이터 전송에 대체로 DTO 객체를 활용하고 있기 때문에 DTO 객체를 대상으로 유효성 검사를 수행하는 것이 일반적이다.
유효성 검사 예
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidRequestDto {
@NotBlank
private String name;
@Email
private String email;
@Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
private String phoneNumber;
@Min(value = 20)
@Max(value = 40)
private int age;
@Size(min = 0, max = 40)
private String description;
@Positive
private int count;
@AssertTrue
private boolean booleanCheck;
}
대표적인 어노테이션
- 문자열 검증
- @Null : null 값만 허용
- @NotNull : null을 허용하지 않는다. "", " "는 허용한다.
- @NotEmpty : null, ""을 허용하지 않는다. " "는 허용한다.
- @NotBlank : null, "", " "을 허용하지 않는다.
- 최댓값/최솟값 검증
- BigDecimal, BigInteger, int, long 등의 타입을 지원한다.
- @DemicalMax(value = "$numberString") : $numberString 보다 작은 값을 허용한다.
- @DemicalMin(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 : 이메일 형식을 검사한다. ""는 허용한다.
- 자릿수 범위 검증
- BigDemical, 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 패키지의 컨벤션을 따른다.
생성한 DTO 사용을 위한 컨트롤러 생성
@RestController
@RequestMapping("/validation")
public class ValidationController {
private final Logger LOGGER = LoggerFactory.getLogger(ValidationController.class);
@PostMapping("/valid")
public ResponseEntity<String> checkValidationByValid(
@Valid @RequestBody ValidRequestDto validRequestDto) {
LOGGER.info(validRequestDto.toString());
return ResponseEntity.status(HttpStatus.OK).body(validRequestDto.toString());
}
}
ValidRequestDto 객체를 RequestBody 값으로 받고 있다. @Valid 어노테이션을 지정해야 DTO 객체에 대해 유효성 검사를 수행한다.
Swagger를 통해 제대로 작동하는지 확인한다.
다음과 같이 로그를 확인할 수 있다.
[Field error in object 'validRequestDto' on field 'age': rejected value [15]; codes [Min.validRequestDto.age,Min.age,Min.int,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [validRequestDto.age,age]; arguments []; default message [age],20]; default message [20 이상이어야 합니다]] ]
10.3.4 @Validated 활용
@Valid 어노테이션은 자바에서 지원하는 어노테이션이며, 스프링도 @Validated라는 별도의 어노테이션으로 유효성 검사를 지원한다. @Validated는 @Valid 어노테이션의 기능을 포함하며, 유효성 검사를 그룹으로 묶어 대상을 특정할 수 있는 기능이 있다.
검증그룹은 인터페이스를 생성해서 사용한다. 내부 내용은 없고 그룹화하는 용도로 사용한다.
검증그룹 설정은 DTO 객체에서 한다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidatedRequestDto {
@NotBlank
private String name;
@Email
private String email;
@Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
private String phoneNumber;
@Min(value = 20, groups = ValidationGroup1.class)
@Max(value = 40, groups = ValidationGroup1.class)
private int age;
@Size(min = 0, max = 40)
private String description;
@Positive(groups = ValidationGroup2.class)
private int count;
@AssertTrue
private boolean booleanCheck;
}
groups 속성을 이용해 그룹을 설정한다. 이 설정을 통해 어느 그룹에 맞춰 유효성 검사를 실시할 것인지 지정한다.
실제로 그룹을 어떻게 설정해서 유효성 검사를 실시할지 결정하는 것은 @Validated 어노테이션에서 한다.
@Validated 어노테이션 사용한 컨트롤러 클래스
@RestController
@RequestMapping("/validation")
public class ValidationController {
private final Logger LOGGER = LoggerFactory.getLogger(ValidationController.class);
@PostMapping("/validated")
public ResponseEntity<String> checkValidation(
@Validated @RequestBody ValidatedRequestDto validatedRequestDto) {
LOGGER.info(validatedRequestDto.toString());
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
@PostMapping("/validated/group1")
public ResponseEntity<String> checkValidation1(
@Validated(ValidationGroup1.class) @RequestBody ValidatedRequestDto validatedRequestDto) {
LOGGER.info(validatedRequestDto.toString());
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
@PostMapping("/validated/group2")
public ResponseEntity<String> checkValidation2(
@Validated(ValidationGroup2.class) @RequestBody ValidatedRequestDto validatedRequestDto) {
LOGGER.info(validatedRequestDto.toString());
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
@PostMapping("/validated/all-group")
public ResponseEntity<String> checkValidation3(
@Validated({ValidationGroup1.class,
ValidationGroup2.class}) @RequestBody ValidatedRequestDto validatedRequestDto) {
LOGGER.info(validatedRequestDto.toString());
return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
}
}
checkValidation에는 그룹 지정 없이 사용했고, checkValidation1과 2는 각각 ValidationGroup1과 2를 그룹으로 지정. checkValidation3은 두 그룹을 모두 지정했다.
checkValidation() 메서드를 확인한다.
age와 count가 유효성 검사를 통과하지 못하는 데이터지만 통과한다. 그룹 지정을 안 했기에 groups 속성을 설정하지 않은 필드에 대해서만 유효성 검사를 실시하기 때문이다.
checkValidation1() 메서드를 확인한다.
로그를 확인하면
[Field error in object 'validatedRequestDto' on field 'age': rejected value [15]; codes [Min.validatedRequestDto.age,Min.age,Min.int,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [validatedRequestDto.age,age]; arguments []; default message [age],20]; default message [20 이상이어야 합니다]] ]
ValidationGroup1을 그룹으로 설정한 age에 대해서만 오류가 발생한다.
checkValidation2() 메서드를 확인하면 count에 대해서만 오류가 발생한다.
[Field error in object 'validatedRequestDto' on field 'count': rejected value [-1]; codes [Positive.validatedRequestDto.count,Positive.count,Positive.int,Positive]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [validatedRequestDto.count,count]; arguments []; default message [count]]; default message [0보다 커야 합니다]] ]
checkValidation3() 은 age와 count 둘 다 오류가 발생한다.
with 2 errors: [Field error in object 'validatedRequestDto' on field 'count': rejected value [-1]; codes [Positive.validatedRequestDto.count,Positive.count,Positive.int,Positive]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [validatedRequestDto.count,count]; arguments []; default message [count]]; default message [0보다 커야 합니다]] [Field error in object 'validatedRequestDto' on field 'age': rejected value [15]; codes [Min.validatedRequestDto.age,Min.age,Min.int,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [validatedRequestDto.age,age]; arguments []; default message [age],20]; default message [20 이상이어야 합니다]] ]
결론은 다음과 같다.
- @Validated 어노테이션에 특정 그룹을 설정하지 않은 경우에는 groups가 설정되지 않은 필드에 대해 유효성 검사를 수행
- @Validated 어노테이션에 특정 그룹을 설정하는 경우에는 지정된 그룹으로 설정된 필드에 대해서만 유효성 검사를 수행
10.3.5 커스텀 Validation 추가
자바 또는 스프링에서 제공하지 않는 경우 ConstraintValidator와 커스텀 어노테이션을 조합해서 별도의 유효성 검사 어노테이션을 생성할 수 있다.
전화번호 형식을 확인하는 유효성 검사 어노테이션을 생성하는 예제를 통해 커스텀 Validation 사용을 학습한다.
우선 ConstraintValidator 인터페이스를 구현하는 클래스 생성한다.
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 인터페이스의 구현체로 정의한다. 인터페이스 선언할 때는 어떤 어노테이션 인터페이스인지 타입 지정이 필요하다.
ConstraintValidator 인터페이스는 isValid() 메서드를 정의해야 한다. 메서드에서 직접 유효성 검사 로직을 작성해야 한다. 예시 코드에서는 null 허용 여부와 지정한 정규식과 비교해서 알맞은 형식인지 검사하는 코드를 작성했다.
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
- ElementType.PACKAGE
- ElementType.TYPE
- ElementType.CONSTRUCTOR
- ElementType.FIELD
- ElementType.METHOD
- ElementType.ANNOTAION_TYPE
- ElementType.LOCAL_VARIABLE
- ElementType.PARAMETER
- ElementType.TYPE_PARAMETER
- ElementType.TYPE_USE
@Retention 어노테이션은 이 어노테이션이 실제로 적용되고 유지되는 범위를 의미한다.
- 사용 가능한 RetentionPolicy
- RetentionPolicy.RUNTIME : 컴파일 이후에도 JVM에 의해 계속 참조한다. 리플렉션이나 로깅에 많이 사용되는 정책.
- RetentionPolicy.CLASS : 컴파일러가 클래스를 참조할 때까지 유지한다.
- RetentionPolicy.SOURCE : 컴파일 전까지만 유지한다. 컴파일 이후에는 사라진다.
@Constraint 어노테이션은 앞서 ConstraintValidator를 구현한 클래스와 매핑하는 작업을 수행한다.
인터페이스 내부에는 message(), groups(), payload() 요소를 정의해야 한다.
- message() : 유효성 검사가 실패할 경우 반환되는 메시지를 의미한다.
- groups() : 유효성 검사를 사용하는 그룹으로 설정한다.
- payload() : 사용자가 추가 정보를 위해 전달하는 값
생성한 커스텀 어노테이션을 적용 후 테스트 한다.
@Telephone
private String phoneNumber;
with 2 errors: [Field error in object 'validatedRequestDto' on field 'phoneNumber': rejected value [12345678]; codes [Telephone.validatedRequestDto.phoneNumber,Telephone.phoneNumber,Telephone.java.lang.String,Telephone]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [validatedRequestDto.phoneNumber,phoneNumber]; arguments []; default message [phoneNumber]]; default message [전화번호 형식이 일치하지 않습니다.]] [Field error in object 'validatedRequestDto' on field 'booleanCheck': rejected value [false]; codes [AssertTrue.validatedRequestDto.booleanCheck,AssertTrue.booleanCheck,AssertTrue.boolean,AssertTrue]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [validatedRequestDto.booleanCheck,booleanCheck]; arguments []; default message [booleanCheck]]; default message [true여야 합니다]] ]
checkValidation()으로 테스트 한 결과 전화번호 형식이 맞지 않아서 발생한 오류와 booleanCheck가 false여서 발생한 오류까지 2개의 오류가 발생한 것을 확인할 수 있다.(booleanCheck를 @AssertTrue로 지정했기 때문)
10.4 예외처리
10.4.1 예외와 에러
예외(exception)란 입력 값의 처리가 불가능하거나 참조된 값이 잘못된 경우 등 애플리케이션이 정상적으로 동작하지 못하는 상황을 의미한다. 예외는 개발자가 직접 처리할 수 있는 것이므로 미리 코드 설계를 통해 처리할 수 있다.
에러(error)란 주로 자바의 가상머신에서 발생시키는 것으로 애플리케이션 코드에서 처리할 수 있는 것이 거의 없다. 에러는 발생 시점에 처리하는 것이 아닌 미리 애플리케이션 코드를 살펴보면서 문제가 발생하지 않도록 예방해서 원천적으로 차단해야 한다.
10.4.2 예외 클래스
모든 예외 클래스는 Throwable 클래스를 상속받고, Exception 클래스는 다양한 자식 클래스를 가지고 있다. 이 자식 클래스는 크게 Checked Exception과 UncheckException으로 구분할 수 있다.
RuntimeException을 상속받는 Exception 클래스는 Unchecked Exception이고 그렇지 않은 클래스는 Checked Exception이다.
Checked Exception | Unchecked Exception | |
처리 여부 | 반드시 예외 처리 필요 | 명시적 처리를 강제하지 않음 |
확인 시점 | 컴파일 단계 | 실행 중 단계 |
대표적인 예외 클래스 | IOException SQLException |
RuntimeException NullPointerException IllegalArgumentException IndexOutOfBoundException SystemExceptopn |
Checked Exception은 컴파일 단계에서 확인 가능한 예외사항이고, 이러한 예외는 IDE에서 예외 처리를 할 수 있게 표시해 준다.
Unchecked Exception은 런타임 단계에서 확인되는 예외사항으로, 문법상 문제는 없지만 프로그램이 동작하는 도중 예기치 않은 상황이 생겨 발생하는 예외다.
10.4.3 예외 처리 방법
① 예외 복구 방법
예외 상황을 파악해서 문제를 해결하는 방식이다. 대표적으로 try/catch 구문이 있다.
② 예외 처리를 회피하는 방법
예외가 발생한 시점에서 바로 처리하는 것이 아니라 예외가 발생한 메서드를 호출한 곳에서 에러 처리를 할 수 있게 전가하는 방식이다. throw 키워드를 사용해 어떤 예외가 발생했는지 호출부에 내용을 전달할 수 있다.
int a = 1;
String b = "a";
try{
System.out.println(a+Integer.parseInt(b));
} catch ( NumberFormatException e){
throw new NumberFormatException("숫자가 아닙니다.");
}
③ 예외 전환 방법
호출부로 예외 내용을 전달하면서 좀 더 적합한 예외 타입으로 전달해야 하거나, 예외 처리를 좀 더 단순하게 하기 위해 래핑(wrapping) 해야 할 때, try/catch 방식을 사용하면서 catch 블록에서 throw 키워드를 사용해 다른 예외 타입으로 전달한다.
★ 예외 처리 방법에 대한 참고 예시
10.4.4 스프링 부트의 예외 처리 방식
예외가 발생했을 때 클라이언트에 오류 메시지를 전달하려면 각 레이어에서 발생한 예외를 엔드포인트 레벨인 컨트롤러로 전달해야 한다. 이렇게 전달받는 예외를 스프링 부트에서 처리하는 방식은 다음과 같다.
- @(Rest)ControllerAdvice와 @ExceptionHandler를 통해 모든 컨트롤러의 예외를 처리
- @ExceptionHandler를 통해 특정 컨트롤러의 예외를 처리
@RestControllerAdvice를 활용한 핸들러 클래스를 생성한다.
@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) {
HttpHeaders responseHeaders = new HttpHeaders();
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
LOGGER.error("Advice 내 exceptionHandler 호출, {}, {}", 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);
}
}
@(Rest)ControllerAdvice는 스프링에서 제공하는 어노테이션이다. 이 어노테이션은 @(Rest)Controller 에서 발생하는 예외를 한 곳에서 관리하고 처리할 수 있게 하는 기능을 수행한다.
다음처럼 별도 설정을 통해 예외를 관제하는 범위를 지정할 수 있다.
@RestControllerAdvice(basePackages = "com.springboot.valid_exception")
@ExceptionHandler는 @(Rest)Controller가 적용된 빈에서 발생하는 예외를 잡아 처리하는 메서드를 정의할 때 사용한다. 어떤 예외 클래스를 처리할지는 value 속성으로 등록한다. value 속성은 배열의 형식으로도 전달받을 수 있어 여러 예외 클래스를 등록할 수도 있다.
handleException 메서드 안에는 클라이언트에게 오류가 발생했다는 것을 알리는 응답 메시지를 구성해서 리턴한다. 컨트롤러의 메서드에 다른 타입의 리턴이 설정돼 있어도 핸들러 메서드에서 별도의 리턴 타입을 지정할 수 있다.
실습을 위한 예외 발생 컨트롤러를 생성한 후 Swagger를 통해 호출하여 확인해 본다.
@RestController
@RequestMapping("/exception")
public class ExceptionController {
@GetMapping
public void getRuntimeException() {
throw new RuntimeException("getRuntimeException 메소드 호출");
}
}
[ERROR] [http-nio-8080-exec-8] com.springboot.valid_exception.common.exception.CustomExceptionHandler Advice 내 exceptionHandler 호출, /exception, getRuntimeException 메소드 호출
다음과 같이 로그에 출력되며 Body에 에러 메시지가 담겨 있음을 확인할 수 있다.
handlerException 메서드는 Map객체에 응답할 메시지를 구성하고 ResponseEntity에 HttpHeader, HttpStatus, Body 값을 담아 전달한다.
이처럼 컨트롤러에서 던진 예외는 @(Rest)ControllerAdvice가 선언돼 있는 핸들러 클래스에서 매핑된 예외 타입을 찾아 처리한다. 별도 범위 설정이 없으면 전역 범위에서 예외를 처리하기 때문에 특정 컨트롤러에서만 동작하는 @ExceptionHandler 메서드를 생성해서 처리할 수도 있다.
@RestController
@RequestMapping("/exception")
public class ExceptionController {
private final Logger LOGGER = LoggerFactory.getLogger(ExceptionController.class);
@GetMapping
public void getRuntimeException() {
throw new RuntimeException("getRuntimeException 메소드 호출");
}
@ExceptionHandler(value = RuntimeException.class)
public ResponseEntity<Map<String, String>> handleException(RuntimeException e,
HttpServletRequest request) {
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setContentType(MediaType.APPLICATION_JSON);
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
LOGGER.error("클래스 내 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 어노테이션을 사용해서 메서드를 선언하면 해당 클래스에 국한해서 예외처리를 할 수 있다.
동일한 예외 타입을 처리한다면 우선순위를 비교하여 우선순위가 높은 쪽이 사용된다.
우선순위를 비교하는 방법은
1. 좀 더 구체적인 예외 타입일 경우 우선순위가 높다.
예를 들어 단순히 Exception 클래스보다 NullPointerException이 구체적인 클래스이기에 우선순위가 높다.
2. @ControllerAdvice의 글로벌 예외 처리와 @Controller 내 예외처리에 동일한 타입의 예외처리를 하게 되면 범위가 좁은 컨트롤러 내의 예외처리가 우선순위를 갖게 된다.
10.4.5 커스텀 예외
커스텀 예외를 만들어서 사용하면 네이밍에 개발자의 의도를 담을 수 있게 때문에 이름만으로도 어느 정도 예외 상황을 짐작할 수 있다.
또한 커스텀 예외를 사용하면 애플리케이션에서 발생하는 예외를 개발자가 직접 관리하기가 수월해진다. 커스텀 예외들을 개발자가 직접 코드로 관리하기 때문에 책임 소재를 애플리케이션 내부로 가져올 수 있고, 동일한 예외 상황이 발생할 경우 한 곳에서 처리하며 특정 상황에 맞는 예외 코드를 적용할 수 있게 된다.
커스텀 예외를 사용하면 예외 상황에 대한 처리도 용이하다. 표준 예외를 사용하면 의도하지 않은 예외 상황도 정해진 예외 처리 코드에서 처리하기 때문에 어디에서 문제가 발생했는지 확인하기가 어렵다. 그러나 커스텀 예외로 관리하면 의도하지 않았던 부분에서 발생한 예외는 개발자가 관리하는 예외 처리 코드가 처리하지 않으므로 개발 과정에서 혼동할 여지가 줄어든다.
10.4.6 커스텀 예외 클래스 생성하기
Exception 클래스의 커스텀 예외 클래스 생성을 통해 학습한다.
커스텀 예외 클래스 생성에 앞서 도메인 레벨 표현을 위한 열거형을 생성한다.
public class Constants {
public enum ExceptionClass {
PRODUCT("Product");
private String exceptionClass;
ExceptionClass(String exceptionClass) {
this.exceptionClass = exceptionClass;
}
public String getExceptionClass() {
return exceptionClass;
}
@Override
public String toString() {
return getExceptionClass() + " Exception. ";
}
}
}
해당 열거형 ExceptionClass는 커스텀 예외 클래스에서 메시지 내부에 어떤 도메인에서 문제가 발생했는지 보여주는 데 사용된다.
커스텀 예외 클래스를 다음과 같이 생성한다.
public class CustomException extends Exception{
private Constants.ExceptionClass exceptionClass;
private HttpStatus httpStatus;
public CustomException(Constants.ExceptionClass exceptionClass, HttpStatus httpStatus,
String message) {
super(exceptionClass.toString() + message);
this.exceptionClass = exceptionClass;
this.httpStatus = httpStatus;
}
public Constants.ExceptionClass getExceptionClass() {
return exceptionClass;
}
public int getHttpStatusCode() {
return httpStatus.value();
}
public String getHttpStatusType() {
return httpStatus.getReasonPhrase();
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
}
앞서 생성한 열거형 ExceptionClass와 HttpStatus를 필드로 정의하고 CustomException을 초기화한다.
ExceptionHandler 클래스에 CustomException에 대한 예외 처리 코드를 추가한다.
@ExceptionHandler(value = CustomException.class)
public ResponseEntity<Map<String, String>> handleException(CustomException e,
HttpServletRequest request) {
HttpHeaders responseHeaders = new HttpHeaders();
LOGGER.error("Advice 내 handleException 호출, {}, {}", request.getRequestURI(),
e.getMessage());
Map<String, String> map = new HashMap<>();
map.put("error type", e.getHttpStatusType());
map.put("code", Integer.toString(e.getHttpStatusCode()));
map.put("message", e.getMessage());
return new ResponseEntity<>(map, responseHeaders, e.getHttpStatus());
}
기존 핸들러 메서드와 달리 예외 발생 시점에 HttpStatus를 정의해서 전달하기에 클라이언트 요청에 따라 유동적인 응답 코드를 설정할 수 있다.
@GetMapping("/custom")
public void getCustomException() throws CustomException {
throw new CustomException(ExceptionClass.PRODUCT, HttpStatus.BAD_REQUEST, "getCustomException 메소드 호출");
}
컨트롤러에서 CustomException을 발생시키면 ExceptionClass에서 도메인을 비롯해 HttpStatus를 통해 어떤 응답 코드를 사용할지와 세부 메시지를 전달한다.
Swagger에 출력된 응답 내용과 로그를 확인하면 설정한 커스텀 예외가 제대로 작동하는 것을 확인할 수 있다.
[ERROR] [http-nio-8080-exec-8] com.springboot.valid_exception.common.exception.CustomExceptionHandler Advice 내 handleException 호출, /exception/custom, Product Exception. getCustomException 메소드 호출
'개발 지식 기록 > 북스터디' 카테고리의 다른 글
[스프링 부트 핵심 가이드] 12. 서버 간 통신 (0) | 2023.10.08 |
---|---|
[스프링 부트 핵심 가이드] 11. 액추에이터 활용하기 (0) | 2023.10.08 |
[스프링 부트 핵심 가이드] 09. 연관관계 매핑 (0) | 2023.09.24 |
[스프링 부트 핵심 가이드] 08. Spring Data JPA 활용 (0) | 2023.09.17 |
[스프링 부트 핵심 가이드] 06. 데이터 베이스 연동 (0) | 2023.09.10 |