본문 바로가기
기타

마이크로서비스 아키텍처에서 API 테스트하기

by 보증서는 남자 2024. 9. 9.

지금 근무하고 있는 곳의 서버는 마이크로서비스 아키텍처를 채택하여 개발하고 있다. API 테스트를 진행하면서 서버쪽 지식이 필요하여 블로그로 남겨두려합니다. 마이크로서비스 아키텍처와 각 서버 애플리케이션 간의 통신은 gRPC를 사용하고 있어 어떤식으로 API테스트를 해야하는지까지 정리해보겠습니다.


마이크로서비스 아키텍처

1. 마이크로서비스 아키텍처란?

마이크로서비스 아키텍처(Microservices Architecture)는 하나의 애플리케이션을 독립적으로 배포되고 실행될 수 있는 작은 서비스들로 나누어 설계하는 소프트웨어 아키텍처 스타일입니다. 각 서비스는 독립적으로 개발, 테스트, 배포, 유지보수가 가능하며, 서로 다른 기술 스택을 사용할 수도 있습니다.

이 방식은 기존의 모놀리식 아키텍처와 대조적입니다. 모놀리식 구조에서는 애플리케이션 전체가 하나의 코드베이스로 구성되며, 배포 및 유지보수에서 유연성이 떨어지는 반면, 마이크로서비스는 독립된 작은 서비스들이 모여 하나의 시스템을 구성하기 때문에 확장성과 유연성을 극대화합니다.

2. 마이크로서비스 아키텍처의 탄생 배경

마이크로서비스 아키텍처는 다음과 같은 이유로 등장하게 되었습니다:

  • 규모 확장 문제: 모놀리식 애플리케이션은 애플리케이션이 커질수록 변경 사항을 배포하거나 확장하는 데 어려움이 생깁니다. 코드베이스가 커지면 테스트와 배포 주기가 길어지고, 특정 모듈에서 발생한 문제로 인해 전체 애플리케이션이 영향을 받을 수 있습니다.
  • 유연성 부족: 모든 팀이 동일한 기술 스택을 사용해야 하며, 코드베이스를 공유해야 하기 때문에 여러 팀이 병렬적으로 작업하기 어렵습니다.
  • 복잡성 증가: 하나의 대형 애플리케이션을 관리하는 데 필요한 복잡성은 시간이 지남에 따라 기하급수적으로 증가합니다. 코드의 의존성이 높아지면 하나의 작은 변경이 예기치 않은 문제를 발생시키기 쉽습니다.

이러한 문제를 해결하기 위해 애플리케이션을 더 작은 단위로 분리하여 독립적으로 관리할 수 있는 마이크로서비스 아키텍처가 각광받게 되었습니다.

3. 마이크로서비스 아키텍처의 장점

3.1 확장성 (Scalability)

각 서비스가 독립적으로 배포 및 확장될 수 있기 때문에 특정 서비스에만 부하가 걸릴 때 그 서비스만 확장할 수 있습니다. 예를 들어, 결제 시스템에 더 많은 리소스가 필요할 경우 결제 서비스만 확장하면 되므로 전체 시스템을 확장할 필요가 없습니다.

3.2 유연성 (Flexibility)

팀들은 각 서비스에서 가장 적합한 기술 스택을 선택할 수 있습니다. 한 팀이 자바(Java)를 사용하고, 다른 팀이 파이썬(Python)을 사용할 수 있으며, 이는 각 서비스가 서로 독립적으로 동작하기 때문입니다.

3.3 개발 및 배포의 독립성 (Independent Development & Deployment)

각 서비스는 별도의 리포지토리로 관리되고 독립적으로 배포됩니다. 이를 통해 다른 서비스에 영향을 주지 않고 특정 서비스만 업데이트할 수 있어 배포 주기를 빠르게 가져갈 수 있습니다.

3.4 고가용성 (High Availability)

서비스 중 하나에 장애가 발생하더라도 다른 서비스에는 영향을 미치지 않는 구조를 설계할 수 있습니다. 따라서 시스템의 가용성이 높아지며 장애에 대한 복구 속도가 빨라집니다.

4. 마이크로서비스 아키텍처의 단점

4.1 복잡성 증가

작은 단위로 서비스를 분리하다 보면 서비스 간의 의존성 관리가 어려워집니다. 서비스 간 통신을 위한 네트워크 설정, 트랜잭션 관리, 데이터 일관성 유지 등의 문제가 복잡해질 수 있습니다.

4.2 테스트의 어려움

