프로그램을 개발하다 보면, 예외를 던지는 상황이 생긴다. try, catch, finally를 사용해 이 예외를 다룰 수 있다. 하지만, 이는 프로젝트 규모가 커지면 코드 중복, 리소스 낭비 등으로 이어진다. Spring Boot에서는 이 예외를 어떻게 다룰까?
우선, Spring Boot는 실행 중 발생한 예외는 기본적으로 처리해 준다.
하지만, 이런 Response는 프론트엔드 입장에서 알아보기 어렵다.
회원가입 시 이름, 이메일, 전화번호를 POST로 우리 서버에 보내는 상황에서 HTTP 400 만으로는 이름이 잘못되었는지, 전화번호가 잘못되었는지 알 수 없다. (Message를 사용하긴 하지만, 프론트의 코드로 Message를 구별하긴 힘들다. 특히 규모가 커질수록…)
이런 문제가 생기지 않도록 Spring Boot 개발자는 예외를 커스텀해서 Handle할 수 있다.
Spring은 전역적으로 ExceptionHandler를 적용할 수 있는 @ControllerAdvice와 @RestControllerAdvice 어노테이션을 제공한다.
@ControllerAdivce는 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용해 준다. 다음과 같이 에러를 핸들링하는 클래스를 만들어 어노테이션을 붙여주면 에러 처리를 위임할 수 있다. MVC 패턴을 사용할 때 적합하다.
- 만약 특정 클래스에만 제한적으로 적용하고 싶다면, @RestControllerAdvice의 basePackages 등을 설정함으로써 제한할 수 있다
@RestControllerAdvice는 @ControllerAdvice와 달리 @ResponseBody가 붙어 있어 응답을 Json으로 내려준다. Restful API를 사용할 때 적합하다.
RestController와 Controller의 차이라고 생각하면 될 것 같다.
우리는 모든 응답에 대해 JSON을 반환하는 Restful API 서버를 만들 것이므로, @RestControllerAdvice를 사용한다.
@RestControllerAdvice, @ControllerAdvice를 사용했을 때의 장점은 무엇이 있을까?
1. Controller에서 예외를 직접 처리하지 않아도 된다.
- 예외가 발생하면 @RestControllerAdvice, @ControllerAdvice가 선언된 클래스에서 해당 예외를 캐치하고 적절한 응답을 반환한다.
2. 예외에 따라 다른 처리 로직을 적용할 수 있다.
- @ExceptionHandler 어노테이션을 사용하여 특정 예외에 대한 핸들러 메서드를 정의할 수 있다.
- 예를 들어, NoResourceFoundException이 발생하면 404 에러 코드와 에러 메시지를 반환하고, IOException이 발생하면 500 에러 코드와 에러 메시지를 반환하는 등의 로직을 구현할 수 있다.
3. 공통적인 예외 처리 로직을 재사용할 수 있다.
- @RestControllerAdvice, @ControllerAdvice가 선언된 클래스는 모든 컨트롤러에 적용된다. 따라서 여러 컨트롤러에서 발생하는 동일한 예외에 대해 한 곳에서 처리할 수 있다.
그럼 이제 전역 예외처리를 @RestControllerAdvice를 통해 적용해 보자.
1. ResponseDto
우선, Response에 사용할 Dto를 만든다.
@Getter
@AllArgsConstructor
public class ErrorResponseDto {
private final String errorCode;
private final String message;
private final String detail;
public static ErrorResponseDto res(final CustomException customException) {
String errorCode = customException.getErrorCode().getCode();
String message = customException.getErrorCode().getMessage();
String detail = customException.getDetail();
return new ErrorResponseDto(errorCode, message, detail);
}
public static ErrorResponseDto res(final String errorCode, final Exception exception) {
return new ErrorResponseDto(errorCode, exception.getMessage(), null);
}
}
2. Custom Error Code
커스텀 예외에서 사용할 커스텀 에러 코드를 만든다.
에러 코드는 400부터 500번대까지 있다. 하지만, 프론트엔드 개발자의 코드 입장에서 HTTP Status 400만으로는 얻을 수 있는 정보가 거의 없다.
회원가입 시 이름, 이메일, 전화번호를 POST로 우리 서버에 보내는 상황에서 HTTP 400 만으로는 이름이 잘못되었는지, 전화번호가 잘못되었는지 알 수 없다. (Message를 사용하긴 하지만, 프론트의 코드로 Message를 구별하긴 힘들다. 특히 규모가 커질수록…)
이 때문에, 회원을 찾을 수 없다면, “4040”, 메모를 찾을 수 없다면 “4041” 과 같이 커스텀한 예외 코드를 반환할 것이다.
예외 코드는 코드(ex: “4040”)와 예외에 대한 설명(ex: “유저를 찾을 수 없습니다”)만을 가질 것이다. 이 상황에서 예외 코드를 구현하기 가장 좋은 방법은 무엇일까?
■ Java의 Enum을 사용해서 구현해 보자! ■
@Getter
@AllArgsConstructor
public enum ErrorCode {
USER_NOT_FOUND("4040", "유저를 찾을 수 없습니다."),
MEMO_NOT_FOUND("4041", "메모를 찾을 수 없습니다.");
private final String code;
private final String message;
}
이렇게 간단한 ErrorCode Enum이 만들어졌다.
3. Custom Exception
커스텀 예외를 만든다.
- 커스텀 예외란, 프로그램 실행 과정에서 발생하는 예외를 상속받아 만드는 예외이다.
- 왜 커스텀 예외를 만들까?
- 특정한 예외 상황이 발생했을 때, 개발자는 그 예외에 대한 의미를 명확하게 전달할 수 있어야 한다.
- 예외에 대한 상세한 내용을 전달할 수 있다.(ex: 해당하는 메모가 없을 때 → MemoNotFoundException)
커스텀 예외 즉, 우리가 일반적으로 사용할 예외는 전부 Runtime에 발생(컴파일 시 처리 불가)하므로, 특별한 이유가 없다면 RuntimeException을 상속받을 것이다.
우리는 커스텀 예외를 다음과 같이 구현할 것이다.
UML로 보면 감이 잘 잡히지 않을 수 있다.
1. RuntimeException을 상속받는 CunstomException 클래스를 만들고, ErrorCode를 멤버변수로 가지게 한다.
2. CustomException은 두 개의 생성자를 가진다.
- 위에서 만든 ErrorCode만을 받는 생성자
- 위에서 만든 ErrorCode와 Message를 같이 받는 생성자(이유는 특정 예외는 발생할 수 있는 경우가 다양해서 발생한 예외를 CustomException으로 만들 때, 더 자세한 Message가 필요한 경우가 있기 때문)
3. 이 CustomExcption을 상속받는 예외 클래스(UserNotFoundException, MemoNotFoundException)를 추가한다.
코드는 다음과 같다.
@Getter
public class CustomException extends RuntimeException {
protected ErrorCode errorCode;
public CustomException(ErrorCode errorCode) {
this.errorCode = errorCode;
}
public CustomException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
}
public class UserNotFoundException extends CustomException {
public UserNotFoundException (ErrorCode errorCode) {
super(errorCode);
}
}
public class MemoNotFoundException extends CustomException {
public MemoNotFoundException (ErrorCode errorCode, String detail) {
super(errorCode, detail);
}
}
이 커스텀 예외를 사용하면 다음과 같이 예외를 던질 수 있다. (아마 이런 Service 함수는 없겠지만)
@Service
@AllArgsConstructor
public class UserQueryServiceImpl implements UserQueryService {
.....
@Override
public ResponseEntity<ResponseDto<UserGetResponseData>> getUser(String userId) {
.....
if(/*user가 존재하지 않는다면*/){
throw new UserNotFoundException(ErrorCode.USER_NOT_FOUND);
}
}
}
이제 던져진 커스텀 예외를 처리하기만 하면 된다.
4. ExceptionHandler
커스텀 예외를 처리하기 위해선, 위에서 언급한 @RestControllerAdvice를 사용하면 된다. 전역으로 예외를 처리하는 클래스를 만들고, 커스텀 예외에 대한 핸들러를 적용하자
@Slf4j
@RestControllerAdvice
public class ExceptionController {
//UserNotFoundException 핸들러
@ResponseStatus(HttpStatus.NOT_FOUND)//response HTTP 상태 코드를 404(NOT_FOUND)로 설정
@ExceptionHandler(UserNotFoundException.class)//UserNotFoundException예외를 처리하는 핸들러
public ResponseEntity<ResponseDto<Void>> handleUserNotFoundException(
UserNotFoundException userNotFoundException ) {
ErrorCode errorCode = userNotFoundException.getErrorCode();
String code = errorCode.getCode();
String message = errorCode.getMessage();
log.error("UserNotFoundException 발생! : {}", message);
return new ResponseEntity<>(ResponseDto.res(code, message), HttpStatus.NOT_FOUND);
}
//MemoNotFoundException 핸들러
@ResponseStatus(HttpStatus.NOT_FOUND)//response HTTP 상태 코드를 404(NOT_FOUND)로 설정
@ExceptionHandler(MemoNotFoundException.class)//MemoNotFoundException예외를 처리하는 핸들러
public ResponseEntity<ResponseDto<Void>> handleMemoNotFoundException(
MemoNotFoundException memoNotFoundException ) {
ErrorCode errorCode = memoNotFoundException .getErrorCode();
String code = errorCode.getCode();
String message = errorCode.getMessage();
log.error("MemoNotFoundException 발생! : {}", message);
return new ResponseEntity<>(ResponseDto.res(code, message), HttpStatus.NOT_FOUND);
}
}
이제 예외 발생 시 커스텀 예외를 발생시킬 수 있다.
통일된 에러 응답, 각 에러 별 에러 코드 지정은 프론트엔드와 백엔드 간의 협업에서 중요한 부분을 차지한다. API에서 발생하는 에러를 적절히 Handle 함으로써, 협업 효율, 코드 가독성, 재사용성 등을 높일 수 있다.
'Spring & Spring Boot' 카테고리의 다른 글
Interceptor를 활용한 JWT 토큰 검증 (2) | 2024.06.07 |
---|---|
Dto Validation 실패 시 예외처리 (1) | 2024.03.15 |
Spring Boot의 ResponseEntity (1) | 2024.03.15 |
Dto에 Validation 적용하기 (2) | 2024.01.21 |
Spring Security jwt 적용, 커스터마이징 (0) | 2024.01.21 |