다음을 통해 공유


PowerShell 모듈 어셈블리 종속성 충돌 해결

C#에서 이진 PowerShell 모듈을 작성하는 경우 기능을 제공하기 위해 다른 패키지 또는 라이브러리에 대한 종속성을 사용하는 것이 당연합니다. 다른 라이브러리에 대한 종속성을 사용하는 것이 코드 재사용에 적합합니다. PowerShell은 항상 어셈블리를 동일한 컨텍스트로 로드합니다. 이렇게 하면 모듈의 종속성이 이미 로드된 DLL과 충돌하고 동일한 PowerShell 세션에서 관련이 없는 두 개의 모듈을 사용하지 못할 수 있는 문제가 발생합니다.

이 문제가 발생한 경우 다음과 같은 오류 메시지가 표시됩니다.

어셈블리 부하 충돌 오류 메시지

이 문서에서는 PowerShell에서 종속성 충돌이 발생하는 몇 가지 방법과 종속성 충돌 문제를 완화하는 방법을 살펴봅니다. 모듈 작성자가 아니더라도 사용하는 모듈에서 발생하는 종속성 충돌을 해결하는 데 도움이 될 수 있는 몇 가지 트릭이 여기에 있습니다.

종속성 충돌이 발생하는 이유는 무엇인가요?

.NET에서는 동일한 어셈블리의 두 버전이 동일한 어셈블리 부하 컨텍스트로드될 때 종속성 충돌이 발생합니다. 이 용어는 이 문서의 뒷부분에서 다른 .NET 플랫폼에서 약간 다른 항목을 의미합니다. 이 충돌은 버전이 지정된 종속성이 사용되는 모든 소프트웨어에서 발생하는 일반적인 문제입니다.

충돌 문제는 프로젝트가 거의 의도적으로 또는 직접 동일한 종속성의 두 버전에 종속되지 않는다는 사실에 의해 복잡합니다. 대신 프로젝트에는 각각 동일한 종속성의 다른 버전이 필요한 둘 이상의 종속성이 있습니다.

예를 들어 .NET 애플리케이션인 DuckBuilder두 종속성을 가져와 기능의 일부를 수행하고 다음과 같이 표시됩니다.

DuckBuilder의 두 종속성은 Newtonsoft.Json 다른 버전에 의존합니다.

Contoso.ZipToolsFabrikam.FileHelpers 둘 다 Newtonsoft.Json서로 다른 버전에 따라 달라지므로 각 종속성이 로드되는 방식에 따라 종속성 충돌이 발생할 수 있습니다.

PowerShell의 종속성과 충돌

PowerShell의 자체 종속성이 동일한 공유 컨텍스트에 로드되므로 PowerShell에서 종속성 충돌 문제가 확대됩니다. 즉, PowerShell 엔진 및 로드된 모든 PowerShell 모듈에 충돌하는 종속성이 없어야 합니다. 이 예제의 대표적인 예는 Newtonsoft.Json.

FictionalTools 모듈은 PowerShell 최신 버전의 Newtonsoft.Json에 따라 달라집니다.

이 예제에서 모듈 FictionalToolsNewtonsoft.Json 버전 12.0.3따라 달라집니다. 이 버전은 예제 PowerShell에서 제공되는 11.0.2 최신 버전입니다.

메모

예를 들면 다음과 같습니다. PowerShell 7.0은 현재 Newtonsoft.Json 12.0.3함께 제공합니다. 최신 버전의 PowerShell에는 Newtonsoft.Json최신 버전이 있습니다.

모듈은 최신 버전의 어셈블리에 따라 달라지므로 PowerShell이 이미 로드한 버전은 허용되지 않습니다. 그러나 PowerShell에서 이미 어셈블리 버전을 로드했으므로 모듈은 기존 부하 메커니즘을 사용하여 자체 버전을 로드할 수 없습니다.

다른 모듈의 종속성 충돌

PowerShell의 또 다른 일반적인 시나리오는 어셈블리의 한 버전에 따라 모듈이 로드된 다음 나중에 다른 버전의 어셈블리에 따라 다른 모듈이 로드된다는 것입니다.

다음과 같은 경우가 많습니다.

두 PowerShell 모듈을 서로 다른 버전의 Microsoft.Extensions.Logging 종속성

이 경우 FictionalTools 모듈에는 Microsoft.Extensions.Logging 모듈보다 최신 버전의 FilesystemManager 필요합니다.

이러한 모듈은 루트 모듈 어셈블리와 동일한 디렉터리에 종속성 어셈블리를 배치하여 해당 종속성을 로드한다고 상상해 보세요. 이렇게 하면 .NET에서 이름으로 암시적으로 로드할 수 있습니다. PowerShell 7.0(.NET Core 3.1 위에 있음)을 실행하는 경우 FictionalTools로드하고 실행한 다음, 문제 없이 FilesystemManager 로드하고 실행할 수 있습니다. 그러나 새 세션에서 FilesystemManager로드하고 실행한 다음 FictionalTools로드하면 로드된 FileLoadException 최신 버전이 필요하기 때문에 FictionalTools 명령에서 Microsoft.Extensions.Logging 가져옵니다. 동일한 이름의 어셈블리가 이미 로드되었으므로 FictionalTools 필요한 버전을 로드할 수 없습니다.

PowerShell 및 .NET

PowerShell은 어셈블리 종속성을 확인하고 로드하는 .NET 플랫폼에서 실행됩니다. 종속성 충돌을 이해하려면 .NET이 여기서 작동하는 방식을 이해해야 합니다.

