Published on

Go gRPC DEADLINE_EXCEEDED 9가지 원인과 처방

Authors

서버 간 통신이 gRPC로 바뀌면 타임아웃은 더 이상 “느리다”의 문제가 아니라 SLO/에러율을 직접 올리는 장애 신호가 됩니다. 특히 Go에서 rpc error: code = DeadlineExceeded desc = context deadline exceeded는 흔하지만, 원인은 네트워크부터 애플리케이션 락, 커넥션 풀, 프록시 타임아웃까지 넓게 퍼져 있어 재현이 어렵습니다.

이 글은 Go gRPC에서 DEADLINE_EXCEEDED를 만드는 9가지 원인을 “어디서 시간을 잃는가” 기준으로 분류하고, 각 원인별로 **관측 포인트(로그/메트릭/트레이싱)**와 **바로 적용 가능한 처방(코드/설정)**을 제시합니다.

> 함께 읽기: 쿠버네티스 환경에서 웹훅/컨트롤러 타임아웃은 gRPC 타임아웃과 같은 패턴으로 나타납니다. EKS AWS Load Balancer Controller 500 Webhook 타임아웃 해결

DEADLINE_EXCEEDED를 제대로 해석하기

gRPC의 DEADLINE_EXCEEDED는 크게 두 갈래입니다.

  • 클라이언트 관점: 내가 준 deadline 안에 응답을 못 받았다(네트워크/서버/중간 프록시/내 코드 모두 가능).
  • 서버 관점: 서버 핸들러가 ctx.Done()을 받았고 작업을 중단하거나, 중단하지 못한 채 응답을 못 내보냈다.

따라서 “타임아웃을 늘리자”는 처방은 가장 마지막입니다. 먼저 어디서 시간이 소비되는지를 분해해야 합니다.

최소 진단 체크리스트(5분)

  1. 클라이언트 deadline 값은 얼마인가? (per-RPC? interceptor?)
  2. 서버 핸들러에서 ctx를 하위 호출(DB/HTTP/gRPC)에 전파하는가?
  3. keepalive/idle timeout, LB timeout(Envoy/ALB/NLB)과 충돌하는가?
  4. 서버/클라이언트의 연결 수, 큐잉, 고루틴/FD 한계는?
  5. 트레이스에서 “DNS/커넥션/핸들러/다운스트림” 중 어디가 긴가?

1) 클라이언트 deadline이 너무 짧거나 일관되지 않음

가장 흔한 원인입니다. 특히 다음 패턴에서 자주 터집니다.

  • 배치/리포트/대용량 응답인데 UI 요청과 같은 deadline을 사용
  • interceptor에서 기본 deadline을 강제하지만 일부 호출에서 override 실패
  • retry를 쓰는데 전체 예산(time budget) 없이 per-try deadline만 짧게 둠

처방

  • “요청 유형별”로 deadline을 분리하고, 전체 예산을 기준으로 retry를 설계합니다.
// 요청 유형별로 명시적 deadline 부여
func callUserService(ctx context.Context, c pb.UserServiceClient, req *pb.GetUserReq) (*pb.GetUserResp, error) {
    ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
    defer cancel()

    return c.GetUser(ctx, req)
}

// 배치성 호출은 더 큰 예산
func callReportService(ctx context.Context, c pb.ReportServiceClient, req *pb.ReportReq) (*pb.ReportResp, error) {
    ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
    defer cancel()

    return c.Generate(ctx, req)
}
  • 가능하면 서버 처리 시간 p95/p99를 기준으로 deadline을 산정하고, “증상”이 아니라 “SLO”에 맞춰 관리합니다.

2) 서버 핸들러가 ctx 취소를 무시(또는 전파하지 않음)

서버에서 deadline이 초과되면 ctx.Done()이 닫힙니다. 하지만 다음과 같은 코드가 있으면 타임아웃이 난 뒤에도 서버는 계속 일을 하며, 결국 큐잉/리소스 고갈로 연쇄 타임아웃이 발생합니다.

  • DB 쿼리에 context.Background() 사용
  • 외부 HTTP 호출에 context 미전파
  • 긴 루프/스트리밍 처리에서 ctx.Err() 체크 누락

처방

  • 모든 하위 호출에 ctx를 전달하고, 긴 작업은 주기적으로 취소를 체크합니다.
func (s *Server) Get(ctx context.Context, req *pb.GetReq) (*pb.GetResp, error) {
    // BAD: ctx를 버림
    // row := s.db.QueryRowContext(context.Background(), "SELECT ...")

    // GOOD: ctx 전파
    row := s.db.QueryRowContext(ctx, "SELECT name FROM users WHERE id=?", req.Id)

    var name string
    if err := row.Scan(&name); err != nil {
        return nil, status.Error(codes.Internal, err.Error())
    }

    // 긴 루프면 취소 체크
    select {
    case <-ctx.Done():
        return nil, status.Error(codes.DeadlineExceeded, ctx.Err().Error())
    default:
    }

    return &pb.GetResp{Name: name}, nil
}

