기록하며 성장한다 - 개발, 회고

개발 회고록

[ 개발 회고록 ] @RestAdviceController 와 @ExceptionHandler 로 예외 처리하기

전대홍 2024. 1. 24. 11:37

 

 


서론

현재 배달과 관련 된 토이프로젝트를 진행하고 있다.

토이 프로젝트 링크 <<<

스프링에는 여러가지 예외처리 방법이 있다. 가장 기본적인 방법, 그리고 필자가 해당 프로젝트를 시작하기 전에 많이 사용했던 방법은 바로 각 Exception 마다 Exception Class 를 만드는 것이었다.

그랬더니 생기는 문제가 바로 너무 많은 클래스들이 생성된다는 것이었다.

상황따라 Exception 은 너무 많은데, 그걸 상황따라 다 만들고 그걸 또 Response 에 담아서 응답을 전송해줘야하는게 힘들었다.

그리고 무엇보다 상황마다 모든 로직에 try catch 를 넣어줘야 했는데, 그게 너무 불편했고, 이러한 에러 처리라는 공통 관심사를 AOP 처럼 분리할 수는 없을까를 고민하게 되었다.

그래서 예외처리 관련해서 이것저것 알고보던 중에, 스프링에서 예외처리를 할 수 있는 많은 방법을 알게 되었다.

 

 


본론

1. 스프링이 제공하는 여러가지 Exception 처리 방법

일단 @ResponseStatus 어노테이션을 활용하는 방법이 있었다. 우리가 만든 커스텀 Exception 클래스에 붙여줌으로써, 응답 상태를 지정해줄 수 있었다.

단, 에러 응답의 내용을 수정할 수 없고, 예외 클래스와 강하게 결합되어있어서 항상 같은 상태와, 같은 에러 메시지만을 반환하게 되는 문제가 있었다.

두번 째는 ResponseStatusExcepion 을 이용하는 방식인데, @ResponseStatus 를 대안하는 방식으로 손쉽게 에러를 반환할 수 있는 방식이다.

try {
    // 코드
} catch (Exception e) {
    throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Item Not Found");
}

이런 느낌으로 사용할 수 있는데,

불필요한 예외 클래스도 안만들어도 되고, 예외 클래스와의 결합도도 낮출 수 있다는 이점이 있었다,

그러나 직접 예외 처리를 프로그래밍하게되서 일관된 예외처리가 어렵고, 중복 코드가 발생하게 된다는 문제점이 있었다.

 

그래서 다음으로 찾은게 바로 @ExceptionHandler 였다.

이는 예외처리를 매우 유연하게 할 수 있는 방법으로,

@ExceptionHandler({ApiException.class})
public ResponseEntity<ExceptionDto> exceptionHandler(final ApiException e) {
    log.error("An error occurred: {}", e.getMessage(), e);
    return ResponseEntity
            .status(e.getError().getStatus())
            .body(ExceptionDto.builder()
                    .errorCode(e.getError().getCode())
                    .errorMessage(e.getError().getMessage())
                    .build());
}

이렇게 내가 만든 커스텀 Exception인 ApiException 이 발생하였을 때,

자동적으로 해당 메서드로 이동시켜, 위와 같은 코드를 실행시켜 클라이언트에게 원하는 응답을 전달해 줄 수 있었다.

ExceptionHandler 를 이용하면 에러 응답을 자유롭게 다룰 수 있게되고, 클래스들을 속성으로 받아 처리할 예외를 지정할 수 있다. 무엇보다 공통 관심사를 처리 할 수 있어서, 유지보수와 가독성 면에서도 좋았다.

이제 이 ExceptionHandler 를 유용하게 사용할 수 있는 또다른 어노테이션을 소개하겠다.

 

 

2. RestControllerAdvice ??

바로 @RestControllerAdvice 라는 어노테이션이다. 만약 이 어노테이션을 적용하지 않고, @ExceptionHandler 만 적용한다면 이는 전역적으로 동작하지는 않는다.

그러나, @RestControllerAdvice 라는 어노테이션을 붙인 클래스 안에 @ExceptionHandler 를 적용시켜놓는다면, 스프링은 전역적으로 @ExceptionHandler 가 적용된 Exception 에 대하여 검사를 하여, 예외처리를 해준다.

@RestControllerAdvice 말고도 @ControllerAdvice 도 있는데, 둘의 차이는 @ResponseBody 가 있어서 Json 으로 응답을 주느냐 마느냐의 차이이다. 당연 Rest 가 붙은 어노테이션이 Json 으로 응답을 준다.

필자는 @RestControllerAdvice 를 사용하였다.

그로인해서 아래와 같은 이점을 얻을 수 있었다.

1. 하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외처리가 가능해짐.

