스프링 클라우드 - 마이크로서비스간 통신이란 (Ribbon)


스프링 클라우드 마이크로서비스간 통신에 대해 알아보자
지난 포스팅에서 서비스 발견(Service discovery)와 설정 서버(configuration Server)에 대해 알아보았다. 하지만, 이 두 설정은 단지 어플리케이션을 독립적이고 standalone하게 관리 할 수 있도록 도와주는 역할이다. 
 이제 내부 서비스간의 통신을 위해 HTTP 클라이언트와 클라이언트 사이드 로드밸런서를 살펴보아야 한다. 

스프링 클라우드 마이크로서비스 통신에서 다루는 내용

  • 서비스 발견을 사용하는 경우와 사용하지 않는 경우에 RestTemplate 사용해보기
  • Ribbon Client 커스터마이징
  • Feign Client에 대한 특징 ( 리본 클라이언트, 서비스 발견, 존 등과 통합 )

스프링 클라우드 마이크로서비스 통신 다양한 스타일

마이크로 서비스간에 통신 스타일을 나눌때 2가지 척도가 있다. 

동기 vs 비동기

비동기 통신에서 중요한 점은 클라이언트가 응답을 기다리는 동안 쓰레드를 Block해서는 안된다. 다음과 같은 통신에서 많이 쓰는 프로토콜이 AMQP이다. 

메시지 받는 마이크로서비스의 수 ( Single message receiver vs multiple receivers )

요청이 1:1인 경우와 요청이 1:다인 경우로 나누어진다. 해당 내용은 메시지 기반 마이크로서비스에서(TODO : link )  다시 다루겠다. 

스프링 클라우드에서 동기적 통신

스프링 클라우드에는 마이크로서비스가 통신을 위해 몇가지 컴포넌트를 제공한다.

마이크로서비스 통신을 도와주는 컴포넌트 1 : RestTemplate

클라이언트가 RESTful webservice를 쓰기 위해서 사용하는 클래스이다. Spring Web 프로젝트에 포함되어 있다.
마이크로서비스 환경에서 효과적으로 사용하기 위해서는, @LoadBalanced 어노테이션을 사용해야 한다.
이 어노테이션 덕분에, Netflix Ribbon을 자동적으로 사용할 수 있게 되고, 서비스 발견을 IP대신 서비스 이름으로 할 수 있게 된다. 

마이크로서비스 통신을 도와주는 컴포넌트 2 : Feign

 Feign은 선언적인 REST 클라이언트이다. @FeignClient어노테이션을 사용하면 된다.

마이크로서비스를 로드밸런싱 해주는 리본이란?

서버사이드 로드밸런싱 

리본은 알아보기 전에, 일반적인 L4 스위치를 알아보자. 서버사이드 로드밸런싱은 L4스위치를 이용한다. 4계층의 Port를 이용하여 트래픽을 여러 서버에게 분산해준다. 하지만 위에 구조는 몇가지 문제가 있다. 서버사이드 로드밸런서를 위해 H/W가 필요하고, 이 때문에 비용과 유연성의 부담을 안게된다. 그리고 트래픽을 분산해줄 Server 1~4의 주소를 알고 있어야 한다. 마지막으로, 로드밸런싱 방법이 제한적이다.

리본(Ribbon)을 이용한 로드밸런스

리본은 Client에 탑재되어 있는 로드밸런서이다. 서버 사이드에서는 필요했던 H/W 부담이 사라지고, 서버 목록 변경이 쉬워지며, 로드밸런싱 방식도 다양하게 설정할 수 있다.  3가지 장점에 대해 좀 더 자세히 살펴보자.

1. 서버 리스트 

리본의 핵심 개념은 클라이언트이다. 그렇게 부르는 이유는 서비스 발견에 연결하지 않고도 호스트 이름과 포트로 호출하는 대신 서비스 이름으로 서비스를 호출할 수 있기 때문이다. 해당 서비스들의 주소는 리본 설정 application.yml에 들어 있어야 한다.

2. 로드 밸런싱 룰 설정

기본은 라운드 로빈 방식이다. 하지만, 3회 연속 실패시 일정시간동안 해당서버로 로드밸런싱 되지 않도록 설정할 수 있다.

