Spring Cloud - Loadbalancer, Feign!
Client-side Load balancer
Load Balancing 은 들어오는 트래픽을 여러 백엔드 서버에 효율적으로 분산시키는 것을 의미한다.
아래 그림과 같이 중앙에 Load Balancing 역할을 해줄 Load Balancer 장비를 두고 트래픽을 분산시키는 것이
전통적인 Server-side Load Balancer 이다.

이전에 배운 Spring Cloud Gateway 가 Server-side Load Balancer 역할을 해준다.
Client-side Load balancer 는 클라이언트가 이미 모든 서버에 대한 url 주소를 알고 있으면
Load Balancer 장비 없이도 클라이언트 내부에서 라운드 로빈으로 분배해서 각 서버에 요청하는 개념이다.
Eurekra 의 Service Discovery 기능을 사용하면 각 서비스들은 다른 서비스들의 url 을 알 수 있음으로
Client-side Load balancer 개념을 사용해 서로 통신할 수 있다.
Client-side Load balancer 를 구현할 수 있는 몇가지 라이브러리가 있다.
- netrix ribbon
- Spring Cloud Loadbalancer
- Spring Cloud OpenFeign
여기서
netrix ribbon은Spring Cloud Hoxton Release를 마지막으로 더이상 지원하지 않기 때문에
아래 2가지만 알아본다.
테스트를 위해 사전에 그림과 같이 Eureka Client 4개를 동작시킨다.


Spring Cloud Loadbalancer
https://docs.spring.io/spring-cloud-commons/docs/current/reference/html/#spring-cloud-loadbalancer
사용방법은 간단하다
아래처럼 Loadbalancer 용 RestTemplate 을 생성하고 @LoadBalanced 어노테이션을 사용해 의존성 주입하여 사용하면 된다.
// Eureka 연동 Client-side LB 를 위한 RestTemplate
@LoadBalanced
@Bean
RestTemplate loadBalanced() {
    return new RestTemplate();
}
// 일반 Rest 요청을 위한 RestTemplate
@Primary
@Bean
RestTemplate restTemplate() {
    return new RestTemplate();
}
@Autowired
@LoadBalanced
private RestTemplate loadBalanced;
...
String result = loadBalanced.getForObject("http://product-service/product/test", String.class);
product-service 의 host port 정보는 DefaultServiceInstance 객체를 스프링 빈으로 등록하여 매핑한다.
Service Discovery 기능을 사용할 경우 자동으로 service-id 와 host port 가 매핑되고
Service Discovery 기능을 사용하지 않을 경우 수기로 service-id 와 host:port 를 매핑해야 한다.
수기로 매핑할 때 application.properties 와 java config 를 사용해 등록하는 방법을 알아본다.
수기 매핑 - application.properties
https://docs.spring.io/spring-cloud-commons/docs/current/reference/html/#simplediscoveryclient
order service 의 application.properties 에 아래와 같이 설정
# app
spring.application.name=order-service
# eurkea, cloud config disable 처리
eureka.client.enabled=false
spring.cloud.config.enabled=false
spring.cloud.discovery.client.simple.instances.product-service[0].uri=http://localhost:8082
spring.cloud.discovery.client.simple.instances.product-service[1].uri=http://localhost:8083
내부적으로 Map<String, List<DefaultServiceInstance>> 형태의 데이터를 생성한다.
수기 매핑 - java config
@Bean
public ServiceInstanceListSupplier serviceInstanceListSupplier() {
    return new ServiceInstanceListSupplier() {
        @Override
        public String getServiceId() {
            return "product-service";
        }
        @Override
        public Flux<List<ServiceInstance>> get() {
            return Flux.just(Arrays.asList(
                new DefaultServiceInstance("product-1", "product-service", "localhost", 8080, false),
                new DefaultServiceInstance("product-2", "product-service", "localhost", 8081, false)
            ));
        }
    };
}
@LoadBalancerClient 어노테이션을 사용하면 Spring Cloud Loadbalancer 에 기본적으로 설정되어 있는 LoadBalancerClientConfiguration 환경구성을 사용하지 않고 변경할 수 있다.
추가적으로 설정해야할 사항이 많음으로
default configuration사용을 권장
// default org.springframework.cloud.loadbalancer.annotation.LoadBalancerClientConfiguration
@LoadBalancerClient(name = "demo-lb", configuration = CustomLoadBalancerConfiguration.class)
Service Discovery 를 사용하면 application.properties 와 java config 을 사용할 필요가 없다.
오히려 수기 설정의 우선순위가 더 높아 제대로 동작하지 않음으로 지워야한다.
Spring Cloud OpenFeign
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
https://docs.spring.io/spring-cloud-openfeign/docs/current/reference/html/
기존에 Netflix 에서 개발된 Http client binder 이지만 Spring Cloud 프로젝트에 합류됨
Spring Cloud Loadbalancer 기반으로 Client-side Loadbalacing 기능을 제공한다.
위에서 배운 @LoadBalanced 어노테이션을 사용하는 RestTemplate 과 클래스 관계도를 비교하면 아래 그림과 같다.

