Files
mc_p2p_mod/docs/DESIGN.md
EJClaw bacc2e91d3
Some checks failed
build / build (push) Has been cancelled
feat: 버전 독립 P2P 터널 모드 초기 구현
jar 하나로 모든 마인크래프트 버전을 지원해야 한다는 요구를 만족시키기 위해
마인크래프트 클래스를 전혀 참조하지 않는 구조로 설계했다. 26.1부터 게임이
비난독화되면서 Yarn/intermediary 가 폐지돼, 게임 클래스를 참조하는 jar 는
1.21.x 와 26.x 경계를 넘을 수 없다 — 버전별 빌드를 피할 유일한 길이다.

게임에서 필요한 두 가지를 자바 API 대신 네트워크 프로토콜로 얻는다.
호스트의 통합 서버 포트는 바닐라 "LAN 에 공개" 가 쏘는 멀티캐스트 비콘에서
읽고, 참가자에게는 같은 비콘을 우리가 쏘아 LAN 목록에 방을 띄운다. 덕분에
믹스인 0개, 버전 분기 0개, Loom·매핑 없는 평범한 자바 빌드가 됐다.

전송은 순수 자바 QUIC(Kwik)을 골랐다. 네이티브 WebRTC 대비 번들이 30MB →
0.71MB 로 줄고 아키텍처 제약이 사라진다. NAT 우회는 후보를 병렬로 시도하되
직결에 600ms 유예를 줘서, 항상 먼저 성공하는 중계가 직결을 밀어내지 않게 했다.
중계는 UDP 데이터그램을 그대로 넘기므로 직결과 터널 코드가 동일하고 QUIC
TLS 1.3 이 종단간이라 중계 서버는 트래픽을 읽을 수 없다.

초대코드는 120비트 난수 자체이고 서버에 등록되는 방 ID 는 그 해시에서
파생된다. 시그널링 서버 운영자도 방에 참가하거나 호스트를 사칭할 수 없다.

검증: 57개 테스트 전부 통과. 직결·중계 양쪽에서 터널이 실제 소켓으로 왕복하고,
512KB 전송, 초대코드 불일치 거절, 지문 불일치 차단, 실제 멀티캐스트 비콘
송수신, 시그널링 프로토콜, 웹 콘솔 HTTP 를 포함한다. 마인크래프트 클래스
참조 0개는 컴파일된 클래스의 상수 풀을 뒤지는 테스트로 강제한다.

실제 게임 클라이언트 접속과 실 NAT 홀펀칭 성공률은 아직 미검증이며
docs/DESIGN.md 에 남은 과제로 정리했다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-28 11:29:25 +09:00

15 KiB

설계 기록

왜 이렇게 만들었는지, 무엇을 버렸는지, 무엇이 아직 안 됐는지.


1. 요구사항

  1. 리소스를 덜 먹고 효율적일 것
  2. jar 하나로 모든 마인크래프트 버전을 지원할 것

2번이 설계 전체를 결정했다. 그래서 그것부터 정리한다.


2. "jar 하나로 모든 버전" 이 왜 어려운가

기존 P2P 모드들(참고 대상: KITE2459/kfcudp-instant-p2p)은 Stonecutter 로 17개 버전을 각각 빌드한다. 게을러서가 아니라, 마인크래프트 클래스를 참조하면 그 방법밖에 없기 때문이다.

결정적인 단절이 2026년에 생겼다. 마인크래프트 26.1부터 코드가 비난독화되면서 Fabric 의 Yarn/intermediary 매핑이 폐지됐다.

1.21.x 이하 26.1 이상
게임 코드 난독화됨 비난독화
매핑 Yarn / intermediary Mojang 공식 이름 그대로
모드가 참조하는 이름 intermediary (class_310) 공식 (net.minecraft.client.Minecraft)
런타임 리매핑 로더가 intermediary → 난독화 없음

즉 마인크래프트 클래스를 참조하는 jar 는 이 경계를 넘을 수 없다. intermediary 이름을 담은 jar 는 26.x 에서 해석되지 않고, 공식 이름을 담은 jar 는 1.21.x 의 난독화 런타임에 그런 클래스가 없다. Fabric 문서도 26.1 포팅에 "모든 기존 모드는 최소한 재컴파일이 필요하다" 고 못 박는다.

FabricMultiVersionHelper 같은 도구는 믹스인을 조건부로 로드해 주지만, 위의 이름 체계 단절 자체를 해결하지는 못한다. 버전별 믹스인 클래스를 여전히 사람이 따로 써야 한다.

결론: 마인크래프트 클래스를 0개 참조한다

우회로가 아니라 유일한 길이다. 그리고 P2P 터널링에는 실현 가능하다. 게임에서 정말로 필요한 것이 두 가지뿐이고, 둘 다 자바 API 가 아니라 네트워크 프로토콜로 노출돼 있다.