3) 서버 측 큐잉(스레드/고루틴/워커풀/세마포어)로 대기 시간이 증가

서버가 CPU/IO를 못 따라가면 실제 처리 시간보다 **대기 시간(Queueing)**이 커져 deadline을 초과합니다. 흔한 형태:

  • 글로벌 mutex 경합
  • 제한된 워커풀(채널)에서 대기
  • DB 커넥션 풀 부족으로 대기

관측 포인트

  • pprof에서 goroutine blocking / mutex profile
  • DB 풀 메트릭(대기 시간, in-use)
  • gRPC server handler latency가 아니라 “request started → handler entered” 지연이 있는지

처방(예: 세마포어 대기에도 ctx 적용)

var sem = make(chan struct{}, 100) // 동시 처리 제한

func acquire(ctx context.Context) error {
    select {
    case sem <- struct{}{}:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func release() { <-sem }

func (s *Server) Heavy(ctx context.Context, req *pb.HeavyReq) (*pb.HeavyResp, error) {
    if err := acquire(ctx); err != nil {
        return nil, status.Error(codes.DeadlineExceeded, "queue wait exceeded")
    }
    defer release()

    // ... heavy work
    return &pb.HeavyResp{}, nil
}

4) DNS/서비스 디스커버리 지연 또는 실패로 연결이 늦게 잡힘

특히 쿠버네티스에서 CoreDNS 부하/네트워크 이슈가 있으면, gRPC 호출이 “서버가 느린 것처럼” 보이지만 실제로는 이름 해석 단계에서 시간을 씁니다.

관측 포인트

  • 클라이언트 측 트레이스에서 DNS lookup 구간
  • CoreDNS latency/error 메트릭
  • 동일 노드/동일 파드에서만 재현되는지

처방

  • 서비스 이름 해석이 잦다면 커넥션 재사용(아래 5번)과 함께, 디스커버리 계층의 병목을 먼저 제거합니다.
  • 장애 시나리오가 “콜드스타트+DNS+스케일아웃”로 엮이면 503/타임아웃이 같이 튀는 패턴이 많습니다. GCP Cloud Run 503·콜드스타트 폭증 해결 가이드

5) 커넥션 재사용 실패(매 요청 Dial, 잦은 커넥션 생성)

gRPC는 HTTP/2 기반이라 연결 하나로 다중 스트림을 처리하는 것이 장점인데, 실수로 매 요청마다 grpc.Dial을 하면 다음 비용이 매번 발생합니다.

  • TCP handshake
  • TLS handshake
  • HTTP/2 settings 교환

이 비용이 deadline을 갉아먹고, 피크에선 포트/FD 고갈까지 이어집니다.

처방

  • grpc.ClientConn은 프로세스 생명주기 동안 재사용합니다.
// 앱 시작 시 1회 생성
conn, err := grpc.NewClient(
    target,
    grpc.WithTransportCredentials(creds),
    grpc.WithKeepaliveParams(keepalive.ClientParameters{
        Time:                30 * time.Second,
        Timeout:             5 * time.Second,
        PermitWithoutStream: true,
    }),
)
if err != nil { log.Fatal(err) }

client := pb.NewUserServiceClient(conn)

// 핸들러에서는 conn을 재사용
func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
    defer cancel()

    _, _ = client.GetUser(ctx, &pb.GetUserReq{Id: "1"})
}

6) keepalive/idle timeout/LB timeout 불일치로 중간에서 연결이 끊김

DEADLINE_EXCEEDED는 “서버가 늦다”가 아니라 중간에서 패킷이 드랍/연결이 유휴로 정리되어 응답이 안 오는 경우에도 발생합니다.

대표적인 불일치:

  • L4/L7 LB의 idle timeout(예: 60s) < gRPC 장기 스트림 유지 시간
  • 프록시(Envoy/Nginx)에서 grpc_read_timeout/stream_idle_timeout이 짧음
  • NAT 게이트웨이/방화벽이 유휴 연결을 정리

처방

  • 클라이언트 keepalive ping을 적절히 설정하고, LB/프록시 idle timeout과 정합성을 맞춥니다.
  • 서버도 필요 시 keepalive.EnforcementPolicyServerParameters를 조정합니다.
s := grpc.NewServer(
    grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
        MinTime:             10 * time.Second,
        PermitWithoutStream: true,
    }),
    grpc.KeepaliveParams(keepalive.ServerParameters{
        Time:    30 * time.Second,
        Timeout: 5 * time.Second,
    }),
)

7) 다운스트림(DB/외부 API/다른 gRPC) 지연이 상위 deadline을 소진

