[Spring Boot] 사용자 정의 예외처리 : Exception Handler
이번 포스팅에서는 Spring Boot에서 예외가 발생했을 때, 이를 처리하기 위한 계층을 정의해서 만들어보려고 한다.
Exception Handler 정의의 필요성
@Override
@Transactional
public Long addMember(MemberJoinRequestDto inputMember) {
Member exist = memberJpaRepository.findMemberByEmail(inputMember.getEmail());
if(exist != null){
throw new AlreadyExistElementException("이미 존재하는 이메일입니다.");
}
//...
}
다음과 같은 Exception이 발생했다고 가정해보자. 아래는 Handler가 있는 경우와 없는 경우이다.
Exception Handler는 애플리케이션에서 예외가 발생했을 때 이를 적절하게 처리하고, 사용자에게 적절한 응답을 제공하기 위해 필요하다.
애플리케이션 전역에서 통일된 형식의 오류 응답을 생성할 수 있기 때문에 클라이언트에게 일관된 방식으로 오류를 전달하고 처리할 수 있게 해준다. 또한 에러 발생 시의 응답을 조정할 수 있으므로 디폴트로 제공되는 예외의 민감한 정보가 노출되는 것을 방지할 수 있다.
Error Response 타입 정의
@RequiredArgsConstructor
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponseDto {
private final int status;
private final String message;
private final LocalDateTime time;
private String stackTrace;
private List<ValidationError> validErrors;
@Data
@RequiredArgsConstructor
private static class ValidationError {
private final String field;
private final String message;
}
public void addValidationError(String field, String message){
if(Objects.isNull(validErrors)){
validErrors = new ArrayList<>();
}
validErrors.add(new ValidationError(field, message));
}
}
예외 발생 시 사용자 응답으로 줄 Response 객체를 생성하였다.
HTTP Status, Message, Timestamp가 기본적으로 포함될 것이다.
StackTrace는 백엔드의 정보를 노출시키는 민감 정보이므로, 백엔드 및 프론트엔드의 개발 단계에서만 반환 가능하도록 처리할 예정이다.
validErrors에는, 만약 ValidationException이 발생했을 시, Request 데이터의 Validation에 실패한 리스트들을 모두 넣어줄 것이다.
Global Exception Handler 작성
@Slf4j(topic = "GLOBAL_EXCEPTION_HANDLER")
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
public static final String TRACE = "trace";
@Value("${error.printStackTrace}")
private boolean printStackTrace;
@Override
protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatusCode statusCode, WebRequest request) {
return buildErrorResponse(ex, ex.getMessage(), HttpStatus.valueOf(statusCode.value()), request);
}
private ResponseEntity<Object> buildErrorResponse(Exception exception,
String message,
HttpStatus httpStatus,
WebRequest request) {
ErrorResponseDto errorResponseDto = new ErrorResponseDto(httpStatus.value(), message, LocalDateTime.now());
if (printStackTrace && isTraceOn(request)) {
errorResponseDto.setStackTrace(ExceptionUtils.getStackTrace(exception));
}
return ResponseEntity.status(httpStatus).body(errorResponseDto);
}
private boolean isTraceOn(WebRequest request) {
String[] value = request.getParameterValues(TRACE);
return Objects.nonNull(value)
&& value.length > 0
&& value[0].contentEquals("true");
}
// 500 Uncaught Exception
@ExceptionHandler(Exception.class)
@Hidden
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseEntity<Object> handleAllUncaughtException(Exception exception, WebRequest request) {
log.error("Internal error occurred", exception);
return buildErrorResponse(exception, exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR, request);
}
}
기본적인 예외 처리 핸들러를 작성하였다.
@RestControllerAdvice 어노테이션은 클래스가 전역 컨트롤러 예외 핸들러로 사용되게 한다. 이제 애플리케이션 전체에서 발생하는 예외에 대한 처리를 담당하게 될 것이다.
buildErrorResponse : 위에서 정의한 커스텀 예외 DTO의 형태로 응답을 생성하기 위한 메서드를 작성하였다.
handleExceptionInternal : ResponseEntityExceptionHandler에서 상속한 메서드로, Spring 내부에서 예외가 발생했을 때 호출된다. buildErrorResponse를 통해 예외를 처리하도록 작성해주었다.
StackTrace의 경우, application.yml에 정의한 error.printStackTrace가 허용(True)로 설정되어 있고, 요청에 쿼리 파라미터로 trace=true가 들어왔을 때만 반환하도록 설정하였다. 따라서 백엔드에서 제어를 받고, 프론트개발자가 Trace를 받기를 원하면 응답에 포함시키게 된다. 실제 라이브 상황에서는 노출시키는 것은 보안상 위험하므로, 개발 단계에서 사용할 수 있도록 만든 것이다.
모든 예외에 대한 Handler
@Slf4j(topic = "GLOBAL_EXCEPTION_HANDLER")
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
// ...
// 500 Uncaught Exception
@ExceptionHandler(Exception.class)
@Hidden
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseEntity<Object> handleAllUncaughtException(Exception exception, WebRequest request) {
log.error("Internal error occurred", exception);
return buildErrorResponse(exception, exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR, request);
}
}
모든 예외에 대한 Handler를 추가해주었다.
앞으로 해당 클래스에 다양한 예외 타입들에 대한 처리 핸들러를 추가해 줄 것인데, 만약 정의되어 있지 않은 Excpetion Type이라면 해당 핸들러를 통해 처리될 것이다.
해당 Handler는 예상치 못한 예외들을 Http Status 500으로 처리할 것인데, 해당 핸들러만 가지고는 403 Error를 Throw하더라도 500으로 처리하게 된다. 따라서 예외 타입을 추가로 정의하여 이제 핸들러에 추가시켜주어야 한다.
내장 Exception 타입 Handler 추가
@Slf4j(topic = "GLOBAL_EXCEPTION_HANDLER")
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
//...
// 403 Access Denied Exception
@ExceptionHandler(AccessDeniedException.class)
@Hidden
@ResponseStatus(HttpStatus.FORBIDDEN) // 403 Forbidden
public ResponseEntity<Object> handleAccessDeniedException(AccessDeniedException exception, WebRequest request) {
log.error("Access denied", exception);
return buildErrorResponse(exception, "Access denied", HttpStatus.FORBIDDEN, request);
}
// 500 Uncaught Exception
@ExceptionHandler(Exception.class)
@Hidden
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseEntity<Object> handleAllUncaughtException(Exception exception, WebRequest request) {
log.error("Internal error occurred", exception);
return buildErrorResponse(exception, exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR, request);
}
}
403 Error에 해당하는 AccessDeniedError를 throw되었을 때, 처리하는 핸들러를 추가하였다.
이렇게 되면 해당 예외에 대해서는 방금 작성한 Uncaugh Exception Handler가 처리하지 않고 해당 핸들러가 처리를 담당하게 된다.
이 때, 응답의 Status는 403으로 설정해주고, 응답 Dto를 생성해서 반환해주었다.
Validation Error Handler 추가
@Slf4j(topic = "GLOBAL_EXCEPTION_HANDLER")
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
//...
// 412 Validate Exception
@Override
@Hidden
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ErrorResponseDto errorResponseDto = new ErrorResponseDto(HttpStatus.UNPROCESSABLE_ENTITY.value(), "Validation error. Check 'errors' field for details.", LocalDateTime.now());
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
errorResponseDto.addValidationError(fieldError.getField(), fieldError.getDefaultMessage());
}
return ResponseEntity.unprocessableEntity().body(errorResponseDto);
}
//...
// 500 Uncaught Exception
@ExceptionHandler(Exception.class)
@Hidden
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseEntity<Object> handleAllUncaughtException(Exception exception, WebRequest request) {
log.error("Internal error occurred", exception);
return buildErrorResponse(exception, exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR, request);
}
}
해당 메서드는 ExceptionHandler의 메서드를 오버라이드하고 있으며. MethodArgumentNotValidException을 처리하는 데 특화된 동작을 정의한다.
@Data
@AllArgsConstructor
public class MemberJoinRequestDto {
@NotBlank(message = "사용자 이메일을 입력해주세요.")
@Email(message = "이메일 형식에 맞지 않습니다.")
private String email;
@NotBlank(message = "사용자 이름을 입력해주세요.")
@Size(min = 3, max = 15, message = "사용자 이름은 15글자 이하로 입력해야 합니다.")
private String name;
@NotBlank
@Pattern(regexp="(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}",
message = "비밀번호는 영문 대,소문자와 숫자, 특수기호가 적어도 1개 이상씩 포함된 8자 ~ 20자의 비밀번호여야 합니다.")
private String password;
}
해당 예외는 주로 위와 같은 DTO에 대한 유효성 검사에서 실패했을 때 발생하며, @Valid 어노테이션을 사용하여 유효성 검사를 수행한 경우에 발생한다.
이에 따른 처리를 위해 핸들러를 추가해주었으며 FieldError들을 가져올 수 있어, 이를 응답에 포함시켜서 어느 부분에서 유효성 검사를 실패했는지를 클라이언트에게 응답으로 제공해 주었다.
사용자 정의 예외처리
public class AlreadyExistElementException extends RuntimeException{
public AlreadyExistElementException(String message) {
super(message);
}
}
@Override
@Transactional
public Long addMember(MemberJoinRequestDto inputMember) {
Member exist = memberJpaRepository.findMemberByEmail(inputMember.getEmail());
if(exist != null){
throw new AlreadyExistElementException("이미 존재하는 이메일입니다.");
}
//...
}
만약 위와 같이 사용자 정의 Exception을 만들어서 사용한다면, 해당 Exception에 대한 예외 처리 핸들러도 달아 줄 수 있다.
@Slf4j(topic = "GLOBAL_EXCEPTION_HANDLER")
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
//...
// 409 AlreadyExistElementException
@ExceptionHandler(AlreadyExistElementException.class)
@Hidden
@ResponseStatus(HttpStatus.CONFLICT)
public ResponseEntity<Object> handleAlreadyExistElementException(AlreadyExistElementException alreadyExistElementException, WebRequest request){
log.error("Failed to element is already exist", alreadyExistElementException);
return buildErrorResponse(alreadyExistElementException, alreadyExistElementException.getMessage(), HttpStatus.CONFLICT, request);
}
// 500 Uncaught Exception
@ExceptionHandler(Exception.class)
@Hidden
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseEntity<Object> handleAllUncaughtException(Exception exception, WebRequest request) {
log.error("Internal error occurred", exception);
return buildErrorResponse(exception, exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR, request);
}
}
마찬가지로 해당 사용자 정의 예외에 대해서 처리하는 핸들러를 추가해주었다.
전체 코드
@Slf4j(topic = "GLOBAL_EXCEPTION_HANDLER")
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
public static final String TRACE = "trace";
@Value("${error.printStackTrace}")
private boolean printStackTrace;
@Override
protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatusCode statusCode, WebRequest request) {
return buildErrorResponse(ex, ex.getMessage(), HttpStatus.valueOf(statusCode.value()), request);
}
private ResponseEntity<Object> buildErrorResponse(Exception exception,
String message,
HttpStatus httpStatus,
WebRequest request) {
ErrorResponseDto errorResponseDto = new ErrorResponseDto(httpStatus.value(), message, LocalDateTime.now());
if (printStackTrace && isTraceOn(request)) {
errorResponseDto.setStackTrace(ExceptionUtils.getStackTrace(exception));
}
return ResponseEntity.status(httpStatus).body(errorResponseDto);
}
private boolean isTraceOn(WebRequest request) {
String[] value = request.getParameterValues(TRACE);
return Objects.nonNull(value)
&& value.length > 0
&& value[0].contentEquals("true");
}
// 412 Validate Exception
@Override
@Hidden
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ErrorResponseDto errorResponseDto = new ErrorResponseDto(HttpStatus.UNPROCESSABLE_ENTITY.value(), "Validation error. Check 'errors' field for details.", LocalDateTime.now());
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
errorResponseDto.addValidationError(fieldError.getField(), fieldError.getDefaultMessage());
}
return ResponseEntity.unprocessableEntity().body(errorResponseDto);
}
// 403 Access Denied Exception
@ExceptionHandler(AccessDeniedException.class)
@Hidden
@ResponseStatus(HttpStatus.FORBIDDEN) // 403 Forbidden
public ResponseEntity<Object> handleAccessDeniedException(AccessDeniedException exception, WebRequest request) {
log.error("Access denied", exception);
return buildErrorResponse(exception, "Access denied", HttpStatus.FORBIDDEN, request);
}
// 409 AlreadyExistElementException
@ExceptionHandler(AlreadyExistElementException.class)
@Hidden
@ResponseStatus(HttpStatus.CONFLICT)
public ResponseEntity<Object> handleAlreadyExistElementException(AlreadyExistElementException alreadyExistElementException, WebRequest request){
log.error("Failed to element is already exist", alreadyExistElementException);
return buildErrorResponse(alreadyExistElementException, alreadyExistElementException.getMessage(), HttpStatus.CONFLICT, request);
}
/** 필요시 ExceptionHandler 추가 - 예상가는 오류 있다면 전부 ExceptionHandler 이용해 처리. **/
// 500 Uncaught Exception
@ExceptionHandler(Exception.class)
@Hidden
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseEntity<Object> handleAllUncaughtException(Exception exception, WebRequest request) {
log.error("Internal error occurred", exception);
return buildErrorResponse(exception, exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR, request);
}
}
핸들러에 의해 나온 응답
'Backend > Spring' 카테고리의 다른 글
[Spring Boot] Spring Security : JWT Auth (SpringBoot 3 버전) (9) | 2024.01.15 |
---|---|
[Spring Boot] Swagger API Docs 작성하기 (SpringDoc, SpringBoot 3 버전) (0) | 2024.01.15 |
[Spring Boot/JPA] Spring data JPA : JpaRepository 사용 (1) | 2024.01.15 |
[Spring Boot/JPA] JPQL : ToMany 관계의 컬렉션 조회 최적화 (1) | 2024.01.09 |
[Spring Boot/JPA] JPQL : 지연 로딩과 N+1 문제 해결 (1) | 2024.01.08 |