Spring OpenFeign - 공통된 응답 포맷을 매번 선언해줘야 할까? (Decoder)

한규주·2021년 8월 8일
1
post-thumbnail

공통된 응답 포맷을 매번 선언해줘야 할까?

MSA를 구성하다보면 여러 upstream 서버들이 공통된 응답 포맷을 가지고 있을 때가 있다.

예를 들어 아래 포맷과 같은 응답을 갖고 있다고 한다면, 정상 응답의 경우 우리가 궁금한건 보통은 data안의 object가 궁금할 것이다.

{
  "result": "SUCCESS" | "FAILED",
  "data": object?,
  "error": errorFormat?
}

그렇다고 interface에서 해당 응답 포맷을 매번 선언해주기는 번거로운 일이다. result와 error는 우리가 관심있는 정보가 아닐 수 있기 때문이다.

또한, Spring OpenFeign에서 정상응답이 아닌 경우에 ErrorHandling을 따로 해줄 수 있기 때문에, 우리가 feign 구현체에서 기대하는 응답값은 모두 정상 응답을 받았을 경우라고 가정할 수 있다. 그렇기 때문에 더더욱 data가 가지는 정보만 관심 있게 되는 것이다.

그러면 Feign에서 응답으로 뱉어주는 값이 data 의 데이터만 포함할 수 있도록 직접 FeignDecoder를 만들어보도록 하자.

목표

FeignDecoder를 구현하고 나면 아래 코드가 가능해질 것이다

As Is

@FeignClient("beerClient")
interface BeerClient {
  @GetMapping
  fun beers(@RequestParam name: String): CommonResponse<List<Beer>>
}

data class CommonResponse<T?>(
  val result: String,
  val data: T?,
  val error: ErrorFormat
}

To Be

@FeignClient("beerClient")
interface BeerClient {
  @GetMapping
  fun beers(@RequestParam name: String): List<Beer>
}

FeignDecoder

GitHub에서 보기

import com.fasterxml.jackson.databind.type.TypeFactory
import feign.Response
import feign.codec.Decoder
import org.springframework.cloud.openfeign.support.ResponseEntityDecoder
import org.springframework.core.ResolvableType
import java.lang.reflect.Type

class BeerClientDecoder(decoder: Decoder) : ResponseEntityDecoder(decoder) {
    override fun decode(response: Response, type: Type): Any? {
        val returnType = TypeFactory.rawClass(type)
        val forClassWithGenerics = ResolvableType.forClassWithGenerics(CommonResponse::class.java, returnType)
        return runCatching {
            (super.decode(response, forClassWithGenerics.type) as CommonResponse<*>).data
        }.getOrDefault(
            super.decode(response, type)
        )
    }
}

주목할 점은 ResolvableType 를 만들어주기 위한 코드가 있다는 점이다. CommonResponse는 data의 type을 Generic으로 받고 있기 때문에 jackson이 이를 잘 parsing하게 하기 위함이다. ResolvableType는 나중에 따로 다룰 예정이다.

FeginDecoder 등록

그리고 우리의 BeerClient에 해당 Decoder를 등록해주자. OpenFeign의 default 동작은 바꾸는 설정은 이 도큐먼트에서 방법을 찾아볼 수 있다.

application.yaml

feign:
  client:
    config:
      default:
        decoder: com.hanqyu.example.feign.beer.BeerClientDecoder

테스트 (feat. Wiremock)

이제 잘 작동하는지 테스트를 해봐야 한다.

Wiremock을 이용해서 서버 응답을 mocking하고, 위에서 만든 BeerClientDecoder가 이 응답을 잘 파싱하는지 테스트할 것이다.

BeerClientTest.kt

GitHub에서 보기

@ExtendWith(SpringExtension::class)
@ImportAutoConfiguration(FeignAutoConfiguration::class, HttpMessageConvertersAutoConfiguration::class)
@SpringBootTest(classes = [FeignEnablingConfiguration::class, WireMockServerConfig::class])
internal class BeerClientTest {

    @Autowired
    private lateinit var sut: BeerClient

    @Autowired
    private lateinit var mockServer: WireMockServer

    @Test
    fun getBeer() {
        val responseBody: String = """
                {
                    "result": "SUCCESS",
                    "data": {
                        "items": [
                            {
                                "id": 1,
                                "name": "빅 웨이브",
                                "type": "ALE"
                            },
                            {
                                "id": 2,
                                "name": "카스",
                                "type": "LARGER"
                            }
                        ]
                    },
                    "error": null
                }
            """.trimIndent()

        mockServer.stubFor(
            get(urlPathEqualTo("/beers"))
                .withQueryParam("size", equalTo("2"))
                .withHeader("Authorization", equalTo("AUTH-KEY"))
                .withHeader("Content-Type", equalTo("application/json"))
                .willReturn(aResponse().withBody(responseBody).withHeader("Content-Type", "application/json"))
        )

        val then = sut.getBeer(2)
        then.items.size shouldBe 2
        then.items.map { it.name } shouldBe listOf("빅 웨이브", "카스")
        then.items.map { it.type } shouldBe listOf(Beer.Type.ALE, Beer.Type.LARGER)
    }
}

WireMockServerConfig (junit5를 사용하고 있을때)

원래 WireMock의 설명으로는 @Rule annotation만으로도 되었지만

@Rule
public WireMockRule wireMockRule = new WireMockRule(int port);

위는 junit4를 사용할 때의 스펙이다. junit5에서는 삭제되었기 때문에 아래로 대체하는게 좋다.

@TestConfiguration
class WireMockServerConfig {
    @Bean(initMethod = "start", destroyMethod = "stop")
    fun mockServer(): WireMockServer {
        return WireMockServer(8081)
    }
}

@SpringBootTest(classes = [WireMockServerConfig::class])
internal class BeerClientTest {
    @Autowired
    private lateinit var mockServer: WireMockServer
}

Trouble Shooting

InvalidDefinitionException (Kotlin 사용 환경)

Decoder까지 설정했는데 com.fasterxml.jackson.databind.exc.InvalidDefinitionException의 오류를 뱉으면서 안되는 경우가 있다. kotlin을 사용하는 경우 jackson이 Kotlin Module을 받아줘야할 필요가 있다. 아래 의존성을 추가해주자.

dependencies {
  ...
  implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
}

예제 소스

위 프로젝트는 아래 소스에서 참고해볼 수 있습니다.
https://github.com/hanqyu/feign-decoder-example

profile
토스페이먼츠의 서버개발자로 일하고 있습니다.

0개의 댓글