Study

Spring Cloud OpenFeign + WireMock Test

Hyunec 2023. 2. 5. 02:26

Netflix OSS 에서 시작한 Feign 은 강력한 기능과 편의성으로 Spring Cloud 에 OpenFeign 으로 편입되었습니다.
OpenFeign 은 다음과 같은 장점을 가지고 있습니다.

  • 외부 연동 로직을 간소화 시킵니다.
  • MSA 구성에 큰 도움을 줍니다. SAGA 패턴, 마이크로서비스 통신
  • 탄력성 등 리액티브 시스템의 특성을 만족시키는데 유용한 패턴을 제공합니다.

 

OpenFeign 은 이미 널리 알려진 기술이기에 좋은 글들이 많지만 Kotlin + Test 로 작성된 글은 별로 없었습니다.
그래서 외부 연동을 준비하며 학습한 OpenFeign 의 기능을 구현과 테스트로 설명합니다.

 

GitHub - Hyune-s-lab/openfeign-spring-tutorial: openfeign 연습하기 with spring cloud

openfeign 연습하기 with spring cloud. Contribute to Hyune-s-lab/openfeign-spring-tutorial development by creating an account on GitHub.

github.com

 

1. OpenFeign 준비하기

// build.gradle.kts
extra["springCloudVersion"] = "2021.0.5"

dependencies {
	implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
}
@Configuration
@EnableFeignClients("com.example.openfeignspringtutorial.openfeign")
class OpenFeignConfig
  • feign 을 적용할 패키지를 지정합니다.

 

// application.yml
logging:
  level:
    com:
      example:
        openfeignspringtutorial:
          openfeign: DEBUG

feign:
  client:
    config:
      fake-api: // feign name
        logger-level: basic
        

// application-test.yml
logging:
  level:
    com:
      example:
        openfeignspringtutorial:
          openfeign: DEBUG

feign:
  client:
    config:
      fake-api: // feign name
        logger-level: full
  • openfeign 은 debug 레벨에서만 로깅이 되기 때문에 패키지를 지정해야 합니다.
  • openfeign logger level 은 필요에 맞게 설정합니다.

 

2. 외부 연동하기 - OpenFeign

@RestController
class PostController(private val fakeApiOpenFeign: FakeApiOpenFeign) {
    @GetMapping("/posts/{postId}")
    fun getPost(@PathVariable postId: Long): PostResponse = fakeApiOpenFeign.getPost(postId)
}


@FeignClient(name = "fake-api", url = "https://jsonplaceholder.typicode.com")
interface FakeApiOpenFeign {
    @GetMapping("/posts/{postId}")
    fun getPost(@PathVariable postId: Long): PostResponse
}
  • PostController 로 들어온 GetPost 가 feign 을 통해 외부 url 을 호출하는 코드입니다.
  • 샘플 코드에서는 Fake Api 를 활용했습니다.

 

// application.yml
application:
  feign-url:
    fake-api: https://jsonplaceholder.typicode.com
@FeignClient(name = "fake-api", url = "\${application.feign-url.fake-api}")
interface FakeApiOpenFeign {
    @GetMapping("/posts/{postId}")
    fun getPost(@PathVariable postId: Long): PostResponse
}
  • url 을 application.yml 에서 관리할 수도 있습니다.

 

3. 테스트 준비하기

// build.gradle.kts
dependencies {
    // feign mock test
    testImplementation("com.github.tomakehurst:wiremock-jre8:2.35.0")
    
    // kotest, gson
    ...
}


// application-test.yml
application:
  feign-url:
    fake-api: http://localhost:9561
@TestConfiguration
class WireMockServerConfig : ApplicationListener<ApplicationReadyEvent> {
    @Bean
    fun wireMockServer(): WireMockServer =
        WireMockServer(9561)

    override fun onApplicationEvent(event: ApplicationReadyEvent) =
        wireMockServer().start()
}
  • wiremock 은 stub server 를 기동시킬 수 있는 라이브러리로 외부 연동 테스트에 적합합니다.
  • 테스트 설정에서는 애플리케이션 기동 완료 후 9561 포트로 wireMock 을 기동시킵니다.
    • @PostConstruct 보다는 순서를 고려하여 더 확실한 ApplicationReadyEvent 를 활용 합니다.

 

object WireMockServerConst {
    const val port = 9561
    const val rootResponsePath = "src/test/resources/__files/"

    object GetPosts {
        const val url = "/posts/([1-99])"
        const val responsePath = "payload/post.json"

        fun absoluteResponsePath(): String = rootResponsePath + responsePath
    }
}
// resources/__files/payload/post.json
{
  "userId": 1,
  "id": 1,
  "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
  "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
}
  • 테스트 편의를 위한 상수 값들을 하나의 파일로 응집시킵니다.
  • wiremock 은 파일 형태의 json response body 를 지원 합니다. 기본 경로는 resources/__files 입니다.

 