사용방법은 간단하다 아래와 같이 @EnableFeignClients 어노테이션을 추가하고
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class OrderApplication {
    ...
}
@FeignClient 어노테이션의 name 속성과 Service Discovery 된 service-id 를 매핑시켜 사용하면 된다.
만약 동일한 service-id 에 여러개의 @FeignClient 를 생성하고 싶다면 contextId 속성을 다르게 설정하면 됨
@FeignClient(name = "customer-service", contextId = "customerClient1")
public interface CustomerClient {
    // 아래 restTemplate 호출과 동일
    // restTemplate.getForObject("http://customer-service/withAccounts/{id}", Customer.class, order.getCustomerId());
    @GetMapping("/withAccounts/{id}")
    Customer findByIdWithAccounts(@PathVariable("id") Long id);
}
@FeignClient(name = "product-service")
public interface ProductClient {
    //restTemplate.postForObject("http://product-service/ids", order.getProductIds(), Product[].class);
    @PostMapping("/ids")
    List<Product> findByIds(List<Long> ids);
}
정의한 @FeignClient 인터페이스는 컴파일 단계에서 재정의 되기 때문에 아래와 같이 의존성 주입하여 사용하면 된다.
@Autowired
CustomerClient customerClient;
@Autowired
ProductClient productClient;
...
...
List<Product> products = productClient.findByIds(order.getProductIds());
Customer customer = customerClient.findByIdWithAccounts(order.getCustomerId());
Get 방식의 feign client api 를 호출할 경우 query parameter 을 위한 POJO 객체 사용시 @ModelAttribute 가 아닌 @SpringQueryMap 사용한다.
ActivityDetailResponseDto getActivityDetail(@SpringQueryMap DetailRequestDto requestDto);
feign.codec.ErrorDecoder
Spring 관련 어노테이션으로 Feign 을 쉽게 이용할 수 있는 이유를 먼저 알아야 한다.
Feign 에서 Http Request, Http Response 를 주고 받을 때 내부적으로 Feign 라이브러리에서 사용하는 Encoder, Decoder 로 감쌓 데이터를 주고 받고
Spring 관련 어노테이션이 설명되어 있는 정보(feign.contract) 가 설정되어 있기 때문이다.
Feign의 각종 편의기능을 쉽게 이용할 수 있으며 커스터마이징 하고 싶다면@FeignClient의configuration설정을 이용하면 됨(권장X)
우리는 Decoder 에서 발생한 에러의 예외처리를 ErrorDecoder 를 통해 처리 가능
Reponse 를 확인 후 정의한 Exception 객체를 전달할 수 있음
@Slf4j
@Component
@RequiredArgsConstructor
public class FeignErrorDecoder implements ErrorDecoder {
    @Override
    public Exception decode(String methodKey, Response response) {
        log.error("feign error invoked, method:{}, status:{}, reason:{}", methodKey, response.status(), response.reason());
        return new FeignRequestException(response);
    }
}
@Slf4j
public class FeignRequestException extends RuntimeException {
    private String reason;
    public FeignRequestException(Response response) {
        try {
            InputStream inputStream = response.body().asInputStream();
            reason = new String(inputStream.readAllBytes());
        } catch (IOException e) {
            log.error("IOException invoked, type:{}, msg:{}", e.getClass().getSimpleName(), e.getMessage());
            reason = e.getMessage();
        }
    }
}
feign.RequestInterceptor
서비스간 통신에 모든 Http Request 헤더에 특정 api key 를 추가해야하는 등의 작업을 할 때 아래와 같이 feign.RequestInterceptor 객체를 스프링 빈으로 등록한다.
@Component
public class FeignClientInterceptor implements RequestInterceptor {
    private static final String API_KEY = "api-key";
    @Value("${api.key}")
    private String apiKey;
    // 모든 feign client 요청은 api-key 를 추가하여 전달
    @Override
    public void apply(RequestTemplate template) {
        template.header(API_KEY, apiKey);
    }
}
@RequestLine
@FeignClient 를 사용하면 Service Discovery 에서 서비스 목록을 읽어 loadbalancer 목록에 자동으로 Feign 객체들을 만들어 저장한다.
그리고 저장해둔 Feign 객체를 round robbin 형식으로 호출하는 구조이다.
@RequestLine 어노테이션을 사용하면 단순 URL 과 Feign 객체를 매핑해서 사용할 수 있다.
아래와 같이 @RequestLine 어노테이션을 가진 interface 를 정의
그리고 Feign.builder() 메서드를 사용해 Feign 객체를 만들면 된다.
public interface ProductRequestLine {
    @RequestLine("GET /product/{id}")
    Product findById(@Param("id") Long id);
}
// for rest json encode, decode
@Autowired
private ObjectFactory<HttpMessageConverters> messageConverters;
@GetMapping("/product/{productId}/line")
public Product getProdcutById(@PathVariable Long productId) {
    ProductRequestLine productService = Feign.builder()
        .encoder(new SpringEncoder(messageConverters))
        .decoder(new SpringDecoder(messageConverters))
        .target(ProductRequestLine.class, "http://localhost:8080/");
    Product result = productService.findById(productId);
    return result;
}
ProductRequestLine 인터페이스는 Feign.builder 메서드를 토행 리플렉션 되어 HardCodedTarget 객체로 반환되고 Http Request 를 수행하게 된다.
@FeignClient를 사용하면 이런Feign객체들이 로르밸런싱 될 수 있도록 여러개 저장되어 있다고 보면 된다.만약 좀더 세세한 jackson 처리가 필요하다면 아래 의존성을 추가해서
Encoder,Decoder를 재정의하면 된다.
implementation "io.github.openfeign:feign-jackson"
@RequestLine 를 사용하면 URL 을 직접 하드코딩해야 함으로 Client-side Load balancing 과는 거리가 멀어진다.
k8s service, aws load balancer 와 같은 기술과 함께 http://product-service DNS 기반 URL 을 사용해 클라우드 내부 컴포넌트에서 로르밸런싱 되도록 설정해야 한다.