왜 서버 시간이 필요한가

모바일 게임에서 시간은 곧 재화다. 스태미나 회복, 일일 보상, 시즌 리셋 - 이 모든 시스템이 “지금이 몇 시인가”에 의존한다.

문제는 DateTime.Now가 반환하는 시간이 디바이스 설정에서 온다는 것이다. 사용자가 설정 앱에서 시간을 1시간 앞으로 돌리면 스태미나가 즉시 충전되고, 하루를 건너뛰면 일일 보상을 두 번 받을 수 있다. 실제로 이런 치트가 가능한 게임은 생각보다 많다.

일반적인 해결책은 서버에서 현재 시각을 받아오는 것이다. 자체 게임 서버가 있다면 API 응답에 서버 타임스탬프를 포함하면 되지만, 서버리스 구조(Firebase 등)에서는 이 방법이 어렵다. 이때 NTP(Network Time Protocol)가 대안이 된다 - 전 세계에 분포한 공개 시간 서버에서 정확한 UTC 시각을 받아와, 로컬 시계와의 차이(offset)만 기억해두는 방식이다.

NTP 프로토콜 핵심 원리

NTP는 RFC 5905에서 정의된 프로토콜로, 네트워크 왕복 지연(round-trip delay)을 상쇄하여 밀리초 단위의 시간 동기화를 달성한다. 핵심은 4개의 타임스탬프다.

기호시점설명
T1Client Send클라이언트가 요청을 보낸 시각
T2Server Receive서버가 요청을 받은 시각
T3Server Send서버가 응답을 보낸 시각
T4Client Receive클라이언트가 응답을 받은 시각

이 네 값으로 두 가지를 계산한다:

offset은 로컬 시계가 서버 시계보다 얼마나 빠르거나 느린지를 나타낸다. 이 값을 DateTime.Now에 더하면 서버 기준 시각을 얻을 수 있다. 네트워크 지연이 대칭적이라고 가정하기 때문에 완벽하진 않지만, 모바일 게임에서 필요한 수준(수십 ms 오차)에는 충분하다.

Unity에서의 NTP 클라이언트 구현

전체 구조

NTP 클라이언트는 Singleton 패턴으로 구현하여 앱 전역에서 하나의 offset 값을 공유한다.

NtpSync.cs
public sealed class NtpSync
{
    private static readonly Lazy<NtpSync> _lazy = new(() => new NtpSync());
    public static NtpSync I => _lazy.Value;
 
    public int OffsetMs { get; private set; }
    public DateTime? LastSyncUtc { get; private set; }
}
  • Lazy<T>로 thread-safe 초기화를 보장한다
  • OffsetMs: 계산된 offset을 밀리초 단위로 저장
  • LastSyncUtc: 마지막 동기화 시점 기록

UDP 패킷 구성과 송수신

NTP는 UDP 포트 123을 사용한다. 요청 패킷은 48바이트로, 첫 바이트에 프로토콜 버전과 모드를 설정한다.

NtpSync.cs
public async UniTask<bool> SyncAsync(
    string server = "time.google.com", int timeoutMs = 2000,
    CancellationToken ct = default, bool throwOnFailure = false)
{
    var request = new byte[48];
    request[0] = 0x1B; // LI=0, VN=3, Mode=3 (client)
 
    using var udp = new UdpClient(AddressFamily.InterNetwork);
    udp.Client.ReceiveTimeout = timeoutMs;
    udp.Connect(server, 123);
 
    // T1: 전송 시각을 NTP epoch 기준으로 bytes 40-47에 기록
    var t1 = DateTime.UtcNow;
    WriteNtpTimestamp(request, 40, t1);
 
    // 송수신 (각각 타임아웃 적용)
    var sendTask = udp.SendAsync(request, request.Length);
    if (await Task.WhenAny(sendTask, Task.Delay(timeoutMs, ct)) != sendTask)
        return false;
 
    var receiveTask = udp.ReceiveAsync();
    if (await Task.WhenAny(receiveTask, Task.Delay(timeoutMs, ct)) != receiveTask)
        return false;
 
    var t4 = DateTime.UtcNow;
    var resp = (await receiveTask).Buffer;
    // ...
}

