실시간 PvP 게임은 어떻게 만들어지는가

worldclasscitizen·2026년 4월 22일

SSAFY

목록 보기
7/9

📖 여는 글

안녕하세요.

이 문서는 Colosseum 프로젝트의 네트워크 토대가 되는 Photon Fusion 2를 중심에 두고, 게임 네트워킹 전반을 이해하실 수 있도록 차근차근 풀어낸 학습 칼럼입니다. 단순히 "Fusion에서 이 함수를 어떻게 쓴다"를 넘어, 그 함수가 왜 그런 모양으로 존재하고 내부에서 어떤 동작이 일어나는지, 그리고 그 아래에 있는 네트워크 계층에서는 어떤 일이 벌어지고 있는지까지 한 번에 연결해서 설명드리려고 합니다.

💡 읽기 전 당부
중간에 나오는 전문 용어를 그냥 넘어가지 마시고 그 용어가 처음 등장할 때 드리는 설명을 확실하게 이해하고 넘어가시는 것인데요. 뒤쪽의 고급 주제들은 앞쪽의 기초 용어들을 당연히 안다는 전제로 쌓아 올리기 때문에, 하나라도 이해가 흐릿한 채로 진행하시면 후반부에 갈수록 점점 읽기가 어려워지실 겁니다.


1. 🧩 게임 네트워킹이 어려운 이유

1.1. 일관성 - 반응성 - 공정성의 Trade-off

 게임 네트워킹이 왜 어려운지부터 이야기해 볼까요. 많은 분들이 "그냥 내 입력을 서버에 보내고 결과를 받아서 그리면 되는 거 아닌가요?" 하고 생각하시는데요, 실제로 해 보시면 그 단순한 접근이 얼마나 끔찍한 플레이 경험을 만들어내는지 금방 깨닫게 되실 겁니다.

 게임 네트워킹의 본질적인 문제는 한 줄로 요약하자면,

🌌 "같은 가상 세계를 여러 명이 동시에 관찰하고 있는데, 각자의 관찰 시점이 네트워크 지연 때문에 조금씩 어긋나 있다"

는 것인데요. 이건 사실 현실 세계의 특수상대성이론과 묘하게 닮아 있습니다. 광속이 유한하기 때문에 멀리 있는 두 관찰자는 같은 사건을 다른 시간에 다른 순서로 보게 되는데, 네트워크 세계에서도 정보가 광속보다 빠르게는 전달되지 않으니까 같은 일이 벌어지는 것입니다.

 이런 비동기적 현실에서 게임 네트워킹 엔진이 달성해야 하는 목표가 세 가지 있는데요.

  • 일관성(consistency) 🎯 : 모든 플레이어가 결국에는 같은 게임 결과를 보게 되어야 한다는 뜻입니다.
  • 반응성(responsiveness) ⚡ : 플레이어가 키를 눌렀을 때 화면이 즉시 반응해야 한다는 것입니다.
  • 공정성(fairness) ⚖️ : 누구는 핑이 낮아서 유리하고 누구는 높아서 불리하면 게임이 망가진다는 것입니다.

 문제는 이 세 목표가 서로 싸운다는 것인데요. 일관성을 완벽히 달성하려면 모든 입력을 서버로 보내고 결과를 받을 때까지 기다려야 하는데, 그러면 반응성이 죽습니다. 반응성을 극대화하려면 클라이언트가 즉시 결정을 내려야 하는데, 그러면 클라이언트들끼리 상태가 갈라져서 일관성이 깨집니다. 공정성을 위해 서버에 권위를 집중시키면 이번에는 서버로부터 물리적으로 먼 플레이어가 불리해지는 새로운 불공정이 생겨납니다.

 현대 게임 네트워킹 프레임워크들은 — Fusion을 포함해서 — 이 삼각형의 적당한 균형점을 다음과 같은 정해진 패턴으로 타협합니다.

🔑 "클라이언트가 예측하고, 서버가 판정하고, 둘의 차이는 롤백으로 보정한다"

뒤에서 이 패턴을 아주 자세히 풀어드릴 텐데요, 지금 시점에서는 "세 목표가 본질적으로 상충한다는 것, 그래서 완벽한 해결책은 없고 타협만 있다는 것"만 받아들이고 넘어가시면 됩니다.

1.2. 결정적 시뮬레이션

 여기서 결정적(deterministic) 이라는 용어를 짚고 넘어가야 하는데요. 결정적이라는 말은 "같은 입력을 주면 언제 어디서 실행해도 반드시 같은 결과가 나온다"는 뜻입니다. 예를 들어 x = 2 + 3 이라는 코드는 언제 실행해도 x가 5가 되므로 결정적이지만, x = Random.Range(0, 10) 이라는 코드는 매번 결과가 달라지므로 비결정적(non-deterministic) 입니다.

 왜 이 개념이 네트워크에서 중요하냐 하면요, 뒤에서 설명드릴 롤백 재시뮬레이션(rollback resimulation) 이라는 기술이 이 결정성을 전제로 동작하기 때문인데요. 쉽게 말하면 "과거의 어떤 시점으로 돌아가서 그때부터 다시 계산하면 원래와 똑같은 결과가 나올 수 있다"는 보장이 있어야 네트워크 오차를 보정할 수 있다는 것입니다. 이 보장이 없으면 보정할 때마다 결과가 달라져서 클라이언트들끼리 점점 더 어긋나게 됩니다.

⚠️ 주의
Fusion에서 FixedUpdateNetwork()라는 함수 안의 코드는 결정적이어야 한다고 강하게 요구받는데, 바로 이 이유 때문입니다. 이 원칙을 어기는 실수가 Fusion 초심자들이 가장 자주 빠지는 함정이므로 꼭 기억해 두셔야 합니다.


2. 🌐 OSI 7계층과 패킷

2.1. OSI 7계층

 네트워크 공부를 하실 때 OSI 7계층(OSI 7 Layer Model) 이라는 것을 들어보셨을 겁니다. 이것은 네트워크 통신을 일곱 개의 추상화 층으로 쪼개서 설명하는 교육용 모델인데요, 실제 구현은 이 모델과 완벽히 일치하지는 않지만 각 층의 역할을 이해하는 데는 여전히 유용합니다.

 간단히 훑어드리면, 가장 아래의 1층인 물리 계층(Physical Layer) 은 전기 신호나 빛 신호 같은 실제 매체를 다루는 층이고, 2층인 데이터 링크 계층(Data Link Layer) 은 한 네트워크 안에서 기기들 사이의 프레임 전달을 담당합니다. 3층인 네트워크 계층(Network Layer) 은 여러 네트워크를 가로지르는 라우팅을 담당하는데 여기서 IP 프로토콜이 등장합니다. 4층인 전송 계층(Transport Layer) 은 프로그램과 프로그램 사이의 통신을 책임지며 TCP와 UDP가 이 층의 주인공들입니다. 5층인 세션 계층(Session Layer) 은 논리적 세션의 열고 닫음을 관리하고, 6층인 표현 계층(Presentation Layer) 은 데이터 인코딩과 암호화를 담당하며, 마지막 7층인 응용 계층(Application Layer) 은 실제 게임 프로토콜이나 HTTP, SMTP 같은 것들이 사는 곳입니다.

 게임 관점에서 가장 중요한 층은 4층의 전송 계층7층의 응용 계층 인데요. 전송 계층에서 UDP냐 TCP냐를 선택하는 순간 게임의 네트워크 특성이 거의 결정되고, 응용 계층에서 Fusion이 어떤 데이터를 어떤 주기로 보낼지가 결정됩니다. 중간의 2, 3층은 주로 OS와 하드웨어가 알아서 처리해 주므로 게임 개발자가 직접 건드릴 일은 거의 없습니다.

게임 관점에서 본 네트워크 스택

  [7층 응용]   Fusion의 틱 페이로드 — 입력과 스냅샷 델타
  [6층 표현]   Networked 속성의 직렬화, 비트패킹, 델타 압축
  [5층 세션]   Photon 룸, AppId, 세션 관리
  [4층 전송]   UDP (Fusion은 UDP 기반)
  [3층 네트워크]   IP — Photon Cloud 릴레이 또는 P2P 경로
  [2층 데이터링크]   이더넷, 와이파이 — MTU 약 1500바이트
  [1층 물리]   광섬유, 전파 — 지연의 물리적 하한은 광속

2.2. 왜 게임은 UDP를 쓰는가 🤔

 여기서 많은 분들이 궁금해하시는 질문, "왜 게임은 UDP를 쓸까요?" 를 제대로 답해 드릴 텐데요. TCP는 우리 일상에서 훨씬 더 친숙합니다. 웹 브라우징도 TCP이고 파일 다운로드도 TCP입니다. TCP는 세 가지 특성을 강제하는데요,

  1. 순서 보장
  2. 신뢰성 — 유실된 패킷의 재전송
  3. 혼잡 제어

이 셋은 웹 브라우징 관점에서는 축복이지만, 게임 관점에서는 하나하나가 독이 됩니다.

🚧 순서 보장의 함정

 먼저 순서 보장의 함정을 보시면요. TCP는 100번 패킷이 유실되고 101번, 102번, 103번이 이미 도착해 있어도 100번이 재전송되어 도착할 때까지 101번 이후의 데이터를 응용 프로그램에 전달하지 않습니다. 이 현상을 Head-of-Line Blocking 즉 선두 차단이라고 부릅니다. 웹 브라우징에서는 문서를 순서대로 읽어야 하니 맞는 설계지만, 게임에서는 치명적입니다. 왜냐하면 게임에서 중요한 것은 "지금 가장 최신 상태가 뭐야?"인데, 과거의 유실된 한 패킷 때문에 신선한 최신 정보가 큐에 묶여 버리기 때문입니다.

🗑️ 재전송의 낭비

 다음으로 재전송의 낭비를 보시면요. 예를 들어 30Hz 틱, 즉 1초에 서른 번 스냅샷이 날아온다고 가정해 보겠습니다. 100번 틱의 스냅샷이 유실되더라도 곧 도착할 101번 스냅샷이 그 내용을 포함하거나 대체하기 때문에, 100번을 재전송하는 것은 도착해 봐야 이미 쓸모없는 정보가 된 뒤입니다. 대역폭만 낭비되고 플레이 경험에는 아무 도움이 안 됩니다.

