Spring Cloud + MSA 애플리케이션 개발 9(Microservice간 통신)

지원·2024년 2월 27일
0

MSA개발

목록 보기
9/15

Communication types

  • Synchronous HTTP communication
    -> 동기 방식 : 요청이 들어오면 이 요청이 끝날때까지는 다른 클라이언트의 요청은 받지 않는다.
  • Asynchronous coomunicatino over AMQP
    -> 비동기 방식 : 순차적으로 하는게 아니라 연결되어 있는 모든 클라이언트에게 한 번에 처리할 수 있다.

user-service 와 order-serivce 가 서로 통신하려면 어떻게 해야할까?

  • user-service 에서 유레카 서버를 통해 order-server 의 위치를 알고 찾은 위치에 요청을 보내면 된다.

하지만 Rest Template 을 사용하면 유레카 서버를 통하지 않고 한 번에 통신할 수 있다.

  • 클라이언트에서 user-service/user/{userId} 로 요청이 들어왔고, userId 를 가지고 고객의 주문 내역도 보여줄 수 있다.
  • 이때 Rest Template 을 사용하면 넘어온 userId 를 가지고 order-service/{userId}/orders 로 한 번에 통신할 수 있게 해준다.

RestTemplate 사용

  • RestTemplate 를 Bean 으로 등록해서 사용하면 된다.
  • 해당 예제에서 사용할 시나리오는 user-service 에서 회원 정보를 찾을 때 order-service 에서 그 회원의 주문 내역까지 담아서 반환하려고 하는 것이다.
  • 그렇다면 user-service 에서 restTemplate 을 사용해서 order-service 를 호출하면 된다.
  • restTemplate.exchange() 를 사용하고 파라미터로 주소 , 요청 방식 , requestType , dataType 양식의 정보들을 넘겨주면 된다.
// UserServiceApplication.class
    // Bean 으로 등록하기 때문에 다른 곳에서 주입 받아 사용할 수 있다.
    @Bean
    public RestTemplate getRestTemplate() {
        return new RestTemplate();
    }
    
// UserServiceImpl.class
    @Override
    public UserDto getUserById(String userId) {
        UserEntity userEntity = userRepository.findByUserId(userId);

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

        UserDto userDto = new ModelMapper().map(userEntity , UserDto.class);

        List<ResponseOrder> orderList = new ArrayList<>();
        userDto.setOrders(orderList);
        return userDto;
    }
    
// order-service
    @GetMapping("/{userId}/orders")
    public ResponseEntity<List<ResponseOrder>> getOrder(@PathVariable("userId") String userId) {
        ModelMapper modelMapper = new ModelMapper();
        Iterable<OrderEntity> orderList = orderService.getOrdersByUserId(userId);

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

        return ResponseEntity.status(HttpStatus.OK).body(result);
    }
  • 원래는 user-service 에서 주문 내역은 비어있는 List 로만 넘겨줬는데 이제 order-service 와 직접 통신을 해서 주문 내역을 가져와서 넘겨주도록 해보자.
  • 그렇게 하려면 Bean 으로 등록했던 RestTemplate 을 사용하면 된다.
    @Override
    public UserDto getUserById(String userId) {
        UserEntity userEntity = userRepository.findByUserId(userId);

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

        UserDto userDto = new ModelMapper().map(userEntity , UserDto.class);

        // restTemplate 을 하는 첫 번째 방법
        String orderUrl = "http://127.0.0.1:8000/order-service/%s/orders";
        ResponseEntity<List<ResponseOrder>> orderListResponse = restTemplate.exchange(orderUrl, HttpMethod.GET, null,
                new ParameterizedTypeReference<List<ResponseOrder>>() {
                });
        List<ResponseOrder> orderList = orderListResponse.getBody();
        userDto.setOrders(orderList);
        return userDto;
    }
  • orderUrl 에 요청할 endpoint 를 저장하고 restTemplate.exchange() 로 통신하면 된다.
  • 넘겨야할 파라미터 : 요청URL , 요청 방식 , 요청 파라미터 (없으면 null) , 전달 받을 때 어떤 형식으로 전달 받을지 설정

위와 같은 방식으로 service 끼리 통신하면 된다.

현재 orderURL 을 하드코딩을 해놨는데 만약 포트 번호가 바뀌거나 endpoint 가 변경될 경우를 고려해보면 좋은 방식은 아니다.

  • 그래서 해당 orderURL 을 config-server 에서 관리하는 파일안에 user-service.yml 에다가 orderURL 을 관리하면 된다.
# user-service.yml
order_service:
  url: http://127.0.0.1:8000/order-service/%s/orders
// UserServiceImpl.class
        String orderUrl = String.format(env.getProperty("order_service.url"), userId);
        ResponseEntity<List<ResponseOrder>> orderListResponse = restTemplate.exchange(orderUrl, HttpMethod.GET, null,
                new ParameterizedTypeReference<List<ResponseOrder>>() {
                });
  • yml 파일에 url 주소를 넣고 사용할 때 Enviroment 를 사용해서 해당 값을 불러오면 된다.
  • env.getProperty() 를 가져오고 %s 로 되어 있는 값을 userId 로 넣어주면 똑같은 코드가 된다.

