카테고리 없음

Neon Rush

LetsCoding 2026. 9. 27. 23:46

  Json Bit-Packed Binary
사람이 읽기 매우 쉬움 어려움
개발 속도 빠름 상대적으로 느림
디버깅 쉬움 protocol 분석 필요
필드 추가/변경 상대적으로 유연 송수신 양쪽 수정 필요
타 언어 연동 매우 쉬움 protocol 구현 필요
Payload 크기 큼 매우 작게 만들 수 있음
직렬화 비용 parser/serializer 필요 단순한 bit/byte 연산 가능
메시지 크기 예측 상대적으로 가변적 매우 명확
20~50Hz 실시간 메시지 불리할 수 있음 매우 적합
유지보수 비교적 쉬움 protocol 관리 중요

그래서 네 프로젝트에서 가장 설득력 있는 설명은 “JSON이 나빠서 버렸다”가 아니야.

이전 프로젝트에서는 개발 속도와 protocol 변경 유연성이 중요한 상황이라 JSON의 장점이 컸지만, 이번 프로젝트에서는 작은 상태 데이터를 초당 수십 회 반복 전송하는 실시간 레이싱 게임이었기 때문에, 가독성과 유연성보다 패킷 크기와 예측 가능한 직렬화 비용을 우선하여 고정 binary/bit-packed protocol을 선택했다.

이 논리가 훨씬 탄탄해.

그리고 문서의 숫자는 **0.9B → 1B, Transform 28B → 29B, 80KB/s vs 4KB/s → 실측 전까지 삭제 또는 재측정**으로 수정하는 걸 권장해. "4~5배 절감" 역시 패킷 종류와 IP/UDP/LiteNetLib overhead를 어디까지 포함했는지에 따라 달라지므로, 실제 packet capture가 없다면 “주요 고빈도 패킷의 application payload를 크게 축소했다” 정도로 쓰는 게 사실관계상 가장 안전하다.

 

 

주요 고빈도 패킷의 application payload를 크게 축소한 것의 정확한 의미가 무엇인가요?

 

제가 말한 application payload는 IP 헤더나 UDP 헤더 같은 네트워크 계층의 부가정보를 제외하고,

실제 게임이 전달하려는 데이터 부분을 의미합니다.

 

이번 프로젝트에서는 특히 PlayerInput이나 TransformUpdate처럼 초당 수십 번 반복해서 보내는 패킷을 고빈도 패킷으로 봤습니다.

 

예를 들어 PlayerInput의 경우 실제로 필요한 정보는 패킷 종류와 플레이어의 입력 상태였습니다. 패킷 종류는 4비트, 입력은 수평 방향 2비트와 전진 여부 1비트로 총 3비트면 표현할 수 있었기 때문에, 두 정보를 합쳐 총 7비트만 사용하도록 설계했습니다.

다만 실제 전송은 byte 단위이기 때문에 7비트가 그대로 0.875바이트로 전송되는 것은 아니고, 최종 application payload는 1바이트를 사용합니다.

반면 JSON으로 같은 의미를 전달한다면 "packetType", "horizontalInput", "verticalInput"처럼 필드 이름 자체도 문자열로 포함되고 숫자 역시 텍스트 형태로 표현됩니다. 따라서 실제 값 자체보다 이를 설명하기 위한 데이터가 훨씬 많이 포함될 수 있습니다.

이 차이는 한 번만 전송하면 크지 않을 수 있지만, 입력 패킷을 초당 20회, Transform 데이터를 초당 50회처럼 지속적으로 전송하면 누적 전송량 차이가 커집니다.

TransformUpdate도 비슷한 방식입니다. 현재 구조에서는 패킷 타입 1바이트와 위치 Vector3의 float 3개, 회전 Quaternion의 float 4개를 보내므로 application payload는 총 29바이트입니다.

그래서 제가 ‘주요 고빈도 패킷의 application payload를 크게 축소했다’고 표현한 것은, 네트워크 전체 패킷 크기 자체를 무조건 몇 분의 1로 만들었다는 의미라기보다는, 게임에서 반복적으로 전송하는 핵심 데이터의 표현 방식을 JSON 문자열에서 고정된 bit/byte 구조로 바꿔 실제 게임 데이터 영역을 작고 예측 가능한 크기로 만들었다는 의미입니다.

