← 기술 백서 목록으로 돌아가기

초·중·고 정보 교육과 군 전산실의 커스텀 기능 무한 확장: ZeliDesk의 AssemblyLoadContext 동적 프로빙 및 IZeliUtility 격리 플러그인 아키텍처

"소프트웨어가 아무리 많은 기능을 내장하더라도, 각 학교와 군부대 전산실마다 요구하는 맞춤형 도구와 특화된 교안 시뮬레이터는 천차만별입니다. 특정 고등학교의 학내망 포트 진단기, 연구실의 특수 장비 제어 패널, 군사 작전망 고유의 암호화 포맷 파서를 본체 런처에 일일이 추가하면 앱이 끝없이 비대해지고 배포 주기가 마비됩니다. ZeliDesk는 단 하나의 C# DLL을 %LOCALAPPDATA%\ZeliDesk\plugins에 복사하는 것만으로 런처 메인 타일과 가이드 카탈로그에 즉시 결합되는 동적 플러그인 시스템을 구축했습니다. Default AssemblyLoadContext 기반 인터페이스 일치, 서브 디렉터리 다중 의존 DLL 런타임 프로빙, 그리고 WinUI 3 XAML 제약을 우회하는 코드 전용 창(Code-Only UI) 설계를 통해 가장 안전하고 무제한 확장 가능한 온디바이스 데스크톱 생태계를 완성했습니다."

1. 확장성의 딜레마: 본체 비대화 vs 현장 맞춤형 도구의 부재

전국의 초·중·고 정보 컴퓨터실과 군 전산실은 저마다 고유한 교육 커리큘럼과 보안 프로토콜을 운영합니다. 교사는 수업 중 학생들의 특정 실습용 하드웨어(센서 보드, 교내 인트라넷 채점 서버 등)를 제어할 도구를 원하고, 군 통제관은 규격화된 특정 전문 통신 패킷을 분석하는 소형 유틸리티를 요구합니다.

그러나 이 모든 요구를 모놀리식(Monolithic) 런처 본체에 탑재하는 것은 다음과 같은 심각한 문제를 야기합니다:

  1. 솔루션 코드베이스 오염 및 릴리스 지연: 특정 단체만을 위한 임시 유틸리티가 코어 리포지토리에 섞여 전체 빌드 파이프라인과 회귀 테스트를 느리게 만듭니다.
  2. 의존성 충돌(Dependency Hell): 서드파티 도구가 요구하는 특정 버전의 라이브러리가 본체의 런타임 라이브러리와 버전 충돌을 일으킵니다.
  3. 설치 권한의 장벽: 새로운 도구를 추가할 때마다 관리자 권한으로 전체 인스톨러를 재배포해야 하므로, 망분리된 실습실에서는 수 주일의 인가 절차가 소요됩니다.

ZeliDesk의 PluginLoader와 ZeliDesk.Sdk는 개발자가 단 10분 만에 독립된 C# 프로젝트에서 유틸리티를 빌드하고, DLL 파일을 로컬 사용자 폴더에 드롭(Drop)하는 것만으로 런처가 0초 만에 감지하여 실행할 수 있는 플러그인 아키텍처를 제공합니다.

설계 영역 일반적인 플러그인 로더 / 호스팅 방식 ZeliDesk PluginLoader 아키텍처
어셈블리 컨텍스트 격리 ALC 사용 시 InvalidCastException 발생 AssemblyLoadContext.Default 단일 어셈블리 타입 정합성 보장
외부 NuGet 의존성 단일 DLL만 로드 가능하여 서드파티 패키지 사용 불가 plugins\이름\*.dll 하위 폴더 자동 프로빙 및 Resolving 훅
UI 프레임워크 계약 외부 XAML 로딩 시 MRT Core PRI 및 리소스 충돌 헤드리스 메타데이터 + 순수 C# 코드 창(Code-Only UI) 설계
예외 격리 DLL 1개 오류 시 앱 전체 기동 크래시 발생 ReflectionTypeLoadException 정밀 격리로 정상 플러그인 보존
앱스토어 규제 대응 동적 코드 로딩으로 인한 스토어 심사 거절 위험 #if DISABLE_PLUGINS 전처리기를 통한 Store/Sideload 이원화

2. 핵심 아키텍처 1: Default AssemblyLoadContext와 타입 정합성(Type Identity) 보장

.NET Core 3.0 이상에서 동적 플러그인을 설계할 때 개발자들이 가장 흔히 마주치는 함정은 "어셈블리 격리를 위해 독립된 AssemblyLoadContext(Custom ALC)를 생성하는 것"입니다.