😡 혼잡 제어의 과민반응

 마지막으로 혼잡 제어의 과민반응입니다. TCP는 패킷 유실을 곧 네트워크 혼잡의 신호로 해석하고 송신 속도를 즉시 절반으로 깎아 버립니다. 이 알고리즘을 AIMD(Additive Increase Multiplicative Decrease) 즉 "가산적 증가, 승법적 감소"라고 하는데요, 무선 환경에서 일시적으로 신호가 약해져서 한두 패킷 유실된 상황에서도 TCP는 그것을 혼잡으로 오해하고 속도를 확 떨어뜨립니다. 와이파이로 게임할 때 가끔 프레임레이트가 뚝 떨어지는 경험, 이게 TCP였다면 그 이유인 셈입니다.

✅ 그래서 UDP

 그래서 게임은 UDP 위에서 동작합니다. UDP는 순서도, 재전송도, 혼잡 제어도 하지 않습니다. 그저 "이 데이터를 저쪽으로 던져"라고 하면 던져 주고, 도착하든 말든 책임지지 않는 매우 단순한 프로토콜입니다. 대신 그 책임을 응용 프로그램 즉 Fusion 같은 프레임워크가 "필요한 만큼만" 다시 구현합니다.

 Fusion이 그것을 어떻게 하느냐 하면요,

  • 상태 스냅샷 은 유실되어도 다음 스냅샷이 대체하니까 재전송하지 않는 비신뢰(unreliable) 방식으로 보냅니다.
  • 반면 일회성으로 딱 한 번 발생하는 이벤트인 RPC 는 반드시 도착해야 하니까 신뢰(reliable) 방식으로 보내되 중복 도착 시 걸러내고 순서도 보장합니다.
  • 플레이어 입력 은 또 다른 전략을 씁니다. 최근 몇 틱의 입력을 매 패킷에 중복해서 담아 보내는데, 덕분에 한 패킷이 유실되어도 다음 패킷에 이전 입력이 남아 있어 복원할 수 있습니다.

이렇게 상황에 맞는 신뢰성을 골라 쓰는 것이 UDP 기반 게임 네트워킹의 정수입니다.

2.3. MTU와 단편화 📦

 여기서 MTU 라는 용어를 소개해 드리겠습니다. MTU(Maximum Transmission Unit)는 "한 번에 보낼 수 있는 패킷의 최대 크기"를 뜻하는데요, 이더넷 환경에서는 보통 1500바이트 입니다. 이 1500바이트에서 IP 헤더 20바이트와 UDP 헤더 8바이트를 빼면, 실제 우리가 실어 보낼 수 있는 알맹이는 약 1472바이트 정도가 됩니다.

 만약 Fusion이 한 틱에 만든 스냅샷 데이터가 이 1472바이트를 넘어가면 어떻게 될까요? IP 계층이 자동으로 그것을 여러 조각으로 쪼개서 보내는데, 이것을 단편화(fragmentation) 라고 부릅니다. 문제는 이 조각들 중 하나라도 유실되면 전체 패킷이 재조립되지 못하고 폐기된다는 것입니다. 즉 조각이 많을수록 전체 유실률이 기하급수적으로 올라갑니다.

🎯 실전 지침
Fusion을 최적화하실 때 중요한 지침 중 하나가 "한 틱 페이로드를 MTU 안에 욱여넣기"입니다. [Networked] 속성이 너무 많아져서 이 한계를 넘기 시작하면 네트워크 안정성이 급격히 나빠지므로, 정말 네트워크 진실(truth)로 남겨야 하는 값만 Networked로 만드는 절제가 필요합니다.

Colosseum의 docs/50_기술_구조_기준/02_네트워크_truth_표.md 문서가 이 원칙을 엄격히 강제하고 있는데, 바로 이 이유 때문입니다.

2.4. NAT, 홀 펀칭, 릴레이 🕳️

 다음으로 NAT(Network Address Translation) 이야기를 해 볼까요. 여러분 집의 인터넷 공유기가 바로 이 NAT 장치입니다. 우리 집 안의 PC, 노트북, 스마트폰은 각자 192.168.0.x 같은 사설 IP(private IP) 를 갖고 있는데요, 이 IP는 전 세계에서 유일한 주소가 아니라서 그대로는 외부 인터넷에서 찾아올 수 없습니다. 공유기가 중간에서 "이 집의 공인 IP는 하나고, 안쪽의 여러 기기는 포트 번호로 구분한다"는 규칙으로 번역해 주는 것이 NAT의 일입니다.

 NAT 덕분에 IP 주소 고갈 문제가 완화되지만 부작용이 하나 있습니다. 외부에서 안쪽으로 먼저 연결을 시도하면 공유기가 "이건 내가 요청한 응답이 아니네" 하고 차단해 버린다는 것 입니다. 그래서 두 플레이어가 P2P 즉 피어 투 피어(peer to peer) 방식으로 직접 연결하려고 해도 서로의 NAT 때문에 막히는 상황이 자주 벌어집니다.

 이 문제를 우회하는 유명한 기술이 홀 펀칭(hole punching) 인데요. 두 피어가 거의 동시에 서로에게 UDP 패킷을 던지면, 각자의 NAT이 "어, 방금 나간 트래픽의 응답이 돌아오는 거구나" 하고 착각해서 임시로 구멍을 열어 줍니다. 이 구멍이 열린 직후부터는 서로 통신이 가능해지는 것입니다. Photon Cloud 같은 서비스는 이 홀 펀칭을 자동으로 시도해 줍니다.

 그런데 NAT 종류에 따라 홀 펀칭이 실패하는 경우가 있는데, 특히 대칭형 NAT(Symmetric NAT) 라는 엄격한 타입에서는 거의 실패합니다. 기업 네트워크나 일부 통신사의 모바일 환경에서 이런 NAT을 자주 만나게 되는데요. 이 때는 마지막 수단으로 릴레이(relay) 를 씁니다. Photon Cloud의 릴레이 서버가 중간에서 트래픽을 받아 다른 쪽으로 전달해 주는 방식인데, 양쪽 피어 모두 바깥쪽으로만 연결하면 되므로 NAT 문제가 없습니다. 대신 트래픽이 물리적으로 한 번 더 우회하기 때문에 지연 시간이 거의 두 배로 늘어납니다.

 Colosseum이 현재 Host Mode MVP지만 나중에 Dedicated Server 전환을 염두에 둔 이유 중 하나가 바로 이것입니다. Dedicated Server는 공인 IP를 가진 클라우드에 상주하니까 NAT 문제가 원천적으로 없고, 릴레이를 거치지 않아도 모든 클라이언트가 직접 서버에 연결할 수 있어서 지연 시간이 개선됩니다.


3. 🏗️ 게임 네트워크 아키텍처의 종류

3.1. 권위 모델의 스펙트럼

 게임에서 "누가 진짜(authoritative) 게임 상태를 소유하느냐"에 따라 네트워크 아키텍처가 네 가지로 나뉩니다. 이 개념이 중요한 이유는 권위 모델에 따라 치트 취약성, 개발 난이도, 운영 비용, 네트워크 특성이 전부 달라지기 때문입니다.

1️⃣ 순수 P2P (peer-to-peer)

 각 클라이언트가 자기 캐릭터의 상태를 소유하고 다른 클라이언트에 전파하는 방식인데요, 구현이 단순하고 지연이 낮지만 치트에 완전히 무방비이고, 두 클라이언트가 서로 모순된 상태를 주장하면 해결할 방법이 없어서 현대 게임에서는 거의 쓰지 않습니다.

2️⃣ 락스텝 (lockstep)

 모든 클라이언트가 같은 입력 시퀀스를 받아서 결정적으로 시뮬레이션을 돌리는 방식인데요, 각자 상태를 계산하지만 모두 같은 입력을 같은 순서로 받으니 결과도 같아야 한다는 보장으로 동작합니다. 전송해야 할 데이터가 입력뿐이라 대역폭이 최소인데, 대신 가장 느린 피어의 속도에 전체가 맞춰져야 한다는 치명적 단점이 있습니다. 스타크래프트 같은 고전 RTS가 이 방식입니다.

3️⃣ 호스트 권위 (host-authoritative)

 한 클라이언트가 서버 역할을 겸하면서 권위를 갖는 구조인데요, 개발이 빠르고 별도 서버 비용이 없어서 MVP 단계에 적합합니다. 다만 호스트 플레이어는 네트워크 지연이 없는 반면 다른 플레이어는 있다는 불공정, 호스트가 치트를 써도 막을 방법이 없다는 근본적 취약, 호스트가 게임을 나가면 세션이 붕괴된다는 문제가 있습니다. Fusion의 Host Mode 가 이 방식이고 Colosseum이 MVP 단계에서 채택한 것입니다.

4️⃣ 전용 서버 권위 (server-authoritative / dedicated server)

 게임에 참여하지 않는 별도의 전용 서버가 권위를 갖고, 모든 클라이언트는 그 서버에 접속해서 플레이하는 구조인데요. 공정성이 보장되고 치트 방어가 가능하며 운영 통제도 용이합니다. 대신 서버를 돌리는 클라우드 비용이 발생하고, 리전별로 서버를 배치해야 하며, 매치메이킹과 서버 오토스케일링 같은 운영 인프라가 필요합니다. 상용 온라인 게임 대부분이 이 방식을 쓰며 Fusion의 Server Mode 가 이것입니다.

3.2. 상태 동기화와 이벤트 동기화

 네트워크로 무엇을 보낼 것인가에 대한 두 가지 근본적 접근 방식이 있는데요, 이 구분을 명확히 이해하고 넘어가셔야 합니다.

📸 상태 동기화 (state synchronization)

 매 틱마다 "지금 플레이어 위치는 (x, y)이고 체력은 75입니다"라는 현재 상태 자체를 전송하는 방식인데요. 이 방식의 장점은 멱등성(idempotency) 입니다. 같은 메시지가 여러 번 도착해도 결과가 같다는 뜻인데, 한 번 도착하면 값이 덮어씌워지고 또 도착해도 같은 값으로 덮어씌워지니 유실이나 중복에 매우 강합니다. 한 틱이 유실되어도 다음 틱이 곧 새 값으로 덮어 주니까 복구가 자동입니다.

🎬 이벤트 동기화 (event synchronization)

 "이 시점에 플레이어가 총을 쐈다", "이 시점에 카드를 뽑았다" 같은 일회성 사건을 전송하는 방식인데요. 대역폭은 적게 들지만 유실되면 그 사건이 영영 없었던 일이 되므로 재전송을 반드시 해야 합니다. 또 순서가 바뀌면 결과가 달라지므로 순서 보장도 필요합니다. RPC가 바로 이 이벤트 방식의 전형 인데요.

 어느 정보를 상태로 보내고 어느 것을 이벤트로 보낼지 결정하는 것이 네트워크 설계의 핵심입니다. Colosseum 팀이 docs/50_기술_구조_기준/02_네트워크_truth_표.md 문서에서 엄격히 분류해 둔 것을 보시면, 체력, 탄약, 위치, 반사 횟수 같은 지속 상태는 Networked 상태로, 히트스톱, 카메라 흔들림, 파티클 같은 순간 연출은 로컬 전용으로, 사망이나 구간 돌파 같은 결과에 영향을 주는 사건은 Networked 상태나 Networked 이벤트로 처리하도록 규정하고 있습니다.

