Exception을 핸들링해보자! | @RestControllerAdvice

gibeom·2022년 10월 21일
1

개발 저장소

목록 보기
1/2

우리는 애플리케이션을 개발할 때면 기획을 통해 정책을 만들곤 한다.
그리고 그 정책에 따라 다양한 조건들을 체크하게 되며, 조건에 맞지 않은 상황이 발생하면 오류를 표시해야 한다.

프로그램 에러의 종류는 컴파일 에러, 런타임 에러 그리고 논리적 에러가 있다.
컴파일 에러는 컴파일 단계에서 발생하는 에러이고, 런타임 에러는 프로그램 실행 도중에 발생하는 에러이다.
논리적 에러는 프로그램은 잘 돌아가지만, 개발자의 의도와는 다르게 동작하는 것을 말한다.

나는 오늘 논리적 에러에 대해서 핸들링한 경험을 얘기해보려고 한다.

Exception?

Exception은 프로그램에서 경미한(?) 오류로써, 프로그램 코드에 의해서 수습될 수 있는 미약한 오류라고 한다.
Exception에 대한 자세한 내용은 생략하고 간단하게만 정리(?) 느낌으로만 얘기해보려 한다.
자세한 내용을 잘 정리해놓은 좋은 글들이 너무 많기에...

Exception은 체크 예외와 언체크 예외로 나누어진다.

RuntimeException을 상속받는 모든 자손들의 Exception은 언체크 예외로 불리는데, try/catch나 throws를 굳이 사용하지 않아도 된다는 특징이 있다.
(try/catch 등이 필수가 아니라는건 예외 복구 전략이 없을 수도 있다는 의미이기에 기본적으로 Rollback을 지원한다.)

이 의미는 즉슨 예상하지 못했던 예외상황이 발생하는게 아닌, 미리 조건을 주의깊게 체크한다면 피할 수 있지만 개발자의 부주의에 의해 일어나는 예외라는 뜻이다. (feat. 토비의 스프링)
따라서 언체크 예외는 null일 수도 있는 상황에서는 Optional을 통해 NPE를 방지하자! 등으로 미리 Exception을 방지해야 할 것이다.

하지만 이와는 별개로 논리적 에러인 경우에는, 구체적인 예외 상황을 명확하게 표시하기 위해 언체크 예외를 활용하기도 한다.

나는 논리적 에러를 주의 깊게 체크함으로써, 조건에 맞지 않는다면 직접 만든 CommonException을 발생시켜 프로그램의 논리적 오류를 방지하려고 한다.


Exception 핸들링의 과정, 그 속으로

서론은 여기까지 하고, 이제부터는 Exception 핸들링 구축 과정을 하나씩 흐름대로 소개하면서 부연 설명을 해볼까 한다.
필자는 TodoList(할 일, 작업)에 대한 상세 정보를 가져오는 API 예시를 통해 이번 개발 과정을 소개해보려 한다.

[잠깐!]

📢 만약 바쁘거나, 구현 방법만 보고 싶다면 맨 아래 "정리"글로 넘어가도 좋을 것 같다!


자 이제 의식의 흐름대로 따라가보자!

첫번째 생각

가져온 데이터가 존재하지 않다면 404 Error로 반환하기

아래 코드는 간단하게 task라는 작업의 상세 정보를 고유 id를 통해 가져오는 코드이다.

@Service
public class TaskServiceImpl implements TaskService {
    private final TaskRepository taskRepository;

	... 생략 ...

    @Override
    public Task getTaskById(Long id) {
        return taskRepository.findById(id)
                .orElseThrow(404 에러 발생시켜!!);
    }
}

taskRepository.findById(id)에서 가져온 데이터는 Optional<Task>로 반환되는데, 이때 반환된 데이터가 없다면 404 Error를 반환해야 하는 상황이다.

일일이 try/catch로 처리하기엔 가독성도 문제며, 기능 하나 추가할 때마다 응답 코드 지정해주며 throw 날리는 노가다를 할 수도 없기 때문에 다른 수단이 필요했다.


두번째 생각

공통 에러를 처리할 수 있는, 재사용이 가능한 객체를 만들자

공통 에러 처리를 담당하는 객체를 만들어서 재사용을 할 수 있게 만들어보자.

아래 코드에서처럼 CommonException 이라는 클래스를 생성하고 RuntimeException을 상속받아서, 공통 에러 처리를 담당하는 객체를 만들었다.