또한 다른 버전의 PowerShell이 다른 .NET 구현에서 실행된다는 사실에 직면해야 합니다. 일반적으로 PowerShell 5.1 이하는 .NET Framework에서 실행되고 PowerShell 6 이상은 .NET Core에서 실행됩니다. .NET의 이러한 두 구현은 어셈블리를 다르게 로드하고 처리합니다. 즉, 종속성 충돌 해결은 기본 .NET 플랫폼에 따라 달라질 수 있습니다.

어셈블리 로드 컨텍스트

.NET에서 ALC(어셈블리 로드 컨텍스트)는 어셈블리가 로드되는 런타임 네임스페이스입니다. 어셈블리의 이름은 고유해야 합니다. 이 개념을 사용하면 각 ALC에서 어셈블리를 이름으로 고유하게 확인할 수 있습니다.

.NET에서 어셈블리 참조 로드

어셈블리 로드의 의미 체계는 .NET 구현(.NET Core 및 .NET Framework) 및 특정 어셈블리를 로드하는 데 사용되는 .NET API 모두에 따라 달라집니다. 여기에서 자세히 설명하는 대신 추가 읽기 섹션에 .NET 어셈블리 로드가 각 .NET 구현에서 작동하는 방식에 대해 자세히 설명하는 링크가 있습니다.

이 문서에서는 다음 메커니즘을 참조합니다.

  • .NET이 .NET 코드의 정적 어셈블리 참조에서 이름으로 어셈블리를 암시적으로 로드하려고 할 때 암시적 어셈블리 로드(효과적으로 Assembly.Load(AssemblyName)).
  • Assembly.LoadFrom()로드된 DLL의 종속성을 해결하기 위해 처리기를 추가하는 플러그 인 지향 로드 API입니다. 이 메서드는 종속성을 원하는 방식으로 해결하지 못할 수 있습니다.
  • Assembly.LoadFile()요청된 어셈블리만 로드하기 위한 기본 로드 API이며 종속성을 처리하지 않습니다.

.NET Framework와 .NET Core의 차이점

이러한 API의 작동 방식은 .NET Core와 .NET Framework 간에 미묘한 방식으로 변경되었으므로 포함된 링크읽어볼 가치가 있습니다. 중요한 것은 어셈블리 로드 컨텍스트 및 기타 어셈블리 확인 메커니즘이 .NET Framework와 .NET Core 간에 변경된 것입니다.

특히 .NET Framework에는 다음과 같은 기능이 있습니다.

  • 컴퓨터 전체 어셈블리 확인을 위한 전역 어셈블리 캐시
  • 어셈블리 격리를 위해 In-Process 샌드박스처럼 작동하지만 경합할 serialization 계층도 제공하는 애플리케이션 도메인
  • 각각 고유한 동작이 있는 고정된 어셈블리 로드 컨텍스트 집합이 있는 제한된 어셈블리 로드 컨텍스트 모델:
    • 어셈블리가 기본적으로 로드되는 기본 로드 컨텍스트
    • 런타임에 수동으로 어셈블리를 로드하기 위한 로드-원본 컨텍스트
    • 어셈블리를 안전하게 로드하여 메타데이터를 실행하지 않고 읽기 위한 리플렉션 전용 컨텍스트
    • Assembly.LoadFile(string path) 사용하여 로드된 어셈블리가 Assembly.Load(byte[] asmBytes) 있는 신비한 공허

자세한 내용은 어셈블리 로드대한 모범 사례를 참조하세요.

.NET Core(및 .NET 5 이상)는 이러한 복잡성을 더 간단한 모델로 대체했습니다.

  • 전역 어셈블리 캐시가 없습니다. 애플리케이션은 모든 자체 종속성을 가져옵니다. 이렇게 하면 애플리케이션의 종속성 확인에 대한 외부 요소가 제거되어 종속성 확인이 더 재현 가능해 줍니다. 플러그 인 호스트인 PowerShell은 모듈에 대해 약간 복잡합니다. $PSHOME 종속성은 모든 모듈과 공유됩니다.
  • 하나의 애플리케이션 도메인만 있고 새 도메인을 만들 수 없습니다. 애플리케이션 도메인 개념은 .NET 프로세스의 전역 상태로 유지 관리됩니다.
  • 확장 가능한 새로운 ALC(어셈블리 로드 컨텍스트) 모델입니다. 어셈블리 해상도는 새 ALC에 배치하여 이름을 지정할 수 있습니다. .NET 프로세스는 모든 어셈블리가 로드되는 단일 기본 ALC로 시작합니다(Assembly.LoadFile(string)Assembly.Load(byte[])로드된 경우 제외). 그러나 프로세스는 자체 로드 논리를 사용하여 자체 사용자 지정 ALC를 만들고 정의할 수 있습니다. 어셈블리가 로드될 때 로드된 첫 번째 ALC는 해당 종속성을 확인하는 역할을 합니다. 이렇게 하면 강력한 .NET 플러그 인 로드 메커니즘을 구현할 수 있는 기회가 생깁니다.

두 구현에서 어셈블리는 지연 로드됩니다. 즉, 형식이 필요한 메서드가 처음으로 실행될 때 로드됩니다.

예를 들어 서로 다른 시간에 종속성을 로드하는 동일한 코드의 두 가지 버전은 다음과 같습니다.