특히 레이싱 게임처럼 작은 상태 정보가 높은 빈도로 계속 오가는 경우에는 이런 최적화가 누적 대역폭을 줄이는 데 의미가 있다고 판단했습니다.

 

입력 값을 서버로  vs 위치 값을 서버로 

 

Unity 문서에서도 variable timestep을 물리 시뮬레이션에 넣으면 비결정적 결과가 발생할 수 있다고 명시하고 있고, fixed step을 사용할수록 재현성이 좋아진다고 설명한다. 동시에 Unity는 PhysX에서 true determinism에는 별도 어려움이 존재한다고 설명한다.

즉,

Unity PhysX 기반 물리 시뮬레이션을 별도의 클라이언트에서 독립 실행하면서 결정론적 lockstep을 보장하지 않았기 때문에, 작은 계산 및 타이밍 차이가 누적될 가능성이 있었다.

 

다만:

position += speed * Time.deltaTime;

처럼 frame delta를 제대로 사용한 일반적인 비물리 로직까지 단순히 "Update()라서 틀렸다"라고 하면 안 돼.

그래서 문서는:

물리 상태에 영향을 주는 일부 로직이 Update()에 의존하거나 fixed tick과 분리되어 있어 클라이언트별 시뮬레이션 타이밍 차이가 발생했다.

정도가 더 정확하다.

 

UDP 기반 실시간 통신에서는 패킷 손실, 지연 및 순서 변경 가능성을 전제로 설계해야 했다.

 

입력만 공유할 때는:

처음 오차 0.01
↓
각자 계속 물리 계산
↓
0.02
↓
0.05
↓
0.2
↓
계속 벌어짐

처럼 서로의 상태를 다시 맞출 기준이 없었다.

Transform을 계속 받으면:

remote client
현재 위치 추정
↓
새 Transform 수신
↓
최신 위치를 새로운 기준점으로 사용
↓
그 사이를 interpolation

할 수 있으니까 오차가 무한히 누적되는 구조를 끊을 수 있다.

각 클라이언트가 독립적으로 상대 차량의 물리를 계속 재현하는 대신, 송신 클라이언트가 주기적으로 보낸 Transform을 새로운 기준 상태(snapshot)로 사용하도록 변경해 장기적인 위치 오차 누적을 방지했다.

라고 쓰는 게 현재 구조와 정확히 맞는다.

"정확도는 오직 latency의 영향만 받는다"는 틀림

이건 반드시 빼는 걸 추천해.

Transform interpolation 품질은 latency뿐 아니라:

Latency
Jitter
Packet loss
Packet reordering
Send rate
Receive rate
Interpolation buffer
클라이언트 frame rate
Transform 양자화/정밀도

등의 영향을 받는다.

예를 들어 50Hz로 보낸다면 이상적으로 새로운 snapshot이:

20ms 간격

으로 오지만 실제 인터넷에서는:

20ms
20ms
35ms
5ms
packet lost
40ms
...

처럼 도착할 수 있다.

그래서 단순 latency보다 jitter가 interpolation 품질에 매우 중요하다.

문장은:

위치 오차의 장기 누적은 제거했지만, 화면상 동기화 품질은 latency, jitter, packet loss 및 snapshot 전송 주기의 영향을 받는다.

가 정확하다.

MTU 부분은 절반은 맞고 절반은 틀림

MTU 이하에서는 패킷 크기 차이가 전송 성능에 큰 영향을 미치지 않는다.

이렇게 일반화하면 안 돼.

MTU가 중요한 이유는 그보다 큰 datagram이 fragmentation을 일으킬 가능성이 있기 때문이야. UDP 지침에서도 path MTU를 넘는 datagram은 피하라고 권고한다. 일반적인 Ethernet의 IP datagram 최대 크기는 1500 octets다. RFC 에디터

하지만:

30B packet
vs
1000B packet

둘 다 MTU보다 작다고 해서 비용이 같은 건 아니다.

당연히 후자가:

대역폭
메모리 복사
queue
socket buffer

