본문 바로가기
Programming/Java, Spring

[Spring Cloud] 마이크로서비스 간의 통신 RestTemplate, FeignClient, Error decoder, 동기화 문제

by Renechoi 2023. 6. 2.

 

동기 방식과 비동기 방식으로 나누어 생각할 수 있다. 

 

동기방식의 문제점은 해당 요청이 끝날 때까지 다른 작업을 할 수 없다는 것이다. 요청 - 컨트롤러 - 서비스 // => 이것을 하나의 프로세스라고 하면 끝나야 다음 작업이 진행된다. 

 

- 일반적인 방식의 동기 방식

 - AMQP를 통한 비동기 방식 

 

Eureka discovery service를 사용하면 기본적으로 round robin 방식으로 순차 처리가 가능하다. 이때 설정에 따라서 dispatch 하는 방식을 다르게 정할 수도 있다. (지역, 시간대, 리소스 등 => 분산) 

 


1. RestTemplate 

Http 프로토콜을 통해 다른 API를 연결하는 방식 

 

클라이언트 요청 -> localhost:8000/user-service/users -> rest template -> orderservice로 연결 

즉 user microservice - orderservice간의 연결 

 

 

먼저 rest template bean을 등록한다. 

 

@Bean
public RestTemplate getRestTemplate() {
   return new RestTemplate();
}

 

 

다음과 같은 주소에 api를 요청할 수 있다. gateway를 통해 order-service에 접속한다. 해당 uri는 getOrder()메서드를 호출해 orders를 반환해 준다.

String orderUrl = "http://127.0.0.1:8000/order-service/%s/orders";

 

OrderService의 반환 메서드 

 

@GetMapping("/{userId}/orders")
public ResponseEntity<List<ResponseOrder>> getOrder(@PathVariable("userId") String userId) throws Exception {
    log.info("Before retrieve orders data");
    Iterable<OrderEntity> orderList = orderService.getOrdersByUserId(userId);

    List<ResponseOrder> result = new ArrayList<>();
    orderList.forEach(v -> result.add(new ModelMapper().map(v, ResponseOrder.class)));

    log.info("Add retrieved orders data");

    return ResponseEntity.status(HttpStatus.OK).body(result);
}

 

 

 

UserService 소환 메서드 

 

@Override
public UserDto getUserByUserId(String userId) {
   UserEntity userEntity = userRepository.findByUserId(userId);

   if (userEntity == null)
      throw new UsernameNotFoundException("User not found");

   String orderUrl = "http://127.0.0.1:8000/order-service/%s/orders";
   /* Using as rest template */
   ResponseEntity<List<ResponseOrder>> orderListResponse = restTemplate.exchange(orderUrl, HttpMethod.GET, null,
      new ParameterizedTypeReference<>() {
      });
   List<ResponseOrder> ordersList = orderListResponse.getBody();
   UserDto userDto = new ModelMapper().map(userEntity, UserDto.class);
   userDto.setOrders(ordersList);

   return userDto;
}

 

 

그런데 url은 하드코딩하기보다 설정 파일에 저장할 수도 있다. 

 

token:
expiration_time: 86400000
secret: user_token

gateway:
ip: 192.8.0.0

order_service:
url: http://127.0.0.1:8000/order-service/%s/orders

 

 

이를 불러와서 사용하는 방식으로 고쳐준다. 

 

@Override
public UserDto getUserByUserId(String userId) {
   UserEntity userEntity = userRepository.findByUserId(userId);

   if (userEntity == null)
      throw new UsernameNotFoundException("User not found");

   /* Using as rest template */
   String orderUrl = String.format(Objects.requireNonNull(environment.getProperty("order_service.url")), userId);
   ResponseEntity<List<ResponseOrder>> orderListResponse = restTemplate.exchange(orderUrl, HttpMethod.GET, null,
      new ParameterizedTypeReference<>() {
      });
   List<ResponseOrder> ordersList = orderListResponse.getBody();
   UserDto userDto = new ModelMapper().map(userEntity, UserDto.class);
   userDto.setOrders(ordersList);

   return userDto;
}

 

 

 

그런데 주소를 좀 더 간단하게 표현할 수 없을까?

 

스프링 클라우드에서 제공하는

@LoadBalanced

애노테이션을 쓰면 

 

gateway 주소인 

http://localhost:8000 대신에 microservice name으로 사용할 수 있다. 

 

=> ip address가 변경되더라도 동일하게 사용 할 수 있다. 

 

yml 파일 변경 ->

order_service:
url: http://ORDER-SERVICE/order-service/%s/orders

 

 


2. Feign Client 

Rest call을 추상화한 Spring cloud netflix의 라이브러리 

 

사용방법 

- 호출하려는 Http Endpoint에 대한 interface 생성 

- @FeignClient 선언

=> Rest template보다 간단하다 

 

Load balanced 지원한다.

 

 

분리된 마이크로서비스이지만 하나의 서비스에 있는 메서드처럼 사용할 수 있어 직관적이고 간편하다. 

 

 

 

User Service에서 Order Service를 호출하는 상황 => User Service에 Feign Interface를 구현하면 된다. 

 

 

먼저 다음과 같은 의존성 추가 

 

implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'

 

 

메인 클래스에 @EnableFeignClients 추가 

 

 

 

 

다음과 같은 인터페이스를 작성한다.

 

@FeignClient(name="order-service")
public interface OrderServiceClient {

    @GetMapping("/order-service/{userId}/orders")
    List<ResponseOrder> getOrders(@PathVariable String userId);

}

 

service 클래스에서 의존성을 주입한다.

 

private final OrderServiceClient orderServiceClient;

 