⚠️ desync 주의
왜 이 구분을 엄격히 해야 하느냐 하면요, 이 경계가 흐려지면 desync 즉 상태 불일치 가 발생하기 때문입니다. 예를 들어 피격 이펙트를 이벤트로 보내고 체력 변화를 상태로 보냈는데, 이벤트는 성공적으로 도착해서 이펙트가 떴지만 상태 갱신은 유실되어서 체력이 안 깎이는 상황이 발생할 수 있습니다. 플레이어 입장에서는 "분명 맞았는데 체력은 그대로다" 하는 당혹스러운 경험이 되는 것입니다.


4. Photon Fusion 2

 이제 본격적으로 Fusion 내부를 파헤쳐 보시죠.

💡 "클라이언트는 예측하고, 서버는 판정하며, 둘의 차이는 롤백으로 수정한다. 이 모든 것은 틱 기반 결정적 시뮬레이션 위에서 일어난다"

위 문장에 담긴 각 개념을 차례대로 풀어 드리겠습니다.

4.1. 틱 = Fusion의 시간 단위

 먼저 틱(tick) 이라는 개념인데요. Unity에는 여러분이 이미 잘 아시는 Update() 함수가 있습니다. 이 함수는 프레임마다 한 번씩 호출되는데, 프레임 시간이 하드웨어 성능에 따라 달라지니까 Update() 호출 간격도 제각각입니다. 60FPS에서는 약 16.67밀리초마다, 30FPS에서는 약 33밀리초마다 호출되지만, 실제로는 더 들쭉날쭉합니다.

 반면 Fusion의 틱은 완전히 다른 시간축 을 씁니다. 네트워크 설정에서 정한 고정 주기로 돌아가는데요, 기본값은 30Hz 즉 1초에 서른 번이며 격투나 슈팅처럼 정밀도가 중요한 장르는 60Hz로 올리기도 합니다. FixedUpdateNetwork(), 줄여서 FUN() 이라고 부르는 함수가 이 틱 주기마다 한 번씩 호출됩니다.

  Unity의 Update() — 가변 주기
  |--|-|--|---|--|---|--|-|--|
  
  Fusion의 Tick — 고정 주기
  |---|---|---|---|---|---|---|
   T0  T1  T2  T3  T4  T5  T6

 왜 이렇게 시간축을 분리했을까요? 답은 "네트워크에서 공통 시간 기준이 필요하기 때문" 입니다. "플레이어 A가 T=152번째 틱에서 점프했다"는 메시지는 모든 피어에게 동일한 의미를 가져야 하는데, 틱 번호는 결정적이고 공유 가능하지만 실시간 프레임은 그렇지 않습니다. 제 컴퓨터의 "33밀리초 뒤"와 여러분 컴퓨터의 "33밀리초 뒤"는 물리적으로 같은 시점이 아닙니다. 그래서 네트워크는 시간을 틱으로 센다고 이해하시면 됩니다.

🚨 매우 중요한 규칙
FUN() 안에서는 절대로 Time.deltaTime을 쓰면 안 됩니다.
Time.deltaTime은 프레임 시간이라 틱 간격과 일치하지 않고 비결정적이기 때문인데요, 대신 Runner.DeltaTime을 쓰셔야 합니다. 이 값은 틱 간격과 정확히 일치하는 고정값이고 모든 피어가 동일한 값을 갖습니다.

Colosseum 가드레일 문서에도 이것이 명시적 금지선으로 올라가 있는데, 이 실수 하나가 네트워크 전체를 망가뜨릴 수 있기 때문입니다.

4.2. 스냅샷과 델타 압축 📸

 서버가 매 틱 클라이언트들에게 보내는 데이터 덩어리를 스냅샷(snapshot) 이라고 부릅니다. 단순하게 생각하면 "현재 모든 NetworkObject의 모든 Networked 속성 값"을 전부 담아 보내는 것이지만, 실제 Fusion은 그렇게 비효율적으로 동작하지 않습니다. 대신 델타 압축(delta compression) 이라는 기법을 씁니다.

 델타 압축이 어떻게 동작하느냐 하면요, 서버가 각 클라이언트별로 "이 클라이언트가 마지막으로 잘 받았다고 확인(ACK)한 틱 번호"를 기억하고 있습니다. 그래서 새 스냅샷을 보낼 때는 전체 상태가 아니라 "마지막 ACK 시점 대비 달라진 값만" 담아서 보냅니다. 변하지 않은 값은 아예 전송하지 않는 것입니다.

💡 중요한 함의
이 메커니즘의 중요한 함의는 "Networked 속성의 개수가 아니라 변화 빈도가 대역폭을 결정한다" 는 점입니다.

매 틱마다 변하는 float 값 — 예를 들어 플레이어 위치 — 은 필연적으로 매 스냅샷에 포함되어야 하니 대역폭을 항상 차지합니다. 반면 거의 변하지 않는 byte 값 — 예를 들어 승리 횟수 — 은 변할 때만 전송되니 사실상 공짜입니다.

 이 원리를 알면 최적화할 때 어디에 칼을 댈지 보이기 시작합니다. 변화가 잦은 값은 꼭 Networked여야 하는지 한 번 더 검토하고, 가능하면 양자화(quantization) 즉 정밀도를 낮춰서 비트수를 줄이는 식의 최적화를 적용할 수 있습니다. Fusion은 [Networked, Accuracy(0.01f)] 같은 어트리뷰트로 이것을 간편하게 지정할 수 있게 해 둡니다.

4.3. 관심 영역 관리 🎯

 여기서 AoI 라는 개념을 소개해 드리겠습니다. AoI는 Area of Interest 의 약자로 "관심 영역"이라고 번역하는데요, 아이디어는 간단합니다. 플레이어 A에게 게임 월드의 모든 객체 상태를 보낼 필요가 없다는 것입니다. A에게서 멀리 떨어진 적의 정확한 위치는 어차피 화면에도 안 보이고 게임 결정에도 영향을 주지 않으니까요.

 Fusion의 AoI 시스템은 각 NetworkObject에 "관심 영역 위치"를 지정하고, 각 플레이어의 시점에서 일정 반경 안에 있는 객체만 복제(replicate)합니다. 이 설정을 활성화하면 대역폭이 "전체 객체 수에 비례"하던 것에서 "근처 객체 수에 비례"하는 것으로 획기적으로 줄어듭니다.

 Colosseum처럼 한 라운드에 두 명이 작은 맵에서 싸우는 구조에서는 AoI가 큰 의미가 없을 수 있지만, 나중에 관전자 기능을 추가하거나 더 큰 맵, 더 많은 인원이 참여하는 모드를 만들게 되면 필수로 검토해야 하는 기능입니다.

4.4. 클라이언트 예측 🔮

 이제 Fusion의 핵심 중의 핵심, 클라이언트 예측(client-side prediction) 이야기를 할 차례입니다. 왜 이게 필요한지부터 짚어 볼까요.

 가장 순진한 클라이언트-서버 구조를 상상해 보시면요, 이런 흐름입니다.

  1. 플레이어가 키를 누르면 클라이언트가 그 입력을 서버로 보냅니다.
  2. 서버가 입력을 받아서 처리한 뒤 결과 상태를 클라이언트로 보냅니다.
  3. 클라이언트가 그 결과를 받아서 화면에 표시합니다.

문제는 이 왕복에 RTT(Round-Trip Time) 즉 왕복 지연 시간만큼의 딜레이 가 생긴다는 것인데요. RTT가 100밀리초라면 플레이어가 키를 누르고 나서 0.1초 뒤에야 캐릭터가 움직이기 시작합니다. 격투 게임에서 이건 완전히 파탄입니다.

 이것을 해결하기 위해 클라이언트 예측 구조가 등장합니다. 흐름은 이렇습니다.

  1. 플레이어가 키를 누르면 클라이언트가 두 가지를 동시에 합니다.
    • 하나는 로컬에서 즉시 그 입력을 반영해 시뮬레이션을 진행 하는 것 즉 "예측"
    • 다른 하나는 같은 입력을 서버로 보내는 것
  2. 서버는 그 입력을 받아 처리한 뒤 권위 있는 결과 상태를 클라이언트로 보냅니다.
  3. 클라이언트는 서버에서 온 권위 상태와 자기가 예측한 상태를 비교합니다.
  4. 둘이 일치하면 아무것도 하지 않고, 불일치하면 서버 상태로 되돌린 뒤 그 이후 쌓여 있는 입력들로 다시 시뮬레이션을 수행합니다.

이 "되돌리고 다시 시뮬레이션하는" 과정을 롤백 재시뮬레이션(rollback resimulation) 이라고 부르는데요, Fusion의 FUN()이 한 틱에 여러 번 호출될 수 있는 이유가 바로 이것입니다. 재시뮬레이션 중에는 과거의 입력들을 하나씩 꺼내서 그 시점의 FUN()을 차례로 돌려야 하니까요.

4.5. 재시뮬레이션의 흐름 🔄

 좀 더 구체적으로 상상해 보실 수 있도록 시나리오를 하나 들어 드리겠습니다. 클라이언트가 T=100번 틱부터 T=105번 틱까지 예측으로 시뮬레이션을 진행했다고 해 봅시다. 그러다가 T=105 시점에 서버로부터 "T=101 시점의 권위 있는 상태는 이것입니다"라는 메시지가 도착했는데, 클라이언트가 예측했던 T=101의 상태와 다릅니다. 예를 들어 서버는 "너는 맞았어" 라고 말하는데 클라이언트는 "안 맞았다"고 예측했던 상황입니다.

  시간:    T=100    T=101    T=102    T=103    T=104    T=105
  예측:   [P100]  [P101]  [P102]  [P103]  [P104]  [P105]
                     ↑
              서버가 "여기 권위 상태는 S101이야" 라고 T=105에 통보
  
  재시뮬:         [S101] → FUN() → [S102'] → FUN() → [S103']
                                 → FUN() → [S104'] → FUN() → [S105']

 이때 Fusion 내부에서 일어나는 일은 다음과 같습니다. 먼저 모든 Networked 상태를 서버가 알려준 T=101의 권위 상태로 되돌립니다. 그 다음 T=102부터 T=105까지 저장해 두었던 로컬 입력들을 차례로 꺼내서 FUN()을 다시 실행합니다. 총 네 번 재호출되는 것입니다. 그리고 T=106부터는 평소처럼 진행합니다.

 이 동작 방식이 정상적으로 동작하려면 몇 가지 엄격한 규칙이 필요한데요, 이것들이 Fusion 초심자들이 가장 자주 어기는 규칙이라 정말 꼭 기억해 두셔야 합니다.

