[LG CNS AM Inspire Camp 1기] MSA (4) - 서비스 사이에서 API 통신

정성엽·2025년 3월 12일
0

LG CNS AM Inspire 1기

목록 보기
58/70
post-thumbnail

INTRO

이전 포스팅에서는 Spring Cloud Config Server에 대해 알아봤다.

MSA를 채택했다면 서비스 사이에서 데이터를 주고받아야하는 경우가 대부분일 것이다.

따라서, 이번에는 MSA 환경에서 서비스 간 통신 방법에 대해 정리해보려고 한다 👀


1. 서비스 간 통신의 필요성

MSA 환경에서는 각 서비스가 독립적으로 동작하지만, 실제 비즈니스 로직을 처리하기 위해서는 서비스 간 데이터 교환이 필요하다.

예를 들어, 사용자 서비스에서 주문 정보를 조회하거나, 주문 서비스에서 상품 정보를 가져오는 등의 작업에서 서비스간의 통신이 필요할 것이다.

우리는 서비스 간 통신 방식을 크게 두 가지로 나눌 수 있다

동기 방식

  • HTTP 기반의 REST API 호출 (REST Template, Feign Client)

비동기 방식

  • 메시지 브로커를 사용한 통신 (AMQP, Kafka 등)

이번 포스팅에서는 우선 동기 방식의 통신 방법을 중심으로 알아보려고 한다.


2. RestTemplate을 이용한 통신

가장 기본적인 서비스 간 통신 방법은 RestTemplate을 사용하는 것이다.

Spring에서 제공하는 RestTemplate을 사용하면 HTTP 요청을 쉽게 만들 수 있다.

우선 코드를 살펴보자

💡 RestTemplate 빈 등록

Sample Code

@Bean
public RestTemplate getRestTemplate() {
    int TIMEOUT = 5000;

    RestTemplate restTemplate = new RestTemplateBuilder()
            .setConnectTimeout(Duration.ofMillis(TIMEOUT))
            .setReadTimeout(Duration.ofMillis(TIMEOUT))
            .build();

    return restTemplate;
}

DI를 통해 쉽게 RestTemplate를 사용하기 위해 빈으로 등록한 과정이다.

빈은 어디에서 등록해도 상관없기 때문에 각자 적절한 위치에서 등록하자

💡 RestTemplate 사용 예시 (1)

Sample Code

@Service
public class UserServiceImpl implements UserService {

    private final RestTemplate restTemplate;
    private final Environment env;

    public UserServiceImpl(RestTemplate restTemplate, Environment env) {
        this.restTemplate = restTemplate;
        this.env = env;
    }

    @Override
    public UserDto getUserByUserId(String userId) {
        // 사용자 정보 조회
        UserEntity userEntity = userRepository.findByUserId(userId);

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

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

        // 주문 서비스에서 주문 정보 가져오기
        String orderUrl = String.format("http://127.0.0.1:8000/order-service/%s/orders", userId);
        ResponseEntity<List<ResponseOrder>> orderListResponse =
                restTemplate.exchange(orderUrl, HttpMethod.GET, null,
                        new ParameterizedTypeReference<List<ResponseOrder>>() {});

        List<ResponseOrder> ordersList = orderListResponse.getBody();
        userDto.setOrders(ordersList);

        return userDto;
    }
}

restTemplate은 exchange 메서드를 호출하여 HTTP 통신을 진행할 수 있다.

코드를 살펴보면 orderUrl을 Order Service에서 제공하는 엔드포인트에 맞춰서 설정하고 HTTP Method 등을 설정하여 요청을 보내고 있다.

여기서 우리가 눈여겨 봐야할 것은 바로 orderUrl을 세팅하는 부분이다.

💡 RestTemplate 사용 예시 (2)

위 코드에서 발생하는 문제점은 바로 OrderUrl이 하드코딩되어있다는 것이다.

이러한 하드코딩을 피하는 방법을 생각해보면 서비스 디스커버리에 등록된 인스턴스를 찾아서 Url을 세팅해주면 될 것이다.

Eureka에서 인스턴스 정보를 뽑아서 사용하기 위해서는 다음과 같은 설정이 필요하다.

Sample Code

