본문 바로가기
📕 Spring Framework

Spring Cloud OpenFeign 더 잘 사용해보기

by SweeetGogum 2024. 5. 11.

마이크로 서비스에서,

Spring Boot 를 이용하여 애플리케이션을 개발할 때면,

외부 API 를 호출해야 하는 상황이 존재한다.

 

Spring 프레임워크가 지원하는 여러 가지 Http Client 가 있다.

 

RestTemplate 의 경우 Blocking 방식으로 Http 요청을 진행할 수 있다.

하나의 요청을 위해, 코드를 작성하려면,

재사용성을 고려한다 할때, 작디 작은 컴포넌트로 추상화를 많이 진행하여 번거로운 코드를 작성해야 한다는 점이 있었다.

무엇보다 어떠한 요청을 하는지 한눈에 들어오지 않았다.

 

WebClient 의 경우, Non-Blocking 방식으로 Http 요청을 진행할 수 있다.

물론 Blocking call 도 가능하다.

빌더 패턴을 활용한 방식으로, RestTemplate 보다는 가독성이 많이 나아졌고,

Error Handling 도 체이닝 메소드를 통해 쉽게 진행이 가능하다는 장점이 있다.

하지만 Blocking 방식의 Call 만을 한다면 크게 사용하려는 의미가 있나 싶다.

무엇보다, WebFlux 의존성을 추가해야 될 뿐만 아니라,

WebFlux 에 대한 이해도가 필요하다는 단점이 존재한다.

 

그 중 개인적으로 선언형 인터페이스

가장 작성하기 쉽고, 가독성이 좋은,

Open Feign Client 를 사용해보며 느낀 장점과 특징에 대해서 정리해 보려 한다.

 

[Open Feign 배경]

Netflix 진영이 만든, 선언형 Http Client 인터페이스이다.

Spring Cloud 생태계로 통합하며,

Spring MVC 애노테이션을 지원하여, Spring Boot 애플리케이션에서 더욱 작성하기 쉬워졌다.

또한, Spring Cloud 의 다양한 기술들인 유레카, 서킷 브레이커, 로드 밸런서와의 통합이 쉽다는 장점이 있다.

 

[사용 방법]

implementation("org.springframework.cloud:spring-cloud-starter-openfeign")

 

Gradle 빌드 툴을 사용한다면, 해당 의존성을 추가하면 끝이다.

그 외, 기본 적인 설정 방법은 생략하겠다.

 

@FeignClient(
    name = "appleClient"
)
interface AppleClient {

    @GetMapping("/api/v1/apples/{id}")
    fun getApple(
        @PathVariable id: Long,
    ): ResponseEntity<GetAppleDTO?>
}

data class GetAppleDTO(
    val id: Long,
    val name: String,
)

 

이게 끝이다.

정말 간단하지 않은가?

 

하지만, 우리가 궁금한 것은 이게 끝이 아닐 것이다.

더 세부적인 설정을 통해 Feign Client 를 좀 더 잘 활용할 수 있는 방법을 알아 보자.

 

 

[application.yaml]

Spring Boot 는 application.yaml 프로파일을 통해,

애플리케이션 개발에 필요한 다양한 설정을 코드 대신 간결하게 설정할 수 있게 도와 준다.

 

Spring Cloud Open Feign 의 경우도 마찬가지이다.

spring:

  cloud:
    openfeign:
      okhttp:
        enabled: true
      client:
        config:
          default:
            logger-level: full
            connect-timeout: 1000
            read-timeout: 3000
          appleClient:
            url: ${APPLE_SERVICE_HOST}
            dismiss404: true

 

기본적으로 위의 세팅을 가져가는 편이다.

Open Feign 은 구현체로 다양한 Http Client 를 사용하는데,

이중 Apache Http Client 대신 OkHttp 를 구현체로 이용하였다.

간략한 사용 이유는, 적당한 요청 수이며, 초당 요청 수에 적은 메모리를 사용하는 장점이 있기 때문이다.

 

Apache Http VS OkHttp 

궁금하신 분은 아래 블로그를 참조하길 바란다.

https://mindule.tistory.com/27

 

OkHttp VS Apache HttpClient

새벽에 알림이 울렸다. 불길하다. 예감이 맞다. 장애가 발생했다. 허겁지겁 PC를 켜고 여기 저기 살펴봤다. 로그를 보고 깜짝 놀랐다. 예상과 달리 OkHttp에서 에러가 발생했다. 난 많이 쓰는 오픈

mindule.tistory.com

 

config 하위 설정으로는,

기본 설정으로 당연히 모니터링을 위해 logger level 은 full 로 설정하였고,

connect timeout, read timeout 의 경우, 서버 리소스에 맞게 충분히 설정하면 될 것 같다.

 

그리고 앞선 코드의 appleClient 에 대한 url 에 대한 Host 를 환경 변수로 사용했고,

dismiss404 옵션을 활용하여, 404 예외를 무시하는 방향으로 설정했다.

true 로 설정하면 서버로부터 404 응답 시 따로 예외를 발생시키지 않고, 무시한다.

 

