TIL

[Unreal Engine 멀티플레이] 남은 시간이 아닌 마감 시각을 복제하는 서버 권위 타이머

think95592 2026. 8. 4. 19:23

오늘의 학습 목표

오늘은 멀티플레이 숫자 야구의 타자 입력 제한 시간을 구현하면서 네트워크 타이머를 동기화하는 방법을 학습했다.

핵심은 서버가 15, 14, 13...처럼 남은 시간을 계속 보내는 것이 아니다.

서버는 타이머가 언제 끝나는지를 나타내는 마감 시각 하나만 복제하고, 각 클라이언트가 동기화된 서버 시간과 비교해 남은 시간을 직접 계산한다.

남은 시간 = 마감 서버 시각 - 현재 서버 시각

이 구조를 사용하면 네트워크 복제 횟수를 줄이면서도 모든 클라이언트에서 비슷한 카운트다운을 표시할 수 있다.


1. 핵심 아이디어

현재 서버 시간이 100초이고 제한 시간이 15초라고 가정해 보자.

서버는 다음 값을 계산한다.

마감 시각 = 100 + 15
          = 115초

그리고 115라는 고정된 마감 시각을 GameState를 통해 클라이언트에 복제한다.

각 클라이언트는 매 프레임 다음 계산만 수행한다.

남은 시간 = 115 - 현재 서버 시각

예를 들어 클라이언트가 추정한 현재 서버 시각이 108.3초라면 다음과 같다.

남은 시간 = 115 - 108.3
          = 6.7초

서버가 남은 시간을 매초 복제할 필요 없이 시작할 때 계산한 마감 시각 하나만 전달하면 된다.


2. 전체 실행 흐름

서버 GameMode
├─ 현재 서버 시각 확인
├─ 현재 서버 시각 + 제한 시간 계산
├─ GameState에 마감 시각 저장
└─ 실제 시간 초과 판정용 서버 타이머 시작
            ↓
서버 GameState
└─ 마감 시각을 모든 클라이언트에 복제
            ↓
각 클라이언트 GameState
└─ RepNotify로 마감 시각 변경 감지
            ↓
각 클라이언트 UMG
├─ 마감 시각 - 현재 서버 시각 계산
├─ 남은 시간을 정수로 변환
└─ 화면에 카운트다운 표시

여기서 화면의 카운트다운과 실제 시간 초과 판정은 서로 다른 책임을 가진다.

클라이언트 UMG
= 남은 시간을 계산해서 보여 주는 역할

서버 GameMode
= 실제 기회를 종료하고 아웃을 판정하는 역할

클라이언트 화면에 0초가 표시됐다는 이유만으로 클라이언트가 직접 게임 상태를 변경해서는 안 된다.


3. 마감 시각을 GameState에 저장하는 이유

마감 시각은 모든 참가자가 알아야 하는 현재 경기 상태다.

  • 타자는 자신의 남은 시간을 확인해야 한다.
  • 투수도 타자의 남은 시간을 확인할 수 있다.
  • 관전자도 현재 카운트다운을 볼 수 있다.
  • 늦게 접속한 클라이언트도 현재 남은 시간을 계산해야 한다.

GameMode는 서버에만 존재하므로 클라이언트가 접근할 수 없다. 반면 GameState는 서버와 모든 클라이언트에 존재하며 공용 상태를 복제하기에 적합하다.

UPROPERTY(ReplicatedUsing =
    OnRep_BatterGuessDeadlineServerTime)
float BatterGuessDeadlineServerTime = 0.0f;

0.0f는 현재 활성화된 타자 입력 타이머가 없다는 의미로 사용한다.


4. RepNotify 구성

마감 시각이 클라이언트에 복제되면 OnRep 함수가 호출된다.

UFUNCTION()
void OnRep_BatterGuessDeadlineServerTime();
void AMYBaseballGameState::
    OnRep_BatterGuessDeadlineServerTime()
{
    NotifyBatterGuessDeadlineChanged();
}

현재 위젯은 매 프레임 GameState의 값을 확인하지만, RepNotify를 함께 구성하면 다음과 같은 확장도 가능하다.

  • 타이머 시작 애니메이션
  • 경고음 재생
  • 타이머 패널 표시
  • 다른 UI에 타이머 변경 이벤트 전달
  • 이벤트 기반 UI 갱신

