이전 글 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 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.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

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