@Bean
@LoadBalanced
public RestTemplate getRestTemplate() {
	int TIMEOUT = 5000;

	RestTemplate restTemplate = new RestTemplateBuilder()
                .setConnectTimeout(Duration.ofMillis(TIMEOUT))
                .setReadTimeout(Duration.ofMillis(TIMEOUT))
                .build();

	return restTemplate;
}

RestTemplate을 빈으로 등록하는 과정에서 @LoadBalanced 라는 어노테이션을 추가해주면 Eureka에 등록된 서비스 이름으로 API를 호출할 수 있다!

Sample Code

@Service
public class UserServiceImpl implements UserService {

    private final RestTemplate restTemplate;
    private final Environment env;

    public UserServiceImpl(RestTemplate restTemplate, Environment env) {
        this.restTemplate = restTemplate;
        this.env = env;
    }

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

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

        UserDto userDto = new ModelMapper().map(userEntity, UserDto.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>>() {});

        List<ResponseOrder> ordersList = orderListResponse.getBody();
        userDto.setOrders(ordersList);

        return userDto;
    }
}

spring:
  config:
    import: optional:configserver:http://127.0.0.1:8888/

필자는 실습 과정에서 Config Server에서 참조하는 Github Repository에서 Order-Service의 URL을 정의하고 있기 때문에 위처럼 order-service.url을 환경변수에 지정해주면 된다.

지정된 URL을 살펴보면 http://ORDER-SERVICE/order-service/%s/orders 로 정의되어있는데 여기서 우리는 한가지 차이점을 기억해야 한다.

@LoadBalanced 어노테이션을 사용하면 API Gateway가 아닌 Eureka에서 직접 인스턴스를 찾아서 접근한다는 것을 기억하자

따라서, ORDER-SERVICE 는 Eureka에 등록된 인스턴스 이름을 의미한다.

뒤에 있는 /order-service/%s/orders 는 API의 엔드포인트이다.

(필자는 order-service에서 /order-service라는 prefix를 사용하고 있기 떄문에 사용하는 것이다.)

이렇게 하면 ORDER-SERVICE라는 서비스 이름으로 Eureka에 등록된 서비스를 찾아 요청을 보낸다.

여러 인스턴스가 있을 경우 로드 밸런싱도 자동으로 처리된다는 것을 기억하자!


3. FeignClient를 이용한 통신

RestTemplate보다 더 선언적이고 간편한 방법으로 Feign Client가 있다.

OpenFeign 은 Netflix에서 개발한 HTTP 클라이언트로, 인터페이스와 어노테이션만으로 HTTP API 호출을 구현할 수 있다.

우선 OpenFeign을 사용하기 위해서는 해당 의존성을 추가해야 한다.

💡 Feign Client 활성화

Sample Code

@SpringBootApplication
@EnableFeignClients
public class UserServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserServiceApplication.class, args);
    }
}

위 코드처럼 BootApplication에서 @EnalbeFeignClients 어노테이션을 추가하여 Feign Client를 사용하겠다는 것을 명시적으로 선언해야 한다.

💡 Feign Client 정의

Sample Code

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

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

실제 /order-service/{userId}/orders 구현부

@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);
}

이처럼 @FeignClient 어노테이션을 사용하여 Feign Client를 정의할 수 있다.

여기서 어노테이션 부분에서 사용된 name 속성은 Eureka에 등록된 서비스 인스턴스의 이름을 의미한다.

다음으로 @GetMapping 주소는 해당 서비스의 API 엔트포인트를 적어주면 된다.

실제로 Order-Service에서는 ResponseEntity 로 반환하고 있지만, Feign Client는 List<ResponseOrder> 를 반환타입으로 지정하고 있다.

ResponseEntity<List<ResponseOrder>> 를 반환하는 것 사이에 불일치가 있는 것처럼 보이지만, 사실 이것은 FeignClient의 동작 방식 때문에 가능한 것이다.

컨트롤러의 실제 반환 부분

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

위처럼 컨트롤러가 실제로 ResponseEntity를 반환하더라도 Feign Client는 두가지의 편의 기능을 모두 제공한다.

응답 본문만 원하는 경우

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

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

응답 전체를 원하는 경우

@FeignClient(name="order-service", configuration = FeignErrorDecoder.class)
public interface OrderServiceClient {

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

따라서, 개발자가 응답 코드도 원한다면 ResponseEntity 타입으로 Feign Client를 정의해주면 된다.

💡 Felign Client 사용 예시

Sample Code

@Service
public class UserServiceImpl implements UserService {