를 더 사용한다.

MTU가 말해주는 것은 주로:

이 크기 이하라면 fragmentation 위험을 피할 수 있다.

이지,

그 이하에서는 크기는 중요하지 않다.

가 아니야.

 

작은 패킷을 너무 자주 보내는 것보다 묶어서 보내면 효율이 좋아질 수 있다.

는 맞는 방향이야.

패킷 하나를 보낼 때마다:

IP header
UDP header
LiteNetLib header
socket 처리
OS/network stack 처리

라는 고정 비용이 있기 때문에

1B × 100 packets

가 반드시

100B × 1 packet

과 같은 비용은 아니다.

LiteNetLib 자체도 automatic small packet merging 기능을 지원한다고 명시한다. GitHub

하지만 batching에도:

더 모일 때까지 기다림 → latency 증가 가능
한 packet 손실 → 묶인 여러 state를 동시에 잃음

이라는 trade-off가 있다.

그래서:

중간 크기의 패킷이 무조건 더 좋았다.

보다

패킷당 고정 overhead를 고려해 지나치게 작은 패킷을 높은 빈도로 개별 전송하지 않도록 전송 주기와 payload 크기의 균형을 조정했다.

라고 쓰는 게 더 좋은 설명이야.

그리고 "실제 운영에서 더 나은 결과를 확인했다"를 쓰려면 profiler나 packet capture 등 실측 자료가 있어야 한다.

 

결국 이 프로젝트의 변화는 **“Input synchronization → Server authoritative synchronization”**이라고 표현하는 것보다 다음처럼 설명하는 게 정확해.

초기 구조

Client A input
      ↓
    Server
      ↓
Client B

Client A와 B가 각각 물리 시뮬레이션
→ 시간이 지나면서 상태 divergence 발생


변경 구조

Client A
↓
로컬 물리 시뮬레이션
↓
Position / Rotation snapshot
↓
Server
↓ relay
Client B
↓
수신 snapshot 기반 상태 보정 / interpolation

즉 핵심 성과는 서버 권위 모델을 만든 것이 아니라,

결정론을 보장하기 어려운 입력 재현 방식에서 벗어나, 주기적인 Transform snapshot을 상태 동기화의 기준으로 사용해 누적된 divergence가 계속 증폭되는 문제를 해결한 것

 

 

네가 말한 방식:

Client
 └─ 자신의 위치 계산
     ↓
     Transform 전송
        ↓
Server
 ├─ 이 이동이 물리적으로 가능한가?
 ├─ 속도가 제한을 넘었나?
 ├─ 이전 위치에서 이 위치까지 이동 가능한가?
 └─ 입력 기록과 이동 결과가 일치하는가?
        ↓
    정상 → 다른 Client에게 중계
    비정상 → 폐기 / 보정 / 경고

는 충분히 구현 가능한 구조야. 다만 이것을 완전한 Server Authoritative Movement라고 부르기보다는, 현재 구조에서는 **서버 검증형 하이브리드 구조(server-validated client movement)**라고 표현하는 게 더 정확해.

현재 네 코드에서는 클라이언트가 Transform을 50Hz로 계산해 서버에 보내고 있고, 입력은 별도로 20Hz 전송하고 있어. 붙여넣은 텍스트(1) 붙여넣은 텍스트(1) 이 두 정보를 같이 가지고 있으면 서버에서 검증에 활용하기 꽤 좋아.

예를 들어 서버가 이전 상태를:

이전 위치
이전 속도
마지막 입력
마지막 tick

형태로 저장해둔다고 해보자.

클라이언트가 새 위치를 보내면 가장 단순하게는:

distance = Vector3.Distance(previousPos, receivedPos);
speed = distance / deltaTime;

if (speed > MAX_ALLOWED_SPEED)
{
    // 비정상 이동
}

처럼 검증할 수 있어.

조금 더 들어가면:

이전 위치 = (0, 0, 0)
현재 위치 = (100, 0, 0)
경과 시간 = 0.02초

→ 20ms 만에 100m 이동?
→ 차량 최고속도로 불가능
→ 패킷 거부

같은 검증이 가능하지.

