AppDomain은 .NET Framework에서 어셈블리 로드 격리, 플러그인 언로드, 충돌 최소화를 위해 제공된 경량 격리 단위입니다. .NET Core/5+에서는 새로운 AppDomain 생성이 불가하며 AssemblyLoadContext(ALC)가 대안입니다. 본 글은 AppDomain의 핵심 개념과 실전 활용, 그리고 현대 .NET에서의 대체 전략을 간단히 정리합니다.
1. AppDomain이란?
- 프로세스 내 논리적 격리 단위입니다. 하나의 프로세스에 여러 AppDomain을 생성해 어셈블리를 분리 로드/언로드할 수 있습니다(.NET Framework).
- 주요 목적: 플러그인 격리, 어셈블리 버전 충돌 회피, 언로드로 메모리 회수, 도메인별 예외 및 설정 분리입니다.
- 제약: 크로스 도메인 호출은 MarshalByRefObject 또는 직렬화가 필요하며, 일부 리소스(Native 핸들 등)가 언로드를 막을 수 있습니다.
2. .NET Framework: AppDomain 생성/언로드 기본
플러그인을 별도 도메인에 로드하고 안전하게 언로드하는 예제입니다. 도메인 간 호출을 위해 MarshalByRefObject를 사용합니다.
// Program.cs (.NET Framework 전용, <TargetFrameworkVersion>v4.x</...>)
using System;
using System.IO;
using System.Reflection;
public class PluginRunner : MarshalByRefObject
{
public void Run(string assemblyPath)
{
var asm = Assembly.LoadFrom(assemblyPath);
var type = asm.GetType("MyPlugin.Entry");
var method = type.GetMethod("Execute", BindingFlags.Public | BindingFlags.Static);
method.Invoke(null, null);
Console.WriteLine($"Plugin executed in AppDomain: {AppDomain.CurrentDomain.FriendlyName}");
}
}
class Program
{
static void Main()
{
var pluginDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "plugins");
var setup = new AppDomainSetup
{
ApplicationBase = AppDomain.CurrentDomain.BaseDirectory,
PrivateBinPath = "plugins" // 의존성 DLL을 plugins 폴더에 둡니다.
};
var domain = AppDomain.CreateDomain("PluginDomain", null, setup);
try
{
var runner = (PluginRunner)domain.CreateInstanceAndUnwrap(
typeof(PluginRunner).Assembly.FullName,
typeof(PluginRunner).FullName);
runner.Run(Path.Combine(pluginDir, "MyPlugin.dll"));
}
finally
{
AppDomain.Unload(domain); // 안전하게 언로드하여 메모리 회수
}
}
}
핵심 포인트:
- PluginRunner는 MarshalByRefObject를 상속하여 원격 개체로 생성/호출합니다.
- PrivateBinPath로 의존성 탐색 경로를 지정합니다.
- Unload 호출 전 해당 도메인에서 실행 중인 작업이 없어야 합니다.
3. 어셈블리 로드/리졸브 이벤트
의존 DLL 경로가 표준 프로빙 범위를 벗어날 때 AssemblyResolve를 활용합니다. 현재 도메인 기준 예시입니다.
using System;
using System.IO;
using System.Reflection;
class ResolverSample
{
static void Main()
{
string pluginDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "plugins");
AppDomain.CurrentDomain.AssemblyResolve += (s, e) =>
{
var name = new AssemblyName(e.Name).Name + ".dll";
var path = Path.Combine(pluginDir, name);
return File.Exists(path) ? Assembly.LoadFrom(path) : null;
};
// 이후 플러그인 로드/실행 코드...
}
}
주의사항:
- AssemblyResolve는 마지막 수단입니다. 먼저 probing 경로(PrivateBinPath, ApplicationBase)를 적절히 구성하세요.
- 이벤트 핸들러에서 예외를 던지면 로딩 실패로 이어집니다. null을 반환해 다른 핸들러 기회를 주는 것이 안전합니다.
4. 격리와 보안
- .NET Framework의 부분 신뢰(CAS) 기반 샌드박스는 최신 환경에서 권장되지 않습니다.
- 안전 격리는 프로세스 경계(별도 exe) 또는 컨테이너 격리가 더 현실적입니다.
- 파일/네트워크 등 외부 리소스는 도메인 언로드만으로 완전히 정리되지 않을 수 있으니 명시적 해제를 구현하세요(IDisposable, 핸들 닫기).
5. .NET Core/5+: AssemblyLoadContext로 대체
.NET Core 이후에는 새로운 AppDomain 생성이 불가합니다. 대신 AssemblyLoadContext를 사용해 플러그인 격리와 언로드(수집 가능 ALC)를 구현합니다.
// .NET 6+ 예시: 수집 가능한 ALC로 플러그인 로드/언로드
using System;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
public class PluginLoadContext : AssemblyLoadContext
{
private readonly string _pluginDir;
public PluginLoadContext(string pluginDir) : base(isCollectible: true)
{
_pluginDir = pluginDir;
}
protected override Assembly Load(AssemblyName assemblyName)
{
string candidate = Path.Combine(_pluginDir, assemblyName.Name + ".dll");
return File.Exists(candidate) ? LoadFromAssemblyPath(candidate) : null;
}
}
class Program
{
static WeakReference _alcRef;
static void Main()
{
LoadAndRun();
for (int i = 0; _alcRef.IsAlive && i < 10; i++)
{
GC.Collect(); GC.WaitForPendingFinalizers();
}
Console.WriteLine($"ALC alive: {_alcRef.IsAlive}");
}
static void LoadAndRun()
{
string pluginDir = Path.Combine(AppContext.BaseDirectory, "plugins");
var alc = new PluginLoadContext(pluginDir);
_alcRef = new WeakReference(alc, trackResurrection: false);
var asm = alc.LoadFromAssemblyPath(Path.Combine(pluginDir, "MyPlugin.dll"));
var type = asm.GetType("MyPlugin.Entry");
var method = type.GetMethod("Execute", BindingFlags.Public | BindingFlags.Static);
method.Invoke(null, null);
alc.Unload(); // 참조 해제 후 GC가 실제 언로드 수행
}
}
핵심 포인트:
- isCollectible: true로 생성해야 언로드 가능합니다.
- 언로드 조건: 플러그인 타입/어셈블리에 대한 참조가 모두 해제되어야 합니다. 정적 이벤트, 스레드, 핸들을 주의하세요.
6. 크로스 도메인 통신
- .NET Framework: MarshalByRefObject 상속 또는 [Serializable]로 객체 교환이 가능합니다. .NET Remoting은 레거시이므로 단순한 메서드 호출/DTO 교환만 권장합니다.
- .NET Core: AppDomain 격리가 없으므로 ALC 내에서는 동일 프로세스 호출입니다. 프로세스 격리 시에는 gRPC/Named Pipe/HTTP를 권장합니다.
7. 진단과 언로드 실패 대응
- .NET Framework: AppDomain.MonitoringIsEnabled = true로 활성화 후 도메인별 메모리/CPU를 확인합니다.
// 도메인 모니터링 예시 (.NET Framework)
AppDomain.MonitoringIsEnabled = true;
Console.WriteLine($"CurrentDomain Survived: {AppDomain.CurrentDomain.MonitoringSurvivedMemorySize} bytes");
- 언로드가 지연되면 다음을 점검합니다:
1) 언로드 대상 도메인 내 스레드가 종료되었는지
2) 정적 이벤트에 외부 참조가 걸리지 않았는지
3) Native 리소스/파일 핸들이 열려 있지 않은지
4) ALC의 경우 약한 참조(WeakReference)로 생존 여부를 추적하세요
8. 베스트 프랙티스 체크리스트
- .NET Framework: 플러그인은 별도 AppDomain + MarshalByRefObject 사용, 명시적 Dispose 구현, Unload 보장
- .NET Core/5+: AssemblyLoadContext(isCollectible) 사용, 모든 참조 해제 후 Unload + GC
- 공통: 의존성 경로는 명확히 설정, AssemblyResolve/ALC.Load 오버라이드는 마지막 수단, 프로세스 격리를 우선 고려
9. 요약
AppDomain은 .NET Framework에서 안전한 플러그인 격리와 언로드를 제공하지만, 최신 .NET에서는 AssemblyLoadContext가 실무 표준입니다. 요구되는 격리 강도와 운영 환경에 따라 AppDomain(ALC) 또는 별도 프로세스를 선택하세요.
'C#' 카테고리의 다른 글
| C# StreamReader와 StreamWriter의 고급 활용 (0) | 2026.07.03 |
|---|---|
| C# 메서드 Attribute 기반 캐싱 구현 (1) | 2026.07.02 |
| C# ConcurrentDictionary를 이용한 안전한 데이터 공유 (1) | 2026.07.01 |
| C# 인터페이스 다중 상속 시 충돌 해결 전략 (0) | 2026.06.30 |
| C# Reflection으로 Private 멤버 접근 및 테스트 활용 (0) | 2026.06.30 |