독립 ALC를 사용할 경우, 플러그인이 참조하는 ZeliDesk.Sdk.dll이 호스트 메모리에 이미 로드되어 있더라도 Custom ALC 내부에서 별도로 재로드됩니다. 그 결과 런타임에서는 "네임스페이스와 클래스명이 완전히 동일하지만 메모리 주소가 다른 두 개의 IZeliUtility 타입"이 존재하게 되어, 호스트가 플러그인 인스턴스를 인터페이스로 캐스팅하는 순간 다음과 같은 치명적인 런타임 크래시가 발생합니다:

Runtime Exception (Custom ALC 사용 시 발생하는 타입 불일치)
System.InvalidCastException: Unable to cast object of type 'MyCustomTool.MyUtility' 
to type 'ZeliDesk.Sdk.IZeliUtility'. 
(Loaded from different AssemblyLoadContext instances)

ZeliDesk의 PluginLoader는 이 문제를 해결하기 위해 과감하게 AssemblyLoadContext.Default에 직접 어셈블리를 로드하는 아키텍처 결정을 내렸습니다:

C# (Services/PluginLoader.cs)
// Default AssemblyLoadContext에 로드 — 플러그인이 참조하는 ZeliDesk.Sdk가
// 호스트에 이미 로드된 동일 어셈블리로 통일되어 인터페이스 타입이 완벽히 일치한다.
var asm = AssemblyLoadContext.Default.LoadFromAssemblyPath(dll);

foreach (var type in asm.GetTypes())
{
    if (type.IsAbstract || !typeof(IZeliUtility).IsAssignableFrom(type))
        continue;

    var utility = (IZeliUtility)Activator.CreateInstance(type);
    string id = utility.Id?.Trim().ToLowerInvariant();
    
    // 유효성 및 중복 방어
    if (string.IsNullOrEmpty(id) || Plugins.ContainsKey(id)) continue;
    
    Plugins[id] = utility;
}

이로 인해 플러그인이 참조하는 ZeliDesk.Sdk는 호스트가 사용하는 단일 어셈블리로 자연스럽게 수렴하며, 어떤 복잡한 리플렉션 래핑이나 리모팅 프록시 없이도 100% 네이티브 속도로 캐스팅 및 메서드 호출이 성립합니다.

3. 핵심 아키텍처 2: 서브 디렉터리 다중 의존 어셈블리 동적 프로빙

실제 현장에서 개발되는 유용한 유틸리티들은 단일 DLL로 끝나지 않습니다. JSON을 파싱하기 위해 System.Text.Json이나 Newtonsoft.Json을 참조하고, 네트워크 통신이나 암호화 라이브러리를 동반하는 경우가 대부분입니다.

ZeliDesk는 단일 파일 배치(plugins\*.dll)뿐만 아니라, **의존 DLL 동반 하위 폴더 배치(plugins\플러그인명\*.dll)** 방식을 완벽히 지원합니다:

C# (의존 어셈블리 해석을 위한 HookResolver 및 s_probeDirs)
private static readonly List<string> s_probeDirs = new();
private static bool s_resolverHooked;

private static void HookResolver(Action<string> log)
{
    if (s_resolverHooked) return;
    s_resolverHooked = true;

    // 플러그인 실행 중 누락된 의존 어셈블리 요청이 발생하면 프로빙 디렉터리를 순회
    AssemblyLoadContext.Default.Resolving += (ctx, name) =>
    {
        foreach (var dir in s_probeDirs)
        {
            string candidate = Path.Combine(dir, name.Name + ".dll");
            if (File.Exists(candidate))
            {
                log?.Invoke($"[Plugin] 의존 어셈블리 해석: {name.Name} ← {dir}");
                try { return ctx.LoadFromAssemblyPath(candidate); } catch { }
            }
        }
        return null; // 탐색 실패 시 런타임 기본 실패 경로로 이관
    };
}

스캐너가 PluginDir 내부의 하위 디렉터리를 발견하면, 디렉터리 경로를 s_probeDirs 컬렉션에 등록하고 AssemblyLoadContext.Default.Resolving 이벤트를 1회 훅(Hook)합니다. 플러그인 코드가 실행되는 도중 런타임이 의존 어셈블리를 요구할 때, 훅 핸들러가 해당 하위 폴더에서 일치하는 DLL을 찾아 즉시 메모리에 바인딩합니다. 이를 통해 플러그인 개발자는 NuGet 패키지가 포함된 복잡한 라이브러리를 자유롭게 번들링하여 배포할 수 있습니다.

4. 핵심 아키텍처 3: 헤드리스 스캐닝과 순수 C# 코드 UI 계약 (IZeliUtility)

WinUI 3 (Windows App SDK) 데스크톱 환경에서는 외부 어셈블리에 번들된 XAML을 동적으로 로드하는 것이 극도로 까다롭습니다. 외부 어셈블리의 XAML 리소스를 인식하려면 호스트 앱의 IXamlMetadataProvider 체이닝과 복잡한 PRI(Package Resource Index) 병합이 필요한데, 이는 경량 무설치 런처의 안정성을 크게 위협합니다.

