Files
mc_p2p_mod/docs/DESIGN.md
EJClaw 16a44ec4bc
Some checks failed
build / build (push) Has been cancelled
feat: LAN 최대 인원 8 → 30 (단일 jar, 확장 불가 버전은 8 유지)
바닐라 "LAN 에 공개" 의 상수 8 을 믹스인 1개로 30 으로 바꾼다. 대상과 메서드를
문자열로만 적고 핸들러를 int -> int 로 만들어 마인크래프트 타입 참조 0 을 유지한다.

8 이 있는 자리 (바닐라 jar 바이트코드 실측, 버전마다 정확히 한 곳):
- 1.17 ~ 1.21.8   class_1130.<init> (super 인자 → PlayerList.maxPlayers)
- 1.21.9 ~ 1.21.11 class_1132.method_3802 (return 8)
- 26.1 ~ 26.3     IntegratedServer.getMaxPlayers (공식 이름)

@Pseudo + require=0 이라 자리를 못 찾는 버전은 크래시 없이 바닐라 8 로 돈다.
적용 여부는 웹 콘솔 "최대 인원" 과 로그로 보인다.

tools/lan-limit-probe: 바닐라 jar + Fabric Loader 로 모드를 실제 로드해 확인하는 도구.
1.18.2 ~ 26.3 (11개 버전, 로더 0.19.5 / 0.14.25) 전부 30, 믹스인을 뺀 jar 는 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:07:06 +09:00

19 KiB

설계 기록

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


1. 요구사항

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

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


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

기존에 배포되는 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 조건부 주석 (기존 구현은 GUI 한 파일에만 96개) → 0개
  • 믹스인 14개 → 1개(LAN 최대 인원 전용, 7절). 다른 모드와 충돌할 지점도 거의 사라졌다.
  • 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개를 들인 이유

"LAN 에 공개" 는 8명이 한계다. 클라이언트 코드에 상수로 박혀 있어 네트워크 쪽에서 우회할 수 없다 — 참가 판정(PlayerList.canPlayerLogin)이 players.size() >= 최대값 을 직접 본다. 30명 방을 만들려면 게임 코드의 그 8 을 바꾸는 수밖에 없고, Fabric 에서 그 수단은 믹스인뿐이다.

2절의 원칙을 깨지 않는 방법이 있었다. 대상과 메서드를 문자열로만 적고, 핸들러를 int -> int 로 만들면 믹스인 클래스에 마인크래프트 타입이 하나도 들어가지 않는다. 그러면 이름 체계가 다른 여러 버전의 대상을 한 클래스에 나란히 적을 수 있다.

8 이 있는 자리 (실측)

바닐라 클라이언트 jar 를 버전별로 내려받아 javap 로 bipush 8 을 셌다. 두 클래스 (IntegratedServer, IntegratedPlayerList)를 합쳐 모든 버전에서 정확히 한 곳이었다.

버전 이름 체계 위치
1.17.1 ~ 1.21.8 intermediary class_1130.<init> — super(..., 8) 인자, PlayerList.maxPlayers 필드로 간다
1.21.9 ~ 1.21.11 intermediary class_1132.method_3802 — return 8
26.1 ~ 26.3 공식 이름 IntegratedServer.getMaxPlayers — return 8

1.21.9 에서 PlayerList.maxPlayers 필드가 사라지고 판정이 server.getMaxPlayers() 로 바뀌었다. 즉 "필드를 리플렉션으로 바꾸기" 는 1.21.8 까지만 통한다. 최신 버전까지 덮으려면 메서드 본문의 상수를 바꿔야 해서 리플렉션이 아니라 믹스인이어야 했다.

설계

LanMaxPlayersMixin 하나가 세 대상을 동시에 노린다.

  • @Pseudo — 실행 중인 버전에 없는 대상 클래스는 건너뛴다.
  • @ModifyConstant(intValue = 8, require = 0) — 메서드 선택자 <init>, method_3802, getMaxPlayers 중 있는 것에서 8 을 찾는다. 없으면 실패하지 않는다.
  • 설정 파일도 required: false, defaultRequire: 0.
  • 1.21.8 이하에서 8 은 super() 호출 전이라 this 가 없다. 그래서 핸들러가 static 이다.
  • 핸들러는 MaxPlayers.replace 를 부르고, 8 이 아닌 값은 그대로 돌려준다.

결과적으로 미래 버전에서 자리가 바뀌면 이 믹스인은 아무것도 안 하고 게임은 8명으로 돈다. "올릴 수 있는 버전은 30, 아니면 8" 이 jar 하나에서 저절로 갈린다. 실제 적용 여부는 MaxPlayers.raised() 로 웹 콘솔에 표시한다.

치른 비용: 시작 로그에 "없는 대상 클래스" WARN 이 한두 줄 찍힌다(@Pseudo 의 정상 동작). 그리고 새 마인크래프트 버전이 나오면 이 표 한 줄은 다시 확인해야 한다. 2절에서 말한 "새 버전에 할 일이 없다" 는 이 기능에 한해서는 "확인할 일이 하나 있다" 로 바뀐다. 확인은 tools/lan-limit-probe 로 한다 — 바닐라 jar 와 Fabric Loader 를 받아 모드를 실제로 로드하고, 게임 메인 직전에 대상 클래스를 불러 값이 30 인지 본다.


8. 남은 과제

우선순위 순.

  1. 실제 게임 검증. 루프백 테스트는 프로토콜 계층까지만 본다. Fabric Loader 위에서 모드가 로드되고 최대 인원 믹스인이 적용되는 것까지는 1.18.2 ~ 26.3 에서 확인했다(7절). 화면을 띄운 실제 게임에서 접속되는지, 9명 이상이 실제로 들어오는지는 아직이다. 나머지 항목보다 이게 먼저다.
  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 가 없는 경우가 많아 홀펀칭 성공률을 크게 올릴 수 있다.

참고 자료

설계 근거로 삼은 것들.

이 목록 외에, 착수 전 조사 단계에서 배포 중인 P2P 모드 한 종을 분석해 문제 정의와 UX 를 참고했다. 본문에서 "기존 구현" 이라고 쓴 것이 그것이다. 제작자·저장소·서버 주소는 의도적으로 적지 않는다. 코드를 가져오지 않았고, 그들의 시그널링·STUN·TURN 서버를 쓰지도 않는다 — 이 프로젝트는 사용자가 직접 띄운 서버만 사용한다(README 의 시그널링 서버 항목 참고).