재시뮬 3대 규칙

  1. FUN() 안의 모든 코드는 결정적이어야 합니다. Random.Range()를 그냥 호출하면 재시뮬 때마다 다른 결과가 나와서 이상해지니, Runner.Seed를 기반으로 한 결정적 랜덤을 써야 합니다.
  2. Networked 속성을 FUN() 바깥에서 변경하면 안 됩니다. Update()에서 체력을 깎으면 재시뮬 시 그 변화가 재현되지 않아 엇나갑니다.
  3. FUN() 안에서 비결정적 부작용 — 사운드 재생, 파티클 생성 같은 것들 — 을 일으키면 재시뮬 때마다 중복 발생할 수 있으니 주의해야 합니다. Fusion은 Runner.IsForward라는 플래그를 제공해서 "지금이 처음 시뮬레이션인지 재시뮬인지"를 구분할 수 있게 해 두었는데, 부작용은 이것이 true일 때만 실행하면 됩니다.

 그리고 예측이 가능한 범위에 대해서도 짚어 드릴게요.

  • 예측 가능 : 로컬 플레이어의 이동, 자신의 발사, 자신의 점프처럼 입력 권한이 로컬에 있고 결과가 입력에서 결정론적으로 따라 나오는 것들
  • 예측 불가능 : 다른 플레이어의 행동, 랜덤한 카드 뽑기 결과, 상대의 공격 판정 같은 것들 — 정보가 네트워크 너머에 있으므로

이 예측 불가능한 것들은 다음 섹션의 보간으로 처리합니다.

4.6. 스냅샷 보간 — 원격 객체를 부드럽게 보여주는 법 🌊

 로컬 플레이어는 예측으로 처리한다고 했는데, 그럼 다른 플레이어는 어떻게 보여줄까요? 그 사람의 입력을 모르니 예측할 수 없고, 서버가 주는 스냅샷은 30Hz나 60Hz 같은 이산적인 간격으로만 도착합니다. 게다가 네트워크 지터(jitter) 때문에 도착 간격이 들쭉날쭉합니다.

📚 지터란?
지터는 패킷 도착 간격의 변동성 을 말합니다. 평균 RTT가 80밀리초여도 실제로는 어떤 패킷은 60밀리초에 오고 어떤 패킷은 120밀리초에 오는 식으로 들쭉날쭉한 것입니다. 게임 경험에서 사실 RTT 자체보다 지터가 더 중요한데요, 일정한 지연은 예측 가능하지만 변동은 예측 불가능하기 때문입니다.

 이 문제를 해결하는 기법이 스냅샷 보간(snapshot interpolation) 입니다. 핵심 아이디어는 "살짝 과거를 본다" 는 것인데요. 클라이언트가 방금 받은 스냅샷을 바로 표시하는 게 아니라, 그것을 잠시 버퍼에 저장해 두었다가 약 한두 틱 정도 뒤처진 시점을 기준으로 두 스냅샷 사이를 부드럽게 보간해서 표시합니다.

  서버 송신:   T=100 ────── T=101 ────── T=102 ──────
                 ↓             ↓             ↓
  클라 수신:   수신→버퍼     수신→버퍼     수신→버퍼
  
  클라 렌더:  ─── T=99.5 ────── T=100.5 ────── T=101.5 ───
                (항상 약 1~2틱 뒤처진 시점을 부드럽게)

 이 방식의 장점은 스냅샷 도착이 조금 지연되거나 한 개가 유실되어도 버퍼에 여유가 있으니 끊김 없이 부드럽게 보일 수 있다는 것입니다. 단점은 내가 보는 다른 플레이어의 모습이 실제 서버 상태보다 한두 틱 과거라는 것인데, 이게 뒤에 나올 레이턴시 보상(lag compensation) 이슈의 근본 원인이 됩니다.

 보간 버퍼 크기에는 중요한 트레이드오프가 있습니다.

  • 🪫 버퍼가 너무 짧으면 : 패킷이 조금만 지연되어도 보간할 데이터가 없어서 화면이 뚝뚝 끊기는 러버밴딩(rubber-banding) 현상이 생깁니다.
  • 🔋 반대로 너무 길면 : 보이는 것이 항상 너무 과거가 되어서 상대의 행동을 늦게 인지하게 됩니다.

Fusion은 네트워크 지터를 실시간으로 측정해서 버퍼 크기를 자동 조정하는데요, NetworkProjectConfigInterpolationMinBufferSize, InterpolationMaxBufferSize 같은 파라미터가 이 동작의 범위를 정합니다.

4.7. INetworkInput — 입력을 어떻게 보내는가 🎮

 Fusion에서 플레이어 입력은 "매 틱마다 일정한 크기의 구조체를 제출하는" 방식으로 처리됩니다. 예를 들어 Colosseum의 입력 구조체는 이런 모양이 될 것입니다.

public struct ColosseumInput : INetworkInput {
    public Vector2 MoveDir;        // 이동 방향
    public NetworkButtons Buttons; // 점프, 발사, 반사 등 비트플래그
    public Angle AimAngle;         // 조준 각도
}

 INetworkInput이라는 인터페이스를 구현한 이 구조체를 매 틱 채워서 Fusion에 건네주면, Fusion이 그것을 서버로 보내고, 서버는 그것을 처리한 뒤 다른 클라이언트들에게도 재분배합니다. FUN() 안에서 GetInput<ColosseumInput>(out var input)으로 꺼내 쓰면 됩니다.

 여기서 한 가지 흥미로운 점을 말씀드리면요, 입력 패킷은 유실되면 치명적 이라는 것입니다. 왜냐하면 한 틱의 입력이 사라지면 재시뮬레이션 때 그 틱에 아무 입력도 없었던 것처럼 처리되니까 결과가 바뀌어 버립니다. 그래서 Fusion은 입력 중복 재전송(input redundancy) 기법을 씁니다. 최근 몇 틱의 입력을 매 패킷에 함께 실어 보내는데요, 예를 들어 T=100 입력을 보낼 때 T=98, T=99, T=100을 같이 담아 보내는 식입니다. 덕분에 한 패킷이 유실되어도 다음 패킷에 이전 입력이 남아 있어 복구가 가능합니다.

 또 한 가지 고급 주제가 있는데요, 입력 지연(input delay) 방식입니다. 지금까지 설명드린 예측 방식과 대비되는 방식인데, 격투 게임 커뮤니티에서 유명한 GGPO 가 쓰는 방식입니다. 핵심 아이디어는 "입력을 바로 적용하지 않고 X틱 뒤에 적용한다"는 것입니다. 예를 들어 2틱 지연으로 설정하면, 지금 누른 키는 2틱 뒤에야 게임에 반영되는데요. 대신 그 2틱 동안 상대방의 입력이 도착할 시간이 확보되니까 예측이나 롤백 없이도 양쪽이 완벽히 일관된 시뮬레이션을 돌릴 수 있습니다. Colosseum은 FPS 성격이 더 강해서 예측 방식이 더 어울리지만, 만약 격투 느낌을 강화하는 방향으로 가신다면 하이브리드 방식을 고려해 볼 수 있습니다.

4.8. [Networked] 속성의 내부 동작 🔬

 [Networked]라는 어트리뷰트를 붙이면 마법처럼 네트워크 동기화가 되는데, 내부에서 무슨 일이 일어나는지 들여다보겠습니다.

public class Player : NetworkBehaviour {
    [Networked] public byte Health { get; set; }
    [Networked] public Vector2 AimDir { get; set; }
    [Networked, Capacity(20)] public NetworkLinkedList<ProjectileInfo> Projectiles => default;
}

 이 코드가 빌드될 때 Fusion의 소스 제너레이터(source generator) 가 개입합니다. 소스 제너레이터는 컴파일 타임에 자동으로 추가 코드를 생성해 주는 C# 기능인데요, Fusion은 이것을 이용해서 [Networked] 속성의 실제 구현 코드를 여러분 대신 작성해 둡니다. 그래서 게시자 수준에서는 보통 C# 속성처럼 보이지만 사실은 동기화 메커니즘이 뒷단에 숨어 있는 것입니다.

 내부 구현의 핵심은 이렇습니다. 모든 [Networked] 속성은 해당 NetworkObject가 관리하는 고정 크기의 메모리 블록에 배치됩니다. Health를 읽는 것은 이 블록의 특정 오프셋을 읽는 것이고, Health를 쓰는 것은 그 오프셋에 값을 쓴 뒤 "이 필드가 변경됐다"는 더티 플래그(dirty flag) 를 세우는 동작입니다. 다음 틱 페이로드를 만들 때 Fusion은 이 더티 플래그를 보고 "아, 이 필드는 변했으니 델타에 포함시켜야겠다" 하고 판단합니다. 변하지 않은 필드는 전송되지 않으니 앞서 말씀드린 델타 압축이 자연스럽게 이루어지는 것입니다.

 그리고 권한 체크도 자동으로 들어갑니다. 만약 HasStateAuthority가 아닌 클라이언트에서 Health = 50을 시도하면 에디터에서는 경고가 뜨고 실제 빌드에서는 그 쓰기가 무시됩니다. 이것이 치트나 의도치 않은 상태 변경을 막는 첫 번째 방어선입니다.

🔑 권한 모델 정리

 여기서 권한 모델을 한 번 정리하고 넘어가겠습니다. 각 NetworkObject는 두 가지 권한을 갖습니다.

  • 상태 권한(StateAuthority) : 이 객체의 Networked 값을 쓸 권리
  • 입력 권한(InputAuthority) : 이 객체에 입력을 제공할 권리

플레이어 캐릭터 객체를 예로 들면, 입력 권한은 해당 플레이어의 클라이언트에게 있고 상태 권한은 서버 — Host Mode면 호스트 클라이언트, Server Mode면 전용 서버 — 에게 있습니다. 플레이어가 키를 누르면 그 입력이 서버로 전달되고, 서버는 FUN()에서 그 입력을 처리해서 상태를 변경한 뒤 모든 피어에게 새 스냅샷을 뿌리는 흐름입니다. Colosseum의 truth 표에서 "발사 요청은 InputAuthority가 제출하고 StateAuthority가 검증한다" 고 한 것이 바로 이 패턴입니다.

4.9. RPC — 일회성 이벤트 채널 📞

 다음으로 RPC(Remote Procedure Call) 라는 것을 이야기해 볼까요. 직역하면 "원격 절차 호출"인데, 다른 기기에서 내 함수를 실행시키는 메커니즘입니다. Fusion에서는 [Rpc] 어트리뷰트를 붙이면 됩니다.