5. DOREPLIFETIME 등록

UPROPERTY(ReplicatedUsing = ...)를 선언하는 것만으로는 프로퍼티 복제가 완성되지 않는다.

GetLifetimeReplicatedProps()에 해당 프로퍼티를 등록해야 한다.

#include "Net/UnrealNetwork.h"

void AMYBaseballGameState::GetLifetimeReplicatedProps(
    TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(
        ThisClass,
        BatterGuessDeadlineServerTime
    );
}

두 코드의 역할은 다음과 같다.

UPROPERTY(ReplicatedUsing = ...)
= 복제 대상이며 어떤 OnRep 함수를 사용할지 선언

DOREPLIFETIME(...)
= 언리얼의 실제 프로퍼티 복제 목록에 등록

둘 중 하나가 누락되면 기대한 대로 복제되지 않을 수 있다.


6. 서버에서 마감 시각 생성하기

타자의 입력 시간이 시작되면 GameMode가 마감 시각을 계산하고 실제 판정용 타이머를 설정한다.

void AMYBaseballGameMode::StartBatterGuessTimer()
{
    if (!HasAuthority())
    {
        return;
    }

    AMYBaseballGameState* BaseballGameState =
        GetGameState<AMYBaseballGameState>();

    if (!IsValid(BaseballGameState)
        || BaseballGameState->GetInningPhase()
            != EMYInningPhase::BatterGuessing)
    {
        return;
    }

    GetWorldTimerManager().ClearTimer(
        BatterGuessTimerHandle
    );

    const float DeadlineServerTime =
        BaseballGameState->GetServerWorldTimeSeconds()
        + BatterGuessTimeLimitSeconds;

    BaseballGameState
        ->SetBatterGuessDeadlineServerTime(
            DeadlineServerTime
        );

    GetWorldTimerManager().SetTimer(
        BatterGuessTimerHandle,
        this,
        &ThisClass::HandleBatterGuessTimeout,
        BatterGuessTimeLimitSeconds,
        false
    );
}

제한 시간은 상수로 관리했다.

static constexpr float BatterGuessTimeLimitSeconds =
    15.0f;

현재 서버 시간이 203.4초라면 마감 시각은 다음과 같다.

현재 서버 시각 : 203.4
제한 시간      :  15.0
마감 서버 시각 : 218.4

GameState에는 남은 시간 15가 아니라 마감 시각 218.4가 저장되고 복제된다.


7. 기존 타이머를 먼저 제거하는 이유

새로운 타이머를 시작하기 전에 기존 타이머를 제거한다.

GetWorldTimerManager().ClearTimer(
    BatterGuessTimerHandle
);

이전 시도의 타이머가 남아 있으면 다음과 같은 문제가 발생할 수 있다.

첫 번째 시도의 타이머
두 번째 시도의 타이머

두 타이머가 동시에 살아 있으면 첫 번째 타이머의 콜백이 나중 시도를 시간 초과로 처리할 수 있다.

따라서 같은 용도의 타이머를 다시 시작하기 전에는 기존 TimerHandle을 정리해야 한다.


8. 서버 타이머와 마감 시각의 역할

서버에서 생성한 마감 시각은 UI 표시를 위한 기준이지만, 마감 시각만 저장한다고 실제 게임 로직이 실행되지는 않는다.

GetWorldTimerManager().SetTimer(
    BatterGuessTimerHandle,
    this,
    &ThisClass::HandleBatterGuessTimeout,
    BatterGuessTimeLimitSeconds,
    false
);

이 서버 타이머가 실제로 다음 함수를 호출한다.

HandleBatterGuessTimeout();

두 장치의 역할은 다음과 같다.

복제된 마감 시각
= 각 클라이언트가 화면의 남은 시간을 계산하기 위한 기준

서버 TimerManager
= 제한 시간이 끝났을 때 실제 게임 상태를 변경하는 장치

화면 표시와 권위 있는 판정을 분리하는 것이 중요하다.


9. 마감 시각은 서버만 변경한다

GameState의 Setter에서도 Authority를 검사한다.

