TT Lab
요금제

한국어

시작하기
블로그

블로그

grpc-go 코드 지도: ClientConn 에서 http2Client 까지, Serve 에서 핸들러까지

상태: 초안 (2026-10-17), 코드 읽기. 기준 판 grpc/grpc-go v1.83.2(커밋 030ee8becb20, 로컬 Go 모듈 캐시에서 읽음). 최신 릴리스는 v1.84.0 이라 줄 번호가 조금 다를 수 있습니다. 본문의 파일:줄 은 v1.83.2 의 함수 선언 줄입니다. 이 편의 새 실측은 없고, 6편 수치와 대조만 합니다.

목차

1. 한 줄 요약: 회사 조직도

채널은 팀장입니다. 일(호출)을 받으면 직접 하지 않고, 주소록 담당(리졸버)이 준 명단을 배정 담당(밸런서)에게 넘기고, 배정 담당은 직원(서브채널)을 뽑아 두었다가 일이 올 때마다 그날의 배정표(픽커)로 한 명을 고릅니다. 직원은 자기 전화선(HTTP/2 연결) 하나를 갖고 있습니다. 배정표는 통째로 갈아 끼우는 종이라서, 일을 고르는 순간에는 누구와도 잠금을 다투지 않습니다.

2. 구조도

grpc-go 1.83 의 클라이언트·서버 계층 클라이언트 ClientConn clientconn.go · NewClient ccResolverWrapper → resolver resolver_wrapper.go · dns/passthrough/xds ccBalancerWrapper → balancer balancer_wrapper.go · pick_first/round_robin pickerWrapper.pick picker_wrapper.go · 호출마다 서브채널 선택 addrConn (SubConn) clientconn.go · 연결·재연결·백오프 http2Client internal/transport/http2_client.go 호출 경로: Invoke → newClientStream → pick → NewStream 서버 Server.Serve → handleRawConn server.go · 연결마다 고루틴 http2Server.operateHeaders http2_server.go · :path 해석, 스트림 생성 handleStream → processUnaryRPC server.go · 서비스 맵에서 핸들러 찾기 공용: rpc_util.go(5바이트 접두), codes·status, keepalive
그림: grpc-go 1.83 클라이언트·서버 계층과 파일

3. 코드 지도

경로 무엇 처음 읽을 곳
clientconn.go 채널, 서브채널(addrConn), 유휴 모드, 재시도 조절 NewClient(183), addrConn(1240)
call.go, stream.go 단항·스트림 호출, 재시도 시도(csAttempt) invoke(65), newClientStream(213)
resolver_wrapper.go, resolver/, internal/resolver/ 리졸버 포장과 dns·passthrough·unix 구현 ccResolverWrapper.UpdateState(129)
balancer_wrapper.go, balancer/ 밸런서 포장, pick_first·round_robin·ringhash·weightedroundrobin 등 ccBalancerWrapper.NewSubConn(180)
picker_wrapper.go 호출마다 서브채널 고르기, 대기 pickerWrapper.pick(105)
internal/transport/ HTTP/2 클라이언트·서버, 흐름 제어, keepalive, 제어 버퍼 http2_client.go, http2_server.go
rpc_util.go 5바이트 접두, 압축, 수신 상한, 호출 옵션 msgHeader(879), parser.recvMsg(785)
server.go 서버, 서비스 등록, 디스패치 Serve(888), handleStream(1800)
codes/, status/ 상태 코드와 상태 객체 codes.go
health/, reflection/, channelz/, admin/ 부가 서비스(5·8편) health/server.go
xds/, internal/xds/ xDS 리졸버·밸런서(5편) 규모가 커서 나중에

4. 핵심 경로 따라가기

4.1 채널 만들기: 아직 아무것도 연결하지 않는다