서비스가 독립적이라도 전체 시스템을 통합한 E2E(End-to-End) 테스트는 복잡해집니다. 서비스 간의 통신이 네트워크 기반으로 이루어지기 때문에 모의(Mock) 테스트를 구현하거나, 실제 환경에서의 통합 테스트를 구축하는 것이 더 까다롭습니다.

4.3 데이터 관리

각 서비스가 독립된 데이터베이스를 가질 경우, 데이터 일관성을 유지하는 것이 어려워질 수 있습니다. 트랜잭션이 여러 서비스에 걸쳐서 발생할 경우, 분산 트랜잭션을 관리해야 하는데, 이는 상당한 기술적 도전 과제가 될 수 있습니다.

4.4 모니터링 및 로깅

모놀리식 애플리케이션에서는 하나의 애플리케이션 내에서 발생하는 로그를 쉽게 추적할 수 있었지만, 마이크로서비스에서는 여러 서비스 간에 분산된 로그와 모니터링 정보를 수집하고 분석해야 합니다. 이를 위해 추가적인 툴과 인프라가 필요합니다.


통신방식

마이크로서비스 아키텍처에서 각각의 서비스는 독립적으로 운영되며, 서로 통신을 해야 할 때가 많습니다. 이러한 통신은 두 가지 방식으로 나눌 수 있습니다.

  1. 동기식 통신(Synchronous Communication)
    • 한 서비스가 다른 서비스에 요청을 보내고 응답을 기다리는 방식입니다.
    • 주로 HTTP/RESTgRPC 프로토콜이 사용됩니다.
  2. 비동기식 통신(Asynchronous Communication)
    • 한 서비스가 다른 서비스로 메시지를 보내고, 응답을 기다리지 않고 작업을 진행합니다.
    • 메시지 큐(Kafka, RabbitMQ)나 이벤트 기반 아키텍처에서 많이 사용됩니다.

이중에서도 gRPC에 대해 알아보겠습니다.

1. gRPC

1.1 gRPC란?

gRPC는 구글이 개발한 오픈 소스 Remote Procedure Call (RPC) 프레임워크로, 클라이언트가 서버에 직접적으로 원격 프로시저를 호출할 수 있게 해줍니다. 이는 두 애플리케이션 간의 통신을 더욱 효율적으로 만들어 주며, HTTP/2와 Protocol Buffers(바이너리 직렬화 형식)를 사용하여 성능을 최적화합니다.

1.2 gRPC의 특징

  • 고성능: gRPC는 HTTP/2를 사용하여 동시에 여러 요청을 처리하고, 데이터 전송을 바이너리로 하여 전송 속도와 대역폭을 효율적으로 관리합니다.
  • 다양한 언어 지원: gRPC는 여러 프로그래밍 언어에서 클라이언트와 서버를 구현할 수 있도록 지원합니다 (C++, Python, Java, Go, Node.js 등).
  • 스트리밍 지원: gRPC는 클라이언트에서 서버로, 또는 서버에서 클라이언트로의 실시간 데이터 스트리밍을 지원합니다. 이는 대규모 데이터 처리 및 실시간 통신에 유리합니다.
  • 엄격한 인터페이스 정의: gRPC는 .proto 파일을 통해 서비스 간의 통신 인터페이스를 명확하게 정의하며, 이는 클라이언트와 서버 간의 계약을 명확히 하고 변경 관리가 수월해집니다.

1.3 gRPC의 활용 사례

  • 마이크로서비스 간 통신: gRPC는 마이크로서비스 간 통신을 위한 고성능 옵션입니다. REST API보다 더 빠른 응답 시간과 낮은 대역폭 사용을 요구하는 경우에 적합합니다.
  • 실시간 통신: 실시간 데이터 전송이 중요한 애플리케이션 (예: 스트리밍 서비스, 실시간 데이터 분석)에서 gRPC는 적합한 선택입니다.

2. API Gateway

2.1 API Gateway란?

API Gateway는 클라이언트와 여러 마이크로서비스 사이에 위치한 중앙 집합점으로, 모든 클라이언트 요청이 API Gateway를 거쳐 각 서비스로 전달됩니다. Gateway는 여러 마이크로서비스에 대한 요청을 하나로 통합하여 클라이언트에게 노출시키며, 인증, 로드 밸런싱, 로깅, API 호출 관리 등을 처리합니다.