상위 서비스는 빠른데, 내부에서 호출하는 DB나 외부 API가 느려지면 결국 DEADLINE_EXCEEDED가 됩니다. 이때 흔한 실수는:

  • 상위 deadline 500ms인데, 내부에서 DB 400ms + 외부 API 400ms를 순차 호출
  • 각 하위 호출에 별도 timeout을 안 걸어 “끝까지 기다림”

처방: 하위 호출에 “부분 예산” 배분

func budget(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
    if deadline, ok := ctx.Deadline(); ok {
        remain := time.Until(deadline)
        if remain < d {
            d = remain
        }
    }
    return context.WithTimeout(ctx, d)
}

func (s *Server) Aggregate(ctx context.Context, req *pb.AggReq) (*pb.AggResp, error) {
    dbCtx, cancelDB := budget(ctx, 200*time.Millisecond)
    defer cancelDB()

    user, err := s.repo.GetUser(dbCtx, req.Id)
    if err != nil { return nil, status.Error(codes.DeadlineExceeded, err.Error()) }

    apiCtx, cancelAPI := budget(ctx, 250*time.Millisecond)
    defer cancelAPI()

    score, err := s.scorer.Fetch(apiCtx, user)
    if err != nil { return nil, status.Error(codes.DeadlineExceeded, err.Error()) }

    return &pb.AggResp{Score: score}, nil
}

8) 대용량 메시지/압축/직렬화 비용으로 시간 초과

Go gRPC에서 큰 payload는 다음 비용을 유발합니다.

  • protobuf marshal/unmarshal CPU
  • 압축(gzip 등) CPU
  • HTTP/2 flow control로 인한 전송 지연

특히 “응답이 커졌는데 deadline은 그대로”인 상황에서 p99가 급격히 상승합니다.

관측 포인트

  • payload size 분포(요청/응답 바이트)
  • CPU 사용량(특히 user time)
  • handler 내부는 빠른데 wire time이 긴지(클라이언트/서버 양쪽 트레이스)

처방

  • 페이지네이션/필드 마스킹/서버 스트리밍으로 전환
  • 정말 필요할 때만 압축 사용(또는 더 빠른 알고리즘)
  • MaxSendMsgSize/MaxRecvMsgSize는 “해결”이 아니라 “가드레일”로 설정

9) 클라이언트 측 리트라이/백오프가 deadline을 잠식(또는 폭주)

리트라이는 타임아웃을 “완화”하기도 하지만, 설계가 나쁘면 오히려 deadline 초과를 늘립니다.

  • per-try timeout이 짧아 계속 실패 → backoff로 시간만 소비
  • 동시 리트라이로 서버 부하 증가 → 큐잉 증가 → 더 많은 DEADLINE_EXCEEDED
  • idempotent가 아닌 요청을 무분별하게 재시도

처방

  • 전체 예산을 기준으로 “시도 횟수/백오프/최대 대기”를 제한
  • 서버가 과부하일 때는 빠르게 실패(fail-fast)하거나, 클라이언트에서 rate limit 적용
// 간단한 예: 전체 예산 내에서만 재시도
func retryWithin(ctx context.Context, attempts int, perTry time.Duration, fn func(context.Context) error) error {
    var last error
    for i := 0; i < attempts; i++ {
        tryCtx, cancel := context.WithTimeout(ctx, perTry)
        err := fn(tryCtx)
        cancel()
        if err == nil {
            return nil
        }
        last = err
        if ctx.Err() != nil {
            return ctx.Err()
        }
        time.Sleep(time.Duration(i+1) * 30 * time.Millisecond) // bounded backoff
    }
    return last
}

실전 트러블슈팅 순서(추천)

  1. 클라이언트 deadline 확인: 실제 값, 적용 위치(interceptor), 요청 유형별 분리 여부
  2. 트레이싱으로 구간 분해: DNS/Connect/Write/Server handler/Downstream
  3. 서버 큐잉/락/풀: DB 풀 대기, mutex 경합, 워커풀 대기
  4. 연결 재사용/keepalive/LB timeout 정합성
  5. payload/직렬화/압축
  6. retry 정책: 예산 기반, 폭주 방지

마무리

Go gRPC의 DEADLINE_EXCEEDED는 단순히 “타임아웃이 짧다”가 아니라, 큐잉·연결·중간 인프라·다운스트림·코드의 ctx 전파 중 한 곳에서 시간이 새고 있다는 신호입니다. 위 9가지 원인을 순서대로 배제하면, 대개 “늘리면 된다”가 아니라 “줄여야 할 지연이 어디인지”가 선명하게 보입니다.

운영 환경에서 특히 많이 놓치는 두 가지는 (1) grpc.ClientConn 재사용 실패로 인한 연결 비용, (2) LB/프록시 idle timeout과 keepalive 불일치입니다. 이 둘만 잡아도 p99 타임아웃이 눈에 띄게 줄어드는 경우가 많습니다.