Netflix OSS 에서 시작한 Feign 은 강력한 기능과 편의성으로 Spring Cloud 에 OpenFeign 으로 편입되었습니다.
OpenFeign 은 다음과 같은 장점을 가지고 있습니다.
- 외부 연동 로직을 간소화 시킵니다.
- MSA 구성에 큰 도움을 줍니다. SAGA 패턴, 마이크로서비스 통신
- 탄력성 등 리액티브 시스템의 특성을 만족시키는데 유용한 패턴을 제공합니다.
OpenFeign 은 이미 널리 알려진 기술이기에 좋은 글들이 많지만 Kotlin + Test 로 작성된 글은 별로 없었습니다.
그래서 외부 연동을 준비하며 학습한 OpenFeign 의 기능을 구현과 테스트로 설명합니다.
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
'Study' 카테고리의 다른 글
토스 SLASH 22 를 보고.. (0) | 2022.07.11 |
---|---|
INSERT INTO SELECT SHARED LOCK은 레코드 락으로 작동하는가? with MySql (0) | 2022.07.06 |
for vs stream (0) | 2022.06.01 |
Entity의 field type은 무엇이 적합할까? (0) | 2022.05.25 |
분산 트랜잭션 설계하기 (초급) (0) | 2022.05.09 |