@Getter
public class CommonException extends RuntimeException {
    private static final String DEFAULT_MESSAGE = "일시적으로 접속이 원활하지 않습니다." +
            "잠시 후 다시 요청해주시길 바랍니다.";
    
    private final HttpStatus status; // 응답 상태 코드
    private final LocalDateTime occuredTime = LocalDateTime.now(); // 에러 발생 시간

    public CommonException() {
        super(DEFAULT_MESSAGE);
        this.status = HttpStatus.INTERNAL_SERVER_ERROR;
    }

    public CommonException(final String message, final HttpStatus status) {
        super(message);
        this.status = status;
    }
}

이제 조건 검증에 필요한 Exception은 CommonException을 상속받아서, 상황에 맞게 응답코드와 에러 메시지를 세팅하여 아래와 같이 선언하여 사용하면 될 것이다.

/**
 * 작업을 찾지 못했을 때 던집니다.
 */
public class TaskNotFoundException extends CommonException {
    private static final String MESSAGE = "할 일이 존재하지 않습니다.";

    public TaskNotFoundException() {
        super(MESSAGE, HttpStatus.NOT_FOUND);
    }
}

실제 TaskNotFoundException을 사용해보자!

@Service
public class TaskServiceImpl implements TaskService {
    private final TaskRepository taskRepository;

	... 생략 ...

    @Override
    public Task getTaskById(Long id) {
        return taskRepository.findById(id)
                .orElseThrow(TaskNotFoundException::new); // 새로 생성한 Exception 발생
    }

Exception 결과

결과는 어지럽다.
응답 status code를 HttpStatus.NOT_FOUND 세팅했지만 404로 반환되어야 할 실제 반환 값은 500이고, 메시지도 너무 난잡하다.
응답 코드가 500인 이유는 CommonException에서 super() 메서드를 통해 RuntimeException에 message만 전달해주었기 때문이다.

public CommonException(final String message, final HttpStatus status) {
    super(message); // status를 넣게 해주세요ㅠ
    this.status = status;
}

RuntimeException에 status를 전달해주고 싶어도, 실제 RuntimeException 생성자는 status를 받는 생성자가 없다.


세번째 생각

Exception 발생 시 원하는 응답 메시지 포맷으로 반환 될 수 있게 변경해보자!

우리는 매우 유연한(flexibility) 성격을 지니고, Java 애플리케이션을 쉽게 만들도록 도와주는 다양한 기능을 제공하는 스프링 프레임워크를 쓰고 있다.
소규모 프로젝트에 필요한건 웬만하면 다 있을 것이라고 믿는다😄

그러니 직접 만든 CommonException (TaskNotFoundException)의 예외를 받아서, 원하는 응답 포맷으로 세팅 후 반환될 수 있게 도와주는 기능을 찾아보자.


@ExceptionHandler

Annotation for handling exceptions in specific handler classes and/or handler methods.

  • 특정 핸들러 클래스 또는 핸들러 메서드에서 예외를 처리하기 위한 어노테이션 (document)

역시 아직은 다큐먼트만으로는 해석이 어렵다😂

기계인간님의 블로그를 참고해보면 다음과 같다.
Spring MVC는 @ExceptionHandler 어노테이션이 붙어 있는 핸들러 메서드를 통해 예외를 처리할 수 있다고 한다.
@ExceptionHandler는 예외를 던질 수 있는 핸들러 메소드와 동일한 컨트롤러 내에 존재하므로, 다른 여러 컨트롤러에서 발생하는 예외는 처리할 수 없다.


@ExceptionHandler로 동일한 컨트롤러 내의 원하는 Exception는 잡을 수 있는 것 같다.
실제 테스트로 Controller 바로 밑에 @ExceptionHandler로 핸들링 메서드를 선언하고 실행했더니, 다음과 같이 반환 형식이 원하는 포맷으로 바뀌었다.

@RestController
@RequestMapping("/tasks")
... 생략 ...
public class TaskController {
	
    ... 생략 ...
   
    @GetMapping("/{id}")
    public ResponseEntity<TaskResponseDto> getTask(@PathVariable @Min(0) Long id) {
        Task task = taskService.getTaskById(id); // CommonException 발생 지점

        return ResponseEntity.status(HttpStatus.OK)
                .body(TaskResponseDto.from(task));
    }