ZeliDesk SDK는 이 난제를 돌파하기 위해 "코드 전용 창(Code-Only Window)"과 "헤드리스 스캐닝(Headless Discovery)"이라는 명확한 아키텍처 규칙을 수립했습니다:

C# (ZeliDesk.Sdk/IZeliUtility.cs)
namespace ZeliDesk.Sdk
{
    public interface IZeliUtility
    {
        /// <summary>고유 식별자 — 소문자·영숫자 (예: "myclock"). 본체 유틸과 충돌 시 무시됨.</summary>
        string Id { get; }

        /// <summary>런처 목록에 표시할 이모지 아이콘 1개.</summary>
        string Icon { get; }

        /// <summary>짧은 표시 이름 (예: "MyClock").</summary>
        string ShortName { get; }

        /// <summary>한 줄 설명. lang은 "ko"·"en"·"zh" — 모르면 기본 문자열 반환</summary>
        string GetDescription(string lang);

        /// <summary>유틸리티 창 생성. 호출자가 Activate()를 부른다.</summary>
        Window CreateWindow();
    }
}

5. 핵심 아키텍처 4: 장애 허용 예외 격리 & 스토어 규제 이원화

사용자가 인터넷이나 커뮤니티에서 다운로드한 서드파티 플러그인 중에는 손상된 DLL이나 호환되지 않는 .NET 버전의 파일이 포함될 수 있습니다. 단 하나의 불량 DLL 때문에 런처 전체가 켜지지 않는다면 교육 현장과 군 전산실에서는 소프트웨어를 신뢰할 수 없습니다.

PluginLoader는 ReflectionTypeLoadException을 정밀하게 가로채어 오류 사유를 안전하게 디버그 로그로 격리하고 정상 DLL의 로딩을 지속합니다:

C# (파일 단위 예외 격리 및 로더 디버그 텔레메트리)
catch (ReflectionTypeLoadException ex)
{
    // 손상된 DLL이라도 첫 번째 로더 예외 메시지만 수집하고 프로세스 크래시를 방지
    log?.Invoke($"[Plugin] {Path.GetFileName(dll)} 타입 로드 실패: {ex.LoaderExceptions?[0]?.Message}");
}
catch (Exception ex)
{
    log?.Invoke($"[Plugin] {Path.GetFileName(dll)} 로드 실패: {ex.Message}");
}

또한 상용 마켓 배포를 위한 거버넌스로 Microsoft Store 정책 10.2(외부 임의 코드 로딩 금지 규정)를 완벽히 준수합니다. 스토어 제출용 패키지를 빌드할 때는 #if DISABLE_PLUGINS 전처리기를 활성화하여 플러그인 로더를 완전히 바이패스하고, 공교육 기관과 국방부, 기업에 직접 납품하는 무설치(Sideload/Portable) 패키지에서는 플러그인 기능을 활성화하는 유연한 이원화 배포 체계를 운영합니다.

6. 결론: "교사와 엔지니어가 직접 만드는 열린 온디바이스 생태계"

완벽한 소프트웨어란 모든 기능을 개발자가 다 만들어 넣은 것이 아니라, 사용자가 필요한 기능을 직접 만들어 확장할 수 있는 여지를 열어둔 소프트웨어입니다.

ZeliDesk의 PluginLoader와 ZeliDesk.Sdk는 단일 DLL 드롭만으로 런처 타일과 카탈로그에 즉시 결합되는 직관성, Default ALC 기반의 무결성 타입 정합성, 그리고 서브 디렉터리 동적 프로빙을 통해 대한민국 학교와 연구실 현장에 무한한 맞춤형 유틸리티 생태계를 제공하고 있습니다.

← 이전 기술 백서 #905 공교육 교실과 군 전산실을 위한 40종 도구 인터랙티브 카탈로그: UtilityGuide의 단일 진실 공급원(SSOT) 메타데이터 프로젝션 및 다국어 인스턴트 디스패치 아키텍처 다음 기술 백서 → #903 WinUI 3의 시스템 트레이 부재를 극복하는 순수 P/Invoke 서브클래싱: ZeliDesk TrayIconService의 Shell_NotifyIcon 및 0ms 백그라운드 상주 오케스트레이션 아키텍처
⚡ ZeliDesk 플러그인 SDK & 샘플 프로젝트 체험하기

단 10분 만에 나만의 C# 유틸리티를 만들어 런처에 꽂을 수 있는 ZeliDesk.Sdk와 강력한 온디바이스 생태계를 직접 경험하세요.

ZeliDesk Suite 무료 다운로드