4. 정상 응답 테스트

@ActiveProfiles("test")
@SpringBootTest(classes = [WireMockServerConfig::class])
internal class FakeApiOpenFeignTest(
    private val wireMockServer: WireMockServer,
    private val fakeApiOpenFeign: FakeApiOpenFeign,
) : BehaviorSpec({
    Given("getPosts stub - success") {
        wireMockServer.stubGetPosts(
            WireMock.aResponse()
                .withStatus(HttpStatus.OK.value())
                .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .withBodyFile(WireMockServerConst.GetPosts.responsePath)
        )

        When("feign 으로 getPost 호출") {
            val then = fakeApiOpenFeign.getPost(1L)

            Then("response 일치") {
                val expected: PostResponse = Gson().fromJson(
                    JsonReader(FileReader(WireMockServerConst.GetPosts.absoluteResponsePath())),
                    PostResponse::class.java
                )
                then shouldBe expected
            }
        }
    }
})

private fun WireMockServer.stubGetPosts(responseDefinitionBuilder: ResponseDefinitionBuilder) {
    stubFor(
        WireMock.get(WireMock.urlPathMatching(WireMockServerConst.GetPosts.url))
            .willReturn(responseDefinitionBuilder)
    )
}
  • wiremock stub server 와 gson 을 활용해 테스트할 수 있습니다.
  • kotlin 의 확장 함수를 통해 stub 생성 메서드를 깔끔하게 분리할 수 있습니다.

 

5. 예외 처리하기 - ErrorDecoder

// application.yml
feign:
  client:
    config:
      fake-api:
        logger-level: basic
        error-decoder: com.example.openfeignspringtutorial.openfeign.FakeApiErrorDecoder
class FakeApiErrorDecoder : ErrorDecoder {
    private val log = LoggerFactory.getLogger(this.javaClass)

    override fun decode(methodKey: String, response: Response): Exception {
        log.info("### ${response.status()}, methodKey = $methodKey");

        if (response.status() == HttpStatus.BAD_REQUEST.value()) {
            return ResponseStatusException(
                HttpStatus.valueOf(response.status()),
                "<You can add error message description here>"
            )
        }

        if (response.status() == HttpStatus.NOT_FOUND.value()) {
        	...
        }

        if (response.status() == HttpStatus.INTERNAL_SERVER_ERROR.value()) {
        	...
       	}

        return Exception(response.reason())
    }
}
  • openfeign 은 ErrorDecoder 를 통해 응답코드에 맞는 예외처리를 지원합니다.
  • 기본적으로 ResponseStatusException 으로 반환하는 것을 추천합니다.

 

    Given("getPosts stub - BAD_REQUEST") {
        wireMockServer.stubGetPosts(WireMock.badRequest())

        When("feign 으로 getPost 호출") {
            val then = shouldThrow<ResponseStatusException> {
                fakeApiOpenFeign.getPost(1L)
            }

            Then("BAD_REQUEST 확인") {
                then.status shouldBe HttpStatus.BAD_REQUEST
            }
        }
    }

    Given("getPosts stub - INTERNAL_SERVER_ERROR") {
        wireMockServer.stubGetPosts(WireMock.serverError())

        When("feign 으로 getPost 호출") {
            val then = shouldThrow<ResponseStatusException> {
                fakeApiOpenFeign.getPost(1L)
            }

            Then("INTERNAL_SERVER_ERROR 확인") {
                then.status shouldBe HttpStatus.INTERNAL_SERVER_ERROR
            }
        }
    }
  • wiremock 은 간단하게 응답코드를 변경해서 내려보내줄 수 있습니다.

 

6. 목표 서버 Connection refused 대응하기 - retry

@Configuration
@EnableFeignClients("com.example.openfeignspringtutorial.openfeign")
class OpenFeignConfig {
    @Bean
    fun retryer(): Retryer {
        return Retryer.Default(300, 2000, 3)
    }
}
    Given("getPosts stub - Connection refused") {
        wireMockServer.stop()

        When("feign 으로 getPost 호출") {
            val then = shouldThrow<RetryableException> {
                fakeApiOpenFeign.getPost(1L)
            }

            Then("Connection refused 확인") {
                then.message shouldStartWith "Connection refused"
            }
        }
    }
  • RetryableException 발생시 등록된 retryer 로직이 동작 합니다.

 

마무리

openfeign 사용에 필요한 내용들을 어느 정도 정리했지만 실무를 위해서는 고민할 점이 남아있습니다.
하지만 이번 글에서는 키워드만 공유하고 좀 더 실무에 적용해본 후 정리하겠습니다.

  • Connection refused 외에도 재시도할 수 있을까? - ErrorDecoder Custom
  • 200 OK 예외는 어떻게 해야 될까? - ErrorDecoder Custom
  • 대상 서버의 장애가 장기화되면 어떻게 해야 될까? - Circuit Breaker, FallBack