docs: add documentation for gRPC over HTTP/3 implementation details

This commit is contained in:
2026-07-17 23:13:51 +09:00
parent db35f38c05
commit b638a1b9fd
+409
View File
@@ -0,0 +1,409 @@
# HTTP/3(QUIC) 기반 gRPC 구현기: 마주친 문제와 해결 전략
이 문서는 `lib/grpc/http3` 예제를 구현하는 과정에서 실제로 부딪혔던 기술적 난제들과 그 해결 방법을 기록한 실전 참고 자료입니다. HTTP/2 기반의 기존 `lib/grpc/basic` 예제와 달리, HTTP/3의 전송 계층인 **QUIC**은 Go 표준 네트워킹 인터페이스나 `google.golang.org/grpc`(grpc-go)가 기대하는 모양과 근본적으로 다르기 때문에, 단순히 프로토콜만 바꾼다고 되는 일이 아니었습니다. 추후 HTTP/3 기반 gRPC를 직접 구현하려는 분들이 동일한 시행착오를 반복하지 않도록, 문제 상황 → 해결 전략 → 실제 코드 순서로 정리했습니다.
각 절은 **간단 설명**(핵심을 한두 문장으로 요약)과 **상세 설명**(기술적 근거와 세부 동작)의 이중 레이어로 구성되어 있습니다.
> [!WARNING]
> **이 문서가 다루는 어댑터 구조는 "진짜 HTTP/3 프레이밍"이 아니며, QUIC의 스트림 단위 HOL 블로킹 방지 효과를 완전히 누리지 못합니다.** 자세한 내용은 §3.3을 반드시 읽어보시기 바랍니다.
---
## 1. 근본 제약: grpc-go는 QUIC을 모른다
* **간단 설명**: `google.golang.org/grpc`는 HTTP/2 프로토콜을 스스로 구현해서 사용하며, HTTP/3(QUIC)를 인식하지 못합니다. 그래서 "QUIC 위에서 도는 gRPC"를 만들려면 grpc-go의 코드를 고치는 대신, grpc-go가 이미 알고 있는 표준 Go 인터페이스(`net.Listener`, `net.Conn`)의 모습으로 QUIC을 "위장"시켜야 했습니다.
* **상세 설명**: grpc-go의 `grpc.Server.Serve(lis net.Listener)``grpc.WithContextDialer(...)`는 전송 계층이 TCP인지, Unix 소켓인지, 심지어 인메모리 파이프(`bufconn`)인지 전혀 신경 쓰지 않습니다. `net.Listener`/`net.Conn` 인터페이스만 만족하면 grpc-go는 그 위에서 자신의 HTTP/2 프레이밍을 그대로 수행합니다. 이 성질을 이용해, `github.com/quic-go/quic-go`가 제공하는 QUIC 커넥션·스트림을 `net.Conn`으로 감싸는 어댑터만 작성하면 grpc-go 코드를 한 줄도 건드리지 않고 QUIC 전송 위에 gRPC를 얹을 수 있습니다. 이 접근은 "RFC 9114를 완전히 준수하는 진짜 HTTP/3 프레이밍"은 아니지만(그건 `quic-go/http3``http.Handler` 기반 서버가 담당하는 영역이며 grpc-go의 API를 쓸 수 없게 됩니다), 전송 계층 자체는 실제 QUIC/UDP이며 gRPC의 개발 경험(protoc 스텁, 서비스 등록 등)을 그대로 유지할 수 있다는 점에서 이 예제가 채택한 실용적인 절충안입니다. **다만 이 절충안에는 중요한 성능적 제약이 뒤따르며, 이는 §3.3에서 자세히 다룹니다.**
---
## 2. Go `net.Listener`/`net.Conn` 규격에 QUIC 맞춰 넣기
### 2.1 `quicNetConn`: QUIC 스트림을 `net.Conn`으로 감싸기
* **간단 설명**: `quic-go`의 스트림(`quic.Stream`)은 읽고 쓰는 기능은 이미 갖추고 있지만, "내 주소가 뭐야?"에 해당하는 `LocalAddr()`/`RemoteAddr()`는 없습니다. 이 두 메서드만 얇게 대신 구현해 주는 별도 오브젝트를 만들면, `net.Conn`의 모든 요구 조건이 채워집니다.
* **상세 설명**: `net.Conn` 인터페이스는 `Read`/`Write`/`Close`/`LocalAddr`/`RemoteAddr`/`SetDeadline`/`SetReadDeadline`/`SetWriteDeadline`를 요구합니다. `quic.Stream``Read`/`Write`/`Close`/`SetDeadline`류를 이미 제공하지만, 주소 정보는 스트림이 아니라 그 스트림이 속한 `quic.Conn`(QUIC 커넥션)이 들고 있습니다. 따라서 다음과 같이 Go의 구조체 임베딩(embedding)을 활용해 `*quic.Stream`의 메서드를 그대로 승격시키고, 부족한 두 메서드만 `conn` 필드로 위임하는 방식으로 해결했습니다:
```go
// lib/grpc/http3/server.go
type quicNetConn struct {
*quic.Stream // Read/Write/Close/SetDeadline류를 그대로 물려받음
conn *quic.Conn // LocalAddr/RemoteAddr 위임용
}
func (c *quicNetConn) LocalAddr() net.Addr {
return c.conn.LocalAddr()
}
func (c *quicNetConn) RemoteAddr() net.Addr {
return c.conn.RemoteAddr()
}
```
이 타입 하나로 서버(`quicListener.Accept()`가 반환)와 클라이언트(`quicDialer`가 반환) 양쪽에서 재사용됩니다(같은 `package http3` 안이므로 `client.go`에서도 별도 선언 없이 그대로 참조).
### 2.2 클라이언트 측: `grpc.WithContextDialer`로 다이얼러 갈아끼우기
* **간단 설명**: grpc-go 클라이언트가 "연결을 만들어라"라고 요청할 때 기본으로는 TCP로 연결하지만, `WithContextDialer` 옵션에 원하는 함수를 꽂아주면 그 연결 방식을 통째로 바꿀 수 있습니다. 이 자리에 QUIC 연결·스트림 개설 로직을 넣었습니다.
* **상세 설명**:
```go
// lib/grpc/http3/client.go
func quicDialer(tlsConf *tls.Config) func(context.Context, string) (net.Conn, error) {
return func(ctx context.Context, addr string) (net.Conn, error) {
qconn, err := quic.DialAddr(ctx, addr, tlsConf, &quic.Config{
KeepAlivePeriod: 10 * time.Second,
})
if err != nil {
return nil, fmt.Errorf("quic dial failed: %w", err)
}
stream, err := qconn.OpenStreamSync(ctx)
if err != nil {
_ = qconn.CloseWithError(0, "failed to open stream")
return nil, fmt.Errorf("failed to open stream: %w", err)
}
return &quicNetConn{Stream: stream, conn: qconn}, nil
}
}
```
`quic.DialAddr`로 QUIC 커넥션(및 TLS 핸드셰이크)을 먼저 완성한 뒤, `OpenStreamSync`로 그 커넥션 위에 논리적 스트림을 **딱 하나만** 열고, 이를 `quicNetConn`으로 감싸 grpc-go에게 "이게 네가 원하는 `net.Conn`이야"라고 건네주는 구조입니다. 스트림 개설이 실패하면 이미 맺어진 QUIC 커넥션을 `CloseWithError`로 정리해, 자원이 새지 않도록 했습니다. **이 "스트림을 하나만 연다"는 설계가 §3.3에서 다루는 제약의 직접적인 원인이 됩니다.**
---
## 3. 비동기 리스너 아키텍처와 헤드오브라인(HOL) 블로킹 예방
이 예제에서 **가장 많은 시행착오를 겪은 부분**입니다.
### 3.1 처음에 시도했다가 폐기한 설계 — 왜 위험했는가
* **간단 설명**: 처음에는 "새 QUIC 연결이 들어오면 그 자리에서 바로 스트림까지 열어서 반환하자"는 단순한 방식으로 `Accept()`를 짰습니다. 하지만 이 방식은 클라이언트 한 명이 스트림을 늦게 열면, **다른 모든 클라이언트의 접속이 전부 멈춰버리는** 심각한 문제가 있었습니다.
* **상세 설명**: grpc-go의 `Server.Serve(lis)`는 내부적으로 `for { rawConn, err := lis.Accept(); ... go s.handleRawConn(rawConn) }` 형태의 **단일 고루틴 직렬 루프**로 동작합니다(`google.golang.org/grpc`의 `server.go` 소스에서 직접 확인한 구조입니다). 즉 `lis.Accept()` 한 번의 호출이 끝나야만 다음 `Accept()`로 넘어갑니다. 만약 커스텀 `Accept()` 구현이 내부에서 `quic.Listener.Accept(ctx)` → `quic.Conn.AcceptStream(ctx)`를 **동기적으로 순차 호출**한다면:
1. **헤드오브라인(HOL) 블로킹**: 클라이언트 A가 QUIC 커넥션은 맺었지만 스트림 개설(`OpenStreamSync`)이 지연되면, 서버의 `Accept()`는 A의 `AcceptStream()` 호출에서 멈춥니다. 그 사이 클라이언트 B가 새로 접속을 시도해도, 서버의 단일 accept 루프가 A에 붙잡혀 있으므로 B는 무한정 대기하게 됩니다.
2. **동일 커넥션의 두 번째 이후 스트림을 영영 받을 수 없음**: `Accept()` 한 번이 스트림 하나만 반환하고 나면, 다음 반복에서는 다시 `quic.Listener.Accept()`(새 커넥션 수락)로 넘어갑니다. 즉 이미 맺어진 커넥션 위에서 클라이언트가 스트림을 추가로 열어도, 서버는 그 커넥션으로 다시 돌아와 확인하지 않으므로 영원히 수락되지 않습니다.
이 두 가지는 실제로 재현 가능한 설계 결함이며, 특히 2번은 단일 QUIC 연결 안에서 여러 RPC(예: Unary 호출을 여러 번 하거나, 스트리밍 RPC를 여러 개 열 때)를 처리해야 하는 gRPC의 일반적인 사용 패턴과 정면으로 충돌합니다.
### 3.2 최종 채택한 구조: 커넥션 수락과 스트림 수락의 완전한 비동기 분리
* **간단 설명**: "새 연결을 받는 일"과 "그 연결 안에서 스트림을 받는 일"을 서로 다른 고루틴으로 완전히 분리하고, 그 결과물을 채널(channel) 하나로 모아서 `Accept()`가 그 채널에서만 기다리게 만들었습니다. 이렇게 하면 한 클라이언트의 지연이 다른 클라이언트나 같은 클라이언트의 다른 스트림에 전혀 영향을 주지 않습니다.
* **상세 설명**: 아래 세 함수가 각자 독립적인 역할을 맡아 비동기로 동작합니다.
```go
// lib/grpc/http3/server.go
type quicListener struct {
lis *quic.Listener
connChan chan net.Conn
errChan chan error
ctx context.Context
cancel context.CancelFunc
}
func NewQuicListener(lis *quic.Listener) *quicListener {
ctx, cancel := context.WithCancel(context.Background())
ql := &quicListener{
lis: lis,
connChan: make(chan net.Conn, 100),
errChan: make(chan error, 10),
ctx: ctx,
cancel: cancel,
}
go ql.listenLoop()
return ql
}
// 커넥션 수락 전담 — 새 QUIC 연결이 오면 즉시 별도 고루틴에 위임하고 곧바로 다음 연결을 기다림
func (ql *quicListener) listenLoop() {
for {
qconn, err := ql.lis.Accept(ql.ctx)
if err != nil {
select {
case ql.errChan <- err:
default:
}
return
}
go ql.acceptStreams(qconn)
}
}
// 스트림 수락 전담 — 커넥션 1개마다 독립된 고루틴으로 실행되며, 그 커넥션이 살아있는 한 계속 스트림을 받아들임
func (ql *quicListener) acceptStreams(qconn *quic.Conn) {
for {
stream, err := qconn.AcceptStream(ql.ctx)
if err != nil {
// 이 커넥션이 끊기면 이 고루틴만 조용히 종료 (다른 커넥션은 영향 없음)
return
}
ql.connChan <- &quicNetConn{Stream: stream, conn: qconn}
}
}
// grpc-go가 실제로 호출하는 지점 — 채널에서 결과가 나올 때까지만 기다림 (뒷단의 QUIC 세부사항과 완전히 분리됨)
func (ql *quicListener) Accept() (net.Conn, error) {
select {
case conn := <-ql.connChan:
return conn, nil
case err := <-ql.errChan:
return nil, err
case <-ql.ctx.Done():
return nil, io.EOF
}
}
func (ql *quicListener) Close() error {
ql.cancel()
return ql.lis.Close()
}
func (ql *quicListener) Addr() net.Addr {
return ql.lis.Addr()
}
```
핵심은 `listenLoop`(커넥션 수락)과 `acceptStreams`(스트림 수락, **커넥션 1개당 고루틴 1개**)가 서로를 절대 기다리지 않는다는 점입니다. `acceptStreams`는 자신이 담당하는 커넥션이 살아있는 동안 계속 루프를 돌며 새 스트림이 열릴 때마다 이를 `connChan`에 밀어 넣고, `Accept()`는 이 채널만 바라보므로 몇 명의 클라이언트가 붙어있든, 각 클라이언트가 스트림을 몇 개나 여는지와 무관하게 즉시 반응할 수 있습니다. `net.Listener` 인터페이스가 요구하는 `Close()`/`Addr()`도 함께 구현해 커스텀 리스너가 완전한 `net.Listener`로 동작하도록 마감했습니다.
이 어댑터를 `grpc.NewServer().Serve(qlis)`에 그대로 넘기기만 하면, grpc-go는 이후 표준 HTTP/2 프레이밍을 그 위에서 동일하게 수행합니다(§1 참고) — 즉 이 절 이후로는 grpc-go 코드를 전혀 건드릴 필요가 없습니다.
* **검증 방법**: 단순히 빌드/실행이 되는 것만으로는 이 문제가 실제로 해결됐는지 알 수 없습니다. 반드시 **하나의 QUIC 연결(하나의 `ClientRun` 세션) 안에서 2회 이상의 RPC 호출**(Unary 반복 호출 또는 스트리밍 RPC)이 모두 성공하는지 확인해야 합니다. `lib/grpc/http3/http3_test.go`의 `TestHttp3ServerClient`가 정확히 이 시나리오(Unary Ping 2회 + 양방향 스트리밍 3회, 모두 같은 연결 위에서)를 검증합니다.
### 3.3 ⚠️ 이 구조가 해결하는 HOL 블로킹과 해결하지 못하는 HOL 블로킹은 다르다 (중요한 제약사항)
* **간단 설명**: §3.2에서 해결한 것은 "서버가 여러 클라이언트의 접속을 동시에 잘 받아주는가"라는 **리스너(Accept) 레벨의 HOL 블로킹**입니다. 이것과 별개로, **하나의 gRPC 연결 안에서 오가는 여러 RPC들이 QUIC 스트림 하나에 몰려 있다는 문제**는 이 구조로 해결되지 않습니다. 패킷 유실이 발생하면 그 스트림에 실려 있던 모든 RPC가 함께 지연됩니다 — 이는 기존 HTTP/2-over-TCP와 동일한 한계입니다.
* **상세 설명**: §2.2에서 확인했듯, `quicDialer`는 `qconn.OpenStreamSync(ctx)`를 **정확히 한 번만** 호출해 QUIC 스트림을 하나 열고, 이를 grpc-go에게 "네가 쓸 유일한 `net.Conn`"으로 건네줍니다. grpc-go는 통상 하나의 대상(target)에 대해 하나의 전송 연결만 수립하고, 그 위에서 자신의 HTTP/2 멀티플렉싱으로 **모든 동시 RPC(Unary, 서버/클라이언트/양방향 스트리밍 불문)를 처리**합니다. 즉:
```text
[클라이언트가 동시에 호출하는 RPC 3개]
grpc-go의 HTTP/2 멀티플렉싱 (여러 논리적 스트림 프레임 생성)
단 하나의 quicNetConn (= 단 하나의 QUIC 스트림)
QUIC 전송 계층 (신뢰적·순서 보장 단일 스트림)
```
QUIC의 핵심 이점 중 하나는 "스트림이 여러 개일 때, 한 스트림의 패킷 유실이 다른 스트림에 영향을 주지 않는다"는 것입니다. 그러나 위 구조에서는 애초에 QUIC 스트림이 **1개뿐**이므로, 그 위에서 벌어지는 QUIC의 재전송·순서 보장 대기는 결국 grpc-go가 그 위에 얹어 놓은 모든 논리적 gRPC 호출에 똑같이 영향을 미칩니다. 다시 말해, **"UDP/QUIC이라는 전송 계층을 사용한다"는 사실과 "QUIC의 스트림 단위 HOL 블로킹 방지 이점을 실제로 누린다"는 것은 별개의 문제이며, 이 어댑터 방식은 전자만 해당하고 후자는 해당하지 않습니다.**
이 한계를 실제로 완화하려면(이번 예제의 범위를 벗어나는 심화 주제입니다), 다음과 같은 방향을 고려할 수 있습니다:
- RPC(또는 RPC 그룹)별로 **별도의 QUIC 스트림**을 열고, grpc-go 대신 자체 RPC 디스패치 로직을 구현(즉 §1에서 언급한 "진짜 HTTP/3" B안에 가까워짐).
- 커넥션 풀링을 도입해 무거운 스트리밍 RPC and 가벼운 Unary RPC를 서로 다른 QUIC 스트림(혹은 별도 QUIC 커넥션)으로 분리.
**이 예제는 "QUIC 전송 위에서 gRPC 개발 경험을 그대로 유지하는 실용적 절충안"을 보여주는 것이 목적이며, 프로덕션 환경에서 QUIC의 HOL 블로킹 방지 이점을 온전히 활용하려면 위와 같은 추가 설계가 필요함을 명확히 인지해야 합니다.**
### 3.4 💡 Tip: Graceful Shutdown 시 QUIC 커넥션 누수 방지 (권장 개선안 — 현재 미반영)
* **간단 설명**: 현재 `quicListener.Close()`는 리스너 자체만 닫을 뿐, 이미 연결되어 있던 개별 QUIC 커넥션들은 닫지 않습니다. 그래서 서버를 정상 종료해도 이미 접속해 있던 클라이언트와의 물리적 QUIC 연결은 클라이언트가 스스로 끊거나 타임아웃이 될 때까지 좀비 상태로 남습니다. 이를 막으려면 리스너가 자신이 수락한 커넥션 목록을 직접 기억해 두었다가, 종료 시점에 일일이 닫아주어야 합니다.
* **상세 설명**: `quic.Listener.Close()`는 (Go의 일반적인 `net.Listener` 관용구와 마찬가지로) **새로운 연결 수락을 중단시킬 뿐, 이미 `Accept()`된 개별 `*quic.Conn`에는 관여하지 않습니다.** `grpc.Server.GracefulStop()`도 자신이 인계받은 `net.Conn`(= QUIC 스트림)들만 정리하므로, 그 밑에 있는 물리적 QUIC 커넥션 자체는 아무도 명시적으로 닫아주지 않는 사각지대가 생깁니다. 장기 기동 서버에서 이 gRPC 컴포넌트만 반복적으로 그레이스풀 재시작하는 운영 시나리오라면, 포트는 매번 정상적으로 풀려도 프로세스 내부에 유효하지 않은 UDP 커넥션 핸들이 누적될 수 있습니다.
**현재 저장소의 `lib/grpc/http3/server.go`에는 아직 반영되어 있지 않은 권장 보강 코드**는 다음과 같습니다(수락된 `*quic.Conn`들을 맵으로 추적하고, `Close()` 시점에 순회하며 명시적으로 종료):
```go
// 권장 보강안 — quicListener에 커넥션 추적 필드 추가
type quicListener struct {
lis *quic.Listener
connChan chan net.Conn
errChan chan error
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
conns map[*quic.Conn]struct{} // 추가: 현재 살아있는 QUIC 커넥션 추적
}
func NewQuicListener(lis *quic.Listener) *quicListener {
ctx, cancel := context.WithCancel(context.Background())
ql := &quicListener{
lis: lis,
connChan: make(chan net.Conn, 100),
errChan: make(chan error, 10),
ctx: ctx,
cancel: cancel,
conns: make(map[*quic.Conn]struct{}), // 추가
}
go ql.listenLoop()
return ql
}
func (ql *quicListener) listenLoop() {
for {
qconn, err := ql.lis.Accept(ql.ctx)
if err != nil {
select {
case ql.errChan <- err:
default:
}
return
}
ql.mu.Lock()
ql.conns[qconn] = struct{}{} // 추가: 커넥션 등록
ql.mu.Unlock()
go ql.acceptStreams(qconn)
}
}
func (ql *quicListener) acceptStreams(qconn *quic.Conn) {
defer func() {
ql.mu.Lock()
delete(ql.conns, qconn) // 추가: 커넥션 종료 시 목록에서 제거
ql.mu.Unlock()
}()
for {
stream, err := qconn.AcceptStream(ql.ctx)
if err != nil {
return
}
ql.connChan <- &quicNetConn{Stream: stream, conn: qconn}
}
}
func (ql *quicListener) Close() error {
ql.cancel()
err := ql.lis.Close()
ql.mu.Lock()
defer ql.mu.Unlock()
for qconn := range ql.conns {
qconn.CloseWithError(0, "server shutting down") // 추가: 잔여 커넥션 명시적 종료
}
return err
}
```
`sync.Mutex`로 맵 접근을 보호하는 이유는 `listenLoop`(등록)과 `acceptStreams`(제거) 여러 고루틴이 동시에 같은 맵을 건드릴 수 있기 때문입니다. 이 보강을 적용하면 `ServerRun`이 반환하는 `cleanup` 함수(`server.GracefulStop(); qlis.Close()`)를 호출했을 때, 리스너뿐 아니라 그 시점까지 맺어져 있던 모든 QUIC 커넥션까지 확실하게 정리됩니다.
---
## 4. TLS 1.3 강제와 자체 서명 인증서
### 4.1 QUIC에서 TLS는 선택이 아니다
* **간단 설명**: TCP는 TLS 없이도(평문으로) 연결할 수 있지만, QUIC은 프로토콜 설계 자체에 TLS 1.3이 포함되어 있어 **TLS 없는 QUIC 연결은 존재하지 않습니다**. 그래서 `lib/grpc/basic`에서 썼던 "완전 평문" 방식은 QUIC에서는 애초에 선택지가 아니었습니다.
* **상세 설명**: `quic.ListenAddr`/`quic.DialAddr`는 `*tls.Config` 인자가 필수로 요구하며, `nil`을 넘기면 즉시 에러가 납니다. 로컬 데모/학습 환경에서는 공인 인증기관(CA)이 발급한 정식 인증서를 쓸 수 없으므로, **실행 시점에 자체 서명(self-signed) 인증서를 코드로 즉석 생성**하는 방식을 택했습니다.
### 4.2 자체 서명 인증서 즉석 생성
* **간단 설명**: `crypto/tls`, `crypto/x509` 같은 Go 표준 라이브러리만으로 "가짜 인증기관 없이 나 스스로 서명한 인증서"를 그 자리에서 만들어 서버에 장착합니다. 외부 파일이나 별도 도구가 전혀 필요 없습니다.
* **상세 설명**:
```go
// lib/grpc/http3/server.go
func generateTLSConfig() (*tls.Config, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"gRPC HTTP3 Canary"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
}
// template을 자기 자신으로 서명(self-signed) — 별도의 상위 CA가 없음
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
...
return &tls.Config{
Certificates: []tls.Certificate{tlsCert},
NextProtos: []string{"grpc-http3-canary"},
}, nil
}
```
`x509.CreateCertificate(..., &template, &template, ...)`처럼 서명 대상과 서명자(issuer) 템플릿을 동일하게 넘기는 것이 "자체 서명"의 핵심입니다. `DNSNames`/`IPAddresses`에 로컬 접속에 쓰일 이름과 주소를 미리 등록해 두어야 TLS 핸드셰이크의 호스트 이름 검증(있을 경우)을 통과할 수 있습니다. `NextProtos`는 ALPN(Application-Layer Protocol Negotiation) 값으로, **서버와 클라이언트가 반드시 동일한 문자열**(`"grpc-http3-canary"`)을 사용해야 QUIC 핸드셰이크 단계에서 서로 호환되는 상위 프로토콜임을 확인하고 연결을 진행합니다.
### 4.3 클라이언트의 `InsecureSkipVerify`와 grpc-go의 `insecure.NewCredentials()` — 이름은 비슷해도 역할은 다르다
* **간단 설명**: 클라이언트 코드에 "insecure"라는 단어가 두 번 등장하는데, 하나는 "자체 서명 인증서라 진위를 검증할 CA가 없으니 검증을 생략한다"는 뜻이고, 다른 하나는 "QUIC이 이미 암호화를 다 했으니 grpc-go가 별도로 또 암호화할 필요는 없다"는 뜻입니다. 둘 다 "완전히 안전하지 않다"는 의미가 아닙니다.
* **상세 설명**:
```go
// lib/grpc/http3/client.go
tlsConf := &tls.Config{
InsecureSkipVerify: true, // (1) 자체 서명 인증서의 CA 체인 검증을 건너뜀
NextProtos: []string{"grpc-http3-canary"},
}
conn, err := grpc.NewClient(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()), // (2) grpc-go 자체 레이어에서는 암호화를 하지 않음
grpc.WithContextDialer(quicDialer(tlsConf)),
)
```
(1) `tls.Config.InsecureSkipVerify`는 QUIC 핸드셰이크 단계에서 서버 인증서의 신뢰 체인을 검증하지 않겠다는 설정입니다. 실제 QUIC 연결 자체는 여전히 TLS 1.3으로 **암호화**되어 있으며, 다만 "이 인증서가 신뢰할 만한 CA가 발급한 것인지"는 확인하지 않는다는 뜻입니다(로컬 데모이므로 자체 서명 인증서를 신뢰하기 위한 실용적 선택).
(2) `grpc.WithTransportCredentials(insecure.NewCredentials())`는 grpc-go 자신의 트랜스포트 레벨 보안 레이어를 비활성화하는 옵션입니다. 이는 grpc-go가 "내가 별도로 TLS 핸드셰이크를 또 하지 않겠다"는 의미일 뿐이며, 이미 QUIC이 그 아래에서 실제 암호화 채널을 제공하고 있으므로 이중으로 암호화할 필요가 없기 때문에 정확한 설정입니다. 이 둘을 혼동해 "이 코드는 완전히 안전하지 않은 통신을 한다"고 오해하지 않도록 주의가 필요합니다.
---
## 5. 포트 바인딩과 에러 진단
### 5.1 UDP 기반이라는 것의 함의
* **간단 설명**: QUIC은 TCP가 아니라 UDP 위에서 동작합니다. 그래서 `:8080` 같은 주소로 리슨할 때도 내부적으로는 TCP 소켓이 아니라 UDP 소켓을 엽니다. 에러 메시지에도 `tcp` 대신 `udp`가 등장합니다.
* **상세 설명**: `quic.ListenAddr(addr, tlsConf, cfg)`는 내부적으로 `net.ListenPacket("udp", addr)`에 준하는 동작을 수행합니다. 따라서 포트가 이미 사용 중일 때 발생하는 에러 메시지도 `lib/grpc/basic`에서 익숙했던 `listen tcp :8080: bind: address already in use`가 아니라 **`listen udp :8080: bind: address already in use`** 형태로 나타납니다. 트러블슈팅 시 이 차이를 인지하지 못하면 "TCP 포트는 비어있는데 왜 에러가 나지?"라며 헤맬 수 있습니다.
### 5.2 실전 사례: 에러를 조용히 삼키던 버그와 그 수정
* **간단 설명**: `ServerRun` and `ClientRun`은 각각 에러를 반환하도록 설계되어 있지만, 이를 호출하는 쪽에서 반환값을 확인하지 않고 버려버리면 문제가 생겨도 **아무 메시지 없이 그냥 조용히 끝나버립니다**. 실제로 이 예제에서도 이런 실수가 있었고, 반환값을 제대로 확인하도록 고친 뒤에야 실패 원인이 화면에 보이기 시작했습니다.
* **상세 설명**: `ServerRun(addr string) (*quic.Listener, func(), error)`과 `ClientRun(addr string) error`는 둘 다 `error`를 반환하는 관용적인(idiomatic) Go 시그니처입니다. 그런데 최초 버전의 `lib/main.go`는 다음처럼 반환값을 그대로 버렸습니다:
```go
// 수정 전 — 에러가 발생해도 아무 것도 출력되지 않음
go http3.ServerRun(port)
...
http3.ClientRun(port)
```
포트가 이미 사용 중인 상태에서 이 코드를 실행하면, 프로그램은 **에러 메시지 없이 정상 종료(exit code 0)**됩니다 — 실패했다는 사실조차 알 수 없는 최악의 실패 모드입니다. 이를 다음과 같이 수정하여 두 함수의 에러를 모두 명시적으로 확인·출력하도록 했습니다:
```go
// 수정 후
go func() {
if _, _, err := http3.ServerRun(port); err != nil {
fmt.Println("ServerRun error:", err)
}
}()
...
if err := http3.ClientRun(port); err != nil {
fmt.Println("ClientRun error:", err)
}
```
실제로 포트 충돌 상황을 인위적으로 재현해 검증한 결과, 수정 후에는 다음과 같이 원인을 즉시 진단할 수 있는 메시지가 출력됨을 확인했습니다:
```text
ServerRun error: listen udp :8080: bind: address already in use
ClientRun error: unary ping 1 failed: rpc error: code = DeadlineExceeded desc = context deadline exceeded while waiting for connections to become ready
```
서버 쪽 에러(`listen udp ...`)가 근본 원인이고, 클라이언트 쪽 에러(`DeadlineExceeded ... waiting for connections to become ready`)는 그 결과로 연결이 아예 이루어지지 않아 타임아웃된 **연쇄 증상**입니다. 이처럼 두 에러가 함께 출력되면 "서버가 애초에 뜨지 못했다"는 진짜 원인을 클라이언트 쪽 에러 메시지만으로 오판하지 않고 빠르게 좁혀나갈 수 있습니다.
### 5.3 흔히 마주치는 에러 유형 요약
| 에러 메시지 패턴 | 원인 | 진단 포인트 |
|---|---|---|
| `listen udp :PORT: bind: address already in use` | 다른 프로세스(또는 이전 실습 세션)가 같은 UDP 포트를 점유 중 | `port` 변수 값을 바꾸거나 점유 프로세스 종료 |
| `... waiting for connections to become ready` (클라이언트 측 `DeadlineExceeded`) | 서버가 아예 뜨지 못했거나, 서버·클라이언트의 TLS/ALPN(`NextProtos`) 설정이 어긋남 | 서버 쪽 로그(`ServerRun` 에러)를 먼저 확인, 양쪽의 `NextProtos` 문자열이 정확히 일치하는지 대조 |
| `quic dial failed: ...` | QUIC 핸드셰이크 자체가 실패(네트워크 도달 불가, UDP 차단 등) | 로컬 방화벽/샌드박스 환경이 UDP를 허용하는지 확인 |
| `failed to open stream: ...` | QUIC 연결은 맺어졌으나 스트림 개설이 실패(커넥션이 이미 닫히는 중 등) | 커넥션 유휴 타임아웃(`KeepAlivePeriod`) 설정과 서버 종료 타이밍 점검 |
---
## 6. 마무리 체크리스트
향후 HTTP/3 기반 gRPC 예제를 새로 만들거나 확장할 때, 아래 항목을 순서대로 점검하면 이 문서에서 다룬 문제들을 다시 겪지 않을 수 있습니다.
- [ ] grpc-go를 그대로 쓸지, `quic-go/http3`의 순정 HTTP/3 서버로 갈아탈지 방향을 먼저 결정했는가 (§1)
- [ ] QUIC 스트림/커넥션을 감싸는 `net.Conn` 어댑터에서 `LocalAddr`/`RemoteAddr`까지 모두 구현했는가 (§2.1)
- [ ] 서버 리스너 어댑터의 `Accept()`가 **절대** `AcceptStream()`을 동기적으로 직접 호출하지 않고, 커넥션 수락과 스트림 수락이 독립된 고루틴+채널로 분리되어 있는가 (§3.2)
- [ ] 검증 시 **하나의 연결 안에서 2회 이상 RPC**가 성공하는 케이스를 반드시 테스트했는가 (§3.2)
- [ ] **(신규)** "이 구조가 QUIC의 스트림 단위 HOL 블로킹 방지 이점을 실제로 제공하지 않는다"는 제약을 문서·설계 리뷰에서 명확히 공유했는가, 프로덕션에서 이 이점이 정말 필요하다면 RPC별 스트림 분리 등 추가 설계를 검토했는가 (§3.3)
- [ ] **(신규)** 서버 그레이스풀 셧다운 시 리스너뿐 아니라 이미 수락된 QUIC 커넥션까지 명시적으로 정리하는 로직이 있는가, 없다면 장기 기동/반복 재시작 환경에서 커넥션 누수 위험을 인지하고 있는가 (§3.4)
- [ ] 서버·클라이언트 양쪽의 TLS `NextProtos`(ALPN) 문자열이 정확히 일치하는가 (§4.2)
- [ ] `InsecureSkipVerify`/`insecure.NewCredentials()`가 각각 무엇을 생략하는 것인지 정확히 이해하고 사용했는가 (§4.3)
- [ ] 포트 바인딩 에러 메시지가 `tcp`가 아닌 `udp`로 나타난다는 점을 팀 문서/트러블슈팅 가이드에 반영했는가 (§5.1)
- [ ] `ServerRun`/`ClientRun` 등 에러를 반환하는 함수의 호출부에서 반환값을 빠짐없이 확인·출력하는가 (§5.2)
---
## 참고
* [gRPC-Go 공식 저장소](https://github.com/grpc/grpc-go)
* [quic-go 공식 저장소](https://github.com/quic-go/quic-go)
* 본 프로젝트 내 관련 코드: [`lib/grpc/http3/server.go`](../lib/grpc/http3/server.go), [`lib/grpc/http3/client.go`](../lib/grpc/http3/client.go), [`lib/grpc/http3/protoapi.proto`](../lib/grpc/http3/protoapi.proto)