NewClient 는 옵션을 적용하고 대상 문자열을 해석해 리졸버 빌더를 고른 뒤 (initParsedTargetAndResolverBuilder, 기본 스킴 dns), 유휴 상태를 준비하고 끝납니다. 유휴 상태 준비(initIdleStateLocked)가 리졸버 포장 (newCCResolverWrapper)과 밸런서 포장(newCCBalancerWrapper)을 만들어 두지만, 리졸버는 아직 돌지 않습니다. 첫 호출이 오면 exitIdleMode 가 채널을 CONNECTING 으로 바꾸고 리졸버를 시작합니다. 30분 동안 호출이 없으면 enterIdleMode 가 리졸버·밸런서와 연결을 걷어 내고 다시 유휴로 돌아갑니다.

4.2 주소가 들어오면: 리졸버 → 밸런서 → 서브채널 → 픽커

  1. 리졸버가 주소를 찾으면 ccResolverWrapper.UpdateState 를 부르고, 채널의 updateResolverStateAndUnlock 이 서비스 설정을 적용한 뒤 (applyServiceConfigAndBalancer) 밸런서에 넘깁니다 (updateClientConnState).
  2. 밸런서(예: round_robin)는 주소마다 NewSubConn 을 부르고, 이것이 newAddrConnLocked 로 addrConn 을 만듭니다. 이 addrConn 이 서브채널의 실체입니다.
  3. addrConn 은 resetTransportAndUnlock → tryAllAddrs → createTransport 순서로 연결하고, 실패하면 백오프 뒤 다시 시도합니다. 실패하거나 연결이 끊기면 리졸버에 ResolveNow 를 요청합니다(5편 4.1절).
  4. 서브채널 상태가 바뀔 때마다 밸런서는 새 픽커를 만들어 ccBalancerWrapper.UpdateState 로 올리고, pickerWrapper.updatePicker 가 픽커를 원자적으로 갈아 끼웁니다. 기다리던 호출들은 이전 픽커에 딸린 채널이 닫히는 것으로 깨어납니다.

왜 이렇게 나눴을까요. 주소를 찾는 일(DNS·xDS·쿠버네티스 API 등)과 고르는 일(첫 번째·돌아가며·가중치·해시)을 서로 모르게 해서 아무 리졸버와 아무 밸런서를 조합할 수 있게 하려는 것입니다. 밸런서 쪽 갱신은 직렬 실행기(grpcsync.CallbackSerializer, balancer_wrapper.go 의 주석)로 차례대로 처리하고, 호출이 지나는 뜨거운 길(pick)은 원자 값 하나만 읽게 해서 두 세계가 서로의 잠금을 기다리지 않게 합니다.

4.3 호출 하나: Invoke 에서 스트림까지

  1. 생성된 스텁이 ClientConn.Invoke 를 부르고, 인터셉터를 거쳐 invoke 가 newClientStream → newClientStreamWithParams 로 갑니다. 단항 호출도 내부적으로는 「메시지 하나 보내고 하나 받는 스트림」입니다.
  2. 시도 하나를 만들고(newAttemptLocked), getTransport 가 픽커에서 서브채널을 고릅니다. 고른 서브채널의 전송에 csAttempt.newStream → http2Client.NewStream 으로 HEADERS 를 보냅니다(3편의 createHeaderFields).
  3. SendMsg 가 직렬화·압축·5바이트 접두를 붙여 csAttempt.sendMsg 로 쓰고, RecvMsg → recv → parser.recvMsg 로 응답을 읽습니다. 실패하면 withRetry 가 4편의 shouldRetry 를 보고 새 시도를 만들고, 버퍼에 쌓아 둔 보낸 메시지를 다시 재생합니다.

4.4 픽커: 기다릴까, 바로 실패할까

호출이 왔는데 READY 인 서브채널이 없으면 어떻게 될까요. pickerWrapper.pick 의 한 분기가 답입니다.

pickResult, err := p.Pick(info)
if err != nil {
	if err == balancer.ErrNoSubConnAvailable {
		continue
	}
	if st, ok := status.FromError(err); ok {
		...
		return pick{}, dropError{error: err}
	}
	// For all other errors, wait for ready RPCs should block and other
	// RPCs should fail with unavailable.
	if !failfast {
		lastPickErr = err
		continue
	}
	return pick{}, status.Error(codes.Unavailable, err.Error())
}