2.2 API Gateway의 주요 기능

  • 요청 라우팅: API Gateway는 들어오는 요청을 각각의 서비스로 라우팅하는 역할을 합니다. 클라이언트는 각 서비스의 세부 사항을 알 필요 없이 Gateway를 통해 모든 요청을 처리할 수 있습니다.
  • 인증 및 권한 관리: API Gateway는 사용자 인증과 권한 관리를 중앙에서 처리하여 각 마이크로서비스가 별도로 인증을 구현하지 않아도 되게 합니다.
  • 로드 밸런싱: 여러 서비스로의 요청을 분산시켜 서비스가 과부하 되지 않도록 관리합니다.
  • 데이터 변환: Gateway는 JSON, XML, 또는 Protocol Buffers 등 다양한 포맷 간의 데이터 변환을 처리할 수 있습니다.
  • 추상화: 클라이언트는 마이크로서비스의 복잡한 내부 구조를 알 필요 없이 API Gateway를 통해 추상화된 단일 인터페이스에 접근할 수 있습니다.

2.3 API Gateway의 활용 사례

  • 서비스 통합: 다수의 마이크로서비스를 하나의 진입점(API Gateway)을 통해 통합하여, 클라이언트와의 상호작용을 단순화할 수 있습니다.
  • 보안 관리: API Gateway에서 인증, 권한 부여, 데이터 암호화 등의 보안 관련 기능을 일괄적으로 처리할 수 있습니다.
  • 로깅 및 모니터링: 모든 요청과 응답을 Gateway에서 모니터링하고 로그를 수집하여, 시스템 전반의 상태를 쉽게 파악할 수 있습니다.

3. gRPC와 API Gateway의 관계

마이크로서비스 아키텍처에서는 gRPCAPI Gateway가 상호 보완적으로 사용될 수 있습니다. gRPC는 서비스 간의 고성능 통신을 지원하는 반면, API Gateway는 클라이언트와 서비스 간의 진입점 역할을 합니다.

3.1 gRPC와 API Gateway의 통합

  • gRPC-JSON 변환: API Gateway는 클라이언트가 gRPC를 직접 사용하지 않더라도, Gateway에서 클라이언트 요청을 받아 gRPC 서비스로 변환할 수 있습니다. 예를 들어, 클라이언트가 JSON 형식으로 요청을 보내면 Gateway는 이를 gRPC로 변환하여 마이크로서비스와 통신할 수 있습니다.
  • gRPC 기반의 Gateway: 일부 API Gateway(예: Envoy, NGINX)는 gRPC와의 네이티브 통합을 지원하여, gRPC 기반의 서비스와 직접적으로 연결될 수 있습니다.

3.2 gRPC와 REST API의 공존

gRPC를 사용할 수 없는 클라이언트 (예: 브라우저)에서는 여전히 REST API가 필요합니다. 이때 API Gateway는 REST 요청을 받아 gRPC로 변환해 서비스 간 통신을 중재하는 역할을 수행할 수 있습니다.


IDL (Interface Definition Language)

IDL(Interface Definition Language)는 gRPC에서 클라이언트와 서버 간의 통신 인터페이스를 정의하는 언어입니다. gRPC는 Protocol Buffers (proto)라는 바이너리 직렬화 형식을 사용하여 데이터를 직렬화하고, 이 형식을 통해 서비스 간의 통신 규약을 정의합니다. Protocol Buffers는 구글에서 개발된 직렬화 도구로, 성능이 뛰어나고 gRPC의 주요 구성 요소 중 하나입니다.

1. IDL의 역할

IDL은 클라이언트와 서버가 서로 다른 언어로 구현되었더라도, 통신 시 사용하는 인터페이스(데이터 구조와 메서드)를 정의하여, 서로 호환되도록 만듭니다. 이는 서비스 간의 통신을 표준화하고, 통신 오류나 데이터 불일치를 방지할 수 있도록 도와줍니다.

gRPC에서 IDL은 .proto 파일을 통해 정의되며, 이 파일은 서비스의 메서드 및 메시지 구조를 정의합니다. 이를 통해 gRPC 프레임워크가 자동으로 코드를 생성하여, 클라이언트와 서버 간의 원활한 통신을 보장합니다.

2. .proto 파일 예시

다음은 간단한 .proto 파일의 예시입니다:

syntax = "proto3";

// gRPC 서비스를 정의하는 부분
service MyService {
  // Unary RPC
  rpc GetData (RequestMessage) returns (ResponseMessage);
}

// 요청 메시지 정의
message RequestMessage {
  string id = 1;
}

