← all posts
DEV 2026.05.02 · 14 min read Intermediate

REST에서 gRPC로 — 점진적 전환의 설계 원칙

모니터링부터 분산 추적, 연결 튜닝, 성능 비교, 마이그레이션까지 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를 비롯한 모든 추적 인프라에 노출되므로 민감한 정보는 절대 담지 않는다.

Baggage 보안 주의

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)**이다. 보이고, 추적되고, 안전하게 전환할 수 있어야 성능 이점이 비로소 의미를 갖는다.