REST API만 쓰다가 gRPC를 처음 접했을 때는 HTTP/2, 스트리밍, Protobuf 같은 개념이 한꺼번에 나왔다.
하나씩 살펴보니 HTTP/2 연결 위에서 Protobuf 메시지를 주고받는 RPC라는 골격으로 정리됐고, 마이크로서비스 내부 통신에서는 낮은 직렬화 비용과 .proto 기반의 타입 안전성이 이점이었다.
gRPC의 핵심 개념부터 실전 활용까지 정리한다.
Protobuf 인코딩 원리는 Protocol Buffers 정리에서 다뤘고, 읽고 오면 이 글이 더 쉽게 읽힌다.
1. gRPC란?
gRPC는 Google에서 개발한 고성능 RPC(Remote Procedure Call) 프레임워크다.
전송 계층으로 HTTP/2를 사용해 멀티플렉싱·스트리밍·헤더 압축을 그대로 활용하고, 메시지는 Protobuf로 바이너리 직렬화한다.
.proto 정의 하나로 C#, Java, Python, Go, Kotlin 등 여러 언어의 코드를 생성할 수 있고, 클라이언트와 서버가 동시에 스트림을 여는 양방향 통신도 지원한다.
2. gRPC 내부 동작
.proto 파일로 서비스 정의
syntax = "proto3";
option csharp_namespace = "ChatService";
service Chat {
rpc SystemChat (SystemChatRequest) returns (SystemChatResponse);
}자동 생성된 코드
protoc 컴파일러가 Server Skeleton과 Client Stub을 생성한다:
// 서버 스켈레톤
public abstract partial class ChatBase
{
public virtual Task<SystemChatResponse> SystemChat(
SystemChatRequest request,
ServerCallContext context)
{
throw new RpcException(new Status(StatusCode.Unimplemented, ""));
}
}
// 클라이언트 스텁
public partial class ChatClient : ClientBase<ChatClient>
{
public virtual AsyncUnaryCall<SystemChatResponse> SystemChatAsync(
SystemChatRequest request,
Metadata headers = null,
DateTime? deadline = null,
CancellationToken cancellationToken = default)
{
// ...
}
}클라이언트는 원격 서버의 함수를 로컬 함수처럼 호출할 수 있다:
public class ChatManager(Chat.ChatClient client)
{
private async Task SendSystemChat()
{
await client.SystemChatAsync(new SystemChatRequest { ... });
}
}3. gRPC 통신 패턴
gRPC는 4가지 통신 패턴을 지원한다.
1) Unary RPC
가장 기본적인 패턴.
요청 1개 → 응답 1개.
rpc PlaceOrder (OrderRequest) returns (OrderResponse);// 서버
public override async Task<OrderResponse> PlaceOrder(
OrderRequest request,
ServerCallContext ctx)
{
return new OrderResponse { Id = request.Id, Status = "ACCEPTED" };
}
// 클라이언트
var response = await client.PlaceOrderAsync(
new OrderRequest { Id = "o-1", Item = "Potion", Qty = 3 });2) Server Streaming RPC
클라이언트 요청 1개 → 서버가 여러 응답을 스트림으로 전송.
rpc ListOrders (OrderQuery) returns (stream OrderResponse);// 서버
public override async Task ListOrders(
OrderQuery request,
IServerStreamWriter<OrderResponse> responseStream,
ServerCallContext ctx)
{
foreach (var order in GetOrders(request.UserId))
{
await responseStream.WriteAsync(order);
}
// 메서드 리턴 시 스트림 자동 종료
}
// 클라이언트
using var call = client.ListOrders(new OrderQuery { UserId = "u-42" });
await foreach (var reply in call.ResponseStream.ReadAllAsync())
{
Console.WriteLine($"{reply.Id} -> {reply.Status}");
}대규모 데이터 조회, 실시간 피드 전송에 적합하다.
3) Client Streaming RPC
클라이언트가 여러 요청을 스트림으로 전송 → 서버가 단일 응답.
rpc UploadOrders (stream OrderRequest) returns (UploadSummary);// 서버
public override async Task<UploadSummary> UploadOrders(
IAsyncStreamReader<OrderRequest> requestStream,
ServerCallContext ctx)
{
int count = 0;
await foreach (var req in requestStream.ReadAllAsync(ctx.CancellationToken))
{
count++;
}
return new UploadSummary { Total = count };
}
// 클라이언트
using var call = client.UploadOrders();
foreach (var i in Enumerable.Range(1, 5))
{
await call.RequestStream.WriteAsync(
new OrderRequest { Id = $"o-{i}", Item = "Gem", Qty = i });
}
await call.RequestStream.CompleteAsync(); // 반드시 종료 명시
var summary = await call.ResponseAsync;파일 업로드, 배치 데이터 전송에 적합하다.
4) Bidirectional Streaming RPC
양쪽이 독립적으로 스트림 전송.
실시간 채팅, 게임에 최적.
rpc Chat (stream ChatMessage) returns (stream ChatMessage);// 서버
public override async Task Chat(
IAsyncStreamReader<ChatMessage> requestStream,
IServerStreamWriter<ChatMessage> responseStream,
ServerCallContext ctx)
{
await foreach (var msg in requestStream.ReadAllAsync(ctx.CancellationToken))
{
await responseStream.WriteAsync(new ChatMessage {
UserId = "server",
Text = $"[echo] {msg.Text}"
});
}
}
// 클라이언트
using var call = client.Chat();
// 수신 태스크 (별도 스레드)
var readTask = Task.Run(async () =>
{
await foreach (var msg in call.ResponseStream.ReadAllAsync())
{
Console.WriteLine($"<- {msg.Text}");
}
});
// 송신
await call.RequestStream.WriteAsync(new ChatMessage { UserId = "u-1", Text = "Hi!" });
await call.RequestStream.WriteAsync(new ChatMessage { UserId = "u-1", Text = "How are you?" });
await call.RequestStream.CompleteAsync();
await readTask;4. HTTP/2 기반 gRPC의 장점
왜 HTTP/2일까?
HTTP/1.1은 한 연결에서 요청-응답을 하나씩만 처리한다.
파이프라이닝을 걸어도 앞 응답이 늦으면 뒤 요청이 전부 기다리는 Head-of-Line Blocking이 생기고, 요청마다 같은 헤더를 중복 전송한다.
HTTP/2는 이 구조 자체를 바꿨다.
멀티플렉싱
하나의 TCP 연결에서 수백 개의 스트림을 병렬로 처리할 수 있다.
각 스트림이 Stream ID로 구분되기 때문에 프레임이 뒤섞여 도착해도 수신 측에서 재조립할 수 있고, HTTP/1.1에서 겪던 HOL Blocking이 완화된다.
헤더 압축 (HPACK)
중복된 헤더를 인덱스 테이블로 치환:
- 첫 요청:
"authorization: Bearer <token>"전송 - 이후 요청: 테이블 인덱스만 전송
- 결과: 네트워크 오버헤드 최소화
영구 연결 (Persistent Connection)
- TLS Handshake 비용 절감
- 지연(latency) 감소
- 초당 수천 건 요청 처리에 필수
5. gRPC 메시지 구조
요청 메시지
HEADERS (flags = END_HEADERS)
:method: POST
:scheme: https
:path: /ProductInfo/getProduct
:authority: api.example.com
content-type: application/grpc
grpc-timeout: 5S
DATA (flags = END_STREAM)
[1B Flag][4B Length][Protobuf Payload]주요 헤더:
:path:/{서비스명}/{메소드명}형식content-type: 항상application/grpcgrpc-timeout: 데드라인 설정
응답 메시지
HEADERS (flags = END_HEADERS)
:status: 200
content-type: application/grpc
DATA
[Protobuf Payload]
HEADERS (flags = END_STREAM, END_HEADERS) // Trailers
grpc-status: 0
grpc-message: OKHTTP Status는 거의 항상 200.
실제 성공/실패는 grpc-status로 판단한다.
6. 고급 기능
Interceptor
RPC 호출 전후에 로직을 삽입할 수 있는 메커니즘.
로깅, 인증, 메트릭 수집에 활용.
public class MetricsInterceptor : Interceptor
{
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
var stopwatch = Stopwatch.StartNew();
try
{
var response = await continuation(request, context);
stopwatch.Stop();
GrpcDuration.WithLabels(context.Method, "OK")
.Observe(stopwatch.Elapsed.TotalSeconds);
return response;
}
catch (Exception ex)
{
stopwatch.Stop();
GrpcDuration.WithLabels(context.Method, "ERROR")
.Observe(stopwatch.Elapsed.TotalSeconds);
throw;
}
}
}Deadline
전체 호출 체인에 대한 타임아웃 설정:
REST의 단순 타임아웃과 달리, Deadline은 전체 체인에 전파된다.
Cancellation
불필요한 작업 중단:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await client.SystemChatAsync(
new SystemChatRequest { ... },
cancellationToken: cts.Token);5초가 지나면 서버에 취소 신호가 전달된다.
Metadata
요청/응답에 부가 정보 전달:
// 클라이언트 - 요청 헤더
var headers = new Metadata {
{ "authorization", $"Bearer {token}" },
{ "x-trace-id", traceId }
};
var call = client.PlaceOrderAsync(request, headers: headers);
// 서버 - 헤더 읽기
var auth = ctx.RequestHeaders.FirstOrDefault(h => h.Key == "authorization")?.Value;
// 서버 - 응답 헤더/트레일러 추가
await ctx.WriteResponseHeadersAsync(new Metadata { { "x-server", "ordersvc" } });
ctx.ResponseTrailers.Add("x-quota-remaining", "42");Load Balancing
로드 밸런싱은 두 가지 방식을 지원한다.
Nginx, Envoy 같은 프록시를 앞에 두는 방식은 클라이언트가 LB 주소만 알면 되고, 트래픽을 중앙에서 관리할 수 있다.
클라이언트 사이드 로드 밸런싱은 클라이언트가 직접 서버를 골라 round-robin, weighted 같은 알고리즘을 적용하는 방식으로, 프록시라는 단일 장애점(SPOF)이 없다.
7. Error Handling
gRPC는 HTTP Status와 별도로 자체 상태 코드를 사용한다.
주요 상태 코드
| Code | Number | 설명 |
|---|---|---|
| OK | 0 | 성공 |
| CANCELLED | 1 | 클라이언트 취소 |
| INVALID_ARGUMENT | 3 | 잘못된 매개변수 |
| DEADLINE_EXCEEDED | 4 | 타임아웃 |
| NOT_FOUND | 5 | 리소스 없음 |
| ALREADY_EXISTS | 6 | 이미 존재 |
| PERMISSION_DENIED | 7 | 권한 없음 |
| UNAVAILABLE | 14 | 서비스 불가 |
| UNAUTHENTICATED | 16 | 인증 실패 |
서버 예외 처리
protected override async ValueTask<RPGBossFirstClearRes> HandleMethod(
RPGBossFirstClearReq request, IUserIdentity userIdentity)
{
var battle = await GetBattle(request.BattleIndex);
if (battle.IsCleared)
{
var trailers = new Metadata
{
{ "app-error-code", "E001_ALREADY_CLEARED" },
{ "app-user-message", "이미 클리어한 인덱스입니다." }
};
throw new RpcException(
new Status(StatusCode.AlreadyExists, "Cleared Index"),
trailers);
}
// 정상 로직...
}Status의 detail 문자열은 디버깅용 영문 메시지로 남겨두고, 유저에게 그대로 노출할 문구는 별도 트레일러에 실었다.
상태 코드만으로는 같은 AlreadyExists라도 어떤 도메인 규칙에 걸렸는지 구분할 수 없어서, app-error-code로 세부 원인을 함께 내려보내 클라이언트가 분기할 수 있게 했다.
클라이언트 예외 처리
try
{
var reply = await client.RPGBossFirstClearAsync(request);
}
catch (RpcException ex)
{
switch (ex.StatusCode)
{
case StatusCode.AlreadyExists:
var userMsg = ex.Trailers.GetValue("app-user-message");
ShowToast(userMsg);
break;
case StatusCode.Unavailable:
case StatusCode.DeadlineExceeded:
EnqueueRetry("잠시 후 다시 시도해주세요.");
break;
default:
ShowToast("알 수 없는 오류가 발생했습니다.");
break;
}
}8. gRPC vs REST 비교
| 항목 | REST | gRPC |
|---|---|---|
| 프로토콜 | HTTP/1.1 (일반적 구현) | HTTP/2 |
| 직렬화 | JSON (텍스트) | Protobuf (바이너리) |
| 스트리밍 | 제한적 | 4가지 패턴 지원 |
| 타입 안전성 | 약함 | 강함 (.proto 기반) |
| 브라우저 지원 | 네이티브 | gRPC-Web 필요 |
| 디버깅 | 쉬움 (JSON 가독성) | 어려움 (바이너리) |
REST 자체는 특정 프로토콜에 묶이지 않아서 HTTP/2·3 위에서 동작하는 구현도 흔하다.
표의 HTTP/1.1은 일반적인 구현을 기준으로 한 것이다.
- 마이크로서비스 간 내부 통신
- 실시간 스트리밍이 필요한 경우
- 성능이 중요한 고트래픽 서비스
- 다양한 언어로 구성된 폴리글랏 환경
마무리
gRPC는 "빠른 REST 대체재"라기보다는, HTTP/2와 Protobuf를 조합해 고성능 RPC를 구현한 프레임워크다.
4가지 통신 패턴과 Deadline, Interceptor, 상태 코드 체계도 결국 이 조합 위에서 분산 환경의 호출을 다루기 위한 장치다.
브라우저 호환과 디버깅 편의가 중요한 공개 API까지 바꿀 이유는 없고, 이점이 뚜렷한 자리는 마이크로서비스 내부 통신처럼 호출량이 많고 타입 안전성이 필요한 구간이다.