Published on

gRPC MSA에서 DEADLINE_EXCEEDED 원인 9가지

Authors

서버가 느린데 왜 하필 gRPC는 DEADLINE_EXCEEDED로 터질까요? gRPC에서 이 에러는 “서버가 응답을 못 했다”가 아니라 클라이언트가 정한 마감시간(deadline) 안에 RPC가 완료되지 않았다는 뜻입니다. 즉, 병목이 서버 CPU일 수도 있고, 커넥션 풀 고갈일 수도 있고, 서비스 메시/로드밸런서/네트워크 레이어에서 지연이 누적된 결과일 수도 있습니다.

MSA에서는 단일 RPC가 여러 다운스트림 호출을 연쇄적으로 만들기 때문에, 작은 지연이 합쳐져 데드라인을 초과하기 쉽습니다. 아래는 현장에서 가장 자주 만나는 원인 9가지와, 각각의 진단 포인트 및 개선 방법입니다.

참고: Go 환경에서의 context deadline exceeded 중심으로 더 깊게 보고 싶다면 Go gRPC context deadline exceeded 9가지 원인도 함께 보세요. (원인 자체는 언어와 무관하게 대부분 동일합니다)

1) 데드라인/타임아웃 값이 “현실”과 맞지 않음

가장 흔한 원인입니다. 클라이언트가 100ms 데드라인을 걸어놓고, 서버는 정상적으로 200ms가 걸리는 작업을 수행한다면 100% 재현됩니다.

특히 다음 패턴에서 자주 터집니다.

  • UI/API Gateway에서 “일괄적으로” 짧은 타임아웃을 적용
  • 배치/동기화 작업이 RPC로 들어오는데도 동일 타임아웃 사용
  • 피크 타임에 P95/P99 지연이 평소보다 커지는데 타임아웃은 고정

개선

  • SLO 기준으로 P95/P99 지연을 보고 타임아웃을 정합니다.
  • “전체 요청 타임아웃”을 정한 뒤, 다운스트림 호출에 예산(budget) 으로 분배합니다.
// Go client: RPC deadline 예시
ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel()

resp, err := c.GetUser(ctx, &pb.GetUserRequest{Id: "42"})
if err != nil {
  // status.Code(err) == codes.DeadlineExceeded 인지 확인
}
// Java client: per-call deadline 예시
UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc
  .newBlockingStub(channel)
  .withDeadlineAfter(800, java.util.concurrent.TimeUnit.MILLISECONDS);

UserResponse resp = stub.getUser(UserRequest.newBuilder().setId("42").build());

2) 서버 핸들러 내부에서 블로킹 I/O 또는 락 경합

서버가 “느린” 이유의 다수는 결국 핸들러 내부에서 발생합니다.

  • DB 쿼리 지연, 인덱스 미스
  • 외부 HTTP 호출이 느리거나 재시도 폭증
  • 전역 락/뮤텍스 경합
  • 파일/디스크 I/O

진단

  • 서버 측에서 RPC별 처리시간을 히스토그램으로 수집 (P50/P95/P99)
  • 분산 트레이싱으로 특정 span(예: DB, HTTP)에서 지연이 집중되는지 확인

개선 포인트

  • 느린 구간에 타임아웃을 별도로 걸고, 상위 데드라인을 넘기기 전에 실패시키기
  • 락을 줄이거나 범위를 축소
// 서버 핸들러에서 DB 호출에도 별도 timeout을 적용
func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
  dbCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
  defer cancel()

  u, err := s.repo.FindUser(dbCtx, req.Id)
  if err != nil {
    return nil, status.Errorf(codes.Internal, "db error: %v", err)
  }
  return &pb.GetUserResponse{Id: u.ID, Name: u.Name}, nil
}

DB/커넥션 풀 병목이 의심된다면 Spring/Java 계열에서는 HikariCP 고갈이 DEADLINE_EXCEEDED의 트리거가 되는 경우가 많습니다. 이 경우는 애플리케이션 레벨에서 “서버가 일을 시작조차 못 하는” 상황이 되기 때문입니다. 관련 진단은 Spring Boot 대규모 트래픽 HikariCP 고갈 진단·튜닝을 참고하세요.

3) 서버 리소스 고갈: CPU throttling, GC, 메모리 압박