종속성 참조가 메서드 내에 어휘적으로 존재하기 때문에 Program.GetRange() 호출되면 첫 번째 종속성이 항상 로드됩니다.

using Dependency.Library;

public static class Program
{
    public static List<int> GetRange(int limit)
    {
        var list = new List<int>();
        for (int i = 0; i < limit; i++)
        {
            if (i >= 20)
            {
                // Dependency.Library will be loaded when GetRange is run
                // because the dependency call occurs directly within the method
                DependencyApi.Use();
            }

            list.Add(i);
        }
        return list;
    }
}

두 번째는 메서드를 통한 내부 간접 참조로 인해 limit 매개 변수가 20 이상인 경우에만 종속성을 로드합니다.

using Dependency.Library;

public static class Program
{
    public static List<int> GetNumbers(int limit)
    {
        var list = new List<int>();
        for (int i = 0; i < limit; i++)
        {
            if (i >= 20)
            {
                // Dependency.Library is only referenced within
                // the UseDependencyApi() method,
                // so will only be loaded when limit >= 20
                UseDependencyApi();
            }

            list.Add(i);
        }
        return list;
    }

    private static void UseDependencyApi()
    {
        // Once UseDependencyApi() is called, Dependency.Library is loaded
        DependencyApi.Use();
    }
}

이는 메모리 및 파일 시스템 I/O를 최소화하고 리소스를 보다 효율적으로 사용하기 때문에 좋은 방법입니다. 이로 인한 부작용은 어셈블리를 로드하려고 하는 코드 경로에 도달할 때까지 어셈블리가 로드되지 않는다는 것입니다.

어셈블리 부하 충돌에 대한 타이밍 조건을 만들 수도 있습니다. 동일한 프로그램의 두 부분이 동일한 어셈블리의 다른 버전을 로드하려고 하면 로드되는 버전은 먼저 실행되는 코드 경로에 따라 달라집니다.

PowerShell의 경우 다음과 같은 요소가 어셈블리 부하 충돌에 영향을 줄 수 있음을 의미합니다.

  • 어떤 모듈이 먼저 로드되었나요?
  • 종속성 라이브러리를 사용하는 코드 경로가 실행되었나요?
  • PowerShell은 시작 시 또는 특정 코드 경로에서만 충돌하는 종속성을 로드하나요?

빠른 수정 및 해당 제한 사항

경우에 따라 모듈을 약간 조정하고 최소한의 노력으로 문제를 해결할 수 있습니다. 그러나 이러한 솔루션에는 주의해야 할 사항이 있습니다. 모듈에 적용할 수 있지만 모든 모듈에 대해 작동하지는 않습니다.

종속성 버전 변경

종속성 충돌을 방지하는 가장 간단한 방법은 종속성에 동의하는 것입니다. 이 작업은 다음과 같은 경우에 발생할 수 있습니다.

  • 충돌은 모듈의 직접 종속성과 관련이 있으며 버전을 제어합니다.
  • 충돌이 간접 종속성과 관련이 있지만 작업 가능한 간접 종속성 버전을 사용하도록 직접 종속성을 구성할 수 있습니다.
  • 충돌하는 버전을 알고 있으며 변경되지 않는 버전에 의존할 수 있습니다.

Newtonsoft.Json 패키지는 이 마지막 시나리오의 좋은 예입니다. 이는 PowerShell 6 이상의 종속성이며 Windows PowerShell에서 사용되지 않습니다. 버전 관리 충돌을 해결하는 간단한 방법은 대상으로 지정할 PowerShell 버전에서 가장 낮은 버전의 Newtonsoft.Json 대상으로 지정하는 것입니다.

예를 들어 PowerShell 6.2.6 및 PowerShell 7.0.2는 현재 Newtonsoft.Json 버전 12.0.3을 사용합니다. Windows PowerShell, PowerShell 6 및 PowerShell 7을 대상으로 하는 모듈을 만들려면 Newtonsoft.Json 12.0.3 종속성으로 대상으로 지정하고 빌드된 모듈에 포함합니다. 모듈이 PowerShell 6 또는 7에 로드되면 PowerShell의 자체 Newtonsoft.Json 어셈블리가 이미 로드됩니다. 모듈에 필요한 버전이므로 해결에 성공합니다. Windows PowerShell에서는 어셈블리가 PowerShell에 아직 없으므로 모듈 폴더에서 로드됩니다.

일반적으로 Microsoft.PowerShell.Sdk 또는 System.Management.Automation같은 구체적인 PowerShell 패키지를 대상으로 하는 경우 NuGet은 필요한 올바른 종속성 버전을 확인할 수 있어야 합니다. 여러 프레임워크를 대상으로 지정하거나 PowerShellStandard.Library중에서 선택해야 하므로 Windows PowerShell과 PowerShell 6 이상을 모두 대상으로 지정하는 것이 더 어려워집니다.

일반적인 종속성 버전에 고정이 작동하지 않는 경우는 다음과 같습니다.

  • 충돌은 간접 종속성과 관련이 있으며 공통 버전을 사용하도록 종속성을 구성할 수 없습니다.
  • 다른 종속성 버전은 자주 변경되기 때문에 일반적인 버전에 정착하는 것은 단기적인 수정일 뿐입니다.

프로세스에서 종속성 사용

이 솔루션은 모듈 작성자보다 모듈 사용자에게 더 적합합니다. 이는 기존 종속성 충돌로 인해 작동하지 않는 모듈에 직면했을 때 사용할 솔루션입니다.