그리고 네가 이미 입력도 보내고 있으니까 더 강하게 검증할 수도 있어.

Client input:
    좌회전 + 가속

Client가 주장한 Transform:
    갑자기 뒤로 30m 순간이동

→ 입력 결과와 위치 변화가 불일치
→ 의심

이런 식이야.

서버에서 현실적으로 넣을 수 있는 검증은 대략 속도/가속도 제한, 순간이동 거리 제한, tick/sequence 검증, 입력과 이동 방향의 일관성, 맵 바깥 이동 여부, 벽이나 장애물 통과 여부, 패킷 전송 빈도 제한 정도야.

특히 네 레이싱 게임이면 차량 스펙을 서버에서도 알고 있게 만들 수 있어.

Car A

최대 속도: 40m/s
최대 가속도: 10m/s²
최대 조향각: 30°

그러면 클라이언트가:

이번 20ms 동안
속도 20m/s → 90m/s

라고 주장했을 때 서버는 바로:

물리적으로 불가능

이라고 판단할 수 있어.


다만 여기서 중요한 차이가 있어.

네가 제안한 방식에서는 여전히 위치 계산의 주체가 Client야.

Client
↓
물리 계산
↓
Transform
↓
Server 검증

즉 서버가 하는 건:

"네가 주장한 결과가 말이 되냐?"

를 검사하는 거야.

반면 완전한 Server Authoritative 방식은 구조가 다르다.

Client
↓
Input만 전송

Server
↓
Input 적용
↓
서버에서 물리/이동 계산
↓
Authoritative Position 생성
↓
모든 Client에게 전달

이 경우 Client가:

"내 위치는 (100,0,0)이야"

라고 주장할 기회 자체를 주지 않는다.

클라이언트가 보내는 건:

가속했다
왼쪽을 눌렀다
브레이크를 눌렀다

정도이고,

최종:

차량 위치
속도
충돌 결과

는 서버가 결정한다.

그래서 보안 수준을 놓고 보면 개념적으로:

Client authoritative
────────────────────────────
Client가 위치 결정
Server는 그대로 믿음

        ↓

Server validated
────────────────────────────
Client가 위치 결정
Server가 이상 여부 검사

        ↓

Server authoritative
────────────────────────────
Client는 입력만 보냄
Server가 위치 자체를 결정

이렇게 갈수록 서버의 권위가 강해진다고 보면 돼.


그렇다고 네 게임에서 반드시 완전한 authoritative physics server를 만들어야 하는 건 아니야.

Unity 차량 물리를 서버에서도 똑같이 돌리는 방식은 구현 난이도와 서버 CPU 비용이 상당히 커질 수 있어. 특히 여러 경기의 차량 Rigidbody/충돌/물리 시뮬레이션을 서버가 전부 담당하면 비용이 증가한다.

그래서 현재 구조를 유지하면서:

Client
→ Transform 계산

Server
→ Transform 검증
→ 정상 범위면 승인
→ 다른 Client에게 relay

하는 것은 충분히 현실적인 절충안이야.

그리고 면접에서 오히려 이렇게 이야기하는 게 설계 판단이 더 잘 드러난다.

예를 들면:

현재 구현에서는 클라이언트가 계산한 Transform을 서버가 중계하기 때문에 완전한 Server Authoritative 구조는 아닙니다. 그래서 클라이언트가 비정상적인 위치를 전송하면 서버가 이를 신뢰한다는 한계가 있습니다. 개선한다면 서버에서 이전 위치, tick, 차량의 최대 속도·가속도, 입력 기록 등을 가지고 수신된 Transform이 물리적으로 가능한 범위인지 검증하고, 비정상적인 값은 거부하는 validation layer를 두고 싶습니다.

그리고 더 발전시키면:

보안성을 더 높여야 한다면 최종적으로는 Client가 위치가 아니라 Input만 서버에 보내고, 서버가 authoritative simulation을 수행한 뒤 위치를 결정하는 구조로 발전시킬 수 있습니다. 다만 서버의 물리 연산 비용과 구현 복잡도가 증가하기 때문에 프로젝트 규모에 따라 server validation 방식과 full authoritative 방식을 선택할 수 있다고 생각합니다.