예외 계층화하기

Nine-JH·2023년 8월 24일
1

들어가며

사이드 프로젝트를 진행하던 중 팀원의 도움 메시지가 왔습니다.

원인 분석

PR 이후 Github_Action에서 테스트 실패되어 merge를 하지 못하는 상태였습니다. 먼저 실패한 테스트를 확인해봤습니다.

살펴보니 ControllerUnitTest 에서 로그인 실패 시 ErrorResponse를 반환하는데 포맷 문제가 발생한 것이었어요.
근데 생각해보니 이분은 전혀 다른 도메인을 개발중이었는데 문제가 발생했던거죠.


뭐가 문제였냐면...

새로 추가한 ControllerAdvice가 문제였습니다. 서로 같은 예외를 처리하는 메서드였는데, 해당 테스트의 exceptionHandling이 다른 도메인의 handler이 수행하면서 발생한 문제였죠.
예를 들자면 이런 상황인거죠.

정상 Flow

image

비정상 Flow

image



다른 예외 처리는 안전한가?

이런 오류를 발견하자 마자 다른 ExceptionAdvice 역시 살펴보기 시작했습니다.
공통적으로 보이는 문제가 몇몇개 있더군요!

1. 중복되는 처리

가장 대표적인 예시로 BindException이 있습니다. 모든 ControllerAdvice에서 처리하다보니 중복되는 현상이 발견되고 있습니다. 이는 결국 저희가 겪었던 문제를 발생시키게 되겠죠...
image


2. 실제 비즈니스 로직에 대한 오류와 특정 라이브러리에 대한 구분하지 못함.

IllegalArgumentException에 대해서도 예외처리를 한 advice가 몇몇개 있었습니다. 사실 IllegalArgumentException은 정말 많은 라이브러리에서도 심심치않게 사용되는 로직입니다.
만약 다른 라이브러리의 오류로 해당 예외가 발생했을때 사용자의 실수인 것 처럼 예외처리를 해버린다면, 이는 사용자에게 큰 혼동을 안겨주게 됩니다.
image

Q) Exception Message를 넘겨주는 식으로 분기처라 하면 되는 것 아닌가?

사용자에게는 내부 라이브러리의 오류로 발생하는 문제를 굳이 메시지로 자세히 알려주지 않아도 된다고 생각합니다. 사용자가 굳이 알아야 하는 정보가 아니기 때문입니다.



그래서 어떻게 해결하지?

여러단계를 거쳐서 해결할 에정입니다.

1. Exception 계층화

  • 우선 타 라이브러리와 비즈니스 로직의 예외를 구분할 수 있게 RuntimeException을 상속하는 ApplicationException을 생성할 예정입니다.
  • 이의 장점은 이제 Application에서 발생하는 예외만 따로 처리를 할 수 있다는 것입니다.

이를 구현하면 다음과 같이 됩니다.

  • 추가적으로 HttpStatus, Message가 있으면 공통 처리에 유리할 것 같네요.
@Getter
public class ApplicationException extends RuntimeException {

    private HttpStatus httpStatus;

    protected ApplicationException(HttpStatus httpStatus, String message) {
        super(message);
        this.httpStatus = httpStatus;
    }
}

public class NotPostWriterException extends ApplicationException {

    private static final HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

    public NotPostWriterException() {
        super(httpStatus, "해당 게시글에 대한 권한이 없습니다.");
    }
}

2. ApplicationExcpetionAdvice 추상화

@Slf4j
@RestControllerAdvice
public class GlobalExceptionRestAdvice {

    @ExceptionHandler
    public ResponseEntity<ResponseDTO<Object>> applicationException(ApplicationException e) {
        log.error("ApplicationExcepotion: " + e.getMessage());
        return ResponseEntity
            .status(e.getHttpStatus())
            .body(ResponseDTO.res(e.getHttpStatus(), e.getMessage()));
    }

    @ExceptionHandler
    public ResponseEntity<ResponseDTO<Object>> serverException(RuntimeException e) {
        log.error("Server Exception: " + e.getMessage());
        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(ResponseDTO.res(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러!"));
    }
   ...
}
  • 이제 ApplicationException만 공통적으로 처리하는 예외 핸들러를 생성합시다.
  • 추가적으로 ApplicationException이 아닌 RuntimeException은 내부 서버 에러로 간주하기로 하고 예외를 처리하면 위의 의도대로 작동할 것 같네요

3. 각 Domain ControllerAdvice 다이어트

@RestControllerAdvice(basePackages = "com.fasttime.domain.member")
public class MemberControllerAdvice {

    @ExceptionHandler
    public ResponseEntity<ResponseDTO<Object>> BadCredentialsException(BadCredentialsException e) {
    	...
    }
    
    ...
}
  • 이제 ControllerAdvice의 범위를 줄이고, 중복되는 기능들도 다 지워나갑시다



대망의 테스트

다행히 모든 테스트가 정상적으로 통과했습니다! 이제 다른 팀원들은 도메인에 특화된 예외 처리만 진행하면 될 것 같네요!

0개의 댓글