[Spring Boot] 공통 응답용 ApiResponse 수정하기

tkdwns414·2024년 4월 16일
1
post-thumbnail

지난 글에 이어서

지난번 글에서는 ApiResponse에 대해서 알아보았다. 글 작성 이후 Security 관련 글을 작성하던 중 이전 글을 주제로 사람들과 이야기하다가 고칠만한 부분이 있다고 생각해서 추가적으로 짧은 글을 작성하게 되었다.

이 글에서는 아래 네 가지를 개선해볼 예정이다.
1. ExceptionDto를 record로 수정
2. ApiResponse 클래스의 fail 메소드의 인자를 CustomException이 아닌 ErrorCode로 변경
3. ResponseInterceptor의 supports 메소드 수정
4. ResponseInterceptor의 클래스명 수정

구현

ExceptionDto

앞으로 Dto는 record로 구현하기로 마음 먹었기 때문에 ExceptionDto 또한 통일성을 위해 record로 수정해주겠다.

아래는 기존의 ExceptionDto 코드이다.

  • 수정 전 ExceptionDto
import com.tkdwns414.Template.dto.type.ErrorCode;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;

@Getter
public class ExceptionDto {
    @NotNull
    private final Integer code;

    @NotNull
    private final String message;

    public ExceptionDto(ErrorCode errorCode) {
        this.code = errorCode.getCode();
        this.message = errorCode.getMessage();
    }

    public static ExceptionDto of(ErrorCode errorCode) {
        return new ExceptionDto(errorCode);
    }
}

record가 아니기 때문에 @Getter 어노테이션을 사용해주었고 생성자도 직접 만들어주었다. record는 DTO의 역할을 수행하기에 적합한 클래스 형태이므로 ExceptionDto 또한 record로 수정해주겠다.

  • 수정 후 ExceptionDto
import com.tkdwns414.Template.dto.type.ErrorCode;
import jakarta.validation.constraints.NotNull;

public record ExceptionDto (
        @NotNull Integer code,
        @NotNull String message
) {

    public static ExceptionDto of(ErrorCode errorCode) {
        return new ExceptionDto(
                errorCode.getCode(),
                errorCode.getMessage()
        );
    }
}

다음과 같이 수정할 수 있겠다.

간혹 잘못 이해하고 CustomException은 왜 record로 구현하지 않냐고 물어볼 수 있는데 CustomException은 DTO가 아니다. 우리(개발자)가 정의할 예외를 표현하기 위한 클래스이며 이를 위해 RuntimeException을 상속 받았다. 이 때문에 CustomException을 예외로 인식하기 때문에 GlobalExceptionHandler의 @ExceptionHandler에서 처리를 할 수 있는 것이다. 또한 record는 불변 데이터 구조로 상속을 받지 못한다.

ApiResponse

지난번에 구현한 ApiResponse에는 요청이 실패했을 때 처리를 위한 fail 메소드가 존재한다. 이때 fail 메소드가 받고있는 인자는 CustomException인데 이렇게 fail 메소드가 사용되는 곳은 GlobalExceptionHandler이다.

GlobalExceptionHandler의 코드를 조금 가져와보면 아래와 같다.

  • 수정 전 GlobalExceptionHandler
    @ExceptionHandler(value = {NoHandlerFoundException.class, HttpRequestMethodNotSupportedException.class})
    public ApiResponse<?> handleNoPageFoundException(Exception e) {
        log.error("GlobalExceptionHandler catch NoHandlerFoundException : {}", e.getMessage());
        return ApiResponse.fail(new CustomException(ErrorCode.NOT_FOUND_END_POINT));
    }


    // 커스텀 예외
    @ExceptionHandler(value = {CustomException.class})
    public ApiResponse<?> handleCustomException(CustomException e) {
        log.error("handleCustomException() in GlobalExceptionHandler throw CustomException : {}", e.getMessage());
        return ApiResponse.fail(e);
    }

    // 기본 예외
    @ExceptionHandler(value = {Exception.class})
    public ApiResponse<?> handleException(Exception e) {
        log.error("handleException() in GlobalExceptionHandler throw Exception : {}", e.getMessage());
        e.printStackTrace();
        return ApiResponse.fail(new CustomException(ErrorCode.INTERNAL_SERVER_ERROR));
    }

