Exception과 HTTP를 분리해보자

gibeom·2022년 11월 10일
1

개발 저장소

목록 보기
2/2

나는 최근 코드숨이라는 멘토링 과정을 통해 스스로 개발을 한 후, 멘토님의 피드백을 받으며 많은 것을 학습해나가고 있다. 해당 과정을 하면서 스스로 만들어 보았던 기능 중에 Exception을 핸들링해보자!라는 주제를 블로그에 적어보면서 개발을 진행했었다.

해당 포스팅을 조금만 요약하자면 @ControllerAdvice를 통해 전역적으로 발생하는 Exception을 핸들링하는 기능으로 아래와 같은 설계를 구성했었다.

해당 설계는 기본적으로 Exception 객체 내부에서 HTTP Status를 직접 지정해주는 방식이다.
이에 관련해서 오늘은 Exception 객체가 의존하고 있는 HTTP를 분리하는 과정을 설명해보려고 한다.


Exception에서 HTTP를 떼어낸다면 좋은 이유

이번 주 리뷰어님인 종립 멘토님은 아래와 같은 리뷰를 남겨주셨다.

(코드숨 과정을 듣는 선택은 정말 잘한 선택인 것 같다😊😊)

자, Exception에서 HTTP를 분리한다면 좋은 점이 무엇일까?
기본적으로 실무에서는 주로 HTTP 요청/응답을 많이 다루기 때문에, Exception 클래스에서 Http Status를 관리하는 것이 효율적일 수도 있다고 생각한다. 이렇게 관리할 경우 필요한 상황에서 관련 에러만 던지면, 알아서 관련 응답 값을 리턴해주기 때문에 개발을 편하게 할 수 있을 것이다.

하지만 좀 더 객체지향적으로 바라봤을 때 Exception 객체는 공통적으로 유의미한 예외 정보를 리턴하는 책임을 갖고있는 객체이다.
반대로 HTTP Status Code는 HTTP 통신만을 위한 응답 값이다. 따라서 HTTP Status는 HTTP 통신을 담당하는 컨트롤러 어댑터를 통해서 설정해주는 것이 객체지향적으로 좋아보인다. (SRP)

어댑터?

  • 컨트롤러는 외부 환경에 대한 어댑터 역할을 해주는 객체이다.
  • 어댑터가 외부 환경을 유스케이스에 맞는 값으로 변환해줌으로써, 유스케이스는 외부 환경으로부터 독립적이게 된다. (OCP)

예시로 Exception을 사용하는 Controller가 HTTP 관련 통신을 하는 어댑터가 아닌 상황을 생각해보자!
현재 Exception을 던지는 위치는 Service(유스케이스) 이다. 그렇다면 HTTP 통신을 하지 않는 Controller는 해당 유스케이스를 사용하지 못한다. (HTTP 예외를 싹 다 지울수도 없으니...)
따라서 HTTP 통신을 담당하는 컨트롤러 어댑터를 통해서 Http Status를 설정해주는 것이 바람직해 보인다.

정리하자면 Exception 객체가 현재 갖고있는 HTTP의 의존성을 분리한다면, Exception 객체의 역할인 "유의미한 예외 정보를 리턴"하는 책임에 좀 더 집중할 수 있을 것 같다.


객체지향에서의 의존이란

의존성 분리 작업을 하기 전에, 간단하게 객체지향에서의 의존이 뭔지 곰곰히 생각해보았다.
개인적으로 정리한 생각은 의존 == 수정인 것 같다.
만약 어떠한 객체의 수정이 일어날 경우, 해당 객체를 의존하고 있는 객체 또한 수정이 일어난다.

간단한 예시를 들면 아래와 같은 상황에서는 "Controller 객체가 ProductUseCase 객체를 의존하고 있다" 라고 표현할 수 있을 것이다.

@RestController
@RequestMapping("/products")
public class ProductController {
    private final ProductUseCase productUseCase;

    public ProductController(ProductUseCase productUseCase) {
        this.productUseCase = productUseCase;
    }
    
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ProductResponse create(@RequestBody @Valid final ProductCreateRequestDto productCreateRequestDto) {
        return ProductResponse.from(
                productUseCase.createProduct(productCreateRequestDto)
        );
    }
}

이 말은 즉슨 ProductUseCase의 코드가 변경될 경우, 의존하고 있던 Controller의 코드까지도 수정될 수 있다는 의미이다.
따라서 우리는 보통 직접 의존하는 것을 피하기 위해 Interface를 통해 의존을 역전하곤 한다. (DIP)


그렇다면 어떻게 분리하여야 할까?

서론이 너무 길었던 것 같다😂 이제 분리 작업을 시작해보자!

위에서 얘기한 대로 Exception에서 HTTP Status를 설정하지 않을 것이다. 그렇다면 컨트롤러에서 직접 하나하나 해주어야할까? 컨트롤러에서 핸들링하려면 예외를 컨트롤러에서 던져야하는데, 그렇게 된다면 컨트롤러에 비지니스 로직이 생겨날 것이다.
따라서 실제 Exception을 던지는 행위는 유스케이스(Service)에서 그대로 진행할 것이다. (예외를 던지는 것 또한 일종의 비지니스 로직의 행위라고 본다.)

