11/7

졸용·2025년 11월 7일

TIL

목록 보기
110/144

🔹 FeignClient 사용해서 API 호출 방식 알아보기

“일단 동작”이 목표라면, Spring Cloud OpenFeign을 써서 마이크로서비스끼리 HTTP 호출을 타입-세이프하게 주고받으면 된다.
아래는 가장 작은 예제 → 실무형 설정(타임아웃/리트라이/에러디코더/헤더전파/폴백) 순서로 정리했다.
(스프링 부트 3.x / Java 17+ / Spring Cloud 2023.x 기준, 리븐(Ribbon) X → Spring Cloud LoadBalancer 사용)



🔹 의존성 & 기본 부트스트랩

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'  // Feign
    implementation 'org.springframework.cloud:spring-cloud-starter-loadbalancer' // 서비스명 로드밸런싱
    // (선택) 서비스 디스커버리(Eureka)를 쓴다면:
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'

    // (선택) 탄탄한 장애대응
    implementation 'io.github.resilience4j:resilience4j-spring-boot3'
    // (선택) JSON 직렬화 커스터마이징 시
    implementation 'com.fasterxml.jackson.core:jackson-databind'
}

// MsaApplication.java
@SpringBootApplication
@EnableFeignClients   // ✅ Feign 활성화
public class MsaApplication {
    public static void main(String[] args) {
        SpringApplication.run(MsaApplication.class, args);
    }
}


🔹 구성 옵션 (Eureka 사용 / 미사용 2가지)

# application.yml
spring:
  application:
    name: order-service

# 공통 타임아웃 + 로깅
feign:
  httpclient:
    enabled: false   # 기본 Feign client 사용 (필요 시 okhttp/Apache로 교체)
  okhttp:
    enabled: false
  client:
    config:
      default:
        connectTimeout: 2000
        readTimeout: 3000
        loggerLevel: FULL  # 요청/응답 로깅(개발용)

logging:
  level:
    feign: DEBUG
    com.example.order.infra.client: DEBUG

🔸 서비스 디스커버리(권장): Eureka + 서비스명 호출

spring:
  cloud:
    loadbalancer:
      retry:
        enabled: true
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka
  • 이 경우 @FeignClient(name = "product-service")처럼 서비스명으로 호출하면, LoadBalancer가 인스턴스들 사이에서 라운드로빈 등으로 분산한다.

🔸 정적 URL(빠른 테스트): 직접 URL 지정

product:
  base-url: http://localhost:8082
  • 이 경우 @FeignClient(name = "product", url = "${product.base-url}")로 고정 URL을 쓴다.


🔹 FeignClient 인터페이스 정의

// infra/client/ProductClient.java
@FeignClient(
    name = "product-service",     // Eureka 사용 시 서비스명
    // url = "${product.base-url}", // 정적 URL 사용 시 주석 해제
    configuration = ProductClientConfig.class, // 인터셉터/에러디코더 등
    fallbackFactory = ProductClientFallbackFactory.class // (선택) 폴백
)
public interface ProductClient {

    @GetMapping("/api/v1/products/{id}")
    ProductResponse getProduct(@PathVariable("id") Long id,
                               @RequestHeader(value = "X-Request-Id", required = false) String requestId);

    @PostMapping("/api/v1/products/search")
    PageResponse<ProductResponse> search(@RequestBody ProductSearchRequest req,
                                         @RequestParam(defaultValue = "0") int page,
                                         @RequestParam(defaultValue = "10") int size);
}

// dto/ProductResponse.java
public record ProductResponse(Long id, String name, String category, Integer price) {}
// dto/PageResponse.java
public record PageResponse<T>(List<T> content, int page, int size, long totalElements) {}
// dto/ProductSearchRequest.java
public record ProductSearchRequest(String keyword, String category) {}


🔹 Feign 설정(인터셉터/로깅/에러매핑)

// infra/client/ProductClientConfig.java
@Configuration
public class ProductClientConfig {

    // 모든 요청에 공통 헤더 추가(+ 트레이싱/토큰 전파 등)
    @Bean
    public RequestInterceptor authAndTracingInterceptor() {
        return template -> {
            // 예: 요청 ID 전파
            String rid = Optional.ofNullable(MDC.get("X-Request-Id"))
                                 .orElse(UUID.randomUUID().toString());
            template.header("X-Request-Id", rid);

            // 예: 인증 토큰 전파(필요 시 SecurityContext에서)
            // String token = SecurityContextHolder.getContext()...
            // template.header("Authorization", "Bearer " + token);
        };
    }

    // 예외 매핑: HTTP → 도메인 예외로 변환
    @Bean
    public ErrorDecoder errorDecoder() {
        return (methodKey, response) -> {
            try (var body = response.body() != null ? response.body().asInputStream() : null) {
                String msg = (body != null) ? new String(body.readAllBytes(), StandardCharsets.UTF_8) : "";
                return switch (response.status()) {
                    case 400 -> new IllegalArgumentException("ProductClient 400: " + msg);
                    case 404 -> new NoSuchElementException("Product not found");
                    case 409 -> new IllegalStateException("Conflict: " + msg);
                    default -> new RuntimeException("Feign error " + response.status() + ": " + msg);
                };
            } catch (IOException e) {
                return new RuntimeException("Feign error decode failed", e);
            }
        };
    }
}


