gRPC 전환 : 죽은 연결 지속 문제와 공통 설정 표준화 본문
배경
내부 서비스 간 통신을 REST에서 gRPC로 전환하고 있다.
팀의 공통 학습을 위해 각자가 자신 있는 서비스 하나씩을 맡아 작업했고, 나는 역시 OpenAPI 서비스였다.
사실 gRPC에 익숙하지 않았던지라, 개인적인 공부가 필요했다.
개념과 이점을 공부했고, 배포와 같은 운영 상황에서의 엣지 케이스를 점검했다.
gRPC는 연결을 유지하며 그 안에서 요청을 처리한다.
매번 핸드셰이크나 풀 관리가 필요 없으니 효율적이면서도, 연결이 유지되어 주의해야 하는 점도 있었다.
팀에서 놓친 설정도 찾았고, 팀에 공유했다.
이를 개선하면서, 동시에 서비스마다 분산되어 있었던 gRPC 설정의 표준화 라이브러리 개발을 맡게 되었다.
이 글에선 쿠버네티스 + gRPC + SpringBoot 환경에서 놓치기 쉬운, "잘못된 연결 고정" 문제를 소개하고자 한다.
부하 쏠림 현상
쿠버네티스 환경에서 서비스 간 통신은, 보통 Service로 요청하여 Pod 분산 요청하는 꼴을 기본으로 생각할 것이다.
이때 Kube-Proxy는 L4 LB라서 연결을 맺는 순간 요청 정보는 모른다.
한번 연결을 맺어 전달을 시작했다면, 'A 요청은 1번 Pod로, B 요청은 2번 Pod로' 와 같은 분산은 불가능하다.
REST 때처럼 ClusterIp 타입의 Service로 gRPC 서버를 찾아선, 한 Pod로만 요청이 전달된다는 뜻이다.
그렇다고 내부 통신을 위한 L7 LB를 따로 두거나 전면 프록시를 타게 하기에는 지연·비용만 늘어난다.
헤드리스 서비스와 라운드 로빈 설정
Headless Service는 ClusterIp 를 None으로, 가상 주소를 안 갖는 Service 형태를 말한다.
이를 사용하면 DNS처럼 Pod IP를 전부 반환받을 수 있게 된다.
gRPC 클라이언트는 기존 L4 LB의 Service에서 벗어나, 직접 모든 gRPC 서버 Pod 들의 접근 경로를 알게 된다.
gRPC 클라이언트의 라운드 로빈은, 유지 중인 연결에 순차적으로 요청을 전달하는 설정이다.
클라이언트 단에서 직접 요청을 연결에 순차적으로 전달하기 때문에, L4 LB 처럼 한 연결에만 요청이 전달되지 않는다.
결국 요청 정보를 구분할 수 없는 L4 LB Service를 피해, 클라이언트가 직접 돌아가며 요청하는 것으로 부하 쏠림 문제를 풀겠다는 것이다.

스케일 아웃 감지
그럼 gRPC 클라이언트는 매번 Headless service에 Pod 목록을 질의할까?
그렇지 않다. 쿼리 결과를 캐싱해 두고 이를 빠르게 사용한다.
연결 정보를 재확인하는 것은, 연결이 끊겼을 때뿐이다.
요청이 많아져 gRPC 서버가 스케일 아웃된 경우를 생각해 보자.
아직 연결이 끊기지 않았기에 gRPC 클라이언트는 서버가 확장되었는지 여부를 알 수 없다.
기존 확장 전의 파드만 알기에, 여전히 부하는 이전 파드들에만 몰리게 된다.
MAX_CONNECTION_AGE 는 연결 최대 유지 시간을 정하는 gRPC 서버의 설정이다.
서버가 주기적으로 연결을 종료하고 재연결하고, 이때 발생하는 연결 끊김 이벤트로 클라이언트는 DNS 쿼리를 다시 조회한다.
그때 스케일 아웃된 파드를 찾게 되고, 그렇게 파드 미감지 문제를 해결할 수 있다.

