728x90
반응형
마이크로서비스 아키텍처에서 서비스 간 통신은 필수입니다.
하지만 RestTemplate 또는 WebClient로 매번 HTTP 요청을 구성하고 응답을 처리하는 것은 번거롭고 중복이 많습니다.
1. OpenFeign 개요
OpenFeign은 인터페이스 기반으로 외부 HTTP API를 간단하고 타입 안전하게 호출할 수 있게 도와주는 선언형(Declarative) HTTP 클라이언트입니다.
| 연도 | 주요 이벤트 |
| 2013 | Netflix가 내부 프로젝트로 Feign 개발 시작 |
| 2015 | Netflix OSS의 일부로 Feign 오픈 소스 공개 |
| 2016 | Spring Cloud Netflix 프로젝트에서 @FeignClient 으로 통합 |
| 2019 | OpenFeign이라는 이름으로 독립 프로젝트로 분리됨 Netfliex는 Feigb의 개발에서 손을 떼고 커뮤니티로 이전 |
| 2020 이후 | Spring Cloud OpenFeign이 주요한 구현체로 유지되며, Spring Boot와 통합 지원 지속 |
주요 특징
- @FeignClient 인터페이스만으로 외부 API 호출 가능
- JSON, Form 등 다양한 HTTP 요청 포맷 지원
- 공통 헤더, 인증 토큰 등을 위한 RequestInterceptor 지원
- Retry, Fallback, ErrorDecoder 등 고급 기능 내장
- Spring Cloud와 완벽 통합 (spring-cloud-starter-openfeign)
2. 기본 구성
2.1. 의존성 추가
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-hc5</artifactId>
</dependency>
</dependencies>
2.2. @EnableFeignClients 설정
@Configuration
@EnableFeignClients(defaultConfiguration = FeignConfig.class)
public class FeignConfig { }
2.3. Feign 인터페이스 정의
@FeignClient(name = "userApi", url = "https://api.example.com")
public interface UserApiClient {
@GetMapping("/users/{id}")
UserResponse getUser(@PathVariable("id") Long id);
@PostMapping(value = "/users", consumes = "application/json")
UserResponse createUser(@RequestBody CreateUserRequest request);
} @FeignClient는 실제 구현체 없이도 자동으로 프록시가 생성됩니다.
3. 실전 사용 예 : 토큰 인증 붙은 API 호출
3.1. 인터셉터로 헤더 자동 삽입
@Component
public class AuthHeaderInterceptor implements RequestInterceptor {
@Autowired
private TokenProvider tokenProvider;
@Override
public void apply(RequestTemplate template) {
// template.removeHeader("Authorization");
template.header("Authorization", "Bearer " + tokenProvider.getToken());
}
}
3.2. 토큰 만료 대응 : ErrorDecoder 사용
@Component
public class CustomFeignErrorDecoder implements ErrorDecoder {
private final ErrorDecoder defaultDecoder = new Default();
@Override
public Exception decode(String methodKey, Response response) {
String body = getBody(response);
if (body.contains("EXPIRE_TOKEN")) {
return new RetryableException(
response.status(),
"Token invalid or expired. Retrying with refreshed token...",
response.request().httpMethod(), // ← method
(Long) null, // ← retryAfter millis (null 이면 기본 정책 사용)
response.request() // ← 원래 요청 정보
);
}
return defaultDecoder.decode(methodKey, response);
}
private String getBody(Response response) {
try (InputStream is = response.body().asInputStream()) {
return IOUtils.toString(is, StandardCharsets.UTF_8);
} catch (IOException e) {
return "";
}
}
}
4. 실전 사용 예 : 동적 통신
4.1. 동적 클라이언트 구현
public interface DynamicFeignClient {
@GetMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
Object get(@SpringQueryMap Object param);
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
Object post(@RequestBody Object body);
@PostMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
Object post(MultiValueMap<String, ?> param);
}@Component
@RequiredArgsConstructor
public class DynamicFeignClientFactory {
private final Client feignClient;
private final ObjectFactory<HttpMessageConverters> messageConverters;
private final TokenProvider tokenProvider;
public DynamicFeignClient create(ApiDefInfo defInfo) {
return Feign.builder()
.client(feignClient)
.contract(new SpringMvcContract())
.encoder(new SpringFormEncodr(new SpringEncoder(messageConverters)))
.decoder(new SpringDecoder(messageConverters))
.requestInterceptor(new DynamicFeignRequestInterceptor(tokenProvider))
.errorDecoder(new DynamicFeignErrorDecoder(tokenProvider))
.options(new Request.Options(defInfo.getConnectionTimeoutMs(), defInfo.getSocketTimeoutMs()))
.target(DynamicFeignClient.class, defInfo.getFullUrl());
}
}
4.2. 호출
@Service
@RequiredArgsConstructor
public class ClientService {
private final ApiUrlResolver apiUrlResolver;
private final DynamicFeignClientFactory dynamicFeignClientFactory;
private final ObjectMapper objectMapper;
public <T extends ApiFeignRes> T get(String defId, Object sendParam, Class<T> clz, Object... args) {
return this.executeAndConvert(HttpMethod.GET, defId, sendParam, clz, args);
}
public <T extends ApiFeignRes> T post(String defId, Object sendParam, Class<T> clz, Object... args) {
return this.executeAndConvert(HttpMethod.POST, defId, sendParam, clz, args);
}
public <T> List<T> getAsList(String defId, Object sendParam, Class<T> clz, Object... args) {
return this.executeAndConvertList(HttpMethod.GET, defId, sendParam, clz, args);
}
public <T> List<T> postAsList(String defId, Object sendParam, Class<T> clz, Object... args) {
return this.executeAndConvertList(HttpMethod.POST, defId, sendParam, clz, args);
}
private <T> T executeAndConvert(HttpMethod method, String defId, Object param, Class<T> clz, Object... args) {
JavaType returnType = TypeFactory.defaultInstance().constructType(clz);
Object result = execute(method, defId, param, args);
return objectMapper.convertValue(result, returnType);
}
private <T> List<T> executeAndConvertList(HttpMethod method, String defId, Object param, Class<T> clz, Object... args) {
CollectionType returnType = TypeFactory.defaultInstance().constructCollectionType(List.class, clz);
Object result = execute(method, defId, param, args);
return objectMapper.convertValue(result, returnType);
}
private Object execute(HttpMethod method, String defId, Object param, Object... args) {
ApiDefInfo defInfo = apiUrlResolver.getDefInfo(defId);
if (defInfo == null) {
throw new RuntimeException("잘못된 defId : " + defId);
}
if (args != null && args.length > 0) {
defInfo.setFullUrl(String.format(defInfo.getFullUrl(), args));
}
DynamicFeignClient client = dynamicFeignClientFactory.create(defInfo);
if (method == HttpMethod.GET) {
return client.get(param);
} else if (method == HttpMethod.POST) {
return client.post(param);
} else {
throw new UnsupportedOperationException("지원하지 않는 HTTP 메서드: " + method);
}
}
/**
* application/x-www-form-urlencoded
*/
public <T extends ApiFeignRes> T post(String defId, MultiValueMap<String, Object> paramMap, Class<T> clz, Object... args) {
ApiDefInfo defInfo = apiUrlResolver.getDefInfo(defId);
if (defInfo == null) {
throw new RuntimeException("잘못된 defId : " + defId);
}
if (args != null && args.length > 0) {
defInfo.setFullUrl(String.format(defInfo.getFullUrl(), args));
}
DynamicFeignClient client = dynamicFeignClientFactory.create(svcCd.name(), defInfo);
Object result = client.post(paramMap);
return objectMapper.convertValue(result, clz);
}
}
5. 고급 기능 요약
| 기능 | 설명 |
| RequestInterceptor | 요청 전 헤더/쿼리 등 전처리 |
| ErrorDecoder | 응답 오류 커스터마이징 |
| Retryer | 재시도 정책 설정 |
| FallbackFactory | 실패 시 fallback 지정 |
| Decoder | 응답 JSON → DTO 매핑 (기본은 Jackson 사용) |
6. 테스트 팁
- FeignClient는 일반 인터페이스이므로, 단위 테스트에서는 @MockBean 또는 Stub으로 쉽게 대체 가능
- 통합 테스트에서는 WireMock 또는 MockWebServer 사용 추천
7. 언제 Feign을 써야 하나?
Feign은 API 호출이 많고, 인증/헤더/재시도 로직이 공통화된 프로젝트에 강력한 생산성과 유지보수성을 제공합니다.
하지만, 단순한 단건 호출이나, 복잡한 커넥션 풀 관리가 필요한 경우엔 WebClient도 고려해볼 수 있습니다.
[Reference]
https://docs.spring.io/spring-cloud-openfeign/docs/current/reference/html/
https://github.com/OpenFeign/feign
728x90
반응형
'spring' 카테고리의 다른 글
| [SpringBoot3] MultipartException, FileCountLimitExceededException 발생 이슈 (0) | 2025.06.27 |
|---|---|
| [SpringBoot3] Resilience4j Java Config 설정 (0) | 2025.05.20 |
| [SpringBoot] logback xml을 java configuration으로 변환하기 (0) | 2024.11.04 |
| [SpringBoot] Spring Profile과 환경별 resource 설정 (0) | 2024.11.04 |
| Spring MVC의 PathPattern (AntPathMatcher, PathPatternParser) (0) | 2024.05.08 |
댓글