이때 yml 파일에 127.0.0.1:8000 이 아닌 유레카 서버에 등록한 이름대로 ORDER-SERVICE/order-service 이런식으로 요청하는게 더 좋은 설계이다.

  • 위와 똑같은 이유로 포트 번호가 바뀌는 경우에 하드 코딩을 하면 직접 바꿔야하지만 저런식으로 요청하게 되면 변경할 필요가 없다.
    @Bean
    // @LoadBalanced 를 붙혀주면 /user-service/ 로 접근할 수 있다.
    @LoadBalanced
    public RestTemplate getRestTemplate() {
        return new RestTemplate();
    }
  • RestTemplate 을 Bean 으로 등록할 때 @LoadBalanced 를 붙혀주면 된다.
order_service:
  url: http://ORDER-SERVICE/order-service/%s/orders
  • 그런 후 user-service.yml 에서 ORDER-SERVICE 로 바꿔주면 된다.
  • 현재 값을 변경했기 때문에 전에 배웠던 spring clooud bus 를 이용하면 한 번에 모든 클라이언트에게 변경된 값을 반영할 수 있다.
  • 그래서 /busrefresh endpoint 를 요청하면 된다.

똑같이 동작하지만 변경을 해야할 때 좀 더 편해지는 코드를 작성해봤다.

FeignClient 사용

  • FeignClient -> HTTP Client
    -> REST Call 을 추상화 한 Spring Cloud Netflix 라이브러리
  • 사용방법
    -> 호출하려는 HTTP Endpoint 에 대한 Interface 를 생성
    -> @FeignClient 선언
  • Load balanced 지원
  • RestTemplate 보다 간단하고 직관적이다.

Feign Client 적용

  • Spring Cloud Netflix 라이브러리 추가
  • @Feign Client Interface 생성
  • UserServiceImpl.class 에서 Feign Client 사용

먼저 user-service 에서 아래에 코드로 라이브러리를 추가한다.

<dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
@EnableFeignClients
public class UserServiceApplication { ... }

@FeignClient(name="order-service") // microservice 이름 넣기
public interface OrderServiceClient {

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

// UserServiceImpl.class
    @Override
    public UserDto getUserById(String userId) {
        UserEntity userEntity = userRepository.findByUserId(userId);

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

        UserDto userDto = new ModelMapper().map(userEntity , UserDto.class);

        /**
         * using feign client
         */
        List<ResponseOrder> orderList = orderServiceClient.getOrders(userId);
        userDto.setOrders(orderList);
        return userDto;
    }
  • 먼저 UserServiceApplication 클래스레벨에 @EnableFeignClients 를 추가해준다.
  • OrdrServiceClient 인터페이스를 만들고 @FeignClient 를 붙혀주고 name 에는 호출할 microservice 이름을 넣어주면 된다.
  • 그런후 메서드를 선언하고 @GetMapping() 에는 order-service 에서 호출할 URL 을 넣어주면 된다.
  • UserServiceImpl 에서 사용할 때는 OrderServiceClient 를 DI 받고 거기서 선언한 메서드를 호출하면 된다.

FeignClient 예외 처리

  • FeignClient 사용시 발생한 로그 추적
  • yml 파일에서 logging.level 을 설정
  • UserServiceApplication.class 에서 Logger.Level 를 Bean 으로 등록
  • 이렇게 설정하면 반환값이나 요청에 대한 정보들이 로그에 출력된다.

FeignException

  • 만약 user-service 에서 order-service 를 호출하려고 하다가 잘못된 endpoint 를 호출했다고 가정해보자.
  • FeignException 예외가 터져서 500 Internal Server Error 가 발생한다.
    -> 물론 현재는 서버의 잘못보다는 클라이언트가 잘못 호출했기 때문에 404 에러가 더 적합하다.
  • 이런 Excpeiton 을 처리하기 위해서는 try catch 를 사용하면 된다.
  • user-service 에서 order-service 를 호출에 문제가 생겼지만 일단 user 의 정보는 반환시키면 좋을것이다.
  • order 의 정보는 못 가져오더라도 사용자의 정보는 문제가 없기 떄문에 사용자의 정보만이라도 반환하도록 해보자.
# application.yml
logging:
  level:
    com.example.userservice.client: DEBUG
  • application.yml 에서 로그 출력할 범위를 정한다.
  • 현재 OrderServiceClient 가 있는 패키지를 선택
    @Bean
    public Logger.Level feignLoggerLevel() {
        return Logger.Level.FULL;
    }
  • Logger.Level 을 Bean 으로 등록

OrderServiceClient 에서 @GetMapping 에 있는 Endpoing 를 이상한 문자를 넣는다.