비정상 종료 시 죽은 연결
gRPC는 연결이 유지된다.
만약 gRPC 서버가 죽으면 연결은 어떻게 될까.
롤링 배포와 같은 정상적인 종료 상황에서는 서버가 종료 신호 (GOAWAY)를 전달하여 깔끔하게 연결을 재수립한다.
특히 Spring boot에선 Graceful shutdown을 지원하기에, gRPC의 연결은 정상 종료된다.
문제는 OOM, SIGKILL, 노드 다운과 같은 비정상 종료이다.
종료 신호가 안 오면, TCP 연결은 끊긴 줄도 모르고 클라이언트는 해당 파드에 요청을 전송하게 된다.
물론 Deadline과 같은 설정으로 요청이 무한 대기하는 상황을 방지할 순 있다.
다만 이는 개별 요청의 타임 아웃이지, 연결 자체를 종료하고 재수립 처리 하진 않는다.
KeepAlive 설정은 주기적으로 PING을 보내고, 죽은 연결을 찾아 종료하는 역할을 한다.
클라이언트는 PING을 보낼 주기와 응답 타임아웃을 설정하고,
서버는 너무 많은 PING을 막기 위한 허용치를 설정한다.
즉 클라이언트의 PING 주기 + 응답 타임아웃이 최대 장애 연결 유지 시간이 된다.
이 길이가 너무 길면 장애 시 종료된 연결로 요청이 가 실패될 수 있고,
반대로 PING이 너무 잦으면 네트워크 부하가 되어 부담이 될 수 있다.
이에 gRPC 공식 문서 / KeepAlive 에선 1분 이상을 권장하고 있고, 팀의 공동 설정에 근거가 되었다.
keepalive가 죽은 연결을 감지하기까지의 시간 동안, 해당 연결로 간 요청은 여전히 실패한다.
이 구간의 실패를 줄이려면 서비스마다 retry 설정이 필요하다.
실패한 요청을 다른 살아있는 파드로 다시 보내면, 사용자 입장에서는 느린 성공이 된다.
즉, keepalive = 죽은 연결 제거, deadline = 개별 요청 시간제한, retry = 죽은 연결로의 요청 실패를 성공으로 전환하는 역할을 한다.
공통 설정 표준화
내가 찾아 팀에 공유했던 내용은 이 KeepAlive 설정이었다.
팀 내 gRPC 클라이언트 전체가 keepalive 설정이 없었고, 서버의 max-connection-age도 일부 서비스에만 개별로 들어가 있었다.
만약 그대로 두었다면 파드가 비정상 종료되었을 때, 수립된 연결에 요청이 계속 전달되어 에러가 지속되었을 것이다.
이에 공통 모듈에 gRPC 설정을 정의했다.
1. 공통 모듈에 넣은 것
클라이언트 keepalive, 서버 permit, 서버 max-connection-age 세 개를 넣었다.
세 값의 기본 값을 정의하여, 각 서비스에서 누락되는 일 없이 자동으로 설정될 수 있도록 했다.
물론 사용처에서 오버라이딩 할 수 있다.
2. 공통 모듈에서 뺀 것
클라이언트의 round_robin 은 Headless DNS에서만 의미가 있다.
즉 적용하려는 서비스의 인프라 환경에 의존된다.
또, Deadline은 타임 아웃으로 각 서비스마다 적절한 값이 다르다.
전역으로 값을 잘못 잡을 경우, 어떤 서비스에선 느리고, 어떤 서비스에선 촉박하여 장애가 이어지기 좋다.
이 두 값은 전역 기본값으로 관리하기 부적합하다고 판단했다.
# 클라이언트
keep-alive-time: 60s # gRPC 공식 문서는 1분 미만 비권장
keep-alive-timeout: 10s
keep-alive-without-calls: true
# 서버
permit-keep-alive-time: 50s # 클라이언트보다 낮게. 여유 10s로 too_many_pings 방지
permit-keep-alive-without-calls: true
max-connection-age: 60s
max-connection-age-grace: 10s
검증
최소한의 gRPC 서버·클라이언트를 개발 환경 k8s에 띄워 확인했다.
서버는 자기 파드 이름을 응답하고, 클라이언트는 0.4초마다 호출해 어느 파드가 받았는지 누적 집계한다.
1. 쏠림 현상 / 클라이언트 단의 분산 요청으로 풀이
- gRPC 서버를 3개 Pod로 띄우고 클라이언트 측에서 41번의 요청을 전송했다.
- 기존 ClusterIP + pick_first(기본값)에서는 41번의 요청이 전부 한 파드로만 갔다.
- headless + round_robin으로 전환하고 세 파드로 균등하게 분산되는 것을 확인할 수 있다.
# pick_first + ClusterIP
$ kubectl logs -l app=whoami-client --tail=1
counts={5wdhn: 41}
# round_robin + headless service
$ kubectl set env deploy/whoami-client TARGET=dns:///whoami-headless:9090 LB=round_robin
$ kubectl logs -l app=whoami-client --tail=1
counts={5wdhn: 11, mx5rc: 10, ncdtp: 10}
2. 스케일아웃 미감지 현상 / 연결 유지 시간 설정으로 풀이
- 서버 파드를 3개에서 5개로 늘렸다.
- MAX_CONNECTION_AGE 없이는 45초가 지나도 새 파드에 트래픽이 0, 감지를 못하는 모습을 볼 수 있다.
- 연결 유지 시간을 설정하니 서버가 주기적으로 연결을 닫고, 클라이언트가 재연결하면서 새 파드를 자동으로 찾아 분산되었다.
# MAX_CONNECTION_AGE 없이 스케일아웃
$ kubectl scale deploy/whoami-server --replicas=5
$ kubectl logs -l app=whoami-client --tail=1 # 45초 뒤
counts={5wdhn: 65, mx5rc: 64, ncdtp: 63} # 새 파드 2개 = 0
# MAX_CONNECTION_AGE 설정 후 스케일아웃
$ kubectl set env deploy/whoami-server GRPC_SERVER_MAX_CONNECTION_AGE=20s
$ kubectl scale deploy/whoami-server --replicas=5 # 40초 뒤
counts={cz6pt: 46, 48bzx: 44, lj2cq: 44, v8jqb: 13, 6r5hb: 13}
3. 비정상 종료 / 연결 상태 주기적 확인으로 풀이
- 파드를 삭제하는 경우에는 서버가 GOAWAY를 전송하여 정상 종료, 클라이언트가 재연결한다.
- 진짜 문제는 OOMKill이나 노드 장애처럼 신호 없이 죽는 경우다.
- 서버 파드로 가는 네트워크 패킷을 iptables로 DROP 해서, 노드 장애처럼 신호 없이 끊기는 상황을 재현했다.
- ACK가 안 오니까 KeepAlive PING이 응답을 못 받아 죽음을 감지할 수 있었고, 연결이 풀에서 제거되어 더 이상 요청이 가지 않았다.

