서버와 클라이언트간 시간 동기화

김지윤·2025년 2월 19일

UE5_MultiplayerShooter

목록 보기
13/25
post-thumbnail

가장 간단하게 생각해보면 매치 카운트다운이 있다.
한 라운드에 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은 아까 구한 클라이언트와 서버간 시간 차이를 현재 시간에 더해 서버 시간을 유추한 값을 리턴한다.
이런 로직을 통해 매치 카운트다운 동기화를 구현했다.

profile
공부한 거 시간 날 때 작성하는 곳

0개의 댓글