λ§μ΄ν¬λ‘ μλΉμ€μμ,
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 κ³Όμ λΉκ΅λ₯Ό ν΄λ³΄κ³ μΆλ€.
κΈ΄ κΈ μ½μ΄μ£Όμ μ κ°μ¬ν©λλ€~