docs: remap gRPC-over-QUIC transport to per-RPC stream mapping, resolve HOLB contradiction

This commit is contained in:
2026-07-06 11:44:09 +09:00
parent f6fb8a038f
commit c6dab0a901
3 changed files with 126 additions and 28 deletions
+2 -2
View File
@@ -220,7 +220,7 @@ MCP 도구 호출은 T2 게이트웨이의 gRPC 인터셉터를 통해 tracepare
**Rationale**: Bidi Streaming을 T2↔T2 엣지 합의에 사용하는 이유는, 다수 엣지 노드가 단일 소켓을 통해 ms 단위의 상호 메시지 교환을 진행할 수 있어 소켓 수를 O(n²)에서 O(n)으로 줄이기 때문이다.
#### 3.2.2 gRPC over QUIC (T1↔T2) [기본] 및 HTTP/2 폴백
#### 3.2.2 gRPC over QUIC (T1↔T2) [기본] 및 다이얼 실패 기반 전송 폴백
T1↔T2 광역 구간은 gRPC over QUIC(HTTP/3) 프로토콜을 기본(Primary) 전송 채널로 사용하며, 다음의 이점을 제공한다:
@@ -236,7 +236,7 @@ gRPC Frame
└── 5G NR / LTE / Wi-Fi Physical
```
또한, 엣지 게이트웨이(T2)와 클라우드(T1) 사이의 전송 신뢰성을 보장하기 위해 **HTTP/2 폴백 협상(Fallback Negotiation)** 메커니즘을 내장한다. 클라이언트(게이트웨이)는 최초 접속 시 ALPN(Application-Layer Protocol Negotiation)에 `h3` 프로토콜을 우선 협상하며, UDP 포트 차단이나 방화벽 제약으로 인해 QUIC 연결 실패는 경우 즉시 표준 TCP 기반의 `h2`(gRPC over HTTP/2) 채택으로 강등(Downgrade)하여 연결 연속성을 유지한다.
QUIC 연결 내부에서는 **개별 RPC 호출(Request/Response)과 네트워크 수준의 QUIC 스트림을 1:1로 매핑**하여 개별 호출의 독립 전송을 보장하고 스트림 수준의 Head-of-Line Blocking(HOLB)을 완전히 차단한다. 또한, 엣지 게이트웨이(T2)와 클라우드(T1) 사이의 전송 신뢰성을 보장하기 위해 **다이얼 실패 기반 전송 폴백(Dial-Failure-Based Transport Fallback)** 메커니즘을 내장한다. 클라이언트(게이트웨이)는 기본적으로 UDP/QUIC 다이얼러를 통해 연결을 시도하며, 방화벽 UDP 차단이나 패킷 영구 손실 등 UDP 연결 실패가 감지되는 경우 즉시 표준 TCP 기반의 `h2`(gRPC over HTTP/2) 다이얼러로 폴백하여 연결 연속성을 유지한다.
#### 3.2.3 T2↔T3 프로토콜 선택 기준
+118 -20
View File
@@ -548,11 +548,22 @@ var defaultDeadlines = map[string]int64{
}
```
> **참고**: 실제 quic-go와 gRPC-Go 통합 시 `grpc.WithContextDialer`를 사용하여 클라이언트 측에서 QUIC 다이얼러를 주입하는 방식이 더 실용적이다. 위 코드는 서버 측 리스너 래핑 패턴을 보여준다. 프로덕션에서는 `quic-go/http3` 패키지의 `ServeListener` 방식 또는 별도 gRPC-QUIC 어댑터 라이브러리(`github.com/open-telemetry/opentelemetry-collector-contrib` 참조)를 활용한다. 본 논문은 공식 gRPC 스택이 제공하지 않는 HTTP/3 over QUIC 인터페이스를 quic-go 기반 커스텀 리스너 및 다이얼러 형태로 직접 패키징하여 동작을 검증하였으며, 이것이 연구의 핵심적인 실증적 구현 기여 중 하나이다. 또한, UDP 통신이 차단되는 환경을 고려하여, 다이얼러 단에서 ALPN 협상 결과에 따라 HTTP/2로 강등하여 표준 TCP gRPC 연결을 재수립하도록 명세한다.
> **참고**: 실제 quic-go와 gRPC-Go 통합 시 공식 gRPC 스택의 HTTP/3 지원이 실험적 단계임을 감안하여, 본 논문은 전송 계층의 독립 스트림 멀티플렉싱(HOLB 해소)을 실질적으로 확보하기 위한 두 가지 커스텀 어댑터 설계 및 다이얼 실패 기반 폴백 메커니즘을 정의한다.
#### HTTP/2 폴백(Fallback) 전송 어댑터 명세
#### 다이얼 실패 기반 전송 폴백(Dial-Failure-Based Transport Fallback) 명세
클라이언트(게이트웨이)가 UDP/QUIC 연결을 시도하고 실패하거나 UDP 차단을 감지한 경우 즉시 TCP/HTTP/2로 자동 전환하기 위해 `internal/gateway/fallback_dialer.go` 명세를 추가한다.
T1 클라우드와 T2 엣지 게이트웨이 간 무선 링크의 신뢰성을 보장하기 위해, 기본 UDP/QUIC 연결 수립 실패 시 TCP/HTTP/2 경로로 자동 전환하는 전송 폴백 메커니즘을 구현한다. 본 구조는 포트나 전송 프로토콜이 상이하여 단일 핸드셰이크 내 ALPN 강등 협상이 불가하므로, 클라이언트 단의 다이얼러에서 명시적인 연결 실패 감지 후 폴백을 수행한다.
##### 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 스트림이 정체되지 않는다.
- **(b) 대안 구현 — per-RPC 커스텀 ClientTransport**:
gRPC-Go의 커스텀 `ClientTransport` 인터페이스를 직접 구현하여, 클라이언트가 RPC를 개시할 때마다 `quic.Connection.OpenStreamSync`를 호출하여 새 QUIC 스트림을 동적으로 개방하고, 해당 스트림에 length-prefixed gRPC 바이트 프레임을 직접 매핑하여 송수신한다. 단, 이 대안은 gRPC-Go 내부의 private transport API 변동에 따른 유지보수 리스크가 있음을 명시한다.
아래는 다이얼 실패 감지 및 `tls.Config` 분리 복제(`Clone`)가 적용된 폴백 다이얼러 구현 명세이다:
```go
package gateway
@@ -567,39 +578,49 @@ import (
"github.com/quic-go/quic-go"
)
// FallbackDialer는 QUIC 연결 시도 실패 시 TCP/HTTP2로 자동 전환하는 다이얼러이다.
// FallbackDialer는 QUIC 연결 시도 실패 시 TCP/HTTP2로 전환하는 다이얼러이다.
type FallbackDialer struct {
tlsConfig *tls.Config
timeout time.Duration
quicTLSConfig *tls.Config // QUIC 전용 ALPN (h3)
tcpTLSConfig *tls.Config // TCP 전용 ALPN (h2)
timeout time.Duration
}
func NewFallbackDialer(tlsConfig *tls.Config, timeout time.Duration) *FallbackDialer {
tlsConfig.NextProtos = []string{"h3", "h2"} // QUIC(h3) 우선 협상, 실패 시 HTTP/2(h2)로 폴백
return &FallbackDialer{tlsConfig: tlsConfig, timeout: timeout}
func NewFallbackDialer(baseConfig *tls.Config, timeout time.Duration) *FallbackDialer {
// 호출자 tls.Config의 사이드 이펙트 방지를 위해 복제(Clone) 수행
quicTLS := baseConfig.Clone()
quicTLS.NextProtos = []string{"h3"} // QUIC은 h3 프로토콜에 한정
tcpTLS := baseConfig.Clone()
tcpTLS.NextProtos = []string{"h2"} // TCP/HTTP2는 h2 프로토콜에 한정
return &FallbackDialer{
quicTLSConfig: quicTLS,
tcpTLSConfig: tcpTLS,
timeout: timeout,
}
}
// DialContext는 UDP/QUIC 연결을 시도하고 실패 시 TCP/HTTP/2 연결로 폴백한다.
// 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)
defer cancel()
// 1. QUIC(HTTP/3) Dial 시도
quicConn, err := quic.DialAddr(quicCtx, addr, d.tlsConfig, nil)
// 각 RPC 호출마다 개별 스트림을 매핑하는 connect-go 또는 커스텀 Transport 구조의 세션을 관리
quicConn, err := quic.DialAddr(quicCtx, addr, d.quicTLSConfig, nil)
if err == nil {
stream, err := quicConn.OpenStreamSync(ctx)
if err == nil {
return &quicStreamConn{stream: stream, conn: quicConn}, nil
}
// 단일 커넥션 내부 스트림 생성을 래핑하는 커넥션 핸들러 반환
return &quicSessionWrapper{conn: quicConn}, nil
}
// 2. 실패 시 TCP 기반 HTTP/2 Fallback 실행
// 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 and HTTP/2 fallback failed: %w", err)
return nil, fmt.Errorf("both QUIC dial and HTTP/2 fallback failed: %w", err)
}
tlsConn := tls.Client(tcpConn, d.tlsConfig)
tlsConn := tls.Client(tcpConn, d.tcpTLSConfig)
if err := tlsConn.HandshakeContext(ctx); err != nil {
tcpConn.Close()
return nil, err
@@ -609,6 +630,83 @@ func (d *FallbackDialer) DialContext(ctx context.Context, addr string) (net.Conn
}
```
##### 2. 서버 측 전송 어댑터 명세 (`internal/gateway/server.go`)
서버 측 역시 단일 연결을 무조건 수락(Accept)하여 gRPC 서버에 전달할 경우 동적 다중 스트림 매핑이 손실된다. 따라서 서버 측은 다음과 같이 HTTP/3 서버 위에 `connect-go` 등의 gRPC 호환 서비스 핸들러를 마운트하여 **단일 비즈니스 로직(Handler)이 HTTP/3와 HTTP/2 폴백 연결에서 공유**되도록 구성한다.
```go
package gateway
import (
"context"
"crypto/tls"
"net/http"
"time"
"github.com/quic-go/quic-go/http3"
)
type GatewayServer struct {
h3Server *http3.Server
h2Server *http.Server
handler http.Handler // HTTP/3 및 HTTP/2 공용 gRPC-compatible 핸들러
}
func NewGatewayServer(addr string, tlsConfig *tls.Config, handler http.Handler) *GatewayServer {
h3TLS := tlsConfig.Clone()
h3TLS.NextProtos = []string{"h3"}
h2TLS := tlsConfig.Clone()
h2TLS.NextProtos = []string{"h2"}
return &GatewayServer{
h3Server: &http3.Server{
Addr: addr,
TLSConfig: h3TLS,
Handler: handler,
},
h2Server: &http.Server{
Addr: addr,
TLSConfig: h2TLS,
Handler: handler, // 동일 비즈니스 핸들러 공유
},
}
}
func (s *GatewayServer) Start() error {
errChan := make(chan error, 2)
// HTTP/3 (UDP) 서버 기동
go func() {
errChan <- s.h3Server.ListenAndServeTLS("", "")
}()
// HTTP/2 (TCP) 서버 기동 (폴백 대비 수신 채널)
go func() {
errChan <- s.h2Server.ListenAndServeTLS("", "")
}()
return <-errChan
}
```
##### 3. 소거 실험용 비교군 명세 (Naive Single-Stream Tunneling)
스트림 매핑 유무가 HOLB 해소에 미치는 실질적인 영향을 입증하는 소거 실험(Ablation Study) 비교군 구성을 위해, 기존에 논의된 단일 QUIC 스트림 고정 터널링(Naive) 코드를 아래와 같이 별도로 명세한다. 이 명세는 평가 단계(Evaluation)에서 패킷 손실 시 TCP와 동등한 수준의 지연시간 병목이 유발됨을 입증하는 대조군으로 기능한다.
```go
// naiveStreamConn은 하나의 QUIC 커넥션에서 단 하나의 스트림만을 점유하여
// gRPC-Go에 net.Conn 인터페이스로 노출함으로써 강제로 단일 채널 터널링을 수행한다.
type naiveStreamConn struct {
quic.Stream
conn quic.Connection
}
func (c *naiveStreamConn) LocalAddr() net.Addr { return c.conn.LocalAddr() }
func (c *naiveStreamConn) RemoteAddr() net.Addr { return c.conn.RemoteAddr() }
// (본 naive 구현은 개별 RPC의 독립 스트림 권한을 박탈하여 세션 내 HOLB를 유발함)
```
### 4.2 MQTT→gRPC 변환 엔진
#### MQTT Topic 네이밍 규칙
@@ -1338,7 +1436,7 @@ import (
telemetryv1 "github.com/your-org/edge-aiot-mas/gen/go/telemetry/v1"
)
// QUICDialer는 quic-go를 사용하여 gRPC 연결을 수립하는 다이얼러다.
// QUICDialer는 소거 실험(Ablation Study) 비교용 naive 단일 스트림 터널링 다이얼러다. (HOLB 재현 구조)
type QUICDialer struct {
tlsConfig *tls.Config
}
@@ -1348,7 +1446,7 @@ func NewQUICDialer(tlsConfig *tls.Config) *QUICDialer {
return &QUICDialer{tlsConfig: tlsConfig}
}
// DialContext는 gRPC의 WithContextDialer에 전달될 다이얼 함수다.
// DialContext는 소거 실험군(naive) gRPC 다이얼 함수다.
func (d *QUICDialer) DialContext(ctx context.Context, addr string) (net.Conn, error) {
// QUIC connection 수립
conn, err := quic.DialAddr(ctx, addr, d.tlsConfig, &quic.Config{