C#에서 AOT로 NLog사용하기
이전 글 C# AOT 라이브러리 활용에 이어 이번 글에서는 로그 시스템으로 많이 사용하는 NLog를 AOT (Ahead-of-Time)로 빌드하여 라이브러리로 사용하는 법을 알아본다.
실행파일과 같은 폴더에 AppLogs 폴더를 만들고 여기에 연월/날짜/로그타입별파일로 기록하고 디버그 모드로 빌드 후 실행 했을때 DebugViewPP로 실시간 로그를 볼 수 있도록 하였다.
기본 패키지의 리플랙션(Reflection)1을 피해서 작성해야 하므로 기본 사용법과 차이가 있을 수 있다. 참고로 Avalonia+SukiUi+MVVM로 구성된 프로젝트를 AOT로 빌드할 수 있도록 주요 패키지를 하나씩 옮겨보려고 한다.
LogHelper(AOT)
- 프로젝트(LogHelper) 구성
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<IsAotCompatible>true</IsAotCompatible>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<OptimizationPreference>Speed</OptimizationPreference>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NLog" Version="6.1.1"/>
<PackageReference Include="NLog.OutputDebugString" Version="6.1.1"/>
</ItemGroup>
<Target Name="CopyLogHelperDebug" AfterTargets="Publish">
<Copy Condition="'$(Configuration)' == 'Debug'"
SourceFiles="$(PublishDir)LogHelper.dll"
DestinationFiles="$(PublishDir)LogHelper.Debug.dll"
SkipUnchangedFiles="true"/>
<Copy Condition="'$(Configuration)' == 'Release'"
SourceFiles="$(PublishDir)LogHelper.dll"
DestinationFiles="$(PublishDir)LogHelper.Release.dll"
SkipUnchangedFiles="true"/>
</Target>
</Project>
- 소스 AOT(LogHelper.cs)
using NLog;
using NLog.Config;
using NLog.Targets;
using NLog.Targets.Wrappers;
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace LogHelper;
public static class LogHelper
{
private static readonly Logger? _logger;
private static readonly LogLevel[] _nLogLevels =
[
LogLevel.Trace,
LogLevel.Info,
LogLevel.Warn,
LogLevel.Error,
LogLevel.Fatal,
LogLevel.Debug
];
static LogHelper()
{
ISetupBuilder logFactory = LogManager.Setup().LoadConfiguration(builder =>
{
LoggingConfiguration config = builder.Configuration;
const string layout =
@"[${date:format=HH\:mm\:ss} ${level:uppercase=true:padding=5} : PID ${processid:Padding=5}] ${message} (${event-properties:File}::${event-properties:Member}[${event-properties:Line}][${threadid}])";
builder.ForLogger().FilterMinLevel(LogLevel.Trace);
#if DEBUG
OutputDebugStringTarget debugTarget = new("debug") { Layout = layout };
LoggingRule debugRule = new("*", LogLevel.Trace, LogLevel.Fatal, debugTarget) { Final = false };
config.LoggingRules.Add(debugRule);
#endif
FileTarget fileTarget = new("FileTarget")
{
FileName = "${basedir}/AppLogs/${date:format=yyyyMM}/${date:format=dd}/${level}_${date:format=yyyyMMdd}.txt",
Layout = layout,
KeepFileOpen = true,
MaxArchiveDays = 90
// 선택사항
ArchiveFileName = "${basedir}/AppLogs/${date:format=yyyyMM}/${date:format=dd}/${level}_${date:format=yyyyMMdd}.{#}.txt",
ArchiveAboveSize = 10485760, // 10MB마다 새 파일 생성
ArchiveEvery = FileArchivePeriod.Day
};
AsyncTargetWrapper asyncWrapper = new(fileTarget)
{
QueueLimit = 10000,
OverflowAction = AsyncTargetWrapperOverflowAction.Discard,
TimeToSleepBetweenBatches = 0
};
LoggingRule rule = new("*", LogLevel.Trace, LogLevel.Fatal, asyncWrapper) { Final = true };
config.LoggingRules.Add(rule);
});
_logger = logFactory.GetLogger("NativeLogger");
}
[UnmanagedCallersOnly(EntryPoint = "LogWrite", CallConvs = [typeof(CallConvCdecl)])]
public static void NativeLog_Write(int level, IntPtr msgPtr, IntPtr fileNamePtr, IntPtr memberPtr, int line)
{
try
{
LogLevel nLevel = (uint)level < (uint)_nLogLevels.Length ? _nLogLevels[level] : LogLevel.Trace;
if (_logger != null && !_logger.IsEnabled(nLevel))
{
return;
}
if (msgPtr == IntPtr.Zero)
{
return;
}
string? msg = Marshal.PtrToStringUTF8(msgPtr);
if (string.IsNullOrWhiteSpace(msg))
{
return;
}
string member = memberPtr != IntPtr.Zero ? Marshal.PtrToStringUTF8(memberPtr) ?? "Unknown" : "Unknown";
string file = fileNamePtr != IntPtr.Zero ? GetFileNameWithoutExtension(fileNamePtr) : "Unknown";
LogEventInfo logEvent = LogEventInfo.Create(nLevel, _logger?.Name, msg);
logEvent.Properties["Member"] = member;
logEvent.Properties["Line"] = line;
logEvent.Properties["File"] = file;
_logger?.Log(logEvent);
}
catch
{
// Native 영역으로 예외가 전파되지 않도록 차단 (Access Violation 방지)
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static unsafe string GetFileNameWithoutExtension(IntPtr ptr)
{
ReadOnlySpan<byte> utf8Span = MemoryMarshal.CreateReadOnlySpanFromNullTerminated((byte*)ptr);
int lastSlash = utf8Span.LastIndexOf((byte)'\\');
if (lastSlash == -1)
{
lastSlash = utf8Span.LastIndexOf((byte)'/');
}
if (lastSlash >= 0)
{
utf8Span = utf8Span[(lastSlash + 1)..];
}
int dot = utf8Span.LastIndexOf((byte)'.');
if (dot >= 0)
{
utf8Span = utf8Span[..dot];
}
return System.Text.Encoding.UTF8.GetString(utf8Span);
}
[UnmanagedCallersOnly(EntryPoint = "LogShutdown")]
public static void NativeLog_Shutdown()
{
try
{
LogManager.Flush(TimeSpan.FromSeconds(2));
LogManager.Shutdown();
}
catch
{
// Native 영역으로 예외가 전파되지 않도록 차단
}
}
}
dotnet publish -r win-x64 -c Release, dotnet publish -r win-x64 -c Debug를 LogHelper.csproj 프로젝트 안에서 실행한 후 LogHelper.Release.dll, LogHelper.Debug.dll을 publish 폴더에서 복사하여 메인 프로그램에서 사용한다.
메인프로그램(콘솔)
- 프로젝트 구성(NativeTest)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<!-- aot가 아닌 콘솔에서 테스트용도, aot만 불러오면 필요없음 -->
<ItemGroup>
<PackageReference Include="NLog" Version="6.1.1" />
<PackageReference Include="NLog.OutputDebugString" Version="6.1.1" />
</ItemGroup>
</Project>
- 메인프로그램 소스(Program.cs)
using System;
using System.Threading.Tasks;
namespace NativeTest;
internal class Program
{
private static async Task Main()
{
LogHelper.Info("info");
LogHelper.Warn("Warn");
LogHelper.Error("Error");
LogHelper.Fatal("Fatal");
LogHelper.Debug("Debug");
LogHelper.Trace("Trace");
Console.WriteLine("헬로우월드");
Console.WriteLine("종료 중...");
bool isLogClose = await Task.Run(()=>
{
LogHelper.Shutdown();
return true;
});
Console.WriteLine(isLogClose ? "로그 Flush 완료" : "로그 Flush 에러");
Console.WriteLine("프로그램 종료");
}
}
- 콘솔에서 사용하는 Helper클래스
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace NativeTest;
public partial class LogHelper
{
#if DEBUG
private const string DLL_NAME = "LogHelper.Debug.dll";
#else
private const string DLL_NAME = "LogHelper.Release.dll";
#endif
private static readonly bool _isNativeLoaded;
static LogHelper()
{
_isNativeLoaded = NativeLibrary.TryLoad(DLL_NAME, Assembly.GetExecutingAssembly(), null, out _);
}
private enum LogLevel
{
TRACE = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
FATAL = 4,
DEBUG = 5
}
[LibraryImport(DLL_NAME, EntryPoint = "LogWrite", StringMarshalling = StringMarshalling.Utf8)]
private static partial void LogWrite(int level, string msg, string file, string member, int line);
[LibraryImport(DLL_NAME, EntryPoint = "LogShutdown")]
private static partial void LogShutdown();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void Write(LogLevel level, string msg, string file, string member, int line)
{
if (!_isNativeLoaded)
{
return;
}
LogWrite((int)level, msg, file, member, line);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Trace(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
=> Write(LogLevel.TRACE, msg, f, m, l);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Info(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
=> Write(LogLevel.INFO, msg, f, m, l);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Warn(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
=> Write(LogLevel.WARN, msg, f, m, l);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Error(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
=> Write(LogLevel.ERROR, msg, f, m, l);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Fatal(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
=> Write(LogLevel.FATAL, msg, f, m, l);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Debug(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
=> Write(LogLevel.DEBUG, msg, f, m, l);
public static void Shutdown()
{
if (!_isNativeLoaded)
{
return;
}
try
{
LogShutdown();
}
catch
{
// 종료 시의 예외는 무시해도 안전한 경우가 많음
}
}
}
위의 소스는 AOT의 LogHelper가 아니고 콘솔에서 라이브러리를 사용하기 위한 헬퍼클래스 이다. 이름이 같으므로 혼동하지 말 것.
AOT없이 않고 직접 NLog 사용하기
using NLog;
using NLog.Config;
using NLog.Targets;
using NLog.Targets.Wrappers;
using System;
using System.Runtime.CompilerServices;
namespace NativeTest;
public static class LogTest
{
private static readonly Logger _logger;
private const string EX = " >> ";
static LogTest()
{
LoggingConfiguration config = new();
const string common_layout =
@"[${date:format=HH\:mm\:ss} ${level:uppercase=true:padding=5} : PID ${processid:Padding=5}] ${message}${onexception:${exception:format=message}} (${callsite:className=true:includeNamespace=false:methodName=true}[${callsite-linenumber}][${threadid}])";
const string log_folder = "${basedir}/AppLogs/${date:format=yyyyMM}/${date:format=dd}";
FileTarget fileTarget = new("FileTarget")
{
FileName = log_folder + "/${level}_${date:format=yyyyMMdd}.txt",
ArchiveFileName = log_folder + "/${level}_${date:format=yyyyMMdd}.{#}.txt",
Layout = common_layout,
Encoding = System.Text.Encoding.UTF8,
KeepFileOpen = true,
OpenFileCacheTimeout = 30,
AutoFlush = false,
BufferSize = 65536,
ArchiveEvery = FileArchivePeriod.Day,
MaxArchiveDays = 90,
ArchiveAboveSize = 10485760
};
AsyncTargetWrapper asyncWrapper = new(fileTarget, 10000, AsyncTargetWrapperOverflowAction.Grow)
{
TimeToSleepBetweenBatches = 0
};
config.AddRule(LogLevel.Trace, LogLevel.Fatal, asyncWrapper);
#if DEBUG
OutputDebugStringTarget debugOutput = new("debugOutput")
{
Layout = common_layout
};
AsyncTargetWrapper asyncDebug = new(debugOutput, 5000, AsyncTargetWrapperOverflowAction.Discard);
config.AddRule(LogLevel.Trace, LogLevel.Fatal, asyncDebug);
#endif
LogManager.Configuration = config;
_logger = LogManager.GetCurrentClassLogger();
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void WriteLog(LogLevel level, string message, Exception? ex = null)
{
if (!_logger.IsEnabled(level))
{
return;
}
try
{
LogEventInfo logEvent = new(level, _logger.Name, message)
{
Exception = ex
};
_logger.Log(typeof(LogTest), logEvent);
}
catch
{
// ignored
}
}
public static void Trace(string msg) => WriteLog(LogLevel.Trace, msg);
public static void Debug(string msg) => WriteLog(LogLevel.Debug, msg);
public static void Info(string msg) => WriteLog(LogLevel.Info, msg);
public static void Warn(string msg) => WriteLog(LogLevel.Warn, msg);
public static void Error(string msg) => WriteLog(LogLevel.Error, msg);
public static void Error(Exception ex, string msg) => WriteLog(LogLevel.Error, msg + EX, ex);
public static void Fatal(string msg) => WriteLog(LogLevel.Fatal, msg);
public static void Fatal(Exception ex, string msg) => WriteLog(LogLevel.Fatal, msg + EX, ex);
public static void Shutdown()
{
try
{
LogManager.Flush(TimeSpan.FromSeconds(2));
LogManager.Shutdown();
}
catch
{
// ignored
}
}
}
위의 소스는 AOT가 아닌 프로젝트에서 바로 NLog를 사용할 때 필요한 소스코드이고 LogTest.Error("Error"); 또는 LogTest.Error(ex, "Error"); 형태로 필요한 곳에서 바로 사용한다. 즉, AOT용 하나, 메인에서 바로 사용하는 파일 하나 이렇게 2개로 예제를 작성한 것이다.
애플케애션 설정(App.axml.cs)
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// UI 스레드 예외
this.DispatcherUnhandledException += (s, args) =>
{
LogHelper.Critical($"[UI Error] {args.Exception.Message}\n{args.Exception.StackTrace}");
args.Handled = true; // 에러메시지 후 앱은 실행
};
//작업 스레드 및 도메인 예외(task.run)
AppDomain.CurrentDomain.UnhandledException += (s, args) =>
{
if (args.ExceptionObject is Exception ex)
{
LogHelper.Fatal($"[Fatal Error] {ex.Message}\n{ex.StackTrace}");
}
LogHelper.Shutdown();
};
// 비동기 Task 예외(await없는 task)
TaskScheduler.UnobservedTaskException += (s, args) =>
{
LogHelper.Error($"[Async Error] {args.Exception.Message}");
args.SetObserved(); // 앱 종료 방지
};
}
// private static async void OnClosing(object? sender, CancelEventArgs e)
protected override void OnExit(ExitEventArgs e)
{
LogHelper.Shutdown();
base.OnExit(e);
}
}
이 부분은 프로그램 만들 때 필수로 처리해야 하는 코드의 예이다.
Reference
- C#에서 리플랙션(Reflection)은 실행 중(Runtime)에 객체의 형식(Type), 메서드, 필드, 프로퍼티 등의 메타데이터를 조사하거나 조작할 수 있게 해주는 기능인데 컴파일 시점에 실행될 코드를 예측할 수 없어서 AOT에서는 피해야 하며 소스 생성기(Source Generators)를 통하여 빌드될 수 있도록 해야 한다. 주로
partial키워드와 필요한 객체 위에Attribute를 사용한다.