gRPC 정리 - HTTP/2 기반 고성능 RPC 이해하기
REST API만 쓰다가 gRPC를 처음 접했을 때 솔직히 좀 당황스러웠다. HTTP/2? Streaming? Protobuf? 개념이 많았다. 근데 막상 적용해보니 마이크로서비스 환경에서 이만한 게 없었다.
gRPC의 핵심 개념부터 실전 활용까지 정리한다. 이전 글에서 다룬 Protobuf 지식이 있으면 더 이해하기 쉽다.
1. gRPC란?
gRPC는 Google에서 개발한 고성능 RPC(Remote Procedure Call) 프레임워크다. 핵심 특징은:
- HTTP/2 기반: 멀티플렉싱, 스트리밍, 헤더 압축 지원
- Protobuf 직렬화: 바이너리 인코딩으로 빠른 속도
- 다중 언어 지원: C#, Java, Python, Go, Kotlin 등
- 양방향 스트리밍: 클라이언트-서버 간 실시간 통신
2. gRPC 내부 동작
sequenceDiagram
participant Client
participant Stub
participant Server
participant Handler
Client->>Stub: getProduct() 호출
Stub->>Stub: 메시지 직렬화
Stub->>Server: HTTP/2 POST
Server->>Handler: 라우팅
Handler->>Handler: 역직렬화 + 비즈니스 로직
Handler->>Server: 응답 생성
Server->>Stub: HTTP/2 응답
Stub->>Stub: 역직렬화
Stub->>Client: 결과 반환.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의 해결책:
flowchart LR
subgraph HTTP_1["HTTP/1.1"]
A[요청1] --> B[응답1]
B --> C[요청2]
C --> D[응답2]
end
subgraph HTTP_2["HTTP/2"]
E[요청1] --> F[응답1]
E --> G[요청2]
G --> H[응답2]
end멀티플렉싱
하나의 TCP 연결에서 수백 개의 스트림을 병렬 처리:
- 각 스트림은 Stream ID로 구분
- 프레임이 뒤섞여도 재조립 가능
- 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: OK
HTTP 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
전체 호출 체인에 대한 타임아웃 설정:
sequenceDiagram
participant Client
participant ProductMgt
participant Inventory
Client->>ProductMgt: Deadline: 50ms
Note over ProductMgt: 처리 20ms
ProductMgt->>Inventory: 남은 Deadline: 30ms
Note over Inventory: 처리 40ms (초과!)
Inventory-->>ProductMgt: DEADLINE_EXCEEDED
ProductMgt-->>Client: DEADLINE_EXCEEDEDREST의 단순 타임아웃과 달리, 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
두 가지 방식 지원:
1. Load-Balancer Proxy (Nginx, Envoy)
- 클라이언트는 LB 주소만 알면 됨
- 중앙 집중식 관리
2. Client-Side Load Balancing
- 클라이언트가 직접 서버 선택
- 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);
}
// 정상 로직...
}클라이언트 예외 처리
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 가독성) | 어려움 (바이너리) |
- 마이크로서비스 간 내부 통신
- 실시간 스트리밍이 필요한 경우
- 성능이 중요한 고트래픽 서비스
- 다양한 언어로 구성된 폴리글랏 환경
마무리
gRPC는 "빠른 REST 대체제"라기보다는, HTTP/2 + Protobuf를 조합해서 고성능 RPC를 구현한 프레임워크다.
정리하면:
- 4가지 통신 패턴으로 다양한 요구사항 대응
- HTTP/2 멀티플렉싱으로 연결 효율 극대화
- Deadline/Cancellation으로 분산 환경에서의 리소스 관리
- Interceptor로 횡단 관심사 처리
- 표준화된 상태 코드로 일관된 에러 처리
REST가 적합한 경우(공개 API, 브라우저 클라이언트 등)도 분명히 있다. 하지만 백엔드 간 통신이나 고성능이 필요한 환경이라면 gRPC를 적극 검토해볼 만하다.
Loading comments...