동일한 어셈블리의 두 버전이 동일한 .NET 프로세스에 로드되기 때문에 종속성 충돌이 발생합니다. 간단한 솔루션은 두 프로세스의 기능을 함께 사용할 수 있는 한 다른 프로세스에 로드하는 것입니다.

PowerShell에서는 다음과 같은 여러 가지 방법으로 이 작업을 수행할 수 있습니다.

  • 하위 프로세스로 PowerShell 호출

    현재 프로세스에서 PowerShell 명령을 실행하려면 명령 호출을 사용하여 직접 새 PowerShell 프로세스를 시작합니다.

    pwsh -c 'Invoke-ConflictingCommand'
    

    여기서 주요 제한 사항은 결과를 재구성하는 것이 다른 옵션보다 더 까다롭거나 오류가 발생할 수 있다는 것입니다.

  • PowerShell 작업 시스템

    또한 PowerShell 작업 시스템은 새 PowerShell 프로세스로 명령을 보내고 결과를 반환하여 명령이 프로세스에서 실행됩니다.

    $result = Start-Job { Invoke-ConflictingCommand } | Receive-Job -Wait
    

    이 경우 변수와 상태가 올바르게 전달되었는지 확인해야 합니다.

    작은 명령을 실행할 때 작업 시스템이 약간 번거로울 수도 있습니다.

  • PowerShell 원격

    이 기능을 사용할 수 있는 경우 PowerShell 원격은 명령을 프로세스 외부로 실행하는 유용한 방법이 될 수 있습니다. 원격을 사용하면 새 프로세스에서 새 PSSession 만들고, PowerShell 원격을 통해 해당 명령을 호출한 다음, 충돌하는 종속성이 포함된 다른 모듈에서 로컬로 결과를 사용할 수 있습니다.

    예제는 다음과 같습니다.

    # Create a local PowerShell session
    # where the module with conflicting assemblies will be loaded
    $s = New-PSSession
    
    # Import the module with the conflicting dependency via remoting,
    # exposing the commands locally
    Import-Module -PSSession $s -Name ConflictingModule
    
    # Run a command from the module with the conflicting dependencies
    Invoke-ConflictingCommand
    
  • Windows PowerShell에 대한 암시적 원격

    PowerShell 7의 또 다른 옵션은 -UseWindowsPowerShellImport-Module 플래그를 사용하는 것입니다. 그러면 로컬 원격 세션을 통해 모듈을 Windows PowerShell로 가져옵니다.

    Import-Module -Name ConflictingModule -UseWindowsPowerShell
    

    모듈이 Windows PowerShell과 호환되지 않거나 다르게 작동할 수 있습니다.

Out-of-process 호출을 사용하지 않아야 하는 경우

모듈 작성자로서 out-of-process 명령 호출은 모듈로 굽기 어렵고 문제를 일으키는 에지 사례가 있을 수 있습니다. 특히 모듈이 작동해야 하는 모든 환경에서 원격 및 작업을 사용할 수 없습니다. 그러나 구현을 프로세스 외부로 이동하고 PowerShell 모듈을 더 얇은 클라이언트로 허용하는 일반적인 원칙은 여전히 적용할 수 있습니다.

모듈 사용자는 Out-of-process 호출이 작동하지 않는 경우가 있습니다.

  • PowerShell 원격 사용이 불가능한 경우, 그 이유는 사용 권한이 없거나 사용이 활성화되지 않았기 때문일 수 있습니다.
  • 출력에서 메서드 또는 다른 명령에 대한 입력으로 특정 .NET 형식이 필요한 경우 PowerShell 원격을 통해 실행되는 명령은 강력한 형식의 .NET 개체가 아닌 역직렬화된 개체를 내보낸다. 즉, 메서드 호출 및 강력한 형식의 API는 원격을 통해 가져온 명령의 출력에서 작동하지 않습니다.

보다 강력한 솔루션

이전 솔루션에는 모두 작동하지 않는 시나리오와 모듈이 있었습니다. 그러나 올바르게 구현하는 것이 비교적 간단하다는 장점도 있습니다. 다음 솔루션은 더 강력하지만 올바르게 구현하려면 더 많은 노력이 필요하며 신중하게 작성되지 않은 경우 미묘한 버그가 발생할 수 있습니다.

.NET Core 어셈블리 로드 컨텍스트를 통해 로드

ALC(어셈블리 로드 컨텍스트)는 특히 동일한 어셈블리의 여러 버전을 동일한 런타임에 로드해야 하는 필요성을 해결하기 위해 .NET Core 1.0에 도입되었습니다.

.NET 내에서 충돌하는 어셈블리 버전을 로드하는 문제에 대한 가장 강력한 솔루션을 제공합니다. 그러나 사용자 지정 ALC는 .NET Framework에서 사용할 수 없습니다. 즉, 이 솔루션은 PowerShell 6 이상에서만 작동합니다.

현재 PowerShell에서 종속성 격리를 위해 ALC를 사용하는 가장 좋은 예는 Visual Studio Code용 PowerShell 확장의 언어 서버인 PowerShell Editor Services에 있습니다. ALC는 사용하여 PowerShell 편집기 서비스의 자체 종속성이 PowerShell 모듈의 종속성과 충돌하지 않도록 방지합니다.

ALC를 사용하여 모듈 종속성 격리를 구현하는 것은 개념적으로 어렵지만 최소한의 예제를 통해 작업하겠습니다. PowerShell 7에서만 작동하도록 의도된 간단한 모듈이 있다고 상상해 보십시오. 소스 코드는 다음과 같이 구성됩니다.