두 번째 메소드 handleCustomException 같은 경우에는 크게 문제가 될 것은 없지만 첫 번째, 세 번째 메소드인 handleNoPageFoundException과 handleException을 보면 처음에 @ExceptionHandler에서 잡히는 예외의 종류가 CustomException이 아니기 때문에 새로 CustomException 객체를 생성해서 반환하고 있다.

이런 부분에 있어서 굳이 생성될 필요가 없는 CustomException이 생성되기 때문에 미약하지만 코드의 효율성과 가독성을 올릴 수 있는 부분이라고 생각했다. 그래서 다음과 같이 수정할 수 있다.

  • 수정 후 GlobalExceptionHandler 일부
... 생략 ...

    // 존재하지 않는 요청에 대한 예외
    @ExceptionHandler(value = {NoHandlerFoundException.class, HttpRequestMethodNotSupportedException.class})
    public ApiResponse<?> handleNoPageFoundException(Exception e) {
        log.error("GlobalExceptionHandler catch NoHandlerFoundException : {}", e.getMessage());
        return ApiResponse.fail(ErrorCode.NOT_FOUND_END_POINT);
    }


    // 커스텀 예외
    @ExceptionHandler(value = {CustomException.class})
    public ApiResponse<?> handleCustomException(CustomException e) {
        log.error("handleCustomException() in GlobalExceptionHandler throw CustomException : {}", e.getMessage());
        return ApiResponse.fail(e.getErrorCode());
    }

    // 기본 예외
    @ExceptionHandler(value = {Exception.class})
    public ApiResponse<?> handleException(Exception e) {
        log.error("handleException() in GlobalExceptionHandler throw Exception : {}", e.getMessage());
        e.printStackTrace();
        return ApiResponse.fail(ErrorCode.INTERNAL_SERVER_ERROR);
    }

굳이 CustomException 객체를 생성하지 않고 fail일 때 ApiResponse의 ExceptionDto에 넣을 값들만 넘겨주는 방식으로 수정할 수 있다. ExceptionDto의 값은 ErrorCode에서 오기 때문에 넣어줄 것이기 때문에 각자에 맞는 ErrorCode를 CustomException에 담아서 전달하는 것이 아닌 그대로 ErrorCode를 인자로 사용했다.

위처럼 코드를 수정했으면 그에 맞게 ApiResponse도 수정해야한다. 사실 실제로는 ApiResponse부터 수정했었는데 글을 적다보니 거꾸로 수정하게 되었다.

  • 수정 후 ApiResponse 일부
... 생략 ...

    public static <T> ApiResponse<T> fail(final ErrorCode c) {
        return new ApiResponse<>(c.getHttpStatus(), false, null, ExceptionDto.of(c));
    }

받는 매개변수의 타입을 CustomException에서 ErrorCode로 바꾸었으며 원래는 CustomException에서 ErrorCode를 받아오고 거기서 HttpStatus를 넘기거나 ErrorCode를 넘기는 부분을 한 단계 줄였다.

ResponseInterceptor

supports 메소드는 지난번에 설명했듯이 beforeBodyWrite를 실행할지 말지 결정하는 역할을 한다. 그런데 지난번에 작성된 supports와 beforeBodyWrite를 보면 해당 역할이 나타나지 않고 있다.

  • 수정 전 ResponseInterceptor
import com.tkdwns414.Template.dto.common.ApiResponse;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

@RestControllerAdvice
public class ResponseInterceptor implements ResponseBodyAdvice {

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return returnType.getParameterType() == ApiResponse.class;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                  Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        HttpStatus status = ((ApiResponse<?>) body).httpStatus();
        response.setStatusCode(status);

        return body;
    }
}

위 코드를 보면 supports는 무조건 true를 반환하고 있기 때문에 beforeBodyWrite가 무조건 실행된다. 굳이 실행되지 않아도 될 메소드까지 실행되게 되므로 원래 supports 메소드의 의도에 맞는 역할을 할 수 있도록 수정해주겠다.