Kubernetes 환경에서 특히 흔합니다.

  • CPU limit이 낮아 throttling이 발생하면 처리시간이 급증
  • JVM GC stop-the-world, Go GC/heap 성장으로 지연 스파이크
  • 메모리 압박으로 OOM 직전 스와핑/페이지 폴트 증가

진단

  • Pod CPU throttling 지표 (container_cpu_cfs_throttled_seconds_total)
  • JVM GC pause time, Go runtime metrics
  • 노드/Pod 메모리 사용량과 재시작 이벤트

개선

  • CPU limit을 “너무 타이트”하게 잡지 않기
  • 서버의 동시성(워크 큐) 제한으로 과부하 시 빠르게 실패
  • 큰 응답/대용량 처리 시 스트리밍 또는 페이지네이션

4) 커넥션 풀/스레드 풀 고갈로 큐잉 지연 발생

gRPC 서버는 내부적으로 스레드/워커를 사용합니다. 여기에 DB 커넥션 풀, HTTP 클라이언트 풀, 메시지 큐 producer 풀 등이 겹치면, 실제 병목은 “느린 처리”가 아니라 “대기열”이 됩니다.

  • 요청은 들어오지만 처리 스레드가 없어 대기
  • DB 커넥션이 없어 대기
  • 외부 API 호출 소켓 풀이 고갈되어 대기

진단

  • 큐 길이/대기시간(서버 프레임워크별 metrics)
  • DB 커넥션 대기시간
  • 스레드 덤프에서 WAITING/BLOCKED 증가

개선

  • 동시성 제한(서킷 브레이커, bulkhead)
  • 커넥션 풀 사이즈를 “무작정 증가”하기 전에, DB/다운스트림 용량과 함께 설계

5) 재시도(retry)와 데드라인의 결합으로 폭발

gRPC는 클라이언트/프록시 레벨에서 재시도가 붙는 순간, 지연이 누적되어 데드라인을 초과하기 쉽습니다.

예시: 타임아웃 600ms, 재시도 2회, 백오프 200ms면 정상 케이스에서도 간당간당합니다.

진단

  • 동일 RPC에 대해 서버 로그에 같은 request id가 반복되는지
  • Envoy/mesh에서 retry policy가 켜져 있는지

개선

  • “전체 데드라인” 안에서만 재시도하도록 설계
  • idempotent한 요청만 재시도
  • 재시도 시 백오프를 짧게 하되 최대 횟수 제한
# 예: Envoy/Service Mesh 정책에서 retry를 과도하게 잡지 않기(개념 예시)
# timeout과 retry budget을 함께 설계해야 함
retryPolicy:
  retryOn: "5xx,connect-failure,reset"
  numRetries: 1
  perTryTimeout: "200ms"

6) 로드밸런서/프록시/서비스 메시에서의 타임아웃 불일치

MSA에서 타임아웃은 한 군데만 존재하지 않습니다.

  • API Gateway timeout
  • Ingress timeout
  • Service mesh(Envoy) timeout
  • gRPC client deadline
  • server keepalive/idle timeout

이 값들이 서로 충돌하면 “서버는 아직 처리 중인데 중간 프록시가 먼저 끊어버리는” 일이 생깁니다. 클라이언트는 이를 DEADLINE_EXCEEDED 또는 UNAVAILABLE로 보기도 합니다.

개선

  • 타임아웃의 우선순위를 정하고, 바깥 레이어가 더 길거나 같도록 정렬
  • keepalive 설정을 통일

7) DNS/서비스 디스커버리 지연 및 커넥션 재수립

다음 상황에서 첫 호출이 유독 느려지고 데드라인을 넘길 수 있습니다.

  • DNS TTL이 짧아 재조회가 잦음
  • CoreDNS 부하
  • Pod 교체로 endpoint 변경이 잦고 커넥션이 자주 끊김
  • 클라이언트가 매 요청마다 새 채널을 만들고 TLS 핸드셰이크 수행

개선

  • gRPC 채널/커넥션을 재사용(채널을 매번 만들지 않기)
  • DNS 캐시/TTL, CoreDNS 리소스 점검
  • 커넥션 워밍업
// (안 좋은 예) 요청마다 Dial
// (좋은 예) 애플리케이션 시작 시 Dial 후 재사용
conn, err := grpc.NewClient(
  target,
  grpc.WithTransportCredentials(creds),
)
if err != nil { /* handle */ }
client := pb.NewUserServiceClient(conn)