0x1B는 이진수로 00 011 011이며, 상위 2비트(Leap Indicator = 0), 다음 3비트(Version = 3), 하위 3비트(Mode = 3, client)를 인코딩한다. 타임아웃은 Task.WhenAny로 처리하여 네트워크 불안정 상황에서 무한 대기를 방지한다.

NTP 타임스탬프 변환

NTP는 1900년 1월 1일을 epoch으로 사용하며, 초 단위 정수(32비트) + 소수부(32비트)의 고정소수점 형식으로 시각을 표현한다. .NET의 DateTime과 양방향 변환이 필요하다.

NtpSync.cs
private static readonly DateTime NtpEpoch = new(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc);
 
private static void WriteNtpTimestamp(byte[] buffer, int startIndex, DateTime utc)
{
    var span = utc - NtpEpoch;
    var seconds = (ulong)span.TotalSeconds;
    var frac = span.TotalSeconds - Math.Floor(span.TotalSeconds);
    var fraction = (uint)Math.Round(frac * 4294967296.0); // 2^32
 
    // big-endian write
    buffer[startIndex + 0] = (byte)((seconds >> 24) & 0xFF);
    buffer[startIndex + 1] = (byte)((seconds >> 16) & 0xFF);
    buffer[startIndex + 2] = (byte)((seconds >> 8) & 0xFF);
    buffer[startIndex + 3] = (byte)(seconds & 0xFF);
 
    buffer[startIndex + 4] = (byte)((fraction >> 24) & 0xFF);
    buffer[startIndex + 5] = (byte)((fraction >> 16) & 0xFF);
    buffer[startIndex + 6] = (byte)((fraction >> 8) & 0xFF);
    buffer[startIndex + 7] = (byte)(fraction & 0xFF);
}

소수부를 로 스케일링하는 이유는, 32비트 정수 하나로 1초 미만의 시간을 약 0.23 나노초 정밀도로 표현하기 위함이다. 네트워크 바이트 순서(big-endian)로 직접 기록한다.

응답 파싱과 Offset 계산

서버 응답에서 세 개의 타임스탬프를 추출하고, sanity check를 거친 뒤 offset을 계산한다.

NtpSync.cs
// 응답에서 T1', T2, T3 추출
var t1Echo = ReadNtpTimestamp(resp, 24); // Originate (서버가 echo한 우리의 T1)
var t2     = ReadNtpTimestamp(resp, 32); // Receive
var t3     = ReadNtpTimestamp(resp, 40); // Transmit
 
// 방어: 서버가 우리 T1을 정확히 반사했는지 확인
var echoDriftMs = Math.Abs((t1Echo - t1).TotalMilliseconds);
if (echoDriftMs > 1000) return false;
 
var offset = (t2 - t1 + (t3 - t4)) / 2.0;
 
// 비정상 값 방어 (24시간 초과 offset은 버림)
if (Math.Abs(offset.TotalMilliseconds) > TimeSpan.FromHours(24).TotalMilliseconds)
    return false;
 
OffsetMs = (int)Math.Round(offset.TotalMilliseconds);

두 가지 방어 로직이 있다:

  1. T1 Echo 검증: NTP 서버는 클라이언트가 보낸 T1을 응답의 Originate 필드에 그대로 돌려준다. 이 값이 원본과 1초 이상 차이나면 응답이 다른 요청의 것이거나 변조된 것으로 판단하고 폐기한다.
  2. 24시간 클램프: offset이 24시간을 초과하면 비정상 응답으로 간주한다. 네트워크 오류나 잘못된 서버 응답을 걸러내는 안전장치다.

ApplyNtpOffset - Extension Method 하나로 전역 적용

