1. gRPC 개념 이해
1.1 기본 개요
gRPC는 Google에서 개발한 오픈소스 RPC(Remote Procedure Call) 시스템으로, 언어와 플랫폼에 독립적입니다. C, C++, Python, PHP, Ruby, Node.js, C#, Objective-C, Golang 등 다양한 언어를 지원합니다. 이름의 'g'는 Google을 의미합니다.
1.2 Proto 파일의 역할
Proto 파일은 서비스 인터페이스와 데이터 구조를 정의하는 규약입니다. 다양한 언어에서 동일한 파일을 사용할 수 있으며, 직렬화 방식으로 Protocol Buffers(PB)와 JSON을 지원합니다. PB는 언어에 독립적인 고성능 직렬화 프레임워크로, HTTP/2와 결합되어 뛰어난 RPC 성능을 제공합니다.
실제로 gRPC는 기존의 WebServices나 WCF와 유사한 원격 호출 기술이지만, 현대적인 특성과 생태계 발전으로 인해 주목받고 있습니다. 본격적인 사용을 위해 .NET Core 기반의 간단한 데모를 구축해 보겠습니다.
이 글은 NuGet 패키지를 통해 마이크로서비스 데이터를 요청하는 내부 패키지 개발 과정에서 gRPC를 적용한 실제 경험을 바탕으로 작성되었습니다.
1.3 실습 시작
1. Visual Studio에서 'ASP.NET Core gRPC 서비스' 프로젝트를 생성하면 다음과 같은 기본 구조가 제공됩니다. 프레임워크에는 미리 정의된 proto 파일과 서비스 인터페이스가 포함되어 있습니다. 다른 방식을 사용하려면 관련 패키지를 참조하고 서비스를 추가하면 됩니다.
2. 사용자 정의 인터페이스를 위해 mytestdemo.proto 파일을 생성하고 메서드를 정의합니다. 주요 패턴은 다음과 같습니다.
- 매개변수 있음 + 반환값 있음
- 매개변수 없음 + 반환값 있음 (빈 매개변수는
google.protobuf.Empty사용) - 컬렉션 반환 (
repeated키워드 필수)
protobuf 문법에 익숙하지 않다면 도구를 사용해 생성할 수 있습니다.
syntax = "proto3";
import "google/protobuf/empty.proto";
option csharp_namespace = "GrpcDemo";
package MyTest;
service MyTestDemo {
rpc MultipleParam(MultipleRequestPara) returns (MultipleRespone);
rpc NoParam(google.protobuf.Empty) returns (SingeRespone);
rpc CollectionParam(google.protobuf.Empty) returns (CollectionResponePara);
}
message MultipleRequestPara {
int32 Id = 1;
string Name = 2;
bool IsExists = 3;
}
message SingeRespone {
bool Success = 1;
TestEntity a1 = 2;
message TestEntity {
int32 Id = 1;
}
}
message MultipleRespone {
bool Success = 1;
}
message CollectionResponePara {
repeated CollectionChildrenRespone1 param1 = 1;
repeated CollectionChildrenRespone2 param2 = 2;
repeated int32 param3 = 3;
}
message CollectionChildrenRespone1 {
int32 Id = 1;
}
message CollectionChildrenRespone2 {
string Name = 1;
}
3. 프로젝트에서 proto 파일을 추가하려면 해당 파일을 마우스 오른쪽 버튼으로 클릭하고 '연결된 서비스 추가' → 'gRPC'를 선택하거나, 프로젝트 파일을 직접 수정합니다.
- 3.1 프로젝트를 다시 빌드한 후 서비스 코드
MyTestService를 생성합니다. - 3.2 시작 클래스에서 gRPC 매핑을 추가합니다:
app.MapGrpcService<MyTestService>(). 그렇지 않으면 'service is unimplemented' 오류가 발생합니다.
public class MyTestService : MyTestDemo.MyTestDemoBase
{
public override async Task<MultipleRespone> MultipleParam(MultipleRequestPara request, ServerCallContext context)
{
return await Task.FromResult(new MultipleRespone { Success = true });
}
public override async Task<SingeRespone> NoParam(Empty request, ServerCallContext context)
{
var t = new TestEntity { Id = 1 };
return await Task.FromResult(new SingeRespone { Success = true, entity = t });
}
public override async Task<CollectionResponePara> CollectionParam(Empty request, ServerCallContext context)
{
var response = new CollectionResponePara();
response.Param1.Add(new CollectionChildrenRespone1 { Id = 1 });
response.Param2.Add(new CollectionChildrenRespone2 { Name = "jeck" });
return await Task.FromResult(response);
}
}
4. 클라이언트 프로젝트를 생성하고 proto 파일을 복사한 후, 서비스를 클라이언트 모드로 추가하고 다음 코드를 작성합니다.
using (var channel = GrpcChannel.ForAddress("https://localhost:7245"))
{
var client = new MyTestDemo.MyTestDemoClient(channel);
var reply = client.MultipleParam(new MultipleRequestPara { Id = 123, Name = "sa", IsExists = true });
var singeRespone = client.NoParam(new Google.Protobuf.WellKnownTypes.Empty());
var collectionResponePara = client.CollectionParam(new Google.Protobuf.WellKnownTypes.Empty());
}
2. gRPC 스트리밍
gRPC는 4가지 스트리밍 방식을 지원합니다.
- 단항 RPC(Unary RPC): 하나의 요청 객체를 보내고 하나의 응답 객체를 받습니다.
- 서버 스트리밍 RPC: 클라이언트가 하나의 요청을 보내면 서버가 여러 응답을 스트리밍합니다. 예: 주식 ID를 보내면 실시간 주가 정보가 지속적으로 전송됩니다.
- 클라이언트 스트리밍 RPC: 클라이언트가 여러 요청을 스트리밍하고 서버가 하나의 응답을 반환합니다. 예: 상위 시스템에서 실시간 센서 데이터를 서버로 전송합니다.
- 양방향 스트리밍 RPC: 서버와 클라이언트 스트리밍을 결합하여 여러 요청과 여러 응답을 주고받습니다. 장기 연결을 통해 상호 작용이 가능합니다.
2.1 서버 스트리밍, 클라이언트 스트리밍, 양방향 스트리밍
서버 스트리밍의 핵심은 서버가 클라이언트로 지속적으로 데이터를 응답하는 것입니다.
1. proto 파일을 생성하고 서버 스트리밍 RPC 인터페이스 ExcuteServerStream과 클라이언트 스트리밍 인터페이스 ExcuteClientStream을 선언합니다.
syntax = "proto3";
option csharp_namespace = "GrpcDemo";
package streamtest;
service StreamTest {
rpc ExcuteServerStream(StreamForClientRequest) returns (stream StreamForClientRespones);
rpc ExcuteClientStream(stream StreamForClientRequest) returns (StreamForClientRespones);
rpc ExcuteMutualStream(stream StreamForClientRequest) returns (stream StreamForClientRespones);
}
message StreamForClientRequest {
int32 Id = 1;
}
message StreamForClientRespones {
repeated int32 Number = 1;
}
2. 서비스 참조를 다시 생성한 후, StreamTestService 구현 클래스를 만들고 메서드를 재정의합니다. 이후 시작 프로그램에서 서비스를 매핑합니다.
public class StreamTestService : StreamTest.StreamTestBase
{
public override async Task ExcuteServerStream(StreamForClientRequest req, IServerStreamWriter<StreamForClientRespones> resStream, ServerCallContext context)
{
var source = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 };
foreach (var item in source)
{
Console.WriteLine($"Processing item: {item}");
var element = new StreamForClientRespones();
element.Number.Add(item);
await resStream.WriteAsync(element);
await Task.Delay(1000);
}
}
public override async Task<StreamForClientRespones> ExcuteClientStream(IAsyncStreamReader<StreamForClientRequest> requestStream, ServerCallContext context)
{
var result = new StreamForClientRespones();
while (await requestStream.MoveNext())
{
result.Number.Add(requestStream.Current.Id + 1);
Console.WriteLine($"ClientStream: Received {requestStream.Current.Id}");
Thread.Sleep(100);
}
return result;
}
public override async Task ExcuteMutualStream(IAsyncStreamReader<StreamForClientRequest> reqStream, IServerStreamWriter<StreamForClientRespones> resStream, ServerCallContext context)
{
int counter = 0;
while (await reqStream.MoveNext())
{
counter++;
var element = new StreamForClientRespones();
element.Number.Add(counter);
await resStream.WriteAsync(element);
await Task.Delay(500);
}
}
}
3. 클라이언트를 생성하고 서버의 proto 파일을 복사한 후 호출 코드를 작성합니다.
// 서버 스트리밍 호출
using (var channel = GrpcChannel.ForAddress("https://localhost:7245"))
{
var client = new StreamTest.StreamTestClient(channel);
var reply = client.ExcuteServerStream(new StreamForClientRequest { Id = 1 });
await foreach (var resp in reply.ResponseStream.ReadAllAsync())
{
Console.WriteLine(resp.Number[0]);
}
}
// 클라이언트 스트리밍 호출
using (var channel = GrpcChannel.ForAddress("https://localhost:7245"))
{
var client = new StreamTest.StreamTestClient(channel);
var reply = client.ExcuteClientStream();
for (int i = 0; i < 10; i++)
{
await reply.RequestStream.WriteAsync(new StreamForClientRequest { Id = new Random().Next(0, 20) });
await Task.Delay(100);
}
Console.WriteLine("Data transmission complete");
await reply.RequestStream.CompleteAsync();
foreach (var item in reply.ResponseAsync.Result.Number)
{
Console.WriteLine($"Result: {item}");
}
}
// 양방향 스트리밍 호출
using (var channel = GrpcChannel.ForAddress("https://localhost:7245"))
{
var client = new StreamTest.StreamTestClient(channel);
var reply = client.ExcuteMutualStream();
var responseTask = Task.Run(async () =>
{
await foreach (var resp in reply.ResponseStream.ReadAllAsync())
{
Console.WriteLine(resp.Number[0]);
}
});
for (int i = 0; i < 10; i++)
{
await reply.RequestStream.WriteAsync(new StreamForClientRequest { Id = new Random().Next(0, 20) });
await Task.Delay(100);
}
await reply.RequestStream.CompleteAsync();
await responseTask;
}
2.2 ASP.NET Core Web 프로젝트를 클라이언트로 사용
1. 먼저 proto 파일을 추가하고 클라이언트를 생성합니다.
2. Web 프로젝트의 컨트롤러에서는 단순히 using 문으로 gRPC 서버에 연결하는 대신, 내장된 의존성 주입 패턴을 사용할 수 있습니다.
3. Grpc.Net.ClientFactory 패키지를 다운로드하고, Program.cs에서 클라이언트를 DI 컨테이너에 추가합니다.
builder.Services.AddGrpcClient<MyTestDemo.MyTestDemoClient>(options =>
{
options.Address = new Uri("https://localhost:7245");
});
4. 컨트롤러에서 직접 주입받아 사용합니다.
[ApiController]
[Route("[controller]")]
public class GrpcTestController : ControllerBase
{
private readonly MyTestDemoClient _client;
public GrpcTestController(MyTestDemoClient client)
{
_client = client;
}
[HttpGet]
public async Task<string> Get()
{
var response = await _client.NoParamAsync(new Google.Protobuf.WellKnownTypes.Empty());
return response.Success.ToString();
}
}
5. 호출 시 인증서 문제가 발생하면 dotnet dev-certs https --trust 명령어를 실행합니다.
3. gRPC AOP 인터셉터
gRPC 서비스 실행 전후에 작업을 수행해야 하는 경우 AOP 인터셉터를 사용할 수 있습니다. 인터셉터 메서드는 Interceptor 클래스에 정의되어 있으며, 서버와 클라이언트 모두 동일한 원리로 작동합니다. 주요 인터셉터 유형은 다음과 같습니다.
| 메서드명 | 설명 |
|---|---|
| BlockingUnaryCall | 동기 단항 호출 차단 |
| AsyncUnaryCall | 비동기 단항 호출 차단 |
| AsyncServerStreamingCall | 비동기 서버 스트리밍 호출 차단 |
| AsyncClientStreamingCall | 비동기 클라이언트 스트리밍 호출 차단 |
| AsyncDuplexStreamingCall | 비동기 양방향 스트리밍 호출 차단 |
| UnaryServerHandler | 서버 측 단항 호출 처리기 차단 |
| ClientStreamingSerHandler | 서버 측 클라이언트 스트리밍 처리기 |
| ServerStreamingSerHandler | 서버 측 서버 스트리밍 처리기 |
| DuplexStreamingSerHandler | 서버 측 양방향 스트리밍 처리기 |
1. 사용자 정의 인터셉터를 생성하여 서버 측 단항 호출의 전후 처리를 구현합니다. Grpc.Core.Interceptors.Interceptor 클래스를 상속받고 UnaryServerHandler 메서드를 재정의합니다.
public class CustomInterceptor : Interceptor
{
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
Console.WriteLine("Before execution");
var result = await continuation(request, context);
Console.WriteLine("After execution");
// 추가 기능:
// - 클라이언트에 추가 정보 첨부
// - try-catch로 예외 로깅
// - context에서 호출자 IP 추출하여 IP 제한
// - continuation 실행 시간 모니터링
return result;
}
}
2. DI 컨테이너에 등록할 때 인터셉터 옵션을 추가합니다.
builder.Services.AddGrpc(options =>
{
options.EnableDetailedErrors = true;
options.Interceptors.Add<CustomInterceptor>();
});