3. Health Check

유레카 서버에서 해당 마이크로서비스가 UP 상태인지 확인해준다. Ping에 실패한 서버는 임시로 로드밸런싱에서 제외한다.

4. 재시도

같은 서버로 몇번을 재시도 할지, 다른 서버로 몇번 재시도 할지도 설정할 수 있다

리본 클라이언트를 이용하여 마이크로서비스간 통신하기

고객이 특정 상품을 살때 호출되는 마이크로서비스들이 있는 아래 예제로 살펴보자. 먼저 동작하는 과정을 간단히 살펴보자.

1. 고객이 상품 주문을 누르면, order-service로 POST 요청이 날아 간다. 
2. 주문이 들어가기 전에, customer-service에 고객이 담아놓은 상품 목록을 가져오는 서비스를 호출한다.
3. 고객의 잔금을 확인하고, 고객이 확정 버튼을 누르면, accept 함수가 호출된다.
4. 계좌 서비스에서 고객 돈이 인출되고, 고객 계좌 내역이 새로고침된다.  

Order-service에서 3가지 마이크로서비스를 호출하기 위해서는 각각 주소가 있는 3가지의 리본 클라이언트 설정이 필요하다. 그리고, 유레카 서버에서 서비스 발견을 하지 않도록 설정해줘야 한다. ( Why? ) 
server:
 port: 8090

account-service:
 ribbon:
   eureka:
     enabled: false
   listOfServers: localhost:8091
customer-service:
 ribbon:
   eureka:
     enabled: false
   listOfServers: localhost:8092
product-service:
 ribbon:
   eureka:
     enabled: false
   listOfServers: localhost:8093


@SpringBootApplication
@RibbonClients({
 @RibbonClient(name = "account-service"),
 @RibbonClient(name = "customer-service"),
 @RibbonClient(name = "product-service")
})
public class OrderApplication {

 @LoadBalanced
 @Bean
 RestTemplate restTemplate() {
     return new RestTemplate();
 } 

 public static void main(String[] args) {
     new SpringApplicationBuilder(OrderApplication.class).web(true).run(args);
 }
 // ...
}

 다음과 같이 설정을 맞쳤다면, 다른 마이크로서비스와 통신을 할 수 있다. 첫번째로 선택된 상품을 가져오는 product-service를 호출하고, 계좌 정보를 가져오는 customer-service를 호출한다.

 계산할 준비를 모두 맞쳤으니 account-service에서 금액 인출을 하면 된다. 


@RestController
public class OrderController {

 @Autowired
 OrderRepository repository; 
 @Autowired
 RestTemplate template;

 @PostMapping
 public Order prepare(@RequestBody Order order) {
     int price = 0;
     Product[] products = template.postForObject("http://product-service/ids", order.getProductIds(), Product[].class);
     Customer customer = template.getForObject("http://customer-service/withAccounts/{id}", Customer.class, order.getCustomerId());
     for (Product product : products) 
         price += product.getPrice();
     final int priceDiscounted = priceDiscount(price, customer);
     Optional<Account> account = customer.getAccounts().stream().filter(a -> (a.getBalance() > priceDiscounted)).findFirst();
     if (account.isPresent()) {
         order.setAccountId(account.get().getId());
         order.setStatus(OrderStatus.ACCEPTED);
         order.setPrice(priceDiscounted);
     } else {
         order.setStatus(OrderStatus.REJECTED);
     }
     return repository.add(order);
 }

 @PutMapping("/{id}")
 public Order accept(@PathVariable Long id) {
     final Order order = repository.findById(id);
     template.put("http://account-service/withdraw/{id}/{amount}", null, order.getAccountId(), order.getPrice());
     order.setStatus(OrderStatus.DONE);
     repository.update(order);
     return order;
 }
 // ...
}

 아래 코드를 보면 customer-service에서도 다른 마이크로서비스와 통신하기 위해 똑같이 리본 클라이언트를 사용하고 있다.