+ AlcModule.psd1
+ src/
    + TestAlcModuleCommand.cs
    + AlcModule.csproj

cmdlet 구현은 다음과 같습니다.

using Shared.Dependency;

namespace AlcModule
{
    [Cmdlet(VerbsDiagnostic.Test, "AlcModule")]
    public class TestAlcModuleCommand : Cmdlet
    {
        protected override void EndProcessing()
        {
            // Here's where our dependency gets used
            Dependency.Use();
            // Something trivial to make our cmdlet do *something*
            WriteObject("done!");
        }
    }
}

(크게 간소화된) 매니페스트는 다음과 같습니다.

@{
    Author = 'Me'
    ModuleVersion = '0.0.1'
    RootModule = 'AlcModule.dll'
    CmdletsToExport = @('Test-AlcModule')
    PowerShellVersion = '7.0'
}

그리고 csproj 다음과 같습니다.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Shared.Dependency" Version="1.0.0" />
    <PackageReference Include="Microsoft.PowerShell.Sdk" Version="7.0.1" PrivateAssets="all" />
  </ItemGroup>
</Project>

이 모듈을 빌드할 때 생성된 출력에는 다음과 같은 레이아웃이 있습니다.

AlcModule/
  + AlcModule.psd1
  + AlcModule.dll
  + Shared.Dependency.dll

이 예제에서 문제는 가상 충돌 종속성인 Shared.Dependency.dll 어셈블리에 있습니다. 모듈별 버전을 사용할 수 있도록 ALC 뒤에 배치해야 하는 종속성입니다.

다음을 수행하려면 모듈을 다시 엔지니어링해야 합니다.

  • 모듈 종속성은 PowerShell의 ALC가 아닌 사용자 지정 ALC에만 로드되므로 충돌이 없을 수 있습니다. 또한 프로젝트에 종속성을 더 추가하므로 계속 로드하는 코드를 더 이상 추가하지 않으려는 것입니다. 대신 재사용 가능한 제네릭 종속성 확인 논리를 원합니다.
  • 모듈 로드는 PowerShell에서 정상적으로 작동합니다. PowerShell 모듈 시스템에 필요한 Cmdlet 및 기타 형식은 PowerShell의 자체 ALC 내에 정의됩니다.

이러한 두 요구 사항을 중재하려면 모듈을 두 어셈블리로 분리해야 합니다.

  • PowerShell의 모듈 시스템에서 모듈을 올바르게 로드하는 데 필요한 모든 형식의 정의를 포함하는 cmdlet 어셈블리(AlcModule.Cmdlets.dll)입니다. 즉, 사용자 지정 ALC를 통해 Cmdlet 제대로 로드하도록 IModuleAssemblyInitializer 대한 이벤트 처리기를 설정하는 AssemblyLoadContext.Default.Resolving 기본 클래스 및 AlcModule.Engine.dll구현하는 클래스의 구현입니다. PowerShell 7은 다른 ALC에 로드된 어셈블리에 정의된 형식을 의도적으로 숨기므로 PowerShell에 공개적으로 노출되도록 하는 모든 형식도 여기에 정의해야 합니다. 마지막으로 사용자 지정 ALC 정의를 이 어셈블리에 정의해야 합니다. 그 외에도 가능한 한 적은 코드가 이 어셈블리에 있어야 합니다.
  • 모듈의 실제 구현을 처리하는 엔진 어셈블리(AlcModule.Engine.dll)입니다. 이 형식의 형식은 PowerShell ALC에서 사용할 수 있지만 처음에는 사용자 지정 ALC를 통해 로드됩니다. 해당 종속성은 사용자 지정 ALC에만 로드됩니다. 실제로 이는 두 ALC 간의 브리지 됩니다.

이 브리지 개념을 사용하면 새 어셈블리 상황은 다음과 같습니다.

두 ALC 브리징하는 AlcModule.Engine.dll 나타내는 다이어그램

기본 ALC의 종속성 검색 논리가 사용자 지정 ALC에 로드되는 종속성을 해결하지 않도록 하려면 모듈의 두 부분을 서로 다른 디렉터리에 구분해야 합니다. 새 모듈 레이아웃의 구조는 다음과 같습니다.

AlcModule/
  AlcModule.Cmdlets.dll
  AlcModule.psd1
  Dependencies/
  | + AlcModule.Engine.dll
  | + Shared.Dependency.dll

구현이 어떻게 변경되는지 확인하기 위해 AlcModule.Engine.dll구현부터 시작합니다.

using Shared.Dependency;

namespace AlcModule.Engine
{
    public class AlcEngine
    {
        public static void Use()
        {
            Dependency.Use();
        }
    }
}

Shared.Dependency.dll종속성에 대한 간단한 컨테이너이지만 다른 어셈블리의 cmdlet이 PowerShell에 대해 래핑하는 기능에 대한 .NET API라고 생각해야 합니다.

AlcModule.Cmdlets.dll cmdlet은 다음과 같습니다.

// Reference our module's Engine implementation here
using AlcModule.Engine;

namespace AlcModule.Cmdlets
{
    [Cmdlet(VerbsDiagnostic.Test, "AlcModule")]
    public class TestAlcModuleCommand : Cmdlet
    {
        protected override void EndProcessing()
        {
            AlcEngine.Use();
            WriteObject("done!");
        }
    }
}