정리
- 배경:
- REST에서 gRPC로 전환하면서 k8s 환경에서의 연결 고정, 스케일아웃 미감지, 비정상 종료 시 죽은 연결 지속 문제를 발견, 팀 공유
- 처리
- 부하 쏠림: headless + round_robin으로 클라이언트 단에서 직접 분산
- 스케일아웃 미감지: MAX_CONNECTION_AGE로 주기적 연결 재수립, DNS 재조회 유도
- 비정상 종료: keepalive로 죽은 연결 감지 및 제거, deadline + retry로 감지 전 실패 대응
- 공통 모듈: gRPC 서버, 클라이언트 공통 설정을 공동 라이브러리에 기본값으로 정의
- 검증
- 개발 환경에서 쏠림, 스케일아웃, 노드 장애를 재현하여 각 설정의 효과를 실측
- iptables DROP으로 비정상 종료를 재현, keepalive ON/OFF 대조로 복구 여부 확인
- 실 EKS stg에서 동일 현상 발견 및 수정 확인
'KimJinHwan > Project' 카테고리의 다른 글
| Apache APISIX Kafka TLS 연결 지원 추가 (0) | 2026.07.17 |
|---|---|
| 데이터 레이크 구축 : 초당 6천 개의 이벤트를 담는 똑똑한 호수 (0) | 2026.03.29 |
| 인증 서버 리팩토링 : OAuth 인증 시나리오와 PKCE (0) | 2026.03.21 |
| 이벤트 파이프라인 성능 개선 : 처리량 향상과 리소스 과부하 대비 (0) | 2026.03.11 |
| 웹 소켓 서버 구조 개선 : Api Gateway 를 사용한 상태 리스 전환 (0) | 2025.12.21 |
