Published on

Go gRPC deadline exceeded(코드 4) 원인·해결

Authors

운영 중인 Go gRPC 서비스에서 rpc error: code = DeadlineExceeded desc = context deadline exceeded(코드 4)가 보이기 시작하면, 단순히 타임아웃 값을 늘리는 것만으로는 재발을 막기 어렵습니다. 이 에러는 “요청이 정해진 시간 안에 끝나지 않았다”는 결과일 뿐이고, 실제 원인은 서버 처리 지연, 큐잉, 커넥션/리졸브 지연, 로드밸런서/프록시 타임아웃, 다운스트림 호출 지연 등 여러 층에 걸쳐 나타납니다.

이 글에서는 Go gRPC에서 코드 4가 발생하는 메커니즘을 짚고, 원인을 빠르게 좁히는 관찰 포인트와 코드 레벨/운영 레벨 해결책을 함께 정리합니다.

DeadlineExceeded(코드 4)란 무엇인가

gRPC에서 코드 4 DeadlineExceeded는 “클라이언트가 설정한 데드라인(또는 타임아웃)까지 응답을 받지 못했다”는 의미입니다. Go에서는 대개 context.WithTimeout 또는 context.WithDeadline으로 설정한 컨텍스트가 만료되면서 발생합니다.

중요한 점은 다음 두 가지입니다.

  • 클라이언트가 먼저 포기한 것일 수 있습니다. 서버는 실제로 처리 중이거나 심지어 처리를 끝냈을 수도 있습니다.
  • 서버의 own timeout(예: 서버 내부에서 만든 컨텍스트) 때문에 실패해도, 바깥에서 보면 비슷한 형태로 보일 수 있습니다.

따라서 “어디에서 데드라인이 걸렸는지”를 먼저 분리해야 합니다.

가장 흔한 원인 8가지

1) 클라이언트 타임아웃이 너무 짧다

가장 흔하지만 가장 위험한 오해가 “일단 타임아웃 늘리면 해결”입니다. 타임아웃이 과도하게 짧으면 정상적인 p95, p99 지연에서도 쉽게 터집니다. 특히 콜드 스타트, 첫 DNS 리졸브, 첫 TLS 핸드셰이크가 포함되면 더 그렇습니다.

체크 포인트

  • 타임아웃이 실제 SLO 대비 너무 짧지 않은가
  • 첫 호출만 유독 느린가(커넥션 생성, 리졸브)

2) 서버 처리 지연(핸들러 내부 연산, DB 쿼리)

서버 핸들러에서 CPU 연산이 길거나, DB 인덱스 미스/락 대기/커넥션 풀 고갈로 지연이 발생하면 데드라인을 넘기기 쉽습니다.

체크 포인트

  • 특정 메서드에서만 집중 발생하는가
  • DB 슬로우 쿼리, 락 대기, 풀 고갈이 있는가

관련해서 DB 레플리카 지연이 폭증하면 읽기 성능과 응답 시간이 함께 무너질 수 있습니다. 아래 글의 진단 포인트도 함께 참고하면 좋습니다.

3) 서버 큐잉(고루틴/스레드/워크큐 포화)

서버가 바쁘면 요청은 도착했지만 실제 핸들러 실행까지 대기 큐가 길어집니다. 이 경우 “핸들러 로직은 빠른데도” 데드라인이 터집니다.

체크 포인트

  • CPU 100% 근접, 런큐 길이 증가
  • Go GOMAXPROCS 설정, GC 압박
  • 서버 측 동시성 제한(세마포어, 워커풀)이 병목

4) 다운스트림 호출이 느리다(연쇄 타임아웃)

gRPC 핸들러가 다른 서비스나 외부 API를 호출하고, 그 다운스트림이 느리면 상위 요청의 데드라인이 함께 터집니다.

체크 포인트

  • 상위 컨텍스트 데드라인을 다운스트림에 그대로 전달하는가
  • 다운스트림별 타임아웃/재시도/서킷브레이커 정책이 있는가

5) 커넥션/리졸브/TLS 핸드셰이크 지연

특히 다음 상황에서 첫 요청이 느려지기 쉽습니다.

  • DNS 리졸브 지연
  • L4/L7 로드밸런서 뒤에서 신규 커넥션 생성이 많음
  • mTLS 사용 시 핸드셰이크 비용

체크 포인트

  • keepalive 설정이 적절한가
  • 커넥션 재사용이 잘 되는가

6) 로드밸런서/프록시 타임아웃과 불일치

Envoy, Nginx, ALB/NLB, Ingress 컨트롤러 등 중간 프록시가 더 짧은 타임아웃을 갖고 있으면, 서버가 응답을 보내기 전에 중간에서 끊어버립니다. 이때 클라이언트는 DeadlineExceeded 또는 Unavailable 등으로 보일 수 있습니다(환경에 따라 다름).

체크 포인트

  • Ingress/Proxy timeout과 gRPC deadline의 정렬
  • 중간 장비 로그에서 upstream timeout류 메시지

