Some checks failed
build / build (push) Has been cancelled
코드와 산출물 jar 에는 애초에 외부 서버 주소가 없었으나(rendezvous 기본값이 빈 문자열), 설계 문서에 참고 대상의 제작자명과 저장소 경로가 남아 있었다. 기술적 관찰 사실은 설계 판단의 근거로 필요하므로 유지하되 "기존 구현" 으로 중립화했다. 더불어 이 프로젝트가 남의 인프라에 얹혀 가지 않는다는 점을 README 에 명시했다. rendezvous 는 빈 값으로 시작해 사용자가 직접 띄운 서버만 쓰며, 유일하게 남는 외부 호출인 STUN 기본값도 제거하는 방법을 함께 적었다. 검증: 작업 트리와 양쪽 산출물 jar 전수 검색 0건, 테스트 57개 전부 통과. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
266 lines
16 KiB
Markdown
266 lines
16 KiB
Markdown
# 설계 기록
|
|
|
|
왜 이렇게 만들었는지, 무엇을 버렸는지, 무엇이 아직 안 됐는지.
|
|
|
|
---
|
|
|
|
## 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개 → 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 가 없는 경우가 많아 홀펀칭 성공률을
|
|
크게 올릴 수 있다.
|
|
|
|
---
|
|
|
|
## 참고 자료
|
|
|
|
설계 근거로 삼은 것들.
|
|
|
|
- [Porting to 26.1 Snapshots — Fabric Documentation](https://docs.fabricmc.net/26.1/develop/porting/) — 비난독화와 Yarn/intermediary 폐지
|
|
- [Migrating Mappings — Fabric Documentation](https://docs.fabricmc.net/develop/porting/mappings/)
|
|
- [Fabric for Minecraft 26.1](https://fabricmc.net/2026/03/14/261.html)
|
|
- [FabricMultiVersionHelper](https://github.com/Klotzi111/FabricMultiVersionHelper) — 믹스인 조건부 로드 접근과 그 한계
|
|
- [LAN Server Discovery — mclauncher-api wiki](https://github.com/tomsik68/mclauncher-api/wiki/LAN-Server-Discovery) — 비콘 포맷
|
|
- [LAN Announcer](https://modrinth.com/mod/lan-announcer) — 멀티캐스트 주소·포트·간격 실측 선례
|
|
- [Implementing NAT Hole Punching with QUIC (arXiv 2408.01791)](https://arxiv.org/abs/2408.01791) — QUIC 홀펀칭과 마이그레이션의 RTT 이득
|
|
- [NAT Traversal — iroh](https://docs.iroh.computer/concepts/nat-traversal) — 중계 우선 + 직결 승격, 성공률
|
|
- [How Tailscale is improving NAT traversal](https://tailscale.com/blog/nat-traversal-improvements-pt-1) — DERP 폴백 모델
|
|
- [Kwik](https://github.com/ptrd/kwik) — 순수 자바 QUIC
|
|
|
|
이 목록 외에, 착수 전 조사 단계에서 배포 중인 P2P 모드 한 종을 분석해 문제 정의와 UX 를
|
|
참고했다. 본문에서 "기존 구현" 이라고 쓴 것이 그것이다. 제작자·저장소·서버 주소는 의도적으로
|
|
적지 않는다. 코드를 가져오지 않았고, 그들의 시그널링·STUN·TURN 서버를 쓰지도 않는다 —
|
|
이 프로젝트는 사용자가 직접 띄운 서버만 사용한다(README 의 시그널링 서버 항목 참고).
|