위의 rest template로 작성한 내용이 두 줄로 축약된다. 

 

/* Using as rest template */
// String orderUrl = String.format(Objects.requireNonNull(environment.getProperty("order_service.url")), userId);
// ResponseEntity<List<ResponseOrder>> orderListResponse = restTemplate.exchange(orderUrl, HttpMethod.GET, null,
//     new ParameterizedTypeReference<>() {
//     });
// List<ResponseOrder> ordersList = orderListResponse.getBody();

/* Using as feign */
List<ResponseOrder> orders = orderServiceClient.getOrders(userId);
userDto.setOrders(orders);

return userDto;

 

 


 

Feign client 로그 추적 및 예외 처리 

 

다음과 같은 코드로 로그를 남길 수 있다. 

 

yml 파일 

logging:
  level:
    com.example.userservice.client: DEBUG

 

bean 등록 

@Bean
public Logger.Level feignLoggerLevel() {
   return Logger.Level.FULL;
}

 

 

 

url을 잘못 썼거나 order service의 서버 에러로 어떤 이유든지 만약 feign client의 호출이 정상적으로 되지 않았을 때는 어떻게 해야할까? 

 

user service에서 order에 대한 정보를 받아와서 반환해주는 상황에서 feign client를 통해 호출한 order에서 에러가 발생했다고 하여 해당 에러를 그대로 본 서버에서 전파시킬 필요는 없다. 따라서 try 구문으로 잡아주고, order는 null로 처리해주는 방식을 생각해볼 수 있다. 

 

List<ResponseOrder> orders =null;
try {
   orders = orderServiceClient.getOrders(userId);
} catch (FeignException e){
   log.info(e.getMessage());
}

userDto.setOrders(orders);

 

* null을 명시적으로 초기화시켜주는 것은 좋지 않다. 다른 방식 고려 필요. 

 

 


 

 

FeignClient에서 제공해주는 ErrorDecoder를 구현해보자. 

 

ErrorDecoder를 구현하는 새로운 클래스를 만들어 decode()를 구현해준다. 

 

에러를 좀 더 디테일하게 처리할 수 있다. 

 


@Component
public class FeignErrorDecoder implements ErrorDecoder {
    Environment env;

    @Autowired
    public FeignErrorDecoder(Environment env) {
        this.env = env;
    }

    @Override
    public Exception decode(String methodKey, Response response) {
        switch(response.status()) {
            case 400:
                break;
            case 404:
                if (methodKey.contains("getOrders")) {
                    return new ResponseStatusException(HttpStatus.valueOf(response.status()),
                            env.getProperty("order_service.exception.orders_is_empty"));
                }
                break;
            default:
                return new Exception(response.reason());
        }

        return null;
    }
}

 

 

이와 같은 클래스를 구현해 줌으로써 더 이상 try catch 구문도 필요 없어졌다. 

 

404 에러가 발생하면 잡아준다. 

 

/* ErrorDecoder */
List<ResponseOrder> orders = orderServiceClient.getOrders(userId);

 

 


 

 

현재 시나리오는 User MicroService와 Order MicroService 두 개의 서버를 상정하고 User -> Order 요청을 보낸 뒤 이를 다시 조회 Order -> User 하는 시나리오이다. 

 

이때 Order MicroService가 여러 인스턴스를 갖고 있고, 유저가 여러 건의 주문을 발생시킨다면 어떻게 될까? 

 

Order는 LoadBalancing에 의해 Round Robin 방식으로 인스턴스에 저장된다. 1 server와 2 server가 있다면 번갈아가면서 생성하게 된다. 즉 데이터가 분산 저장되는 것이다. 

 

Service 1에 {1, 3, 5, 7, 9} orders가 저장되고, Service 2에 {2, 4, 6, 8, 10} orders가 저장된다면, User 에서 Order를 조회할 때는 그렇다면 ㅇ

 

 

 

 

 

각각의 다른 포트번호에서 실행 중인 두 가지 인스턴스의 OrderService 

 

 

 

다음과 같이 order-service에 주문을 넣어준다. 5개를 주문한다. 

 

 

이때 다음과 같이 두 개의 다른 db에 분산 저장되는 것을 확인할 수 있다. 

 

 

 

이때 사용자 정보를 요청하면 다음과 같이 두 개의 주문만 가져온다. 

 

 

다시 한번 호출 하면 

 

 

 

3개를 가져온다.

 

즉, 두가지 OrderService가 running하고 있는 상황에서는 번갈아가면서 응답하므로 동기화 문제가 발생하는 것을 볼 수 있다. 

 

 

 

이와 같은 동기화 문제가 발생할 때 3가지 해결책을 생각해볼 수 있다. 

 

1. 하나의 데이터베이스를 사용한다 

-> 물리적으로 분리된 인스턴스에서 하나의 데이터베이스 접근하려면 트랜잭션 동시성 문제가 발생할 수 있다. 

 

2. 데이터베이스의 자료를 동기화시킨다 

-> 각각의 데이터베이스를 갖되 중간의 매개체를 이용해 이벤트 발생시 알려준다. 

 

3. 위의 2가지 방법을 혼합한다

-> 하나의 데이터베이스를 쓰되 중간에 매게체를 사용한다. 

-> Message Queing Server로 RabbitMq나 Apache Kafka를 고려한다. 

-> Order Service는 MQS에 전달만 하면 모든 책임을 다하며, Queing Server는 데이터베이스 저장 역할을 수행한다. 

 

 

 

 


ref. https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-%EB%A7%88%EC%9D%B4%ED%81%AC%EB%A1%9C%EC%84%9C%EB%B9%84%EC%8A%A4/

 

 

 

반응형