	// 핸들링 지점
    @ExceptionHandler(value = {CommonException.class})
    protected ResponseEntity<ErrorResponse> handleCommonException(final CommonException exception) {
        return ResponseEntity
                .status(exception.getStatus())
                .body(ErrorResponse.from(exception));
    }
}

[Exception 반환 결과]


의문점 1

책임을 분리하기 위해 클래스를 새로 생성하여, 위와 똑같이 선언했지만 Exception이 핸들링되지 않는다.

@Component
public class ExceptionHandlerTest {

    @ExceptionHandler(value = {CommonException.class})
    protected ResponseEntity<ErrorResponse> handleCommonException(final CommonException exception) {
        return ResponseEntity
                .status(exception.getStatus())
                .body(ErrorResponse.from(exception));
    }
}

@ExceptionHandler는 Exception 발생 지점인 Controller에서 선언된 것만 예외가 잡히는 걸까?

다시 한번 공식 문서를 봐보자.

Annotation for handling exceptions in specific handler classes and/or handler methods.

@ExceptionHandler특정 핸들러 클래스 및 특정 핸들러 메서드에서 예외를 처리하기 위한 주석이라고 한다.

의문점 2

핸들러는 정확하게 무엇을 말하는걸까?

필자는 핸들러에 대해 아래와 같이 여러 문서로 알아본 결과, 핸들러는 한 요청에 대한 처리를 담당 해주는 친구 (ex. 컨트롤러)라고 생각했다.


@ExceptionHandler는 특정 컨트롤러, 메서드에서 발생하는 예외를 처리해주는 친구라고 한다.


네번째 생각 (마지막)

전역적으로 모든 Controller에서 발생하는 Exception을 한 곳으로 모아 핸들링하자!

자 그럼 모든 Controller에서 발생하는 Exception을 한 곳으로 모아 @ExceptionHandler를 통해 핸들링 할 수 있는 방법을 찾아보자.
위에서 보았던 기계인간 종립님의 블로그에 아래 문구가 있었다.

@ExceptionHandler 대신에 @ControllerAdvice를 사용하면 여러 컨트롤러에서 발생하는 예외를 한곳에서 처리할 수 있다.


@ControllerAdvice

[document]
Specialization of @Component for classes that declare @ExceptionHandler, @InitBinder, or @ModelAttribute methods to be shared across multiple @Controller classes.

For handling exceptions, an @ExceptionHandler will be picked on the first advice with a matching exception handler method.

By default, the methods in an @ControllerAdvice apply globally to all controllers.

정확한 번역은 힘들지만 열심히 보기엔 대략 이런 내용인 것 같다.

  • 모든 컨트롤러에 대해 @ExceptionHandler@InitBinder, @ModelAttribute가 적용된 매서드를 공유하기 위한 특수한 @Component 클래스이다.
  • @ExceptionHandler은 예외를 처리하기 위해 가장 먼저 예외에 매칭된 핸들러 메서드를 선택한다.
  • 기본적으로 @ControllerAdvice의 메서드는 모든 컨트롤러에 전역적으로 적용된다.

즉 여러 컨트롤러를 전역적으로 관리하여 @ExceptionHandler를 적용시킬 수 있게 도와주는 친구이다.


자 그럼 @ControllerAdvice를 통해 공통 예외 핸들러를 만들어보자!

첫번째로 @RestControllerAdvice를 적용한 GlobalExceptionHandler라는 클래스를 생성했다.

  • 현재 이 프로젝트는 API 서버를 만들고 있기 때문에, 응답 포맷을 Json 형식으로 하기 위해 @ControllerAdvice@ResponseBody를 합친 @RestControllerAdvice를 사용했다.
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(value = {CommonException.class})
    protected ResponseEntity<ErrorResponse> handleCommonException(final CommonException exception) {
        return ResponseEntity
                .status(exception.getStatus())
                .body(ErrorResponse.from(exception));
    }
}

위와 같이 세팅을 하면 전역적으로 발생하는 예외를 해당 클래스의 @ExceptionHandler로 캐치할 수 있다.

이 때 매개변수로 전달 받은 exception에는 메시지응답 상태코드가 들어있을 것이다.