(1) 호스트의 통합 서버 포트를 알아내기

바닐라 "LAN 에 공개" 는 1.5초마다 멀티캐스트로 방송한다.

224.0.2.60:4445 (UDP)   IPv6: ff75:230::60
[MOTD]월드 이름[/MOTD][AD]25565[/AD]

이걸 들으면 사용자가 LAN 공개를 누른 순간과 그 포트를 알 수 있다. 믹스인이 필요 없다. 비콘이 끊기는 것은 월드가 닫혔다는 신호이기도 해서, 월드 종료 감지까지 같은 경로로 된다.

(2) 참가자에게 방을 보여 주기

같은 비콘을 우리가 쏘면 바닐라 클라이언트의 LAN 목록에 우리 터널이 그대로 나타난다. GUI 코드가 한 줄도 필요 없다.

여기서 중요한 사실: 페이로드에 IP 가 없다. 포트만 있다. 따라서 클라이언트는 UDP 패킷의 발신 주소를 서버 주소로 쓸 수밖에 없다. 우리가 어느 인터페이스로 쏘는지가 곧 클라이언트가 접속할 IP 를 결정한다는 뜻이고, LanMulticast.openSender() 가 인터페이스를 명시적으로 고정하는 이유다.

이 프로토콜은 마인크래프트 1.3(2012) 이후 바뀐 적이 없고 난독화와 무관하다. LAN Announcer 같은 기존 모드가 이미 같은 방식으로 원격 서버를 LAN 월드로 띄운다 — 선례가 있다.

유일한 예외

P2PMod 가 구현하는 net.fabricmc.api.ClientModInitializer 와 Config 가 쓰는 FabricLoader.getConfigDir(). 둘 다 Fabric Loader 소유라서 마인크래프트 클래스가 아니고 리매핑 대상도 아니며, 로더가 존재하는 모든 버전에서 같은 이름·같은 시그니처다.

ArchitectureTest 가 컴파일된 클래스의 상수 풀을 직접 뒤져서 이 약속을 강제한다. 누가 마인크래프트 타입을 하나라도 참조하면 빌드가 깨진다. import 를 grep 하는 것과 달리 바이트코드 수준의 보증이다. 로더 API 참조도 위 두 클래스 밖으로 새는 순간 실패한다.

얻은 것

  • 버전별 빌드 17개 → 1개
  • Stonecutter 조건부 주석 (참고 모드의 RoomListScreen.java 한 파일에만 96개) → 0개
  • 믹스인 14개 → 0개. 다른 모드와 충돌할 지점도 함께 사라졌다.
  • Fabric Loom·매핑·마인크래프트 jar 다운로드 없음 → 빌드가 몇 초로 끝나고 CI 매트릭스도 1칸
  • 게임 API 를 안 건드리므로 새 마인크래프트 버전이 나와도 할 일이 없다

치른 비용

