오늘 작성한 필기를 남깁니다. 표로된 부분, 놓친 부분을 중점으로 복습할 예정입니다.
멀티플레이어 게임에서 칼을 휘둘렀는데 0.1초 뒤에 적이 죽었습니다.
그 0.1초 동안 패킷을 대륙을 횡단하고, 지구를 한 바퀴 돌아서 돌아옵니다.
클라이언트 개발자가 네트워크 코드를 직접 짜지 않더라도, 왜 동기화가 어려운지, 왜 서버가 권위를 가져야 하는지 대답할 수 있어야 합니다.
네트워킹의 원리부터 동기화, 최적화, 치팅 방지까지, 0.1초 안에 벌어지는 일의 전부를 다룹니다.
사람은 0.15초 이상 넘어가면 지연을 감지할 수 있게 됩니다.
60프레임일 때 1프레임은 대략 0.016입니다.
IP - 네트워크상 컴퓨터를 식별하는 고유 주소( 4 .으로 구분되면 IPv4 예: 192.168.1.10, 6개로 구분되면 IPv6)
포트(Port) - 같은 컴퓨터 안에서 프로그램을 구분하는 번호 예: HTTP 80, HTTPS 443, 게임 서버 7777
이관식님의 댓글 : 낮은 포트번호는 쓰지맙시다 시스템에서 사용하는 경우 많음
튜터님 첨삭 : 0 ~1000 이미 다른 프로토콜에 선점 당한 경우가 많습니다. 이미 세계적으로 사용중인 번호들이 있으니 그에 따라야 합니다.
소켓(Socket) IP + 포트를 묶어서 실제 데이터를 주고받는 통로, OS가 제공하는 네트워크 통신 API(sendto(), recvfrom() 등)
편지 봉투로 생각해도 됩니다. 편지 봉투를 열어서 편지를 담고 보낸다.
하나의 서버에서 게임, 채팅, 음성을 동시에 돌릴 수 있는 이유는 포트가 프로스세스를 구분하기 때문입니다.
CPU의 코어 수에 따라 돌릴 수 있는 프로세스의 수가 결정되기에 한번에 처리할 수 있는 프로세스의 수는 늘 제한되어 있습니다. 포트가 아무리 많아도 실제로 사용되는 프로세스는 한정.
클라이언트 하나당 하나의 프로세스를 할당하는 경우도 존재합니다.
패킷 = 데이터를 잘게 쪼갠 조각 + 헤더(목적지, 순서 등의 메타데이터)
클라이언트 -> 라우터 -> 라우터 -> 라우터 -> 서버
Network라는 이름처첨 라우터의 연결망은 그물처럼 복잡합니다.
플레이어 상태 + 타임스탬프 + ID
-> 패킷으로 포장 -> 라우터를 여러 개 거침(Hop)
-> 도착 후 재조립. 각 라우터는 헤더의 목적지 IP를 보고 다음 경로를 결정
지연(Latency)
패킷이 출발지에서 목적지까지 도달하는데 걸리는 시간
게임에서 공격판정 지연, 위치 불일치의 원인
핑 (Ping, RTT)
왕복 지연시간(Round-Trip Time)
50ms ping = 편도 약 25ms
대역폭(Bandwidth)
단위 시간당 전송 가능한 최대 데이터량 (bps).
동시 플레이어 수의 상한을 결정.
-> 깃허브나 우리의 데디케이트 서버도 대역폭이 존재, 데디케이트는 클라이언트에서의 동작도 똑같이 수행해야 하기 때문에 많아도 80명이 한계?
-> 1만 명이 동시에 접속가능한 서버를 만드는 것은 서버 프로그래머의 꿈? 매우 어렵다!
패킷 손실(Packet Loss)
전송 중 유실되는 패킷의 비율(%).
순간이동 끊김, 동기화 깨짐의 원인.
지터(Jitter)
연속 패킷 간 도착 시간의 편차
30ms -> 80ms -> 20ms 흔들림 - 프레임 타이밍이 불안정해짐.
일반적인 핑 값