// 응답 메시지 정의
message ResponseMessage {
  string data = 1;
}

 

  • syntax = "proto3": 이 줄은 Protocol Buffers의 버전을 나타냅니다. gRPC에서는 proto3가 사용됩니다.
  • service MyService: MyService라는 이름의 gRPC 서비스를 정의합니다. 이 서비스는 하나의 메서드 GetData를 가지고 있습니다.
  • rpc GetData: GetData는 클라이언트가 RequestMessage를 서버로 보내고, 서버가 ResponseMessage를 반환하는 단방향 RPC입니다.
  • message RequestMessagemessage ResponseMessage: 각각 요청과 응답에 사용할 메시지 구조를 정의한 부분입니다. id와 data라는 필드를 가지고 있으며, 각각의 필드는 고유한 숫자 태그를 가집니다.

 

3. IDL을 이용한 코드 생성

gRPC에서는 이 .proto 파일을 기반으로 서버 및 클라이언트에서 사용할 코드(메서드, 데이터 클래스 등)를 자동 생성할 수 있습니다. gRPC는 다수의 언어를 지원하며, 각 언어에 맞는 코드를 프로토콜 컴파일러가 생성해줍니다.

3.1 클라이언트와 서버 코드 생성

gRPC에서 제공하는 Protocol Buffers 컴파일러 (protoc)는 .proto 파일을 기반으로 **클라이언트 스텁(Client Stub)**과 서버 스켈레톤(Server Skeleton) 코드를 자동으로 생성합니다. 이를 통해 개발자는 기본적인 통신 구조를 수동으로 작성할 필요 없이, 생성된 코드를 사용하여 빠르게 클라이언트 및 서버 코드를 작성할 수 있습니다.

3.1.1 코드 생성 과정

  1. .proto 파일 작성
    • 서비스와 메시지 정의를 포함한 .proto 파일을 작성합니다.
  2. protoc 컴파일러 사용
    • protoc 명령어를 사용하여 .proto 파일을 컴파일하면, 각 언어에 맞는 클라이언트/서버 코드가 자동으로 생성됩니다.
    예시:
protoc --java_out=. --grpc-java_out=. my_service.proto

 

위 명령어는 Java용 gRPC 클라이언트 및 서버 코드를 생성합니다. 다른 언어에 대해서도 비슷한 명령어를 사용할 수 있습니다.

 

3.2 자동 생성된 코드 예시 (Kotlin)

RequestMessage와 ResponseMessage 같은 메시지 클래스, 그리고 gRPC 메서드 스텁이 자동으로 생성됩니다.

예를 들어, RequestMessage 메시지에 대한 자동 생성된 코드는 다음과 같습니다:

data class RequestMessage(
    val id: String
)

이 RequestMessage 클래스는 .proto 파일에서 정의한 내용에 따라 자동으로 생성되며, 클라이언트와 서버에서 데이터를 주고받을 때 사용됩니다.

 

3.2.1 자동 생성된 클라이언트 스텁 예시

gRPC 클라이언트는 자동 생성된 스텁을 사용하여 서버에 요청을 보냅니다. 예를 들어, GetData RPC 호출을 하는 클라이언트 코드는 다음과 같이 작성될 수 있습니다:

val channel = ManagedChannelBuilder.forAddress("localhost", 8080)
    .usePlaintext()
    .build()

val stub = MyServiceGrpc.newBlockingStub(channel)

val request = RequestMessage.newBuilder()
    .setId("123")
    .build()

val response = stub.getData(request)

println("Received: ${response.data}")

3.3 다양한 언어 지원

gRPC는 다음과 같은 다양한 프로그래밍 언어에 대한 코드 생성을 지원합니다:

  • Java/Kotlin
  • C++
  • Go
  • Python
  • C#
  • Node.js
  • Ruby

각 언어의 경우 protoc를 이용하여 .proto 파일을 컴파일하면, 해당 언어에 맞는 데이터 클래스 및 스텁 코드가 생성됩니다.


마이크로 서비스에서의 API 테스트

마이크로서비스 아키텍처에서는 단일 서비스에 대한 테스트만으로는 시스템의 복잡성을 충분히 검증할 수 없습니다. 서비스들이 서로 의존하고 상호작용하기 때문에, 각 API가 독립적으로 잘 동작하는지뿐만 아니라 전체 시스템이 올바르게 연동되는지를 확인하는 것이 중요합니다. 이를 위해 API 테스트는 통합 테스트E2E 테스트로 확장되어야 합니다.

1. 단순 API 호출 테스트의 한계

단순한 API 테스트는 한 개의 서비스에 요청을 보내고, 그 응답을 검증하는 방식으로 이루어집니다. 예를 들어, OrderService의 PlaceOrder API에 요청을 보내고, 응답으로 주문 상태를 확인하는 방식입니다.

val request = OrderRequest.newBuilder()
    .setProductId("12345")
    .setQuantity(2)
    .build()