[Custom Logger]

Feign Client 를 사용할 때 모니터링을 위해서는,

외부 API Call 에 logging 을 진행해야 할 것이다.

 

그 때 Feign 의 Logger 추상 클래스를 상속하여 Custom 을 진행할 수 있는데,

기본적으로 요청과 응답에 대해서는 디테일하게 logging 을 진행하는 편이 향후 모니터링이 수월할 것이다.

 

class FeignClientCustomLogger : Logger() {

    companion object {
        private val logger = LoggerFactory.getLogger(FeignClientCustomLogger::class.java)
    
        private const val ERROR: String = "error"
        private const val REMOTE_SERVICE: String = "remote-service"
        private const val METHOD: String = "method"
        private const val URL: String = "url"
        private const val HEADERS: String = "headers"
        private const val REQUEST: String = "request"
        private const val RESPONSE: String = "response"
        private const val STATUS: String = "status"
        private const val ERROR: String = "error"
    }

    override fun logRequest(configKey: String, logLevel: Level, request: Request) {
        val log = mapOf<String, String>(
            REMOTE_SERVICE to configKey,
            METHOD to request.httpMethod().name,
            URL to request.url(),
            HEADERS to getHeaders(request).toString(),
            REQUEST to getRequestBody(request)
        )
        logger.info(log)
    }

    override fun logAndRebufferResponse(
        configKey: String,
        logLevel: Level,
        response: Response,
        elapsedTime: Long
    ): Response {
        var chainingResponse = response
        var responseBody: Any = ""
        if (hasResponseBody(response)) {
            val bodyData: ByteArray = Util.toByteArray(response.body().asInputStream())
            responseBody = logger.jsonLogFormat(bodyData)
            chainingResponse = response.toBuilder().body(bodyData).build()
        }
        val log = mapOf(
            REMOTE_SERVICE to configKey,
            RESPONSE to responseBody,
            STATUS to response.status()
        )

        logger.info(log)
        return chainingResponse
    }

    override fun logIOException(
        configKey: String,
        logLevel: Level,
        ioe: IOException,
        elapsedTime: Long
    ): IOException {
        val errorMessage = "Error occured Class: ${ioe.javaClass.simpleName}, Error message: ${ioe.message}"
        val log = mapOf(
            REMOTE_SERVICE to configKey,
            ERROR to errorMessage
        )
        logger.info(log)
        return ioe
    }

    override fun log(configKey: String, format: String, vararg args: Any?) = Unit

    private fun getHeaders(request: Request): Map<String, Any> {
        val headerMap: MutableMap<String, Any> = HashMap()
        request.headers().forEach { entry ->
            headerMap[entry.key] = entry.value.first()
        }
        return headerMap
    }

    private fun getRequestBody(request: Request): String {
        return request.charset()?.let { String(request.body(), request.charset()) } ?: ""
    }

    private fun hasResponseBody(response: Response): Boolean {
        val status: Int = response.status()
        return response.body() != null &&
            !(status == HttpStatus.NO_CONTENT.value() || status == HttpStatus.RESET_CONTENT.value())
    }
}

 

요청에 대한 logging 으로

외부 서비스 호스트,

Http Method,

url,

header 정보,

request payload 를 포함하였다.

 

응답에 대한 logging 으로

외부 서비스 호스트,

response payload,

response status 를 포함하였다.

 

IOException 에 대한 핸들링은,

외부 서비스와 발생한 에러 메시지를 로깅하였다.

 

위와 같이 진행하면,

더욱 Feign 을 이용한 외부 API 요청 및 응답에 대한 로깅을 세부적으로 진행할 수 있을 것이다.

 

[Custom Request Interceptor]

Feign Client 를 사용하여 선언형 인터페이스를 작성할 때,

Request 에 대한 공통적인 설정을 진행할 수 있다.

 

예를 들어, Header 를 통해 값을 전달해야 할 경우,

@RequestHeader 애노테이션을 통한 헤더 값을 전달할 수 있다.

하지만, 공통적으로 각종 Client 를 사용할 때의 헤더 설정으로 Feign 의 RequestInterceptor 인터페이스를 구현하여,

Custom 할 수 있다.

 

class TraceHeaderInterceptor : RequestInterceptor {

	companion object {
    	private const val X_REQUEST_ID: String = "x-request-id"
        private const val REQUEST_ID: String = "request-id"
    }

    override fun apply(template: RequestTemplate) {
        template.header(X_REQUEST_ID, MDC.get(REQUEST_ID))
    }
}

 

이렇게 모니터링을 위한 Trace 를 Logging 하려거나,

특정 헤더 값을 추가할 때 사용할 수 있을 것이다.

 

[Custom Error Decoder]

Feign 에서는 ErrorDecoder 라는 인터페이스를 지원한다.

에러에 대한 핸들링을 진행할 수 있게 해주는 인터페이스인데,

 