2. 직접 정의한 에러 응답을 일관성 있게 클라이언트에게 전다링 가능함.

3. Try Catch 를 사용하지 않아, 가독성이 올라감.

이런 이유 덕분에 현업에서는 @RestControllerAdvice 를 많이 사용한다고 한다.

다만, 한 프로젝트당 하나의 @RestControllerAdvice 를 사용하는 것이 좋다고 한다. 왜냐하면 어드바이스는 순서가 지정되어있지 않다면, 임의의 순서로 처리를 하기 때문이다.

그래서 꼭 여러개의 ControllerAdvice 를 쓰고 싶다면, @Order 어노테이션을 사용하여 순서를 지정해주어야 한다.

 

 

3. 어떻게 사용했는가 ?

그래서 필자는 아래와 같이 작성하였다.

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    /**
     * Custom Exception 인 ApiException 이 호출 되었을 경우 동작합니다.
     * Enum 에 미리 저장된, HttpStatus 와 Code 그리고 메시지가
     * 클라이언트에게 return 됩니다.
     */
    @ExceptionHandler({ApiException.class})
    public ResponseEntity<ExceptionDto> exceptionHandler(final ApiException e) {
        log.error("An error occurred: {}", e.getMessage(), e);
        return ResponseEntity
                .status(e.getError().getStatus())
                .body(ExceptionDto.builder()
                        .errorCode(e.getError().getCode())
                        .errorMessage(e.getError().getMessage())
                        .build());
    }

    @ExceptionHandler({HttpClientErrorException.class})
    public ResponseEntity<ExceptionDto> exceptionHandler(final HttpClientErrorException e) {
        log.error("An error occurred: {}", e.getMessage(), e);
        return ResponseEntity
                .status(LOGIN_SECURITY_ERROR.getStatus())
                .body(ExceptionDto.builder()
                        .errorCode(LOGIN_SECURITY_ERROR.getCode())
                        .errorMessage(e.getMessage())
                        .build());
    }
}

이로 인하여, 필자는 필자가 원하는대로 Exception 을 처리할 수 있게 되었다.

예를들어

@Transactional(readOnly = true)
public void existsByEmail(String email) {
    if ( memberRepository.existsByEmail(email) ) throw new ApiException(DUPLICATED_EMAIL);
}

이렇게 해당 이메일이 존재하는지 여부를 판단하는 메서드를 만들었다고 하자.

여기서는 해당 이메일이 존재하면, 바로 threw new ApiException(DUPLICATED_EMAIL); 을 발생시킨다.

 

그래서 아래와 같은 필자의 커스텀 Exception 발생시킨다.

@Getter
@ToString
public class ApiException extends RuntimeException {

    private final ExceptionEnum error;

    public ApiException(ExceptionEnum e) {
        super(e.getMessage());
        this.error = e;
    }
}

 

이 때, ExceptionEnum 이라는 상황에따라 다르게 에러 내용을 반환하는 Enum 을 만들어서 추가하였다.

@RequiredArgsConstructor
@Getter
public enum ExceptionEnum {

    RUNTIME_EXCEPTION(HttpStatus.BAD_REQUEST, "E_0001", "잘못 된 요청입니다."),
    MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "E_0002", "해당 회원을 찾을 수 없습니다."),
    // ...
    
    
    private final HttpStatus status;
    private final String code;
    private final String message;
}

 

그리고 마지막으로 ExceptionHandler 를 통해, 클라이언트들에게 응답을 보낼 양식을 Dto 로 만들었다.

@Builder
public class ExceptionDto {
    private String errorCode;
    private String errorMessage;
}

 

이후, 테스트를 진행해보니

어떤 컨트롤러든, 서비스든 throw new ApiException(..) 만 작성하였을 뿐인데도, 해당 내용이 붙어있으면 자동으로 Exception 처리를 해주게 되었다.

이로써 위에서 이야기하였듯 필자가 원하는 방향으로 Exception을 처리 할 수 있었다.

1. try catch 를 통한 코드 중복, 가독성이 떨어지는 문제를 해결하였고

2. 커스텀 Exception 을 적극 활용할 수 있게되었으며

3. 커스텀 Exception 을 상황에따라 여러개 만들지 않아도 되었다.

4. 그리고 횡단 관심사를 따로 공통화시켜 분리할 수 있었다.

 

 


결론

필자가 이번에 개발을 진행하면서,

제일 성장했다고 느끼는 부분이 바로 공통 관심사를 분리 하는데에 신경을 많이 쓰게되었다는 점이었다.

이전에 개발을 할 때는 중복 로직이 너무 많아서, 유지보수가 어려웠는데

지금은 그런 부분들을 싹 없애며 개발하기를 노력하고 있다.

성취감과 함께 점점 개발이 즐거워지는 순간이 바로 지금이지 않을까 싶다.