개발을 하다 보면 예상 못한 예외가 발생하거나, 또는 예상 가능한 예외를 처리해줘야 하는 경우가 있다.
이처럼 여러 상황에서 발생할 수 있는 예외를 나만의 방식으로 처리하고 이를 response 형태로 반환하는 방법을 알아보자.
ControllerAdvice
@ControllerAdvice는 @Controller가 붙은 클래스, 즉 컨트롤러 전역에서 발생할 수 있는 예외를 잡아 처리해주는 애노테이션이다.
만약 서비스 로직에서 예외가 발생해도 상위 메서드를 타고 올라가면서 이를 호출한 컨트롤러 계층까지 예외가 넘어가게 되는데,
개발자가 해당 예외를 따로 처리해주지 않으면 인터셉터, 필터를 거쳐 WAS 영역까지 가서 예외를 터뜨리게 된다.
@ControllerAdvice는 컨트롤러에서 발생한 예외를 붙잡아 개발자가 임의적으로 처리하여 다양한 예외에 대해 대응하고 원하는 response를 반환할 수 있게 해준다.
다음 예시를 보자.
<ControllerAdvice>
@Slf4j
@ControllerAdvice
public class ControllerAdvice {
@ExceptionHandler(Exception.class)
protected String handleException(Exception e) {
log.error("handleException", e);
return "exception";
}
}
예외(Exception.class)가 발생했을 때, @ExceptionHandler에서 이를 붙잡아 "exception"이라는 문자열을 반환하도록 설정했다.
지금은 간단한 예시를 위해 Exception(최상위 예외 클래스)를 핸들링하게 설정했는데, 상황에 따라 원하는 예외들을 자유롭게 추가할 수 있 다.
에러를 처리하기 위한 response 설정
이제 ControllerAdvice를 활용해 에러를 처리하기 위한 response를 설정해보자.
ErrorCode
먼저 ErrorCode enum 클래스를 생성한다.
<ErrorCode>
@Getter
@AllArgsConstructor
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum ErrorCode {
ENTITY_NOT_FOUND("NURI0001", HttpStatus.NOT_FOUND, "Entity not found in database")
SERVER_ERROR("NURI9999", HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error")
private final String code;
private final HttpStatus status;
private final String error;
}
ErrorCode는 사용자 정의 예외를 편리하게 설정하기 위해 만든 클래스이다.
예를 들어 비즈니스 로직을 처리하는 중, 데이터베이스에 특정 엔티티가 존재하지 않다면, ENTITY_NOT_FOUND와 같은 예외를 지정할 수 있다.
ErrorResponse
ErrorCode의 정보를 통해 에러를 처리하기 위한 response 형식을 지정한 클래스이다.
<ErrorResponse>
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {
@Builder.Default
private LocalDateTime timeStamp = LocalDateTime.now();
private String code;
private HttpStatus status;
private String error;
private String message;
private String path;
public static ErrorResponse of(ErrorCode errorCode, String message){
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
return new ErrorResponse(LocalDateTime.now(), errorCode.getCode(), errorCode.getStatus(), errorCode.getError(), message, request.getServletPath());
}
}
BusinessException
이미 스프링에 내장된 예외 말고 커스텀한 예외를 지정하는 방법을 알아보자.
여기서는 비즈니스 로직에 대한 예외를 처리하기 위한 예외 클래스를 하나 만들었다.
<BusinessException>
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(String message, ErrorCode errorCode) {
super(message);
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
BusinessException은 비즈니스 로직에 대한 모든 예외를 관리하는 클래스로,
특정 예외 상황을 관리하고 싶다면 해당 클래스를 상속하면 된다.
<EntityNotFoundExecption>
public class EntityNotFoundException extends BusinessException {
public EntityNotFoundException(String message) {
super(message, ErrorCode.ENTITY_NOT_FOUND);
}
}
이제 비즈니스 로직의 특정 상황에서 예외를 터뜨리면 된다.
Account account = accountRepository.findByAccountNumber(accountNumber)
.orElseThrow(() -> new EntityNotFoundException("[" + accountNumber + "] 해당 계좌를 찾을 수 없습니다."));
이제 ControllerAdvice에 해당 예외를 추가해보자.
<ControllerAdvice>
@Slf4j
@ControllerAdvice
public class ControllerAdvice {
/**
* 비즈니스 요구사항에 따른 Exception
*/
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
log.error("businessException", e);
return new ResponseEntity<>(ErrorResponse.of(e.getErrorCode(), e.getMessage()), HttpStatus.valueOf(e.getErrorCode().getStatus()));
}
/**
* 그 밖에 발생하는 모든 예외 처리
* 직접 핸들링해서 다른 예외로 던지지 않으면 모두 이곳으로 모인다.
*/
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("handleException", e);
return new ResponseEntity<>(ErrorResponse.of(ErrorCode.SERVER_ERROR, e.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
실제로 EntityNotFoundException이 발생했을 때 response는 다음과 같다.
{
"timeStamp": "2023-02-27T14:10:29.185178",
"code": "NURI0001",
"status": "NOT_FOUND",
"error": "Entity not found in database",
"message": "[333333000000] 해당 계좌번호에 대한 account가 존재하지 않습니다.",
"path": "/v1/api/account/balance/get"
}
지금까지 ControllerAdvice를 통해 예외를 처리하는 간단한 예시를 알아봤다.
내가 실제로 사용하고 있는 ControllerAdvice는 다음과 같다.
<ControllerAdvice>
@Slf4j
@RestControllerAdvice
public class ControllerAdvice {
/**
* '@RequestParam, @ModelAttribute, @RequestBody 에서 binding 하지 못할 경우 발생
* '@Validated, @Valid 등 Spring validation 에 걸리는 경우
*/
@ExceptionHandler(BindException.class)
protected ResponseEntity<ErrorResponse> handleBindException(BindException e) {
log.error("handleBindException", e);
return new ResponseEntity<>(ErrorResponse.of(ErrorCode.REQUEST_BIND_ERROR, e.getBindingResult()), ErrorCode.REQUEST_BIND_ERROR.getStatus());
}
/**
* '@RequestParam, @ModelAttribute, @RequestBody 에서 binding 하지 못할 경우 발생
* 데이터 타입 match 에 실패하는 경우
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
protected ResponseEntity<ErrorResponse> handleMethodHttpMessageNotReadableException(HttpMessageNotReadableException e) {
log.error("handleHttpMessageNotReadableException", e);
return new ResponseEntity<>(ErrorResponse.of(ErrorCode.REQUEST_TYPE_ERROR, e.getMessage()), ErrorCode.REQUEST_TYPE_ERROR.getStatus());
}
/**
* '@RequestHeader 에 값이 들어오지 않는 경우 발생
*/
@ExceptionHandler(MissingRequestHeaderException.class)
protected ResponseEntity<ErrorResponse> handleMissingRequestHeaderException(MissingRequestHeaderException e) {
log.error("handleMissingRequestHeaderException", e);
return new ResponseEntity<>(ErrorResponse.of(ErrorCode.REQUEST_HEADER_NOT_FOUND, e.getMessage()), ErrorCode.REQUEST_HEADER_NOT_FOUND.getStatus());
}
/**
* 지원하지 않은 HTTP method를 호출 할 경우 발생한다.
* 컨트롤러에서 매칭되는 url이 없기 때문에 path를 알 수 없음.
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
protected ResponseEntity<ErrorResponse> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
log.error("handleHttpRequestMethodNotSupportedException", e);
return new ResponseEntity<>(ErrorResponse.of(ErrorCode.METHOD_NOT_FOUND, e.getMessage()), ErrorCode.METHOD_NOT_FOUND.getStatus());
}
/**
* 비즈니스 요구사항에 따른 Exception
*/
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
log.error("businessException", e);
return new ResponseEntity<>(ErrorResponse.of(e.getErrorCode(), e.getMessage()), e.getErrorCode().getStatus());
}
/**
* 그 밖에 발생하는 모든 예외 처리
* 직접 핸들링해서 다른 예외로 던지지 않으면 모두 이곳으로 모인다.
*/
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("handleException", e);
return new ResponseEntity<>(ErrorResponse.of(ErrorCode.SERVER_ERROR, e.getMessage()), ErrorCode.SERVER_ERROR.getStatus());
}
}
<ErrorResponse>
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {
@Builder.Default
private LocalDateTime timeStamp = LocalDateTime.now();
private String code;
private HttpStatus status;
private String error;
private String message;
private String path;
public static ErrorResponse of(ErrorCode errorCode, String message){
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
return new ErrorResponse(LocalDateTime.now(), errorCode.getCode(), errorCode.getStatus(), errorCode.getError(), message, request.getServletPath());
}
public static ErrorResponse of(ErrorCode errorCode, BindingResult bindingResult)
StringBuilder builder = new StringBuilder();
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
for (FieldError fieldError : fieldErrors) {
builder.append("[");
builder.append(fieldError.getField());
builder.append("](은)는 ");
builder.append(fieldError.getDefaultMessage());
builder.append(" 입력된 값: [");
builder.append(fieldError.getRejectedValue());
builder.append("]");
}
return of(errorCode, builder.toString());
}
}
BusinessException 뿐만 아니라, request body나 header에 데이터가 잘못 파싱되는 경우 등을 추가로 고려했다.
'java > spring' 카테고리의 다른 글
[Spring] Pointcut 유형에 따라 Proxy 생성 방식이 달라진다? (CGLIB or JDK Proxy) (0) | 2024.11.02 |
---|---|
[Spring] junit test에서 lombok 사용하는 방법 (0) | 2024.10.16 |
[Spring] @SpringBootTest vs @DataJpaTest (0) | 2023.07.15 |
[SpringMVC] 타임리프 - 기본기능 (0) | 2023.07.08 |
[Spring] DTO의 사용 범위 (0) | 2023.06.27 |