본문 바로가기

Spring & Spring Boot

Spring Boot에선 예외를 어떻게 처리할까?(전역, 커스텀 예외처리)

 프로그램을 개발하다 보면, 예외를 던지는 상황이 생긴다. 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를 사용할 때 적합하다.

 

RestControllerController의 차이라고 생각하면 될 것 같다.

 

우리는 모든 응답에 대해 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만을 받는 생성자
  • 위에서 만든 ErrorCodeMessage를 같이 받는 생성자(이유는 특정 예외는 발생할 수 있는 경우가 다양해서 발생한 예외를 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 함으로써, 협업 효율, 코드 가독성, 재사용성 등을 높일 수 있다.