[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
public void RPC_ShowVictoryFlash(PlayerRef winner) {
    // 이 함수 본문은 모든 클라이언트에서 실행됨
}

 RPC는 Networked 속성과는 완전히 다른 채널로 전송됩니다. 신뢰성이 있고 순서가 보장되며 반드시 도착합니다. 또 롤백 재시뮬레이션과 무관하게 딱 한 번만 실행됩니다. 대신 신뢰성 보장을 위해 ACK 기반이라 RTT 영향을 받고 오버헤드가 상대적으로 큽니다.

안티패턴 경고
많은 초심자가 "RPC가 직관적이니까 모든 것을 RPC로 해결" 하는 경향이 있는데요, 이건 재앙입니다. 매 틱 위치를 RPC로 보내면 대역폭이 폭발하고 네트워크가 마비됩니다. Networked 상태로 하면 델타 압축되고 자연스럽게 흘러갈 것을 RPC로 하면 비효율적일 뿐 아니라 잘못된 타이밍에 실행될 수도 있습니다.

 RPC의 올바른 용도는 "드물게 발생하고, 반드시 도착해야 하며, 시뮬레이션 밖에서 처리해도 되는 것" 입니다. 게임 시작/종료 신호, 승리 연출 트리거, 채팅 메시지 같은 것들입니다. 반대로 매 틱 변할 수 있는 상태, 시뮬레이션 결과에 영향을 주는 값은 Networked 속성으로 처리해야 합니다. Colosseum의 truth 표가 이 구분을 엄격히 강제하는 것을 다시 한번 강조드립니다.

4.10. NetworkRigidbody2D와 물리 동기화 ⚙️

 Colosseum은 2D 물리 기반이므로 물리 동기화가 중요한데요, 이게 의외로 까다로운 주제입니다. 왜냐하면 Unity의 물리 시스템은 자체적인 FixedUpdate 주기 — 보통 50Hz — 에서 시뮬레이션되는데, Fusion의 FixedUpdateNetwork는 또 다른 주기 — 보통 30Hz나 60Hz — 에서 돌기 때문입니다. 이 두 시간축이 어긋나면 문제가 됩니다.

 Fusion 2의 해법은 자체 물리 러너 를 갖고 Fusion의 틱과 일치하는 물리 스텝을 FUN() 직전에 실행하는 것입니다. 그리고 NetworkRigidbody2D라는 컴포넌트가 Rigidbody2D의 위치, 속도, 회전을 Networked 속성으로 감싸서 서버와 클라이언트 사이에 동기화해 줍니다.

 여기서 실무에서 자주 틀리는 포인트를 몇 가지 짚어 드릴게요.

⚠️ 물리 동기화 3대 주의점

  1. Rigidbody2D.AddForce()는 반드시 FUN() 안에서 호출해야 합니다. Update()에서 호출하면 비결정적 타이밍에 물리가 적용되어 재시뮬 때 결과가 안 맞습니다.
  2. OnTriggerEnter2D 같은 Unity 콜백은 Unity 쪽에서 호출하는 거라 FUN() 바깥의 타이밍에서 발생합니다. 이걸 그대로 네트워크 로직에 쓰면 결정성이 깨지므로, Fusion은 틱 기반 충돌 감지 패턴 — 예를 들어 Runner.GetPhysicsScene2D()를 써서 틱 안에서 명시적으로 쿼리하는 방식 — 을 권장합니다.
  3. 3D 관련 컴포넌트는 절대 섞지 마셔야 합니다. Colosseum의 가드레일 문서가 "3D Rigidbody, 3D Collider, ConfigurableJoint는 금지한다"고 명확히 선언한 것이 이 이유 때문입니다.

5. 📊 네트워크 품질을 가늠하는 네 가지 지표

5.1. 레이턴시 ⏳

 레이턴시(latency) 즉 지연 시간인데요, 가장 흔히 언급되는 지표입니다. 일반적으로는 RTT(Round-Trip Time) 즉 왕복 시간을 측정합니다. 내가 보낸 패킷이 상대방에게 갔다가 응답이 돌아오는 데 걸리는 총 시간입니다. 한 방향만의 지연(one-way latency)은 두 기기의 시계를 동기화해야 정확히 잴 수 있어서 일반적인 측정은 어렵습니다.

 체감 기준으로 말씀드리면:

레이턴시체감
50ms 이하 🟢쾌적함 (격투/FPS 권장)
150ms 이하 🟡그럭저럭 플레이 가능
250ms 이상 🔴사실상 정상 플레이 어려움

이 지연의 물리적 하한은 빛의 속도인데요, 서울과 부산 사이는 광속으로 약 1.5밀리초, 서울과 뉴욕 사이는 약 35밀리초입니다. 이건 이론적 최솟값이고 실제로는 라우팅과 큐잉 오버헤드 때문에 더 걸립니다. 그래서 리전별 서버 배치가 중요한 것입니다.

5.2. 지터 📈

 지터(jitter) 는 앞서 잠깐 설명드렸지만 다시 짚으면, 패킷 도착 간격의 변동성 입니다. 평균 RTT가 80밀리초로 일정한 것보다 20~140밀리초 사이에서 들쭉날쭉한 게 훨씬 더 나쁩니다. 왜냐하면 일정한 지연은 보간 버퍼로 완벽히 흡수할 수 있지만, 변동은 버퍼를 크게 잡아야 대응할 수 있고 그건 평균 반응성 저하로 이어지기 때문입니다.

 지터의 원인은 주로 경로 중간의 라우터 큐잉, 와이파이 경합, ISP의 트래픽 혼잡 같은 것들인데요. Fusion의 보간 버퍼가 이 지터를 흡수하는 완충 장치 역할을 하지만 흡수량에는 한계가 있습니다.

5.3. 패킷 손실 💧

 패킷 손실(packet loss) 은 유실되는 패킷의 비율입니다.

  • 1% 이하 🟢 : 정상
  • 5% 이상 🟡 : 게임 체감 악화
  • 10% 이상 🔴 : 심각한 문제

원인은 무선 간섭, 혼잡으로 인한 라우터의 큐 오버플로우, 앞서 설명드린 MTU 초과로 인한 단편 유실 등 다양합니다.

 Fusion은 유실에 강한 구조지만 한계는 분명히 있습니다. 특히 입력 패킷 유실은 재시뮬의 입력 누락을 낳을 수 있고, 여러 연속 스냅샷이 유실되면 보간 버퍼가 비어서 뚝뚝 끊기는 현상이 생깁니다.

5.4. 대역폭 📶

 대역폭(bandwidth) 은 단위 시간당 전송 가능한 바이트 수입니다. 많은 분들이 다운로드 속도만 생각하시는데, 게임에서는 업로드 속도가 더 자주 병목 이 됩니다. 일반 가정용 인터넷은 다운로드는 수백 Mbps지만 업로드는 수십 Mbps에 불과한 경우가 많기 때문입니다.

 Fusion 기본 설정에서 플레이어 두 명이 붙는 Colosseum 같은 상황이면 초당 약 2KB 수준, 열 명 규모 모드라면 초당 약 30KB 수준으로 생각하시면 됩니다. 이 정도는 현대 인터넷 환경에서 문제가 되지 않지만, 모바일 데이터 요금제를 고려하면 "장시간 플레이 시 총 사용량"도 의식해야 하는 부분입니다.


6. 🛠️ 실무에서 마주치는 네트워크 문제와 대처법

6.1. 러버밴딩 🪀

 러버밴딩(rubber-banding) 은 캐릭터가 앞으로 쭉 가다가 갑자기 뒤로 끌려오는 듯이 보이는 현상을 말합니다. 고무줄처럼 보인다고 해서 붙은 이름입니다.

 원인을 분석해 보면, 클라이언트의 예측과 서버의 권위 상태가 자주 크게 어긋나서 반복적인 큰 폭의 보정(reconciliation) 이 발생하는 것입니다. 예측 알고리즘과 서버 알고리즘이 다른 공식을 쓰거나, 서버가 아직 입력을 받기 전인데 클라이언트가 너무 먼 미래까지 예측해 나가거나, 결정성이 깨져서 같은 입력으로도 다른 결과가 나오는 경우에 발생합니다.

 대처 방법은 여러 가지가 있는데요.

  1. 가장 근본적인 것은 예측 공식과 서버 공식을 같은 코드로 공유 하는 것입니다. Fusion에서는 FUN() 한 벌만 쓰므로 자연스럽게 이 원칙이 지켜집니다.
  2. 다음으로 보정 시 차이가 일정 임계값 이하면 즉시 스냅하지 않고 몇 프레임에 걸쳐 부드럽게 보간하는 기법이 있습니다. 이것은 프레젠테이션 레이어에서 처리되는 시각적 스무딩 인데요, 진짜 상태는 서버 값으로 이미 바뀌어 있지만 눈에 보이는 것만 천천히 따라가게 하는 방식입니다.
  3. 마지막으로 네트워크가 악화되면 예측 거리를 동적으로 줄이는 적응형 전략 도 쓸 수 있습니다.

⚖️ 트레이드오프 경고
여기서 트레이드오프를 하나 지적해 드리고 싶은데요. 스무딩으로 러버밴딩을 숨기면 "진짜 상태"와 "보이는 상태"가 잠시 어긋나게 됩니다. Colosseum에서 이것은 "내가 피한 것처럼 보였는데 실제로는 맞은 처리"같은 판정 혼란으로 이어질 수 있습니다. 그래서 피격이나 사망 같은 결정적 순간에는 스무딩을 쓰지 않고 즉시 정정하는 것이 truth 표의 권고입니다.

6.2. 상태 불일치 🧬

 디싱크(desync) 즉 상태 불일치 는 두 클라이언트가 서로 다른 게임 상태를 믿고 있는 현상입니다. 최악의 증상은 "나는 이겼는데 상대 화면에서는 내가 죽어 있는" 것입니다.

 원인은 대체로 조합적으로 발생하는데요.

  • FUN() 안에 비결정적 코드가 섞여 있거나 — 직접 Random 호출, Time.deltaTime 사용, 순서가 불확정한 Dictionary 순회 같은 것들이요
  • Networked 속성을 FUN() 밖에서 변경하거나
  • RPC로 처리해야 할 결과 상태를 로컬 전용 이벤트로 처리한 경우 등입니다.

 대처는 예방이 최선인데요.

  1. 결정성 감사 를 주기적으로 합니다. FUN() 경로의 모든 코드가 결정적인지 리뷰하고, 의심스러운 API 사용을 금지 목록으로 관리합니다. Colosseum 가드레일의 금지선이 이 역할을 합니다.
  2. 개발 모드에서 주기적으로 양쪽 상태를 해시해서 일치하는지 자동 검사 하는 디버그 훅을 심어 둡니다.
  3. truth 표를 엄격히 준수 합니다. 무엇이 상태이고 무엇이 이벤트이고 무엇이 로컬 전용인지 명확히 분류하고 이 경계를 흐리지 않습니다.

6.3. 레이턴시 보상 — 슈팅 게임의 본질적 딜레마 🎯

 이제 조금 난이도 있는 주제를 다뤄 보겠습니다. 레이턴시 보상(lag compensation) 은 슈팅이나 격투 장르에서 본질적으로 발생하는 딜레마를 다루는 기법인데요. 상황을 구체적으로 그려 드리겠습니다.

 플레이어 A의 핑은 50밀리초이고 플레이어 B의 핑은 100밀리초라고 해 봅시다. 서버 시간 T=500에 B가 한 자리에 서 있었습니다. A가 바로 그 순간 B를 조준해서 쐈습니다. 그런데 A의 화면에 보이는 B는 실제로는 T=450 시점의 위치입니다. 왜냐하면 A는 B의 상태를 네트워크로 받아서 보간 버퍼를 거쳐 표시하니까 약 50밀리초 지연된 B를 보고 있었던 것입니다.

 A의 "쏘기" 입력이 서버에 도착했을 때 서버 시간은 이미 T=500 이후입니다. 그런데 그 사이에 B는 이동했습니다. A의 관측 시점에서 본 B 위치를 기준으로 하면 명중이지만, 서버의 현재 B 위치를 기준으로 하면 빗맞음입니다. A 입장에서는 "분명 조준해서 쐈는데 빗맞았다"고 느끼고, B 입장에서는 "분명 피했는데 맞았다"고 느낄 수 있습니다.

 이 문제를 해결하는 기법이 레이턴시 보상 입니다. 서버가 A의 "쏘기" 입력을 처리할 때, A가 실제로 그 순간 본 게임 세계 상태로 모든 다른 플레이어의 위치를 되돌려서 판정을 수행합니다. Valve의 Source 엔진 이 이것을 유명하게 구현했고 카운터 스트라이크, 팀 포트리스 2 같은 게임이 다 이 방식입니다.

  서버가 T=500에 A의 "쏘기" 입력을 받음
  
  A의 관측 시점 계산:
  = 현재 서버 시간 - A의 단방향 지연 - A의 보간 버퍼
  = 500 - 25 - 30
  = T=445
  
  서버는 B의 T=445 위치로 히트박스를 되돌려서 판정을 수행
  결과를 다시 현재 시점에 적용

😵 부작용: "코너 뒤에서 맞는" 현상
이 방식의 부작용도 반드시 알아야 하는데요, 가장 유명한 것이 "코너 뒤에서 맞는" 현상입니다. B가 엄폐물 뒤로 숨었는데 A의 관측에서는 아직 엄폐물 앞이라서 맞은 걸로 처리되는 경우입니다. B 입장에서는 "벽 뒤에 숨었는데 죽었다"는 납득 안 되는 경험이 됩니다.

 여기서 본질적인 공정성 논쟁이 발생하는데요. "쏜 사람의 관측을 믿어줘야 하는가, 아니면 현재 상태를 기준으로 해야 하는가" 의 문제입니다. 대부분의 FPS는 쏜 사람 쪽에 유리하게 설계되어 있습니다. 왜냐하면 "조준해서 쐈는데 안 맞음"은 플레이어가 자기 조작 실패로 인지하기 더 어려워서 좌절이 크기 때문입니다. 반면 "엄폐물 뒤에서 맞음"은 불쾌하긴 해도 "네트워크가 그렇다네" 하고 받아들이는 경향이 상대적으로 덜 심합니다.

 Colosseum은 반사탄 기반 전투라 이 문제가 덜 첨예한데요, 왜냐하면 투사체는 이동 중 누구나 볼 수 있는 객체라서 레이턴시 보상이 덜 필요하기 때문입니다. 투사체 자체의 위치만 잘 동기화하면 "어, 저 투사체가 나를 향해 오고 있네" 하고 모든 플레이어가 같이 인지할 수 있습니다. 만약 나중에 히트스캔 무기 — 총알이 즉시 도달하는 무기 — 가 추가된다면 그때는 레이턴시 보상을 본격적으로 고려해야 할 것입니다.

6.4. 치트 방어 🛡️

🚨 Host Mode의 근본 취약점
호스트 클라이언트가 서버 역할을 겸하고 있으니까, 호스트 본인이 치트를 쓰면 막을 방법이 없습니다. 권위가 호스트에게 있는데 그 호스트가 정직하지 않다면 게임 규칙 자체를 바꿀 수 있기 때문입니다.

 그래도 비호스트 클라이언트의 치트는 서버 측에서 충분히 막을 수 있습니다. 대표적인 공격 유형별로 대처를 정리해 보면요,

  • 💥 데미지 조작 : 클라이언트에서 데미지 수치를 조작하려고 해도 상태 자체는 서버에서 결정되니 이미 차단됩니다.
  • 🚀 텔레포트 : 클라이언트에서 캐릭터 위치를 텔레포트시키려고 해도 서버가 "한 틱에 가능한 최대 이동 거리" 같은 상식적 제약으로 검증하면 막을 수 있습니다.
  • 📦 패킷 조작 : 서버가 입력 구조체의 유효성 — 예를 들어 이동 벡터 크기가 1을 넘지 않는지 — 을 검사하면 걸러집니다.

 다만 에임봇 처럼 "규칙 자체는 어기지 않지만 인간을 초월한 정확도"를 내는 치트는 서버 단에서 탐지하기 매우 어렵습니다. 이런 것들은 응용 프로그램 레벨 안티치트 — Easy Anti-Cheat, BattlEye 같은 상용 솔루션 — 가 필요하며, 플레이어 행동 통계 분석으로 사후 적발하는 접근도 씁니다.

 그리고 가장 중요한 결론은요, 호스트 본인 치트라는 근본 취약점을 해결하려면 결국 Server Mode로 가야 한다는 것 입니다. 이것이 Colosseum이 "Host Mode 출시 후 Server Mode 전환 염두"라는 전략을 택한 핵심 이유 중 하나입니다.

6.5. 재연결 처리 🔌

 현실 네트워크에서는 끊김이 빈번합니다. 특히 모바일 환경에서는 와이파이에서 LTE로 전환, 터널 진입, 엘리베이터 이동, 일시적 트래픽 혼잡 같은 상황이 수시로 발생합니다.

 Fusion의 기본 동작은 5~10초 정도 무응답이 지속되면 연결을 끊은 것으로 판정합니다. 재접속 시도는 응용 프로그램이 처리하는데요, 실무에서 쓰는 전략 몇 가지를 소개해 드릴게요.

⏱️ 짧은 끊김 (1~3초 이내)

 그레이스 피리어드(grace period) 즉 유예 기간을 두고 게임을 멈추지 않고 계속 진행하는 방식이 있습니다. UI에는 "재연결 중"이라는 작은 표시만 띄우고 실제 플레이는 이어지게 하는 것입니다. 보간 버퍼에 아직 데이터가 남아 있다면 그 사이 자연스럽게 복구될 가능성이 높습니다.

🔄 재접속 처리

 재접속 시에는 서버가 현재 상태의 전체 스냅샷을 보내 주어 클라이언트를 현 시점에 동기화시킵니다. Fusion이 이 과정을 자동으로 처리해 주기는 하지만, 게임 특화 상태 — 예를 들어 선택된 카드, 라운드 점수 — 의 복원도 제대로 되는지 테스트로 검증해야 합니다.

⏰ 긴 끊김 (15초 이상)

 캐릭터를 일시적으로 무적 처리한 뒤 타임아웃이 오면 패배 처리하거나 AI가 대신 조작하도록 대체하는 방안을 씁니다. Colosseum의 MVP 단계에서는 아마 "긴 끊김은 라운드 패배"로 단순하게 처리하는 것이 합리적일 것입니다.

 그리고 Host Mode 특유의 문제로 호스트가 나가면 세션 자체가 붕괴 되는 리스크가 있습니다. 이것을 해결하는 호스트 마이그레이션(host migration) 기능이 Fusion에 있긴 하지만, 구현이 복잡하고 전환 과정에서 일부 상태 유실 위험이 있어서 MVP에서는 지원하지 않고 "호스트 이탈 시 매치 종료"로 처리하는 것도 합리적인 선택입니다.

6.6. 대역폭 최적화 기법 🗜️

 대역폭이 빡빡해지는 상황에서 쓸 수 있는 기법들을 정리해 드릴게요.

  1. 비트 패킹 : [Networked] bool은 Fusion이 알아서 실제로 1비트만 쓰도록 최적화합니다. 비슷하게 정수형도 실제 쓰는 범위에 맞춰 비트수를 줄일 수 있습니다.
  2. 정밀도 양자화 : [Networked, Accuracy(0.01f)] 같은 어트리뷰트를 쓰면 float 값을 소수점 둘째 자리 단위로 양자화해서 고정소수점으로 저장하는데, 원래 32비트 float이 16비트나 그 이하로 줄어듭니다. 게임 시각적 품질에는 거의 영향이 없으면서 대역폭은 크게 아낄 수 있습니다.
  3. 컬렉션 용량 고정 : [Networked, Capacity(N)]으로 최대 크기를 정해 두면 가변 길이 정보를 보낼 필요가 없어져서 효율이 올라갑니다.
  4. 자주 변하는 필드와 드물게 변하는 필드 분리 : 예를 들어 플레이어 위치(매 틱 변함)와 플레이어 이름(거의 불변)을 같은 구조체에 두면 델타 압축 효율이 떨어질 수 있는데, 분리하면 각각 최적화가 잘 됩니다.
  5. AoI 즉 관심 영역 관리 활성화 : 멀리 있는 객체는 전송하지 않는 것입니다.
  6. 틱 레이트 자체를 낮추기 (극단적 경우) : 30Hz를 20Hz로 낮추면 대역폭이 약 1/3 줄어드는데, 당연히 반응성 저하가 따라옵니다.

7. 🧠 네트워크 최적화에 직결되는 컴퓨터 과학 기초

7.1. TCP/IP 스택과 커널 네트워크

 여기서부터는 Fusion보다 더 아래 계층 이야기인데요. 운영체제의 네트워크 스택을 살짝 들여다보겠습니다. UDP 소켓을 만들고 데이터를 보내면, 그 데이터는 먼저 커널의 송신 버퍼(send buffer) 라는 링 버퍼에 쌓입니다. 커널이 네트워크 카드로 이 데이터를 내보내는 속도가 응용 프로그램이 쌓는 속도보다 느리면 버퍼가 꽉 차고, 그 이후 전송 시도는 실패하거나 블록됩니다.

 수신 쪽도 마찬가지로 수신 버퍼(receive buffer) 가 있는데요, 응용 프로그램이 빠르게 읽어가지 않으면 여기에 쌓이다가 꽉 차면 새 패킷이 drop됩니다. 이런 맥락에서 Fusion 같은 프레임워크는 내부적으로 별도의 소켓 스레드를 돌려서 수신 버퍼를 최대한 빨리 비워내는 구조를 씁니다.

 그리고 TCP를 쓸 일이 있다면 알아두셔야 할 Nagle 알고리즘 이 있습니다. TCP는 작은 패킷이 여러 개 있으면 효율을 위해 모아서 한꺼번에 보내는데, 이게 응답성을 떨어뜨립니다. 게임에서 TCP를 쓴다면 TCP_NODELAY 옵션으로 이 알고리즘을 꺼야 합니다. Fusion은 UDP 기반이라 이 문제가 없긴 하지만, 만약 별도로 채팅 서버나 로비 서버를 TCP로 구현하신다면 이 설정을 꼭 확인하셔야 합니다.

7.2. 큐잉 이론과 버퍼블로트 🚰

 네트워크 경로의 모든 라우터와 스위치는 들어오는 패킷을 큐에 저장했다가 순서대로 내보냅니다. 유입 속도가 처리 속도보다 일시적으로 빠르면 큐가 쌓이고, 쌓인 만큼 대기 시간 즉 지연이 늘어납니다. 포화되면 결국 drop이 발생합니다.

 여기서 버퍼블로트(bufferbloat) 라는 현대 네트워크의 고질병을 소개해 드릴게요. 현대 라우터들은 메모리가 예전보다 훨씬 커져서 큐 크기를 넉넉하게 잡는 경향이 있는데요, 이게 역설적으로 문제를 일으킵니다. 큐가 커지면 drop이 덜 발생하는 대신 큐에 머무르는 시간이 늘어나 지연이 선형적으로 증가합니다. 원인을 알 수 없는 핑 스파이크의 많은 경우가 사실 이 버퍼블로트 때문입니다.

 해결책으로 능동적 큐 관리(Active Queue Management, AQM) 알고리즘이 있는데요, CoDel 이나 fq_codel 같은 알고리즘은 큐 크기를 동적으로 관리해서 과도한 쌓임을 막습니다. 가정용 공유기에 SQM(Smart Queue Management) 설정이 있다면 이것을 켜는 것만으로도 게임 핑 안정성이 크게 개선됩니다.

🎓 사용자 지원 팁
이 지식은 사용자 지원 관점에서도 유용한데요, 플레이어가 "핑이 높아요" 하고 문의할 때 "집 공유기의 SQM을 켜 보세요"가 의외로 효과적인 조언이 될 수 있습니다.

7.3. 동시성과 스레딩 🧵

 Unity 엔진에는 "대부분의 Unity API는 메인 스레드에서만 호출할 수 있다" 는 강한 제약이 있습니다. 그런데 Fusion의 네트워크 소켓은 별도의 내부 스레드에서 동작하는데요, 그럼 어떻게 두 스레드가 협력할까요?

 해답은 스레드 사이에 스레드 안전 큐 를 두는 것입니다. 소켓 스레드가 패킷을 받으면 큐에 넣고, 메인 스레드의 FUN() 호출 직전에 큐에서 꺼내 처리합니다. 이 패턴이 Fusion의 내부 어디서든 쓰이는데요, 여러분이 게임 로직을 짜실 때는 그냥 [Networked]GetInput()만 쓰시면 Fusion이 뒷단에서 알아서 동기화를 처리합니다.

 다만 여러분이 직접 별도 스레드를 만들어서 Networked 속성을 건드리면 그 안전성 보장이 깨집니다. 그래서 Fusion 관련 코드는 메인 스레드에서만 다루는 것이 원칙 입니다.

7.4. 데이터 직렬화 🔢

 직렬화(serialization) 는 객체를 바이트 배열로 바꾸는 과정이고, 역직렬화(deserialization) 는 그 역입니다. 네트워크 전송은 반드시 바이트 단위이므로 모든 [Networked] 속성은 직렬화되어 보내집니다.

 여기서 엔디안(endianness) 이야기를 할까요. 숫자를 바이트로 표현할 때 높은 자릿수부터 적느냐 낮은 자릿수부터 적느냐에 따라 빅 엔디안(big-endian)리틀 엔디안(little-endian) 으로 나뉩니다. 대부분의 현대 CPU는 리틀 엔디안이지만, 네트워크 표준은 빅 엔디안입니다. Fusion은 내부적으로 일관되게 처리하니 여러분이 직접 신경 쓸 일은 없지만, 저수준 프로토콜을 다루실 일이 생기면 이 개념을 반드시 알아두셔야 합니다.

 가변 길이 인코딩(variable-length encoding) 이라는 것도 있는데요, 작은 수는 적은 바이트로 큰 수는 많은 바이트로 저장하는 방식입니다. 예를 들어 대부분의 정수가 255 이하라면 1바이트로 충분하지만 가끔 큰 수가 오면 2바이트나 4바이트로 늘려서 저장합니다. Fusion은 기본적으로 고정 크기 방식이지만, INetworkStruct를 커스텀 구현하면 이런 기법도 적용할 수 있습니다.

 그리고 스키마 진화(schema evolution) 라는 문제도 있는데요. 클라이언트와 서버의 버전이 달라서 네트워크 구조체 정의가 다르면 심각한 장애가 발생합니다. Fusion은 버전 체크로 이것을 막는데, 실무에서는 "클라이언트 업데이트를 강제"하는 정책이 일반적입니다.

7.5. 시간 동기화 🕰️

 네트워크 게임에서 시간 개념은 미묘합니다. 각 클라이언트의 로컬 시계는 서로 다르고, NTP로 동기화해도 수 밀리초의 오차가 있습니다. 그래서 게임 네트워크는 보통 "실시간"이 아니라 "틱 번호"를 공통 시간축 으로 씁니다. "지금 몇 시인가"가 아니라 "지금 몇 번째 틱인가" 가 모두가 공유하는 기준인 것입니다.

 Fusion의 내부에서 각 클라이언트는 자신의 렌더 시점이 서버의 어느 틱에 해당하는지를 계속 계산하며 동기화하는데요. 이 계산에 RTT와 지터 정보가 반영됩니다. 서버가 "지금 T=500이야" 라고 주기적으로 알려주고, 각 클라이언트는 "내가 받은 시점에서 내 보간 버퍼만큼 뺀 것이 내가 지금 렌더링해야 하는 틱"이라는 식으로 자기 시점을 유지합니다.


8. 🏛️ Colosseum의 실제 통신 구조 정리

8.1. Host Mode에서의 토폴로지

 이제 앞에서 배운 내용을 Colosseum의 실제 구조에 대응시켜 볼까요. Host Mode에서 통신이 실제로 어떻게 흐르는지 그림으로 그려 보면 이렇습니다.

              ┌──────────────────┐
              │  Photon Cloud    │
              │   (릴레이 서버)   │
              └────────┬─────────┘
                       │
          ┌────────────┼────────────┐
          │            │            │
     ┌────▼───┐   ┌───▼────┐   ┌───▼────┐
     │  Host  │   │ Client │   │ Client │
     │ (권위) │   │   B    │   │   C    │
     │   A    │   └────────┘   └────────┘
     └────────┘

🚦 중요 포인트
"모든 통신이 Photon Cloud를 경유한다"는 것인데요. A 클라이언트와 B 클라이언트가 직접 통신하지 않습니다. B가 입력을 보내려면 B→Photon Cloud→A 경로를 거치고, A가 처리한 결과 스냅샷은 A→Photon Cloud→B, A→Photon Cloud→C로 흘러갑니다.

이 때문에 Photon Cloud 리전이 플레이어들과 지리적으로 가까워야 RTT가 작아집니다. 한국 플레이어끼리 매칭되는데 Photon 서버가 미국에 있으면 핑이 크게 늘어나는 것이 이 이유입니다.

8.2. 클라이언트끼리 직접 통신하는가

 궁금해하실 수 있는 질문이죠. Fusion에서는 원칙적으로 클라이언트끼리 직접 통신하지 않습니다. 모든 것이 서버 즉 Host나 Dedicated Server를 경유합니다.

 예외가 몇 가지 있긴 한데요. 음성 채팅을 담당하는 Photon Voice 는 별도 채널로 P2P 연결이 가능하고, Fusion의 Shared Mode 를 쓰면 피어들이 객체별로 권한을 나눠 갖는 구조가 되어 직접 통신에 좀 더 가까워집니다. 하지만 Colosseum은 Host Mode를 쓰므로 이 예외들은 현재 해당 없고, 모든 게임 데이터는 Host를 경유한다고 이해하시면 됩니다.

8.3. Server Mode로 전환하면 무엇이 달라지는가 🔀

 만약 나중에 Server Mode 즉 Dedicated Server 방식으로 전환하면 토폴로지가 이렇게 바뀝니다.

              ┌──────────────────┐
              │ Dedicated Server │
              │   (전용 권위)     │
              │  (AWS, Azure 등) │
              └────────┬─────────┘
                       │
          ┌────────────┼────────────┐
          │            │            │
     ┌────▼───┐   ┌───▼────┐   ┌───▼────┐
     │Client A│   │Client B│   │Client C│
     └────────┘   └────────┘   └────────┘

 달라지는 점들을 짚어 드리면,

  • ✅ 서버가 공인 IP를 가진 전용 머신이므로 NAT 문제가 원천적으로 사라집니다.
  • ✅ 호스트가 게임에 참여하지 않으므로 호스트 본인의 치트가 불가능 해져 공정성이 보장됩니다.
  • ✅ Photon Cloud 릴레이를 거치지 않고 클라이언트가 서버에 직접 연결할 수 있어서 구간 지연이 줄어들 수 있습니다.
  • 서버 리전 을 원하는 대로 배치할 수 있고 오토스케일링 도 가능해져 운영 유연성이 커집니다.
  • ❌ 그 대신 서버를 상시 돌리는 비용이 발생 하는데, 이것이 상용 서비스 BM과 직결되는 고민이 됩니다.

 그리고 기술적으로 Fusion의 API는 Host Mode와 Server Mode가 거의 동일 합니다. 코드 레벨에서 크게 바뀌는 것은 없고, 대신 Runner.IsServer 같은 "Host 특화" 판정에 의존하는 코드가 있다면 재검토가 필요합니다. Colosseum 가드레일이 "Host Mode에서만 성립하고 Server Mode 전환을 막는 구조를 만들지 않는다"고 금지선을 그은 것이 이 이유입니다.

8.4. 매 틱 실제로 흘러가는 정보의 목록 📋

 Colosseum의 truth 표를 기반으로, 실제 네트워크 상에 흘러가는 정보가 어떤 것들인지 구체적으로 나열해 보겠습니다.

📡 매 틱 스냅샷으로 흘러가는 상태 정보

 각 플레이어의 위치, 속도, 회전은 NetworkRigidbody2D를 통해 동기화되고, 체력과 탄약 같은 전투 수치, 반사탄의 반사 횟수(ReflectCount), 투사체의 위치와 속도, 그리고 카드 선택 페이즈 동안에는 카드 선택 상태가 포함됩니다. 또 매 틱 각 클라이언트에서 서버 방향으로 입력 구조체가 흘러가는데, 앞에서 말씀드렸듯이 유실에 대비해 최근 몇 틱의 입력이 중복 포함되어 있습니다.

🎬 이벤트 방식으로 가끔만 흘러가는 정보

 게임 시작과 종료 신호, 라운드 전환, 카드 드로우의 결과 — 카드 풀에서 어떤 카드가 뽑혔는지 — , 승리 연출 트리거 같은 것들은 RPC나 Networked 이벤트로 처리됩니다.

🎨 네트워크와 무관한 로컬 전용 정보

 카메라 흔들림, 줌 효과, 사운드, 파티클, HP 바 애니메이션 — 값 자체는 네트워크지만 애니메이션만 로컬 — , 히트스톱 연출, 반사 예고선과 트레일 색상 같은 시각적 강조 요소들이 여기에 속합니다. 이런 것들은 각 클라이언트가 truth 상태를 읽어서 독립적으로 계산하고 표시합니다.

📌 다시 한 번 강조
이 분류가 왜 중요한지 다시 강조드리면요, 이 경계가 흐려지는 순간 desync나 판정 혼란이 발생하기 때문입니다. "연출상 맞은 것처럼 보였는데 상태는 안 변했다"거나 "상태는 변했는데 연출이 안 떴다"는 버그가 이 경계 오류에서 비롯됩니다. Colosseum이 docs/50_기술_구조_기준/02_네트워크_truth_표.md 문서를 기술 문서의 중심에 둔 이유가 바로 이것입니다.


9. ⚖️ 설계 결정별 트레이드오프 — 왜 이렇게 했는가

 마지막으로 지금까지 설명드린 내용들이 실제 설계 결정에서 어떻게 충돌하고 타협되는지 서술해 드리겠습니다. Colosseum의 기술 선택을 중심으로 정리해 볼게요.

🏠 Host Mode 채택

 Host Mode를 채택 하신 것은 빠른 개발 속도와 무료 운영이라는 장점 때문인데요, 대신 공정성과 치트 방어가 약해진다는 단점을 감수한 것입니다. 이 단점은 MVP 단계에서는 크게 문제 되지 않지만, 상용 출시 이후에는 Server Mode 전환이 필요해질 가능성이 높습니다. 그래서 가드레일이 전환 가능성을 구조적으로 보장한 것이 현명한 설계입니다.

🔮 예측 활성화

 예측을 활성화 하신 것은 반응성을 확보하기 위함인데요, 대신 러버밴딩 리스크가 생깁니다. 이 리스크는 truth 표에서 "예측 가능한 것(이동, 발사)"과 "예측 불가능하고 서버 결과만 신뢰해야 하는 것(사망, 구간 돌파, 카드 결과)"을 명확히 분리함으로써 완화하고 있습니다.

⏱️ 틱 레이트 (30Hz vs 60Hz)

 틱 레이트를 30Hz로 잡느냐 60Hz로 잡느냐 도 중요한 결정인데요, 30Hz면 대역폭이 절반으로 줄지만 반응성과 정밀도가 떨어지고, 60Hz는 반대입니다. 격투 성격을 강화하려면 60Hz로 가는 것이 맞지만 모바일 포팅이나 저성능 환경 지원을 고려하면 30Hz가 안전합니다.

🌊 보간 버퍼 크기

 보간 버퍼 크기 도 트레이드오프가 있는데요, 작게 잡으면 원격 객체가 빠르게 반영되지만 지터에 취약해지고, 크게 잡으면 안정적이지만 상대 행동을 더 늦게 보게 됩니다. Fusion의 자동 조정을 그대로 쓰시는 것이 대부분의 경우 합리적입니다.

📊 Networked 속성의 양

 Networked 속성을 많이 쓰느냐 적게 쓰느냐 도 고민 지점입니다. 많이 쓰면 개발이 편하지만 대역폭이 증가하고, 적게 쓰면 최적은 되지만 일부 상태를 로컬 재계산해야 하니 버그 여지가 생깁니다. Colosseum의 truth 표 원칙 — "게임 결과를 바꾸는 것만 Networked" — 은 이 트레이드오프의 건강한 중간 지점입니다.

🎯 레이턴시 보상

 레이턴시 보상을 구현하느냐 는 히트스캔 무기를 추가할 때 고민해야 할 결정입니다. 구현하면 "쏜 사람"의 관측을 신뢰해 조준 좌절이 줄지만 "숨은 사람이 엄폐물 뒤에서 죽는" 새로운 불쾌감이 생깁니다. 현재 반사탄 투사체 기반에서는 큰 문제가 아니지만 무기 다양성이 확장될 때 반드시 고려해야 합니다.


10. ❓ 스스로 답할 수 있어야 할 핵심 질문들

 이 문서를 제대로 흡수하셨다면 아래 질문들에 막힘없이 답하실 수 있어야 합니다. 단순히 "알고 있다"가 아니라 "다른 사람에게 설명할 수 있다" 가 목표입니다.

Q1. 왜 게임은 TCP가 아니라 UDP를 쓰는가?

 답은 TCP의 세 가지 특성 — 순서 보장, 재전송, 혼잡 제어 — 이 게임에서는 오히려 해가 되기 때문입니다. 순서 보장은 Head-of-Line Blocking을 일으켜 최신 정보가 과거 유실 때문에 지연되고, 재전송은 다음 스냅샷이 곧 올 상황에서 낭비이며, 혼잡 제어는 일시적 유실을 과도한 속도 저하로 해석해 게임 경험을 망칩니다. UDP는 이 모두를 벗어던지고 응용 프로그램이 필요한 신뢰성만 선택적으로 구현하게 해 줍니다.

Q2. Fusion의 FixedUpdateNetwork와 Unity의 FixedUpdate는 어떻게 다른가?

 답은 전자가 네트워크 틱에 동기된 결정적 시뮬레이션 단위이고 재시뮬레이션 중 한 틱에 여러 번 호출될 수 있다는 점입니다. 후자는 Unity 물리 타임스텝으로 비결정적 맥락을 포함합니다. 그래서 FUN() 안에서는 Time.deltaTime 대신 Runner.DeltaTime을 써야 하고, 결정성을 해치는 호출을 피해야 합니다.

Q3. 클라이언트 예측은 왜 필요하고 언제 실패하는가?

 답은 RTT만큼의 입력 지연을 숨기기 위해 필요하며, 로컬에서 즉시 시뮬레이션한 뒤 서버 권위 도착 시 비교해서 차이가 있으면 롤백 재시뮬레이션으로 보정하는 구조입니다. 예측 로직과 서버 로직이 다르거나 코드가 비결정적이면 보정 폭이 커져서 러버밴딩으로 나타납니다.

Q4. Networked 상태와 RPC를 언제 나눠 쓰는가?

 답은 지속되고 재접속 시 필요한 값은 Networked 상태로, 순간적이고 한 번만 트리거되는 연출은 RPC로 처리합니다. 단 게임 결과에 영향을 주는 이벤트는 비록 순간적이어도 Networked 상태나 Networked 이벤트로 승격해야 합니다. 피격, 사망, 구간 돌파가 그 예입니다.

Q5. 레이턴시 보상이 해결하는 문제와 부작용은 무엇인가?

 답은 쏜 사람의 관측 시점을 기준으로 판정해서 "조준했는데 안 맞음"을 줄이는 것이 해결하는 문제이고, "엄폐물 뒤에서 죽음"이 부작용입니다. 비대칭적 관측을 어느 쪽이 감수할지 선택해야 하며 대부분의 FPS는 쏜 사람 쪽에 유리하게 설계합니다.

Q6. Host Mode의 근본 취약점과 Server Mode 전환 비용은 무엇인가?

 답은 호스트 본인의 치트를 막을 수 없다는 것이 근본 취약점이고, 전환 시에는 Dedicated Server 운영비, 리전별 서버 배치, 매치메이킹 인프라, Host Migration 관련 코드 제거 등이 필요합니다. Fusion API 자체는 거의 동일하므로 주된 작업은 인프라와 "Host 특화" 가정의 제거입니다.

Q7. 지터가 레이턴시보다 왜 더 나쁠 수 있는가?

 답은 일정한 지연은 보간 버퍼로 완벽히 흡수할 수 있지만 변동은 버퍼를 크게 잡아야 대응 가능하고 그만큼 평균 반응성이 저하되기 때문입니다. 또 일시적으로 버퍼가 비면 화면이 뚝뚝 끊기는 stutter가 발생합니다.

Q8. Colosseum의 반사탄 drift 문제는 왜 생기고 어떻게 해결했는가?

 답은 투사체의 반사 과정에서 클라이언트와 서버의 부동소수점 계산이 누적 오차로 벌어지는 것이 원인입니다. 해결은 ReflectCount[Networked] byte로 권위화하고 위치와 속도는 매 틱 서버 값으로 보간하며, 로컬에서는 예측하지 않고 서버 값만 따라가는 방식입니다. 자세한 배경은 docs/99_아카이브/2026-04-20_반사탄_drift_Option_A_채택.md에 정리되어 있습니다.


🎬 닫는 글

 여기까지 읽으셨다면 게임 네트워킹과 Photon Fusion 2에 대해 상당히 탄탄한 이해를 갖추신 것입니다. 이 문서는 Colosseum 프로젝트의 현재 설계 결정들을 왜 그렇게 내렸는지 맥락적으로 이해하는 데 집중했는데요, 앞으로 기능을 추가하거나 문제를 해결하실 때 "이 결정이 어느 트레이드오프 축에 서 있는가" 를 의식하면서 판단하시면 팀의 기술 가드레일과 자연스럽게 일치하는 설계를 하실 수 있을 것입니다.

0개의 댓글