  • order-service 에는 해당 endpoint 가 없기 때문에 오류가 발생한다.
  • 실제로 호출을 해보면 FeignException 예외가 발생한다.
  • 호출을 해볼때 위에서 로그 출력을 추가했기 떄문에 각 정보들이 로그에 출력되는 것을 확인할 수 있다.
List<ResponseOrder> orderList = null;
        try {
            orderList = orderServiceClient.getOrders(userId);
        } catch (FeignException ex) {
            log.error(ex.getMessage());
        }
  • 위와 같이 try catch 를 사용해서 예외를 잡으면 된다.

ErrorDecoder 를 이용한 예외처리

  • FeignErrorDecoder 클래스를 만들고 ErrorDecoder 를 implements 한다.
  • decode 라는 메서드를 구현하면 된다.
  • decode 는 FeignClient 에서 발생한 에러가 어떠한 에러가 발생헀는지 상태코드에 따라서 작업할 수 있도록 한다.
  • 이렇게 만든 ErrorDecoder 를 Bean 으로 등록한다.
  • 해당 방법을 사용하면 try catch 구문이 필요없어진다.
@Component
public class FeignErrorDecoder implements ErrorDecoder {

    private Environment env;

    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")) {
                    // 404 에러로 번환하고 메시지 담아서 새로운 예외 객체 생성
                    return new ResponseStatusException(HttpStatus.valueOf(response.status()),
                            "User's order is empty");
                    // env.getProperty("order_service.exception.orders_is_empty);
                }
            default:
                // 예외가 발생했던 원인에 대해서 출력
                return new Exception(response.reason());
        }
        return null;
    }
}

// UserServiceImpl.class
// ErrorDecode 는 따로 주입 받지 않아도 된다.
        List<ResponseOrder> orderList = orderServiceClient.getOrders(userId);
  • 먼저 ErrorDecoder 를 implements 하고 decode 를 구현하면 된다.
  • switch case 을 사용하고 404 에러이면서 예외가 발생한 method 이름에 getOrders 를 포함하고 있다면 새로운 예외 객체를 만들어서 반환한다.
  • 사용할 때는 따로 주입받고 사용하는 것이 아니라 이미 @Component 으로 Bean 으로 등록했기 때문에 자동으로 예외가 발생하면 로직이 수행된다.
  • ResponseStatusException 객체를 만들 때 message 에 하드코딩 하는 것이 아니라 설정 정보에 넣고 env 로 가져다가 사용하는 코드가 더 좋은 코드이다.

데이터 동기화 문제

  • Orders Service 2개 기동
    -> Users 의 요청 분산 처리
    -> Orders 데이터도 분산 저장 -> 동기화 문제

order service 2개를 기동했을 때 각각의 데이터베이스를 가진다.

  • 각각의 데이터베이스를 가지기 때문에 데이터 동기화 문제가 발생한다.
  • user 가 order 를 첫 번째 order-service 를 통해서 하고 또 다른 order 는 두 번째 order-service 로 했다고 가정해보자.
  • 사용자가 주문 내역을 보면 어쩔때는 첫 번째의 주문이 나오고 어쩔때는 두 번째의 주문이 나오는 문제가 발생한다.

해결방법
1. 하나의 database 사용
-> 이때는 트랜잭션 관리를 잘해야한다. (여러개의 서비스가 하나의 database 를 사용하기 때문)
2. database 간의 동기화
-> 각 서비스는 데이터베이스에 데이터를 저장하는것이 아니라 Message Queuing Server 에 전달을 한다.
-> Meesage Queuing Server 에 구독신청한 또 다른 서비스에게 서버에서 변경된 데이터를 보내주고 업데이트 하도록 한다.
3. kafka connector + db
-> 1번과 2번의 방법을 모두 사용
-> Message Queuing Server 를 미들웨어 즉 중간 매개체로 사용한다.
-> Service 와 database 사이에 Message Queuing Server 를 놓고 관리한다.
-> 1초 안에 수만건을 처리할 수 있도록 설계되어 있다.

order-service 서버를 기동하고 terminal 에 가서 mvn spring-boot:run 명령어로 하나의 서버를 더 기동한다.

유레카 서버에서 각 서비스의 포트 번호를 가지고 h2-console 로 접근해본다.

  • 서로 다른 데이터베이스를 가진다.

order-serivce/{userId}/orders

  • 해당 API 를 사용해서 계속 주문을 하고 order-service 들의 h2-console 로 가본다.
  • 만약 5번을 했다면 한 DB 에 3건 , 또 다른 DB 에 2건이 저장된다.
  • 또한 위에서 restTemplate , FeignClient 를 사용해서 user-service 에서 order-serivce 통신한 로직을 사용해보자.
  • 이때도 한 번은 3건만 출력되고 또 다른 한 번은 2건만 출력이 된다.
  • 번갈아가면서 나오는 방법은 기본적으로 라운드 로빈 방식을 사용하기 때문이다.

즉 데이터 동기화에 대한 문제가 있다.

이제 데이터 동기화를 위해 Apache Kafka 를 활용해보자.

참고자료

profile
덕업일치

0개의 댓글