logo MSJO.kr

C#에서 AOT로 NLog사용하기

2026-03-24
MsJ
   

이전 글 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 DebugLogHelper.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
  1. C#에서 리플랙션(Reflection)은 실행 중(Runtime)에 객체의 형식(Type), 메서드, 필드, 프로퍼티 등의 메타데이터를 조사하거나 조작할 수 있게 해주는 기능인데 컴파일 시점에 실행될 코드를 예측할 수 없어서 AOT에서는 피해야 하며 소스 생성기(Source Generators)를 통하여 빌드될 수 있도록 해야 한다. 주로 partial 키워드와 필요한 객체 위에 Attribute를 사용한다.

Prεv(Θld)  
Content
Search     RSS Feed     BY-NC-ND