🔹 (선택) 폴백(Resilience4j + FallbackFactory)

// infra/client/ProductClientFallbackFactory.java
@Component
@RequiredArgsConstructor
public class ProductClientFallbackFactory implements FallbackFactory<ProductClient> {

    private final Logger log = LoggerFactory.getLogger(getClass());

    @Override
    public ProductClient create(Throwable cause) {
        return new ProductClient() {
            @Override
            public ProductResponse getProduct(Long id, String requestId) {
                log.warn("Fallback getProduct id={} cause={}", id, cause.toString());
                // 안전한 디폴트 or 캐시된 데이터
                return new ProductResponse(id, "UNKNOWN", "UNKNOWN", 0);
            }

            @Override
            public PageResponse<ProductResponse> search(ProductSearchRequest req, int page, int size) {
                log.warn("Fallback search req={} cause={}", req, cause.toString());
                return new PageResponse<>(List.of(), page, size, 0);
            }
        };
    }
}
  • 참고: Resilience4j 어노테이션으로 회로차단/재시도 추가 가능

// application service에서 사용 예
@Retry(name = "product", fallbackMethod = "searchFallback")
@CircuitBreaker(name = "product", fallbackMethod = "searchFallback")
public PageResponse<ProductResponse> searchProducts(ProductSearchRequest req, int page, int size) {
    return productClient.search(req, page, size);
}

public PageResponse<ProductResponse> searchFallback(ProductSearchRequest req, int page, int size, Throwable t) {
    return new PageResponse<>(List.of(), page, size, 0);
}


🔹 호출부(Service/Controller) 예시

// application/service/OrderService.java
@Service
@RequiredArgsConstructor
public class OrderService {
    private final ProductClient productClient;

    public ProductResponse getProductForOrder(Long productId) {
        // X-Request-Id를 명시적으로 넣고 싶으면 Header 인자 전달
        return productClient.getProduct(productId, MDC.get("X-Request-Id"));
    }

    public PageResponse<ProductResponse> searchProducts(String keyword, int page, int size) {
        return productClient.search(new ProductSearchRequest(keyword, null), page, size);
    }
}

// presentation/OrderController.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/orders")
public class OrderController {
    private final OrderService orderService;

    @GetMapping("/products/{id}")
    public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
        return ResponseEntity.ok(orderService.getProductForOrder(id));
    }

    @GetMapping("/products")
    public ResponseEntity<PageResponse<ProductResponse>> search(@RequestParam(required = false) String keyword,
                                                                @RequestParam(defaultValue = "0") int page,
                                                                @RequestParam(defaultValue = "10") int size) {
        return ResponseEntity.ok(orderService.searchProducts(keyword, page, size));
    }
}


🔹 핵심 체크리스트(실무 포인트)

  1. Ribbon 대신 LoadBalancer: Spring Cloud 202x부터는 Ribbon 미사용.
  2. 서비스명 호출을 권장(Eureka/Consul 등) → 인스턴스 증감에 자동 대응.
  3. 타임아웃/로깅/재시도/서킷브레이커 기본값 점검.
  4. 헤더 전파(X-Request-Id, Authorization, Locale 등) RequestInterceptor로 표준화.
  5. 에러 디코더로 4xx/5xx를 도메인 예외로 변환 → 호출부 단순화.
  6. 폴백/캐시 전략: 장애시 안전한 디그레이드 경로 준비.
  7. DTO 계약 안정성: 공용 DTO 모듈 버저닝 혹은 API 문서화(Swagger/AsciiDoc).
  8. 보안: 내부망 + Gateway 통합 인증(토큰 검증은 게이트웨이, 내부 서비스는 토큰 전파/경량검증).


🔹 한 줄 정리

  • @EnableFeignClients 켜고, @FeignClient 인터페이스를 정의한 뒤, 서비스명(또는 URL)로 호출한다.
  • LoadBalancer + (선택) Eureka로 인스턴스 간 분산 호출을 하고, 인터셉터/에러디코더/폴백으로 실무 품질을 갖추면 될 듯 하다.

  • implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' 이 의존성과,

  • @EnableFeignClients 이 어노테이션은 각각의 애플리케이션에 넣어주면 되는데, 이 때,

  • Feign을 사용하는 애플리케이션(=API 호출을 할 애플리케이션)에만
    spring-cloud-starter-openfeign + @EnableFeignClients를 필수로 추가하면 된다.

  • API를 제공만 하는 애플리케이션은 Feign이 필요 없으므로 둘 다 추가하지 않아도 된다.

OrderService   --->  ProductService
   (Feign 사용)      (Feign 불필요)
profile
꾸준한 공부만이 답이다

0개의 댓글