TCP(Transmission Control Protocol)
모든 데이터가 순서대로, 빠짐없이 도착하는 것을 보장하는 프로토콜입니다.
TCP - 선두 차단(Head-of-Line Blocking)
선두 차단(Head-of-Line Blocking)
패킷이 하나가 손실되며느 뒤의 모든 패킷이 재전송을 기다립니다. TCP의 순서 보장이 실시간 게임에서는 가장 큰 병목이 됩니다.
UDP
보내고 잊는다

왜 게임은 udp를 쓰는가?
중간 패킷이 소실되면?
UDP - 이후 패킷 전부 블로킹
UDP - 최신 데이터 바로 적용
최신 값이 이전 값을 덮어쓰는 데이터(이동 등)는 재전송이 오히려 해롭습니다.
실제 게임은 UDP 위에 자체 신뢰성 레이어(Reliable UDP)를 구축합니다. 중요 이벤트(사망, 아이템 획득 등)만 선택적으로 보장합니다.
Unreal도 기본적으로 UDP이며 ReliableUDP를 사용합니다.
클라이언 - 서버
p2p
권위 서버 = 서버가 게임 세계의 유일한 진실
클라이언트는 입력 장치일 뿐, 게임 상태를 결정하지 않습니다.
클라이언트가 보내는 정보는 다 거짓말이라 생각하는 것이 좋음.
클라이언트의 치팅으로부터 비교젹 안전합니다.
클라이언트 예측(Client-Side Prediction) - 문제
권위 서버는 입력 -> 서버 -> 응답 왕복 시간만큼 지연됩니다.
핑 100ms -> W 키 입력 후 100ms동안 캐릭터가 안 움직임!
선조치 후보고!
가끔 렉이 걸리면 뒤로 순간이동되는 현상은 이것 때문입니다.
보정 폭이 크면 캐릭터가 텔레포트 하는 것처럼 보입니다.
-> 보정을 부드럽게 보간(Interpolation)하는 기술이 필요합니다.
근본 문제
네트워크 지연 = 모든 클라이언트가 과거의 세계를 보고 있습니다.
핑 100ms인 플레이어는 50ms 전이 세계를 렌더링합니다.
이 불일치를 줄이기 위한 기법으로 보간, 외삽, 래그 보상이 있습니다.
보간
서버로부터 받은 두 시점의 상태 사이를 부드럽게 연결
DelayNetCode, 딜레이 방식 핑 때문에 딜레이가 생기니 그냥 다 딜레이를 걸어버리자?
외삽(Extrapolation)
마지막으로 수신한 정보를 기반으로 미래를 추측
보간 vs 외삽