특별한 설정이 없다면 그 구현체인 Default 클래스가 동작한다.

    @Override
    public Exception decode(String methodKey, Response response) {
      FeignException exception = errorStatus(methodKey, response);
      Date retryAfter = retryAfterDecoder.apply(firstOrNull(response.headers(), RETRY_AFTER));
      if (retryAfter != null) {
        return new RetryableException(
            response.status(),
            exception.getMessage(),
            response.request().httpMethod(),
            exception,
            retryAfter,
            response.request());
      }
      return exception;
    }

 

Retryable Exception 을 기본적으로 발생시킨다.

이 기본 설정을 외부 애플리케이션들의 형편에 맞게 Custom 하고 싶을 때가 있을 것이다.

 

예를 들면,

서버가 죽어 있을 때에 컨테이너 응답에 대한 설정

타임 아웃 시의 설정 등

다양한 Custom 상황이 있을 것이다.

 

이 부분을 구현하면 될 것 같다.

 

class CustomErrorDecoder(
	private val objectMapper: ObjectMapper
): ErrorDecoder {

    override fun decode(methodKey: String, response: Response): Exception {
        val exception = FeignException.errorStatus(methodKey, response)
        val retryStatus = listOf(
            HttpStatus.BAD_GATEWAY.value(),
            HttpStatus.SERVICE_UNAVAILABLE.value(),
            HttpStatus.GATEWAY_TIMEOUT.value()
        )
        
        // 추가적으로 Apple Service 에러 핸들링에 필요한 상태를 정의했다.
        val appleServerErrorStatus = listof(
        	HttpStatus.BAD_REQUEST.value(),
            HttpStatus.UNAUTHORIZED.value(),
            HttpStatus.NOT_FOUND.value(),
            HttpStatus.INTERNAL_SERVER_ERROR.value()
        )
 
        val errorResponseBytes = response.body().asInputStream().readAllBytes()
        val appleServerErrorResponseDTO = objectMapper.readValue(
            errorResponseBytes,
            AppleServerErrorResponseDTO::class.java
        )
        
        return when (response.status()) {
            in retryStatus -> RetryableException(
                response.status(),
                exception.message,
                response.request().httpMethod(),
                exception,
                null,
                response.request()
            )
            
            in appleServerErrorStatus -> InternalServerAppleException(
                errorResponseBody = appleServerErrorResponseDTO
            ) // apple service 에러일 때, Custom Exception 을 발생시켜 상위 클래스에서 제어 진행.

            else -> exception
        }
    }
}

 

이렇게 컨테이너 사용 시, 기본적으로 발생할 수 있는 502, 503, 504 응답 코드 이외에,

apple service 만의 error status code 를 따로 진행하여 custom exception 을 발생 시켰다.

그렇게 하면 상위 apple client service [ apple client 를 호출하여 인프라 단 비즈니스를 작성하는 클래스 ]

에서 핸들링을 진행할 수 있을 것 같다.

 

[Default Feign Client Configuration]

위 기본 설정을 한번에 묶어보자

 

@Configuration
class DefaultFeignConfig {

    @Bean
    fun customLogger(): Logger = FeignClientCustomLogger()

    @Bean
    fun defaultRetryPolicy(): Retryer {
        return Retryer.Default()
    }

    @Bean
    fun errorDecoder(): ErrorDecoder {
        return CustomErrorDecoder()
    }

    @Bean
    fun headerInterceptor(): TraceHeaderInterceptor {
        return TraceHeaderInterceptor()
    }
}

 

기본 설정은 다음과 같다.

하지만 apple service 에는 따로 Retry 를 진행하지 않고 싶다.

 

그렇게 하기 위해서, apple service configration 으로 override 하고 싶다.

 

class AppleClientConfig(
    private val objectMapper: ObjectMapper,
) {

    @Bean
    fun appleClientHeaderInterceptor() = AppleClientHeaderInterceptor()

    @Bean
    fun errorDecoder(): ErrorDecoder = AppleCustomErrorDecoder(objectMapper)
}

 

이렇게 추가 후 AppleClient 인터페이스의 

@FeignClient 애노테이션 configuration 속성에 추가하면 될 것 같다.

 

@FeignClient(
    name = "appleClient",
    configuration = [AppleClientConfig::class.java]
)

 

이렇게 하면,

apple service 만의 feign client 설정이 완성 된다.

 

[정리]

Open Feign Client 를 사용하며,

실제 운영에서 보다 디테일한 설정을 통해 얻어간 경험과 정보를 정리해보고 싶었다.

이 글로 많은 분들이 Open Feign 설정을 편하게 하셨으면 하는 바램이다.

 

Open Feign 은 정말 사용하면 할 수록 편한 http client 라 생각 된다.

동기 호출에 있어서 기본 적으로 작성해야 할 코드도 적고, 가독성이 정말 좋고 그 외 많은 장점들이 있다.

 

이번에 Spring 6 에서 Rest Client 라이브리가 나왔는데,

WebClient 의 불필요한 단점을 개선한 동기 호출 목적의 http client 이다.

사용 방식과 체이닝 패턴 또한 동일하던데,

이 기술도 한번 사용해보고 장점 정리와 Feign 과의 비교를 해보고 싶다.

 

긴 글 읽어주셔서 감사합니다~

반응형