1. gRPC: 마이크로서비스 간 효율적인 통신 방법
마이크로서비스 아키텍처(MSA)에서는 서비스 간의 원활하고 효율적인 통신이 매우 중요하다. 전통적인 REST API(HTTP/JSON) 방식 외에도, gRPC는 특히 성능과 명확한 계약 정의가 중요할 때 강력한 대안이 될 수 있다. 이 글에서는 heojunhyoung/k8s-msa 프로젝트의 예시를 통해 gRPC가 어떻게 구현되고 동작하는지 알아볼 것이다.
2. gRPC란 무엇인가?
gRPC(gRPC Remote Procedure Calls)는 Google에서 개발한 고성능 오픈소스 RPC(Remote Procedure Call) 프레임워크이다. RPC는 마치 로컬 함수를 호출하는 것처럼 네트워크상의 다른 서버에 있는 함수(프로시저)를 호출할 수 있게 해주는 기술이다.
gRPC는 다음과 같은 주요 특징을 가진다.
- Protocol Buffers (Protobuf) 사용: 인터페이스 정의 언어(IDL) 및 데이터 직렬화 형식으로 Protocol Buffers를 기본으로 사용한다. 이를 통해 언어 중립적인 서비스 계약을 정의하고, 바이너리 기반의 효율적인 데이터 직렬화를 수행한다.
- HTTP/2 기반 통신: 최신 HTTP/2 프로토콜 위에서 동작하여, 단일 TCP 연결 상에서 여러 요청을 동시에 처리(Multiplexing), 헤더 압축, 양방향 스트리밍 등 REST API(HTTP/1.1) 대비 높은 성능과 효율성을 제공한다.
- 코드 자동 생성: .proto 파일에 정의된 서비스 계약을 기반으로 다양한 언어(Java, Go, Python, C++ 등)의 클라이언트 및 서버 코드를 자동으로 생성해 준다.
[1] 계약 정의 (.proto 파일)
gRPC 구현의 시작은 .proto 파일을 작성하는 것이다. 이 파일은 클라이언트와 서버 간의 계약(Contract)을 정의하며, 서비스의 구조, 메서드(RPC), 주고받을 데이터 형식(메시지)을 명시한다. heojunhyoung/k8s-msa 프로젝트의 order.proto 파일은 OrderService를 정의하고 있다.
syntax = "proto3";
// 생성될 자바 파일의 패키지 경로와 옵션 지정
option java_multiple_files = true;
option java_package = "com.example.orderservice.grpc";
package orderservice;
// 서비스(API) 정의
service OrderService {
// GetOrders 라는 함수는 OrderRequest 를 입력받아 OrderResponse 를 반환한다.
rpc GetOrders(OrderRequest) returns (OrderResponse) {}
}
// 요청 메시지 형태 정의
message OrderRequest {
int64 userId = 1; // user-service에서 Long 타입으로 요청하므로 int64
}
// 응답 메시지 형태 정의
message OrderResponse {
repeated Order orders = 1;
}
// Order 데이터의 상세 구조 정의
message Order {
int64 orderId = 1; // Long -> int64
int64 productId = 2; // Long -> int64
int32 quantity = 3; // Integer -> int32
int32 unitPrice = 4; // Integer -> int32
}
- service OrderService { ... }: OrderService라는 이름의 서비스를 정의한다.
- rpc GetOrders(OrderRequest) returns (OrderResponse) {}: GetOrders라는 RPC 메서드를 정의한다. 이 메서드는 OrderRequest 메시지를 입력받아 OrderResponse 메시지를 반환한다.
- message OrderRequest { ... }, message OrderResponse { ... }, message Order { ... }: 통신에 사용될 데이터 구조(메시지)와 각 필드의 타입, 순서를 정의한다.
이 .proto 파일은 서버와 클라이언트 양쪽 모두에 필요하며, Maven 빌드 시 protobuf-maven-plugin에 의해 gRPC 통신에 필요한 Java 클래스들(메시지 객체, 서비스 인터페이스, 클라이언트 스텁 등)이 자동으로 생성된다.
[2] gRPC 서버 구현 (order-service)
gRPC 서버는 .proto 파일에 정의된 서비스의 실제 로직을 구현하는 부분이다. order-service의 OrderGrpcService 클래스가 이 역할을 한다.
@GrpcService // gRPC 서비스로 등록
@RequiredArgsConstructor
public class OrderGrpcService extends OrderServiceGrpc.OrderServiceImplBase { // 자동 생성된 기본 구현 상속
private final OrderRepository orderRepository;
@Override
public void getOrders(com.example.orderservice.grpc.OrderRequest request, // gRPC 요청 객체
StreamObserver<com.example.orderservice.grpc.OrderResponse> responseObserver) { // gRPC 응답 스트림
// 1. DB 조회 (비즈니스 로직)
List<OrderEntity> orderEntities = orderRepository.findByUserId(request.getUserId());
// 2. Entity -> gRPC 응답 객체로 변환
List<Order> grpcOrders = orderEntities.stream()
.map(entity -> Order.newBuilder() // 자동 생성된 빌더 사용
.setOrderId(entity.getId())
.setProductId(entity.getProductId())
.setQuantity(entity.getQuantity())
.setUnitPrice(entity.getUnitPrice())
.build())
.collect(Collectors.toList());
// 3. 최종 gRPC 응답 생성
com.example.orderservice.grpc.OrderResponse response =
com.example.orderservice.grpc.OrderResponse.newBuilder()
.addAllOrders(grpcOrders)
.build();
// 4. 응답 스트림으로 결과 전송
responseObserver.onNext(response); // 결과 전송
responseObserver.onCompleted(); // 응답 완료 알림
}
}
- @GrpcService 어노테이션을 통해 gRPC 서비스로 Spring 컨테이너에 등록된다.
- .proto 파일로부터 자동 생성된 OrderServiceGrpc.OrderServiceImplBase 추상 클래스를 상속받는다.
- getOrders 메서드를 오버라이드하여 실제 로직을 구현한다. 파라미터와 반환 타입은 반드시 자동 생성된 gRPC 객체(com.example.orderservice.grpc.*)를 사용해야 한다.
- DB 조회 결과(OrderEntity)를 자동 생성된 Order 및 OrderResponse gRPC 객체로 변환한 후, StreamObserver를 통해 클라이언트에게 응답을 보낸다.
application.yml 파일의 grpc.server.port 설정은 gRPC 서버가 리스닝할 포트를 지정한다.
[3] gRPC 클라이언트 구현 (user-service)
gRPC 클라이언트는 서버의 RPC 메서드를 호출하는 부분이다. user-service의 GrpcOrderServiceAdapter가 이 역할을 수행한다.
@Component
@Profile("grpc") // "grpc" 프로파일 활성화 시 사용
public class GrpcOrderServiceAdapter implements OrderServiceAdapter {
// "order-service" 이름으로 등록된 gRPC 클라이언트 스텁 주입
@GrpcClient("order-service")
private OrderServiceGrpc.OrderServiceBlockingStub orderStub; // 동기식 호출용 스텁
@Override
public List<com.example.userservice.dto.OrderResponse> getOrders(Long userId) { // 내부 DTO 반환
// 1. gRPC 요청 객체 생성
OrderRequest request = OrderRequest.newBuilder().setUserId(userId).build();
// 2. gRPC 서버 호출 및 gRPC 응답 객체 수신
com.example.orderservice.grpc.OrderResponse grpcResponse = orderStub.getOrders(request);
// 3. gRPC 응답 객체 -> 애플리케이션 내부 DTO로 변환
return grpcResponse.getOrdersList().stream()
.map(order -> {
com.example.userservice.dto.OrderResponse responseDto = new com.example.userservice.dto.OrderResponse();
responseDto.setOrderId(order.getOrderId());
responseDto.setProductId(order.getProductId());
responseDto.setQuantity(order.getQuantity());
responseDto.setUnitPrice(order.getUnitPrice());
return responseDto;
})
.collect(Collectors.toList());
}
}
- @GrpcClient("order-service") 어노테이션은 application.yml에 설정된 order-service 주소로 연결되는 gRPC 클라이언트 스텁(Stub)을 주입한다. 스텁은 원격 메서드를 로컬 메서드처럼 호출할 수 있게 해주는 프록시 객체이다.
- getOrders 메서드는 userId를 받아 **자동 생성된 gRPC 요청 객체(com.example.orderservice.grpc.OrderRequest)**를 만든다.
- 주입된 orderStub을 사용하여 서버의 getOrders RPC를 호출하고, **자동 생성된 gRPC 응답 객체(com.example.orderservice.grpc.OrderResponse)**를 받는다.
- 중요: 서버로부터 받은 gRPC 응답 객체를 그대로 사용하는 것이 아니라, 애플리케이션 **내부 로직에서 사용하기 위한 DTO (com.example.userservice.dto.OrderResponse)**로 변환하여 반환한다. 이는 gRPC 통신 계층과 애플리케이션 내부 로직을 분리하기 위함이다.
[3] 내부 동작 과정 ⚙️
gRPC 통신 시 내부적으로 다음과 같은 과정이 일어난다.
- (클라이언트 측 - GrpcOrderServiceAdapter) 요청 준비:
- UserService로부터 userId(Long 타입)를 받는다.
- .proto로부터 자동 생성된 com.example.orderservice.grpc.OrderRequest 객체를 newBuilder()를 사용해 생성하고, userId를 설정한다.
- (클라이언트 측) 직렬화 및 전송:
- 주입된 gRPC 스텁(orderStub)의 getOrders 메서드를 호출하면, gRPC 라이브러리가 OrderRequest 객체를 Protocol Buffers 바이너리 형식으로 직렬화한다.
- 직렬화된 데이터는 HTTP/2 프로토콜을 통해 order-service의 gRPC 서버로 전송된다.
- (서버 측 - OrderGrpcService) 수신 및 역직렬화:
- gRPC 서버는 받은 바이너리 데이터를 역직렬화하여 자동 생성된 com.example.orderservice.grpc.OrderRequest 객체를 복원한다.
- (서버 측) 비즈니스 로직 처리:
- 복원된 OrderRequest 객체에서 userId를 꺼내 DB 조회 등 비즈니스 로직을 수행한다. 결과로 List<OrderEntity>를 얻는다.
- (서버 측) 응답 준비:
- 조회 결과(List<OrderEntity>)를 gRPC 응답 객체(com.example.orderservice.grpc.OrderResponse) 형태로 변환한다. 이때 자동 생성된 Order.newBuilder() 등을 사용한다.
- (서버 측) 직렬화 및 전송:
- gRPC 라이브러리가 OrderResponse 객체를 Protocol Buffers 바이너리 형식으로 직렬화하여 클라이언트로 전송한다.
- (클라이언트 측 - GrpcOrderServiceAdapter) 수신 및 역직렬화:
- gRPC 스텁은 서버로부터 받은 바이너리 데이터를 역직렬화하여 자동 생성된 com.example.orderservice.grpc.OrderResponse 객체를 복원한다.
- (클라이언트 측) 내부 DTO 변환 및 반환:
- 수신한 gRPC 응답 객체(grpcResponse)의 데이터를 애플리케이션 내부 DTO(com.example.userservice.dto.OrderResponse) 리스트로 변환한다.
- 변환된 내부 DTO 리스트를 UserService 등 호출한 곳으로 반환한다.
이름 혼동 방지 팁
- 패키지 경로 확인: 가장 확실한 방법은 import 문이나 클래스의 전체 경로(Full Qualified Name)를 확인하는 것이다. com.example.orderservice.grpc.*는 gRPC 통신용이고, com.example.userservice.dto.* 또는 com.example.orderservice.dto.*는 내부용이다.
- IDE 도움 활용: IntelliJ IDEA 같은 IDE는 자동 완성 시 패키지 경로를 보여주므로 구분하기 쉽다.
- 네이밍 컨벤션: 만약 직접 제어 가능하다면, gRPC 메시지 이름에 접미사(예: OrderGrpc, OrderResponseProto)를 붙이거나 내부 DTO 이름에 다른 접미사(예: OrderInternalDto)를 붙여 구분하는 것도 방법이다. (하지만 현재 코드는 패키지로 구분하고 있다)
결론적으로, gRPC 통신 계층과 애플리케이션 내부 로직 계층은 서로 다른 책임과 요구사항을 가지므로, 각 계층에 맞는 별도의 DTO를 사용하는 것이 효율적인 통신과 유지보수성을 위해 필요하다. 비록 이름이 같아 헷갈릴 수 있지만, 패키지 경로와 사용되는 맥락(gRPC 스텁/서비스 vs. 내부 서비스/컨트롤러)을 통해 명확히 구분해야 한다.
[4] gRPC 동작 원리 요약 ⚙️
- 계약 정의: .proto 파일로 서비스와 메시지 구조 정의.
- 코드 생성: 빌드 도구(Maven 플러그인)가 .proto 파일을 기반으로 서버/클라이언트 코드 및 메시지 객체 자동 생성.
- 서버 구현: 자동 생성된 기본 구현을 상속받아 실제 비즈니스 로직 구현.
- 클라이언트 구현: 자동 생성된 스텁을 주입받아 원격 메서드 호출.
- 통신: 클라이언트 스텁 호출 시 요청 메시지는 Protobuf로 직렬화되어 HTTP/2를 통해 서버로 전송됨. 서버는 요청을 처리하고 응답 메시지를 Protobuf로 직렬화하여 HTTP/2를 통해 클라이언트로 전송함. 클라이언트는 응답을 받아 역직렬화하여 사용함.
'MSA > MSA 아키텍처' 카테고리의 다른 글
| [BASIC #1] 마이크로서비스 아키텍처의 이해: Monolithic에서 MSA까지 (0) | 2026.03.02 |
|---|---|
| [5] EDA (0) | 2025.10.20 |
| [4] SAGA 패턴 (0) | 2025.10.20 |
| [3] CQRS (0) | 2025.10.20 |
| [1] OpenFeign (0) | 2025.10.20 |