“일단 동작”이 목표라면, 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);
}
}
# 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
spring:
cloud:
loadbalancer:
retry:
enabled: true
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka
@FeignClient(name = "product-service")처럼 서비스명으로 호출하면, LoadBalancer가 인스턴스들 사이에서 라운드로빈 등으로 분산한다.product:
base-url: http://localhost:8082
@FeignClient(name = "product", url = "${product.base-url}")로 고정 URL을 쓴다.// 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) {}
// 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);
}
};
}
}
// 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);
}
};
}
}
// 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);
}
// 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));
}
}
RequestInterceptor로 표준화.implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' 이 의존성과,
@EnableFeignClients 이 어노테이션은 각각의 애플리케이션에 넣어주면 되는데, 이 때,
Feign을 사용하는 애플리케이션(=API 호출을 할 애플리케이션)에만
spring-cloud-starter-openfeign + @EnableFeignClients를 필수로 추가하면 된다.
API를 제공만 하는 애플리케이션은 Feign이 필요 없으므로 둘 다 추가하지 않아도 된다.
OrderService ---> ProductService
(Feign 사용) (Feign 불필요)