
.NET System.Net.Quic으로 방화벽 뒤 P2P(홀펀칭) 뚫어보기
공개 로그로그 요약
본문과 댓글은 @dimohy의 #quic 태그, 시리즈 없음, 리비전 v2, 2개 이전/다음 로그에 이어진 지식 로그입니다.
.NET에 들어온 QUIC로 방화벽/NAT 뒤에서 P2P가 될까? MsQuic의 소켓 주입 불가라는 함정을 close & rebind로 우회하고, presence 서버 + 자체 STUN + 자동 재연결까지 만들어본 연구 기록입니다.
안녕하세요. 이번에는 요즘 사부작사부작 파보던 주제 하나를 공유해봅니다. 바로 .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" 입니다. 이름 그대로예요.
- 펀칭용
UdpClient를 로컬 포트P에 열고, 상대에게 패킷을 쏴서 NAT에 구멍을 뚫고… - 그 소켓을 닫은 뒤,
- QUIC를 같은 포트
P로 다시 바인딩합니다. (LocalEndPoint/ListenEndPoint지정)
말로만 하면 좀 헷갈리니, 그림 하나 그려봤습니다.
[로컬 포트 P]
│
├─ ① UdpClient로 상대에게 펀칭 패킷 발사 ──► NAT에 구멍이 뚫림!
│
├─ ② 펀칭 소켓 close (포트 P 반납)
│
└─ ③ QUIC를 같은 포트 P로 rebind ─────────► 뚫린 그 구멍으로 QUIC 핸드셰이크
(LocalEndPoint = P)
대부분의 cone NAT은 매핑을 내부 (IP, 포트) 기준으로 잡기 때문에, 같은 포트로 다시 올리면 뚫어둔 구멍이 그대로 유지되더라고요. 이게 될까 싶었는데… 되네요!
// 펀칭용 소켓으로 구멍을 뚫고 닫은 뒤,
// 같은 로컬 포트로 QUIC를 다시 올립니다.
var options = new QuicClientConnectionOptions
{
LocalEndPoint = new IPEndPoint(IPAddress.Any, localPort), // ★ 핵심
RemoteEndPoint = peerEndpoint,
// ... TLS, KeepAlive 등
};
var connection = await QuicConnection.ConnectAsync(options);
핵심 한 줄: 소켓을 공유하진 못해도, 같은 포트로 다시 바인딩 하면 뚫어둔 NAT 구멍을 QUIC가 그대로 물려받습니다.
실제로 만들어봤습니다
말로만 하면 재미없으니 작은 PoC를 만들어봤어요. 전체 흐름은 이렇습니다.
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 → 같은 포트로 QUIC rebind
A->>B: 직접 QUIC P2P 연결
Note over A,B: 서버 경유 X
- presence 서버: 각 피어가 상시 접속(SSE)해서 "누가 온라인인지" 서버가 압니다. 둘 다 온라인이면 서로의 공인 주소와 동기화된 펀치 시각을 내려줍니다.
- 자체 STUN 리플렉터: 구글 STUN에 기대지 않고, 내 서버가 UDP로 공인 주소를 되돌려주게 했습니다. 자급자족이죠 ^^
- 자동 재연결: QUIC 연결이 죽으면(NAT 매핑이 만료되거나 상대가 끊기면) 감지해서 새 포트로 다시 펀칭하고 재연결합니다.
마지막 자동 재연결은 Syncthing 사용자분이 "QUIC 홀펀칭이 한 36시간쯤 잘 되다가 죽으면 영영 재연결이 안 된다"고 남긴 글을 보고 꼭 넣고 싶었던 부분이었습니다. 실제로 상대 피어를 강제로 죽여봤더니, 이쪽이 알아서 포트를 바꿔 다시 붙더라고요. 뿌듯 ^^
한계는 분명합니다
너무 장밋빛으로만 적으면 안 되겠죠. 솔직하게 남겨둡니다.
- Symmetric NAT에서는 이 방식이 깨집니다. 목적지마다 외부 포트가 바뀌거든요. 이건 QUIC의 한계가 아니라 NAT의 한계라, 결국 TURN 릴레이 폴백이 필요합니다.
- 아직 루프백/로컬 검증까지만 했습니다. 진짜 서로 다른 두 네트워크에서의 NAT 통과는 다음 숙제로 남겨뒀어요. 이 부분은 공인 서버에 올려서 꼭 실측해볼 생각입니다.
- MsQuic이 소켓을 직접 여는 구조라, close→rebind 사이의 아주 짧은 순간은 여전히 경합 구간입니다. (클라이언트가 몇 번 재시도하도록 해뒀습니다.)
마무리
정리하면, "System.Net.Quic으로 홀펀칭이 되냐"는 질문의 답은 '조건부로 됩니다' 정도가 될 것 같네요. 소켓을 공유하지 못하는 제약을 close & rebind로 메우면, cone NAT 환경에선 직접 QUIC P2P가 성립합니다. 다만 symmetric NAT과 실제 인터넷 검증이라는 숙제가 남아있습니다.
.NET 진영에는 아직 이걸 정면으로 다룬 공개 레퍼런스가 잘 안 보이더라고요. 그래서 살짝 개척자(?)가 된 기분으로 즐겁게 파봤습니다 ^^ 혹시 비슷한 걸 고민하시는 분이 계시면 반갑게 이야기 나눠요.
그럼 다음엔 실제 NAT 통과 성공기로 돌아오겠습니다… 되기를 바라며 ^^;



대화 흔적 (0)
이 로그에 댓글을 남기려면 지식 로그 홈으로 돌아가야 합니다. 지식 로그로 돌아가기
아직 이어진 댓글이 없습니다. 첫 댓글을 남겨 보세요.