void AMYBaseballGameState::
    SetBatterGuessDeadlineServerTime(
        const float NewDeadlineServerTime)
{
    if (!HasAuthority())
    {
        UE_LOG(
            LogMYBaseball,
            Warning,
            TEXT(
                "SetBatterGuessDeadlineServerTime "
                "rejected without authority."
            )
        );

        return;
    }

    const float SanitizedDeadline =
        FMath::Max(
            0.0f,
            NewDeadlineServerTime
        );

    if (FMath::IsNearlyEqual(
        BatterGuessDeadlineServerTime,
        SanitizedDeadline))
    {
        return;
    }

    BatterGuessDeadlineServerTime =
        SanitizedDeadline;

    NotifyBatterGuessDeadlineChanged();
    ForceNetUpdate();
}

Authority 검사

클라이언트가 자신의 GameState 복제본에서 Setter를 호출해도 서버의 원본은 변경되지 않는다.

함수 내부에서도 Authority를 검사하면 잘못된 호출을 명확하게 차단할 수 있다.

음수 방지

FMath::Max(0.0f, NewDeadlineServerTime);

음수 마감 시각을 방지한다. 이 구현에서는 0을 비활성화 상태로 사용한다.

중복 변경 방지

FMath::IsNearlyEqual(...);

부동소수점 값을 단순히 ==로 비교하지 않고 거의 같은 값이면 불필요한 변경과 UI 알림을 생략한다.

빠른 복제 요청

ForceNetUpdate();

GameState가 다음 일반 네트워크 업데이트 시점까지 기다리지 않고 변경된 상태를 가능한 한 빨리 복제하도록 요청한다.

다만 이것은 즉시 전송을 절대적으로 보장하는 명령이 아니라 다음 복제 기회를 앞당기는 요청에 가깝다.

서버에서는 RepNotify가 자동으로 호출된다고 기대하지 않고 Setter 안에서 알림 함수를 직접 호출했다. 덕분에 Listen Server의 로컬 UI도 변경을 감지할 수 있다.


10. 클라이언트가 서버 시간을 구하는 방법

각 클라이언트의 로컬 GetTimeSeconds() 값은 서로 다를 수 있다.

따라서 단순히 다음과 같이 계산하면 모든 클라이언트의 기준이 일치하지 않는다.

// 클라이언트마다 기준이 다를 수 있다.
GetWorld()->GetTimeSeconds();

언리얼의 AGameStateBase는 서버 시간 기준의 현재 시각을 구할 수 있는 함수를 제공한다.

GetServerWorldTimeSeconds();

개념적으로는 클라이언트의 로컬 월드 시간에 서버와의 시간 차이를 보정하는 방식이다.

서버에서 받은 시간 - 클라이언트 로컬 시간
= 서버 시간 보정값

클라이언트 로컬 시간 + 서버 시간 보정값
= 추정된 현재 서버 시각

따라서 Client A와 Client B의 로컬 시간이 달라도 GetServerWorldTimeSeconds()를 사용하면 같은 서버 시간축에 가까운 값을 얻을 수 있다.

별도의 서버 시간 동기화 RPC를 직접 만들지 않아도 되는 이유는 GameState가 이미 관련 기능을 제공하기 때문이다.


11. 남은 시간 계산하기

GameState에서는 마감 시각과 현재 서버 시각을 사용해 남은 시간을 계산한다.

float AMYBaseballGameState::
    GetBatterGuessTimeRemaining() const
{
    if (BatterGuessDeadlineServerTime <= 0.0f)
    {
        return 0.0f;
    }

    return FMath::Max(
        0.0f,
        BatterGuessDeadlineServerTime
        - GetServerWorldTimeSeconds()
    );
}

공식은 단순하다.

남은 시간
= 마감 서버 시각 - 현재 서버 시각

예를 들어 다음과 같다.

복제된 마감 시각 : 218.4
현재 서버 시각   : 210.1
남은 시간        :   8.3초

마감 이후에는 계산 결과가 음수가 될 수 있다.

218.4 - 219.2 = -0.8

UI에 음수가 표시되지 않도록 최소값을 0으로 제한한다.

FMath::Max(0.0f, RemainingTime);

12. UMG에서는 로컬 계산만 수행한다

