gRPC 도입 후 발생하는 간헐적 502 에러와 Latency 급증: 실전 MSA 디버깅 및 .proto 설계 가이드

최근 레거시 REST API 기반의 정산 시스템을 마이크로서비스(MSA)로 분리하면서 gRPC를 전격 도입했습니다. 목표는 명확했습니다. JSON의 무거운 직렬화(Serialization) 오버헤드를 줄이고, 내부 서비스 간 통신 속도를 밀리초(ms) 단위로 단축하는 것이었습니다. 하지만 배포 첫날, 모니터링 대시보드는 붉은색으로 물들었습니다. 트래픽이 피크를 칠 때마다 간헐적으로 io.grpc.StatusRuntimeException: UNAVAILABLE 에러가 발생했고, 특정 페이로드에서는 알 수 없는 파싱 에러로 데이터가 유실되는 현상을 목격했습니다. 단순한 네트워크 문제라기엔 패턴이 너무 불규칙했습니다. 이 글은 그날 밤새 로그를 뒤지며 찾아낸 gRPC의 까다로운 동작 방식과, 이를 해결하기 위해 재정립한 Protocol Buffers(.proto) 설계 전략에 대한 기록입니다.

HTTP/2와 gRPC의 오해: 연결은 영원하지 않다

문제 해결의 실마리는 gRPC 공식 문서의 깊은 곳에 있는 HTTP/2 연결 관리 메커니즘을 이해하는 것에서 시작되었습니다. 많은 개발자들이 gRPC가 HTTP/2의 Multiplexing(멀티플렉싱)을 지원하므로, 단 하나의 TCP 커넥션만 맺어두면 영구적으로 고성능 통신이 가능할 것이라 착각합니다. 저 또한 그랬습니다.

당시 우리 시스템은 AWS EKS(Kubernetes) 환경에서 구동 중이었고, 앞단에는 NLB(Network Load Balancer)가 위치해 있었습니다. 문제는 L4 레벨인 NLB는 TCP 커넥션의 생명주기를 애플리케이션만큼 섬세하게 관리하지 않는다는 점이었습니다.

증상(Symptom): 클라이언트(Spring Boot)는 연결이 살아있다고 판단하여 요청을 보냈지만, 서버(Go) 혹은 중간의 로드밸런서는 이미 유휴 시간(Idle Timeout) 초과로 커넥션을 끊어버린 상태였습니다. 이로 인해 RST_STREAM 프레임이 발생하며 요청이 증발했습니다.

일반적인 REST API(HTTP/1.1)였다면 Connection Pool 관리가 비교적 단순했겠지만, gRPC의 롱 런(Long-lived) 커넥션 특성은 로드밸런서의 설정과 충돌하기 십상입니다. 특히 Deadlock이나 Head-of-Line Blocking 문제가 TCP 레벨에서 발생할 수 있음을 간과해서는 안 됩니다.

초기 접근의 실패: 단순 재시도(Retry)의 함정

처음에는 단순히 클라이언트 측에 RetryPolicy를 적용하여 해결하려 했습니다. "연결이 끊기면 다시 보내면 되지 않는가?"라는 안일한 생각이었습니다. 하지만 이는 Thundering Herd(양떼 효과)를 유발했습니다. 이미 부하를 받아 응답이 늦어지는 서버에 재시도 요청까지 몰리면서 CPU 사용률이 100%를 찍고 컨테이너가 OOM(Out of Memory)으로 죽는 연쇄 작용이 일어났습니다. 재시도는 근본적인 해결책이 아니었습니다. 연결 자체의 견고함을 높여야 했습니다.

해결책: KeepAlive 설정과 .proto 설계의 정석

안정적인 gRPC 통신을 위해서는 두 가지 핵심 조치가 필요했습니다. 첫째는 네트워크 레벨의 KeepAlive 튜닝이고, 둘째는 애플리케이션 레벨의 엄격한 Protocol Buffers 버전 관리입니다.

1. KeepAlive & MaxConnectionAge 설정

가장 먼저 수행한 것은 클라이언트와 서버 양측의 KeepAlive 주기를 맞추는 것이었습니다. 특히, 로드밸런서의 Idle Timeout보다 짧게 설정하여 좀비 커넥션을 방지해야 합니다. 다음은 Java(Spring Boot) 기반의 클라이언트 설정 예시입니다.

// Netty 기반의 gRPC 채널 설정
// 로드밸런서의 타임아웃보다 짧게 설정하는 것이 핵심입니다.
ManagedChannel channel = NettyChannelBuilder.forAddress("order-service", 9090)
    .keepAliveTime(30, TimeUnit.SECONDS)  // 30초마다 핑(Ping) 전송
    .keepAliveTimeout(10, TimeUnit.SECONDS) // 핑 응답 대기 시간
    .keepAliveWithoutCalls(true) // 활성 호출이 없어도 핑 전송 유지
    .idleTimeout(10, TimeUnit.MINUTES) // 유휴 연결 종료 시간
    .usePlaintext()
    .build();

위 코드에서 keepAliveWithoutCalls(true) 옵션은 트래픽이 없는 시간대에도 연결을 유지시켜, 갑작스러운 트래픽 스파이크 시 핸드쉐이크 비용 없이 즉시 요청을 처리할 수 있게 해 줍니다. 서버 측에서도 MaxConnectionAge를 설정하여 주기적으로 클라이언트가 재접속하도록 유도(Graceful Drain)해야 특정 파드에만 커넥션이 쏠리는 불균형 현상을 막을 수 있습니다.