또한 ResponseInterceptor에서 구현하고 있는 ResponseBodyAdvice에 타입을 지정해주지 않아서 beforeBodyWrite에서 어떤 객체가 올지 예상하지 못하는 상태이다. 그렇기 때문에 Object를 사용하고 있으며 형변환을 하는 과정이 포함돼있다. 이 또한 수정해주도록 하겠다.

  • 수정 후 ResponseInterceptor
import com.tkdwns414.Template.dto.common.ApiResponse;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

@RestControllerAdvice
public class ResponseInterceptor implements ResponseBodyAdvice<ApiResponse<?>> {

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return returnType.getParameterType() == ApiResponse.class;
    }

    @Override
    public ApiResponse<?> beforeBodyWrite(
            ApiResponse body,
            MethodParameter returnType,
            MediaType selectedContentType,
            Class selectedConverterType,
            ServerHttpRequest request,
            ServerHttpResponse response
    ) {
        HttpStatus status = body.httpStatus();
        response.setStatusCode(status);

        return body;
    }
}

if 문 자체를 supports 메소드로 옮겨서 꼭 필요한 경우(응답 클래스가 ApiResponse인 경우)에만 beforeBodyWrite를 실행하도록 변경했다. 이를 통해 불필요한 메소드 호출을 줄이고 유지보수 면에서도 supports의 역할을 통해 언제 beforeBodyWrite가 실행되는지 더 쉽게 이해할 수 있다.

또한 해당 ResponseBodyAdvice에 취급하는 클래스가 ApiResponse임을 지정해줌을 통해 beforeBodyWrite에서 ApiResponse를 사용한다는 것을 확인할 수 있다. 그렇기에 이제 형변환을 할 필요가 없어졌다. 타입 지정과 supports를 통해 beforeBodyWrite에 들어오는 body가 ApiResponse임을 보장받을 수 있기 때문이다.

ResponseInterceptor naming

위에서 수정한 ResponseInterceptor는 네이밍만 보면 interceptor 같지만 실제로는 인터셉터가 아니다. 클래스 구현 내용만 보아도 ResponseBodyAdvice를 구현하는 advice이며 이러한 점이 네이밍과 패키징 방식에 따라 다른 개발자들에게 혼란을 줄 수 있다고 생각했다.

그렇기 때문에 ResponseInterceptor라는 클래스 이름을 수정하기로 했다.

해당 클래스가 하는 역할은 ApiResponse를 사용하는 응답의 HttpStatus를 의도한대로 바꾸는 것이다. 그렇기 때문에 해당 역할이 잘 드러나도록 이름을 변경해주었다. 일단 변경한 이름은 ResponseStatusSetterAdvice이다. 목적이 Response의 status를 set 해주는 것이기 때문에 이렇게 지었으며 어차피 ApiResponse만을 처리하는 것은 supports에서 잘 나타나기 때문에 굳이 명시하지 않았다.(사실 이름이 너무 길어질까봐...)

  • ResponseInterceptor -> ResponseStatusSetterAdvice 클래스명 변경 및 위치 변경
import org.sopt.carrot.dto.common.ApiResponse;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

@RestControllerAdvice
public class ResponseStatusSetterAdvice implements ResponseBodyAdvice<ApiResponse<?>> {

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return returnType.getParameterType() == ApiResponse.class;
    }

    @Override
    public ApiResponse<?> beforeBodyWrite(
            ApiResponse body,
            MethodParameter returnType,
            MediaType selectedContentType,
            Class selectedConverterType,
            ServerHttpRequest request,
            ServerHttpResponse response
    ) {
        HttpStatus status = body.httpStatus();
        response.setStatusCode(status);

        return body;
    }
}

마무리

이렇게 미미하지만 코드의 효율성을 높이고 전체적으로 봤을 때 의미없다고 느껴질 수 있는 부분을 제거함을 통해 코드를 보는 다른 개발자로 하여금 가독성을 높일 수 있었다.

관련 코드 pr:
https://github.com/tkdwns414/FleetingSpring/pull/5
https://github.com/tkdwns414/FleetingSpring/pull/6

0개의 댓글