Spring Web, RestTemplate, Feign은 Http Client의 모듈이다. Http Client는 Http 프로토콜을 사용하여 서버와 통신하기 위해 클라이언트 측에서 사용되는 소프트웨어를 말한다. 서버와 요청과 응답을 주고받으며, 웹 서버의 데이터를 가져오거나, 데이터를 업로드 하거나, REST API 호출을 수행하는 등의 역할을 한다. 오늘은 Http Client의 모듈 중 Netflix에서 개발된 Feign에 대해 정리해보려고 한다.
패스트 캠퍼스 강의를 참고하며 학습하였습니다.
1. FeignClient란 무엇인가?
- Netflix에서 개발한 Http Client로 현재는 OpenFeign이라는 이름으로 오픈소스로 공개되었다. 이후 스프링 클라우드에서 스프링부트와 함께 사용할 수 있도록 지원하고 있다. 따라서 스프링 MVC 어노테이션을 사용할 수 있게 되었다.
- Feign Client는 자바 기반의 Http Client의 라이브러리 중 하나로 인터페이스를 작성하고 어노테이션을 선언하여 구현체 없이 HttpClient를 구현할 수 있다.
- 쉽게 생각한다면 Spring Data JPA에서 실제 쿼리를 작성하지 않고, 인터페이스만 지정하여 쿼리실행 구현체를 자동으로 만들어주는 것과 유사하다고 생각하면 된다.
- 이처럼 인터페이스와 어노테이션을 기반으로 자동으로 REST API 호출 코드를 생성하기에 선언적인(Declarative) API라고도 한다.
- 개발자는 구현 세부사항을 몰라도 간단하게 REST API를 호출할 수 있다!!
▶ Feign Client 동작 원리
클라이언트는 서버에게 특정 HTTP 메서드(GET, POST, DELETE, PUT 등) 요청을 보내면 해당 컨트롤러가 작업을 처리한. 동시에 Feign Client의 컨트롤러에서도 요청을 받은 후 작업을 처리한다.
2. Spring WebClient VS RestTemplate VS FeignClient[참고]
Feign이 생기기 전, Http Client 후보군에는 Spring WebClient, RestTemplate 등이 있다. 이들 모두 장단점이 있으므로 상황에 맞게 사용하면 좋을 것이다.
■ Spring Web Client
- 싱글쓰레드
- 비동기 → 요청할 때 Connection을 통해 요청하고 이후 이벤트를 통해 통신
- Non - Blocking 방식 → 작업을 요청해놓고 다른 작업을 하고 있다가 작업을 다 처리했다는 응답이 오면 그때 결과를 반환
- Spring WebClient를 사용하기 위해서는 spring-webflux 의존성을 추가해야하는데 해당 의존성은 상당히 무겁다. 스프링 WebFlux의 경우, 이벤트 루프 메커니즘을 사용하여 비동기 작업을 처리하기 때문에 효율측면에서는 좋지만 비동기 논블록킹이 꼭 필요하지 않다면 안쓰는 것이 좋다.
■ Rest Template
- 멀티 쓰레드
- 동기 → 요청자와 제공자 사이에 계속 Connection이 맺어져 있어야 한다.
- Blocking 방식 → 작업을 요청하고 응답이 올때까지 대기(해당 쓰레드는 작업이 완료될 때까지 사용할 수 없다.)
- 코드가 증가할수록 유지보수가 어렵다.
- 불필요한 코드들이 반복적으로 나타나고, 이에 따라 테스트에 어려움이 있다.
■ Feign Client
- 싱글 쓰레드
- 동기
- Blocking 방식
- 타 스프링 클라우드 기술 활용가능(Eureka, CircuitBreaker, Hystrix, LoadBalancer, Ribbon 등)
- 하지만 Hystrix와 함께 사용될 경우 멀티 쓰레드로 동작시켜 병렬 처리가 가능하다.
- SpringMVC에서 제공하는 애노테이션을 그대로 사용 가능
- 프록시를 통한 인터페이스 접근
RestTemplate과 Feign Client를 사용해보면서 느낀 점은 RestTemplate을 사용하기 위해 각각의 서비스 로직에 매번 의존성을 추가해줘야 한다. 따라서 RestTemplate과 Service 간에 결합이 높아지고, 코드 또한 서로 엉키게 된다. 반면 FeignClient는 인터페이스에서 관리하므로 서비스와 엉킬일이 없고, 서비스에서 신경 쓰지 않아도 된다. 즉, 이것은 관심사의 분리가 된 것이다. AOP를 통해 중복 코드 제거가 가능해지고, 재활용할 수 있고, 유지보수를 쉽게 할 수 있게 된다.
3. FeignClient의 특징
- 선언적 방식의 API 호출 → API 호출의 목적을 명확히 하고, 코드 가독성을 높일 수 있음
- 인터페이스 기반 코드 작성 → HTTP 요청을 FeignClient가 자동으로 처리해주기 때문에 개발자는 핵심 로직에 집중 가능
- 클라이언트 코드의 의존성 관리 : 인터페이스를 생성하고 이를 사용하여 클라이언트 코드의 의존성 관리를 용이하게 함
- 로드밸런싱 : Eureka, Ribbon과 같은 서비스 디스커버리 프레임워크와 함께 사용할 때 로드밸런싱을 자동으로 처리할 수 있음
- 서비스 디스커버리 : 클라이언트가 직접 서버를 찾을 필요가 없도록 한다.
- 다양한 인코딩 지원 : JSON, XML, form-encoded, binary 등 다양한 형식 지원
▶ Connection / Read Timeout
- 외부 서버와 통신 시 Connection / Read Timeout을 설정할 수 있다.
feign:
url:
prefix: http://localhost:8080/target_server # DemofeignClient 인터페이스에서 사용할 url prefix 값
client:
config:
default:
connectTimeout: 1000
readTimeout: 3000
loggerLevel: NONE
demo-client: # DemoFeignClient에서 사용할 Client 설정 값
connectTimeout: 1000
readTimeout: 10000
loggerLevel: HEADERS #여기서 설정한 값은 FeignCustomLogger -> Logger.Level logLevel 변수에 할당됨
# [loggerLevel ??]
# ref : feign.Logger.Level
# ```
# NONE, // No logging.
# BASIC, // Log only the request method and URL and the response status code and execution time.
# HEADERS, // Log the basic information along with request and response headers.
# FULL // Log the headers, body, and metadata for both requests and responses.
# ```
▶ Feign Interceptor
- 외부로 요청이 나가기 전에 만약 공통적으로 처리해야하는 부분이 있다면 Interceptor를 재정의하여 처리가 가능하다.
import static java.nio.charset.StandardCharsets.UTF_8;
import org.apache.commons.lang3.StringUtils;
import feign.Request.HttpMethod;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(staticName = "of")
public final class DemoFeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) { // 필요에 따라 template 필드 값을 활용하자!
// get 요청일 경우
if (template.method() == HttpMethod.GET.name()) {
System.out.println("[GET] [DemoFeignInterceptor] queries : " + template.queries());
// ex) [GET] [DemoFeignInterceptor] queries : {name=[CustomName], age=[1]}
return;
}
// post 요청일 경우
String encodedRequestBody = StringUtils.toEncodedString(template.body(), UTF_8);
System.out.println("[POST] [DemoFeignInterceptor] requestBody : " + encodedRequestBody);
// ex) [POST] [DemoFeignInterceptor] requestBody : {"name":"customName","age":1}
// Do Something
// ex) requestBody 값 수정 등등
// 새로운 requestBody 값으로 설정
template.body(encodedRequestBody);
}
}
▶ Feign Logger
- Request / Response 등 운영을 하기위한 적절한 Log를 남길 수 있다.
import static feign.Util.UTF_8;
import static feign.Util.decodeOrDefault;
import static feign.Util.valuesOrEmpty;
import java.io.IOException;
import feign.Logger;
import feign.Request;
import feign.Response;
import feign.Util;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
public class FeignCustomLogger extends Logger {
private static final int DEFAULT_SLOW_API_TIME = 3_000;
private static final String SLOW_API_NOTICE = "Slow API";
@Override
protected void log(String configKey, String format, Object... args) {
// log를 어떤 형식으로 남길지 정해준다.
System.out.println(String.format(methodTag(configKey) + format, args));
}
@Override
protected void logRequest(String configKey, Logger.Level logLevel, Request request) {
/**
* [값]
* configKey = DemoFeignClient#callGet(String,String,Long)
* logLevel = BASIC # "feign.client.config.demo-client.loggerLevel" 참고
*
* [동작 순서]
* `logRequest` 메소드 진입 -> 외부 요청 -> `logAndRebufferResponse` 메소드 진입
*
* [참고]
* request에 대한 정보는
* `logAndRebufferResponse` 메소드 파라미터인 response에도 있다.
* 그러므로 request에 대한 정보를 [logRequest, logAndRebufferResponse] 중 어디에서 남길지 정하면 된다.
* 만약 `logAndRebufferResponse`에서 남긴다면 `logRequest`는 삭제해버리자.
*/
System.out.println("[logRequest] : " + request);
}
@Override
protected Response logAndRebufferResponse(String configKey, Logger.Level logLevel,
Response response, long elapsedTime) throws IOException {
/**
* [참고]
* - `logAndRebufferResponse` 메소드내에선 Request, Response에 대한 정보를 log로 남길 수 있다.
* - 매소드내 코드는 "feign.Logger#logAndRebufferResponse(java.lang.String, feign.Logger.Level, feign.Response, long)"에서 가져왔다.
*
* [사용 예]
* 예상 요청 처리 시간보다 오래 걸렸다면 "Slow API"라는 log를 출력시킬 수 있다.
* ex) [DemoFeignClient#callGet] <--- HTTP/1.1 200 (115ms)
* [DemoFeignClient#callGet] connection: keep-alive
* [DemoFeignClient#callGet] content-type: application/json
* [DemoFeignClient#callGet] date: Sun, 24 Jul 2022 01:26:05 GMT
* [DemoFeignClient#callGet] keep-alive: timeout=60
* [DemoFeignClient#callGet] transfer-encoding: chunked
* [DemoFeignClient#callGet] {"name":"customName","age":1,"header":"CustomHeader"}
* [DemoFeignClient#callGet] [Slow API] elapsedTime : 3001
* [DemoFeignClient#callGet] <--- END HTTP (53-byte body)
*/
String protocolVersion = resolveProtocolVersion(response.protocolVersion());
String reason = response.reason() != null
&& logLevel.compareTo(Level.NONE) > 0 ? " " + response.reason() : "";
int status = response.status();
log(configKey, "<--- %s %s%s (%sms)", protocolVersion, status, reason, elapsedTime);
if (logLevel.ordinal() >= Level.HEADERS.ordinal()) {
for (String field : response.headers().keySet()) {
if (shouldLogResponseHeader(field)) {
for (String value : valuesOrEmpty(response.headers(), field)) {
log(configKey, "%s: %s", field, value);
}
}
}
int bodyLength = 0;
if (response.body() != null && !(status == 204 || status == 205)) {
// HTTP 204 No Content "...response MUST NOT include a message-body"
// HTTP 205 Reset Content "...response MUST NOT include an entity"
if (logLevel.ordinal() >= Level.FULL.ordinal()) {
log(configKey, ""); // CRLF
}
byte[] bodyData = Util.toByteArray(response.body().asInputStream());
bodyLength = bodyData.length;
if (logLevel.ordinal() >= Level.HEADERS.ordinal() && bodyLength > 0) {
log(configKey, "%s", decodeOrDefault(bodyData, UTF_8, "Binary data"));
}
if (elapsedTime > DEFAULT_SLOW_API_TIME) {
log(configKey, "[%s] elapsedTime : %s", SLOW_API_NOTICE, elapsedTime);
}
log(configKey, "<--- END HTTP (%s-byte body)", bodyLength);
return response.toBuilder().body(bodyData).build();
} else {
log(configKey, "<--- END HTTP (%s-byte body)", bodyLength);
}
}
return response;
}
}
▶ Feign ErrorDecoder
- 요청에 대해 정상 응답이 아닌 경우 핸들링이 가능하다.
import org.springframework.http.HttpStatus;
import feign.Response;
import feign.codec.ErrorDecoder;
public final class DemoFeignErrorDecoder implements ErrorDecoder {
private final ErrorDecoder errorDecoder = new Default();
@Override
public Exception decode(String methodKey, Response response) {
final HttpStatus httpStatus = HttpStatus.resolve(response.status());
/**
* [참고]
* 외부 컴포넌트와 통신 시
* 정의해놓은 예외 코드 일 경우엔 적절하게 핸들링하여 처리한다.
*/
if (httpStatus == HttpStatus.NOT_FOUND) {
System.out.println("[DemoFeignErrorDecoder] Http Status = " + httpStatus);
throw new RuntimeException(String.format("[RuntimeException] Http Status is %s", httpStatus));
}
return errorDecoder.decode(methodKey, response);
}
}
4. FeignClient 사용법
1. openfeign 의존성 추가하기
ext {
/**
* Spring Boot and springCloudVersion must be compatible.
* 2.6.x, 2.7.x (Starting with 2021.0.3) = 2021.0.x
* ref : https://spring.io/projects/spring-cloud
*/
// Feign
set('springCloudVersion', '2021.0.3')
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
dependencies {
// Feign
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
}
- ext는 gradle 내에서 동작하는 변수이다.
- Spring Cloud는 스프링 부트와 호환되는 버전을 확인 후 사용해야한다. 아래의 사이트에서 Release Train을 참고하자.
2. @EnableFeignClients 애노테이션 선언하기
- 해당 애노테이션을 루트 패키지(일반적으로는 메인 클래스에 설정, 없을 경우 @ComponentScan의 basePackages에 지정해줘야함)에 선언함으로써 @FeignClient를 선언한 인터페이스를 찾아 자동으로 구현체를 만들어준다.
- ex) @EnableFeignClients(basePackages = "com.example.clients")
3. 인터페이스에 @FeignClient 애노테이션 선언하기
@FeignClient(
name = "demo-client", // application.yaml에 설정해 놓은 값을 참조
url = "${feign.url.prefix}", // application.yaml에 설정해 놓은 값을 참조 (= http://localhost:8080/target_server)
configuration = DemoFeignConfig.class)
public interface DemoFeignClient {
@GetMapping("/get") // "${feign.url.prefix}/get"으로 요청
ResponseEntity<BaseResponseInfo> callGet(@RequestHeader(CUSTOM_HEADER_NAME) String customHeader,
@RequestParam("name") String name,
@RequestParam("age") Long age);
@PostMapping("/post") // "${feign.url.prefix}/post"로 요청
ResponseEntity<BaseResponseInfo> callPost(@RequestHeader(CUSTOM_HEADER_NAME) String customHeader,
@RequestBody BaseRequestInfo baseRequestInfo);
@GetMapping("/errorDecoder")
ResponseEntity<BaseResponseInfo> callErrorDecoder();
}
- name : feign client의 이름
- url : 호출할 API url
- fallback : API 호출 시 발생하는 예외처리를 위한 클래스 정의
- configuration : FeignClient의 설정 정보를 등록. 사용자가 Bean으로 등록한 클래스를 사용할 수 있음[참고][참고]
- feign은 기본적으로 Decoder, Enclder, Logger, Contract, Feign.Builder, Client 등의 Bean을 제공한다.
- Logger.Level, Retryer, ErrorDecoder, Request.Options 등의 설정 정보는 빈으로 기본 제공이 되지 않으니 필요하면 따로 설정하여야 한다.
4. @RequestMapping 애노테이션으로 메타데이터, 메서드 정의
- 위 3번의 인터페이스처럼 Http 메서드를 정의해주고 부가적으로 필요한 헤더정보나 파라미터, 바디 정보를 설정하여 사용한다.
Reference
'Spring' 카테고리의 다른 글
[Spring Security] 스프링 시큐리티에 대한 흐름 이해 (0) | 2023.10.12 |
---|---|
[Spring Batch] Batch 이해하기 (0) | 2023.03.07 |
[Spring Boot JWT Tutorial - 인프런] 스프링 JWT 적용하기 part4. 회원가입 API 구현 및 권한 검증 확인 (0) | 2022.09.03 |
[Spring Boot JWT Tutorial - 인프런] 스프링 JWT 적용하기 part3. Repository, 로그인 API 구현 (0) | 2022.09.03 |
[Spring Boot JWT Tutorial - 인프런] 스프링 JWT 적용하기 part2. JWT 관련 설정하기 (0) | 2022.09.03 |