마이크로 서비스에서,
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
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 과의 비교를 해보고 싶다.
긴 글 읽어주셔서 감사합니다~
'📕 Spring Framework' 카테고리의 다른 글
[Spring Boot] Restclient & HttpInterface 조합 사용 시, 요청 응답을 Logging 해보자 (1) | 2025.01.06 |
---|---|
Spring Boot 애플리케이션 k8s 환경에서 WarmUp 적용하기 (0) | 2024.03.17 |