본문 바로가기

C#

C# AssemblyLoadContext로 플러그인 아키텍처 만들기

런타임에 플러그인을 로드하고 언로드하려면 .NET의 AssemblyLoadContext를 활용하는 것이 가장 깔끔합니다. AppDomain이 사라진 .NET 5+ 환경에서 격리와 수집 가능한 언로드를 제공하여 메모리 누수 없이 확장 가능한 플러그인 시스템을 만들 수 있습니다.

1. 핵심 개념

AssemblyLoadContext는 어셈블리 로드 범위를 분리하는 컨테이너입니다. 기본 컨텍스트(Default ALC)는 애플리케이션 핵심 어셈블리를 로드합니다. 플러그인은 별도 컨텍스트(Collectible=true)에 로드하여 독립 실행과 언로드가 가능하도록 합니다. 호스트와 플러그인 사이에는 반드시 "공유 계약" 어셈블리를 통해 타입을 교환합니다.

2. 공유 계약 어셈블리 정의

namespace PluginContracts
{
    public interface IPlugin
    {
        string Name { get; }
        string Execute(string input);
    }
}

PluginContracts.dll은 호스트와 모든 플러그인이 공통으로 참조하며 기본 컨텍스트에서 로드합니다. 이렇게 해야 교차 컨텍스트 타입 불일치 문제가 발생하지 않습니다.

3. PluginLoadContext 구현

using System;
using System.Reflection;
using System.Runtime.Loader;

public sealed class PluginLoadContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver _resolver;

    public PluginLoadContext(string pluginMainAssemblyPath) : base(isCollectible: true)
    {
        _resolver = new AssemblyDependencyResolver(pluginMainAssemblyPath);
    }

    protected override Assembly? Load(AssemblyName assemblyName)
    {
        // 공유 계약은 기본 컨텍스트에서 로드하여 타입을 공유합니다.
        if (assemblyName.Name == "PluginContracts")
            return null; // 기본 ALC로 위임

        var path = _resolver.ResolveAssemblyToPath(assemblyName);
        if (path != null)
            return LoadFromAssemblyPath(path);

        return null; // 기본 ALC에 위임
    }

    protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
    {
        var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
        if (path != null)
            return LoadUnmanagedDllFromPath(path);
        return IntPtr.Zero;
    }
}

AssemblyDependencyResolver는 플러그인 폴더 내 종속성(NuGet DLL, 네이티브 라이브러리)을 안정적으로 찾아줍니다.

4. 플러그인 로드 및 실행

using System;
using System.IO;
using System.Linq;
using System.Reflection;

public sealed class PluginHandle
{
    public PluginLoadContext Context { get; }
    public Assembly Assembly { get; }
    public PluginContracts.IPlugin Instance { get; }

    public PluginHandle(PluginLoadContext context, Assembly assembly, PluginContracts.IPlugin instance)
    {
        Context = context;
        Assembly = assembly;
        Instance = instance;
    }
}

public static class PluginHost
{
    public static PluginHandle LoadPlugin(string pluginDllPath)
    {
        var plc = new PluginLoadContext(pluginDllPath);
        var asm = plc.LoadFromAssemblyPath(pluginDllPath);

        var pluginType = asm.GetTypes()
            .FirstOrDefault(t => typeof(PluginContracts.IPlugin).IsAssignableFrom(t) && !t.IsAbstract);

        if (pluginType == null)
            throw new InvalidOperationException("IPlugin 구현을 찾을 수 없습니다.");

        var instance = (PluginContracts.IPlugin)Activator.CreateInstance(pluginType)!;
        return new PluginHandle(plc, asm, instance);
    }
}

// 사용 예시
var handle = PluginHost.LoadPlugin(@"C:\\plugins\\EchoPlugin\\EchoPlugin.dll");
Console.WriteLine(handle.Instance.Execute("Hello"));

플러그인 DLL은 자신이 참조하는 모든 종속성을 같은 폴더에 배치해야 합니다. 호스트와 플러그인은 동일한 PluginContracts.dll 버전을 참조합니다.

5. 안전한 언로드 절차

// 언로드는 참조 해제가 핵심입니다.
var weakRef = new WeakReference(handle.Context);

// 컨텍스트 언로드 요청
handle.Context.Unload();

// 플러그인 타입/어셈블리에 대한 모든 참조 해제
handle = null;

// GC로 실제 메모리 회수 유도
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

Console.WriteLine($"Unloaded: {!weakRef.IsAlive}");

주의사항: 플러그인 타입을 보관하는 static 필드, 이벤트 구독, 스레드에서 살아있는 콜백 등은 컨텍스트를 붙잡습니다. 언로드 전에 반드시 정리합니다.

6. 베스트 프랙티스

공유 계약을 작게 유지하고 버전 호환성에 신경 씁니다. 계약 어셈블리는 기본 컨텍스트에서만 로드합니다. 플러그인에서 기본 컨텍스트 타입(System.*, 계약 타입) 외에는 교차 참조하지 않습니다. 예외와 로그를 호스트에서 캡처해 문제 있는 플러그인을 빠르게 언로드할 수 있도록 합니다. 보안 격리는 ALC가 아닌 프로세스 경계가 필요합니다.

7. 폴더 구조와 배포

HostApp.exe
PluginContracts.dll
Plugins/ 
  EchoPlugin/ 
    EchoPlugin.dll
    Newtonsoft.Json.dll
  AnotherPlugin/
    AnotherPlugin.dll

각 플러그인 폴더의 대표 DLL 경로를 PluginLoadContext 생성자에 전달합니다. dotnet publish로 플러그인을 자체 포함 배포하면 종속성 누락을 줄일 수 있습니다.

8. 마치며

AssemblyLoadContext로 플러그인 아키텍처를 구현하면 동적 확장, 안심되는 언로드, 버전 분리라는 세 가지 장점을 얻습니다. 먼저 작은 계약과 하나의 샘플 플러그인으로 시작하고, 로드/언로드를 반복 테스트하여 참조 누수 없이 작동하도록 강화하는 것을 권장합니다.