각 클라이언트의 Widget은 NativeTick()에서 타이머 표시를 갱신한다.

void UMYBaseballGameWidget::NativeTick(
    const FGeometry& MyGeometry,
    const float InDeltaTime)
{
    Super::NativeTick(
        MyGeometry,
        InDeltaTime
    );

    RefreshTimerText();
}

이 계산은 클라이언트의 로컬 연산이다.

Client A
→ Client A의 UMG에서 남은 시간 계산

Client B
→ Client B의 UMG에서 남은 시간 계산

Dedicated Server
→ UMG가 없으므로 화면 계산 없음

매 프레임 실행되더라도 네트워크 패킷을 추가로 전송하지 않는다. 복제된 마감 시각과 동기화된 서버 시간을 읽어 화면만 갱신하기 때문이다.


13. 타이머 활성 상태 검사

카운트다운은 다음 조건을 모두 만족할 때만 표시한다.

const bool bTimerIsActive =
    IsValid(BoundGameState)
    && BoundGameState->GetInningPhase()
        == EMYInningPhase::BatterGuessing
    && BoundGameState
        ->GetBatterGuessDeadlineServerTime()
        > 0.0f;

필요한 조건은 다음과 같다.

  • GameState가 유효하다.
  • 현재 이닝 단계가 타자 추측 단계다.
  • 마감 서버 시각이 0보다 크다.

활성 상태라면 남은 시간을 정수로 변환한다.

const int32 DisplayedSeconds =
    bTimerIsActive
    ? FMath::CeilToInt(
        BoundGameState
            ->GetBatterGuessTimeRemaining()
      )
    : -1;

14. CeilToInt를 사용하는 이유

화면에 표시할 시간은 올림 처리한다.

FMath::CeilToInt(RemainingTime);

표시 결과는 다음과 같다.

14.92초 → 15
 5.01초 →  6
 4.98초 →  5
 0.01초 →  1
 0.00초 →  0

내림 처리하면 타이머가 시작되자마자 실제로는 거의 15초가 남았는데 화면에는 14초가 표시될 수 있다.

사용자에게 남아 있는 완전한 초 단위를 자연스럽게 보여 주려면 올림 처리가 적합하다.


15. TextBlock은 숫자가 바뀔 때만 갱신한다

남은 시간 계산은 매 프레임 수행하지만, TextBlock까지 매 프레임 다시 설정할 필요는 없다.

if (DisplayedSeconds
    == LastDisplayedTimerSeconds)
{
    return;
}

60FPS 환경에서 한 초 동안 계산 결과가 다음과 같이 변할 수 있다.

14.95
14.93
14.91
...
14.10

모두 올림하면 15다.

따라서 화면의 정수 값이 이전과 같다면 SetText()를 호출하지 않는다.

이 구조는 다음 비용을 줄인다.

  • 불필요한 TextBlock 갱신
  • 반복적인 문자열 생성
  • Slate 레이아웃 및 페인트 작업
  • 불필요한 UI 상태 변경

네트워크 복제는 시작 시 마감 시각을 한 번 전달하는 수준으로 유지하면서 UI도 필요한 순간에만 변경할 수 있다.


16. 실제 타이머 표시

타이머가 활성화되지 않았다면 다음과 같이 표시한다.

if (!bTimerIsActive)
{
    TimerText->SetText(
        FText::FromString(
            TEXT("TIME: --")
        )
    );

    TimerText->SetColorAndOpacity(
        FSlateColor(
            FLinearColor::White
        )
    );

    return;
}

활성 상태에서는 두 자리 숫자로 표시한다.

TimerText->SetText(
    FText::FromString(
        FString::Printf(
            TEXT("TIME: %02d"),
            DisplayedSeconds
        )
    )
);

화면에는 다음과 같이 나타난다.

TIME: 15
TIME: 09
TIME: 00

남은 시간이 5초 이하가 되면 색상을 변경해 긴박감을 줄 수 있다.

const FLinearColor TimerColor =
    DisplayedSeconds <= 5
    ? FLinearColor(
        1.0f,
        0.18f,
        0.12f,
        1.0f
      )
    : FLinearColor::White;

TimerText->SetColorAndOpacity(
    FSlateColor(TimerColor)
);