이 시점에서 AlcModule 로드하고 실행하려는 경우 기본 ALC가 실행하기 위해 로드하려고 할 때 FileNotFoundException 가져옵니다. 이는 기본 ALC가 숨기려는 종속성을 찾을 수 없다는 것을 의미하기 때문에 좋습니다.

이제 AlcModule.Cmdlets.dll해결하는 방법을 알 수 있도록 AlcModule.Engine.dll 코드를 추가해야 합니다. 먼저 사용자 지정 ALC를 정의하여 모듈의 Dependencies 디렉터리에서 어셈블리를 확인해야 합니다.

namespace AlcModule.Cmdlets
{
    internal class AlcModuleAssemblyLoadContext : AssemblyLoadContext
    {
        private readonly string _dependencyDirPath;

        public AlcModuleAssemblyLoadContext(string dependencyDirPath)
        {
            _dependencyDirPath = dependencyDirPath;
        }

        protected override Assembly Load(AssemblyName assemblyName)
        {
            // We do the simple logic here of looking for an assembly of the given name
            // in the configured dependency directory.
            string assemblyPath = Path.Combine(
                _dependencyDirPath,
                $"{assemblyName.Name}.dll");

            if (File.Exists(assemblyPath))
            {
                // The ALC must use inherited methods to load assemblies.
                // Assembly.Load*() won't work here.
                return LoadFromAssemblyPath(assemblyPath);
            }

            // For other assemblies, return null to allow other resolutions to continue.
            return null;
        }
    }
}

그런 다음 애플리케이션 도메인에서 Resolving 이벤트의 ALC 버전인 기본 ALC의 AssemblyResolve 이벤트에 사용자 지정 ALC를 연결해야 합니다. 이 이벤트는 AlcModule.Engine.dll 호출할 때 EndProcessing() 찾기 위해 발생합니다.

namespace AlcModule.Cmdlets
{
    public class AlcModuleResolveEventHandler : IModuleAssemblyInitializer, IModuleAssemblyCleanup
    {
        // Get the path of the dependency directory.
        // In this case we find it relative to the AlcModule.Cmdlets.dll location
        private static readonly string s_dependencyDirPath = Path.GetFullPath(
            Path.Combine(
                Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location),
                "Dependencies"));

        private static readonly AlcModuleAssemblyLoadContext s_dependencyAlc =
            new AlcModuleAssemblyLoadContext(s_dependencyDirPath);

        public void OnImport()
        {
            // Add the Resolving event handler here
            AssemblyLoadContext.Default.Resolving += ResolveAlcEngine;
        }

        public void OnRemove(PSModuleInfo psModuleInfo)
        {
            // Remove the Resolving event handler here
            AssemblyLoadContext.Default.Resolving -= ResolveAlcEngine;
        }

        private static Assembly ResolveAlcEngine(AssemblyLoadContext defaultAlc, AssemblyName assemblyToResolve)
        {
            // We only want to resolve the Alc.Engine.dll assembly here.
            // Because this will be loaded into the custom ALC,
            // all of *its* dependencies will be resolved
            // by the logic we defined for that ALC's implementation.
            //
            // Note that we're safe in our assumption that the name is enough
            // to distinguish our assembly here,
            // since it's unique to our module.
            // There should be no other AlcModule.Engine.dll on the system.
            if (!assemblyToResolve.Name.Equals("AlcModule.Engine"))
            {
                return null;
            }

            // Allow our ALC to handle the directory discovery concept
            //
            // This is where Alc.Engine.dll is loaded into our custom ALC
            // and then passed through into PowerShell's ALC,
            // becoming the bridge between both
            return s_dependencyAlc.LoadFromAssemblyName(assemblyToResolve);
        }
    }
}

새 구현을 사용하여 모듈이 로드되고 Test-AlcModule 실행될 때 발생하는 호출 시퀀스를 살펴봅니다.

사용자 지정 ALC를 사용하여 종속성 로드하는 호출의 시퀀스 다이어그램

관심 지점은 다음과 같습니다.

  • 모듈이 IModuleAssemblyInitializer 이벤트를 로드하고 설정하면 Resolving 먼저 실행됩니다.
  • Test-AlcModule 실행되고 EndProcessing() 메서드가 호출될 때까지 종속성을 로드하지 않습니다.
  • EndProcessing() 호출되면 기본 ALC가 AlcModule.Engine.dll 찾지 못하고 Resolving 이벤트가 발생합니다.
  • 이벤트 처리기는 사용자 지정 ALC를 기본 ALC에 연결하고 AlcModule.Engine.dll 로드합니다.
  • AlcEngine.Use()내에서 AlcModule.Engine.dll 호출되면 사용자 지정 ALC가 다시 시작되어 Shared.Dependency.dll해결합니다. 특히 기본 ALC의 모든 항목과 충돌하지 않으며 디렉터리에서만 표시되므로 항상 로드합니다.

구현을 어셈블하면 새 소스 코드 레이아웃은 다음과 같습니다.

+ AlcModule.psd1
+ src/
  + AlcModule.Cmdlets/
  | + AlcModule.Cmdlets.csproj
  | + TestAlcModuleCommand.cs
  | + AlcModuleAssemblyLoadContext.cs
  | + AlcModuleInitializer.cs
  |
  + AlcModule.Engine/
  | + AlcModule.Engine.csproj
  | + AlcEngine.cs