8) 메시지 크기/직렬화 비용: 큰 payload, 압축, protobuf 변환

응답이 커지면 아래 비용이 누적됩니다.

  • protobuf marshal/unmarshal
  • 압축/해제
  • 네트워크 전송
  • 중간 프록시 버퍼링

특히 “목록 API를 한 번에 다 내려주는” RPC는 피크 시간에 쉽게 데드라인을 초과합니다.

개선

  • 페이지네이션/커서 기반 조회
  • 필요한 필드만 내려주는 별도 RPC 설계
  • 스트리밍 RPC로 전환
// 서버 스트리밍으로 큰 목록을 나눠 전달
service UserService {
  rpc ListUsers(ListUsersRequest) returns (stream User);
}

9) 다운스트림(특히 DB/외부망) 네트워크 경로 문제

애플리케이션이 아니라 네트워크 레이어가 원인인 경우도 많습니다.

  • Pod에서 RDS로 가는 경로에 NAT/SG/NACL 이슈
  • 특정 AZ 간 라우팅 문제
  • 패킷 드랍으로 재전송 증가

이 경우 gRPC는 “그냥 느려진” 것이 아니라, TCP 레벨에서 재전송이 늘어나면서 지연이 급증해 데드라인을 넘깁니다.

진단

  • 같은 코드/같은 부하인데 특정 노드/서브넷에서만 재현되는지
  • VPC Flow Logs, NAT gateway metrics, RDS 연결 지표

네트워크 타임아웃 진단 체크리스트는 EKS Pod→RDS 504 타임아웃 - SG·NACL·NAT 10분 진단도 도움이 됩니다. (HTTP 504 사례지만 원인 분류와 진단 순서는 gRPC에도 그대로 적용됩니다)

재현/진단을 빠르게 만드는 로그·메트릭 최소 세트

원인 9가지를 빠르게 좁히려면, “에러 1개”만 보지 말고 다음 3종을 붙여서 봐야 합니다.

1) 클라이언트: deadline 값과 실제 소요시간

  • RPC별 deadline (또는 timeout)
  • 실제 경과시간
  • gRPC status code
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

_, err := c.GetUser(ctx, &pb.GetUserRequest{Id: "42"})
dur := time.Since(start)

st, _ := status.FromError(err)
log.Printf("rpc=GetUser dur=%s code=%v err=%v", dur, st.Code(), err)

2) 서버: handler 처리시간 + 다운스트림 span

  • 서버 인터셉터로 RPC latency 기록
  • DB/HTTP 호출 시간을 별도 span으로 분리
// Unary interceptor로 서버 처리시간 측정
func timingInterceptor(
  ctx context.Context,
  req any,
  info *grpc.UnaryServerInfo,
  handler grpc.UnaryHandler,
) (any, error) {
  start := time.Now()
  resp, err := handler(ctx, req)
  dur := time.Since(start)

  st, _ := status.FromError(err)
  log.Printf("method=%s dur=%s code=%v", info.FullMethod, dur, st.Code())
  return resp, err
}

3) 인프라: throttling/큐잉/네트워크

  • CPU throttling, GC pause
  • 커넥션 풀 대기시간
  • 재전송/오류율, NAT/ingress timeout

마무리: “데드라인 초과”는 증상이고, 병목은 따로 있다

DEADLINE_EXCEEDED는 gRPC가 친절하게 “시간 예산을 초과했다”고 알려주는 신호입니다. 하지만 원인은 애플리케이션 로직부터 리소스/풀 고갈, 재시도 정책, 프록시 타임아웃 불일치, 네트워크 경로 문제까지 넓게 퍼져 있습니다.

정리하면, 해결의 핵심은 다음 순서가 가장 효율적입니다.

  1. 클라이언트 deadline이 합리적인지 확인
  2. 서버 handler에서 실제로 느린지, 아니면 큐잉인지 분리
  3. 다운스트림(DB/외부 API) 지연을 span으로 고립
  4. mesh/ingress/LB 타임아웃 정렬
  5. 리소스 throttling과 풀 고갈을 지표로 상시 감시

이 5단계만 체계화해도, DEADLINE_EXCEEDED의 상당수는 “원인 미상”에서 “재현 가능하고 고칠 수 있는 이슈”로 바뀝니다.