프로그래밍에서 예외 처리는 매우 중요하다. 상세한 예외처리는 더욱 안정적인 프로그램을 만든다.
따라서 우리의 코드에는 수많은 예외처리 코드가 생기게 된다.
결국 비즈니스 로직에 집중하기 어렵고 무엇보다도 유지보수하기 아주 어려워진다.
이런 문제를 개선하기 위해 스프링에서는 `@ControllerAdvice` 어노테이션을 사용해 컨트롤러에서 발생하는 예외를 중앙 집중식으로 처리하고 공통된 예외 처리 로직을 적용할 수 있다.
결론부터 말하자면, 아래와 같이 Exception Response를 구성했다.
- `status`: HttpStatus 코드
- `description`: 예외 상황에 대한 설명
- `code`: 개발자용 내부 코드
- `timestamp`: 예외가 발생한 시각
Handling Exception with ControllerAdvice
1. 요청이 특정 컨트롤러 메서드에서 처리되는 동안 예외(`Exception1`)가 발생한다.
2. 익셉션이 발생한 컨트롤러 내부에 `@ExceptionHandler(Exception1.class)`이 적용된 메서드가 있는지 확인한다.
- 존재한다면 해당 메서드가 호출되어 익셉션을 처리한다.
3. 존재하지 않으면, @ControllerAdvice 클래스 내부에 `@ExceptionHandler(Exception1.class)`이 적용된 메서드가 있는지 확인한다.
- 존재한다면 해당 메서드가 호출되어 익셉션을 처리한다.
4. 존재하지 않으면, 발생한 예외(`Exception1`)에 아래와 같이 `@ResponseStatus`로 응답 상태 코드가 설정되어 있는지 확인한다.
- 설정되어 있다면 ResponseStatusExceptionResolver가 어노테이션을 감지하고 지정된 상태 코드로 응답을 생성한다.
- 설정되어 있지 않다면 DefaultHandlerExceptionResolver가 기본적으로 500 Internal Server Error 상태코드로 응답을 생성한다.
@ResponseStatus(HttpStatus.NOT_FOUND) // 해당 예외가 발생했을 때 HTTP 404 NOT FOUND 상태 코드를 반환
public class Exception1 extends RuntimeException {
// ...
}
💫 즉, 익셉션이 발생한 컨트롤러 내부에 `@ExceptionHandler`이 적용된 메서드가 없다면 @ControllerAdvice 클래스 내부에서 처리하기 때문에 @ControllerAdvice를 통해 여러 컨트롤러에서 발생하는 특정 예외를 일관된 방식으로 처리할 수 있는 것이다.
Implementation
1. ErrorCode 인터페이스 정의하기
나중에 AuthErrorCode, MemberErrorCode, PetErrorCode 등 도메인 별로 ErrorCode를 구현할 것이기 때문에 ErrorCode를 인터페이스로 선언해준다.
public interface ErrorCode {
int getCode();
String getDescription();
HttpStatus getHttpStatus();
}
AuthErrorCode - 인증과 관련된 에러 코드
@Getter
@AllArgsConstructor
public enum AuthErrorCode implements ErrorCode {
REFRESH_TOKEN_NOT_FOUND(1000, "존재하지 않는 리프레시 토큰입니다", UNAUTHORIZED),
REFRESH_TOKEN_EXPIRED(1001, "만료된 리프레시 토큰입니다", UNAUTHORIZED),
ACCESS_TOKEN_INVALID(1002, "유효하지 않은 엑세스 토큰입니다", UNAUTHORIZED);
private final int code;
private final String description;
private final HttpStatus httpStatus;
}
MemberErrorCode - 회원과 관련된 에러 코드
@Getter
@AllArgsConstructor
public enum MemberErrorCode implements ErrorCode {
MEMBER_NOT_FOUND(2000, "해당 유저 정보를 찾을 수 없습니다", NOT_FOUND),
CONFLICT_NICKNAME(2001, "이미 사용중인 nickname 입니다", CONFLICT),
SOCIAL_LOGIN_ERROR(2002, "소셜 로그인에 실패했습니다.", INTERNAL_SERVER_ERROR);
private final int code;
private final String description;
private final HttpStatus httpStatus;
}
등등.. 도메인에 따라 필요한 ErrorCode를 정의한다.
🙋♀️ 하나의 enum 클래스에서 모든 도메인에 필요한 ErrorCode를 정의해도 무방하지만, 각 도메인에 대한 에러 상황이 늘어날수록 가독성이 좋지 않음을 느꼈기 때문에 도메인별 ErrorCode를 분리하기로 했다.
2. BusinessException 정의하기
BusinessException을 정의함으로써 모든 비즈니스 예외가 ErrorCode와 관련된 메시지를 가지게 할 수 있다.
@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getDescription());
this.errorCode = errorCode;
}
}
즉, 예외 처리를 단순화하고 표준화하여 아래처럼 일관된 방식으로 예외를 처리할 수 있다.
Member owner = memberRepository.findByProviderId(username)
.orElseThrow(() -> new BusinessException(MemberErrorCode.MEMBER_NOT_FOUND));
private void validateAge(final Integer age) {
if (age < 1 || age > 100) {
throw new BusinessException(PetErrorCode.PET_AGE_INVALID);
}
}
3. ErrorResponse 정의하기
ErrorResponse는 클라이언트에게 제공할 응답으로, `toResponseEntity()` 메서드를 통해 ErrorCode에 담긴 정보를 조립하여 ResponseEntity 객체를 생성한다.
@Getter
@Builder
public class ErrorResponse {
private final int status;
private final String description;
private final int code;
private final LocalDateTime timestamp;
public static ResponseEntity<ErrorResponse> toResponseEntity(ErrorCode errorCode) {
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ErrorResponse.builder()
.status(errorCode.getHttpStatus().value())
.description(errorCode.getDescription())
.code(errorCode.getCode())
.timestamp(now())
.build()
);
}
}
4. RestControllerAdvice 정의하기
전역의 컨트롤러에서 RestControllerAdvice 내 `@ExceptionHandler`에 설정된 익셉션이 발생할 경우, 해당하는 로직을 실행한다.
나는 `@ExceptionHandler`를 사용해 직접 정의한 BusinessException이 발생하는 경우와 일반적인 Exception이 발생하는 경우를 분리하였다.
-> BusinessException이 발생하는 경우에는 해당 익셉션 안에 있는 `ErrorCode` 객체를 활용하여 ErrorResponse를 구성하고, 그게 아닌 경우는 INTERNAL_SERVER_ERROR로 ErrorResponse를 구성한다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleCustomException(BusinessException ex) {
return ErrorResponse.toResponseEntity(ex.getErrorCode());
}
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleGlobalException(Exception ex) {
return ErrorResponse.toResponseEntity(ex.getLocalizedMessage());
}
}
참고 자료
Handling Exceptions with RestControllerAdvice, ExceptionHandler
@ControllerAdvice, @ExceptionHandler를 이용한 예외처리 분리, 통합하기
'✍️ 개발 기록' 카테고리의 다른 글
[🎵MIML] H2 DB를 이용하여 Mocking 없이 테스트 하기 (0) | 2023.11.10 |
---|---|
[🎵MIML] 친구관계 구현하기 🤝 (0) | 2023.11.03 |
[🎵MIML] 서브모듈(Submodule)을 사용하여 개발 효율 개선하기 (0) | 2023.10.23 |
[🎵MIML] 리다이렉트 지옥 탈출한 썰..😵💫 (with. SessionCreationPolicy) (0) | 2023.10.20 |
[🎵MIML] Spotify 소셜 로그인 하기 (0) | 2023.10.04 |