왜 서버 시간이 필요한가

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

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

일반적인 해결책은 서버에서 현재 시각을 받아오는 것입니다. 자체 게임 서버가 있다면 API 응답에 서버 타임스탬프를 포함하면 되지만, 서버리스 구조에서는 이 방법이 어렵습니다. 이때 NTP(Network Time Protocol)가 대안이 됩니다.

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하며, 이후 재동기화는 별도로 하지 않습니다.

주의점

Google Public NTP의 실제 사용 정책에 따르면, 게임 클라이언트에서 NTP 시간 보정 용도로 time.google.com을 쓰는 건 괜찮다고 합니다.

하지만, Google Public NTP는 SLA가 없다는 게 핵심 리스크입니다. 가용성이나 정확도에 대한 어떤 약속도 제공하지 않기 때문에, 게임 로직이 시간 보정에 강하게 의존한다면 (예: 서버 인증, 캠패인 타이밍) time.google.com이 일시적으로 안 될 때 대비책이 있어야 합니다. 보통 여러 NTP 서버를 풀로 두고 그중 하나가 죽어도 돌아가게 합니다.

다만, 백업 NTP 서버에도 주의사항이 있다고 합니다. 바로 Google이 윤초를 24시간에 걸쳐 부드럽게 분산시키는 leap smear 방식을 쓴다는 것입니다. 그래서 스미어링되지 않은 다른 NTP 서비스와 섞어 쓰는 건 권장되지 않습니다. 대부분의 케이스에서는 Google의 4개 서버(time1~4.google.com)끼리만 묶어도 일반적인 상업용 서비스에서는 충분히 안전하다고 합니다.