AlcModule.Cmdlets.csproj는 다음과 같습니다.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\AlcModule.Engine\AlcModule.Engine.csproj" />
    <PackageReference Include="Microsoft.PowerShell.Sdk" Version="7.0.1" PrivateAssets="all" />
  </ItemGroup>
</Project>

AlcModule.Engine.csproj는 다음과 같습니다.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Shared.Dependency" Version="1.0.0" />
  </ItemGroup>
</Project>

따라서 모듈을 빌드할 때 전략은 다음과 같습니다.

  • 빌드 AlcModule.Engine
  • 빌드 AlcModule.Cmdlets
  • AlcModule.Engine 모든 항목을 Dependencies 디렉터리로 복사하고 복사한 내용을 기억합니다.
  • AlcModule.Cmdlets 없는 AlcModule.Engine 모든 항목을 기본 모듈 디렉터리에 복사합니다.

여기서 모듈 레이아웃은 종속성 분리에 매우 중요하므로 원본 루트에서 사용할 빌드 스크립트는 다음과 같습니다.

param(
    # The .NET build configuration
    [ValidateSet('Debug', 'Release')]
    [string]
    $Configuration = 'Debug'
)

# Convenient reusable constants
$mod = "AlcModule"
$netcore = "netcoreapp3.1"
$copyExtensions = @('.dll', '.pdb')

# Source code locations
$src = "$PSScriptRoot/src"
$engineSrc = "$src/$mod.Engine"
$cmdletsSrc = "$src/$mod.Cmdlets"

# Generated output locations
$outDir = "$PSScriptRoot/out/$mod"
$outDeps = "$outDir/Dependencies"

# Build AlcModule.Engine
Push-Location $engineSrc
dotnet publish -c $Configuration
Pop-Location

# Build AlcModule.Cmdlets
Push-Location $cmdletsSrc
dotnet publish -c $Configuration
Pop-Location

# Ensure out directory exists and is clean
Remove-Item -Path $outDir -Recurse -ErrorAction Ignore
New-Item -Path $outDir -ItemType Directory
New-Item -Path $outDeps -ItemType Directory

# Copy manifest
Copy-Item -Path "$PSScriptRoot/$mod.psd1"

# Copy each Engine asset and remember it
$deps = [System.Collections.Generic.Hashtable[string]]::new()
Get-ChildItem -Path "$engineSrc/bin/$Configuration/$netcore/publish/" |
    Where-Object { $_.Extension -in $copyExtensions } |
    ForEach-Object { [void]$deps.Add($_.Name); Copy-Item -Path $_.FullName -Destination $outDeps }

# Now copy each Cmdlets asset, not taking any found in Engine
Get-ChildItem -Path "$cmdletsSrc/bin/$Configuration/$netcore/publish/" |
    Where-Object { -not $deps.Contains($_.Name) -and $_.Extension -in $copyExtensions } |
    ForEach-Object { Copy-Item -Path $_.FullName -Destination $outDir }

마지막으로, 시간이 지남에 따라 더 많은 종속성이 추가됨에 따라 강력한 상태로 유지되는 어셈블리 부하 컨텍스트에서 모듈의 종속성을 격리하는 일반적인 방법을 살펴보겠습니다.

자세한 예제를 보려면 이 GitHub 리포지토리. 이 예제에서는 .NET Framework에서 해당 모듈을 계속 작동하면서 ALC를 사용하도록 모듈을 마이그레이션하는 방법을 보여 줍니다. 또한 .NET Standard 및 PowerShell Standard를 사용하여 핵심 구현을 간소화하는 방법도 보여 줍니다.

이 솔루션은 Bicep PowerShell 모듈사용되며, PowerShell 모듈 충돌 해결 블로그 게시물은 이 솔루션에 대한 또 다른 좋은 내용입니다.

병렬 로드를 위한 어셈블리 확인 처리기

강력하지만 위에서 설명한 솔루션은 모듈 어셈블리가 종속성 어셈블리를 직접 참조하지 않고 종속성 어셈블리를 참조하는 래퍼 어셈블리를 참조해야 합니다. 래퍼 어셈블리는 브리지처럼 작동하여 모듈 어셈블리에서 종속성 어셈블리로 호출을 전달합니다. 이렇게 하면 일반적으로 이 솔루션을 채택하는 작업이 간단하지 않습니다.

  • 새 모듈의 경우 디자인 및 구현에 복잡성이 더해집니다.
  • 기존 모듈의 경우 상당한 리팩터링이 필요합니다.

사용자 지정 Resolving 인스턴스와 AssemblyLoadContext 이벤트를 연결하여 병렬 어셈블리 로드를 달성하는 간소화된 솔루션이 있습니다. 모듈 작성자에게는 이 메서드를 사용하는 것이 더 쉽지만 두 가지 제한 사항이 있습니다. 이 솔루션에 대한 이러한 제한 사항 및 자세한 시나리오를 설명하는 샘플 코드 및 설명서는 PowerShell-ALC-Samples 리포지토리를 확인하세요.

중요하다

종속성 격리 용도로 사용하지 Assembly.LoadFile 마세요. Assembly.LoadFile 사용하면 다른 모듈이 동일한 어셈블리의 다른 버전을 기본 로드할 때 AssemblyLoadContext 문제가 발생합니다. 이 API는 어셈블리를 별도의 AssemblyLoadContext 인스턴스로 로드하지만 로드된 어셈블리는 PowerShell의 형식 확인 코드검색할 수 있습니다. 따라서 두 개의 다른 ALC에서 사용할 수 있는 완전히 qualifed 형식 이름이 동일한 중복 형식이 있을 수 있습니다.