7) 재시도 폭풍(retry storm)으로 인한 자기증폭

클라이언트가 짧은 데드라인 + 공격적인 재시도를 쓰면, 서버 부하가 급증해 지연이 더 커지고, 다시 타임아웃이 늘어나는 악순환이 생깁니다.

체크 포인트

  • 재시도 횟수/백오프/지터가 있는가
  • idempotent가 아닌 호출에 재시도를 걸고 있지 않은가

8) Pod/노드/런타임 이슈(리소스 제한, OOM, 재시작)

Kubernetes 환경이라면 CPU throttling, 메모리 압박, 빈번한 재시작이 지연과 타임아웃을 유발합니다. 서비스가 재시작 루프에 빠지면, 클라이언트는 연결은 되지만 응답이 늦거나 실패하는 구간이 늘어납니다.

(컨테이너 환경에서도 “재시작 루프가 지연을 만든다”는 관점은 동일합니다.)

재현과 분리: “클라이언트가 포기” vs “서버가 늦음”

가장 먼저 할 일은 관찰 가능성을 확보하는 것입니다.

1) 클라이언트에서 데드라인과 경과 시간을 로깅

다음은 Go gRPC 클라이언트에서 데드라인과 실제 소요 시간을 남기는 예시입니다.

ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel()

start := time.Now()
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "42"})
elapsed := time.Since(start)

deadline, ok := ctx.Deadline()
if ok {
    log.Printf("deadline=%s elapsed=%s", deadline.Format(time.RFC3339Nano), elapsed)
} else {
    log.Printf("no-deadline elapsed=%s", elapsed)
}

if err != nil {
    st, _ := status.FromError(err)
    log.Printf("grpc code=%s msg=%s", st.Code(), st.Message())
    return
}
_ = resp

이 로그만 있어도 “항상 800ms 근처에서 끊기는지”, “특정 구간에서만 길어지는지”를 빠르게 볼 수 있습니다.

2) 서버에서 컨텍스트 만료 감지 및 단계별 타이밍

서버 핸들러에서는 단계별로 시간을 찍고, 컨텍스트가 이미 만료되었는지도 확인합니다.

func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
    start := time.Now()
    defer func() {
        log.Printf("GetUser total=%s", time.Since(start))
    }()

    if err := ctx.Err(); err != nil {
        // 핸들러 진입 시점부터 이미 클라이언트가 포기했을 수도 있음
        log.Printf("context already done: %v", err)
        return nil, status.Error(codes.DeadlineExceeded, "client deadline already exceeded")
    }

    t1 := time.Now()
    user, err := s.repo.FindUser(ctx, req.Id)
    log.Printf("db elapsed=%s", time.Since(t1))
    if err != nil {
        return nil, status.Error(codes.Internal, err.Error())
    }

    return &pb.GetUserResponse{Id: user.ID, Name: user.Name}, nil
}

핵심은 “서버 총 시간”, “DB/외부호출 시간”을 분리해 기록하는 것입니다.

해결 전략: 타임아웃을 늘리기 전에 할 것들

1) 타임아웃 예산을 계층적으로 설계

상위 요청의 데드라인을 그대로 모든 다운스트림에 넘기면, 하위 호출이 예산을 다 써버려 상위가 실패합니다. 반대로 하위에 너무 짧게 주면 하위가 자주 실패합니다.

권장 패턴

  • 상위: 사용자 체감 SLO 기반(예: 1.5s)
  • 하위(DB): 상위의 일부만(예: 300ms~800ms)
  • 하위(외부 API): 더 짧게 + 재시도는 제한적으로

예시로, 상위 데드라인에서 안전 마진을 빼고 하위 컨텍스트를 만드는 방식이 실무에서 자주 쓰입니다.

func withChildTimeout(ctx context.Context, max time.Duration, safety time.Duration) (context.Context, context.CancelFunc) {
    if dl, ok := ctx.Deadline(); ok {
        remain := time.Until(dl) - safety
        if remain <= 0 {
            // 이미 예산 소진
            return context.WithTimeout(ctx, 1*time.Millisecond)
        }
        if remain < max {
            return context.WithTimeout(ctx, remain)
        }
    }
    return context.WithTimeout(ctx, max)
}

// 사용 예
childCtx, cancel := withChildTimeout(ctx, 500*time.Millisecond, 50*time.Millisecond)
defer cancel()

2) 재시도 정책을 보수적으로, 그리고 지터를 넣기

gRPC 재시도는 강력하지만, 잘못 쓰면 장애를 증폭합니다.

  • 재시도는 idempotent 호출에만
  • exponential backoff + jitter
  • 데드라인 내에서만 재시도

클라이언트에서 무작정 재시도를 켜기보다, 서버가 과부하일 때는 빠르게 실패시키는(예: ResourceExhausted) 전략도 고려합니다.

3) 서버 동시성/큐잉 병목 제거

