Spring Cloud OpenFeign — 선언적 HTTP 클라이언트로 서비스 간 통신하기
다른 서비스의 API를 호출할 때마다 RestTemplate으로 URL을 조립하고, 헤더를 세팅하고, 응답을 파싱하는 코드를 반복 작성해야 할까요?
마이크로서비스 환경에서는 서비스 간 HTTP 통신이 빈번합니다. 매번 HTTP 클라이언트 코드를 직접 작성하면 보일러플레이트가 쌓이고, 변경에도 취약해집니다. OpenFeign은 인터페이스와 어노테이션만으로 HTTP 클라이언트를 선언적으로 정의하여 이 문제를 깔끔하게 해결합니다.
OpenFeign이란
OpenFeign은 Netflix가 만든 선언적 HTTP 클라이언트 라이브러리입니다. Spring Cloud OpenFeign은 이를 Spring 생태계와 통합하여, Spring MVC 어노테이션(@RequestMapping 등)을 그대로 사용할 수 있게 합니다.
핵심 아이디어는 단순합니다:
- Java 인터페이스에 HTTP API를 ** 선언 **한다
- 구현체는 Feign이 ** 런타임에 자동 생성 **한다
- 개발자는 비즈니스 로직에만 집중한다
의존성
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
메인 애플리케이션 클래스에 @EnableFeignClients를 추가합니다:
@SpringBootApplication
@EnableFeignClients
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
@FeignClient 기본 사용법
@FeignClient를 인터페이스에 붙이면 Feign이 해당 인터페이스의 프록시 구현체를 자동으로 만들어줍니다.
// 서비스 디스커버리 기반 (name = 서비스 ID)
@FeignClient(name = "payment-service")
public interface PaymentClient {
@PostMapping("/api/payments")
PaymentResponse createPayment(@RequestBody PaymentRequest request);
@GetMapping("/api/payments/{paymentId}")
PaymentResponse getPayment(@PathVariable("paymentId") Long paymentId);
@GetMapping("/api/payments")
List<PaymentResponse> getPayments(
@RequestParam("orderId") Long orderId,
@RequestParam("status") String status
);
}
주요 속성 정리:
- name: 서비스 디스커버리에서 사용할 서비스 ID (필수)
- url: 고정 URL 직접 지정 (로컬 테스트나 외부 API 호출 시 유용)
- path: 공통 경로 prefix 설정
- fallback / fallbackFactory: 장애 시 대체 응답 처리 클래스
// 고정 URL + 공통 경로
@FeignClient(
name = "external-api",
url = "${external.api.url}",
path = "/api/v2"
)
public interface ExternalApiClient {
@GetMapping("/users/{id}")
UserDto getUser(@PathVariable("id") String id);
}
공부하다 보니 name과 url의 관계에서 자주 헷갈렸습니다. url을 지정하면 로드밸런싱 없이 해당 URL로 직접 요청하고, url 없이 name만 지정하면 서비스 디스커버리를 통해 인스턴스를 찾아 로드밸런싱이 적용됩니다.
요청/응답 세부 설정
헤더와 인터셉터
공통 헤더는 RequestInterceptor로 처리하는 게 깔끔합니다:
@Configuration
public class FeignConfig {
@Bean
public RequestInterceptor authInterceptor() {
return requestTemplate -> {
// 현재 인증 토큰을 전파
String token = SecurityContextHolder.getContext()
.getAuthentication().getCredentials().toString();
requestTemplate.header("Authorization", "Bearer " + token);
};
}
}
특정 클라이언트에만 적용하려면 @FeignClient(configuration = FeignConfig.class)로 지정합니다. 이때 FeignConfig에 @Configuration을 붙이면 ** 전역 적용 **되니 주의가 필요합니다. 특정 클라이언트 전용이라면 @Configuration을 제거하세요.
타임아웃 설정
spring:
cloud:
openfeign:
client:
config:
default: # 전역 설정
connect-timeout: 5000
read-timeout: 5000
payment-service: # 특정 서비스만 다르게
connect-timeout: 3000
read-timeout: 10000
로깅
Feign의 로깅 레벨을 설정하면 요청/응답을 디버깅할 때 매우 유용합니다:
@Bean
Logger.Level feignLoggerLevel() {
return Logger.Level.FULL; // NONE, BASIC, HEADERS, FULL
}
logging:
level:
com.example.client.PaymentClient: DEBUG
FULL 레벨은 요청/응답 헤더와 바디를 모두 출력하므로, ** 운영 환경에서는 BASIC이나 NONE**으로 설정해야 합니다.
에러 처리: ErrorDecoder 커스터마이징
기본 ErrorDecoder는 4xx/5xx 응답을 FeignException으로 변환합니다. 문제는 이 예외에서 의미 있는 비즈니스 에러 정보를 꺼내기 어렵다는 것입니다.
public class CustomErrorDecoder implements ErrorDecoder {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Exception decode(String methodKey, Response response) {
// 응답 바디에서 에러 정보 추출
try {
ErrorResponse error = objectMapper.readValue(
response.body().asInputStream(),
ErrorResponse.class
);
return switch (response.status()) {
case 400 -> new BadRequestException(error.getMessage());
case 404 -> new ResourceNotFoundException(error.getMessage());
case 409 -> new ConflictException(error.getMessage());
default -> new ServiceException(
"서비스 호출 실패: " + error.getMessage()
);
};
} catch (IOException e) {
return new ServiceException("응답 파싱 실패");
}
}
}
이렇게 하면 호출하는 쪽에서 FeignException이 아니라 비즈니스 의미가 담긴 예외를 받을 수 있습니다:
@Service
@RequiredArgsConstructor
public class OrderService {
private final PaymentClient paymentClient;
public void processOrder(OrderRequest request) {
try {
paymentClient.createPayment(request.toPaymentRequest());
} catch (BadRequestException e) {
// 결제 요청 데이터 오류 → 주문 검증 실패 처리
throw new OrderValidationException(e.getMessage());
} catch (ConflictException e) {
// 중복 결제 → 이미 처리된 주문
log.warn("이미 처리된 결제: {}", e.getMessage());
}
}
}
Resilience4j 연동: CircuitBreaker + Fallback
Feign과 Resilience4j를 함께 사용하면 장애 시 자동으로 서킷 브레이커가 동작하고, Fallback 응답을 반환할 수 있습니다.
의존성 추가
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>
spring:
cloud:
openfeign:
circuitbreaker:
enabled: true # Feign + CircuitBreaker 통합 활성화
Fallback 구현
fallback은 고정 응답, fallbackFactory는 예외 정보를 활용한 분기 처리가 가능합니다:
// FallbackFactory — 예외 정보를 받아 분기 처리
@Component
public class PaymentClientFallbackFactory
implements FallbackFactory<PaymentClient> {
@Override
public PaymentClient create(Throwable cause) {
return new PaymentClient() {
@Override
public PaymentResponse createPayment(PaymentRequest request) {
if (cause instanceof CircuitBreakerOpenException) {
// 서킷 오픈 → 결제 보류 상태로 저장
return PaymentResponse.pending("서킷 브레이커 활성화");
}
// 기타 오류 → 기본 실패 응답
return PaymentResponse.failed(cause.getMessage());
}
@Override
public PaymentResponse getPayment(Long paymentId) {
return PaymentResponse.unknown();
}
@Override
public List<PaymentResponse> getPayments(Long orderId, String status) {
return Collections.emptyList();
}
};
}
}
@FeignClient(
name = "payment-service",
fallbackFactory = PaymentClientFallbackFactory.class
)
public interface PaymentClient {
// ... 메서드 선언
}
실무에서는 fallback보다 fallbackFactory를 더 많이 쓰게 됩니다. 장애 원인에 따라 다른 대체 응답을 줘야 하는 경우가 대부분이기 때문입니다.
로드밸런싱: Spring Cloud LoadBalancer
Spring Cloud OpenFeign은 Spring Cloud LoadBalancer와 자연스럽게 통합됩니다.
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
@FeignClient(name = "payment-service")에서 url 없이 name만 지정하면 LoadBalancer가 자동 적용됩니다. 이때 name은 서비스 디스커버리(Eureka, Consul 등)에 등록된 서비스 ID와 일치해야 합니다.
로드밸런싱 알고리즘 커스터마이징:
// 특정 서비스에만 Random 로드밸런서 적용
@Configuration
@LoadBalancerClient(
name = "payment-service",
configuration = PaymentLBConfig.class
)
public class LoadBalancerConfig {}
public class PaymentLBConfig {
@Bean
public ReactorLoadBalancer<ServiceInstance> randomLoadBalancer(
Environment env,
LoadBalancerClientFactory factory) {
String name = env.getProperty(
LoadBalancerClientFactory.PROPERTY_NAME);
return new RandomLoadBalancer(
factory.getLazyProvider(name, ServiceInstanceListSupplier.class),
name
);
}
}
@HttpExchange — Feign을 대체하는 Spring 네이티브 방식
여기서부터가 핵심 트렌드입니다. Spring Framework 6(Spring Boot 3)부터 @HttpExchange가 spring-web 코어에 내장되었고, Spring Framework 7 / Boot 4에서는 이것이 표준 선언적 HTTP 클라이언트 로 자리잡고 있습니다.
왜 @HttpExchange인가
- **별도 의존성 불필요 **: spring-web에 포함되어 Spring Cloud 없이 사용 가능
- **WebClient / RestClient 지원 **: 리액티브(WebClient)와 블로킹(RestClient) 모두 지원
- **Spring MVC 어노테이션과 분리 **: HTTP 클라이언트 전용 어노테이션 체계
Feign → @HttpExchange 마이그레이션 비교
Before (OpenFeign):
@FeignClient(name = "payment-service", path = "/api/payments")
public interface PaymentClient {
@PostMapping
PaymentResponse createPayment(@RequestBody PaymentRequest request);
@GetMapping("/{paymentId}")
PaymentResponse getPayment(@PathVariable("paymentId") Long paymentId);
@GetMapping
List<PaymentResponse> getPayments(
@RequestParam("orderId") Long orderId);
}
After (@HttpExchange):
// 별도 의존성 불필요 — spring-web에 포함
@HttpExchange(url = "/api/payments")
public interface PaymentClient {
@PostExchange
PaymentResponse createPayment(@RequestBody PaymentRequest request);
@GetExchange("/{paymentId}")
PaymentResponse getPayment(@PathVariable("paymentId") Long paymentId);
@GetExchange
List<PaymentResponse> getPayments(
@RequestParam("orderId") Long orderId);
}
@Configuration
public class HttpClientConfig {
@Bean
public PaymentClient paymentClient() {
// RestClient 기반 (블로킹)
RestClient restClient = RestClient.builder()
.baseUrl("http://payment-service")
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(PaymentClient.class);
}
}
어노테이션 매핑 정리:
| OpenFeign | @HttpExchange |
|---|---|
@FeignClient | @HttpExchange |
@GetMapping | @GetExchange |
@PostMapping | @PostExchange |
@PutMapping | @PutExchange |
@DeleteMapping | @DeleteExchange |
@PatchMapping | @PatchExchange |
로드밸런싱도 가능
@HttpExchange에서도 Spring Cloud LoadBalancer를 함께 사용할 수 있습니다:
@Bean
public PaymentClient paymentClient(
LoadBalancerClientFactory factory) {
RestClient restClient = RestClient.builder()
.baseUrl("http://payment-service")
.requestInterceptor(
new LoadBalancerInterceptor(factory)) // 로드밸런싱 적용
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(PaymentClient.class);
}
에러 처리
@HttpExchange에서는 RestClient의 status handler로 에러를 처리합니다:
RestClient restClient = RestClient.builder()
.baseUrl("http://payment-service")
.defaultStatusHandler(
HttpStatusCode::is4xxClientError,
(request, response) -> {
// 4xx 에러 커스텀 처리
throw new ClientException("클라이언트 오류: " + response.getStatusCode());
})
.defaultStatusHandler(
HttpStatusCode::is5xxServerError,
(request, response) -> {
throw new ServerException("서버 오류: " + response.getStatusCode());
})
.build();
어떤 걸 선택해야 할까
공부하면서 정리해 보니, 선택 기준은 명확합니다:
** 새 프로젝트라면 → @HttpExchange**
- Spring Framework 코어에 포함되어 의존성이 가벼움
- RestClient(블로킹), WebClient(리액티브) 모두 지원
- Spring의 공식적인 방향성과 일치
** 기존 OpenFeign 프로젝트라면 → 점진적 마이그레이션**
- 당장 전환할 필요는 없음 (OpenFeign도 계속 유지보수 중)
- 새로 추가하는 클라이언트부터
@HttpExchange로 작성 - 기존 FeignClient는 리팩토링 시점에 전환
Spring Cloud 없는 프로젝트라면 → @HttpExchange 일택
- OpenFeign은 Spring Cloud 의존성이 필요하지만, @HttpExchange는 spring-web만으로 충분
정리
- OpenFeign은 인터페이스와 어노테이션으로 HTTP 클라이언트를 선언적으로 정의하는 라이브러리
- ErrorDecoder를 커스터마이징해서 비즈니스 의미가 담긴 예외로 변환하는 게 실무의 핵심
- Resilience4j 연동 시
fallbackFactory로 예외 종류별 대체 응답을 분기 처리 - Spring Framework 7 / Boot 4에서는 @HttpExchange가 표준 — 새 프로젝트는 @HttpExchange로 시작하는 것을 권장
- OpenFeign에서 @HttpExchange로의 전환은 어노테이션만 바꾸면 될 정도로 간단