val response = orderServiceStub.placeOrder(request)
assert(response.status == "success")
assert(response.orderId != null)

이러한 테스트는 단일 서비스의 기능을 검증하는 데 유용하지만, 실제 마이크로서비스 아키텍처에서는 여러 서비스들이 상호작용하기 때문에 통합적인 시나리오 테스트가 필요합니다.

 

2 API 통합 테스트

통합 테스트는 여러 서비스가 어떻게 상호작용하는지를 확인합니다. 예를 들어, OrderService가 PaymentService와 InventoryService와 연동되어 작동한다고 가정할 때, 단순히 주문을 생성하는 것만으로는 충분하지 않습니다. 주문 생성 후 결제가 성공적으로 처리되고, 재고가 올바르게 업데이트되는지와 같은 흐름을 검증하는 것이 필요합니다.

 

이러한 통합 테스트는 각각의 서비스에 대한 개별적인 테스트 외에, 서비스 간의 상호작용까지 포함한 시나리오를 검증합니다. 예시로, 다음과 같은 흐름을 테스트할 수 있습니다:

  1. 주문 생성 (OrderService)
  2. 결제 처리 (PaymentService)
  3. 재고 업데이트 (InventoryService)
// 주문 생성 요청
val orderRequest = OrderRequest.newBuilder()
    .setProductId("12345")
    .setQuantity(2)
    .build()

// 주문 생성 API 호출
val orderResponse = orderServiceStub.placeOrder(orderRequest)

// 결제가 정상적으로 처리되었는지 확인
val paymentStatus = paymentServiceStub.getPaymentStatus(orderResponse.orderId)
assert(paymentStatus == "success")

// 재고가 정상적으로 차감되었는지 확인
val inventory = inventoryServiceStub.getProductStock("12345")
assert(inventory.remaining == expectedRemainingStock)

이 테스트는 단순히 하나의 서비스만을 확인하는 것이 아니라, 여러 서비스가 올바르게 상호작용하는지를 검증합니다.

 

3 API를 이용한 E2E 테스트

E2E(End-to-End) 테스트는 사용자의 실제 시나리오를 기반으로 전체 시스템의 흐름을 테스트합니다. 마이크로서비스 아키텍처에서는 사용자가 여러 서비스와 상호작용하기 때문에, 각각의 서비스가 서로 독립적으로 잘 동작하는지뿐만 아니라, 사용자가 시스템을 사용하는 전체 경험을 테스트하는 것이 중요합니다.

예를 들어, 사용자가 상품을 주문하고 결제한 후 배송되는 흐름을 E2E 테스트로 작성할 수 있습니다.

// 사용자 주문 -> 결제 -> 배송까지의 E2E 시나리오
val orderRequest = OrderRequest.newBuilder()
    .setProductId("12345")
    .setQuantity(1)
    .build()

// 주문 생성
val orderResponse = orderServiceStub.placeOrder(orderRequest)
assert(orderResponse.status == "success")

// 결제 완료 확인
val paymentStatus = paymentServiceStub.getPaymentStatus(orderResponse.orderId)
assert(paymentStatus == "success")

// 배송 상태 확인
val shippingStatus = shippingServiceStub.getShippingStatus(orderResponse.orderId)
assert(shippingStatus == "shipped")

이 테스트는 각 서비스가 서로 다른 도메인에서 동작하더라도, 전체 시스템이 사용자의 요구를 충족하는지를 검증합니다.

 

결론

마이크로서비스 아키텍처에서의 API 테스트는 단순한 단일 서비스 호출 이상의 복잡성을 요구합니다. 각 서비스가 상호작용하는 통합 테스트와, 사용자 관점에서 전체 시스템이 올바르게 작동하는지 검증하는 E2E 테스트는 필수적입니다.

gRPC와 IDL을 이용해 자동으로 생성된 코드는 이러한 테스트를 손쉽게 작성하고, 마이크로서비스 간의 통신을 효율적으로 관리할 수 있는 기반을 제공합니다. 서비스 간의 상호작용을 검증하고, 통합 및 E2E 테스트로 전체 시스템의 안정성을 확보하는 것이 마이크로서비스 아키텍처에서의 테스트의 핵심입니다.

'기타' 카테고리의 다른 글

테스트 케이스 작성 전략  (2) 2024.09.27
Github와 Githib Actions 맛보기  (1) 2024.09.20
Gradle로 IDE없이 프로그램 실행하기  (1) 2024.09.16
Mobile App 초기 실행 테스트  (0) 2024.09.13
Python 기초 개념 정리하기  (4) 2024.09.02