다른 서비스의 API를 호출할 때마다 RestTemplate으로 URL을 조립하고, 헤더를 세팅하고, 응답을 파싱하는 코드를 반복 작성해야 할까요?

마이크로서비스 환경에서는 서비스 간 HTTP 통신이 빈번합니다. 매번 HTTP 클라이언트 코드를 직접 작성하면 보일러플레이트가 쌓이고, 변경에도 취약해집니다. OpenFeign은 인터페이스와 어노테이션만으로 HTTP 클라이언트를 선언적으로 정의하여 이 문제를 깔끔하게 해결합니다.

OpenFeign이란

OpenFeign은 Netflix가 만든 선언적 HTTP 클라이언트 라이브러리입니다. Spring Cloud OpenFeign은 이를 Spring 생태계와 통합하여, Spring MVC 어노테이션(@RequestMapping 등)을 그대로 사용할 수 있게 합니다.

핵심 아이디어는 단순합니다:

  • Java 인터페이스에 HTTP API를 ** 선언 **한다
  • 구현체는 Feign이 ** 런타임에 자동 생성 **한다
  • 개발자는 비즈니스 로직에만 집중한다

의존성

XML
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

메인 애플리케이션 클래스에 @EnableFeignClients를 추가합니다:

JAVA
@SpringBootApplication
@EnableFeignClients
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

@FeignClient 기본 사용법

@FeignClient를 인터페이스에 붙이면 Feign이 해당 인터페이스의 프록시 구현체를 자동으로 만들어줍니다.

JAVA
// 서비스 디스커버리 기반 (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: 장애 시 대체 응답 처리 클래스
JAVA
// 고정 URL + 공통 경로
@FeignClient(
    name = "external-api",
    url = "${external.api.url}",
    path = "/api/v2"
)
public interface ExternalApiClient {

    @GetMapping("/users/{id}")
    UserDto getUser(@PathVariable("id") String id);
}

공부하다 보니 nameurl의 관계에서 자주 헷갈렸습니다. url을 지정하면 로드밸런싱 없이 해당 URL로 직접 요청하고, url 없이 name만 지정하면 서비스 디스커버리를 통해 인스턴스를 찾아 로드밸런싱이 적용됩니다.

요청/응답 세부 설정

헤더와 인터셉터

공통 헤더는 RequestInterceptor로 처리하는 게 깔끔합니다:

JAVA
@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을 제거하세요.

타임아웃 설정

YAML
spring:
  cloud:
    openfeign:
      client:
        config:
          default:  # 전역 설정
            connect-timeout: 5000
            read-timeout: 5000
          payment-service:  # 특정 서비스만 다르게
            connect-timeout: 3000
            read-timeout: 10000

로깅

Feign의 로깅 레벨을 설정하면 요청/응답을 디버깅할 때 매우 유용합니다:

JAVA
@Bean
Logger.Level feignLoggerLevel() {
    return Logger.Level.FULL; // NONE, BASIC, HEADERS, FULL
}
YAML
logging:
  level:
    com.example.client.PaymentClient: DEBUG

FULL 레벨은 요청/응답 헤더와 바디를 모두 출력하므로, ** 운영 환경에서는 BASIC이나 NONE**으로 설정해야 합니다.

에러 처리: ErrorDecoder 커스터마이징

기본 ErrorDecoder는 4xx/5xx 응답을 FeignException으로 변환합니다. 문제는 이 예외에서 의미 있는 비즈니스 에러 정보를 꺼내기 어렵다는 것입니다.

JAVA
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이 아니라 비즈니스 의미가 담긴 예외를 받을 수 있습니다:

JAVA
@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 응답을 반환할 수 있습니다.

의존성 추가

XML
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>
YAML
spring:
  cloud:
    openfeign:
      circuitbreaker:
        enabled: true  # Feign + CircuitBreaker 통합 활성화

Fallback 구현

fallback은 고정 응답, fallbackFactory는 예외 정보를 활용한 분기 처리가 가능합니다:

JAVA
// 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();
            }
        };
    }
}
JAVA
@FeignClient(
    name = "payment-service",
    fallbackFactory = PaymentClientFallbackFactory.class
)
public interface PaymentClient {
    // ... 메서드 선언
}

실무에서는 fallback보다 fallbackFactory를 더 많이 쓰게 됩니다. 장애 원인에 따라 다른 대체 응답을 줘야 하는 경우가 대부분이기 때문입니다.

로드밸런싱: Spring Cloud LoadBalancer

Spring Cloud OpenFeign은 Spring Cloud LoadBalancer와 자연스럽게 통합됩니다.

XML
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>

@FeignClient(name = "payment-service")에서 url 없이 name만 지정하면 LoadBalancer가 자동 적용됩니다. 이때 name은 서비스 디스커버리(Eureka, Consul 등)에 등록된 서비스 ID와 일치해야 합니다.

로드밸런싱 알고리즘 커스터마이징:

JAVA
// 특정 서비스에만 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):

JAVA
@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):

JAVA
// 별도 의존성 불필요 — 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);
}
JAVA
@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를 함께 사용할 수 있습니다:

JAVA
@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로 에러를 처리합니다:

JAVA
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로의 전환은 어노테이션만 바꾸면 될 정도로 간단
댓글 로딩 중...