    private final OrderServiceClient orderServiceClient;

    public UserServiceImpl(OrderServiceClient orderServiceClient) {
        this.orderServiceClient = orderServiceClient;
    }

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

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

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

        // Feign Client를 통한 주문 정보 조회
        List<ResponseOrder> ordersList = orderServiceClient.getOrders(userId);
        userDto.setOrders(ordersList);

        return userDto;
    }
}

여기서 OrderServiceCLient는 Feign Client를 정의한 부분이다.

💡 에러 헨들링

서비스 사이에서 오류가 발생한 경우, 당연히 개발자는 에러를 처리해야 한다.

간단하게 try-catch 블록을 추가해서 에러를 잡아서 해결할 수도 있겠으나, Feign Client를 사용하는 경우 ErrorDecoder 를 구현하여 등록하면 된다.

우선 코드를 살펴보자

Sample Code

@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()),
//                            "User's orders is empty");
                           env.getProperty("order-service.exception.order-is-empty"));
                }
                break;
            default:
                return new Exception(response.reason());
        }

        return null;
    }
}

ErrorDecode를 상속받아서 별도의 Decoder를 구현해주면 된다.

여기서 decode 메서드를 오버라이딩하여 어떻게 처리할지를 결정하면 된다.

methodKey에는 호출한 메서드명이 들어가게 되고, response는 호출 결과를 가져온다.

(status에 접근할 수 있다.)

다음으로 이전에 구현한 Feign Client에 해당 Decoder를 등록해주면 된다.

Sample Code

@FeignClient(name="order-service", configuration = FeignErrorDecoder.class)
public interface OrderServiceClient {

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

이렇게 하면 Feign Client에서 발생하는 모든 예외가 ErrorDecoder를 통해 처리된다!

서비스 간 통신에서 발생하는 다양한 오류 상황을 중앙에서 관리할 수 있어 코드가 더 깔끔해진다는 장점이 있다.


6. 분산 환경에서 발생하는 통신 문제

간단하게 서비스만 구조도에 포함하면 다음과 같은 구조를 갖는다.

즉, 여러개의 서비스 인스턴스가 생기면 각 서비스에 매핑되는 DB가 생긴다.

이렇게 되면 다음과 같은 문제가 발생할 수 있다.

우선 위 구조와 동일하게 Order-Service 인스턴스를 2개 생성해보자

다음으로 User가 Catalog-001, Catalog-002, Catalog-003 상품을 주문했다.

이후, 주문 내역 조회를 시도해보면 다음과 같은 화면을 확인할 수 있다.

처음 조회에는 Catalog-002 상품만 조회되고 있다.

다시한번 동일한 path로 주문 내역 조회 요청을 날리면 다음과 같은 결과를 볼 수 있다.

Catalog-001과 Catalog-002 상품이 조회된다.

실제로 각 서비스의 h2-console에 접근하면 다음과 같은 내용을 확인할 수 있다.

사용자 서비스에서 RestTemplate이나 Feign Client를 통해 주문 정보를 조회하면 로드 밸런싱에 의해 두 인스턴스 중 하나에만 요청이 가게 된다.

따라서 첫 번째 인스턴스로 요청이 갔다면 첫 번째와 세 번째 주문만 보이고, 두 번째 인스턴스로 요청이 갔다면 두 번째 주문만 보이게 된다.

그렇다면 이러한 문제를 어떻게 해결할 수 있을까?

이 내용은 다음 포스팅에서 다뤄보려고 한다.


OUTRO

이번 포스팅에서는 RestTemplate과 FeignClient를 사용한 동기 방식의 통신에 대해서 살펴봤다.

FeignClient를 사용할 때는 ErrorDecoder를 통해 효율적인 예외 처리가 가능하다는 점도 알아봤다.

하지만, 여러 서비스 인스턴스에서 발생하는 데이터 일관성 문제는 동기 방식의 통신만으로는 해결하기 어려운 문제다.

이를 해결하기 위해서는 이벤트 기반의 비동기 통신 방식이 필요하다.

다음 포스팅에서는 Kafka를 활용한 이벤트 기반 아키텍처로 데이터 일관성 문제를 해결하는 방법에 살펴보려고 한다.

각 서비스 사이에서 통신을 어떻게 처리하는지는 잘 알아두도록 하자 👊

profile
코린이

0개의 댓글