From f2a16113906ef0ede2fe0aeb0a1e1418e84ee8e8 Mon Sep 17 00:00:00 2001 From: Godopu Date: Mon, 6 Jul 2026 11:55:11 +0900 Subject: [PATCH] docs: redesign T1 client creation with transport selector pattern, remove grpc.ClientConn from QUIC path --- gRPC_Based_Interface/IMPL_DESIGN.md | 209 ++++++++++++++++++++-------- 1 file changed, 150 insertions(+), 59 deletions(-) diff --git a/gRPC_Based_Interface/IMPL_DESIGN.md b/gRPC_Based_Interface/IMPL_DESIGN.md index 0830789..1a99b0d 100644 --- a/gRPC_Based_Interface/IMPL_DESIGN.md +++ b/gRPC_Based_Interface/IMPL_DESIGN.md @@ -39,7 +39,7 @@ - `net/http` 인터페이스 호환 → HTTP/3 서버를 표준 핸들러로 운영 가능 - Connection migration 내장: 핸드오버(IP 변경) 시 세션 유지 - QUIC Datagram 확장(RFC 9221) 지원 → 저지연 V2X 메시지에 활용 가능 -- gRPC-Go 트랜스포트 레이어 교체 방식으로 통합 (`WithContextDialer` + `quic.DialAddr`) +- TransportSelector 기반의 사전 프로브 및 분기 방식으로 통합 (UDP 성공 시 connect-go/HTTP3 RoundTripper, 실패 시 grpc-go/HTTP2 폴백) #### gRPC-Go (`google.golang.org/grpc`) - 공식 Google Go gRPC 구현 — Interceptor Chain, Health Checking, Service Config(retry policy) 내장 @@ -554,16 +554,19 @@ var defaultDeadlines = map[string]int64{ T1 클라우드와 T2 엣지 게이트웨이 간 무선 링크의 신뢰성을 보장하기 위해, 기본 UDP/QUIC 연결 수립 실패 시 TCP/HTTP/2 경로로 자동 전환하는 전송 폴백 메커니즘을 구현한다. 본 구조는 포트나 전송 프로토콜이 상이하여 단일 핸드셰이크 내 ALPN 강등 협상이 불가하므로, 클라이언트 단의 다이얼러에서 명시적인 연결 실패 감지 후 폴백을 수행한다. -##### 1. 클라이언트 측 전송 어댑터 및 폴백 다이얼러 (`internal/gateway/fallback_dialer.go`) +##### 1. 클라이언트 측 전송 어댑터 및 트랜스포트 셀렉터 (`internal/gateway/fallback_dialer.go`) 공식 gRPC-Go의 단일 커넥션 다이얼러 방식은 하나의 QUIC 스트림으로 HTTP/2 전체 프레임을 터널링할 경우 QUIC 세션 내부에서 TCP 수준의 HOLB를 그대로 재현하는 설계 결함(naive 구현)을 갖는다. 이를 방지하기 위해 각 RPC 호출을 독립된 QUIC 스트림에 1:1로 매핑하는 구조를 채택하며, 다음의 두 가지 대안을 명세한다: - **(a) 권장 구현 — HTTP/3 의미론 기반 매핑 (connect-go 활용)**: - `quic-go/http3` 패키지의 `http3.RoundTripper`를 기반으로 동작하는 HTTP/3 클라이언트를 활용하여 gRPC와 와이어 프로토콜 수준에서 호환되도록 구성한다. 이 방식은 HTTP/3 명세에 따라 **개별 RPC 호출(Request/Response)이 네트워크 수준의 독립된 QUIC 스트림으로 자동 매핑**되므로 패킷 유실 시에도 다른 RPC 스트림이 정체되지 않는다. + `quic-go/http3` 패키지의 `http3.RoundTripper`를 기반으로 동작하는 HTTP/3 클라이언트를 활용하여 gRPC와 와이어 프로토콜 수준에서 호환되도록 구성한다. 이 방식은 HTTP/3 명세에 따라 **개별 RPC 호출(Request/Response)이 네트워크 수준의 독립된 QUIC 스트림으로 자동 매핑**되므로 패킷 유실 시에도 다른 RPC 스트림이 정체되지 않는다. 이 권장 구현의 Go 코드 세부 사양은 부록 [internal/cloud/grpc_quic_client.go](file:///Users/godopu16/PuKi/lab/canary_projects/multi-agent-paper/gRPC_Based_Interface/IMPL_DESIGN.md#L1498)의 `NewT1Client` QUIC 분기 구현(RoundTripper 및 http.Client 연계)을 통해 구체화된다. - **(b) 대안 구현 — per-RPC 커스텀 ClientTransport**: gRPC-Go의 커스텀 `ClientTransport` 인터페이스를 직접 구현하여, 클라이언트가 RPC를 개시할 때마다 `quic.Connection.OpenStreamSync`를 호출하여 새 QUIC 스트림을 동적으로 개방하고, 해당 스트림에 length-prefixed gRPC 바이트 프레임을 직접 매핑하여 송수신한다. 단, 이 대안은 gRPC-Go 내부의 private transport API 변동에 따른 유지보수 리스크가 있음을 명시한다. -아래는 다이얼 실패 감지 및 `tls.Config` 분리 복제(`Clone`)가 적용된 폴백 다이얼러 구현 명세이다: +> [!IMPORTANT] +> **구조적 제약 규정**: QUIC 경로 클라이언트 기동 시, 기존의 naive 터널링 회귀를 원천적으로 방지하기 위해 `grpc.DialContext` 및 `WithContextDialer` API의 사용을 구조적으로 금지한다. QUIC 전송은 오직 `http3.RoundTripper`와 와이어 수준에서 호환되는 HTTP/3 기반 클라이언트(예: `connect-go`)를 통해서만 독립 QUIC 스트림으로 개별 RPC를 매핑해야 한다. + +아래는 다이얼 실패 감지 및 `tls.Config` 분리 복제(`Clone`)가 적용된 트랜스포트 셀렉터 구현 명세이다: ```go package gateway @@ -571,62 +574,53 @@ package gateway import ( "context" "crypto/tls" - "fmt" "net" "time" "github.com/quic-go/quic-go" ) -// FallbackDialer는 QUIC 연결 시도 실패 시 TCP/HTTP2로 전환하는 다이얼러이다. -type FallbackDialer struct { +// TransportKind는 연결에 사용될 최적의 전송 방식을 정의한다. +type TransportKind int + +const ( + TransportQUIC TransportKind = iota // UDP/QUIC (HTTP/3) + TransportH2 // TCP/HTTP2 (폴백) +) + +// TransportSelector는 연결 수립 전 UDP/QUIC의 가용성을 사전에 프로브하는 셀렉터이다. +type TransportSelector struct { quicTLSConfig *tls.Config // QUIC 전용 ALPN (h3) - tcpTLSConfig *tls.Config // TCP 전용 ALPN (h2) timeout time.Duration } -func NewFallbackDialer(baseConfig *tls.Config, timeout time.Duration) *FallbackDialer { +func NewTransportSelector(baseConfig *tls.Config, timeout time.Duration) *TransportSelector { // 호출자 tls.Config의 사이드 이펙트 방지를 위해 복제(Clone) 수행 quicTLS := baseConfig.Clone() quicTLS.NextProtos = []string{"h3"} // QUIC은 h3 프로토콜에 한정 - tcpTLS := baseConfig.Clone() - tcpTLS.NextProtos = []string{"h2"} // TCP/HTTP2는 h2 프로토콜에 한정 - - return &FallbackDialer{ + return &TransportSelector{ quicTLSConfig: quicTLS, - tcpTLSConfig: tcpTLS, timeout: timeout, } } -// DialContext는 UDP/QUIC 다이얼을 시도하고 실패 시 0-RTT 이내에 TCP 기반 HTTP/2 연결로 폴백한다. -func (d *FallbackDialer) DialContext(ctx context.Context, addr string) (net.Conn, error) { - quicCtx, cancel := context.WithTimeout(ctx, d.timeout) +// Probe는 UDP/QUIC 다이얼을 시도하여 가용성을 판정하고, 즉시 연결을 해제한 뒤 적절한 TransportKind를 반환한다. +// (성공 시 커넥션을 즉시 닫고 TransportQUIC 반환. 0-RTT/세션 재개 각주 및 프로브 커넥션 캐싱 변형 병기 가능) +func (s *TransportSelector) Probe(ctx context.Context, addr string) (TransportKind, error) { + probeCtx, cancel := context.WithTimeout(ctx, s.timeout) defer cancel() - // 1. QUIC(HTTP/3) Dial 시도 - // 각 RPC 호출마다 개별 스트림을 매핑하는 connect-go 또는 커스텀 Transport 구조의 세션을 관리 - quicConn, err := quic.DialAddr(quicCtx, addr, d.quicTLSConfig, nil) + // 1. QUIC(HTTP/3) Dial 프로브 시도 + conn, err := quic.DialAddr(probeCtx, addr, s.quicTLSConfig, nil) if err == nil { - // 단일 커넥션 내부 스트림 생성을 래핑하는 커넥션 핸들러 반환 - return &quicSessionWrapper{conn: quicConn}, nil + // 프로브 성공 후 커넥션 종료 (HTTP/3 RoundTripper에서 정식으로 독립 연결을 맺고 세션을 관리하도록 함) + conn.CloseWithError(0, "probe finished") + return TransportQUIC, nil } - // 2. QUIC 다이얼 실패 또는 UDP 블로킹 감지 시 TCP/HTTP/2 Fallback 실행 - dialer := &net.Dialer{Timeout: d.timeout} - tcpConn, err := dialer.DialContext(ctx, "tcp", addr) - if err != nil { - return nil, fmt.Errorf("both QUIC dial and HTTP/2 fallback failed: %w", err) - } - - tlsConn := tls.Client(tcpConn, d.tcpTLSConfig) - if err := tlsConn.HandshakeContext(ctx); err != nil { - tcpConn.Close() - return nil, err - } - - return tlsConn, nil + // 2. 다이얼 실패 시 h2 폴백 지시 + return TransportH2, nil } ``` @@ -690,6 +684,13 @@ func (s *GatewayServer) Start() error { } ``` +##### 2.1. 전송 계층 단일화 대안 및 런타임 강등 정책 + +- **M3-alt 대안: connect-go + http2.Transport 기반 폴백 단일화**: + 기본 구현은 QUIC 경로에는 `connect-go`를, 폴백 TCP 경로에는 표준 `grpc-go`를 분리 적용하여 구현하나, 설계 대안으로 **폴백 TCP 경로마저 connect-go + http2.Transport 스택으로 단일화**하는 방안을 고려할 수 있다. 이 경우 클라이언트 스택은 `http.Client` 하나로 완전히 단일화되어 `connectT1Client` 1종만 구현하면 되며, 서버 측의 단일 공용 HTTP 핸들러 공유 구조와 구조적인 대칭성을 완벽히 만족하게 된다. +- **M4 정책: 런타임 강등 및 복구 정책**: + T1 클라이언트 기동 이후 연속 3회 이상 네트워크 타임아웃 또는 전송 계층 에러가 검출될 경우, 클라이언트는 현재 커넥션을 파기하고 즉시 `TransportSelector.Probe`를 재수수행하여 네트워크 가용성을 다시 판정한 후 T1Client 인스턴스를 재생성한다. 단, 이 과정에서의 기존 실행 중인 RPC 세션 정보 복구 및 상태 동기화의 상세 알고리즘은 향후 연구 범위로 위임한다. + ##### 3. 소거 실험용 비교군 명세 (Naive Single-Stream Tunneling) 스트림 매핑 유무가 HOLB 해소에 미치는 실질적인 영향을 입증하는 소거 실험(Ablation Study) 비교군 구성을 위해, 기존에 논의된 단일 QUIC 스트림 고정 터널링(Naive) 코드를 아래와 같이 별도로 명세한다. 이 명세는 평가 단계(Evaluation)에서 패킷 손실 시 TCP와 동등한 수준의 지연시간 병목이 유발됨을 입증하는 대조군으로 기능한다. @@ -1488,23 +1489,70 @@ func (c *quicStreamConn) SetDeadline(t interface{}) error { return nil } func (c *quicStreamConn) SetReadDeadline(t interface{}) error { return nil } func (c *quicStreamConn) SetWriteDeadline(t interface{}) error { return nil } -// T1Client는 T1 클라우드에서 T2 게이트웨이로 연결하는 gRPC 클라이언트다. -type T1Client struct { - conn *grpc.ClientConn - agentClient agentv1.AgentServiceClient - telemetryClient telemetryv1.TelemetryServiceClient +// TransportKind는 감지된 전송 프로토콜 종류를 나타낸다. +type TransportKind int + +const ( + TransportQUIC TransportKind = iota + TransportH2 +) + +// T1Client는 T1 클라우드에서 T2 게이트웨이로 연결하는 gRPC 클라이언트 인터페이스이다. +type T1Client interface { + ExecuteTask(ctx context.Context, task *agentv1.AgentTask) (*agentv1.AgentResult, error) + StreamTelemetry(ctx context.Context, query *telemetryv1.TelemetryQuery, handler func(*telemetryv1.DataPoint) error) error + Close() error } -// NewT1Client는 T2 게이트웨이에 gRPC over QUIC 연결을 수립한다. -func NewT1Client(ctx context.Context, gatewayAddr string, tlsConfig *tls.Config) (*T1Client, error) { - dialer := NewQUICDialer(tlsConfig) +// NewT1Client는 TransportSelector를 통해 QUIC 가용성을 우선 프로브하고 최적의 클라이언트 스택을 구성한다. +func NewT1Client(ctx context.Context, gatewayAddr string, tlsConfig *tls.Config) (T1Client, error) { + // T1 클라이언트 수립 시, QUIC 경로에서는 grpc.Dial 및 WithContextDialer 사용이 구조적으로 금지된다. + selector := NewTransportSelector(tlsConfig, 3*time.Second) + kind, err := selector.Probe(ctx, gatewayAddr) + if err != nil { + kind = TransportH2 + } - conn, err := grpc.DialContext( - ctx, - gatewayAddr, - grpc.WithContextDialer(dialer.DialContext), - grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), - // Retry policy: 무선 손실 시 지수 백오프 + if kind == TransportQUIC { + // (a) 권장 구현 — connect-go + HTTP/3 RoundTripper 구성 (grpc.ClientConn 미사용) + quicTLS := tlsConfig.Clone() + quicTLS.NextProtos = []string{"h3"} + h3RoundTripper := &http3.RoundTripper{ + TLSClientConfig: quicTLS, + } + httpClient := &http.Client{ + Transport: h3RoundTripper, + Timeout: 10 * time.Second, + } + + return &connectT1Client{ + httpClient: httpClient, + agentClient: agentv1.NewAgentServiceClient(httpClient), + telemetryClient: telemetryv1.NewTelemetryServiceClient(httpClient), + }, nil + } + + // (b) 폴백 구현 — grpc-go over HTTP/2 구성 (TCP 전용 다이얼러 적용) + tcpTLS := tlsConfig.Clone() + tcpTLS.NextProtos = []string{"h2"} + + dialer := func(ctx context.Context, addr string) (net.Conn, error) { + d := &net.Dialer{Timeout: 3 * time.Second} + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, err + } + tlsConn := tls.Client(conn, tcpTLS) + if err := tlsConn.HandshakeContext(ctx); err != nil { + conn.Close() + return nil, err + } + return tlsConn, nil + } + + conn, err := grpc.DialContext(ctx, gatewayAddr, + grpc.WithContextDialer(dialer), + grpc.WithTransportCredentials(insecure.NewCredentials()), // dialer단에서 tls 핸드셰이크 처리 완료 grpc.WithDefaultServiceConfig(`{ "methodConfig": [{ "name": [{}], @@ -1519,23 +1567,28 @@ func NewT1Client(ctx context.Context, gatewayAddr string, tlsConfig *tls.Config) }`), ) if err != nil { - return nil, fmt.Errorf("grpc dial: %w", err) + return nil, fmt.Errorf("tcp fallback dial failed: %w", err) } - return &T1Client{ + return &grpcT1Client{ conn: conn, agentClient: agentv1.NewAgentServiceClient(conn), telemetryClient: telemetryv1.NewTelemetryServiceClient(conn), }, nil } -// ExecuteTask는 T2 게이트웨이에 AgentTask를 전송한다. -func (c *T1Client) ExecuteTask(ctx context.Context, task *agentv1.AgentTask) (*agentv1.AgentResult, error) { +// connectT1Client는 connect-go/HTTP3 기반의 T1Client 구현체이다. (RPC 단위 독립 QUIC 스트림 매핑) +type connectT1Client struct { + httpClient *http.Client + agentClient agentv1.AgentServiceClient + telemetryClient telemetryv1.TelemetryServiceClient +} + +func (c *connectT1Client) ExecuteTask(ctx context.Context, task *agentv1.AgentTask) (*agentv1.AgentResult, error) { return c.agentClient.Execute(ctx, task) } -// StreamTelemetry는 T2 게이트웨이에서 텔레메트리 스트림을 구독한다. -func (c *T1Client) StreamTelemetry( +func (c *connectT1Client) StreamTelemetry( ctx context.Context, query *telemetryv1.TelemetryQuery, handler func(*telemetryv1.DataPoint) error, @@ -1544,7 +1597,6 @@ func (c *T1Client) StreamTelemetry( if err != nil { return fmt.Errorf("stream start: %w", err) } - for { dp, err := stream.Recv() if err != nil { @@ -1556,8 +1608,47 @@ func (c *T1Client) StreamTelemetry( } } -// Close는 gRPC 연결을 종료한다. -func (c *T1Client) Close() error { return c.conn.Close() } +func (c *connectT1Client) Close() error { + if transport, ok := c.httpClient.Transport.(*http3.RoundTripper); ok { + return transport.Close() + } + return nil +} + +// grpcT1Client는 gRPC-Go/HTTP2 기반의 T1Client 폴백 구현체이다. +type grpcT1Client struct { + conn *grpc.ClientConn + agentClient agentv1.AgentServiceClient + telemetryClient telemetryv1.TelemetryServiceClient +} + +func (c *grpcT1Client) ExecuteTask(ctx context.Context, task *agentv1.AgentTask) (*agentv1.AgentResult, error) { + return c.agentClient.Execute(ctx, task) +} + +func (c *grpcT1Client) StreamTelemetry( + ctx context.Context, + query *telemetryv1.TelemetryQuery, + handler func(*telemetryv1.DataPoint) error, +) error { + stream, err := c.telemetryClient.Stream(ctx, query) + if err != nil { + return fmt.Errorf("stream start: %w", err) + } + for { + dp, err := stream.Recv() + if err != nil { + return fmt.Errorf("stream recv: %w", err) + } + if err := handler(dp); err != nil { + return err + } + } +} + +func (c *grpcT1Client) Close() error { + return c.conn.Close() +} ``` ---