@GetMapping("/withAccounts/{id}")
public Customer findByIdWithAccounts(@PathVariable("id") Long id) {
 Account[] accounts = template.getForObject("http://account-service/customer/{customerId}", Account[].class, id);
 Customer c = repository.findById(id);
 c.setAccounts(Arrays.stream(accounts).collect(Collectors.toList()));
 return c;}

 해당 예제의 문제점

클라우드 환경에서 Auto-scale은 필수이다. 위 예제와 같이 설정을 하면, Auto-scale할 때마다 applicaion.yml을 수정하고 무거운 Jar파일을 재시작해야 한다. 그러므로 서비스 발견과 클라이언트 사이트 로드밸런서와 함께 동작할 수 있도록 수정해보자.

마이크로서비스 발견을 이용하여 RestTemplate 사용하기

위의 예제에서 ribbon.eureka.enabled를 false로 사용했지만 이제 서비스 발견사용을 위해 true로 수정해주자. 서비스 발견은 내부 통신을 위한 설정을 간단하게 해준다. 

이제 유레카 서버를 띄워보자. 유레카 서버에 대한 설명은 해당 포스팅(TODO - url fix )을 참고하자. 이제, 전의 예제에서 리본 클라이언트 관련된 어노테이션과 설정을 모두 지우자. 그리고 유레카 서버와의 연동을 위해서 @EnableDiscoveryClient 어노테이션과 application.yml에 유레카 서버 정보를 입력해주자.

@SpringBootApplication
@EnableDiscoveryClient
public class OrderApplication {

 @LoadBalanced
 @Bean
 RestTemplate restTemplate() {
 return new RestTemplate();
 }

 public static void main(String[] args) {
     new SpringApplicationBuilder(OrderApplication.class).web(true).run(args);
 }
 // ...
}

spring: 
 application:
   name: order-service

server:
 port: ${PORT:8090}

eureka:
 client:
   serviceUrl:
     defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/}

그리고 product-service와 account-service를 하나 더 띄워보자. Eureka 서버를 확인해보면 다음과 같이 서비스들이 등록되어 있다.


로드밸런싱에 대하여

product-service를 호출하면, 리본 클라이언트는 마지막 요청이 어디로 갔는지 기억하고 현재 요청을 다른 곳에 보내준다. 즉, 라운드 로빈 방식을 사용하고 있다. 

order-service를 호출하면, 다음과 같은 로그를 볼 수 있다. 해당 로그는 리본 클라이언트가 마이크로서비스들이 등록된 주소들을 보여준 것이다. 

DynamicServerListLoadBalancer for client account-service initialized: DynamicServerListLoadBalancer:{NFLoadBalancer:name=account-service,current list of Servers=[minkowp-l.p4.org:8091, minkowp-l.p4.org:9091],Load balancer stats=Zone stats: {defaultzone=[Zone:defaultzone; Instance count:2; Active connections count: 0; Circuit breaker tripped count: 0; Active connections per server: 0.0;]
 },Server stats: [[Server:minkowp-l.p4.org:8091; Zone:defaultZone; Total Requests:0; Successive connection failure:0; Total blackout seconds:0; Last connection made:Thu Jan 01 01:00:00 CET 1970; First connection made: Thu Jan 01 01:00:00 CET 1970; Active Connections:0; total failure count in last (1000) msecs:0; average resp time:0.0; 90 percentile resp time:0.0; 95 percentile resp time:0.0; min resp time:0.0; max resp time:0.0; stddev resp time:0.0]
 , [Server:minkowp-l.p4.org:9091; Zone:defaultZone; Total Requests:0; Successive connection failure:0; Total blackout seconds:0; Last connection made:Thu Jan 01 01:00:00 CET 1970; First connection made: Thu Jan 01 01:00:00 CET 1970; Active Connections:0; total failure count in last (1000) msecs:0; average resp time:0.0; 90 percentile resp time:0.0; 95 percentile resp time:0.0; min resp time:0.0; max resp time:0.0; stddev resp time:0.0]
 ]}ServerList:org.springframework.cloud.netflix.ribbon.eureka.DomainExtractingServerList@3e878e67

Feign Client를 사용해서 마이크로서비스간 호출해보기

