이전 글 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>
- 소스(LogHelper.cs)
using NLog;
using NLog.Config;
using NLog.Targets;
using NLog.Targets.Wrappers;
using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace LogHelper;
public static class LogHelper
{
private static readonly Logger _logger;
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
Register(LogLevel.Fatal, "Fatal");
Register(LogLevel.Error, "Error");
Register(LogLevel.Warn, "Warn");
Register(LogLevel.Info, "Info");
Register(LogLevel.Trace, "Trace");
Register(LogLevel.Debug, "Debug");
return;
void Register(LogLevel level, string folder)
{
FileTarget fileTarget = new($"{level}Target")
{
FileName = $"$/AppLogs/$/$/{folder}_$.txt",
Layout = layout,
KeepFileOpen = true,
MaxArchiveDays = 90
};
AsyncTargetWrapper asyncWrapper = new(fileTarget)
{
QueueLimit = 10000,
OverflowAction = AsyncTargetWrapperOverflowAction.Discard,
TimeToSleepBetweenBatches = 0
};
LoggingRule rule = new("*", level, level, asyncWrapper) { Final = true };
config.LoggingRules.Add(rule);
}
});
_logger = logFactory.GetCurrentClassLogger();
}
[UnmanagedCallersOnly(EntryPoint = "LogWrite", CallConvs = [typeof(CallConvCdecl)])]
public static void NativeLog_Write(int level, IntPtr msgPtr, IntPtr fileName, IntPtr memberPtr, int line)
{
try
{
if (msgPtr == IntPtr.Zero)
{
return;
}
string? msg = Marshal.PtrToStringUTF8(msgPtr);
if (string.IsNullOrEmpty(msg))
{
return;
}
string? member = (memberPtr != IntPtr.Zero)
? Marshal.PtrToStringUTF8(memberPtr)
: "Unknown";
string? file = (fileName != IntPtr.Zero)
? Marshal.PtrToStringUTF8(fileName)
: "Unknown";
LogLevel nLevel = level switch
{
1 => LogLevel.Info,
2 => LogLevel.Warn,
3 => LogLevel.Error,
4 => LogLevel.Fatal,
5 => LogLevel.Debug,
_ => LogLevel.Trace
};
LogEventInfo logEvent = LogEventInfo.Create(nLevel, _logger.Name, msg);
logEvent.Properties["Member"] = member;
logEvent.Properties["Line"] = line;
logEvent.Properties["File"] = Path.GetFileNameWithoutExtension(file);
_logger.Log(logEvent);
}
catch
{
// Native 영역으로 예외가 전파되지 않도록 차단
}
}
[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.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace NativeTest;
public partial class LogHelper
{
#if DEBUG
[LibraryImport("LogHelper.Debug.dll", EntryPoint = "LogWrite", StringMarshalling = StringMarshalling.Utf8)]
private static partial void LogWrite(int level, string msg, string file, string member, int line);
[LibraryImport("LogHelper.Debug.dll", EntryPoint = "LogShutdown")]
private static partial void LogShutdown();
#else
[LibraryImport("LogHelper.Release.dll", EntryPoint = "LogWrite", StringMarshalling = StringMarshalling.Utf8)]
private static partial void LogWrite(int level, string msg, string file, string member, int line);
[LibraryImport("LogHelper.Release.dll", EntryPoint = "LogShutdown")] private static partial void LogShutdown();#endif
private static void Write(int level, string msg, string f, string m, int l)
{
try
{
LogWrite(level, msg, f, m, l);
}
catch
{
// ignored
}
}
public static void Trace(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
{
Write(0, msg, f, m, l);
}
public static void Info(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
{
Write(1, msg, f, m, l);
}
public static void Warn(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
{
Write(2, msg, f, m, l);
}
public static void Error(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
{
Write(3, msg, f, m, l);
}
public static void Fatal(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
{
Write(4, msg, f, m, l);
}
public static void Debug(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
{
Write(5, msg, f, m, l);
}
public static void Shutdown()
{
try
{
LogShutdown();
}
catch
{
// ignored
}
}
}
위의 소스는 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}])";
AddLevelTarget(config, LogLevel.Trace, common_layout);
AddLevelTarget(config, LogLevel.Debug, common_layout);
AddLevelTarget(config, LogLevel.Info, common_layout);
AddLevelTarget(config, LogLevel.Warn, common_layout);
AddLevelTarget(config, LogLevel.Error, common_layout);
AddLevelTarget(config, LogLevel.Fatal, common_layout);
#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();
}
private static void AddLevelTarget(LoggingConfiguration config, LogLevel level, string layout)
{
const string log_folder = $"$/AppLogs/$/$";
string fileName = $"{log_folder}/{level.Name}_$.txt";
FileTarget fileTarget = new($"{level.Name}File")
{
FileName = fileName,
ArchiveFileName = $"{log_folder}/{level.Name}_$..txt",
Layout = 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
};
LoggingRule rule = new("*", level, level, asyncWrapper);
config.LoggingRules.Add(rule);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void WriteLog(LogLevel level, string message, Exception? ex = null)
{
try
{
LogEventInfo logEvent = new(level, _logger.Name, message);
if (ex != null)
{
logEvent.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개로 예제를 작성한 것이다.
실제 애플케애션 설정(추가)
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를 사용한다.