.NET System.Net.Quic으로 방화벽 뒤 P2P(홀펀칭) 뚫어보기

안녕하세요. 이번에는 요즘 사부작사부작 파보던 주제 하나를 공유해봅니다. 바로 **.NET의 `System.Net.Quic`으로 방화벽 뒤에서 P2P 연결이 될까?** 하는 이야기입니다 ^^ 시작은 단순한 궁금증이었어요. 최근 .NET에 들어온 QUIC… TCP도 아니고 UDP 기반이라던데, 이거라면 그 어렵다는 NAT/방화벽 뚫기(홀펀칭)를 해볼 수 있지 않을까? 하는 생각이었습니다. ## QUIC이 왜 P2P에 어울릴까요 QUIC은 UDP 위에서 도는 전송 프로토콜입니다. .NET 7부터 `System.Net.Quic`으로 정식 노출되었고, 내부적으로는 MsQuic을 씁니다. TLS 1.3이 기본이라 암호화는 공짜고, 연결 마이그레이션 같은 재미난 기능도 있죠. 무엇보다 **UDP 기반**이라는 점이 중요합니다. TCP로는 거의 불가능한 UDP 홀펀칭을 쓸 수 있거든요. P2P NAT 통과의 핵심이 바로 이 홀펀칭입니다. ## 그런데… `System.Net.Quic`엔 함정이 하나 있습니다 먼저 검증된 사례들을 찾아봤는데요, go-libp2p나 quic-go(Go) 같은 곳은 이미 프로덕션에서 QUIC 홀펀칭을 잘 하고 있었습니다. 그 비결은 **"하나의 UDP 소켓을 STUN·펀칭·QUIC가 전부 공유"** 하는 것이었어요. 문제는 여기서 생깁니다. `System.Net.Quic`은 내부의 MsQuic이 **자기 소켓을 직접 열어버려서, 내가 만든 소켓을 주입할 방법이 없습니다.** 아… 여기서 좀 막혔죠 ^^; 그래서 생각해낸 우회가 **"close & rebind"** 입니다. 이름 그대로예요. 1. 펀칭용 `UdpClient`를 로컬 포트 `P`에 열고, 상대에게 패킷을 쏴서 NAT에 구멍을 뚫고… 2. 그 소켓을 **닫은 뒤**, 3. QUIC를 **같은 포트 `P`** 로 다시 바인딩합니다. (`LocalEndPoint` / `ListenEndPoint` 지정) 말로만 하면 좀 헷갈리니, 그림 하나 그려봤습니다. ```text [로컬 포트 P] │ ├─ ① UdpClient로 상대에게 펀칭 패킷 발사 ──► NAT에 구멍이 뚫림! │ ├─ ② 펀칭 소켓 close (포트 P 반납) │ └─ ③ QUIC를 같은 포트 P로 rebind ─────────► 뚫린 그 구멍으로 QUIC 핸드셰이크 (LocalEndPoint = P) ``` 대부분의 cone NAT은 매핑을 내부 `(IP, 포트)` 기준으로 잡기 때문에, 같은 포트로 다시 올리면 뚫어둔 구멍이 그대로 유지되더라고요. 이게 될까 싶었는데… 되네요! ```csharp // 펀칭용 소켓으로 구멍을 뚫고 닫은 뒤, // 같은 로컬 포트로 QUIC를 다시 올립니다. var options = new QuicClientConnectionOptions { LocalEndPoint = new IPEndPoint(IPAddress.Any, localPort), // ★ 핵심 RemoteEndPoint = peerEndpoint, // ... TLS, KeepAlive 등 }; var connection = await QuicConnection.ConnectAsync(options); ``` > **핵심 한 줄:** 소켓을 공유하진 못해도, *같은 포트로 다시 바인딩* 하면 뚫어둔 NAT 구멍을 QUIC가 그대로 물려받습니다. ## 실제로 만들어봤습니다 말로만 하면 재미없으니 작은 PoC를 만들어봤어요. 전체 흐름은 이렇습니다. ```mermaid sequenceDiagram autonumber participant A as 피어A participant S as 내 서버 (presence + STUN) participant B as 피어B A->>S: 상시 접속(SSE) B->>S: 상시 접속(SSE) Note over A,B: 둘 다 online 확인 S-->>A: 상대 주소 + 펀치 시각 S-->>B: 상대 주소 + 펀치 시각 par 동시에 펀칭 A->>B: 펀칭 패킷 B->>A: 펀칭 패킷 end Note over A,B: close → 같은 포트로 QUI…