17. UMG에서 시간 초과를 판정하면 안 되는 이유

클라이언트가 표시하는 시간은 UI용 추정값이다.

네트워크 지연과 서버 시간 보정 때문에 클라이언트마다 아주 작은 차이가 발생할 수 있다.

다음과 같이 UMG에서 게임 상태를 변경하면 안 된다.

// 잘못된 구조
if (DisplayedSeconds <= 0)
{
    ++TryCount;
}

모든 클라이언트에서 같은 코드가 실행되면 중복 판정이 발생할 수 있다. 악의적인 클라이언트가 코드를 조작할 가능성도 있다.

따라서 책임을 다음과 같이 분리한다.

클라이언트 UMG
= TIME: 00 표시

서버 GameMode
= 실제 시간 초과 처리
= 시도 횟수 증가
= 아웃 판정
= 다음 시도 시작

18. 서버의 실제 시간 초과 처리

서버 타이머가 만료되면 GameMode에서 시간 초과를 처리한다.

void AMYBaseballGameMode::
    HandleBatterGuessTimeout()
{
    if (!HasAuthority())
    {
        return;
    }

    ClearBatterGuessTimer();

    ++CurrentBatterTryCount;

    BaseballGameState->SetAtBatStatus(
        CurrentBatterTryCount,
        0,
        TArray<int32>()
    );

    // 남은 기회가 있다면
    // 다음 15초 타이머 시작
    StartBatterGuessTimer();
}

서버는 다음 작업을 담당한다.

  1. 현재 타이머를 종료한다.
  2. 타자의 시도 횟수를 증가시킨다.
  3. GameState의 타석 상태를 변경한다.
  4. 아웃 조건을 검사한다.
  5. 남은 기회가 있다면 새로운 타이머를 시작한다.

클라이언트는 결과를 GameState Replication으로 전달받아 화면에 표시한다.


19. RPC와 시간 초과가 거의 동시에 발생하는 경우

실제 멀티플레이에서는 타자가 제한 시간 마지막 순간에 입력할 수 있다.

다음 상황이 발생할 수 있다.

서버의 마감 시각은 이미 지남
서버 TimerManager의 콜백은 아직 대기 중
타자의 입력 RPC가 먼저 서버에 도착

서버 타이머 콜백만 믿으면 마감 이후에 도착한 입력이 정상 입력으로 처리될 수 있다.

따라서 Server RPC를 처리하는 시점에도 마감 시각을 다시 확인해야 한다.

if (BaseballGameState
        ->GetBatterGuessDeadlineServerTime()
        > 0.0f
    && BaseballGameState
        ->GetBatterGuessTimeRemaining()
        <= 0.0f)
{
    HandleBatterGuessTimeout();

    OutErrorMessage =
        TEXT(
            "The guess arrived "
            "after the time limit."
        );

    return false;
}

서버의 현재 시각을 기준으로 이미 마감됐다면 서버가 직접 시간 초과를 처리하고 해당 RPC를 거절한다.


20. 이전 시도의 늦은 RPC 방지

네트워크 지연으로 이전 시도에서 보낸 RPC가 다음 시도에 도착할 수도 있다.

이를 방지하기 위해 클라이언트가 자신이 입력한 시도의 번호를 함께 보낼 수 있다.

if (ExpectedTryCount
    != CurrentBatterTryCount)
{
    OutErrorMessage =
        TEXT(
            "This guess belongs "
            "to an expired attempt."
        );

    return false;
}

예를 들어 다음과 같다.

클라이언트가 보낸 ExpectedTryCount : 0
서버의 CurrentBatterTryCount       : 1

두 값이 다르면 이전 시도에서 보낸 오래된 요청으로 판단하고 거절한다.

이 검사가 없다면 첫 번째 시도에서 늦게 도착한 입력이 두 번째 시도의 입력으로 잘못 처리될 수 있다.

마지막 순간의 입력은 두 단계로 검증한다.

1. 요청의 시도 번호가 현재 시도와 같은가?
2. 서버 기준 마감 시각이 지나지 않았는가?

21. 타이머 종료와 상태 초기화

타이머를 종료할 때는 TimerHandle만 제거하는 것이 아니라 GameState의 마감 시각도 0으로 변경한다.