조작 UI 를 게임 안에 넣을 수 없다. 그래서 웹 콘솔(http://127.0.0.1:25585)로 뺐다. 게임 화면을 벗어나야 하는 것은 분명한 단점이다. 대신 GUI 유지보수 비용이 통째로 사라졌고, 클립보드 붙여넣기 같은 조작은 오히려 브라우저가 낫다.


3. 전송: 순수 자바 QUIC

두 후보를 비교했다. 기준은 사용자가 요구한 "효율과 리소스" 다.

순수 자바 QUIC (Kwik) 네이티브 WebRTC (dev.onvoid.webrtc)
네이티브 라이브러리 0개 플랫폼별 필수
번들 크기 0.71 MB (실측) 약 30 MB (win 7.9 + linux 8.8 + mac 7.0 + mac-arm 5.8)
플랫폼 제약 없음 (JVM 만) 번들한 아키텍처만
계층 QUIC = TLS 1.3 + 혼잡제어 + 스트림 다중화 DTLS + SCTP + ICE 별도
NAT traversal 직접 구현 libwebrtc 내장 (검증 충분)

순수 자바 QUIC을 선택했다. 크기가 42배 차이 나고, 아키텍처 제약이 사라지는 것이 결정적이다. 참고 모드의 WebRTC 빌드는 linux-aarch64 를 번들에서 빠뜨려서 ARM 리눅스에서 아예 실패하는데, 네이티브가 없으면 그런 실수를 할 여지 자체가 없다. "jar 하나로 모든 버전" 목표와도 맞는다. QUIC 은 암호화·혼잡제어·다중화를 프로토콜 한 겹에서 주므로 DTLS+SCTP+ICE 조합보다 얇다.

바꿔 말하면 NAT traversal 을 직접 써야 한다. libwebrtc 가 수년간 다진 ICE 예외 처리를 포기하는 것이라 이 부분이 이 프로젝트에서 가장 위험한 코드다. 그래서 여기에 테스트를 몰아 두었다.

라이브러리는 tech.kwik:kwik:0.11 (Maven Central 최신). 의존성은 agent15(TLS 1.3), hkdf, siphash 뿐이고 모두 순수 자바다. 클래스 파일 major 55 = Java 11 이라 우리 하한(17)을 제약하지 않는다.

알려진 한계: Kwik 은 연결당 스레드를 쓰는 블로킹 모델이다. 수만 연결을 받는 서버라면 문제가 되겠지만 방 하나에 참가자 몇 명인 상황에서는 무관하다. 논블로킹이 필요해질 만큼 커지면 그때 다시 볼 일이다.


4. NAT traversal 설계

조사 결과 현재 정설은 두 가지였다.

  • QUIC 기반 홀펀칭이 TCP 기반보다 낫다. 특히 열악한 회선에서 차이가 크고, IETF 초안(ADD_ADDRESS / PUNCH_ME_NOW / REMOVE_ADDRESS)은 QUIC 의 경로 검증을 그대로 써서 네이티브로 구멍을 뚫고 직결로 옮겨가는 방향을 제시한다.
  • Tailscale·iroh 는 "중계로 먼저 붙이고 나중에 직결로 갈아탄다." 연결이 즉시 성립하고 홀펀칭이 성공하면 조용히 승격된다. iroh 는 이 방식으로 약 9/10 성공률을 보고한다. 권장 폴백 순서는 직결 → QUIC 프록시 → TURN → 최후에 TCP/443 이다.

채택: 병렬 시도 + 직결 유예

갈아타기(migration)는 스트림을 끊김 없이 이전하는 장치가 추가로 필요하다. 그 복잡도를 지금 감당할 이유가 없다고 판단했다. 대신 모든 후보를 동시에 찔러 보되 직결에 600ms 유예를 준다.

  • 직결이 되면 즉시 쓴다 (유예를 다 기다리지 않는다)
  • 직결이 안 되면 600ms 후 중계로 넘어간다

유예가 없으면 중계가 항상 이긴다. 중계 서버는 NAT 뒤에 없어서 첫 프로브에 바로 응답하기 때문이다. 최악의 대기가 600ms 인 대신 구현이 훨씬 단순하다. P2PSocket.awaitPath 에 이 판단을 적어 두었고, 갈아타기는 아래 남은 과제로 넘겼다.

참고 모드는 이걸 다르게 풀었다. 1차는 TURN 후보를 아예 만들지 않고, 실패하면 2차에 릴레이를 허용해 재협상한다. 목적은 같지만 세션을 두 번 만들고, 양쪽이 같은 단계로 맞춰야 해서 "조인자의 OFFER 재협상 횟수로 호스트가 단계를 유추" 하는 조율이 들어간다. 유예 방식은 세션 하나로 끝난다.

소켓을 하나로 묶는 이유

NAT 매핑은 (내부IP:포트 → 공인IP:포트) 단위로 생긴다. 홀펀칭으로 구멍을 뚫고 QUIC 을 다른 소켓으로 열면 그 구멍은 쓸모가 없다. 그래서 STUN 질의, 펀칭 프로브, QUIC 데이터가 전부 P2PSocket 하나를 지난다.

구현은 DatagramSocket 을 상속해 수신을 가로채는 방식이다. 전용 읽기 스레드 하나가 들어온 데이터그램을 프로브와 나머지로 나눠, 프로브는 직접 처리하고 나머지는 큐에 넣어 Kwik 에게 넘긴다. 읽기 주체가 하나라 "펀칭 루프와 Kwik 수신 스레드가 패킷을 서로 훔쳐가는" 경쟁이 생기지 않는다.

프로브 첫 바이트는 0x11 이다. QUIC 패킷은 헤더 고정 비트(0x40)가 반드시 1 이므로 0x40 비트가 꺼진 첫 바이트는 어떤 유효한 QUIC 패킷과도 겹치지 않는다.

중계는 UDP 데이터그램을 그대로 넘긴다

TCP 로 중계하면 QUIC 을 그 위에 올릴 수 없다. TURN 처럼 데이터그램을 넘기면 직결과 중계에서 터널 코드가 완전히 같아진다 — 목적지 주소만 다르다. 그리고 QUIC 의 TLS 1.3 이 종단간이라 중계 서버는 지나가는 트래픽을 읽을 수 없다.

중계는 방마다가 아니라 참가 세션마다 포트 한 쌍을 배정한다. 방 단위로 하면 여러 참가자가 같은 포트를 공유해 서로의 주소를 덮어쓴다.


5. 초대코드가 시그널링 서버로부터 비밀인 구조

초대코드 = 120비트 난수 그 자체. 서버에 등록되는 방 ID 는 그 난수의 SHA-256 앞 40비트에서 파생된다.

초대코드(비밀) ──SHA-256──> 방 ID (서버가 보는 값)
      │                          │
      └─ 코드 소유자는 방 ID 를    └─ 서버는 이 값에서 코드를
         직접 계산할 수 있다          되돌릴 수 없다

효과가 두 가지다. 코드를 아는 사람은 조회 과정 없이 방 ID 를 바로 계산하고, 서버 운영자는 방에 몰래 참가하거나 호스트를 사칭할 수 없다. 호스트가 스트림 첫머리에서 원본 비밀을 대조하기 때문이다(비교는 MessageDigest.isEqual 로 타이밍 노출을 막는다).

표기는 Crockford Base32 24자를 6자씩 4묶음으로 끊는다. I·L·O·U 를 뺀 알파벳이라 1·0 과 눈으로 헷갈리지 않고, 입력받을 때는 그 혼동을 되돌려 준다(I·L→1, O→0).


6. 의존성을 줄인 선택들

전부 같은 이유다 — 마인크래프트가 어떤 라이브러리를 클래스패스에 올려 두는지는 버전마다 다르다. JDK 안에서 끝내면 그 변수가 사라진다.

안 쓴 것 대신 이유
SLF4J System.out 래퍼 1.17 미만에는 없다
GSON / JSON 라이브러리 java.util.Properties 버전마다 존재 여부가 다르다
BouncyCastle 손으로 조립한 X.509 DER (약 100줄) 위와 같음
com.sun.net.httpserver 직접 쓴 HTTP (약 80줄) jdk.httpserver 모듈이 항상 있다고 보장할 수 없다
WebSocket 줄 단위 TCP 텍스트 메시지가 4종류뿐. 서버가 200줄 아래로 떨어진다
STUN 라이브러리 Binding 요청만 직접 (약 150줄) 필요한 게 요청 1종·속성 1개다
Gradle Shadow 플러그인 jar 태스크에서 직접 번들 의존성 4개라 재배치가 필요 없다

직접 쓴 것에는 전부 테스트를 붙였다. 손으로 쓴 프로토콜 코드는 검증 없이 두면 안 된다. 인증서 테스트는 조립한 DER 이 실제로 파싱되고 서명 검증까지 통과하는지 본다.


7. 남은 과제

우선순위 순.

  1. 실제 게임 검증. 루프백 테스트는 프로토콜 계층까지만 본다. 1.21.x 와 26.x 양쪽에서 실제로 로드되고 접속되는지 확인해야 한다. 나머지 항목보다 이게 먼저다.
  2. 실제 NAT 뒤에서의 홀펀칭 성공률 측정. 같은 기기 안 테스트에는 NAT 가 없다. 대칭 NAT· CGNAT 비율에 따라 중계 의존도가 결정되므로 숫자를 봐야 다음 판단을 할 수 있다.
  3. 시그널링 TLS. 지금은 평문 TCP 라 이 경로를 장악한 중간자가 인증서 지문을 바꿔칠 수 있다. 참고 모드도 같은 약점을 갖고 있다(기본 ws://). TLS 를 씌우거나 초대코드에서 파생한 키로 시그널링 메시지를 인증하면 된다. 후자가 서버 인증서 없이 되므로 더 맞을 수 있다.
  4. 중계 → 직결 승격. 4절에서 미룬 것. 중계로 먼저 붙여 대기를 0 으로 만들고, 홀펀칭이 성공하면 조용히 갈아탄다. QUIC 의 연결 마이그레이션(CID 유지)을 쓰면 재핸드셰이크 없이 가능하고 측정치로는 QUIC 재펀칭 대비 2 RTT, TCP 재펀칭 대비 3 RTT 를 아낀다. Kwik 의 마이그레이션 지원 범위를 먼저 확인해야 한다.
  5. 호스트 측 방 관리. 화이트리스트·킥·밴. 중계를 지나면 모든 참가자가 같은 주소로 보이므로 IP 기준이 아니라 초대코드/연결 단위로 설계해야 한다.
  6. NeoForge 지원. 코어가 게임과 로더를 참조하지 않으므로 진입점만 추가하면 된다. 다만 단일 jar 에 두 로더 매니페스트를 같이 넣으면 일부 도구가 잘못 판단하는 사례가 있어, jar 를 나누는 편이 나을 수 있다.
  7. IPv6 후보. 지금은 IPv4 전용이다. IPv6 는 NAT 가 없는 경우가 많아 홀펀칭 성공률을 크게 올릴 수 있다.

참고 자료

설계 근거로 삼은 것들.