
가장 간단하게 생각해보면 매치 카운트다운이 있다.
한 라운드에 120초라고 가정해보자.
모두가 동시에 게임을 시작한다면 서버와 클라이언트간 동기화할 필요가 없다.
물론 그렇다면 정말 좋겠지만 클라이언트의 성능이 아무리 좋아도, 핑이 아무리 낮더라도 접속이 지연되는 게 당연하다.

위는 내 컴퓨터에서 실행한 리슨서버와 클라이언트다.
같은 로컬에서 진행되는 2개의 소프트웨어도 차이가 나는데 실제 온라인 환경은 더 차이가 날 것이다.
세션이 시작되면 서버는 즉시 카운트다운을 시작하지만, 3초 이후 들어온 클라이언트의 남은 시간은 서버가 117초일 때 120초가 된다.
그럼 단순히 서버에 현재 시간을 요청해 가져와 동기화한다면?
클라이언트는 연결 지연 시간만큼 늦은 시간을 보게 될 것이다.
해결 방법은 굉장히 간단하다.

우리가 알아야 하는 건 '연결 지연 시간'이다.
위 사진에서 클라이언트가 알 수 있는 정보는 3개다.

클라이언트가 패킷(요청)을 보낸 시간,
서버가 패킷을 보낸 시간,
클라이언트가 패킷을 받은 시간이다.
여기서 패킷을 보낸 시간과 패킷을 받은 시간을 빼면 '연결 지연 시간(C->S) + 연결 지연 시간(S->C)'를 구할 수 있다.
이게 우리가 흔히 얘기하는 핑이다.
핑은 멀티플레이어 게임에서 이동 동기화에도 쓰이는 아주 중요한 개념이다.
특히 레이싱 게임은 이게 상당히 중요하다.
클라이언트쪽에선 내가 더 앞서간 것처럼 보였는데, 서버에선 아니다?
바로 게임 접는다.
유저가 게임을 접지 않게 하기 위해 동기화에 공들일 필요가 있다.

동기화 로직 적용 이후 사진이다.
물론 약간의 오차는 존재하지만 이 정도면 충분하다.
이제 구현한 로직을 작성하겠다.
void SetHUDTime();
/**
* 서버와 클라이언트간 매치 카운트다운 싱크
*/
UFUNCTION(Server, Reliable)
void ServerRequestServerTime(float TimeOfClientRequest);
UFUNCTION(Client, Reliable)
void ClientReportServerTime(float TimeOfClientRequest, float TimeServerReceivedClientRequest);
float ClientServerDelta = 0.f;
// n초 주기로 매치 카운트다운 싱크 업데이트
UPROPERTY(EditAnywhere, Category = "Time")
float TimeSyncFrequency = 5.f;
// 위 주기를 세기 위한 또 다른 float
float TimeSyncRunningTime = 0.f;
// 매치 카운트다운 싱크 업데이트
void CheckTimeSync(float DeltaTime);
구현한 함수들 선언이다.
호출 순서대로 설명하겠다.
void ABlasterPlayerController::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// 클라이언트에서 시간을 계속 계산하며 HUD에 표시
SetHUDTime();
CheckTimeSync(DeltaTime);
}
Tick에서 호출되는 함수는 2개다.
HUD를 업데이트하는 함수, 시간 싱크를 맞추는 함수.
CheckTimeSync먼저 설명해야 이해가 쉬우니 여기부터 보자.
void ABlasterPlayerController::CheckTimeSync(float DeltaTime)
{
// 쓰로틀링 걸어서 n초 주기로 서버에 패킷 보냄
TimeSyncRunningTime += DeltaTime;
if (IsLocalController() && TimeSyncRunningTime >= TimeSyncFrequency)
{
// 서버에게 현재 시간을 보냄
ServerRequestServerTime(GetWorld()->GetTimeSeconds());
TimeSyncRunningTime = 0.f;
}
}
흔히 쓰로틀링이라고 하면 하드웨어 과열로 인한 성능 저하 이슈를 말하긴 하는데, 여기선 디바운싱과 함께 최적화 기술 중 하나다.
Tick에서 그대로 사용하면 서버에 너무 많은 요청을 보내 과한 트래픽이 발생하므로, n초마다 요청을 보내도록 한다.
TimeSyncRunningTime에 DeltaTime을 더해서, 이 값이 TimeSyncFrequency를 넘으면 서버에 요청을 보내고 다시 TimeSyncRunningTime을 0으로 초기화한다.
void ABlasterPlayerController::ServerRequestServerTime_Implementation(float TimeOfClientRequest)
{
// 현재 서버 시간
float ServerTimeOfReceipt = GetWorld()->GetTimeSeconds();
// 현재 서버의 시간과 함께 클라이언트에게 받은 시간은 그대로 돌려보냄
ClientReportServerTime(TimeOfClientRequest, ServerTimeOfReceipt);
}
서버는 클라이언트의 현재 시간을 받지만 사용은 하지 않고 그대로 돌려보낸다.
현재 서버의 시간도 함께 보내준다.
void ABlasterPlayerController::ClientReportServerTime_Implementation(float TimeOfClientRequest,
float TimeServerReceivedClientRequest)
{
// 현재 시간에서 처음 서버에게 요청한 시간을 빼는 것으로, 패킷이 돌아오는 데까지 걸린 시간을 계산
float RoundTripTime = GetWorld()->GetTimeSeconds() - TimeOfClientRequest;
// 위 시간의 절반을 서버가 패킷을 보내줄 때의 시간에 더해서 현재 서버의 시간을 유추
float CurrentServerTime = TimeServerReceivedClientRequest + (0.5f * RoundTripTime);
// 유추한 서버의 현재 시간과 클라이언트의 시간의 차이
ClientServerDelta = CurrentServerTime - GetWorld()->GetTimeSeconds();
}
클라이언트는 패킷을 받은 시간에서 보낸 시간을 뺀다.
그럼 2개의 연결 지연 시간을 더한 값이 나오므로, 이걸 반으로 나눠 함께 온 현재(사실 과거의)서버 시간에 더한다.
유추한 서버의 시간에서 현재 시간을 뺀 값을 저장한다.
void ABlasterPlayerController::SetHUDTime()
{
// 매치 시간에서 유추한 서버 시간을 빼는 것으로 남은 시간 계산
uint32 SecondsLeft = FMath::CeilToInt(MatchTime - GetServerTime());
// 실질적으로 시간이 바뀌었을 때만 업데이트를 호출해 최적화
if (CountdownInt != SecondsLeft)
{
SetHUDMatchCountdown(MatchTime - GetServerTime());
CountdownInt = SecondsLeft;
}
}
다시 Tick으로 돌아가 SetHUDTime을 보면 MatchTime(120초)에서 GetServerTime의 리턴값을 뺀 값을 HUD로 보내는 걸 알 수 있다.
float ABlasterPlayerController::GetServerTime()
{
if (HasAuthority())
{
// 서버는 자신의 시간을 사용하면 됨
return GetWorld()->GetTimeSeconds();
}
else
{
// 클라이언트는 서버의 시간을 유추함
return GetWorld()->GetTimeSeconds() + ClientServerDelta;
}
}
GetServerTime은 아까 구한 클라이언트와 서버간 시간 차이를 현재 시간에 더해 서버 시간을 유추한 값을 리턴한다.
이런 로직을 통해 매치 카운트다운 동기화를 구현했다.