[Portfolio] 네트워크 동기화와 지연 보상
카테고리: Portfolio
태그: C++, LagCompensation, Multiplayer, Networking, UE5
📌 이 문서는 현재 구조를 정리한 것이며, 완성본이 아닙니다.
만들어 가는 과정은 개발 기록에 있습니다.
동작 영상
파란색이 현재 히트박스, 빨간색이 리와인드로 복원한 과거 히트박스다. 클라이언트가 쏜 시점의 빨간 박스로 판정한다.
전제: 서버가 유일한 진실이다
클라이언트는 입력을 보내고 결과를 받아 그린다. HP, 탄약, 히트 판정, 아이템 소유. 전부 서버가 정한다.
검증은 PIE 다중 클라이언트 2인 + Listen Server 구성까지 했다. 데디케이티드 서버 타깃은 아직 분리하지 않았다.
클라이언트가 하는 일은 두 가지뿐이다.
- 입력 전송 서버 RPC 또는 CMC 이동 패킷
- 예측 연출 총구 화염처럼 되돌려도 손해가 없는 것만
되돌릴 수 없는 것은 예측하지 않는다. 데미지, 사망, 아이템 소비가 여기에 해당한다.
1. 이동 상태 동기화: RPC가 아니라 CMC 확장
Sprint, ADS, Crouch 같은 이동 상태를 처음에는 Server RPC로 보냈다. 이 방식은 클라이언트 예측과 어긋난다.
CMC는 자기가 보낸 이동을 저장해뒀다가, 서버 보정이 오면 그 지점부터 다시 재생한다. 그런데 Sprint 상태를 별도 RPC로 보내면 재생 과정에 그 상태가 없다. 재생할 때마다 속도가 달라져서 위치가 계속 어긋난다.
그래서 이동 상태를 CMC의 이동 패킷 안에 넣었다.
// FSavedMove_EPCharacter::GetCompressedFlags()
// 이동 입력과 같은 패킷에 상태를 실어 보낸다
if (bSavedWantsToSprint) Result |= FLAG_Custom_0;
if (bSavedWantsToADS) Result |= FLAG_Custom_1;
| Server RPC 방식 | CMC 확장 방식 | |
|---|---|---|
| 이동 재생 시 상태 | 없음 | 있음 |
| 패킷 수 | 이동 + 상태 별도 | 이동 하나 |
| 위치 어긋남 | 발생 | 없음 |
CMC 내부를 이해하는 데 시간이 제일 많이 들었다.
ReplicateMoveToServer → AllocateNewMove → GetCompressedFlags → UpdateFromCompressedFlags → PrepMoveFor 로 이어지는 흐름을 직접 따라가며 정리했다.
2. 지연 보상: 본 단위 히트박스 리와인드
문제
핑이 있으면 클라이언트가 보는 적의 위치는 과거다. 클라이언트 화면에서 정확히 조준해도, 서버의 현재 위치는 이미 옮겨져 있어서 빗나간다.
해결
서버가 모든 캐릭터의 본 위치를 시간과 함께 기록해두고, 발사 요청이 오면 그 클라이언트가 쏜 시점으로 히트박스를 되돌려서 판정한다.
flowchart TD
A[클라 발사<br/>ClientFireTime 동봉] --> B[서버 수신]
B --> C[GetHitscanCandidates<br/>Broad Phase 후보 추림]
C --> D[GetSnapshotAtTime<br/>두 스냅샷 사이 보간]
D --> E[본 Transform을 과거로 이동]
E --> F[Narrow Trace]
F --> G[원위치 복원]
G --> H[히트 확정 → GE 데미지]
핵심 설계 결정 몇 가지다.
- 단일 캡슐이 아니라 Physics Asset 본 단위로 판정한다. 머리와 팔을 구분하려면 캡슐 하나로는 안 된다
- 시간 기준을
GetServerWorldTimeSeconds()로 통일했다. 클라와 서버가 각자 로컬 시계를 쓰면 기준이 갈린다 MaxRewindSeconds = 0.5f상한을 뒀다. 클라가 보낸 시각을 그대로 믿으면 시각 위조로 과거를 무한정 되돌릴 수 있다- 후보군 밖 히트는 버린다. Broad Phase에서 추린 집합에 없는 캐릭터가 Narrow Trace에 잡히면 무시한다
- SSR 컴포넌트는 복제하지 않는다.
SetIsReplicatedByDefault(false). 서버 전용이다
겪은 문제: 보이는 것보다 이전 위치를 때려야 맞았다
리와인드를 붙였는데 여전히 어긋났다. 그것도 항상 정확히 한 틱만큼이었다.
원인은 계산식이 아니라 시각을 읽는 위치였다.
스냅샷의 시각은 CMC::OnMovementUpdated에서 찍고, 본 Transform은 같은 틱의 TG_PostPhysics에서 읽고 있었다.
같은 틱이니 같은 프레임이라고 믿었는데, UWorld::Tick의 순서가 그렇지 않았다.
// LevelTick.cpp: UWorld::Tick
BroadcastTickDispatch(DeltaSeconds); // ServerMove RPC 처리. 여기서 시각을 읽었다
BroadcastPostTickDispatch(); // CMC::OnMovementUpdated가 여기서 돈다
...
TimeSeconds += DeltaSeconds; // 월드 시간은 '그 뒤'에 전진한다
...
RunTickGroup(TG_PrePhysics); // 틱 그룹은 '그 다음'
GetServerWorldTimeSeconds()는 World->GetTimeSeconds()를 그대로 쓴다.
그러니 TickDispatch에서 읽은 시각은 직전 프레임 값이고, PostPhysics에서 읽은 본은 이번 프레임 값이다.
GetServerWorldTimeSeconds()는 한 프레임 안에서도 어디서 부르느냐에 따라 다른 값을 준다.
이게 “항상 정확히 한 틱”의 정체였다. 600cm/s로 달리는 캐릭터면 한 프레임에 10cm다. 그런데 실제 오차는 훨씬 컸다. 어긋난 두 스냅샷 사이를 보간이 메우면서 오차가 증폭되고, 패킷 간격이 벌어지는 나쁜 네트워크에서는 그 간격이 그대로 곱해진다.
해결: 세 값을 한 순간에 묶는다
시각과 위치를 TickDispatch에서 보관만 하고, 본이 확정된 TG_PostPhysics에서 함께 커밋한다.
// TickDispatch 시점. 본 Transform은 아직 갱신 전이므로 값만 보관한다
void UEPServerSideRewindComponent::OnServerMoveProcessed(float Time, FVector Location)
{
bHasPendingSnapshot = true;
PendingSnapshotTime = Time;
PendingSnapshotLocation = Location;
}
// PostPhysics. 본 Transform 갱신 완료. 여기서 커밋한다
void UEPServerSideRewindComponent::TickComponent(...)
{
if (bHasPendingSnapshot)
{
SaveHitboxSnapshot(PendingSnapshotTime, PendingSnapshotLocation);
bHasPendingSnapshot = false;
}
}
| 수정 전 | 수정 후 | |
|---|---|---|
RewindPos 오차 |
242cm | 2.3cm |
242cm는 캐릭터 하나를 통째로 빗나가는 거리고, 2.3cm는 히트박스 안이다. Bad 네트워크 프리셋 기준이다.
여기에 서버 쪽 전제가 하나 더 붙는다.
GetMesh()->VisibilityBasedAnimTickOption =
EVisibilityBasedAnimTickOption::AlwaysTickPoseAndRefreshBones;
서버는 렌더링이 없어서 기본 설정으로는 포즈를 갱신하지 않는다.
이걸 안 켜면 TG_PostPhysics에서 읽어도 스냅샷이 정적 포즈로 고정된다.
원인을 찾는 데 일주일이 걸렸다.
어떤 상황에서도 오차가 정확히 한 틱이라는 점이 실마리였다.
값이 랜덤하게 틀렸다면 계산식을 의심했을 텐데, 항상 일정하니 게임 로직이 아니라 틱 구조를 봐야 한다고 판단할 수 있었다.
결국 UWorld::Tick을 직접 읽어서 찾았다.
자세한 구현: 히트박스 스냅샷 구조 · SSR 컴포넌트 구현 · 사격 흐름 통합
3. 복제 설계 원칙
프로퍼티마다 매번 고민하지 않으려고 규칙을 먼저 정했다.
| 상황 | 방식 |
|---|---|
| 값만 필요 | UPROPERTY(Replicated) |
| 값이 바뀔 때 클라 반응 필요 | ReplicatedUsing = OnRep_ |
| 소유자만 알아야 함 | COND_OwnerOnly |
| 일회성 연출 | Multicast RPC (Unreliable) |
| 되돌릴 수 없는 요청 | Server RPC (Reliable) |
연출은 Unreliable이다. 총구 화염 한 발이 유실돼도 게임은 굴러간다. 반대로 상태 변경은 Reliable이어야 한다. 재장전 요청이 유실되면 무기가 영영 잠긴다.
자세한 내용: 멀티플레이어 복제 설계 · 서버 권한형 매치 흐름
4. 디버그 시각화
네트워크 문제는 눈으로 봐야 잡힌다. 그래서 판정 결과를 그리게 해뒀다.
| 색 | 의미 |
|---|---|
| 파랑 | 현재 물리 히트박스 |
| 빨강 | 리와인드로 복원한 과거 히트박스 |
| 하양 | 트레이스 선 |
| 노랑 | 확정된 히트 |
UEPCombatDeveloperSettings에서 켜고 끈다. 셰이핑 빌드에서는 컴파일 자체가 빠진다.
#if !(UE_BUILD_SHIPPING || UE_BUILD_TEST)
// 디버그 드로우
#endif
남은 것
정직하게 적어둔다.
- Broad Phase가 O(N)이다. 플레이어 수가 늘면 Spatial Hash로 바꿔야 한다.
GetHitscanCandidates하나만 교체하면 되도록 격리해뒀다 - Physics Asset 바디 수에 비례해 판정 비용이 오른다. 손가락, 발가락은 애초에 제외했다
- 쿨다운 지연 보정이 없다. 핑이 높을수록 실질 연사가 느려진다. GAS 문서에 자세히 적었다
- 발사와 착탄 연출이 아직 Multicast RPC다. GameplayCue로 옮기는 게 맞다

댓글남기기