...  handleCommonException(final CommonException exception) {

                                           ↓↓↓↓
                                             
public class TaskNotFoundException extends CommonException {
    private static final String MESSAGE = "할 일이 존재하지 않습니다.";

    public TaskNotFoundException() {
        super(MESSAGE, HttpStatus.NOT_FOUND); // 이 인수 두개가 전달 된다!!!!!!!!!!!!!
    }
}

그럼 .status(exception.getStatus())를 통해 응답코드를 세팅하고, .body(ErrorResponse.from(exception));를 통해 예외 message예외 발생 시간을 세팅한다.

return ResponseEntity
                .status(exception.getStatus())
                .body(ErrorResponse.from(exception)); // 예외 메시지, 발생시간 세팅
								↓↓↓↓↓↓
@Getter
@Builder
public class ErrorResponse {
    private final LocalDateTime occuredTime;
    private final String message;

    public static ErrorResponse from(final CommonException exception) {
        return ErrorResponse.builder()
                .message(exception.getMessage())
                .occuredTime(exception.getOccuredTime())
                .build();
    }
}

자 이제 존재하지 않는 작업 id값을 이용해 API를 호출하여 에러 결과를 확인해보자!

원하는 포맷에 맞게 잘 반환되는 것을 볼 수 있다.

정리

이번 공통 Exception 핸들링 기능을 만들었던 과정을, 의식의 흐름 그대로 작성해보았더니 글이 난잡해진 느낌이다.
따라서 Exception 발생 시 예외 처리 흐름을 따라가며 정리해보려고 한다.

먼저 지금까지 만든 공통 Exception 핸들링의 구조를 그린 구조도를 보자!

위 구조도에서는 CommonException 외로 3개의 예외 핸들러가 추가되었지만, 일단 빨간색만 봐도 된다.
나머지 3개는 그냥 GlobalExceptionHandler@ExceptionHandler 메서드를 추가만 해주면 되니..


TaskNotFoundException 발생 시 예외 처리 흐름을 예시로 들어보겠다.

  1. TaskNotFoundException 발생!! -> .orElseThrow(TaskNotFoundException::new);

  2. TaskNotFoundException의 인스턴스 생성자가 호출된다. (메시지, 응답코드 세팅)

public class TaskNotFoundException extends CommonException {
    private static final String MESSAGE = "할 일이 존재하지 않습니다.";

    public TaskNotFoundException() {
        super(MESSAGE, HttpStatus.NOT_FOUND); // 메시지, 응답코드 세팅
    }
}
  1. TaskNotFoundException의 부모인 CommonException의 인스턴스가 호출된다. (예외 발생시간 세팅)
@Getter
public class CommonException extends RuntimeException {
    private static final String DEFAULT_MESSAGE = "...~~ 잠시 후 다시 요청해주시길 바랍니다.";
    private final HttpStatus status;
    private final LocalDateTime occuredTime = LocalDateTime.now();

    public CommonException(final String message, final HttpStatus status) {
        super(message);
        this.status = status;
    }
}
  1. CommonException의 부모인 RuntimeException을 거쳐 예외가 던져진다.

  2. GlobalExceptionHandler 클래스 내부에서 CommonException 핸들러 메서드로 예외가 캐치된다.

    • ResponseEntityExceptionHandler를 상속받으며 @RestControllerAdvice까지 선언하여, 웬만한 exception을 전역적으로 모두 핸들링 할 수 있다.
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(value = {CommonException.class})
    protected ResponseEntity<ErrorResponse> handleCommonException(final CommonException exception) {
        return ResponseEntity
                .status(exception.getStatus())
                .body(ErrorResponse.from(exception));
    }
}
  1. ErrorResponse를 통해 응답 body 데이터를 세팅한 후 클라이언트로 반환한다.
@Getter
@Builder
public class ErrorResponse {
    private final LocalDateTime occuredTime;
    private final String message;

    public static ErrorResponse from(final CommonException exception) {
        return ErrorResponse.builder()
                .message(exception.getMessage())
                .occuredTime(exception.getOccuredTime())
                .build();
    }
}

마무리

이번 공통 Exception 핸들링 기능을 개발하면서, 그 과정을 적어봤지만 생각보다 글이 난잡해진 것 같아서 속상하다… 😂
의식의 흐름대로 개발하면서, 중간 중간 궁금증들을 해결하는 과정까지 다 담아내서 그런 것 같다.

그래도 이 글을 보고 단 한명이라도 해당 기능을 만들다 막힌 그 막막함을 조금이나마 풀 수 있다면 만족할 것 같다.
(더 혼란이 왔다면 죄송합니다..! 🙇🏻‍♂️🙇🏻‍♂️)

글을 다시 보니 글쓰기 능력이 너무나도 부족한 것 같다는 생각이 든다.
이 글을 시작으로 계속해서 공부하고 작성하며 글 쓰는 능력을 키워야겠다!

profile
꾸준함의 가치를 향해 📈

0개의 댓글