래그 보상(Lag Compensation)
상황: 핑 80ms인 플레이어가 FPS에서 헤드샷을 쏘았을 때를 가정합니다.
슈터 화면 (40ms 전) : 적이 문 앞에 서 있음 -> 정확히 머리를 조준 -> 발사
서버 (션재) : 적으 이미 문 뒤로 이동 -> 총알이 벽에 맞음!
래그 보상 - 서버 되감기(Server Rewind)
서버가 슈터의 지연만큼 월드를 되감아서 그 시점의 위치로 히트 판정
-> "쏜 사람의 화면을 존중"
핑 만큼의 처리를 해서 반환하는 방식?
래그 보상 - 트레이드 오프
피커스 어드밴티지(Picker's Adventage)
코너를 먼저 도는 쪽이 지연만큼 유리한 현상입니다.
대부분의 FPS는 쏘는 쪽을 우선합니다. - "맞추는 느낌"이 핵심이기 때문이라 합니다.
복제(Replication)
상태 복제(State Replication)
서버가 주기적으로 게임 오브젝트의 상태를 클라이언트에 전송
원격 호출(RPC, Remote Procedure Call)
원격 머신에서 함수를 호출하는 것
상태 복제 vs RPC - 핵심 원칙
상태는 복제, 사건은 RPC입니다.

관련성과 위선순위
64명 배틀로얄 - 모든 플레이어의 상태를
모든 클라이언트에 보내면 대역폭이 폭발합니다.

관련성 : 시야/거리 밖 데이터를 아예 전송 안 함
-> 월핵 방지 효과도 있다고 합니다.
우선순위 : 가까울 수록 자주, 멀수록 드물게
왜 최적화가 필요한가
최적화 없이 계산할 때, 64명 서버라면
상태 56B X 64명 X 30Hz X 63명(수신자) 대략 6.8MB/s 업로드 서버 대역폭이 금방 바닥납니다.
최적화는 선택이 아닌 필수입니다.
최적화 방법

양자화(Quantization)
정밀도를 줄여 자료형을 축소
사람이 감지하지 못할 정도의 차이만 발생시켜야 합니다.
델타 압축(Delta Compression)
변경된 필드만 전송합니다.
어떤 필드가 변경되었는지 비트마스크(DirtyFlags)로 추적합니다.
비트 패킹(Bit Packing)
패킷 하나에서는 작아 보여도 64명 * 30Hz = ㅊ호당 7,680바이트를 절약할 수 있습니다.
빈도 조절(Frequency Scaling)
모든 데이터가 매 프레임 동기화될 필요는 없습니다.
"대역폭 예산" 안에서 중요한 데이터에 더 많은 대역폭을 할당하는 것이 핵심

한참을 해맸습니다. AI는 계속 같은 말만 반복하기만 했습니다. 그것이 잘못되었다는 것을 알게 된 수차례의 구글링 끝에 한 포럼에서 찾은 글 덕분이었습니다.
Static Mesh Location의 Mesh Binding은 Object 타입의 매개변수만 바인딩이 가능하다.
아니 StaticMesh 매개변수 생성도 가능하고, StateMeshDataInterface 타입도 생성이 가능하면서 Object만 받아들일 수 있는 것은 또 왜일까요? 이것 때문에 수 시간을 찾아해맸습니다.
지금은 정상저긍로 매쉬를 가져와서 사용이 됩니다.
Altar 내부에 올릴 아이템의 정보를 다음 슬롯 배열을 만들어 둡니다. 각 슬롯에는 배치될 아이템의 DataAsset(태그, 매쉬 정보 등을 가져오기 위함)과 배치될 상대 위치가 들어갑니다.
BeginPlay 시 Naiagara System을 생성하고 나이이가라 컴포넌트에 설정합니다.
이 때 Activate와 RegisterComponent의 위치가 중요하다고 합니다.
정확한 것은 아니지만 오류가 발생하고 있었을 때, AI가 말한 것으로는 RegisterComponent 호출 시 내부의 데이터가 Default 값으로 설정이 될 가능 성이 있다고 합니다. 또 Activate가 되어 있는 상태에서는 값은 변경이 바로 적용되지 않기에 적용되지 전의 설정으로 파티클이 출력될 수 있다고 합니다. 이를 막기 위해서는 Deactive를 한 상태에서 설정하고 설정이 완료된 후에 Actiavet를 해줘야 한다고 하는데 솔직히 크게 의미 없어 보입니다.
나이아가라 이펙트 생성 코드입니다.
UNiagaraComponent* NiagaraComp = NewObject<UNiagaraComponent>(this);
NiagaraComp->SetAutoActivate(false);
NiagaraComp->SetAsset(Slot.RequiredItemHintEffect);
NiagaraComp->SetupAttachment(RootComponent);
NiagaraComp->SetRelativeLocation(Slot.AttachOffset);
NiagaraComp->RegisterComponent();
// 메쉬 정보 전달 (Niagara에 User.Mesh 파라미터가 있을 경우)
UVGMissionItemDataAsset* ItemDataAsset = Slot.ItemDataAsset.Get();
if (ItemDataAsset)
{
// NiagaraComp->SetVariableStaticMesh(FName("User.TargetMesh"), ItemDataAsset->ItemMesh);
NiagaraComp->SetVariableObject(FName("User.TargetObject"), ItemDataAsset->ItemMesh);
UE_LOG(LogTemp, Warning, TEXT("TargetMesh = %s"),
*GetNameSafe(ItemDataAsset->ItemMesh));
}
NiagaraComp->Activate();
NiagaraComp->ReinitializeSystem();
비트 마스크를 사용해서 어떤 슬롯이 채워졌는 지를 복제하고 있습니다.
// 슬롯 점유 비트마스크 — 1바이트로 최대 8슬롯의 점유 여부를 압축 전송
UPROPERTY(Replicated)
uint8 PlacedSlotMask = 0;
void AVGMissionGimmickAltar::SetSlotBit(int32 SlotIndex)
{
if (SlotIndex < 0 || SlotIndex >= PlacementSlots.Num())
{
return;
}
PlacedSlotMask |= (1 << SlotIndex);
}
bool AVGMissionGimmickAltar::IsSlotBitSet(int32 SlotIndex) const
{
return PlacedSlotMask & (1 << SlotIndex);
}
void AVGMissionGimmickAltar::UpdateHintEffectVisibility()
{
APlayerController* LocalPC = GetWorld()->GetFirstPlayerController();
if (!LocalPC || !LocalPC->GetPawn())
{
return;
}
float DistSq = FVector::DistSquared(
GetActorLocation(),
LocalPC->GetPawn()->GetActorLocation()
);
bool bShouldShow = DistSq < FMath::Square(HintVisibleRange);
for (int32 Index = 0; Index < HintEffectComponents.Num(); Index++)
{
UNiagaraComponent* Comp = HintEffectComponents[Index];
if (!Comp)
{
continue;
}
// 이미 채워진 슬롯은 무조건 비활성
bool bSlotEmpty = !IsSlotBitSet(Index);
bool bActivate = bShouldShow && bSlotEmpty;
if (bActivate && !Comp->IsActive())
{
Comp->Activate();
}
else if (!bActivate && Comp->IsActive())
{
Comp->Deactivate();
}
}
}

기존에는 미션 오브젝트의 위치에 보상을 생성하기 위해서는 미션 클래스에서 보상 생성 함수를 덮어쓰는 수밖에 없었습니다.
이번에 수정을 하여 bSpawnRewardAtMission이 true이면 MissionObject에서 생성을 할 수 있게 변경하여 코드의 중복을 줄였습니다.
void AVGMissionBase::SpawnRewardItems()
{
// 스폰은 서버에서만 진행
if (!HasAuthority())
{
return;
}
// 기본 구현: LastContributor 주변에 아이템 스폰
// 자식 클래스에서 override하여 커스텀
if (!LastContributor.IsValid() || GetRewardItemClass() == nullptr)
{
UE_LOG(LogTemp, Error, TEXT("LastContributor or RewardItemClass is Missing."));
return;
}
FVector SpawnLocation = GetActorLocation();
if (!bSpawnRewardAtMission)
{
SpawnLocation = LastContributor->GetActorLocation()
+ LastContributor->GetActorForwardVector() * 100.f;
SpawnLocation.Z += 50.f;
}
FActorSpawnParameters Params;
Params.SpawnCollisionHandlingOverride =
ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
GetWorld()->SpawnActor<AVGEquippableActor>(GetRewardItemClass(), SpawnLocation,
FRotator::ZeroRotator, Params);
}
현재 제한 시간 내에 모든 샌드백을 파괴하는 미션을 구현 중입니다. 리셋 로직이 정상적으로 동작하지 않아 내일 중으로 해결할 계획입니다.
void AVGMissionItemBase::OnInteractWith(AActor* Interactor, const FTransform& InteractTransform)
{
if (!HasAuthority())
{
return;
}
if (!CanInteractWith(Interactor))
{
return;
}
OnPickedUp(Interactor);
if (UVGEquipmentComponent* EquipComp =
Interactor->FindComponentByClass<UVGEquipmentComponent>())
{
EquipComp->Server_EquipItem(this);
if (HasAuthority())
{
EquipComp->OnItemDropped.AddDynamic(this, &AVGMissionItemBase::OnDropped);
}
UE_LOG(LogTemp, Log, TEXT("[%s] Equipped!"),*GetName());
}
}
void AVGMissionItemBase::OnInteractWith(AActor* Interactor, const FTransform& InteractTransform)
{
if (!HasAuthority())
{
return;
}
if (!CanInteractWith(Interactor))
{
return;
}
OnPickedUp(Interactor);
if (UVGEquipmentComponent* EquipComp =
Interactor->FindComponentByClass<UVGEquipmentComponent>())
{
EquipComp->Server_EquipItem(this);
if (HasAuthority())
{
EquipComp->OnItemDropped.AddDynamic(this, &AVGMissionItemBase::OnDropped);
}
UE_LOG(LogTemp, Log, TEXT("[%s] Equipped!"),*GetName());
}
}