- Published on
Go gRPC 데드라인 초과 원인 7가지와 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
gRPC를 운영에 올리면 가장 흔하게 마주치는 에러 중 하나가 DeadlineExceeded입니다. 겉으로는 “시간이 초과됐다” 한 줄이지만, 실제 원인은 네트워크부터 서버 리소스, HTTP/2 흐름 제어, 커넥션 재사용, 로드밸런서 설정, 데이터베이스 지연까지 다양합니다.
이 글은 Go 기반 gRPC 클라이언트·서버에서 데드라인 초과가 발생하는 대표 원인 7가지를 재현 포인트, 확인 방법, 해결책으로 나눠 정리합니다. 특히 “가끔만 터지는” 타임아웃을 줄이려면 관측(메트릭·로그·트레이싱)과 데드라인 전파가 핵심입니다.
먼저 확인: 데드라인 초과의 정확한 의미
DeadlineExceeded는 보통 아래 중 하나입니다.
- 클라이언트 컨텍스트의 데드라인이 먼저 만료됨
- 서버가 응답을 만들었지만 네트워크/전송 계층에서 완료되기 전에 데드라인 만료
- 서버가 데드라인을 인지하지 못하고 계속 처리하다가 클라이언트가 취소함
즉 “서버가 느리다”만이 아니라, 클라이언트 설정/네트워크/프록시/HTTP/2 상태까지 포함한 종합 증상입니다.
아래 코드는 클라이언트 데드라인을 명시하고, 에러를 상태 코드로 분해하는 기본 패턴입니다.
ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel()
resp, err := c.GetUser(ctx, &pb.GetUserRequest{Id: "42"})
if err != nil {
st, ok := status.FromError(err)
if ok {
// st.Code() == codes.DeadlineExceeded 인지 확인
log.Printf("grpc error code=%s msg=%s", st.Code(), st.Message())
}
return
}
_ = resp
원인 1) 데드라인 전파 누락 또는 계층별 타임아웃 불일치
증상
- 외부 API 호출은 300ms 제한인데, 내부 gRPC는 무제한 혹은 5초로 설정
- 서버는 계속 일을 하다가 클라이언트가 먼저 끊음
- 로그에는 서버 처리 시간이 길게 남고, 클라이언트는
DeadlineExceeded
확인 방법
- 서버 핸들러에서
ctx.Deadline()를 로깅 - downstream 호출(다른 gRPC/DB/HTTP)에 같은
ctx를 전달했는지 점검
해결
- “최상위 요청”에서 받은 컨텍스트를 끝까지 전달
- 계층별 타임아웃을 정렬: 예) 전체 800ms, 내부 RPC 600ms, DB 450ms
func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
// downstream도 동일 ctx 사용
user, err := s.repo.LoadUser(ctx, req.Id)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.GetUserResponse{User: user}, nil
}
func (r *Repo) LoadUser(ctx context.Context, id string) (*pb.User, error) {
// DB도 ctx 사용 (예: database/sql)
row := r.db.QueryRowContext(ctx, "select ... where id=$1", id)
// ...
return &pb.User{Id: id}, nil
}
원인 2) 커넥션 재사용 실패로 매 요청마다 핸드셰이크/리졸브 비용 발생
증상
- 트래픽이 늘면 갑자기 타임아웃 증가
- p95/p99가 튀고, 특히 첫 요청이 느림
- 클라이언트가
grpc.Dial을 요청마다 수행
확인 방법
- 코드에서
grpc.Dial이 핫패스에 있는지 검색 - 커넥션 수가 비정상적으로 많아지는지(클라이언트/서버 양쪽) 확인
해결
- 프로세스 생명주기 동안
ClientConn을 재사용 - 리졸버/밸런서 설정을 명확히 하고, 불필요한 재연결을 줄임
// 앱 시작 시 1회 생성
conn, err := grpc.NewClient(
"dns:///user-svc.default.svc.cluster.local:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil { panic(err) }
defer conn.Close()
client := pb.NewUserServiceClient(conn)
// 요청마다 conn을 새로 만들지 말 것
원인 3) 서버 처리 지연: DB 락, 느린 쿼리, 외부 API 지연
증상
- 서버 애플리케이션 로그에서 특정 핸들러가 오래 걸림
- 특정 엔드포인트/특정 테넌트에서만 타임아웃
확인 방법
- 서버에 핸들러 레벨 latency 히스토그램 추가
- DB slow query 로그, 락 대기, 커넥션 풀 고갈 여부 확인
해결
- 쿼리/인덱스 최적화, 락 범위 축소
- 풀 사이즈 및 타임아웃 조정
- 캐시 도입 또는 읽기 전용 리플리카 분리
DB가 원인인 경우, VACUUM/블로트로 인한 지연도 흔합니다. PostgreSQL을 쓴다면 아래 글도 함께 보면 진단 속도가 빨라집니다.
원인 4) 리소스 병목: CPU 스로틀링, GC 압박, 메모리 부족, OOM 전조
증상
- 노드/파드 CPU가 꽉 차거나 스로틀링 발생
- GC 시간이 길어지고 stop-the-world가 늘어 p99가 튐
- 컨테이너 메모리 압박으로 응답 지연 후 타임아웃
확인 방법
container_cpu_cfs_throttled_seconds_total,go_gc_duration_seconds같은 메트릭- 파드 이벤트에 OOMKilled/재시작 징후
해결
- CPU/메모리 requests·limits 재조정, 오토스케일 설정
- 큰 객체 할당 줄이기, 스트리밍 처리로 메모리 피크 낮추기
- 고비용 작업을 비동기로 분리
Kubernetes 환경에서 메모리로 인한 지연과 OOM은 타임아웃의 “전조 증상”으로 자주 나타납니다.
원인 5) HTTP/2 레벨 문제: 흐름 제어, 스트림 에러, 프록시 호환성
증상
- 서버는 빨리 처리했는데 클라이언트는 타임아웃
- 특정 환경(특정 LB/Ingress)에서만 재현
- 간헐적으로
RST_STREAM류 에러가 섞여 나옴
확인 방법
- gRPC debug 로그(필요 시)로 transport 레벨 이벤트 확인
- Ingress/LB가 HTTP/2를 제대로 프록시하는지, idle timeout이 짧지 않은지 점검
해결
- 중간 프록시가 gRPC를 “HTTP/2 end-to-end”로 지원하는지 확인
- keepalive, max connection age, idle timeout을 인프라와 맞추기
- HTTP/2 스트림 에러 원인을 별도로 제거
HTTP/2 스트림 계층 이슈는 DeadlineExceeded와 함께 나타나는 경우가 많습니다. 아래 글의 체크리스트가 도움이 됩니다.
원인 6) 메시지 크기/직렬화 비용: 큰 payload, 압축, 역직렬화 과부하
증상
- 특정 요청(대용량 응답/파일 메타데이터 등)에서만 타임아웃
- CPU는 낮지 않은데 네트워크 전송이 길어짐
- protobuf marshal/unmarshal 시간이 눈에 띄게 큼
확인 방법
- 요청/응답 바이트 크기 로그(샘플링)
- pprof로
proto.Marshal,proto.Unmarshal비중 확인
해결
- 응답을 쪼개서 paging 또는 server streaming으로 전환
- 불필요한 필드 제거, 반복 필드 크기 제한
- 압축은 만능이 아님: CPU가 병목이면 오히려 느려질 수 있음
예: 대용량 리스트를 unary로 한 번에 보내지 말고 스트리밍으로 전환합니다.
// proto: rpc ListUsers(ListUsersRequest) returns (stream User);
func (s *Server) ListUsers(req *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {
ctx := stream.Context()
it := s.repo.IterUsers(ctx, req.PageSize)
for it.Next() {
if err := stream.Send(it.User()); err != nil {
return err
}
}
return it.Err()
}
원인 7) 재시도/헤지 요청의 역효과: 타임아웃을 더 악화시키는 폭주
증상
- 타임아웃이 늘자 재시도를 넣었더니 더 심해짐
- 서버 QPS가 급증하고 tail latency가 폭발
- 일부 요청은 성공하지만 전체적으로 불안정
확인 방법
- 클라이언트 재시도 정책 여부 확인(라이브러리/서비스 메쉬 포함)
- 실패 시도 횟수와 서버 부하의 상관관계 확인
해결
- 무조건 재시도 금지:
DeadlineExceeded는 이미 “시간이 없다”는 신호일 수 있음 - 재시도는 짧은 지터 백오프, 상한, idempotent 요청에만 제한
- 서버가 과부하일 때는 빠르게 실패하도록
codes.ResourceExhausted등으로 보호
간단한 클라이언트 측 보호 패턴은 “남은 데드라인이 충분할 때만 재시도”입니다.
func shouldRetry(ctx context.Context, err error) bool {
st, ok := status.FromError(err)
if !ok { return false }
if st.Code() != codes.Unavailable { // 보수적으로 제한
return false
}
dl, ok := ctx.Deadline()
if !ok { return false }
return time.Until(dl) > 200*time.Millisecond
}
운영에서 바로 쓰는 진단 체크리스트
1) 클라이언트
WithTimeout값이 현실적인지(네트워크+서버 처리+여유)ClientConn재사용하는지- 재시도 정책이 폭주를 만들지
2) 서버
- 핸들러가
ctx취소를 존중하는지 - DB/외부 API 호출에 같은
ctx를 전달하는지 - pprof와 메트릭으로 CPU/GC/락/풀 고갈을 확인했는지
3) 인프라
- LB/Ingress의 HTTP/2 및 gRPC 지원이 확실한지
- idle timeout, keepalive 정책이 서로 충돌하지 않는지
Go에서 관측 가능하게 만들기: 인터셉터로 데드라인과 지연 로그
문제가 “간헐적”일수록, 재현보다 관측이 빠릅니다. unary 서버 인터셉터로 처리 시간과 데드라인 정보를 남기면 원인 분리가 쉬워집니다.
func LoggingUnaryServerInterceptor() grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
start := time.Now()
var deadlineStr string
if dl, ok := ctx.Deadline(); ok {
deadlineStr = time.Until(dl).String()
} else {
deadlineStr = "none"
}
resp, err := handler(ctx, req)
dur := time.Since(start)
st, _ := status.FromError(err)
log.Printf("method=%s dur=%s deadline_in=%s code=%s", info.FullMethod, dur, deadlineStr, st.Code())
return resp, err
}
}
이 로그만으로도 아래를 빠르게 구분할 수 있습니다.
deadline_in이 원래부터 너무 짧았는지- 서버 처리 시간
dur이 데드라인을 초과했는지 - 특정 메서드에만 문제가 집중되는지
마무리: DeadlineExceeded는 “시간”이 아니라 “설계” 문제일 때가 많다
Go gRPC에서 데드라인 초과를 줄이려면 단순히 타임아웃을 늘리는 것보다,
- 데드라인 전파와 계층별 타임아웃 정렬
- 커넥션 재사용과 재시도 정책 절제
- 서버 리소스/DB 병목 제거
- HTTP/2 및 프록시 설정 정합성 확보
이 네 가지를 우선순위로 잡는 편이 재발 방지에 훨씬 효과적입니다.
운영 중 DeadlineExceeded와 함께 HTTP/2 스트림 계층 에러가 섞여 보인다면, 원인을 같은 선상에서 보지 말고 전송 계층 체크리스트도 병행하세요.