2. 방어적인 .proto 파일 설계와 필드 관리

두 번째 이슈였던 데이터 유실은 .proto 파일의 하위 호환성(Backward Compatibility) 위반 때문이었습니다. 개발자가 기존 필드의 태그 번호(Tag Number)를 변경하거나, required(proto2)와 같은 위험한 키워드를 혼용했던 것이 원인이었습니다.

다음은 유지보수성을 극대화한 Protocol Buffers 설계 모범 사례입니다.

syntax = "proto3";

package com.company.payment.v1;

// 옵션: Java 빌드 시 생성될 클래스 설정
option java_multiple_files = true;
option java_outer_classname = "PaymentProto";

service PaymentService {
  // 메서드명은 동사, 메시지명은 명사로 명확히 구분
  rpc ProcessPayment (PaymentRequest) returns (PaymentResponse);
}

message PaymentRequest {
  // 1. 필드 삭제 시 'reserved'를 사용하여 태그 재사용 방지 (매우 중요)
  reserved 3, 4, 5;
  reserved "credit_card_info";

  // 2. 필드 번호 1~15는 1바이트로 인코딩되므로 자주 쓰는 필드에 할당
  string order_id = 1; 
  double amount = 2;
  
  // 3. 확장성을 위해 oneof 나 Any 타입 고려
  oneof payment_method {
    string card_token = 6;
    string paypal_id = 7;
  }
}

message PaymentResponse {
  bool success = 1;
  string error_message = 2;
  // 에러 코드는 별도 Enum으로 관리하여 타입 안정성 확보
  ErrorCode error_code = 3; 
}

enum ErrorCode {
  UNKNOWN = 0; // Enum의 0번은 항상 기본값(Default)이어야 함
  INSUFFICIENT_FUNDS = 1;
  GATEWAY_TIMEOUT = 2;
}

특히 reserved 키워드는 필수입니다. 만약 과거에 사용했던 필드 번호 3번을 지우고, 실수로 새로운 필드에 3번을 할당하면, 구버전 클라이언트가 보낸 데이터가 신버전 서버에서 엉뚱한 값으로 해석되는 치명적인 논리적 오류가 발생합니다. 이는 컴파일 에러도 나지 않아 디버깅이 지옥과 같습니다.

성능 검증: REST vs gRPC

위의 설정을 적용하고 배포한 후, 부하 테스트 도구(nGrinder)를 통해 성능 변화를 측정했습니다. 결과는 예상보다 극적이었습니다.

지표 (Metric) REST (JSON) gRPC (Protobuf) 개선율
Avg Response Time 120 ms 45 ms 62.5% 감소
CPU Usage (Serialization) 35% 12% 65% 절약
Network Throughput 80 Mbps 25 Mbps 68% 대역폭 절감

가장 큰 이득은 직렬화 비용 감소였습니다. JSON 파싱을 위해 낭비되던 CPU 사이클이 비즈니스 로직 처리에 할당되면서, 동일한 하드웨어 스펙으로 2배 이상의 트래픽을 감당할 수 있게 되었습니다. 페이로드 크기 감소 또한 네트워크 병목 구간을 해소하는 데 결정적이었습니다.

GitHub에서 gRPC Java 예제 코드 확인하기

주의사항 및 엣지 케이스

gRPC가 만능은 아닙니다. 도입 전 반드시 고려해야 할 몇 가지 엣지 케이스가 있습니다.

브라우저 호환성: 웹 브라우저는 gRPC를 직접 지원하지 않습니다. 프론트엔드와 직접 통신해야 한다면 gRPC-Web 프록시나 Envoy가 필수적입니다.

또한, 디버깅 난이도가 높습니다. 바이너리 데이터는 curl로 확인이 불가능합니다. 따라서 개발 환경에서는 반드시 grpcurl이나 Evans 같은 CLI 도구를 설치하여, 리플렉션(Reflection)을 통해 런타임에 서비스 명세를 확인하고 요청을 날려볼 수 있는 환경을 구축해야 합니다. 서버 측에서 ProtoReflectionService를 활성화해두지 않으면, 장애 발생 시 원인을 파악하는 데 평문의 JSON보다 몇 배의 시간이 소요될 수 있습니다.

Best Practice: 운영 환경에서는 리플렉션을 끄고, 스테이징/개발 환경에서만 켜두는 것이 보안상 안전합니다.

결론

MSA 환경에서 gRPC는 분명 강력한 무기이지만, HTTP/1.1 시절의 습관대로 다루다가는 예상치 못한 장애에 부딪히기 쉽습니다. 연결 관리(Connection Management)와 스키마 설계(Schema Design)에 대한 깊은 이해 없이는 성능 이점을 온전히 누릴 수 없습니다. 이번 트러블슈팅 경험을 통해 KeepAlive 튜닝과 reserved 필드 활용이 단순한 옵션이 아니라 운영의 필수 요건임을 뼈저리게 느꼈습니다. 여러분의 시스템이 이유 없는 레이턴시 급증을 겪고 있다면, 지금 바로 gRPC 채널 설정과 .proto 히스토리를 점검해 보시기 바랍니다.

Post a Comment