시간 변경 필요성
개발하다 보면 테스트 목적으로 시간을 변경해야 하는 필요가 생길 때가 있다.
Windows 경우 보통 제어판에서 시간을 바꿀 수 있고, 실행되어있는 프로그램에도 바로 바뀐 시간이 적용된다.
하지만 회사 정책으로 시간 변경이 막혀있거나, 실행 중인 서버에 원격 접속할 수 없는 상황 등
다양한 이유로 프로그램 내부에서 시간을 바꿔야 하는 경우가 있다.
이를 위해 .NET 8부터는 TimeProvider라는 기능을 도입해 시간을 추상화 할 수 있도록 했다.
What is the TimeProvider class - .NET | Microsoft Learn
하지만 이미 개발이 많이 진행되어 코드 베이스가 방대해졌다면 DateTime을 사용하는 모든 코드를 찾아 바꿔야 하고,
정적 분석 등으로 일반 DateTime을 사용하지 못하게 막지 않는 이상,
추상화 된 DateTime과 일반적인 DateTime이 코드 안에 혼재하는 상황이 벌어질 수 있다.
또 이렇게 관리하더라도 서드 파티 라이브러리에서 사용하는 DateTime까지는 추상화 할 수 없는 문제가 있다.
이러한 이유로 .NET의 시간 추상화를 사용하지 않고 DateTime을 후킹해 시간을 변경하는 기능이 필요하다.
Harmony를 통한 후킹
일반적으로 .NET에서 런타임에 메서드의 동작을 바꾸고 싶을 때는 Harmony라는 라이브러리를 사용하면 된다.
GitHub - pardeike/Harmony: A library for patching, replacing and decorating .NET and Mono methods during runtime
예를 들어 Postfix를 사용하면 메서드의 결과 값을 수정할 수 있다.
아래 코드는 DateTime.Now의 결과 값에 100년을 더하는 예시이다.
using System.Reflection;
using HarmonyLib;
internal class Program
{
static void Main(string[] args)
{
var now = DateTime.Now;
var harmony = new Harmony("TimeHook");
harmony.PatchAll(Assembly.GetExecutingAssembly());
var newNow = DateTime.Now;
Console.WriteLine($"{now}"); // 2025-06-08 오후 5:52:46
Console.WriteLine($"{newNow}"); // 2125-06-08 오후 5:52:46
}
const int YEAR_TEST = 100;
[HarmonyPatch(typeof(DateTime), "get_Now")]
class Patch
{
static DateTime Postfix(DateTime result)
{
return result.AddYears(YEAR_TEST);
}
}
}
하지만 이 방식은 모든 곳에 적용할 수 있는 것은 아니다.
.NET 버전에 따라 DateTime.Now와 DateTime.UtcNow 적용 가능 여부가 달랐다.
마지막으로 테스트 해봤을 때,
.NET 8 환경에서는 DateTime.UtcNow 후킹할 경우 왠지 모르지만 Exception이 났고,
.NET 7 환경에서는 최적화에 의해 메서드가 인라인 되어버서 DateTime.Now 동작이 바뀌지 않았다.
Can't override property getter. · Issue #70 · pardeike/Harmony
Failed to patch DateTime.Now with .net 7.0 · Issue #546 · pardeike/Harmony
s_pfnGetSystemTimeAsFileTime을 사용한 후킹
설명하기 앞서 이 방법은 Windows에서만 가능한 방법으로, Linux와 같이 다른 플랫폼에서는 불가능하다.
.NET은 Windows에서 시간을 구하기 위해 kernel32.dll을 사용한다.
좀 더 정밀도가 높은 GetSystemTimePreciseAsFileTime을 사용하는 게 좋지만,
잘못 설정된 시스템에서는 오히려 오차가 더 커지기 때문에 GetSystemTimeAsFileTime으로 fallback 해야한다.
DateTime.Now is drifting away with computer's up time · Issue #9014 · dotnet/runtime
따라서 .NET에는 런타임에 Precise 값에 문제가 없는지 판단해서 fallback 여부를 결정하는 코드가 들어가 있다.
매번 이 과정을 하면 성능에 문제가 생기기 때문에 아래 코드와 같이 둘 중 어느 것을 사용할지 정해 함수 포인터를 static 변수에 저장해두고 사용하게 된다.
// .NET 7 코드
private static readonly unsafe delegate* unmanaged[SuppressGCTransition]<ulong*, void> s_pfnGetSystemTimeAsFileTime = GetGetSystemTimeAsFileTimeFnPtr();
private static unsafe delegate* unmanaged[SuppressGCTransition]<ulong*, void> GetGetSystemTimeAsFileTimeFnPtr()
{
IntPtr kernel32Lib = Interop.Kernel32.LoadLibraryEx(Interop.Libraries.Kernel32, IntPtr.Zero, Interop.Kernel32.LOAD_LIBRARY_SEARCH_SYSTEM32);
Debug.Assert(kernel32Lib != IntPtr.Zero);
IntPtr pfnGetSystemTime = NativeLibrary.GetExport(kernel32Lib, "GetSystemTimeAsFileTime");
if (NativeLibrary.TryGetExport(kernel32Lib, "GetSystemTimePreciseAsFileTime", out IntPtr pfnGetSystemTimePrecise))
{
for (int i = 0; i < 10; i++)
{
long systemTimeResult, preciseSystemTimeResult;
((delegate* unmanaged[SuppressGCTransition]<long*, void>)pfnGetSystemTime)(&systemTimeResult);
((delegate* unmanaged[SuppressGCTransition]<long*, void>)pfnGetSystemTimePrecise)(&preciseSystemTimeResult);
if (Math.Abs(preciseSystemTimeResult - systemTimeResult) <= 100 * TicksPerMillisecond)
{
pfnGetSystemTime = pfnGetSystemTimePrecise; // use the precise version
break;
}
}
}
return (delegate* unmanaged[SuppressGCTransition]<ulong*, void>)pfnGetSystemTime;
}
따라서 이 static 변수만 우리의 시간 함수로 변경해버리면 복잡한 과정 없이도 .NET 시간 전체를 변경할 수 있게 된다.
이 static 변수는 private일 뿐 아니라 readonly이기 때문에 직접 수정하는 것은 불가능하다.
하지만 DynamicMethod로 런타임에 메서드를 만들 경우 이러한 제한과 상관없이 객체의 필드를 수정하는 게 가능하다.
따라서 리플렉션으로 s_pfnGetSystemTimeAsFileTime을 찾고, 파라미터로 값을 받아 이 필드에 값을 설정하는 메서드를 DynamicMethod로 만들고,
우리가 만든 시간 함수의 포인터를 인자로 넣어 실행하게 되면 이 필드를 바꿀 수 있다.
아래는 이를 구현한 코드로, .NET 버전에 따라 s_pfnGetSystemTimeAsFileTime의 위치가 변경된 것도 대응 되어있다.
만약 멀티 스레드 환경이라면 Init은 되도록 스레드가 분리되기 전 Main함수 시작 시 해주는 것을 권장한다.
컴파일러 최적화 문제인지 정확히는 모르겠지만, Release 빌드에서 스레드가 분리되고 나서 Init하면 적용이 잘 안되는 케이스가 있었다.
HookedSystemTime 구현
using System.Diagnostics;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.InteropServices;
namespace TimeTest;
public static class NativeMethods
{
[StructLayout(LayoutKind.Sequential)]
public struct FileTime
{
public uint dwLowDateTime;
public uint dwHighDateTime;
}
[DllImport("kernel32.dll", SetLastError = true)]
public static extern void GetSystemTimeAsFileTime(out FileTime lpSystemTimeAsFileTime);
}
public unsafe class HookedSystemTime
{
private static int _days;
private static int _hours;
private static int _minutes;
private static int _seconds;
public static bool Initialized { get; private set; }
public static Action? OnChanged;
public static long Ticks
{
get
{
long ticks = 0;
ticks += TimeSpan.TicksPerDay * Volatile.Read(ref _days);
ticks += TimeSpan.TicksPerHour * Volatile.Read(ref _hours);
ticks += TimeSpan.TicksPerMinute * Volatile.Read(ref _minutes);
ticks += TimeSpan.TicksPerSecond * Volatile.Read(ref _seconds);
return ticks;
}
}
public static bool Modified => Initialized && Ticks > 0;
public static void Init()
{
if (Initialized)
return;
ulong testTime;
GetCustomSystemTime(&testTime);
if (testTime <= 0)
return;
IntPtr customFuncPtr = GetCustomSystemTimePtr();
if (customFuncPtr == IntPtr.Zero)
return;
FieldInfo? fieldInfo = null;
foreach (FieldInfo fi in typeof(DateTime).GetRuntimeFields())
{
if (fi.Name == "s_pfnGetSystemTimeAsFileTime" && fi.FieldType == typeof(IntPtr))
{
fieldInfo = fi;
break;
}
}
if (fieldInfo == null)
{
Type? leapClass = typeof(DateTime).GetNestedType("LeapSecondCache", BindingFlags.NonPublic);
if (leapClass != null)
{
foreach (FieldInfo fi in leapClass.GetRuntimeFields())
{
if (fi.Name == "s_pfnGetSystemTimeAsFileTime" && fi.FieldType == typeof(IntPtr))
{
fieldInfo = fi;
break;
}
}
}
}
if (fieldInfo == null)
{
Debug.Fail("Failed to find s_pfnGetSystemTimeAsFileTime field for hooking.");
return;
}
var method = new DynamicMethod(
name: "HookDateTime",
returnType: null,
parameterTypes: new[] { typeof(IntPtr) },
restrictedSkipVisibility: true
);
var gen = method.GetILGenerator();
gen.Emit(OpCodes.Ldarg_0);
gen.Emit(OpCodes.Stsfld, fieldInfo);
gen.Emit(OpCodes.Ret);
var fieldSetter = (Action<IntPtr>)method.CreateDelegate(typeof(Action<IntPtr>));
fieldSetter(customFuncPtr);
Initialized = true;
}
private static IntPtr GetCustomSystemTimePtr()
{
var methodInfo = typeof(HookedSystemTime).GetMethod(nameof(GetCustomSystemTime), BindingFlags.Static | BindingFlags.Public);
return methodInfo == null ? IntPtr.Zero : methodInfo.MethodHandle.GetFunctionPointer();
}
public static void GetCustomSystemTime(ulong* ptr)
{
NativeMethods.GetSystemTimeAsFileTime(out NativeMethods.FileTime fileTime);
ulong combined = ((ulong)fileTime.dwHighDateTime << 32) | fileTime.dwLowDateTime;
combined += (ulong)Ticks;
*ptr = combined;
}
public static void AddSeconds(int seconds)
{
AddOffset(0, 0, 0, seconds);
}
public static void AddMinutes(int minutes)
{
AddOffset(0, 0, minutes, 0);
}
public static void AddHours(int hours)
{
AddOffset(0, hours, 0, 0);
}
public static void AddDays(int days)
{
AddOffset(days, 0, 0, 0);
}
public static void AddOffset(int days, int hours, int minutes, int seconds)
{
if (!Initialized)
return;
if (days == 0 && hours == 0 && minutes == 0 && seconds == 0)
return;
if (days != 0) Interlocked.Add(ref _days, days);
if (hours != 0) Interlocked.Add(ref _hours, hours);
if (minutes != 0) Interlocked.Add(ref _minutes, minutes);
if (seconds != 0) Interlocked.Add(ref _seconds, seconds);
OnChanged?.Invoke();
}
public static DateTime GetOriginalNow()
{
return Modified ? DateTime.Now.AddTicks(-Ticks) : DateTime.Now;
}
}
테스트 코드
테스트는 아래와 같이 해볼 수 있다
class Program
{
static void Main(string[] args)
{
Console.WriteLine($"[Main] Before: {DateTime.Now}");
for (int i = 0; i < 4; i++)
{
int index = i;
new Thread(() =>
{
Console.WriteLine($"[Thread {index}] Before: {DateTime.Now}");
Thread.Sleep(3000);
Console.WriteLine($"[Thread {index}] After: {DateTime.Now}");
}).Start();
}
HookedSystemTime.Init();
HookedSystemTime.AddDays(100);
Console.WriteLine($"[Main] After: {DateTime.Now}");
}
}