REST에서 gRPC로 — 점진적 전환의 설계 원칙
모니터링부터 분산 추적, 연결 튜닝, 성능 비교, 마이그레이션까지 gRPC 운영의 핵심 패턴을 하나의 관통 철학으로 추적한다.
- 01 gRPC는 왜 REST보다 마이크로서비스에 맞는가
- 02 Protobuf은 왜 JSON보다 작고 빠른가
- 03 gRPC를 제대로 쓴다는 것의 의미
- 04 gRPC 스트리밍은 왜 Polling보다 효율적인가
- 05 gRPC 보안은 왜 계층으로 쌓는가
- 06 Spring에서 gRPC를 제대로 쓰려면 무엇이 필요한가
- 07 REST에서 gRPC로 — 점진적 전환의 설계 원칙
gRPC를 도입하는 팀이 가장 먼저 마주치는 문제는 성능이 아니다. “지금 무슨 일이 일어나고 있는가”를 알 수 없다는 것이다. REST는 브라우저, curl, 로그 한 줄로 디버깅이 가능하지만, gRPC는 바이너리 프로토콜 위에서 돌아간다. 이 불투명함을 해결하지 않으면 성능 이점은 의미가 없다. 그렇다면 gRPC를 운영 가능한 시스템으로 만드는 설계 원칙은 무엇인가?
관찰 가능성 — 보이지 않으면 제어할 수 없다
gRPC 서비스의 첫 번째 운영 과제는 메트릭 수집이다. grpc-spring-boot-starter는 Micrometer를 통해 세 가지 핵심 메트릭을 자동으로 노출한다.
# application.yml
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
tags:
application: my-grpc-service
environment: production
자동 수집되는 메트릭의 구조는 다음과 같다.
grpc_server_calls_total (Counter)
├─ service: UserService
├─ method: GetUser
└─ status: OK | INVALID_ARGUMENT | INTERNAL | ...
grpc_server_processing_duration_seconds (Timer/Histogram)
├─ le: 0.005, 0.01, 0.025, 0.05, 0.1, ...
└─ p50, p90, p99 계산 가능
에러율과 p99 지연시간은 가장 먼저 설정해야 할 알람이다.
# 에러율 (5분 윈도우)
rate(grpc_server_calls_total{status!="OK"}[5m]) /
rate(grpc_server_calls_total[5m])
# p99 응답시간
histogram_quantile(0.99,
rate(grpc_server_processing_duration_seconds_bucket[5m]))
p50 = 10ms이고 p99 = 1000ms라면, 평균만 보는 팀은 문제를 인지하지 못한다. p99가 치솟는 순간이 바로 이벤트 루프나 스레드 풀이 포화되는 신호다.
분산 추적 — TraceID가 서비스 경계를 넘는 방법
메트릭이 “무엇이 잘못됐는가”를 알려준다면, 분산 추적은 “어디서 잘못됐는가”를 알려준다. OpenTelemetry는 gRPC 메타데이터에 traceparent 헤더를 자동으로 주입해 A → B → C 서비스 체인을 하나의 Trace로 묶는다.
Frontend (TraceID: abc123)
│ HTTP Header: traceparent: abc123
▼
Service A (SpanID: span1)
│ gRPC Metadata: traceparent: abc123
▼
Service B (SpanID: span2, Parent: span1)
│ gRPC Metadata: traceparent: abc123
▼
Service C (SpanID: span3, Parent: span2)
│
▼
Jaeger: Trace abc123 → Total: 250ms (A: 50ms, B: 100ms, C: 80ms + DB: 20ms)
Baggage는 TraceID와 별개로 비즈니스 컨텍스트(예: user_id, request_correlation_id)를 체인 전체에 전파하는 메커니즘이다. 단, Baggage는 Jaeger UI를 비롯한 모든 추적 인프라에 노출되므로 민감한 정보는 절대 담지 않는다.
user_password나 세션 토큰을 Baggage에 저장하면 모든 추적 시스템과 로그에 평문으로 노출된다. Baggage에는 user_id, request_id처럼 식별 가능하되 민감하지 않은 값만 담는다.
Sampling 전략은 시스템 부하와 가시성의 트레이드오프다. 고트래픽 환경에서 always_on(100% 샘플링)을 설정하면 Jaeger가 스팬을 감당하지 못해 드롭이 발생한다. ParentBasedSampler + 10%가 권장 출발점이다 — 부모 스팬이 샘플링되면 자식도 함께 샘플링되어 체인의 일관성이 유지된다.
연결 관리 — 프록시가 연결을 끊는 이유
AWS ALB, Nginx는 기본적으로 60초간 트래픽이 없는 연결을 종료한다. gRPC는 HTTP/2 위에서 장기 연결을 유지하므로, keepAlive를 설정하지 않으면 유휴 연결이 프록시에 의해 끊기고 ECONNRESET 에러가 발생한다.
grpc:
server:
enable-keep-alive: true
keep-alive-time: 30s # 프록시 타임아웃보다 짧게
keep-alive-timeout: 10s # PONG 대기 시간
keep-alive-without-calls: true # 유휴 상태에서도 PING
max-connection-age: 5m # 5분마다 연결 교체 (LB 재분배)
max-connection-idle: 2m
keepAliveTime: 30s는 30초마다 HTTP/2 PING 프레임을 서버에 전송한다. 서버가 10초 내에 PONG을 응답하지 않으면 연결을 강제 종료한다. 이 메커니즘이 프록시의 유휴 타임아웃(60초)보다 먼저 작동해 연결을 갱신한다.
maxConnectionAge: 5m은 다른 이유로 중요하다. 연결이 수 시간 동안 유지되면, 로드밸런서가 새 인스턴스를 추가해도 기존 연결들은 계속 이전 인스턴스로만 향한다. 주기적 연결 교체는 로드밸런서 재분배를 강제해 인스턴스 간 트래픽 불균형을 방지한다.
keepAliveTime을 짧게(10초) 설정하면 연결 문제를 빠르게 감지하지만 PING/PONG 오버헤드가 증가한다. 길게(300초) 설정하면 오버헤드가 줄지만 장애 감지가 느려진다. 30~60초가 대부분의 ALB 환경에서 적합한 균형점이다. Channel Pool은 단일 연결로는 병렬 처리가 어려울 때(처리량 > 500 req/s) 도입하며, 크기는 CPU 코어 수를 기준으로 잡는다.
성능 비교 — 수치가 말하는 것과 말하지 않는 것
gRPC가 REST보다 빠른 이유는 두 가지다. Protobuf의 바이너리 직렬화(같은 메시지 대비 30~50% 크기 절감)와 HTTP/2 멀티플렉싱(하나의 연결로 병렬 요청 처리). 동일 하드웨어 단일 머신 기준으로 Unary RPC는 REST(HTTP/1.1) 대비 약 5배의 처리량(2,000 → 10,000 req/s)과 절반 이하의 p99 지연시간을 보인다.
그러나 벤치마크는 함정이 많다. JVM 워밍업 없이 측정하면 JIT 컴파일 전 수치가 섞여 결과가 왜곡된다. localhost에서만 측정하면 네트워크 RTT가 0ms인 비현실적 환경이 된다. 실제 운영 메시지 크기(보통 1~10KB)가 아닌 100B짜리 테스트 메시지로 측정하면 직렬화 오버헤드가 과소 계산된다.
JMH를 사용할 때는 @Warmup(iterations = 5) 이후 측정값만 신뢰하고, 실제 운영 메시지 크기를 @State로 준비해 사용해야 한다.
gRPC가 REST보다 느릴 수 있는 경우도 있다. 낮은 처리량(< 100 req/s) 환경에서는 Protobuf 역직렬화 오버헤드가 JSON 파싱보다 크게 느껴질 수 있다. HTTP 캐싱이 중요한 시나리오나 웹 브라우저가 직접 호출하는 공개 API에서는 REST가 여전히 더 적합하다.
마이그레이션 — Strangler Fig와 Feature Flag
REST에서 gRPC로 한 번에 전환하는 “빅뱅” 방식은 실패 시 전체 서비스가 다운된다. Strangler Fig 패턴은 gRPC API를 기존 REST API와 병렬로 구축하고, 트래픽을 점진적으로 이동시키는 전략이다.
핵심은 클라이언트 코드를 인터페이스로 추상화해 Feature Flag 하나로 전환할 수 있게 만드는 것이다.
// 추상화 인터페이스
public interface UserServiceClient {
User getUser(String id);
}
// Feature Flag 기반 주입
@Bean
public UserServiceClient userServiceClient(
@Value("${featureFlags.useGrpc.enabled}") boolean useGrpc) {
return useGrpc
? new GrpcUserServiceClient(createGrpcStub())
: new RestUserServiceClient(new RestTemplate());
}
롤아웃 순서는 위험도 기준으로 Tier를 나눈다. 내부 관리 도구(Tier 1, 트래픽 10%) → 모바일 앱(Tier 2, 트래픽 30%) → 핵심 공개 API(Tier 3, 트래픽 100%). 각 단계에서 에러율이 임계값(1%)을 넘으면 Feature Flag를 자동으로 비활성화해 REST로 롤백한다.
Proto-First 설계는 마이그레이션 전에 반드시 거쳐야 하는 단계다. .proto 파일을 먼저 팀 간 합의하고 reserved 필드로 확장 공간을 확보해야, 구현 중간에 메시지 구조가 바뀌는 Breaking Change를 방지할 수 있다.
정리
- 메트릭(Micrometer)과 분산 추적(OpenTelemetry)은 선택이 아니라 gRPC 운영의 전제 조건이다.
- keepAlive는 ALB/프록시 환경에서 필수다.
keepAliveTime < 프록시 타임아웃으로 설정하고 여유 마진을 둔다. - gRPC의 성능 이점은 높은 처리량과 대역폭 제약 환경에서 두드러진다. 낮은 트래픽이나 공개 API에서는 REST가 더 실용적일 수 있다.
- 마이그레이션은 Proto-First 설계 → Strangler Fig 구축 → Feature Flag 롤아웃 → 자동 롤백 순서로 진행한다.
gRPC 도입의 핵심은 성능이 아니라 **운영 가능성(operability)**이다. 보이고, 추적되고, 안전하게 전환할 수 있어야 성능 이점이 비로소 의미를 갖는다.