- Published on
gRPC MSA 데드라인 전파 누락 진단·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려진 것도 아닌데 특정 시간대마다 p95/p99 지연이 튀고, 결국엔 전체가 타임아웃으로 무너지는 현상은 MSA에서 흔합니다. 특히 gRPC 기반 호출 체인에서 deadline(또는 timeout)이 제대로 전파되지 않으면, 상위 요청은 이미 포기했는데 하위 서비스는 계속 일을 하면서 리소스를 태우는 상황이 생깁니다. 이 글은 “데드라인 전파 누락”을 어떻게 진단하고, 어디를 어떻게 고쳐야 재발을 막는지에 집중합니다.
데드라인 전파 누락이 만드는 전형적 장애 패턴
gRPC에서 데드라인은 단순한 “클라이언트 타임아웃”이 아니라 요청의 유효 기간입니다. 상위 호출자가 deadline을 설정하면, 서버는 그 시각 이후엔 결과를 반환해도 의미가 없으니 가능한 빨리 중단해야 합니다.
전파가 누락되면 다음 패턴이 반복됩니다.
- 상위는 타임아웃(또는 취소)인데 하위는 계속 실행
- DB 쿼리, 외부 API, CPU 바운드 작업이 계속 돌아감
- 연쇄 리소스 고갈
- gRPC 서버 워커 스레드/이벤트루프 점유
- 커넥션 풀 고갈, DB 세션 고갈
- 큐/락 경합 증가
- 꼬리 지연 악화
- p50은 멀쩡한데 p99만 급격히 상승
- 관측 신호
- 클라이언트는
DEADLINE_EXCEEDED가 늘어나는데 - 서버는 실제로는 오래 처리하다가 응답을 버림(혹은 이미 취소된 스트림에 write)
- 클라이언트는
이 문제는 단일 서비스 튜닝으로는 잘 해결되지 않고, 호출 체인의 “시간 예산”을 끝까지 전달하는 설계가 필요합니다.
gRPC 데드라인의 핵심 동작 요약
- 클라이언트는 RPC 호출 시
deadline을 설정할 수 있습니다. - 서버는 해당 데드라인을 메타데이터로 전달받고, 내부적으로
context취소 신호로 노출합니다. - 데드라인이 지나면 클라이언트는 대개
DEADLINE_EXCEEDED로 종료합니다. - 서버가 데드라인을 존중하지 않으면, 서버는 계속 일하고 결과를 폐기하게 됩니다.
즉 “전파”는 두 단계입니다.
- 상위 요청이 데드라인을 설정한다
- 하위 호출이 상위 컨텍스트(데드라인 포함)를 그대로 물고 나간다
문제는 2)에서 자주 발생합니다.
어디서 전파가 끊기는가: 흔한 누락 지점 7가지
1) 서버 핸들러에서 새 컨텍스트를 만들어 하위 호출에 사용
대표적으로 Go에서 context.Background()나 context.TODO()를 하위 호출에 넣는 실수입니다.
2) 언어/프레임워크별 기본 타임아웃이 0(무제한)
일부 클라이언트는 명시적으로 설정하지 않으면 무제한으로 호출이 대기합니다.
3) 인터셉터에서 데드라인을 덮어쓰거나 제거
“표준 타임아웃을 강제한다”는 명목으로 무조건 새 데드라인을 설정하거나, 메타데이터를 재구성하면서 기존 데드라인을 무시하는 경우가 있습니다.
4) HTTP 게이트웨이(Envoy, grpc-gateway, ALB 등)에서 timeout 매핑 실패
HTTP 레이어의 timeout과 gRPC 데드라인이 별개로 동작하면, 중간에서 끊기거나 무제한으로 늘어질 수 있습니다.
5) 재시도 로직이 데드라인을 고려하지 않음
남은 시간 예산이 50ms인데 재시도 3회를 강행하면, 의미 없는 부하만 추가합니다.
6) 스트리밍 RPC에서 cancel 신호를 무시
서버가 ctx.Done()을 체크하지 않고 계속 send/recv 루프를 돌면, 취소된 스트림이 시스템을 붙잡습니다.
7) DB/외부 API 호출이 컨텍스트 취소를 지원하지 않음
라이브러리가 컨텍스트를 받더라도 실제로는 취소를 반영하지 않는 경우가 있습니다(혹은 타임아웃 옵션을 별도로 줘야 함).
빠른 진단: “상위는 죽었는데 하위는 산다”를 증명하기
1) 로그에 “데드라인 시각”과 “남은 시간”을 찍어라
핸들러 진입 시점에 남은 시간을 찍으면, 전파가 끊긴 요청은 바로 티가 납니다.
- 정상:
remaining_ms가 수백~수천 ms로 들어옴 - 비정상:
remaining_ms가 비정상적으로 크거나(사실상 무제한), 아예 값이 없음
2) 서버에서 취소 이벤트를 관측하라
서버 핸들러에서 취소 이벤트가 발생했는데도 하위 호출이 계속 진행되면, 내부 전파가 끊겼다는 뜻입니다.
3) 트레이싱에서 “deadline/timeout” 속성을 표준화해 넣어라
OpenTelemetry를 쓰는 경우, span attribute로 rpc.grpc.deadline_ms 같은 값을 넣어두면 호출 체인에서 어디서 사라지는지 찾기 쉽습니다.
4) 에러 비율만 보지 말고 “취소 이후 작업량”을 보라
CPU, DB QPS, 외부 API 호출량이 타임아웃 발생 직후에도 유지/증가하면 전형적인 누락 신호입니다.
해결 원칙: 데드라인은 “전파”가 아니라 “차감”이다
호출 체인의 각 hop은 상위 데드라인을 그대로 전달하되, 내부 작업(큐잉, 직렬화, 리트라이, 백오프)로 시간이 소모됩니다. 따라서 하위 호출은 항상 남은 시간 예산 안에서만 실행돼야 합니다.
- 상위에서 2초를 줬다면
- 중간 서비스에서 300ms를 이미 썼다면
- 하위 호출에는 최대 1.7초(또는 그보다 짧은 내부 정책값)만 줘야 합니다.
이 원칙이 없으면, 각 서비스가 “나는 2초 필요”를 주장하며 전체는 6초, 10초로 늘어납니다.
Go 예제: 전파 누락과 올바른 패턴
나쁜 예: context.Background()로 데드라인을 끊어버림
func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
// 전파 끊김: 상위 데드라인/취소 신호가 사라짐
resp, err := s.profileClient.GetProfile(context.Background(), &pb.GetProfileRequest{UserId: req.UserId})
if err != nil {
return nil, err
}
return &pb.GetUserResponse{Nickname: resp.Nickname}, nil
}
좋은 예: 상위 ctx를 그대로 전달하고, 남은 시간에 맞춰 하위 데드라인을 설정
func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
// 상위 데드라인이 없다면 정책적으로 기본값을 부여
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 2*time.Second)
defer cancel()
}
// 하위 호출에 별도 예산을 주고 싶다면, 상위 ctx에서 파생
// (상위 데드라인보다 길게 잡히지 않음)
childCtx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
resp, err := s.profileClient.GetProfile(childCtx, &pb.GetProfileRequest{UserId: req.UserId})
if err != nil {
return nil, err
}
return &pb.GetUserResponse{Nickname: resp.Nickname}, nil
}
핵심은 WithTimeout을 쓰더라도 반드시 **부모 컨텍스트가 ctx**여야 한다는 점입니다. 그래야 상위 취소가 하위로 전파됩니다.
서버에서 취소를 적극 반영하기: ctx.Done() 체크
func (s *Server) StreamEvents(req *pb.StreamEventsRequest, stream pb.EventService_StreamEventsServer) error {
ctx := stream.Context()
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// 클라이언트 취소/데드라인 초과
return ctx.Err()
case <-ticker.C:
if err := stream.Send(&pb.Event{Message: "tick"}); err != nil {
return err
}
}
}
}
Java 예제: Deadline 누락을 막는 클라이언트 습관
Java gRPC는 stub에 데드라인을 명시적으로 부여하는 패턴이 흔합니다. 문제는 중간 레이어에서 stub을 재사용하면서 데드라인 설정을 빼먹는 경우입니다.
UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel)
.withDeadlineAfter(800, java.util.concurrent.TimeUnit.MILLISECONDS);
UserResponse resp = stub.getUser(UserRequest.newBuilder().setId(id).build());
권장 패턴은 다음 중 하나입니다.
- 호출 지점에서 무조건
withDeadlineAfter를 설정 - 또는 클라이언트 인터셉터로 “기본 데드라인”을 강제하되, 이미 데드라인이 있으면 존중
인터셉터에서 기존 데드라인을 덮어쓰지 않도록 주의해야 합니다.
인터셉터로 기본 데드라인 강제하기(덮어쓰기 금지)
데드라인이 없는 요청만 기본값을 부여하면, “전파는 존중하면서도” 무제한 호출을 막을 수 있습니다.
Go 클라이언트 인터셉터 예시
func DefaultTimeoutUnaryClientInterceptor(d time.Duration) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, d)
defer cancel()
}
return invoker(ctx, method, req, reply, cc, opts...)
}
}
이 인터셉터는 “데드라인이 이미 있는 호출 체인”에서는 아무것도 바꾸지 않으므로 전파를 깨지 않습니다.
게이트웨이/프록시 레이어에서의 함정: HTTP timeout과 gRPC deadline 불일치
실무에서는 브라우저/모바일 HTTP 요청이 API Gateway를 거쳐 gRPC로 변환되는 경우가 많습니다. 이때 다음이 불일치하면 전파가 깨집니다.
- 외부 HTTP 서버의 request timeout
- 프록시(Envoy 등)의 upstream timeout
- gRPC 클라이언트 데드라인
권장 접근은 “외부 timeout을 내부 deadline으로 변환”하고, 내부 서비스는 그 deadline을 신뢰하는 것입니다. 또한 게이트웨이의 timeout이 내부보다 짧으면, 내부는 계속 일하는데 외부는 먼저 끊는 문제가 생기므로 게이트웨이 timeout은 내부 데드라인보다 약간 길게(예: 10~20% 여유) 두는 식의 정책이 필요합니다.
EKS에서 Ingress/ALB 계층 문제로 지연과 reset이 섞여 보일 때는 네트워크 계층 증상과 애플리케이션 타임아웃을 분리해서 봐야 합니다. 관련해서는 EKS ALB Ingress 502 Target reset 원인과 해결도 함께 참고하면 원인 분리가 빨라집니다.
재시도 설계: 남은 시간 예산 기반으로만 시도하라
데드라인 전파가 되어도, 재시도가 남은 시간을 무시하면 결국 같은 문제가 됩니다.
권장 규칙:
- 재시도는
remaining_time이 충분할 때만 - 백오프는
remaining_time을 초과하지 않게 - 시도 횟수보다 “총 소요 시간”을 상한으로
OpenAI API 같은 외부 호출에서 429 대응을 하다 보면, 백오프/큐잉/토큰 버짓을 “시간 예산”으로 관리하는 감각이 중요합니다. 이 관점은 내부 gRPC 재시도에도 그대로 적용됩니다. 자세한 설계 감각은 OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기에서 아이디어를 얻을 수 있습니다.
DB까지 전파하라: 쿼리 타임아웃과 락 대기
상위 요청이 취소됐는데 DB 쿼리가 계속 도는 경우가 특히 치명적입니다.
- 커넥션 풀이 잠기고
- 락 대기가 늘어나며
- 정상 트래픽까지 같이 느려집니다
가능하면 DB 드라이버가 컨텍스트 취소를 지원하도록 하고, 별도로 쿼리 타임아웃(또는 statement timeout)을 설정하세요. 락 경합이 겹치면 “데드라인 누락”이 “DB 데드락/락 대기 폭증”으로 확대됩니다. 이 영역은 MySQL·PostgreSQL 데드락 분석과 트랜잭션·인덱스 튜닝도 함께 보면 진단 범위를 넓히는 데 도움이 됩니다.
운영 체크리스트: 재발 방지용 가드레일
1) 모든 서버 핸들러에서 “데드라인 존재 여부”를 메트릭으로 수집
deadline_present=true/false같은 라벨deadline_remaining_ms히스토그램
전파가 끊긴 구간은 false가 급증합니다.
2) 서버에서 취소 이후 작업을 줄이는 방어 코딩
- 긴 루프는
ctx.Done()을 주기적으로 체크 - 워커/고루틴을 띄울 때는 반드시 부모
ctx를 넘김
3) 공통 클라이언트 라이브러리로 데드라인 정책을 표준화
- 팀마다 제각각 설정하면 누락이 생깁니다.
- 인터셉터로 기본값을 넣고, 서비스별 override를 허용하세요.
4) “hop별 예산”을 문서화
예:
- Edge
HTTP3s - Gateway 내부 변환 2.7s
- Service A 전체 2.5s
- Service A에서 Service B 호출 1.2s
- Service B에서 DB 600ms
5) 부하 테스트에서 “취소 폭풍” 시나리오를 포함
일부러 짧은 데드라인을 주고, 서버가 취소를 잘 반영해 리소스가 회복되는지 확인해야 합니다.
결론: 데드라인 전파는 성능 기능이 아니라 안정성 기능
gRPC MSA에서 데드라인 전파 누락은 단순히 몇 건의 타임아웃을 만드는 문제가 아니라, 시스템이 바쁠수록 더 바빠지는 악순환을 만듭니다. 해결의 핵심은 다음 3가지입니다.
- 상위
ctx를 절대 끊지 말 것(특히context.Background()금지) - 인터셉터/게이트웨이에서 “기본값은 주되 덮어쓰지 말 것”
- 남은 시간 예산 기반으로 하위 호출, 재시도, DB 쿼리를 설계할 것
이 세 가지를 표준화하면, 같은 트래픽에서도 꼬리 지연과 장애 전파가 눈에 띄게 줄어듭니다.