NTP 동기화의 결과물은 단 하나, OffsetMs 정수값이다. 이 값을 매번 수동으로 더하는 대신, Extension Method로 깔끔하게 추상화한다.

NtpSync.cs
public static class DateTimeExtensions
{
    public static DateTime ApplyNtpOffset(this DateTime dt)
    {
        var ms = NtpSync.I.OffsetMs;
#if UNITY_EDITOR
        if (_debugTimeTravelEnabled) ms += _debugTimeTravelMs;
#endif
        return dt.AddMilliseconds(ms);
    }
}

사용하는 쪽에서는 기존 코드에 .ApplyNtpOffset()만 체이닝하면 된다:

// Before: 조작 가능한 로컬 시간
var now = DateTime.Now;
 
// After: NTP 보정된 시간
var now = DateTime.Now.ApplyNtpOffset();

초기화 시점과 앱 생명주기 연동

스태미나 회복, 시즌 리셋, 출석 체크, 이벤트 기간 판정 등 대부분의 게임 시스템이 정확한 시간에 의존한다. 따라서 NTP 동기화는 앱 초기화 과정에서 가장 이른 시점에 실행되어야 한다.

C# 쪽에서는 throwOnFailure: true로 호출하여, 동기화에 실패하면 앱 진입 자체를 차단한다. 이후 재동기화 시에는 기본값인 false를 사용해 실패해도 기존 offset으로 계속 동작하게 한다.

모바일 앱은 백그라운드에 빠졌다가 수 분, 수 시간 뒤에 다시 돌아올 수 있다. 이때 OnApplicationPause(false) 이벤트에 핸들러를 등록해두면, 복귀 시점에 NTP 보정 시간으로 경과를 재계산하여 밀린 보상을 정산할 수 있다.

Flutter: ntp 패키지

같은 시간 동기화를 Flutter 프로젝트에서는 ntp 패키지를 사용해 훨씬 간결하게 구현할 수 있다. NTP 프로토콜의 UDP 통신, 타임스탬프 파싱, offset 계산을 패키지가 모두 처리해주므로, 앱 코드에서는 offset 관리와 적용만 신경 쓰면 된다.

Extension Method

time_utils.dart
import 'package:ntp/ntp.dart';
 
extension DateTimeNtpExtension on DateTime {
  static int _ntpOffset = 0;
 
  static Future<void> refreshNtpOffset() async {
    try {
      final ntpOffset = await NTP.getNtpOffset(
        timeout: const Duration(seconds: 1),
      );
      if (ntpOffset.abs() > 60000) {
        _ntpOffset = ntpOffset;
      }
    } catch (e) {
      developer.log(name: 'DateTimeNtpExtension', 'NTP offset Refresh Failed: $e');
    }
  }
 
  DateTime get ntpTime => add(Duration(milliseconds: _ntpOffset));
}

C#의 ApplyNtpOffset()과 동일한 패턴이지만 Dart extension property로 더 짧게 표현된다. NTP.getNtpOffset()이 내부적으로 RFC 5905 프로토콜을 수행하고, 결과로 밀리초 단위 offset 정수를 반환한다.

한 가지 차이점은 60초 임계값이다. offset의 절대값이 60초 미만이면 적용하지 않는다. 모바일 디바이스의 시계가 자동 동기화(carrier/OS NTP) 상태라면 수십 ms 수준의 미세한 차이만 있을 것이고, 이 정도는 게임 로직에 영향을 주지 않는다. 60초 이상 차이가 난다면 사용자가 의도적으로 시계를 조작했거나, 자동 동기화가 꺼진 상태일 가능성이 높다.

마찬가지로 앱 초기화 단계에서 refreshNtpOffset()을 호출한다. C#과 달리 실패해도 예외를 던지지 않고 로컬 시간으로 fallback하며, 이후 재동기화는 별도로 하지 않는다 - 앱을 완전히 껐다 켜야 offset이 갱신된다.