그럼 어디서 설정을 해주어야 할까... @ConrollerAdvice가 생각이 났다.
작업을 시작하기 전, 생각해 본 분리 방법은 다음과 같다.

  1. 기존 Exception에서 설정하고 있는 HTTP Status를 제거한다.
  2. 기존 각 예외들을 공통 부모로 묶었던 CommonException을 제거하고, 모두 RuntimeException상속받는다.
    • 원래라면 Http통신에서의 NotFound라는 특징을 공통으로 묶어 HttpNotFoundException이라는 객체로 묶어보려고 했을 것이다. (원래 관점으로는 아마 아래처럼 했을 것 같다.)
    • 하지만 지금 작업의 핵심은 Exception과 HTTP를 분리하고, 다양한 애플리케이션에서 사용할 수 있도록 다양한 출력을 지원하도록 설계하는 것이니 아래처럼 모두 RuntimeException으로 상속받겠다.
  1. HTTP Status Code 설정은 @ControllerAdvice에서 각각의 관련 Exception에 대한 응답 코드를 설정한다.
    • @ControllerAdvice은 각 도메인별로 하나씩 갖도록 한다.
    • @ControllerAdvice에서 @ExceptionHandler 메서드가 예외를 처리하는 담당이니, 해당 객체에게 응답 코드 관련 설정도 위임한다.
  1. 기존에 사용했던 코드들을 정비한다.


작업 과정

1. 모든 예외의 공통적으로 사용되는 body값을 생성해주는 ErrorResponse는 다음과 같이 변경하였다.

[기존]

@Getter
@Builder
@Slf4j
public class ErrorResponse {
    private final LocalDateTime occuredTime;
    private final String message;

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

[변경]

@Getter
@Builder
@Slf4j
public class ErrorResponse {
    private final LocalDateTime occuredTime;
    private final String message;
	
    public static <T extends Exception> ErrorResponse from(final T exception) {
        return ErrorResponse.builder()
                .message(exception.getMessage())
                .occuredTime(LocalDateTime.now())
                .build();
    }
}

2. 실제 예외(ProductNotFoundException) 클래스는 다음과 같이 변경하였다. (공통 부모는 삭제하였다)

[기존]

public class ProductNotFoundException extends HttpBusinessException {
    private static final String MESSAGE = "상품이 존재하지 않습니다.";

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

    public ProductNotFoundException(Long id) {
        super(MESSAGE + " Id: " + id, HttpStatus.NOT_FOUND);
    }
}

[변경]

// HTTP Status 설정 코드 삭제
public class ProductNotFoundException extends RuntimeException { // RuntimeException으로 상속
    private static final String MESSAGE = "상품이 존재하지 않습니다.";

    public ProductNotFoundException(Long id) {
        super(MESSAGE + " Id: " + id);
    }
}

3. @ControllerAdvice의 코드는 다음과 같이 변경하였다.

[기존]

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

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

[변경]

@ProductControllerAdvice
public class ProductExceptionHandler {

    @ExceptionHandler(value = {ProductNotFoundException.class})
    protected ResponseEntity<ErrorResponse> handleProductNotFoundException(final ProductNotFoundException exception) {
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(ErrorResponse.from(exception));
    }
}

여기서 @ProductControllerAdvice는 커스텀 어노테이션이다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestControllerAdvice(basePackageClasses = {
        ProductController.class     // Product 관련 컨트롤러를 모두 집어넣어주면 된다.
})
@Order(Ordered.HIGHEST_PRECEDENCE) // 다른 ControllerAdvice보다 우선권을 가진다.
public @interface ProductControllerAdvice {
}

여기서 왜 커스텀 어노테이션을 만들었는지와 @Order를 통해 우선권을 얻으려는지 궁금할 수도 있을 것 같다.

일단 먼저 도메인 별 ControllerAdvice 커스텀 어노테이션을 만든 이유는 위에서 basePackageClasses에서 설정해주는 관련 클래스들이 추후에 많이 늘어날 것으로 예상되었다.
basePackage를 사용해서 @RestControllerAdvice(basePackage="com.codesoom.assignment.product)"로 설정해준다면 한 줄로 코드가 끝나서 깔끔해질수도 있었지만, 일단 문자열을 그대로 써야된다는 것을 지양하고 싶었다.
폴더 구조의 리팩토링이 되거나, 오타가 발생할 가능성이 없지 않기 때문이다.
따라서 basePackageClasses를 통해 문자열을 직접 치지 않고 관련 클래스들을 넣어주는 방식을 선택했다. 이 방식은 관련 클래스가 많아질 수록 코드가 길어지기 때문에 커스텀 어노테이션으로 따로 빼준 것이다.

@Order를 통해 우선권을 가진 이유는 아래의 코드처럼 각 도메인 별 ControllerAdvice가 미쳐 잡지 못한 나머지 예외를 잡아주려고 설정한 GlobalExceptionHandler 때문이다.

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(value = {Exception.class})
    protected ResponseEntity<ErrorResponse> handleException(final HttpServletRequest request,
                                                            final Exception exception) {
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ErrorResponse.from(request, exception));
    }
}

만약 ProductControllerAdvice@Order를 통해 각 도메인의 어드바이스가 우선권을 획득하지 않는다면 Product에서 발생한 예외가 GlobalExceptionHandler에서 잡히는 상황이 발생하기 때문이다.


아직 글을 쓰는 것이 익숙하지 않아 설명이 부족한 것 같습니다.
잘 이해가 안된다면 여기에서 관련 코드를 확인할 수 있습니다.


💡 잘못된 내용이 있을 경우, 소중한 피드백 부탁드립니다 :)

🙋🏻‍♂️ Call me
Email : dev.gibeom@gmail.com
KakaoTalk : https://open.kakao.com/me/beomdrive

profile
꾸준함의 가치를 향해 📈

0개의 댓글