출처: grpc/grpc-go v1.83.2 picker_wrapper.go 157~176행 발췌, Apache-2.0

운영에서 이 차이는 이렇게 보입니다. 서버를 재시작하는 몇 초 동안 fail fast 클라이언트는 UNAVAILABLE 을 쏟아 내고(6편 Collector 의 10-13 기록처럼), wait-for-ready 클라이언트는 조용히 기다렸다가 deadline 안에 서버가 돌아오면 성공합니다.

4.5 서버: Serve 에서 핸들러까지

  1. Server.Serve 가 Accept 할 때마다 handleRawConn 을 고루틴으로 띄웁니다.
  2. 자격 증명 핸드셰이크 뒤 NewServerTransport 가 HTTP/2 설정을 교환하고, serveStreams 가 프레임을 읽습니다. 동시 스트림 수 기본값은 무제한(math.MaxUint32, server.go:189)입니다.
  3. HEADERS 가 오면 operateHeaders 가 :path, content-type, grpc-timeout 을 읽어 스트림을 만들고(잘못된 content-type 은 415), handleStream 이 경로를 서비스 이름과 메서드 이름으로 나눠 RegisterService 로 등록된 표(1편의 ServiceDesc)에서 찾습니다. 없으면 UNIMPLEMENTED.
  4. 단항이면 processUnaryRPC, 스트림이면 processStreamingRPC 가 메시지를 읽고 인터셉터와 핸들러를 부른 뒤, writeStatus 로 트레일러를 씁니다.
  5. GracefulStop 은 GOAWAY 를 보내 새 스트림을 막고 진행 중인 것이 끝나길 기다립니다. 5편의 MaxConnectionAge 도 같은 GOAWAY 를 씁니다.

5. 6편 실측과의 대조

6편에서 잰 것 이 지도에서
apiserver → etcd TCP 247개 etcd 클라이언트 247개 = ClientConn 247개, 각자 READY addrConn 하나(대상이 127.0.0.1 하나라서)
노드 A 의 열린 Watch 스트림 242 각 ClientConn 의 http2Client 위 장수 스트림 하나
kubelet → containerd 채널 2개 런타임·이미지 ClientConn 둘, passthrough 가 아닌 유닉스 주소
hubble-relay 의 노드 8개 연결 노드마다 ClientConn(릴레이의 피어 풀이 관리, 추정)
kubelet gRPC 프로브 프로브마다 ClientConn 생성·종료(5편 6절)

같은 grpc-go 라도 채널을 몇 개 만들어 어떻게 쓰느냐는 쓰는 쪽 코드가 정하고, 그 선택이 연결 수·메모리·부하 분산을 정합니다.

6. 어디부터 읽을까

  1. Documentation/ 의 keepalive.md·concurrency.md·anti-patterns.md 를 먼저 읽습니다. anti-patterns.md 는 옛 Dial·WithBlock 을 왜 피하라는지 설명합니다.
  2. call.go → stream.go 의 newClientStreamWithParams·csAttempt 로 호출 하나를 따라갑니다.
  3. picker_wrapper.go → balancer_wrapper.go → balancer/roundrobin 으로 고르는 쪽을, resolver_wrapper.go → internal/resolver/dns 로 찾는 쪽을 봅니다.
  4. internal/transport/http2_client.go 의 NewStream·reader·keepalive, controlbuf.go 의 쓰기 루프로 내려갑니다.
  5. 서버는 server.go 의 handleStream → processUnaryRPC 만 읽어도 디스패치 구조가 보입니다.

7. 첫 기여하기 좋은 곳

8. 한계

티티 한마디. 배정표(픽커)를 통째로 바꿔 끼우니까, 손님이 몰려도 배정 담당과 줄 서서 싸울 일이 없어요. 대신 직원이 다 아프면 기다릴지(WaitForReady) 돌려보낼지(fail fast)는 손님이 정해요!

참고

모든 자료는 2026-10-17 에 열람했습니다.

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다