왜 서버 시간이 필요한가
모바일 게임에서 시간은 곧 재화다. 스태미나 회복, 일일 보상, 시즌 리셋 - 이 모든 시스템이 “지금이 몇 시인가”에 의존한다.
문제는 DateTime.Now가 반환하는 시간이 디바이스 설정에서 온다는 것이다. 사용자가 설정 앱에서 시간을 1시간 앞으로 돌리면 스태미나가 즉시 충전되고, 하루를 건너뛰면 일일 보상을 두 번 받을 수 있다. 실제로 이런 치트가 가능한 게임은 생각보다 많다.
일반적인 해결책은 서버에서 현재 시각을 받아오는 것이다. 자체 게임 서버가 있다면 API 응답에 서버 타임스탬프를 포함하면 되지만, 서버리스 구조(Firebase 등)에서는 이 방법이 어렵다. 이때 NTP(Network Time Protocol)가 대안이 된다 - 전 세계에 분포한 공개 시간 서버에서 정확한 UTC 시각을 받아와, 로컬 시계와의 차이(offset)만 기억해두는 방식이다.
NTP 프로토콜 핵심 원리
NTP는 RFC 5905에서 정의된 프로토콜로, 네트워크 왕복 지연(round-trip delay)을 상쇄하여 밀리초 단위의 시간 동기화를 달성한다. 핵심은 4개의 타임스탬프다.
| 기호 | 시점 | 설명 |
|---|---|---|
| T1 | Client Send | 클라이언트가 요청을 보낸 시각 |
| T2 | Server Receive | 서버가 요청을 받은 시각 |
| T3 | Server Send | 서버가 응답을 보낸 시각 |
| T4 | Client Receive | 클라이언트가 응답을 받은 시각 |

이 네 값으로 두 가지를 계산한다:
offset은 로컬 시계가 서버 시계보다 얼마나 빠르거나 느린지를 나타낸다. 이 값을 DateTime.Now에 더하면 서버 기준 시각을 얻을 수 있다. 네트워크 지연이 대칭적이라고 가정하기 때문에 완벽하진 않지만, 모바일 게임에서 필요한 수준(수십 ms 오차)에는 충분하다.
Unity에서의 NTP 클라이언트 구현
전체 구조
NTP 클라이언트는 Singleton 패턴으로 구현하여 앱 전역에서 하나의 offset 값을 공유한다.
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바이트로, 첫 바이트에 프로토콜 버전과 모드를 설정한다.
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과 양방향 변환이 필요하다.
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을 계산한다.
// 응답에서 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);두 가지 방어 로직이 있다:
- T1 Echo 검증: NTP 서버는 클라이언트가 보낸 T1을 응답의 Originate 필드에 그대로 돌려준다. 이 값이 원본과 1초 이상 차이나면 응답이 다른 요청의 것이거나 변조된 것으로 판단하고 폐기한다.
- 24시간 클램프: offset이 24시간을 초과하면 비정상 응답으로 간주한다. 네트워크 오류나 잘못된 서버 응답을 걸러내는 안전장치다.
ApplyNtpOffset - Extension Method 하나로 전역 적용
NTP 동기화의 결과물은 단 하나, OffsetMs 정수값이다. 이 값을 매번 수동으로 더하는 대신, Extension Method로 깔끔하게 추상화한다.
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
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이 갱신된다.