본문 바로가기
  • 개발하는 곰돌이
Development/Spring & Spring Boot

[Spring] ResponseBodyAdvice를 사용해서 API에 공통 응답 포맷 적용하기

by 개발하는 곰돌이 2025. 2. 25.

목차

    들어가기 전에

    프로젝트를 시작하게 되면 클라이언트가 서버에서 받은 응답을 원활하게 처리하도록 하기 위해 공통 응답으로 사용할 JSON 포맷을 정해서 봉투 패턴을 사용하는 경우가 많습니다.

    공통 응답 포맷을 적용하기 위해 컨트롤러 메소드의 반환 타입을 모두 공통 응답 DTO로 작성하게 됩니다. 문제는 컨트롤러 메소드를 작성할 때마다 매번 반환 객체를 공통 응답 DTO로 감싸줘야 해서 귀찮기도 하고, 실수로 누락할 수도 있다는 거죠. 그러다보니 자연스럽게 전역적으로 처리할 수 있을지 고민하게 됩니다.

     

    이럴 때 사용할 수 있는 ResponseBodyAdvice에 대해 알아봅시다.

    ResponseBodyAdvice

    ResponseBodyAdvice는 스프링에서 제공하는 인터페이스로, @ResponseBody를 달아놓은 컨트롤러 메소드나 ResponseEntity 타입을 반환하는 메소드가 실행됐을 때 응답을 커스터마이징할 수 있도록 지원합니다.

    ResponseBodyAdvice는 위와 같이 supportsbeforeBodyWrite라는 2개의 메소드로 구성되어 있습니다. supports에서 true를 반환하면 beforeBodyWrite가 실행되면서 응답 바디에 대해 여러가지 동작을 수행할 수 있습니다.

     

    다시 말해, ResponseBodyAdvice의 구현체만 잘 작성해주면 전역적으로 공통 응답 포맷을 적용할 수 있는거죠.

    ResponseBodyAdvice를 활용해보자!

    그러면 이제 본격적으로 ResponseBodyAdvice를 활용해서 공통 응답 포맷을 프로젝트 전역에 적용해보겠습니다.

    공통 응답 DTO 작성

    공통 응답 포맷을 적용하기 위해 우선 공통 응답 DTO를 작성합니다.

    data class CommonResponse<T>(
        val code: ResponseCode,
        val data: T? = null
    ) {
        val message: String = code.message
    }

    공통 응답 DTO는 enum class에서 정의된 응답 코드와 응답 코드에 대한 메시지, 실제 반환 데이터를 담고 있도록 구성했습니다.

    ResponseBodyAdvice 구현체를 작성하자!

    이제 프로젝트 전역에 공통 응답 포맷을 적용하기 위해 ResponseBodyAdvice 구현제를 작성해보겠습니다.

    @RestControllerAdvice
    class CommonResponseAdvice : ResponseBodyAdvice<Any?> {
        override fun supports(returnType: MethodParameter, converterType: Class<out HttpMessageConverter<*>>): Boolean {
            return true
        }
    
        override fun beforeBodyWrite(
            body: Any?,
            returnType: MethodParameter,
            selectedContentType: MediaType,
            selectedConverterType: Class<out HttpMessageConverter<*>>,
            request: ServerHttpRequest,
            response: ServerHttpResponse
        ): Any? {
            return CommonResponse(ResponseCode.SUCCESS, body)
        }
    }

    컨트롤러 메소드의 반환 타입에 상관없이 공통 응답 포맷으로 묶어주기 위해 ResponseBodyAdvice의 제네릭은 Any?로 설정하고, supports는 그냥 다른 로직 없이 true를 반환하도록 구현합니다. beforeBodyWrite도 마찬가지로 다른 로직 없이 응답으로 나가려던 본문을 공통 응답 포맷으로 묶어서 반환해주도록 구현합니다.

     

    이제 컨트롤러 메소드의 반환 타입을 CommonResponse로 포장하지 않고 원본 그대로 반환해주더라도 공통 응답 포맷으로 응답을 받을 수 있습니다!

    @RestController
    class UserController {
        @GetMapping("/user")
        fun user(): User {
            return User(id = 1L, name = "콜라곰", phone = "010-1234-5678", email = "colabear754@demo.com")
        }
    }

    테스트를 위해 위와 같이 컨트롤러 메소드와 테스트 코드를 작성하고 실행해보면 잘 통과하는 것을 볼 수 있죠.

    문제점

    하지만 위와 같이 ResponseBodyAdvice로 모든 응답을 포장하는 방식은 몇 가지 문제점이 있습니다. 차근차근 살펴보도록 하겠습니다.

    ExceptionHandler의 응답도 포장된다

    프로젝트에서 전역적으로 예외 처리를 하기 위해 RestControllerAdvice와 ExceptionHandler를 조합해서 사용하는 경우를 생각해봅시다.

     

    예외가 발생했을 때의 응답은 예외의 종류에 따라 각기 다른 HTTP 응답 코드를 사용해야 하고, 공통 응답 포맷으로 응답을 반환하더라도 예외 종류에 알맞은 에러 코드를 반환해야 합니다.

    @RestControllerAdvice
    class GlobalExceptionHandler {
        @ExceptionHandler(IllegalArgumentException::class)
        fun handleIllegalArgumentException(): ResponseEntity<CommonResponse<*>> {
            return ResponseEntity.badRequest().body(CommonResponse(ResponseCode.ERROR01, null))
        }
    
        @ExceptionHandler(Exception::class)
        fun handleException(e: Exception): ResponseEntity<CommonResponse<*>> {
            e.printStackTrace()
            return ResponseEntity.internalServerError().body(CommonResponse(ResponseCode.ERROR99, null))
        }
    }

     

    하지만 처음에 구현해놓은 CommonResponseAdvice와 같이 모든 응답을 성공 응답으로 포장해버리면 예외가 발생해서 에러 코드를 담은 공통 응답 포맷을 반환하더라도 그 공통 응답 포맷조차 모두 SUCCESS로 포장되어 반환됩니다.

    이런 문제를 방지하기 위해 CommonResponseAdvice의 내용을 조금 수정하겠습니다.

    @RestControllerAdvice
    class CommonResponseAdvice : ResponseBodyAdvice<Any?> {
        override fun supports(returnType: MethodParameter, converterType: Class<out HttpMessageConverter<*>>): Boolean {
            return true
        }
    
        override fun beforeBodyWrite(
            body: Any?,
            returnType: MethodParameter,
            selectedContentType: MediaType,
            selectedConverterType: Class<out HttpMessageConverter<*>>,
            request: ServerHttpRequest,
            response: ServerHttpResponse
        ): Any {
            return if (body?.javaClass != CommonResponse::class.java) {		// body 타입 검사 조건 추가
                CommonResponse(ResponseCode.SUCCESS, body)
            } else {
                body
            }
        }
    }

    이미 공통 응답 포맷을 반환하는 경우에도 다시 공통 응답 포맷으로 감싸주는 것이 문제가 되기 때문에 body의 타입이 CommonResponse가 아닌 경우에만 공통 응답 포맷으로 감싸주도록 수정했습니다.

    다시 테스트 코드를 실행해보면 테스트에 성공하는 것을 확인할 수 있습니다.

     

    이외에도 다음과 같이 @RestControllerAdvice에서 ResponseBodyAdvice를 적용할 패키지만 basePackages에 지정하는 방법으로도 처리할 수 있습니다.

    // ResponseBodyAdvice를 적용할 패키지 지정
    @RestControllerAdvice(basePackages = ["com.colabear754.responsebody_advice_demo.controller"])
    class CommonResponseAdvice : ResponseBodyAdvice<Any?> {
        override fun supports(returnType: MethodParameter, converterType: Class<out HttpMessageConverter<*>>): Boolean {
            return true
        }
    
        override fun beforeBodyWrite(
            body: Any?,
            returnType: MethodParameter,
            selectedContentType: MediaType,
            selectedConverterType: Class<out HttpMessageConverter<*>>,
            request: ServerHttpRequest,
            response: ServerHttpResponse
        ): Any {
            return if (body?.javaClass != CommonResponse::class.java) {
                CommonResponse(ResponseCode.SUCCESS, body)
            } else {
                body
            }
        }
    }

    되도록이면 두가지 방법을 모두 사용하는 것이 보다 안정적일 것이라고 생각합니다.

    String 타입을 반환하면 에러가 발생한다!

    진짜 문제는 String 타입을 반환하는 메소드가 있는 경우에 발생합니다.

    이렇게 반환 타입이 String인 컨트롤러 메소드가 있을 때는 아래와 같은 형식으로 반환이 되기를 기대했습니다.

    하지만 실제로 이를 검증하기 위한 테스트 코드를 실행해보면 ClassCastException이 발생하는 것을 볼 수 있습니다.

    예외 메시지를 확인해보면 CommonResponse 클래스를 String으로 캐스트할 수 없다고 합니다. 분명히 CommonResponseAdvice에서 컨트롤러 메소드의 응답값을 포장해서 반환하게 했는데 갑자기 String으로 캐스트를 시도하고 있는걸 보면 뭔가 이상합니다.

    왜 String으로 캐스트를 하고 있을까?

    ResponseBodyAdvicesupports 메소드를 보면 converterType이라는 파라미터가 있습니다. HttpMessageConverter의 서브 타입을 검사하기 위한 파라미터인데, 컨트롤러 메소드의 응답을 클라이언트에서 받을 수 있는 형태로 변환할 컨버터의 타입을 나타내는 파라미터죠.

     

    디버그를 통해 두 경우를 비교해보면 차이를 알 수 있습니다.

    일반적인 경우

    일반적인 경우에는 converterType으로 MappingJackson2HttpMessageConverter를 사용합니다.

    컨트롤러 메소드가 String을 반환하는 경우

    하지만 컨트롤러 메소드가 String을 반환할 때는 StringHttpMessageConverter를 사용하는 것을 볼 수 있습니다. 이 두 클래스는 모두 AbstractHttpMessageConverter를 상속받은 클래스로, writeInternal라는 메소드를 호출해서 응답값을 기록하게 됩니다.

    writeInternal은 제네릭을 사용하는 것을 볼 수 있습니다.

    그런데 이를 상속받은 StringHttpMessageConverter는 제네릭을 String 타입으로 지정해버렸죠. 이 때문에 str으로 어떤 타입이 들어오든 String으로 처리할 수 있도록, 컴파일 과정에서 자동으로 캐스팅 코드가 삽입되어 버립니다.

     

    supports에서 이미 컨버터가 StringHttpMessageConverter로 지정되어 버렸으니, beforeBodyWrite에서 응답값을 CommonResponse로 포장해버리면 컴파일 과정에서 자동으로 삽입된 캐스팅 코드가 이 객체를 String으로 캐스팅하기 때문에 ClassCastException이 발생하게 되는거죠.

    String을 반환하면 공통 응답 포맷을 적용하지 말자!

    컨트롤러 메소드가 String 타입을 반환하면 예외가 발생해버리기 때문에 이 경우는 공통 응답 포맷 적용 대상에서 제외하도록 하겠습니다. 물론, String에도 공통 응답 포맷을 적용할 방법은 있지만 스프링 설정을 변경해야하기 때문에 다른 글에서 따로 다루겠습니다.

     

    CommonResponseAdvicesupports를 다음과 같이 수정합니다.

    override fun supports(returnType: MethodParameter, converterType: Class<out HttpMessageConverter<*>>): Boolean {
        return !converterType.isAssignableFrom(StringHttpMessageConverter::class.java)
    }

    converterTypeStringHttpMessageConverter 또는 그 서브 클래스인 경우에는 false를 반환해서 beforeBodyWrite가 실행되지 않도록 수정했습니다.

     

    테스트 코드도 문자열 응답을 반환하는지 체크하도록 수정하고 실행을 해보면...

    테스트를 잘 통과하는 것을 볼 수 있습니다.

     

    실제로 @ResponseBody가 적용된 컨트롤러 메소드나 @RestController의 메소드에서 String을 반환하는 경우는 거의 없거나, 있더라도 서버 상태 체크용 메시지이기 때문에 공통 응답 포맷 적용 대상에서 제외하더라도 별다른 문제가 되지는 않습니다.

    참조 링크

     

    [SpringBoot] ResponseBodyAdvice를 이용한 공통 응답 처리와, 관련 트러블 슈팅

    클라이언트에게 전달되는 응답의 형식을 통일하는 ResponseBodyAdvice를 사용해본다. 이후 메시지 컨버터와 관련한 트러블 슈팅 과정을 살펴본다.

    velog.io

     

    문자열 응답 시 공통응답형식이 적용되지 않는 문제 개선하기

    MessageConverter가 결정되어 문자열을 DTO객체로 바꾸지 못하는 문제! 어떻게 해결할 수 있을까

    rokwonk.github.io

     

    댓글