다음 항목은 DeadlineExceeded의 “진짜 원인”인 경우가 많습니다.

  • DB 커넥션 풀 크기, 쿼리 최적화, 인덱스
  • 고루틴 폭증 방지(세마포어로 제한하되, 제한 때문에 큐잉이 길어지지 않게 설계)
  • CPU throttling이 있다면 requests/limits 재조정

간단한 동시성 제한 예시(과도한 병렬 DB 호출 방지)

type Server struct {
    repo *Repo
    sem  chan struct{}
}

func NewServer(repo *Repo, maxConcurrent int) *Server {
    return &Server{repo: repo, sem: make(chan struct{}, maxConcurrent)}
}

func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
    select {
    case s.sem <- struct{}{}:
        defer func() { <-s.sem }()
    case <-ctx.Done():
        return nil, status.Error(codes.DeadlineExceeded, "queue timeout")
    }

    user, err := s.repo.FindUser(ctx, req.Id)
    if err != nil {
        return nil, status.Error(codes.Internal, err.Error())
    }
    return &pb.GetUserResponse{Id: user.ID, Name: user.Name}, nil
}

포인트는 “제한 자체”가 아니라 “제한으로 인해 대기하다가 데드라인이 터지는지”를 관찰하는 것입니다.

4) Keepalive 및 커넥션 재사용 점검

커넥션을 자주 새로 만들면 리졸브/TLS 비용이 매 요청에 섞입니다. 서버/클라이언트 양쪽에서 keepalive를 적절히 설정하고, 로드밸런서가 유휴 커넥션을 어떻게 처리하는지 확인하세요.

Go gRPC keepalive 예시(서버)

ka := keepalive.ServerParameters{
    MaxConnectionIdle: 5 * time.Minute,
    Time:              2 * time.Hour,
    Timeout:           20 * time.Second,
}

grpcServer := grpc.NewServer(grpc.KeepaliveParams(ka))

환경에 따라 값은 달라지며, 중간 프록시 정책과 충돌하지 않게 맞추는 것이 중요합니다.

5) 관측 도구: 인터셉터로 지연과 코드 수집

서비스 전반에 분산된 로그만으로는 패턴이 잘 안 보입니다. unary interceptor로 코드/지연을 표준화해 수집하면, 어떤 메서드가 p99에서 데드라인을 터뜨리는지 한눈에 보입니다.

func unaryServerInterceptor() grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
        start := time.Now()
        resp, err := handler(ctx, req)
        elapsed := time.Since(start)

        code := codes.OK
        if err != nil {
            if st, ok := status.FromError(err); ok {
                code = st.Code()
            } else {
                code = codes.Unknown
            }
        }

        // 실제로는 Prometheus histogram + counter로 보내는 것을 권장
        log.Printf("method=%s code=%s elapsed=%s", info.FullMethod, code.String(), elapsed)
        return resp, err
    }
}

이 데이터가 쌓이면 “타임아웃을 늘릴지”가 아니라 “어느 구간을 줄일지”가 보이기 시작합니다.

운영 체크리스트(빠른 원인 좁히기)

1) 어디서 터지나

  • 특정 메서드만? 전체 메서드?
  • 특정 AZ/노드/파드만?
  • 배포 직후만? 트래픽 피크에만?

2) 지연의 성격

  • p50은 정상인데 p99만 튀는가(큐잉/락/GC/네트워크)
  • 첫 요청만 느린가(리졸브/핸드셰이크)

3) 인프라 타임아웃 정렬

  • Ingress/Proxy의 upstream timeout
  • 클라이언트 deadline
  • 서버 내부 다운스트림 timeout

4) 리소스/재시작

  • CPU throttling, 메모리 압박, OOMKilled
  • readiness/liveness로 트래픽이 불안정하게 흔들리지 않는가

Kubernetes에서 리소스/상태 문제는 애플리케이션 에러처럼 보이기 쉽습니다. 인증/권한 문제로 인해 이미지 풀 자체가 실패하는 케이스도 “서비스가 없다”로 이어져 타임아웃이 증가할 수 있으니, 배포 레벨 이슈도 함께 점검하세요.

마무리: 코드 4는 증상, 해결은 병목 제거

DeadlineExceeded는 “느렸다”는 신호일 뿐, 근본 원인은 보통 다음 중 하나로 수렴합니다.

  • 타임아웃 예산 설계가 잘못됨(상위/하위 불일치)
  • 서버 큐잉 또는 다운스트림 지연으로 p99가 무너짐
  • 프록시/로드밸런서 타임아웃과 keepalive가 맞지 않음
  • 재시도 정책이 장애를 증폭함

권장 접근은 단순합니다.

  1. 인터셉터로 메서드별 지연과 코드 분포를 수집하고
  2. 단계별 타이밍으로 병목 구간을 분리한 뒤
  3. 타임아웃 예산을 계층적으로 재설계하고
  4. 큐잉/DB/다운스트림/인프라 타임아웃을 정렬합니다.

이 순서로 접근하면 “타임아웃 늘리기” 같은 임시방편이 아니라, 재발률을 실제로 낮추는 방향으로 문제를 닫을 수 있습니다.