Prologue
-
Feign 이라는 Client를 사용하여 Http 통신을 할 수 있다.
Feign에 대한 기본적인 개념은 이 글에서 다루지 않을 것이며
혹시라도 기본 개념을 모른다면 Spring Cloud Openfeign 글을 추천한다.
-
우선 이 글을 작성하는 이유는 다음과 같다.
Feign을 실제로 Code 레벨에서 어떻게 사용하는지 찾아보았는데
입맛에 맞는 자료를 찾을 수 없었고
그래서 직접 코드를 가다듬어서 만들기로 하였다.
그리고 그 내용들을 공유하면 좋겠다 싶어서 작성하게 되었다.
Feign Client 사용하기
- 우선 Feign Client를 사용하기 위해선 2가지 절차가 필요하다.
-
@EnableFeignClients 선언
-
Dependency 추가
@EnableFeignClients 선언
-
아래와 같이 원하는 위치에
@EnableFeignClients를 선언해주면 된다.
Example
@EnableFeignClients
@SpringBootApplication
public class GoodgidApplication {
public static void main(String[] args) {
SpringApplication.run(GoodgidApplication.class, args);
}
}
Dependency 추가
-
Feign과 관련된 Dependency를 추가해준다.
참고로 Spring Boot 버전에 따라 springCloudVersion 값이 다르므로 반드시 확인이 필요하다.
build.gradle
ext {
/**
* Spring Boot and springCloudVersion must be compatible.
* 2.4.x == 2020.0.x
* ref : https://spring.io/projects/spring-cloud
*/
// Feign
set('springCloudVersion', '2020.0.1')
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
dependencies {
// Feign
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
...
}
Feature
- 필자가 만들어놓은 Demo 프로젝트에 어떤 Feature가 있는지 알아보자.
Profile 설정
Set Default Profile when an inappropriate profile value is entered
public class YamlEnvironmentPostProcessor implements EnvironmentPostProcessor {
private static final String[] acceptsProfiles = { "local", "beta", "real" };
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment,
SpringApplication application) {
// Set Default Profile
boolean isValidProfileActive = environment.acceptsProfiles(Profiles.of(acceptsProfiles));
if (!isValidProfileActive) {
environment.setActiveProfiles("local");
Resource path = new ClassPathResource("config/application.yml");
try {
environment.getPropertySources().addLast(
new PropertiesPropertySourceLoader().load("application", path).get(0));
} catch (IOException e) {
e.printStackTrace();
}
}
...
}
}
-
실제 운영을 위한 프로젝트라면 Multi Profile 환경은 필수이다.
그리고 argument를 통해 사용하려는 Profile을 지정하는데
만약 유효하지 않은 argument가 input으로 들어오면 default profile이 설정되도록 하였다.
Example
-
VM 옵션으로 profile에 이상한 값을 주고
environement 값을 보면 다음과 같다.
VM option : -Dspring.profiles.active=goodGid
environement : StandardServletEnvironment {activeProfiles=[goodGid] ... }
-
environement의 activeProfiles 값이 goodGid으로 들어옴
-> environment.acceptsProfiles(Profiles.of(acceptsProfiles)) 값은 false를 return
-> 유효하지 않은 profile 값이라 판단
-> 강제로 local profile로 설정
-
이렇게 생성한 YamlEnvironmentPostProcessor.class를
Spring이 알아서 인식해주면 좋겠지만 그렇진 않다.
-
그러므로 Spring이 해당 class를 사용할 수 있는 설정이 필요하다.
관련된 설정 파일은 반드시 META-INF/spring.factories에 위치해야 한다.
META-INF/spring.factories
org.springframework.boot.env.EnvironmentPostProcessor=\
dev.be.goodgid.common.property.YamlEnvironmentPostProcessor
- 이유는 모르겠으나 EnvironmentPostProcessor.class에 그렇게 적혀있다.
EnvironmentPostProcessor.class
EnvironmentPostProcessor implementations have to be registered in META-INF/spring.factories,
using the fully qualified name of this class as the key.
Custom *.yml 사용
We can use custom *.yml property
-
Custom 하게 생성한 yml 파일을
Spring이 사용하기 위해선 추가로 설정이 필요하다.
Example
public class YamlEnvironmentPostProcessor implements EnvironmentPostProcessor {
private static final String[] propertyUris = { "classpath*:config/custom/*.yml" };
private final YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
private final ResourcePatternResolver resourcePatternResolver = new PathMatchingResourcePatternResolver();
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment,
SpringApplication application) {
try {
List<Resource> resourceList = new ArrayList<>();
for (String propertyUri : propertyUris) {
resourceList.addAll(List.of(resourcePatternResolver.getResources(propertyUri)));
}
resourceList.stream().map(this::loadYaml).forEach(them -> {
if (them != null) {
for (PropertySource<?> it : them) {
environment.getPropertySources().addLast(it);
}
}
});
} catch (Exception e) {
throw new BeanCreationException(e.getMessage(), e);
}
}
private List<PropertySource<?>> loadYaml(Resource resource) {
if (!resource.exists()) {
throw new IllegalArgumentException("Resource " + resource + " does not exist");
}
try {
return loader.load(resource.getURL().toString(), resource);
} catch (IOException ex) {
throw new IllegalStateException("Failed to load yaml configuration from " + resource, ex);
}
}
}
-
위 코드는 다음과 같은 역할을 한다.
-
propertyUris에 위치한 *.yml 파일 load
-> 각 yml 파일을 property로 추가
-
실제로 resourceList에는 url.yml 1개가 담겨있고
Application이 url.yml 에 있는 값을 사용할 수 있게 된다.
ErrorDecoder 사용
We can use ErrorDecoder for Feign
-
특정 Error Code Handling이 가능하다.
사용법은 @FeignClient의 configuration 속성에
설정한 Config 파일을 지정해주면 된다.
@FeignClient(
name = "demo-name",
url = "${feign.api.demo.url}",
configuration = DemoFeignConfig.class)
public interface DemoFeignClient {
...
}
public class DemoFeignConfig {
@Bean
public DemoFeignErrorDecoder DemoErrorDecoder() {
return new DemoFeignErrorDecoder();
}
}
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());
// Handle Custom Error Status Code
// The rest is delegated to the default error decoder
if (httpStatus == HttpStatus.NOT_FOUND) {
log.warn("Handle Custom Error Status. httpStatus : {}", httpStatus);
throw new CustomException();
}
return errorDecoder.decode(methodKey, response);
}
}
Connection/Read Timeout 설정
We can use Custom Timeout Property while using feign client
- 사용하려는 Client마다 Connection/Read Timeout 설정이 가능하다.
@FeignClient(
name = "demo-name",
url = "${feign.api.demo.url}",
configuration = DemoFeignConfig.class)
public interface DemoFeignClient {
...
}
application-{profile}.yml
feign:
client:
config:
default:
connectTimeout: 1000
readTimeout: 5000
loggerLevel: NONE
demo-name:
connectTimeout: 1000
readTimeout: 4000
loggerLevel: BASIC
-
DemoFeignClient의 Timeout property 값은
application.yml에 feign.client.config.demo-name 값을 찾게 된다.
// prefix : feign.client.config
-
만약 Client에 선언한 name에 해당하는 값이
application.yml에 없을 경우엔 default 값을 따르게 된다.
DefaultFeignClient
@FeignClient(
name = "default-client",
url = "${feign.api.default.url}",
configuration = DefaultFeignConfig.class)
public interface DefaultFeignClient {
...
}
// Connection Timeout : 1000ms
// Read Timeout : 5000ms
Feign Interceptor
DemoFeignInterceptor.class
public final class DemoFeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
if (template.body() == null) {
return;
}
String oldMessage = StringUtils.toEncodedString(template.body(), UTF_8);
log.info("[DemoFeignInterceptor] Old Message. {}", oldMessage);
ObjectMapper objectMapper = new ObjectMapper();
BaseRequestInfo oldInfo = null;
String newMessage = null;
try {
oldInfo = objectMapper.readValue(oldMessage, BaseRequestInfo.class);
BaseRequestInfo newInfo = BaseRequestInfo.builder()
.name("[DemoFeignInterceptor] " + oldInfo.getName())
.age(oldInfo.getAge())
.requestDate(oldInfo.getRequestDate())
.build();
newMessage = objectMapper.writeValueAsString(newInfo);
} catch (JsonProcessingException e) {
log.warn("Error occurred while parsing objectMapper. ", e);
newMessage = oldMessage;
}
log.info("[DemoFeignInterceptor] New Message. {}", newMessage);
template.body(newMessage); // Change :: Old Body -> New Body
}
}
-
RequestInterceptor를 상속 후 apply 메소드를 Override 하여
요청을 보내기 전 데이터를 Interceptor 하여 데이터를 조작할 수 있다.
위 예에서는 간단하게 BaseRequestInfo 객체의 name 값을 변경하였다.
Output
[DemoFeignInterceptor] Old Message. {"name":"goodGid","age":1,"requestDate":"2021-05-05T16:01:56"}
[DemoFeignInterceptor] New Message. {"name":"[DemoFeignInterceptor] goodGid","age":1,"requestDate":"2021-05-05T16:01:56"}
- 출력을 통해 정상적으로 값이 변경되는 것을 확인할 수 있다.
Feign CustomLogger
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface FeignSlowApiThreshold {
int value() default 5_000;
}
public class FeignCustomLogger extends Logger {
private static final int DEFAULT_SLOW_API_TIME = 3_000;
private static final String SLOW_API_NOTICE = ", Slow API";
private static final Map<String, Integer> slowApiThresholdMap = new ConcurrentHashMap<>();
// [1]
@Override
protected void logRequest(String configKey, Logger.Level logLevel, Request request) {
// Do nothing when request
// request info was logged in `logAndRebufferResponse`
}
// [2]
@Override
protected Response logAndRebufferResponse(String configKey, Logger.Level logLevel,
Response response, long elapsedTime) throws IOException {
int slowApiThreshold = findThresholdByClientName(configKey); // [2-1]
if (response.body() != null) {
byte[] bodyData = Util.toByteArray(response.body().asInputStream());
List<Object> arguments = new LinkedList<>();
arguments.add(response.request().httpMethod());
arguments.add(response.request().headers());
arguments.add(response.request().url());
arguments.add(getRequestBodyString(response.request()));
arguments.add(getResponseBodyString(bodyData));
arguments.add(elapsedTime);
arguments.add(elapsedTime >= slowApiThreshold ? SLOW_API_NOTICE : StringUtils.EMPTY);
if (HttpStatus.OK.value() == response.status()) {
log(configKey, SUCCESS_LOG_MSG, arguments.toArray());
} else {
log(configKey, ERROR_LOG_MSG, arguments.toArray());
}
return response.toBuilder().body(bodyData).build();
}
return response;
}
// [2-1]
private int findThresholdByClientName(String configKey) {
// ex) configKey = "DemoFeignClient#testPostMethod(String,BaseRequestInfo)"
String clientName = configKey.split("#")[0]; // clientName = DemoFeignClient
if (!slowApiThresholdMap.containsKey(clientName)) {
int threshold = Arrays.stream(applicationContext.getBeanDefinitionNames())
.filter(s -> s.contains(clientName))
.map(s -> applicationContext.findAnnotationOnBean(s, FeignSlowApiThreshold.class))
.filter(s -> s != null)
.map(s -> s.value())
.findFirst().orElse(DEFAULT_SLOW_API_TIME);
slowApiThresholdMap.put(clientName, threshold);
}
return slowApiThresholdMap.getOrDefault(clientName, DEFAULT_SLOW_API_TIME);
}
...
}
-
[1] : Request에 대한 정보를 logRequest 메소드에서 남기지 않고 logAndRebufferResponse 에서 남긴다.
-
[2] : 요청에 대한 응답을 log로 남긴다.
여기서 만약 요청 시간이 길다면 Slow API라는 텍스트를 명시적으로 기록한다.
그렇다면 요청 시간이 길다 의 기준은 어떻게 결정할까?
-
[2-1] : 요청 시간이 길다 의 기준을 구하기 위해 사용하는 메소드이다.
API 요청 시 사용한 FeignClient에
@FeignSlowApiThreshold가 선언되어 있다면
그 값을 기준으로 Slow API 유무를 결정한다.
-
만약 선언되어있지 않다면
DEFAULT_SLOW_API_TIME를 기준으로 Slow API 유무를 결정한다.
Example
@FeignSlowApiThreshold
@FeignClient(
name = "demo-name",
url = "${feign.api.demo.url}",
configuration = DemoFeignConfig.class)
public interface DemoFeignClient { ... }
if (elapsedTime >= 5_000)
Slow API 출력
// Output
d.b.g.feign.logger.FeignCustomLogger : ... [Elapsed : 7164ms, Slow API]
Trouble Shooting
- 프로젝트를 Build Up하면서 겪었던 Trouble Shooting을 정리해본다.
Version 호환
-
Spring Boot version에 따라 Feign의 version이 달랐다.
반드시 호환되는 Spring Cloud version을 사용하자.
Retrospective
-
직접 Build Up을 해보니
몰랐던 부분을 명확하게 알 수 있었던 좋은 시간이였다.
-
역시 코딩은 눈이 아니라 손으로 해야 제맛이다. ㅎㅎ
Summary
-
Feign 사용법과 프로젝트에 녹아있는 Feature에 대해 알아보았다.
누군가에겐 꼭 도움이 되었으면 좋겠다. ㅎㅎ
-
전체 소스 코드는 github에 올려놓았다.
사용해보고 싶은 분은 직접 다운을 받아보자 !
-
그리고 수정할 부분 혹은 피드백이 있다면
자유롭게 의견 주시면 이른 시일 안에 반영하도록 하겠습니다 !
-
끝으로 긴 글 읽어주셔서 감사합니다.
다 읽지 않았어도 감사합니다.
그냥 감사합니다.
모두 행복하세요 ㅎㅎ
:wq