사용자 지정 애플리케이션 도메인

어셈블리 격리에 대한 최종적이고 가장 극단적인 옵션은 사용자 지정 애플리케이션 도메인사용하는 것입니다. 애플리케이션 도메인 .NET Framework에서만 사용할 수 있습니다. .NET 애플리케이션의 일부 간에 프로세스 내 격리를 제공하는 데 사용됩니다. 사용 중 하나는 동일한 프로세스 내에서 어셈블리 로드를 서로 격리하는 것입니다.

그러나애플리케이션 도메인은 직렬화 경계입니다. 한 애플리케이션 도메인의 개체는 다른 애플리케이션 도메인의 개체에서 직접 참조하고 사용할 수 없습니다. MarshalByRefObject구현하여 이 작업을 수행할 수 있습니다. 그러나 종속성이 있는 경우처럼 형식을 제어하지 않으면 여기서 구현을 강제 적용할 수 없습니다. 유일한 해결 방법은 아키텍처를 크게 변경하는 것입니다. 직렬화 경계에는 심각한 성능 영향도 있습니다.

애플리케이션 도메인에는 이러한 심각한 제한이 구현하기가 복잡하며 .NET Framework에서만 작동하기 때문에 여기서 사용할 수 있는 방법에 대한 예제는 제공하지 않습니다. 가능성으로 언급할 가치가 있지만 권장되지는 않습니다.

사용자 지정 애플리케이션 도메인을 사용하려는 경우 다음 링크가 도움이 될 수 있습니다.

  • 애플리케이션 도메인 대한 개념 설명서
  • 애플리케이션 도메인 사용하기 위한 예제

PowerShell에서 작동하지 않는 종속성 충돌에 대한 솔루션

마지막으로, .NET에서 .NET 종속성 충돌을 조사할 때 발생할 수 있는 몇 가지 가능성을 다루겠습니다. 이는 유망해 보일 수 있지만 일반적으로 PowerShell에서는 작동하지 않습니다.

이러한 솔루션에는 애플리케이션 및 전체 컴퓨터를 제어하는 환경에 대한 배포 구성이 변경된다는 공통된 테마가 있습니다. 이러한 솔루션은 웹 서버 및 서버 환경에 배포된 다른 애플리케이션과 같은 시나리오를 지향합니다. 여기서 환경은 애플리케이션을 호스트하기 위한 것이며 배포하는 사용자가 자유롭게 구성할 수 있습니다. 또한 .NET Framework 지향성이 매우 높기 때문에 PowerShell 6 이상에서는 작동하지 않습니다.

모듈이 완전히 제어할 수 있는 Windows PowerShell 5.1 환경에서만 사용된다는 것을 알고 있는 경우 이들 중 일부는 옵션일 수 있습니다. 그러나 일반적으로 모듈은 이같은 전역 컴퓨터 상태를 수정해서는 안 됩니다. powershell.exe, 다른 모듈 또는 모듈이 예기치 않은 방식으로 실패하는 다른 종속 애플리케이션에서 문제를 일으키는 구성을 중단시킬 수 있습니다.

동일한 종속성 버전을 강제로 사용하도록 app.config 정적 바인딩 리디렉션

.NET Framework 애플리케이션은 app.config 파일을 활용하여 일부 애플리케이션 동작을 선언적으로 구성할 수 있습니다. 어셈블리 로드를 특정 버전으로 리디렉션하도록 어셈블리 바인딩을 구성하는 app.config 항목을 작성할 수 있습니다.

PowerShell에 대한 두 가지 문제는 다음과 같습니다.

  • .NET Core는 app.config지원하지 않으므로 이 솔루션은 powershell.exe만 적용됩니다.
  • powershell.exe System32 디렉터리에 있는 공유 애플리케이션입니다. 모듈이 많은 시스템에서 해당 콘텐츠를 수정할 수 없을 가능성이 높습니다. 가능하더라도 app.config 수정하면 기존 구성이 중단되거나 다른 모듈의 로드에 영향을 줄 수 있습니다.

app.config 사용하여 codebase 설정

동일한 이유로 PowerShell 모듈에서 codebase 설정을 app.config하려고 하면 작동하지 않을 것입니다.

GAC(전역 어셈블리 캐시)에 종속성 설치

.NET Framework에서 종속성 버전 충돌을 해결하는 또 다른 방법은 GAC에 종속성을 설치하여 GAC에서 여러 버전을 나란히 로드할 수 있도록 하는 것입니다.

PowerShell 모듈의 경우 다음과 같은 주요 문제가 있습니다.

  • GAC는 .NET Framework에만 적용되므로 PowerShell 6 이상에서는 도움이 되지 않습니다.
  • GAC에 어셈블리를 설치하는 것은 전역 컴퓨터 상태를 수정한 것이며 다른 애플리케이션 또는 다른 모듈에 부작용을 일으킬 수 있습니다. 모듈에 필요한 액세스 권한이 있는 경우에도 올바르게 수행하기가 어려울 수 있습니다. 잘못되면 다른 .NET 애플리케이션에서 심각한 컴퓨터 차원의 문제가 발생할 수 있습니다.

추가 읽기

.NET 어셈블리 버전 종속성 충돌에서 더 많은 내용을 읽을 수 있습니다. 다음은 포인트에서 몇 가지 좋은 점프입니다 :