위에서 RestTemplate를 통해 통신을 하였다. Netflix에서는 설정없이 REST service를 호출해주는 기능을 제공해준다. 그것이 Feign Client이다. 

Feign interface 만들기

@FeignClient(name = "... " ) 어노테이션과 인터페이스만 만들면 기존의 Controller처럼 사용할 수 있다. FeignClient에 들어가는 name은 유레카 서버에 등록된 서비스 이름이다. 그리고 서비스 발견이 enabled로 되어 있어야 한다. order-service에 다음과 같이 설정해주자.

@FeignClient(name = "account-service")
public interface AccountClient {
    @PutMapping("/withdraw/{accountId}/{amount}")
    Account withdraw(@PathVariable("accountId") Long id, @PathVariable("amount") int amount);
}

@FeignClient(name = "customer-service")
public interface CustomerClient {
    @GetMapping("/withAccounts/{customerId}")
    Customer findByIdWithAccounts(@PathVariable("customerId") Long customerId);
}

@FeignClient(name = "product-service")
public interface ProductClient {
    @PostMapping("/ids")
    List<Product> findByIds(List<Long> ids);
}
@Autowired
OrderRepository repository;
@Autowired
AccountClient accountClient;
@Autowired
CustomerClient customerClient;
@Autowired
ProductClient productClient;

@PostMapping
public Order prepare(@RequestBody Order order) {
    int price = 0;
    List<Product> products = productClient.findByIds(order.getProductIds());
    Customer customer = customerClient.findByIdWithAccounts(order.getCustomerId());
    for (Product product : products)
        price += product.getPrice();
    final int priceDiscounted = priceDiscount(price, customer);
    Optional<Account> account = customer.getAccounts().stream().filter(a -> (a.getBalance() > priceDiscounted)).findFirst();
    if (account.isPresent()) {
        order.setAccountId(account.get().getId());
        order.setStatus(OrderStatus.ACCEPTED);
        order.setPrice(priceDiscounted);
    } else {
        order.setStatus(OrderStatus.REJECTED);
    }
    return repository.add(order);
}

다른 존 지원하기

이전 포스팅에서 살펴본 Zoning mechanism에 대해서 살펴보자. 모든 마이크로서비스는 2개씩 떠 있고, 유레카 서버만 하나 떠 있다. Feign Client 사용법과 마이크로서비간 통신을 Zoning 메카니즘에 어떻게 적용하는지 살펴보자.

다음 아키텍처를 위해 application.yml을 수정하자. 

spring: 
 application:
     name: account-service

---
spring:
 profiles: zone1
eureka:
 instance:
     metadataMap:
         zone: zone1
 client:
     serviceUrl:
        defaultZone: http://localhost:8761/eureka/
        preferSameZoneEureka: true
server: 
 port: ${PORT:8091}

---
spring:
 profiles: zone2
eureka:
 instance:
     metadataMap:
        zone: zone2
 client:
     serviceUrl:
        defaultZone: http://localhost:8761/eureka/
        preferSameZoneEureka: true
server: 
 port: ${PORT:9091}

Anntotation 대신 Feign Builder API 사용하기

AccountClient accountClient = Feign.builder().client(new OkHttpClient())
    .encoder(new JAXBEncoder())
    .decoder(new JAXBDecoder())
    .contract(new JAXRSContract())
    .requestInterceptor(new BasicAuthRequestInterceptor("user", "password"))
    .target(AccountClient.class, "http://account-service");

스프링 클라우드에서 마이크로서비스간 통신 정리

마이크로서비스간 통신에 대해 살펴보았다. Rest Client, 많은 인스턴스로 로드밸런싱하기, 서비스 발견과 통합하기에 대해 살펴보았다. 다음 포스팅에서는 내부 서비스간 통신에 대해 좀더 자세히 살펴볼 예정이다. 
 우리는 이제 Ribbon, Feign, RestTemplate를 Spring Cloud Componet와 연결할 때 적절하게 사용할 수 있다. 

댓글

이 블로그의 인기 게시물

[Protocol] WIEGAND 통신

Orange for Oracle에서 한글 깨짐 해결책

[UI] GNB·LNB·SNB·FNB 용어 설명