마이크로서비스 디스커버리 탐색: 클라이언트 측 및 서버 측 패턴 파헤치기
Lukas Schneider
DevOps Engineer · Leapcell

소개
마이크로서비스 아키텍처 스타일은 유연성, 확장성 및 복원력 덕분에 엄청난 인기를 얻었습니다. 그러나 이러한 분산 특성은 새로운 복잡성을 야기하며, 그중 가장 근본적인 것은 서비스가 서로를 어떻게 찾고 통신하는가입니다. 서비스 인스턴스가 끊임없이 생성, 확장 또는 종료되는 동적 환경에서는 네트워크 위치를 하드코딩하는 것이 단순히 실현 가능하지 않습니다. 이러한 문제는 서비스 디스커버리라는 개념을 탄생시켰습니다. 이는 서비스가 다른 서비스의 사용 가능한 인스턴스를 찾을 수 있도록 하는 중요한 메커니즘입니다. 클라이언트 측 및 서버 측 서비스 디스커버리 패턴 간의 미묘한 차이를 이해하는 것은 강력하고 유지 관리 가능한 마이크로서비스 생태계를 구축하는 데 매우 중요합니다. 이 기사는 두 가지 주요 접근 방식을 심층적으로 비교하여 기본 메커니즘, 실제 구현 및 적합한 시나리오를 탐색하고 궁극적으로 개발자가 정보에 입각한 아키텍처 결정을 내릴 수 있도록 안내하는 것을 목표로 합니다.
서비스 디스커버리 패턴 해독
클라이언트 측 및 서버 측 서비스 디스커버리의 구체적인 내용으로 들어가기 전에, 우리의 논의에서 자주 등장할 몇 가지 필수 용어를 명확히 하겠습니다.
- 서비스 레지스트리: 사용 가능한 모든 서비스 인스턴스의 네트워크 위치(IP 주소 및 포트)를 저장하는 중앙 데이터베이스 또는 저장소입니다. 서비스는 시작 시 자체 등록하고 종료 시 등록 해제합니다.
- 서비스 인스턴스: 네트워크 주소와 종종 고유 ID로 식별되는 특정 서비스의 실행 중인 프로세스입니다.
- 서비스 제공자: 다른 서비스에 API 또는 기능을 노출하는 서비스입니다.
- 서비스 소비자 (클라이언트): 서비스 제공자가 제공하는 기능을 소비해야 하는 서비스입니다.
이러한 정의를 바탕으로 두 가지 주요 서비스 디스커버리 패턴을 살펴보겠습니다.
클라이언트 측 서비스 디스커버리
클라이언트 측 서비스 디스커버리 패턴에서는 클라이언트(서비스 소비자)가 통신하려는 서비스의 사용 가능한 인스턴스를 찾기 위해 서비스 레지스트리를 쿼리할 책임이 있습니다. 네트워크 위치를 얻으면 클라이언트는 로드 밸런싱 알고리즘을 사용하여 사용 가능한 인스턴스 중 하나를 선택하고 직접 요청을 보냅니다.
작동 방식:
- 서비스 등록: 서비스 제공자 인스턴스가 시작되면 네트워크 위치(IP 주소, 포트)를 서비스 레지스트리에 등록합니다. 종종 상태 및 가용성을 나타내기 위해 주기적인 하트비트를 보냅니다.
- 서비스 디스커버리: 서비스 소비자가 서비스 제공자를 호출해야 할 때, 해당 서비스의 사용 가능한 모든 인스턴스에 대해 서비스 레지스트리를 쿼리합니다.
- 로드 밸런싱: 그런 다음 서비스 소비자는 기본 제공 또는 외부 로드 밸런서(또는 사용자 지정 알고리즘)를 사용하여 목록에서 건강한 서비스 인스턴스 하나를 선택합니다.
- 직접 통신: 서비스 소비자는 선택한 서비스 인스턴스와 직접 통신합니다.
구현 예:
클라이언트 측 서비스 디스커버리의 일반적인 구현은 서비스 레지스트리로 Netflix Eureka를, 클라이언트 측 로드 밸런서로 Netflix Ribbon을 사용합니다.
ProductService가 OrderService를 호출해야 한다고 가정해 봅시다.
OrderService (서비스 제공자):
// OrderService용 Spring Boot 애플리케이션 @SpringBootApplication @EnableEurekaClient // 서비스 등록을 위해 Eureka 클라이언트 활성화 public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); } @RestController class OrderController { @GetMapping("/orders/{id}") public String getOrder(@PathVariable Long id) { return "Order details for ID: " + id; } } }
ProductService (서비스 소비자):
// ProductService용 Spring Boot 애플리케이션 @SpringBootApplication @EnableEurekaClient // 서비스 디스커버리를 위해 Eureka 클라이언트 활성화 public class ProductServiceApplication { public static void main(String[] args) { SpringApplication.run(ProductServiceApplication.class, args); } @RestController class ProductController { // 클라이언트 측 로드 밸런싱을 위해 Ribbon을 사용하는 Spring Cloud의 RestTemplate 사용 // @LoadBalanced 주석은 RestTemplate가 Ribbon을 인식하도록 만듭니다. private final RestTemplate restTemplate; public ProductController(RestTemplate restTemplate) { this.restTemplate = restTemplate; } @GetMapping("/products/{productId}/order-info") public String getProductOrderInfo(@PathVariable Long productId) { // "ORDER-SERVICE"는 Eureka에 등록된 논리 서비스 이름입니다. String orderInfo = restTemplate.getForObject("http://ORDER-SERVICE/orders/" + productId, String.class); return "Product " + productId + " order details: " + orderInfo; } } @Bean @LoadBalanced // Ribbon 통합에 필수적 public RestTemplate restTemplate() { return new RestTemplate(); } }
이 예에서:
OrderService는 Eureka에 자체를 등록합니다.ProductService는@LoadBalanced RestTemplate을 사용합니다.restTemplate.getForObject("http://ORDER-SERVICE/...")호출 시 Ribbon이 요청을 가로채서 "ORDER-SERVICE" 인스턴스에 대해 Eureka에 쿼리하고, 하나를 선택하여 실제 IP와 포트로 URL을 다시 작성합니다.
장점:
- 간단한 네트워크 토폴로지: 클라이언트는 인스턴스와 직접 통신하여 여분의 홉을 피합니다.
- 비용 효율적인 로드 밸런싱: 클라이언트 측 라이브러리를 활용하는 것이 전용 하드웨어 로드 밸런서보다 경제적일 수 있습니다.
- 더 유연한 로드 밸런싱 규칙: 클라이언트 측 라이브러리는 소비자의 요구에 맞는 정교한 로드 밸런싱 알고리즘을 허용하는 경우가 많습니다.
- 지연 시간 감소: 직접 통신은 추가 홉을 통한 것보다 잠재적으로 지연 시간을 줄일 수 있습니다.
단점:
- 언어 결합: 서비스 디스커버리 로직(및 로드 밸런싱)은 모든 클라이언트 애플리케이션, 잠재적으로 다른 프로그래밍 언어 전반에 걸쳐 구현되거나 통합되어야 합니다.
- 클라이언트에 대한 복잡성 증가: 클라이언트는 디스커버리, 로드 밸런싱 및 잠재적인 서킷 브레이커를 관리해야 하므로 더 복잡해집니다.
- 업데이트의 어려움: 디스커버리 메커니즘에 대한 모든 변경으로 인해 모든 클라이언트 서비스를 업데이트하고 다시 배포해야 합니다.
적용 시나리오:
- 제한된 수의 클라이언트 기술(예: 주로 Spring Cloud를 사용하는 Java 서비스)이 있는 환경.
- 클라이언트에 의한 로드 밸런싱에 대한 세분화된 제어가 원하는 경우.
- 전용 로드 밸런서에 대한 인프라 비용이 상당한 문제가 되는 경우.
서버 측 서비스 디스커버리
서버 측 서비스 디스커버리 패턴에서는 클라이언트(서비스 소비자)가 잘 알려진 URL의 프록시(종종 API 게이트웨이 또는 전문 로드 밸런서)에 요청을 보냅니다. 이 프록시는 서비스 레지스트리를 쿼리하고, 사용 가능한 인스턴스를 선택하고, 해당 인스턴스로 요청을 라우팅할 책임이 있습니다. 클라이언트는 서비스 등록 및 로드 밸런싱 세부 정보를 알지 못합니다.
작동 방식:
- 서비스 등록: 클라이언트 측과 마찬가지로 서비스 제공자 인스턴스는 네트워크 위치를 서비스 레지스트리에 등록합니다.
- 요청 라우팅: 서비스 소비자가 서비스를 호출해야 할 때, 프록시(예: 로드 밸런서, API 게이트웨이)의 잘 알려진 엔드포인트로 요청을 보냅니다.
- 프록시에 의한 디스커버리: 프록시는 서비스 레지스트리를 쿼리하여 대상 서비스의 사용 가능한 인스턴스를 찾습니다.
- 로드 밸런싱 및 포워딩: 프록시는 로드 밸런싱 알고리즘을 사용하여 건강한 서비스 인스턴스를 선택하고 클라이언트의 요청을 해당 인스턴스로 전달합니다.
- 응답: 서비스 인스턴스의 응답은 프록시를 통해 클라이언트로 반환됩니다.
구현 예:
일반적인 구현은 AWS ELB/ALB, Nginx 또는 Kubernetes Ingress와 같은 로드 밸런서와 Consul 또는 etcd와 같은 서비스 레지스트리를 함께 사용하는 것을 포함합니다. Kubernetes의 경우 내부 DNS 기반 서비스 디스커버리가 서버 측 디스커버리의 대표적인 예입니다.
Spring Cloud Gateway와 같은 API 게이트웨이 또는 리버스 프록시와의 통합을 고려해 보겠습니다.
OrderService (서비스 제공자):
// OrderService용 Spring Boot 애플리케이션 @SpringBootApplication public class OrderServiceApplication { // Consul DNS와 같은 외부 디스커버리를 사용하는 경우 서비스에 직접 @EnableEurekaClient 주석을 달 필요 없음 public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); } @RestController class OrderController { @GetMapping("/orders/{id}") public String getOrder(@PathVariable Long id) { return "Order details for ID: " + id + " from instance: " + System.getenv("HOSTNAME"); // 또는 고유 식별자 } } }
참고: Kubernetes와 같은 진정한 서버 측 디스커버리 시나리오에서는 서비스 자체에 명시적인 디스커버리 클라이언트 주석이 필요하지 않은 경우가 많습니다. 단순히 포트를 노출합니다.
API 게이트웨이 (서버 측 디스커버러/라우터):
// Spring Cloud Gateway 애플리케이션 @SpringBootApplication public class GatewayApplication { public static void main(String[] args) { SpringApplication.run(GatewayApplication.class, args); } @Bean public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { // 'order-service'가 DNS 또는 게이트웨이에 통합된 서비스 레지스트리를 통해 확인 가능하다고 가정 return builder.routes() .route("order_route", r -> r.path("/api/orders/**") .uri("lb://ORDER-SERVICE")) // "ORDER-SERVICE"는 일반적으로 Eureka/Consul과 같은 레지스트리에 등록됨 .build(); } }
이 예에서:
OrderService는 단순히 실행되고 엔드포인트를 노출합니다.GatewayApplication은 서버 측 디스커버러 역할을 합니다./api/orders/**로 요청이 오면 게이트웨이는 내부 라우팅 메커니즘(종종 서비스 레지스트리 또는 Kubernetes DNS와 통합됨)을 사용하여ORDER-SERVICE를 실제 인스턴스로 확인하고 요청을 전달합니다.ProductService(클라이언트)는 자체 디스커버리 로직 없이http://gateway-host/api/orders/{id}만 호출하면 됩니다.
장점:
- 결합되지 않은 클라이언트: 클라이언트는 디스커버리 프로세스를 전혀 알지 못합니다. 단순히 프록시에 요청을 보냅니다.
- 언어에 구애받지 않음: 디스커버리 로직이 프록시에 있으므로 모든 언어로 작성된 클라이언트와 원활하게 작동합니다.
- 중앙 집중식 제어: 서비스 디스커버리, 로드 밸런싱 및 라우팅 관리가 한 곳에서 중앙 집중화됩니다.
- 업데이트 용이: 서비스 디스커버리 로직 또는 로드 밸런싱 알고리즘의 변경 사항은 모든 클라이언트가 아닌 프록시만 업데이트하면 됩니다.
- 강화된 보안: 프록시는 보안 정책, 속도 제한 및 기타 교차 관심사에 대한 시행 지점 역할을 할 수 있습니다.
단점:
- 추가 네트워크 홉: 모든 요청은 프록시를 통과하여 추가 지연 홉이 발생합니다.
- 단일 실패 지점 (적절히 관리되지 않은 경우): 프록시 자체는 고가용성과 확장성을 갖추지 않으면 병목 또는 단일 실패 지점이 될 수 있습니다.
- 인프라 복잡성 증가: 전용 프록시 계층을 배포하고 관리해야 합니다.
- 비용: 프록시 인프라 및 유지 관리에 대한 추가 비용이 발생할 수 있습니다.
적용 시나리오:
- 다양한 클라이언트 기술(다국어 환경)을 갖춘 마이크로서비스 아키텍처.
- 라우팅, 보안 및 교차 관심사에 대한 중앙 집중식 제어가 필요한 경우.
- API 게이트웨이가 자연스럽게 존재하는 공개 API.
- 내부 DNS 기반 서비스 디스커버리 및 Ingress 컨트롤러가 본질적으로 이 기능을 제공하는 Kubernetes와 같은 환경.
결론
클라이언트 측 및 서버 측 서비스 디스커버리 패턴 모두 동적 마이크로서비스 환경에서 서비스를 찾는 문제를 효과적으로 해결하지만, 디스커버리 로직이 어디에 있는지에 따라 근본적으로 다릅니다. 클라이언트 측 디스커버리는 소비자에게 책임을 부여하여 클라이언트 측 복잡성과 결합이 증가하는 비용으로 유연성과 잠재적으로 낮은 지연 시간을 제공합니다. 서버 측 디스커버리는 프록시에서 디스커버리와 라우팅을 중앙 집중화하여 강력한 분리, 언어 독립성 및 중앙 집중식 제어를 제공하지만, 추가 네트워크 홉과 인프라 증가라는 희생이 따릅니다. 최적의 선택은 특정 아키텍처 요구사항, 개발 팀의 전문성, 기술 스택 및 운영 고려 사항에 따라 달라집니다. 궁극적으로 두 패턴 모두 강력하고 확장 가능한 마이크로서비스 아키텍처의 중요한 지원자입니다.

