개요
서버 거리 검증 로그에 거리 0으로 통과한 타격이 하나 찍혀 있었다.
같은 요청에서 시작점이 대상 콜라이더 안에 있었고, 시작점과 콜라이더 중심의 X·Z가 똑같았다. 맞은 대상 이름은 Multiplayer Controller(Clone).. 곡괭이로 내 몸을 치고 있었다.
1편에서 정한 “살아있는 광물 채굴” 게임(Minor On Air)의 첫 작업으로, 곡괭이 타격을 클라이언트 의도 → 서버 검증 → 대상 반응 → 발행 흐름으로 만들었다. 그 과정에서 나온 버그와 판단을 적어보겠다!
입력·Raycast·대상 탐색, HitRpc 전달, 서버 검증(요청자·대상·쿨다운·거리)은 내가 짰다. 검증을 메서드로 나누는 정리, IHittable 인터페이스, 테스트용 도구는 AI에게 맡겼다.
서버는 어디에 있나
구성은 NGO 2.x의 client-hosted listen server + Unity Relay다. 여기서 서버는 별도 프로세스가 아니라 호스트 PC의 같은 빌드 안에서 IsServer가 참인 경로다. 서버 코드를 따로 두거나 호스팅하지 않는다. Relay는 NAT를 넘기 위해 패킷을 중계만 하고 게임 로직은 돌리지 않는다.
방향이 다른 두 흐름을 구분해야 했다.
| 방향 | 서버 입장 | |
|---|---|---|
| 요청(Rpc) | 클라이언트 → 서버 | 의심해야 하는 입력 |
| 복제 | 서버 → 클라이언트 | 서버가 확정한 결과 |
그리고 호스트는 지연이 0이다. 호스트로만 쳐보면 네트워크를 타는 경로의 버그가 하나도 안 보인다. 이번 글의 버그 대부분이 여기서 나왔다.
Rpc는 곡괭이를 든 쪽에 둔다
Rpc 입구는 행동하는 쪽의 오브젝트(곡괭이를 든 플레이어)에 두고, 대상은 인자로 받는다. 광석 종류가 늘어도 검증 경로가 하나로 유지되고, 쿨다운 같은 행위자 상태도 같은 곳에 모인다.
인자는 대상 NetworkObjectReference 하나다. 누가 쳤는지는 보내지 않는다.
- 요청자는 서버가
RpcParams.Receive.SenderClientId로 이미 안다. - 인자는 클라이언트가 마음대로 채울 수 있는 값이다. 플레이어 ID를 인자로 받으면 남의 ID를 적어 보내는 것도 막을 수 없다.
그래서 서버 처리 맨 앞에서 SenderClientId == OwnerClientId를 확인한다. 이게 없으면 남의 플레이어 오브젝트로 요청을 보내 “그 사람이 친 것”으로 만들 수 있다. 거리와 쿨다운도 남의 것으로 판정되고, 사건 기록의 “누가”까지 오염된다.
NGO 2.7 문서 기준으로 [Rpc(SendTo.Server)]의 기본 호출 권한은 InvokePermission.Everyone이다. owner가 아닌 클라이언트도 부를 수 있다는 뜻이고, owner로 제한하려면 InvokePermission = RpcInvokePermission.Owner를 쓴다( 예전의 RequireOwnership은 deprecated ).
샘플 프로젝트의 PickupItem.PickUpRpc(ulong clientId)는 이 반대였다. 대상(아이템) 쪽에 Rpc가 있고, 요청자 ID를 인자로 받아 그대로 쓰고, 거리 검증도 없다. 샘플이라고 검증된 교재는 아니었다..
헷갈렸던 건 “남의 가방에서 꺼내기” 같은 경우다. 가방 주인의 오브젝트에 Rpc를 둘 것 같지만, 기준은 데이터의 소유자가 아니라 행동하는 사람이다. 꺼내는 사람의 오브젝트에 두고, 서버에서 “가방 주인 ≠ 요청자”를 따로 확인한다.
꺼둔 컴포넌트에서도 Rpc는 돈다
2인 접속이면 PC마다 플레이어가 둘씩 있다. 같은 컴포넌트 인스턴스가 전체로 4개고, 같은 플레이어의 복제본은 PC가 달라도 같은 NetworkObjectId를 가진다. Rpc는 이 ID를 따라 상대 PC의 인스턴스에서 실행된다.
즉 클라이언트가 HitRpc를 보내면, 서버 처리는 호스트 PC에 있는 그 클라이언트 플레이어의 복제본에서 돈다.
그런데 그 복제본은 OnNetworkSpawn에서 꺼둔 상태다. 내 키보드 입력이 남의 캐릭터를 움직이지 않도록 owner가 아니면 컴포넌트를 끈다.
private void Awake()
{
_movement = GetComponent<PlayerMovement>();
_lastTime = Time.timeAsDouble - _cooldown; // 첫 타격은 항상 통과
}
public override void OnNetworkSpawn()
{
if (!IsOwner)
{
enabled = false;
return;
}
var playerInput = GetComponent<PlayerInput>();
_swingAction = playerInput.actions["Swing"];
}
꺼져 있으니 Rpc도 안 받을 줄 알았는데, 서버 처리부에서 로그를 찍고 클라이언트로 쳐보니 이렇게 나왔다.
Send ID 1 / Owner ID 1 / 서버 여부 True / 활성화 여부 False
enabled = false는 엔진이 거는 콜백(Update 등)만 멈춘다. Rpc는 NGO가 직접 호출하므로 꺼진 컴포넌트에서도 실행된다.
여기서 owner 확인이 두 종류라는 게 정리됐다.
| 위치 | 역할 | |
|---|---|---|
if (!IsOwner) enabled = false |
클라이언트, OnNetworkSpawn
|
입력 라우팅: 내 키보드에 어느 복제본이 반응할지 |
SenderClientId == OwnerClientId |
서버, HitRpc 맨 앞 |
보안: 이 요청을 보낸 게 이 플레이어 주인인지 |
IsOwner는 spawn 이후에 확정되니까 owner에 의존하는 초기화는 OnNetworkSpawn에 둔다. 반대로 서버 경로가 쓰는 참조(_movement)는 게이트에서 return되기 전에, Awake에서 잡아둔다.
클라이언트가 칠 때만 거리가 143
거리 검증을 넣자 클라이언트 타격이 전부 거부됐다. 서버가 잰 거리가 클라이언트가 어디에 서 있든 143으로 고정이었고, 호스트 타격은 멀쩡했다.
딱히 가설이 없어서 서버 거리 계산이 쓰는 값을 거슬러 올라가다 PlayerMovement.Update에서 이 줄을 찾았다.
InteractRay = new Ray(_cameraPoint.position, _cameraPoint.forward);
InteractRay는 owner의 Update가 매 프레임 갱신해서 저장해 두는 값이다. 호스트 PC의 클라이언트 복제본에서는 Update가 돌지 않으니 옛 값에 멈춰 있었다. 복제본에서 Update가 멈추는 정확한 경로(컴포넌트 비활성인지, _isPlaying 플래그인지)는 아직 확인하지 못했다.
서버 시작점을 플레이어 루트의 CameraPoint Transform 위치로 바꾸자 클라이언트 타격이 서로 다른 위치에서 정상 통과했다. Transform은 동기화되니까 복제본에서도 최신 위치다( 샘플의 PickUpRpc도 서버에서 이 점을 쓴다 ).
여기서 서버 경로에서 읽으면 안 되는 값을 정리했다.
- 카메라·입력·UI처럼 그 PC의 로컬 플레이어에게만 의미 있는 값
- owner의
Update가 갱신해 저장해 두는 값
대신 동기화되는 Transform이나 NetworkVariable을 직접 읽는다.
쿨다운은 시각으로 비교한다
같은 이유로 쿨다운도 “매 프레임 줄이기”로는 못 만든다. 이건 짜기 전에 걸러졌다.
Update에서 남은 시간을 줄이는 방식이면 서버 복제본에서는 첫 타격 이후 값이 줄지 않는다. 그 클라이언트의 요청은 이후 전부 거부되고, 호스트 자신의 타격은 정상이라 호스트 테스트로는 드러나지 않는다. 143 버그와 똑같은 모양이다.
그래서 통과 시각을 기록해 두고, 요청이 도착할 때 비교한다.
- 단위는
Time.timeAsDouble(초). 비교가 호스트 프로세스 안에서만 일어나니 서버 로컬 시간으로 충분하다. - 프레임 수로 세면 안 된다. 같은 30프레임이 호스트가 60fps면 0.5초, 144fps면 약 0.21초다. 호스트 PC 성능이 모든 플레이어의 타격 속도를 바꿔버린다.
- 기록은 모든 검증을 통과한 뒤에만 갱신한다. 거부된 요청까지 갱신하면 일찍 도착한 패킷 하나가 기준을 뒤로 밀어 거부가 연쇄되고, 뒤쪽 검증(대상·거리)에서 떨어진 요청이 쿨다운을 소모하게 된다.
거리 검증은 같은 선분을 잰다
서버는 클라이언트 Raycast와 같은 선분을 잰다.
- 시작점: 동기화되는
CameraPoint위치(눈) - 끝점:
Collider.ClosestPoint(눈)(대상 표면)
사거리 끝에서 친 타격이 거부되면 네트워크 지연부터 의심하기 쉽다. 하지만 클라이언트는 “눈 → 대상 표면”을 재는데 서버가 “발밑 → 대상 중심”을 재면 지연이 0이어도 항상 거부된다. 지연을 의심하기 전에 “지연이 0이어도 이 계산이 맞는가”부터 본다.
그래서 둘을 분리했다. 기하 차이는 같은 선분을 재서 0으로 만들고, 지연은 여유값(_rangeMargin)으로만 흡수한다.
// 저장하지 않고 매번 계산한다 (InteractRange는 PlayerMovement 쪽 값)
private float MaxHitDistance => _movement.InteractRange + _rangeMargin;
한계를 Awake에서 계산해 두지 않은 이유는 두 가지다.
- Play 중 인스펙터에서 값을 바꾸면 바로 반영된다.
- 같은 오브젝트에 붙은 컴포넌트끼리
Awake순서는 보장되지 않는다. 다른 컴포넌트 값을Awake에서 캐싱하면 초기화 전 값을 읽을 수 있다.
정상 클라이언트는 사거리 밖을 그냥 허공으로 처리하니 서버 거리 거부는 평소에 볼 일이 없다. 테스트하려면 사거리를 어기는 클라이언트를 일부러 만들어야 해서, 기본값이 false인 디버그 스위치를 달았다.
거리 0의 정체
그렇게 검증을 돌리다 개요의 그 로그를 봤다. ClosestPoint 결과가 시작점과 같았고, 같은 요청에서 이게 같이 찍혔다.
bounds.Contains(시작점) = True- 시작점과 콜라이더 중심의 X·Z가 일치
- 맞은 대상:
Multiplayer Controller(Clone)
눈이 대상 콜라이더 안에 있다 → 대상이 나 자신이었다.
플레이어 루트에는 콜라이더가 여러 개 있다. 클라이언트 Raycast가 맞힌 콜라이더와 서버가 TryGetComponent<Collider>로 가져온 콜라이더가 달랐을 가능성이 있다고 보고 있는데, 아직 추측이다. ClosestPoint가 콜라이더 내부의 점을 그대로 돌려준다는 것도 관찰과는 맞지만 문서로 확인하진 않았다.
수정은 양쪽에 넣었다.
- 클라이언트: Raycast 결과에서 자기 자신을 거른다.
- 서버: 대상이 자기 자신이면 거부,
IHittable이 아니면 거부.
클라이언트에서 거르는 건 정상 플레이를 위해서고, 서버에서 거르는 건 클라이언트를 믿지 않기 위해서다.
대상이 사라지는 중에 도착한 요청
광물이 부서져 사라지는 사이 늦게 도착한 타격은 어떻게 될지도 봤다. Multiplayer Tools의 Network Simulator로 Lag spike(5000ms)를 걸고, 그동안 클라이언트로 치고, 호스트에서 대상을 없앴다.
- 스파이크가 끝나자
HitRpc다섯 개가 같은 프레임(2001)에 몰려 도착했다. 첫 요청만 처리되고 나머지는 쿨다운으로 거부됐다( 남은 시간 = 쿨다운 전체 ). - 호스트 Hierarchy에서 Delete하자 호스트에서만 사라지고 클라이언트에는 남았다. 그 상태로 보낸 요청은 대상 유효성 검증을 통과했다.
- 서버에서
NetworkObject.Despawn()을 부르니([ContextMenu]테스트 도구) 양쪽에서 사라졌고, 늦게 온 요청은Object ID : 1 유효하지 않은 오브젝트로 거부됐다.
에디터에서 지우는 건 네트워크 despawn이 아니었다. 네트워크 오브젝트를 모든 화면에서 없애려면 서버에서 Despawn()을 불러야 한다. Delete가 왜 전파되지 않는지 메커니즘은 아직 모른다.
참고로 PC 간 시점을 맞춰볼 때 Time.frameCount는 쓸 수 없다. 프로세스마다 따로 센다. 프로세스 간 비교는 콘솔에 찍힌 실제 시각으로 했다.
최종 검증 순서
① 요청자 → ③ 대상(존재, 자기 자신 아님, IHittable) → ② 쿨다운 → ④ 거리
→ 통과 시각 갱신 → OnServerHit → 발행
번호와 순서가 어긋난 건 대상 유효성(③)을 쿨다운 앞으로 옮겼기 때문이다. 무효한 대상에 대한 요청은 쿨다운을 소모하지 않는다( 로그로 확인 ).
검증 로직은 내가 짰고, 이를 같은 클래스의 private 메서드로 나누는 정리는 AI가 했다.
[Rpc(SendTo.Server)]
public void HitRpc(NetworkObjectReference targetRef, RpcParams rpcParams = default)
{
double now = Time.timeAsDouble; // 이번 요청의 서버 시각
if (!IsFromOwner(rpcParams.Receive.SenderClientId)) return; // ① 보낸 사람 == 이 플레이어 주인
if (!TryResolveTarget(targetRef, out var targetObject, out var hittable)) return; // ③ 존재·자기 자신 아님·IHittable
if (!IsCooldownReady(now)) return; // ② 마지막 통과 시각과 비교
if (!IsInRange(targetObject, out var hitPoint)) return; // ④ 눈 → 대상 표면 거리
_lastAcceptedTime = now; // 모든 검증을 통과한 요청만 기록을 바꾼다
hittable.OnServerHit(OwnerClientId, hitPoint); // 대상 반응
PublishHit(targetObject, hitPoint); // t03 GameEventBus 전까지는 로그
}
검증이 늘었다고 책임이 늘어난 건 아니다. 책임을 “그 코드를 바꾸게 만드는 이유”로 세면, 검증 네 개는 전부 “이 요청을 받아들일지 판정한다” 하나다. 그래서 HitRpc에는 판정 → 대상 반응 → 발행 흐름만 남기고, 별도 Validator 클래스는 같은 검증을 쓰는 두 번째 Rpc가 생길 때 뽑기로 했다.
IHittable.OnServerHit(ulong attackerClientId, Vector3 hitPoint)는 바위, 터지는 광물, 반전형 광물이 함께 구현할 계약이다. 시그니처는 AI가 정한 거라 팀원과 확정해야 한다.
MPPM에서 걸린 것들
Multiplayer Play Mode로 가상 플레이어를 띄워 테스트하다 두 번 막혔다.
- 씬·프리팹을 저장하지 않고 Play → 클라이언트에서
In-Scene placed NetworkObject soft synchronization failure,Failed to spawn. 저장하니 해결됐다. - 스크립트를 바꾼 뒤
NetworkConfig mismatch로 접속 실패 → 가상 플레이어를 재시작하니 해결됐다.
첫 번째를 NetworkPrefabs 등록으로 덮는 방법도 있지만 쓰지 않았다. 등록하면 클라이언트가 오브젝트를 새로 생성해버려서 원인(씬 불일치)이 가려진다.
아직 확인하지 못한 것
- 프로젝트의 정확한 NGO 버전 (호출 권한 기본값은 2.7 문서 기준)
- 호스트가 보낸 서버 Rpc가 네트워크를 거치지 않고 로컬에서 바로 실행되는지
- 에디터 Hierarchy Delete가 despawn으로 전파되지 않는 이유
- 복제본에서
PlayerMovement.Update가 멈추는 경로 - 콜라이더가 여러 개인 대상(플레이어)의 거리 측정 방식. 팀킬을 만들 때 필요하다.
-
IHittable시그니처의 팀 합의 - 다음에 만들 “캐내기” 요청은
HitRpc의 검증 중 무엇을 재사용하고 무엇이 달라져야 할까?
소감
143 고정, 쿨다운 설계, 거리 0의 내 몸까지.. 이번에 걸린 건 전부 클라이언트로 쳐야만 보이는 것들이었다. 호스트로만 테스트했으면 하나도 몰랐을 거라 클라이언트 테스트를 따로 두는 게 얼마나 중요한지 제대로 체감했다!