void AMYBaseballGameMode::
    ClearBatterGuessTimer()
{
    GetWorldTimerManager().ClearTimer(
        BatterGuessTimerHandle
    );

    if (AMYBaseballGameState* BaseballGameState =
        GetGameState<AMYBaseballGameState>())
    {
        BaseballGameState
            ->SetBatterGuessDeadlineServerTime(
                0.0f
            );
    }
}

0이 클라이언트에 복제되면 각 UI는 타이머가 비활성화됐다고 판단한다.

TIME: --

정상적인 입력으로 시도가 끝난 경우에도 기존 타이머를 먼저 제거한다.

현재 시도 시작
→ 새로운 마감 시각 복제

현재 시도 종료
→ 서버 타이머 제거
→ 마감 시각 0 복제

다음 시도 시작
→ 새로운 마감 시각 복제

정답이 확정되지 않아 다음 기회가 남아 있다면 새로운 15초 타이머를 시작한다.


22. 남은 시간을 매초 복제하는 방식과 비교

서버가 남은 시간을 매초 복제한다면 다음 값을 계속 변경해야 한다.

15 → 14 → 13 → ... → 1 → 0

15초 타이머 하나에도 최대 16회의 상태 변경이 발생한다.

네트워크 상태에 따라 특정 값이 늦게 도착하거나 중간 숫자를 건너뛸 수도 있다.

마감 시각 방식은 다음 값 하나를 복제한다.

DeadlineServerTime = 218.4

각 클라이언트는 이 고정값과 현재 서버 시각을 사용해 카운트다운을 진행한다.

마감 시각 방식의 장점

  • 타이머 전용 복제 횟수가 감소한다.
  • 모든 클라이언트가 같은 서버 시간축을 사용한다.
  • 중간에 접속한 클라이언트도 현재 남은 시간을 계산할 수 있다.
  • 클라이언트가 매끄럽게 카운트다운을 표시할 수 있다.
  • 실제 판정은 계속 서버 권위로 유지된다.
  • 타이머마다 별도의 시간 동기화 RPC가 필요하지 않다.

언리얼은 GameState를 통해 서버 시간 동기화를 지원한다. 이번 구현은 그 기능 위에 게임 타이머를 구성한 것이다.


23. 실행 위치와 책임 정리

코드실행 위치책임

StartBatterGuessTimer() 서버 마감 시각과 실제 서버 타이머 생성
SetBatterGuessDeadlineServerTime() 서버 권위 있는 마감 시각 변경
프로퍼티 Replication 서버 → 클라이언트 마감 시각 전달
OnRep_BatterGuessDeadlineServerTime() 각 클라이언트 복제 도착 알림
GetBatterGuessTimeRemaining() 호출한 컴퓨터 서버 시간축으로 남은 시간 계산
NativeTick() 각 로컬 클라이언트 카운트다운 UI 갱신
HandleBatterGuessTimeout() 서버 기회 소진과 아웃 판정
ClearBatterGuessTimer() 서버 타이머 제거 및 마감 시각 초기화

24. 오늘 배운 내용

오늘은 멀티플레이 타이머에서 남은 시간을 매초 복제하는 대신, 서버가 고정된 마감 시각 하나를 복제하는 구조를 구현했다.

GameMode는 마감 시각을 생성하고 실제 서버 타이머를 통해 시간 초과를 판정한다. GameState는 마감 시각을 모든 클라이언트에 복제한다.

각 클라이언트의 UMG는 다음 공식으로 화면의 남은 시간을 계산한다.

남은 시간
= 복제된 마감 서버 시각
- 동기화된 현재 서버 시각

UI는 카운트다운을 보여 줄 뿐이며, 실제 시도 횟수 증가와 아웃 판정은 서버만 수행한다.

또한 제한 시간 끝에 도착한 입력이 잘못 처리되지 않도록 RPC 처리 시점에 마감 시각과 시도 번호를 다시 검증했다.

이번 구조의 핵심을 한 문장으로 정리하면 다음과 같다.

서버는 마감 시각과 실제 판정을 소유하고,
GameState는 마감 시각을 복제하며,
각 클라이언트는 서버 시간축을 기준으로 화면만 갱신한다.