C#에서 플러그인 DLL 동적 로딩 및 핫 스왑 구현

애플리케이션 실행 중 DLL 파일을 동적으로 로드하거나 언로드할 수 있는 플러그인 아키텍처는 유연한 확장성을 제공합니다. 이 방식은 새로운 기능을 추가하거나 기존 기능을 수정할 때 전체 애플리케이션을 재시작하지 않아도 된다는 장점이 있습니다. 이 글에서는 C# WebAPI 프로젝트를 기준으로 플러그인 DLL을 동적으로 로딩하는 방법을 단계별로 설명합니다.

1. 인터페이스 정의

먼저 공유 계약(contract) 역할을 할 플러그인 인터페이스를 정의합니다. 모든 플러그인 DLL은 이 인터페이스를 구현해야 합니다.

public interface IPluginModule
{
    string ModuleName { get; } // 플러그인 이름
    string Execute(string operation, object parameters);
    string Save(object input);
    string Query(object filter);
}

2. DLL 로더 유틸리티 클래스 구현

다음으로 동적 로딩과 언로딩을 담당하는 유틸리티 클래스를 작성합니다. AssemblyLoadContext를 사용하여 각 어셈블리를 격리된 컨텍스트에서 로드하고 필요 시 해제할 수 있습니다.

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

public static class PluginLoader
{
    private static List<IPluginModule> _loadedPlugins = new List<IPluginModule>();
    private static readonly string PluginDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Plugins");

    /// <summary>
    /// Plugins 폴더 내 모든 DLL을 스캔하여 플러그인 인스턴스 생성
    /// </summary>
    public static void LoadAllPlugins()
    {
        if (!Directory.Exists(PluginDirectory))
        {
            Directory.CreateDirectory(PluginDirectory);
            Console.WriteLine("Plugins 폴더가 없어 새로 생성했습니다.");
            return;
        }

        _loadedPlugins.Clear();

        foreach (string dllFile in Directory.GetFiles(PluginDirectory, "*.dll"))
        {
            try
            {
                // 각 DLL마다 별도의 AssemblyLoadContext 생성 (언로드 가능)
                var loadContext = new AssemblyLoadContext(Path.GetFileNameWithoutExtension(dllFile), isCollectible: true);
                Assembly assembly = loadContext.LoadFromAssemblyPath(dllFile);

                // IPluginModule 인터페이스를 구현한 콘크리트 클래스 찾기
                var pluginTypes = assembly.GetTypes()
                    .Where(t => typeof(IPluginModule).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract);

                foreach (var type in pluginTypes)
                {
                    if (Activator.CreateInstance(type) is IPluginModule pluginInstance)
                    {
                        _loadedPlugins.Add(pluginInstance);
                        Console.WriteLine($"플러그인 로드 성공: {pluginInstance.ModuleName}");
                    }
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"DLL 로드 실패: {dllFile}, 오류: {ex.Message}");
            }
        }
    }

    /// <summary>
    /// 특정 이름의 플러그인을 언로드하고 리스트에서 제거
    /// </summary>
    public static void UnloadPlugin(string moduleName)
    {
        var plugin = _loadedPlugins.FirstOrDefault(p => p.ModuleName == moduleName);
        if (plugin != null)
        {
            _loadedPlugins.Remove(plugin);
            // 참고: 실제 어셈블리 언로드는 GC가 수집할 때 이루어지며,
            // 필요 시 AssemblyLoadContext.Unload()를 호출할 수 있음
            Console.WriteLine($"플러그인 언로드: {moduleName}");
        }
    }

    /// <summary>
    /// 로드된 모든 플러그인 목록 반환
    /// </summary>
    public static List<IPluginModule> GetPlugins() => _loadedPlugins;
}

3. 플러그인 DLL 프로젝트 생성

별도의 클래스 라이브러리 프로젝트를 만들고, 위에서 정의한 IPluginModule 인터페이스를 포함하는 어셈블리를 참조합니다. 그런 다음 인터페이스를 구현하는 클래스를 작성하고 빌드합니다.

public class SamplePlugin : IPluginModule
{
    public string ModuleName => "SamplePlugin";

    public string Execute(string operation, object parameters)
    {
        // 실제 비즈니스 로직 구현
        return $"SamplePlugin executed with operation '{operation}'";
    }

    public string Save(object input)
    {
        return "Data saved by SamplePlugin";
    }

    public string Query(object filter)
    {
        return "Data queried by SamplePlugin";
    }
}

4. 플러그인 배치 및 초기 로드

빌드하여 생성된 SamplePlugin.dll (및 해당 종속성)을 메인 WebAPI 프로젝트의 Plugins 폴더에 복사합니다. 애플리케이션 시작 시 PluginLoader.LoadAllPlugins()를 호출합니다.

// Program.cs 또는 Startup.cs
var app = builder.Build();
PluginLoader.LoadAllPlugins(); // 모든 DLL 로드
// ... 나머지 설정

5. WebAPI에서 플러그인 활용

API 컨트롤러에서 PluginLoader를 통해 로드된 플러그인 인스턴스를 가져와 동적으로 메서드를 호출할 수 있습니다.

[ApiController]
[Route("api/plugins/{pluginName}")]
public class PluginController : ControllerBase
{
    [HttpPost("execute")]
    public IActionResult ExecutePlugin(string pluginName, [FromBody] object parameters)
    {
        var plugin = PluginLoader.GetPlugins().FirstOrDefault(p => p.ModuleName == pluginName);
        if (plugin == null)
            return NotFound($"Plugin '{pluginName}' not found.");

        var result = plugin.Execute("dynamicCall", parameters);
        return Ok(new { Result = result });
    }

    [HttpPost("save")]
    public IActionResult SavePlugin(string pluginName, [FromBody] object data)
    {
        var plugin = PluginLoader.GetPlugins().FirstOrDefault(p => p.ModuleName == pluginName);
        if (plugin == null)
            return NotFound($"Plugin '{pluginName}' not found.");

        var result = plugin.Save(data);
        return Ok(new { Result = result });
    }

    [HttpGet("reload")]
    public IActionResult ReloadPlugins()
    {
        PluginLoader.LoadAllPlugins();
        return Ok("Plugins reloaded.");
    }
}

이 구조를 사용하면 런타임 중에도 /api/plugins/reload를 호출하여 새로운 DLL을 다시 로드하거나, 특정 이름의 플러그인을 언로드하는 로직을 추가할 수 있습니다. 각 플러그인은 독립적인 AssemblyLoadContext를 가지므로 필요 시 완전히 해제될 수 있습니다(단, 모든 참조가 제거되어야 함).

태그: C# Plugin Architecture dynamic loading AssemblyLoadContext DLL

7월 1일 20:12에 게시됨