C#(CSharp)에서 PowerShell을 실행하고 결과를 객체로 받는 방법

· 업데이트: · · C#, CSharp, PowerShell, Windows, .NET, 자동화, 기존 자산 활용

수정 이력(10건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
기사 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건)에 대응해 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
비동기로 중지한 파이프라인을, 완료를 기다리지 않고 해제하던 문제를 고쳤습니다. `await running`이 반환되는 것과 `BeginStop`으로 시작한 중지가 끝나는 것은 따로 일어나기 때문에, `using PowerShell` 그대로 빠져나오면 `Dispose` 뒤에 `EndStop`이 이미 해제된 인스턴스에 접근합니다. 게다가 그 지점은 스레드 풀 콜백이라, 던진 예외를 받을 곳이 없습니다. `using`을 그만두고 `try`/`finally`로 바꾼 뒤, 중지를 `Task.Factory.FromAsync`로 `Task`로 만든 다음 `await`하는 형태로 바꿨습니다(`EndStop`의 예외도 그 `Task`에 들어가므로 스레드 풀에서 새어 나오지 않습니다). 7.x용과 5.1용 예시를 같은 형태로 맞췄습니다.
Windows PowerShell 5.1용 폴백 예시에서, 취소 등록이 파이프라인 시작보다 앞에 있던 문제를 고쳤습니다. 이전에 `CancellationToken`을 추가할 때 `Task.Run` 안에서 「이미 취소됐는지 확인하기 → 등록하기 → `Invoke()`」 순으로 작성했기 때문에, 등록과 시작 사이에 들어온 취소가 무시됩니다. 아직 돌지 않는 파이프라인으로 `BeginStop`이 날아가고, 그다음 `Invoke()`가 시작된 뒤 중지 신호는 이미 소비된 상태가 됩니다. 같은 절의 `InvokeAsync` 예시에서 피한 함정을 그대로 재현하고 있었습니다. 동기 `Invoke()`에서는 「실행한 뒤에 등록한다」를 쓸 수 없어서 `BeginInvoke`/`EndInvoke`로 나누고 `Task.Factory.FromAsync`로 감싸는 형태로 바꿨습니다.
Windows PowerShell 5.1용 폴백 예시에 취소 수단을 추가했습니다. `Task.Run`으로 동기 `Invoke()`를 감싸기만 하면 UI 스레드는 자유로워지지만, 실행 중인 파이프라인에는 아무도 손을 대지 못합니다. 사용자가 취소를 눌러도, hang된 명령은 뒤에서 계속 실행됩니다. 같은 절 4번째에서 「중지할 수단을 반드시 마련한다」고 쓰면서도, 이 예시만 `PowerShell` 인스턴스를 메서드 안에 숨긴 채였습니다. `CancellationToken`을 받고 `Register`에서 `BeginStop`을 호출하는 형태로 바꿨습니다(동기 `Stop()`은 파이프라인이 멈출 때까지 반환하지 않으므로, Cancel을 누른 UI 스레드를 기다리게 하지 않습니다).
취소 등록을 파이프라인 시작보다 앞에 두던 문제를 고쳤습니다. `Register`는 이미 취소된 토큰이면 그 자리에서 콜백을 실행하므로, 아직 시작하지 않은 파이프라인에 `BeginStop`이 날아가 무효가 되고, 그다음 `InvokeAsync`가 명령을 시작한 뒤 중지 신호는 이미 소비된 상태가 됩니다. 창을 닫아도 명령이 계속 실행됩니다. 먼저 이미 취소됐는지 확인한 뒤, 시작하고 나서 등록하는 순서로 고쳤습니다.
화면 앱 예시에서 `PowerShell` 인스턴스를 메서드 지역 변수에 숨긴 채여서, 호출 쪽이 `Stop()`을 호출할 수단이 없었습니다. 바로 아래에 「중간에 멈추려면 `Stop()`을 호출한다」고 쓰면서도, 샘플에서는 그렇게 구현할 수 없는 상태였습니다. 명령이 hang되거나 사용자가 창을 닫으면, 버튼을 비활성화한 채로 계속 기다리는 화면이 됩니다. `CancellationToken`을 받고, 취소 시 `BeginStop`으로 파이프라인을 멈추는 형태로 바꿨으며, 중지 버튼과 창을 닫을 때의 호출 쪽도 추가했습니다. `PipelineStoppedException`은 실패가 아니라 정상적인 경로로 잡는다는 점도 명시했습니다.
`Where-Object`의 축약 구문을 매개변수 할당으로 조립하는 예시를 고쳤습니다. `-EQ`는 값을 받지 않는 스위치이고, 비교할 값은 `-Value`에 전달합니다. `AddParameter("EQ", "Running")`처럼 쓰면 할당 단계에서 실패하고, 필터가 동작하지 않습니다.
화면 앱에서 UI 스레드를 막지 않는 구현 예시를 `InvokeAsync`로 다시 썼습니다(이중 실행 방지와 `Stop()`으로의 취소를 포함합니다). 동시 실행과 최초 기동 비용 절을 새로 두고, 실행 중인 인스턴스에 다시 Invoke하면 예외가 난다는 점과 `RunspacePool`로 병렬 실행하는 내용을 추가했습니다. .NET Framework용으로 쓸 때의 차이도 표로 정리했습니다.
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635298)

이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.

Go Komura (2026). 「C#(CSharp)에서 PowerShell을 실행하고 결과를 객체로 받는 방법」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635298 https://comcomponent.com/ko/blog/csharp-run-powershell-receive-objects/

DOI(최신 버전)
10.5281/zenodo.21635298
DOI(이 버전)
10.5281/zenodo.21635299

C#에서 PowerShell을 실행하고 싶은 상황은 업무 애플리케이션이나 사내 도구에서 자주 있습니다. 예를 들면 다음과 같은 처리입니다.

  • Windows의 서비스 목록을 가져온다
  • 프로세스나 이벤트 로그를 조사한다
  • 기존 PowerShell 스크립트를 C# 애플리케이션에서 호출한다
  • 관리자용 작은 GUI 도구에서 PowerShell 명령을 실행한다
  • PowerShell 쪽에 있는 기존 자동화 자산을 .NET 애플리케이션에 조금씩 가져온다

단순히 실행만 하면 된다면, powershell.exepwsh.exe를 외부 프로세스로 시작하고 표준 출력을 문자열로 읽는 방법으로도 동작합니다. 다만 이 방법으로는 PowerShell의 장점인 「객체의 파이프라인」이 사라집니다.

PowerShell의 결과는 원래 단순한 텍스트가 아닙니다. Get-Process의 결과는 프로세스 객체이고, Get-Service의 결과는 서비스 객체입니다. C# 쪽에서도 그 구조를 유지한 채로 받을 수 있으면 문자열 파싱이 필요 없어지고, 처리가 훨씬 안전해집니다.

이 글에서는 C#에서 PowerShell을 실행하고 결과를 PSObject로 받는 기본을 정리합니다.

참고로, 이 글에 나오는 코드는 빌드·실행할 수 있는 샘플 일체(실행 래퍼와 변환 처리 라이브러리, 글의 각 장을 시연하는 콘솔 데모, PSObject 수신과 오류 처리를 검증하는 유닛 테스트)로 GitHub에 공개하고 있습니다.

csharp-run-powershell-receive-objects - komurasoft-blog-samples (GitHub)

1. 외부 프로세스 시작이 아니라 PowerShell SDK를 사용한다

C#에서 PowerShell을 호출하는 방법은 크게 두 가지입니다.

방법 특징 맞는 상황
ProcessStartInfopowershell.exe / pwsh.exe를 시작한다 표준 출력·표준 오류를 문자열로 읽는다 기존 배치의 단순 실행, 로그만 남기는 처리
System.Management.Automation.PowerShell을 사용한다 결과를 PSObject로 받을 수 있다 C# 쪽에서 결과를 가공하는 처리, 관리 도구, 업무 애플리케이션

이 글에서 다루는 것은 후자입니다. System.Management.Automation.PowerShell을 사용하면 PowerShell 파이프라인을 C# 코드에서 조립해 실행할 수 있습니다. 중요한 점은 반환값이 문자열이 아니라, 기본적으로 Collection<PSObject>가 된다는 것입니다.

즉 흐름은 다음과 같습니다.

PowerShell 명령을 실행한다
  ↓
결과를 PSObject 컬렉션으로 받는다
  ↓
BaseObject나 Properties에서 값을 꺼낸다
  ↓
필요하면 C#의 DTO / record / class로 변환한다

PowerShell 출력을 문자열로 분해하는 것이 아니라, 처음부터 객체로 다루는 것이 핵심입니다.

이 글의 지식 맵

C#에서 PowerShell을 호출할 때, 외부 프로세스를 시작해 표준 출력을 문자열로 읽는 방법은 표시 형식에 의존해 깨지기 쉬운 반면, PowerShell SDK를 쓰면 결과를 PSObject 컬렉션으로 받을 수 있고, BaseObject로 원래 .NET 객체를, Properties로 열 이름을 지정한 값을 꺼낼 수 있습니다. C#으로 넘길 때는 Format-Table이 아니라 Select-Object나 [pscustomobject]로 열을 맞춰야 하며, 사용자 입력은 AddScript에 문자열 연결하지 말고 AddCommand/AddParameter로 매개변수 값으로 넘겨 코드 주입을 피합니다. 화면 앱에서는 InvokeAsync로 비동기 실행하되, CancellationToken 등록을 파이프라인 시작보다 뒤에 하면 중지 요청이 헛돌게 됩니다. 실행 중인 동일 인스턴스에 다시 Invoke하면 예외가 되므로, 병렬 실행에는 RunspacePool을 사용합니다.

C#에서 PowerShell을 PSObject로 실행하는 지식 맵PowerShell SDK를 쓰면 PowerShell 실행 결과를 문자열이 아니라 PSObject로 받을 수 있다는 점, BaseObject와 Properties의 용도 구분, AddParameter로 코드 주입을 피하는 방법, Select-Object와 Format-Table의 용도 구분, InvokeAsync에서의 취소와 RunspacePool에 의한 병렬 실행의 요점을 보여주는 그림이용한다이용한다이용한다이용한다이용한다사용은 비권장권장되는 대응권장되는 대응권장되는 대응원인이 될 수 있다방지한다이용한다양립하지 않는다이용한다이용한다이용한다원인이 될 수 있다권장되는 대응사용은 비권장에서 구성할 수 있다사용은 비권장권장되는 대응이용한다이용한다원인이 될 수 있다방지한다PowerShell SDK(System.Management.Automation.PowerShell)PSObjectAddCommand/AddParameterAddScriptC# 측 결과 가공 처리PSObject.BaseObjectPSObject.PropertiesFormat-Table/Format-ListSelect-Object[pscustomobject]PowerShell 사용자 입력 전달 요건스크립트 주입 리스크PowerShell.InvokeAsyncWindows PowerShell 5.1BeginInvoke/EndInvoke.NET Framework파이프라인 중지 API(Stop/BeginStop/EndStop)PipelineStoppedExceptionRunspacePoolPowerShell 병렬 실행 요건ErrorAction Stop외부 프로세스로서의 PowerShell 기동CancellationToken으로 파이프라인을 안전하게 중지하는 구현 패턴CancellationToken(.NET)파이프라인 기동 전 취소 등록등록과 시작 사이 취소 미적용

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 26건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. 전제 환경

이 글에서는 .NET 8 콘솔 애플리케이션을 예로 듭니다. PowerShell SDK는 버전에 따라 대상 .NET이 다르므로, 프로젝트의 타깃 프레임워크에 맞춰 고릅니다.

2026년 6월 시점에서는, 예를 들어 다음과 같이 생각하면 이해하기 쉽습니다.

C# 앱의 타깃 예로 쓰는 PowerShell SDK 비고
.NET 8 Microsoft.PowerShell.SDK 7.4 계열 .NET 8 앱에서 쓰기 쉽다
.NET 10 Microsoft.PowerShell.SDK 7.6 계열 새 PowerShell SDK를 쓸 때의 후보
.NET Framework Microsoft.PowerShell.5.1.ReferenceAssemblies Windows PowerShell 5.1용. 신규 개발에서는 요건을 확인한다

여기서는 .NET 8 예로 Microsoft.PowerShell.SDK 7.4.16을 사용합니다.

dotnet new console -n PowerShellObjectSample
cd PowerShellObjectSample
dotnet add package Microsoft.PowerShell.SDK --version 7.4.16

.csproj는 예를 들어 다음과 같습니다.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.PowerShell.SDK" Version="7.4.16" />
  </ItemGroup>

</Project>

버전은 고정해 두는 것을 권합니다. PowerShell SDK는 편리하지만, 앱 실행 환경, 대상 .NET, PowerShell 모듈 호환성의 영향을 받습니다. 업무 애플리케이션에서는 「개발 환경에서 동작한 최신 버전」을 그대로 쓰기보다, 동작 확인한 버전을 명시해 두는 편이 안전합니다.

.NET Framework용으로 쓸 때 무엇이 다른가

유지 대상이 .NET Framework의 Windows Forms 앱이나 WPF 앱이고, 그곳에서 PowerShell을 호출하고 싶은 경우도 있습니다. 이 글의 코드 예는 현재 .NET을 전제로 하지만, C# 쪽 작성법은 거의 같습니다. 다른 점은 다음입니다.

관점 현재 .NET + Microsoft.PowerShell.SDK .NET Framework + Microsoft.PowerShell.5.1.ReferenceAssemblies
실행되는 PowerShell 패키지에 포함된 PowerShell 7 계열 Windows에 포함된 Windows PowerShell 5.1
NuGet 패키지의 역할 실체를 포함한다 참조 어셈블리만. 런타임 어셈블리는 OS 쪽
쓸 수 있는 구문과 cmdlet PowerShell 7 계열의 범위 5.1의 범위. ForEach-Object -Parallel, ??, 삼항 연산자 등은 쓸 수 없다
모듈 탐색 위치 PowerShell 7의 $env:PSModulePath Windows PowerShell의 $env:PSModulePath
비동기 실행 InvokeAsync를 쓸 수 있다 BeginInvoke / EndInvoke, 또는 Task.Run으로 Invoke()를 감싼다
C# 쪽 작성법 PowerShell.Create(), AddCommand, AddParameter, Invoke(), Collection<PSObject> 같다
배포 크기 SDK 일체를 포함하므로 크다 OS 쪽을 쓰므로 작다

.csproj는 예를 들어 다음과 같습니다.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net48</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.PowerShell.5.1.ReferenceAssemblies" Version="1.0.0" />
  </ItemGroup>

</Project>

실무에서 가장 크게 작용하는 것은 실행되는 것이 Windows PowerShell 5.1이라는 점입니다. 기존 PowerShell 스크립트를 가져올 때, 그 스크립트가 PowerShell 7로 작성돼 있으면 5.1에서는 구문 오류가 날 수 있습니다. 반대로, Windows PowerShell 5.1에서만 동작하는 오래된 모듈을 쓰고 싶다는 이유로 이쪽을 고르기도 합니다.

참고로, 이 글의 3장부터 12장까지의 코드는 InvokeAsync를 쓰지 않으므로 그대로 .NET Framework 쪽에서도 통합니다. 비동기화 주변만 13장에서 다시 다룹니다.

3. 최소 코드: PowerShell을 실행하고 PSObject를 받는다

우선, 현재 C# 앱 자신의 프로세스를 PowerShell에서 가져와 봅니다.

using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Management.Automation;

int currentProcessId = Environment.ProcessId;

using PowerShell ps = PowerShell.Create();

Collection<PSObject> results = ps
    .AddCommand("Get-Process")
    .AddParameter("Id", currentProcessId)
    .Invoke();

foreach (PSObject item in results)
{
    Console.WriteLine($"PSObject type: {item.GetType().FullName}");
    Console.WriteLine($"BaseObject type: {item.BaseObject.GetType().FullName}");

    if (item.BaseObject is Process process)
    {
        Console.WriteLine($"Id: {process.Id}");
        Console.WriteLine($"Name: {process.ProcessName}");
        Console.WriteLine($"Memory: {process.WorkingSet64:N0} bytes");
    }
}

여기서 짚을 점은 세 가지입니다. PowerShell.Create()로 PowerShell 실행 객체를 만든다는 것, AddCommand("Get-Process")AddParameter("Id", currentProcessId)로 명령과 매개변수를 조립한다는 것, 그리고 Invoke()의 반환값이 Collection<PSObject>라는 것입니다.

PSObject는 PowerShell이 출력하는 값을 감싸는 래퍼입니다. 안에 있는 원래 .NET 객체를 보고 싶으면 BaseObject를 봅니다. 이 예에서는 Get-Process 결과의 내용이 System.Diagnostics.Process로 꺼낼 수 있습니다.

4. BaseObject와 Properties의 구분

C#에서 PowerShell 결과를 다룰 때, 처음에 헷갈리는 것이 다음 두 가지입니다.

item.BaseObject
item.Properties["Name"]?.Value

어느 쪽을 쓸지의 기준은 다음과 같습니다.

꺼내는 방법 쓰는 상황
BaseObject PowerShell이 반환한 원래 .NET 객체를 그대로 쓰고 싶을 때
Properties["..."] Select-Object[pscustomobject]로 만든 열을 꺼내고 싶을 때

Get-Process 같은 명령을 그대로 실행한 경우, BaseObject에 원래 .NET 객체가 들어 있는 경우가 있습니다. 한편 PowerShell 쪽에서 Select-Object로 열을 정리하면, 결과는 PowerShell의 커스텀 객체로 반환되는 경우가 많아집니다. 그때는 Properties에서 열 이름으로 값을 꺼내는 쪽이 자연스럽습니다.

5. Select-Object 결과를 C#에서 읽는다

실무에서는 PowerShell에서 돌아오는 모든 속성이 필요한 경우는 많지 않습니다. 필요한 열만 C# 쪽으로 넘기고 싶을 때는 PowerShell 파이프라인에서 Select-Object를 사용합니다.

using System.Collections.ObjectModel;
using System.Globalization;
using System.Management.Automation;

using PowerShell ps = PowerShell.Create();

Collection<PSObject> rows = ps
    .AddCommand("Get-Process")
    .AddCommand("Sort-Object")
        .AddParameter("Property", "CPU")
        .AddParameter("Descending", true)
    .AddCommand("Select-Object")
        .AddParameter("First", 10)
        .AddParameter("Property", new[] { "Name", "Id", "CPU", "WorkingSet" })
    .Invoke();

foreach (PSObject row in rows)
{
    string name = Convert.ToString(row.Properties["Name"]?.Value, CultureInfo.InvariantCulture) ?? "";
    int id = Convert.ToInt32(row.Properties["Id"]?.Value, CultureInfo.InvariantCulture);
    double? cpu = row.Properties["CPU"]?.Value is null
        ? null
        : Convert.ToDouble(row.Properties["CPU"]!.Value, CultureInfo.InvariantCulture);
    long workingSet = Convert.ToInt64(row.Properties["WorkingSet"]?.Value, CultureInfo.InvariantCulture);

    Console.WriteLine($"{id}: {name}, CPU={cpu}, WorkingSet={workingSet:N0}");
}

이 코드는 PowerShell에서는 다음과 같은 파이프라인에 해당합니다.

Get-Process |
  Sort-Object -Property CPU -Descending |
  Select-Object -First 10 -Property Name, Id, CPU, WorkingSet

C#에서 보면 AddCommand를 이어서 호출해 PowerShell 파이프라인을 만듭니다.

.AddCommand("Get-Process")
.AddCommand("Sort-Object")
.AddCommand("Select-Object")

이렇게 쓰면 앞 명령의 출력이 다음 명령으로 넘어갑니다.

Select-Object로 열을 좁힌 뒤에는 row.Properties["Name"]?.Value처럼 열 이름으로 값을 꺼냅니다.

6. C#의 record로 변환한다

PSObject 그대로 앱 전체에 넘기면, 이후 코드가 PowerShell에 너무 의존합니다. 화면 표시나 업무 처리에 쓴다면 C# 쪽 타입으로 변환하는 편이 다루기 쉬워집니다.

예를 들어 프로세스 정보를 다음 record로 변환합니다.

public sealed record ProcessSummary(
    string Name,
    int Id,
    double? Cpu,
    long WorkingSet);

변환 처리는 다음과 같이 나눠 두면 흐름이 잘 보입니다.

using System.Globalization;
using System.Management.Automation;

static ProcessSummary ToProcessSummary(PSObject row)
{
    string name = GetString(row, "Name");
    int id = GetInt32(row, "Id");
    double? cpu = GetNullableDouble(row, "CPU");
    long workingSet = GetInt64(row, "WorkingSet");

    return new ProcessSummary(name, id, cpu, workingSet);
}

static string GetString(PSObject row, string propertyName)
{
    return Convert.ToString(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture) ?? "";
}

static int GetInt32(PSObject row, string propertyName)
{
    return Convert.ToInt32(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture);
}

static long GetInt64(PSObject row, string propertyName)
{
    return Convert.ToInt64(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture);
}

static double? GetNullableDouble(PSObject row, string propertyName)
{
    object? value = row.Properties[propertyName]?.Value;
    return value is null ? null : Convert.ToDouble(value, CultureInfo.InvariantCulture);
}

쓰는 쪽은 이렇게 됩니다.

List<ProcessSummary> processes = rows
    .Select(ToProcessSummary)
    .ToList();

foreach (ProcessSummary process in processes)
{
    Console.WriteLine($"{process.Id}: {process.Name}");
}

PSObject는 PowerShell과의 경계에서 다루고, 앱 내부에서는 ProcessSummary 같은 일반적인 C# 타입으로 변환합니다.

이렇게 나눠 두면, 나중에 PowerShell 명령을 바꿔도 영향 범위를 작게 유지할 수 있습니다.

7. PSCustomObject를 반환하면 C# 쪽에서 다루기 쉽다

PowerShell 쪽에서 여러 값을 모아 반환하고 싶을 때는 [pscustomobject]를 쓰면 편리합니다.

using System.Collections.ObjectModel;
using System.Management.Automation;

string script = @"
[pscustomobject]@{
    MachineName       = [System.Environment]::MachineName
    PowerShellVersion = $PSVersionTable.PSVersion.ToString()
    CurrentDirectory  = (Get-Location).Path
}
";

using PowerShell ps = PowerShell.Create();

Collection<PSObject> rows = ps
    .AddScript(script, useLocalScope: true)
    .Invoke();

foreach (PSObject row in rows)
{
    Console.WriteLine($"MachineName: {row.Properties["MachineName"]?.Value}");
    Console.WriteLine($"PowerShell:  {row.Properties["PowerShellVersion"]?.Value}");
    Console.WriteLine($"Directory:   {row.Properties["CurrentDirectory"]?.Value}");
}

PowerShell 스크립트 마지막에서 [pscustomobject]를 반환하면, C# 쪽에서는 Properties에서 이름으로 값을 꺼낼 수 있습니다. 복잡한 문자열을 반환해 C# 쪽에서 나누는 것보다 훨씬 안전합니다.

피하고 싶은 예는 다음과 같은 출력입니다.

"$MachineName,$PowerShellVersion,$CurrentDirectory"

이 방법은 겉보기에는 쉽지만, 값 안에 쉼표나 줄바꿈이 들어가면 깨집니다.

PowerShell 쪽에서는 객체를 반환하고, C# 쪽에서는 속성으로 읽습니다. 이 형태로 두면 나중에 열이 늘어나도 확장하기 쉬워집니다.

8. AddScript에 사용자 입력을 직접 넣지 않는다

PowerShell SDK를 쓰는 경우에도, 문자열로 스크립트를 조립하면 위험합니다. 예를 들어 다음과 같은 코드는 피하는 편이 좋습니다.

// 피할 예
string userInputPath = GetPathFromUser();
string script = $"Get-ChildItem -Path '{userInputPath}'";

using PowerShell ps = PowerShell.Create();
ps.AddScript(script).Invoke();

이 작성법에서는 사용자 입력이 PowerShell 코드로 해석될 여지가 있습니다. PowerShell 명령에 값을 넘길 때는 되도록 AddCommandAddParameter를 사용합니다.

string userInputPath = GetPathFromUser();

using PowerShell ps = PowerShell.Create();

Collection<PSObject> files = ps
    .AddCommand("Get-ChildItem")
    .AddParameter("Path", userInputPath)
    .AddParameter("File", true)
    .Invoke();

AddParameter로 넘긴 값은 PowerShell 코드 문자열로 이어 붙여지는 것이 아니라, 매개변수 값으로 처리됩니다.

실무에서는 다음과 같이 구분하는 것이 무난합니다.

작성법 쓰는 곳
AddCommand / AddParameter C# 쪽에서 안전하게 명령을 조립하고 싶을 때
AddScript 고정된 짧은 스크립트를 실행할 때, 기존 스크립트를 불러올 때
문자열을 이어 붙인 AddScript 원칙적으로 피한다. 쓴다면 입력값 검증과 이스케이프를 신중히 한다

PowerShell을 C#에 넣으면 앱 기능으로서 강한 조작을 할 수 있게 됩니다. 편리한 반면, 사용자 입력을 그대로 스크립트화하지 않는다는 선만은 지켜야 합니다.

9. Format-Table은 마지막 화면 표시용. C#에 넘기기 전에는 쓰지 않는다

PowerShell 결과를 C#에서 객체로 받고 싶을 때는 Format-Table이나 Format-List를 기본적으로 쓰지 않습니다.

예를 들어 다음과 같은 PowerShell은 사람이 화면에서 보기에는 편리합니다.

Get-Service | Format-Table Name, Status

그러나 C# 쪽에서 받기 전에 Format-Table을 쓰면, 결과는 서비스 객체가 아니라 화면 표시용 서식 정보가 됩니다. C#에서 다루고 싶을 때는 Select-Object를 사용합니다.

Get-Service | Select-Object Name, Status

C#에서 쓴다면 다음과 같습니다.

using PowerShell ps = PowerShell.Create();

Collection<PSObject> services = ps
    .AddCommand("Get-Service")
    .AddCommand("Select-Object")
        .AddParameter("Property", new[] { "Name", "Status" })
    .Invoke();

생각은 단순합니다.

화면에서 보기 쉽게만 한다 → Format-Table / Format-List
C#에서 후속 처리에 쓴다    → Select-Object / PSCustomObject

이는 PowerShell만 쓸 때도 해당하는 이야기이지만, C#과 연동할 때는 특히 효과가 큽니다.

10. 오류를 받는다

PowerShell에서는 출력과 오류가 별도 스트림입니다. Invoke()의 반환값만 보고 있으면 오류를 놓칠 수 있습니다. 기본 형태는 다음과 같습니다.

using System.Management.Automation;

using PowerShell ps = PowerShell.Create();

Collection<PSObject> output = ps
    .AddCommand("Get-Item")
    .AddParameter("Path", @"C:\no-such-file.txt")
    .Invoke();

if (ps.HadErrors)
{
    foreach (ErrorRecord error in ps.Streams.Error)
    {
        Console.WriteLine($"Error: {error.Exception.Message}");
        Console.WriteLine($"Category: {error.CategoryInfo.Category}");
        Console.WriteLine($"Target: {error.TargetObject}");
    }
}

PowerShell cmdlet에는 처리를 멈추는 오류와, 처리를 계속하는 오류가 있습니다. C# 쪽에서 예외로 다루고 싶을 때는 ErrorActionStop을 지정하는 방법이 있습니다.

using System.Management.Automation;

try
{
    using PowerShell ps = PowerShell.Create();

    Collection<PSObject> output = ps
        .AddCommand("Get-Item")
        .AddParameter("Path", @"C:\no-such-file.txt")
        .AddParameter("ErrorAction", "Stop")
        .Invoke();
}
catch (RuntimeException ex)
{
    Console.WriteLine($"PowerShell failed: {ex.Message}");
}

어느 쪽이 맞는지는 앱의 성격에 따라 다릅니다. 관리 도구에서 「일부 실패해도 목록은 내고 싶다」면 오류 스트림을 모아 화면에 표시하는 편이 맞고, 「실패하면 처리 전체를 멈추고 싶다」면 ErrorAction Stop으로 예외로 다루는 편이 이해하기 쉽습니다.

11. 작은 실행 래퍼를 만든다

PowerShell을 여러 번 호출하는 앱에서는, 매번 같은 오류 처리를 쓰면 흩어집니다. 간단한 래퍼를 마련해 두면 편리합니다.

using System.Management.Automation;

public sealed record PowerShellRunResult(
    IReadOnlyList<PSObject> Output,
    IReadOnlyList<ErrorRecord> Errors);

public static class PowerShellRunner
{
    public static PowerShellRunResult Run(Action<PowerShell> build)
    {
        using PowerShell ps = PowerShell.Create();

        build(ps);

        List<PSObject> output;

        try
        {
            output = ps.Invoke().ToList();
        }
        catch (RuntimeException ex)
        {
            throw new InvalidOperationException($"PowerShell execution failed: {ex.Message}", ex);
        }

        return new PowerShellRunResult(
            Output: output,
            Errors: ps.Streams.Error.ToList());
    }
}

쓰는 쪽은 명령 조립에만 집중할 수 있습니다.

PowerShellRunResult result = PowerShellRunner.Run(ps => ps
    .AddCommand("Get-Service")
    .AddCommand("Where-Object")
        .AddParameter("Property", "Status")
        .AddParameter("EQ", "Running")
    .AddCommand("Select-Object")
        .AddParameter("First", 10)
        .AddParameter("Property", new[] { "Name", "DisplayName", "Status" }));

foreach (PSObject row in result.Output)
{
    Console.WriteLine($"{row.Properties["Name"]?.Value}: {row.Properties["Status"]?.Value}");
}

foreach (ErrorRecord error in result.Errors)
{
    Console.Error.WriteLine(error.Exception.Message);
}

다만 이 예의 Where-Object처럼, PowerShell 고유의 조건 지정을 C#에서 조립하면 조금 읽기 어려워지는 경우가 있습니다. 단순한 명령과 매개변수는 AddCommand / AddParameter로 충분하지만, 복잡한 필터나 집계는 고정 PowerShell 스크립트로 준비하는 편이 읽기 쉬운 경우도 있습니다. 그때에도 외부 입력을 스크립트 문자열에 직접 이어 붙이지 않는 방침은 바뀌지 않습니다.

12. 복잡한 처리는 PowerShell 쪽에서 객체로 맞춘다

C#과 PowerShell을 조합할 때는, 어느 쪽이 무엇을 맡을지를 나누면 설계하기 쉬워집니다.

권하는 분담은 다음과 같습니다.

담당 하는 일
PowerShell Windows나 모듈에 가까운 조작, 기존 스크립트, 관리 명령 실행
C# UI, 입력 검증, 타입 변환, 업무 로직, 저장, API 연동

PowerShell 쪽에서는 최종 출력을 [pscustomobject]로 맞춥니다.

Get-Service |
  Where-Object Status -eq 'Running' |
  Select-Object Name, DisplayName, Status

또는 명시적으로 [pscustomobject]를 만듭니다.

$services = Get-Service | Where-Object Status -eq 'Running'

[pscustomobject]@{
    Count = $services.Count
    Names = $services.Name
}

C# 쪽에서는 돌아온 PSObject의 속성을 읽어 자신의 앱 타입으로 변환합니다.

이 형태로 두면 PowerShell의 세세한 구현을 C# 쪽으로 너무 흘리지 않아도 됩니다.

13. 실무에서 자주 나오는 주의점

C#에서 PowerShell을 실행할 때는 코드가 동작하는 것만으로는 부족합니다. 실무에서는 다음 점을 일찍 확인해 두면 안전합니다.

실행 사용자의 권한

PowerShell은 C# 앱을 실행 중인 사용자의 권한으로 동작합니다. 관리자 권한이 필요한 명령은 일반 사용자로 실행해도 실패합니다. 서비스 조작, 이벤트 로그, 인증서, 레지스트리, Hyper-V, Microsoft 365 관리 계열 모듈 등에서는 권한을 구분해 확인할 필요가 있습니다.

32bit / 64bit의 차이

Windows에서는 32bit 프로세스와 64bit 프로세스가 보는 레지스트리나 모듈이 달라지는 경우가 있습니다. Windows 관리용 도구로 만든다면, 기본적으로는 x64에서 돌리는 전제로 하는 편이 문제를 줄입니다.

실행 환경에 모듈이 있는지

C# 앱에 PowerShell SDK를 넣어도 모든 PowerShell 모듈이 자동으로 들어오지는 않습니다. 예를 들어 특정 제품의 관리 모듈이나 사내 모듈을 쓸 때는, 실행 환경에 그 모듈이 있는지, 어느 경로에서 불러올지를 확인할 필요가 있습니다.

화면 앱에서는 UI 스레드를 막지 않는다

WinForms나 WPF에서 PowerShell을 실행할 때, 무거운 처리를 UI 스레드에서 직접 실행하면 화면이 멈춥니다. 그때는 백그라운드 처리로 실행하고, 완료 후에 UI를 갱신하는 설계로 합니다.

PowerShell SDK에는 비동기 실행 API가 있으므로, 그것을 쓰는 것이 가장 자연스럽습니다. PowerShell.InvokeAsyncTask<PSDataCollection<PSObject>>를 반환하므로 그대로 await할 수 있습니다.

먼저 PowerShell을 호출하는 부분을, UI에 손을 대지 않는 메서드로 잘라 냅니다.

using System.Management.Automation;

// 아래 두 메서드는 창이나 폼 클래스 안에 두는 전제입니다.
//
// cancellationToken을 반드시 받습니다. PowerShell 인스턴스를 이 메서드의
// 지역 변수에 숨긴 채로 두면, 호출 쪽은 Stop()을 호출할 수단이 없습니다.
// 명령이 hang되거나 사용자가 화면을 닫았을 때, await 중인
// 핸들러는 버튼을 비활성화한 채로 계속 기다리게 됩니다
private static async Task<IReadOnlyList<PSObject>> GetRunningServicesAsync(
    CancellationToken cancellationToken)
{
    // using으로 두지 않습니다. 중지 완료를 기다린 뒤 해제해야 하기 때문입니다(후술)
    PowerShell ps = PowerShell.Create();
    Task? stopping = null;

    try
    {
        // `-EQ`는 값을 받지 않는 스위치이고, 비교할 값은 `-Value`에 전달합니다
        // (축약 구문은 `-Property <String> -EQ -Value <Object>`).
        // AddParameter("EQ", "Running")처럼 쓰면 매개변수 할당에서 실패합니다
        ps.AddCommand("Get-Service")
          .AddCommand("Where-Object")
              .AddParameter("Property", "Status")
              .AddParameter("EQ")
              .AddParameter("Value", "Running")
          .AddCommand("Select-Object")
              .AddParameter("Property", new[] { "Name", "DisplayName", "Status" });

        // 시작보다 먼저 등록하지 말 것. Register는 넘긴 토큰이 이미
        // 취소된 상태면 그 자리에서 콜백을 실행합니다. 아직 시작하지
        // 않은 파이프라인에 BeginStop이 날아가 무효가 되고, 그다음 InvokeAsync가
        // 명령을 시작해 중지 신호는 이미 소비된 상태가 됩니다.
        // 창을 닫아도 계속 실행되는 명령이 그렇게 만들어집니다.
        // 먼저 이미 취소됐는지 확인한 뒤, 실행하고, 그다음에 중지 수단을 붙입니다
        cancellationToken.ThrowIfCancellationRequested();

        Task<PSDataCollection<PSObject>> running = ps.InvokeAsync();

        // 취소되면 파이프라인을 멈춥니다. Stop()은 중지가 끝날 때까지 반환하지 않고,
        // 취소는 UI 스레드에서 호출되는 경우가 있으므로 비동기 버전을 사용합니다.
        // 중지 자체를 Task로 받아, 아래 finally에서 완료를 기다립니다
        using (cancellationToken.Register(
            state => Volatile.Write(ref stopping, StopAsync((PowerShell)state!)), ps))
        {
            PSDataCollection<PSObject> output = await running;
            return output.ToList();
        }
    }
    finally
    {
        // 중지를 요청했다면, 그 완료를 기다린 뒤 해제합니다.
        // await running이 반환되는 것과 중지가 끝나는 것은 따로 일어나므로, 여기서
        // 기다리지 않고 Dispose하면, 나중에 도는 EndStop이 이미 해제된
        // PowerShell에 접근합니다. 게다가 그곳은 스레드 풀 콜백이라,
        // 던진 예외를 받을 곳이 없습니다(프로세스 전체가 죽습니다)
        Task? pending = Volatile.Read(ref stopping);
        if (pending is not null)
        {
            try { await pending; }
            catch { /* 중지 쪽 실패로 원래 결과나 예외를 덮지 않는다 */ }
        }

        ps.Dispose();
    }
}

// BeginStop / EndStop을 Task로 감쌉니다. EndStop을 콜백 안에서
// 직접 호출하면, 그 예외가 스레드 풀에서 새어 나옵니다
private static Task StopAsync(PowerShell ps) =>
    Task.Factory.FromAsync(ps.BeginStop, ps.EndStop, null);

호출 쪽은 창이나 폼의 이벤트 핸들러입니다. 여기는 async void여도 됩니다. 이벤트 핸들러는 async void가 허용되는 몇 안 되는 장소입니다.

// WPF 창 클래스에 두는 전제입니다.
// WinForms라면 RoutedEventArgs를 EventArgs로,
// IsEnabled를 Enabled로, ItemsSource를 DataSource로
// 바꿔 읽으면 됩니다.
// 실행 중 취소용. 중지 버튼과 창을 닫을 때 양쪽에서 사용
private CancellationTokenSource? _running;

private async void RunButton_Click(object sender, RoutedEventArgs e)
{
    RunButton.IsEnabled = false;

    using var cts = new CancellationTokenSource();
    _running = cts;

    try
    {
        IReadOnlyList<PSObject> services = await GetRunningServicesAsync(cts.Token);

        ResultList.ItemsSource = services
            .Select(row => row.Properties["Name"]?.Value?.ToString() ?? "")
            .ToList();
    }
    catch (PipelineStoppedException)
    {
        // 중지 버튼이나 창을 닫을 때의 정상적인 경로. 아무것도 내지 않는다
    }
    catch (RuntimeException ex)
    {
        MessageBox.Show($"PowerShell failed: {ex.Message}");
    }
    finally
    {
        _running = null;
        RunButton.IsEnabled = true;
    }
}

private void CancelButton_Click(object sender, RoutedEventArgs e) => _running?.Cancel();

protected override void OnClosed(EventArgs e)
{
    _running?.Cancel();   // 닫았는데 뒤에서 파이프라인이 계속 도는 상태를 만들지 않는다
    base.OnClosed(e);
}

짚어 둘 점은 네 가지입니다.

  1. await에서 돌아온 지점은 WinForms든 WPF든 UI 스레드입니다. 동기화 컨텍스트가 되돌려 주므로 InvokeDispatcher.Invoke는 필요 없습니다.
  2. 실행 중에는 버튼을 비활성화합니다. 비활성화하지 않으면 같은 처리를 이중으로 돌리게 됩니다.
  3. 예외는 RuntimeException으로 받습니다. ErrorActionStop으로 두지 않은 경우에는 오류 스트림도 확인합니다(10장).
  4. 중지할 수단을 반드시 마련합니다. PowerShell 인스턴스를 메서드 지역 변수에 숨긴 채로 두면 호출 쪽은 Stop()을 호출할 수 없습니다. 명령이 hang되거나 사용자가 창을 닫았을 때, 버튼을 비활성화한 채로 계속 기다리는 화면이 됩니다.

중지하면 기다리고 있던 InvokeAsyncPipelineStoppedException을 던집니다. 위와 같이, 이는 실패가 아니라 정상적인 경로로 잡으세요. PowerShell의 중지 API에는 동기 Stop(), 비동기 BeginStop / EndStop, StopAsync가 있습니다(끝의 참고 정보를 참조). UI 스레드에서 멈출 때가 있으므로, 기다리게 하지 않는 비동기 버전을 고릅니다.

그리고 비동기로 멈췄다면, 해제하기 전에 그 완료를 기다리세요. await running이 반환되는 것과 BeginStop으로 시작한 중지가 끝나는 것은 따로 일어납니다. using PowerShell ps 그대로 빠져나오면 Dispose 뒤에 EndStop이 돌고, 이미 해제된 인스턴스에 접근합니다. 게다가 그곳은 스레드 풀 콜백이라, 던진 예외를 받을 try가 어디에도 없습니다 ── 앱이 종료 직전에, 원인 불명으로 죽습니다. 위 코드에서 using을 그만두고 try / finally로 바꾼 뒤, 중지를 Task로 가진 다음 await하는 것은 이 때문입니다. Task.Factory.FromAsync로 감싸면 EndStop의 예외도 그 Task에 들어가, 스레드 풀에서 새어 나오지 않습니다.

Windows PowerShell 5.1용으로 작성해 InvokeAsync를 쓸 수 없는 경우에는 BeginInvoke / EndInvoke로 나눕니다. Task.Run으로 동기 Invoke()를 감싸는 작성법도 있지만, 그것은 취소를 버릴 때로 한정입니다. Task.Run은 UI 스레드를 풀어 줄 뿐, 실행 중인 파이프라인에는 아무도 손을 대지 못합니다. 사용자가 「취소」를 눌러도, hang된 명령은 뒤에서 계속 실행됩니다.

using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;

private static async Task<IReadOnlyList<PSObject>> GetRunningServicesLegacyAsync(
    CancellationToken cancellationToken)
{
    PowerShell ps = PowerShell.Create();
    Task? stopping = null;

    try
    {
        ps.AddCommand("Get-Service")
          .AddCommand("Select-Object")
              .AddParameter("Property", new[] { "Name", "Status" });

        // 순서와 뒷정리도 위의 InvokeAsync 예와 완전히 같습니다.
        // 먼저 이미 취소됐는지 확인하고, 실행한 뒤, 그다음에 중지 수단을 붙입니다.
        // 동기 Invoke()에서는 「실행한 뒤에 등록한다」를 쓸 수 없으므로,
        // BeginInvoke / EndInvoke로 나눌 필요가 있습니다
        cancellationToken.ThrowIfCancellationRequested();

        // BeginInvoke / EndInvoke를 Task로 감싼다. 스레드 풀 한 줄을
        // 계속 기다리게 하는 Task.Run과 달리, 완료는 PowerShell 쪽에서 알립니다
        Task<PSDataCollection<PSObject>> running =
            Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);

        using (cancellationToken.Register(
            state => Volatile.Write(ref stopping, StopAsync((PowerShell)state!)), ps))
        {
            // 중지되면 PipelineStoppedException
            PSDataCollection<PSObject> output = await running;
            return output.ToList();
        }
    }
    finally
    {
        Task? pending = Volatile.Read(ref stopping);
        if (pending is not null)
        {
            try { await pending; } catch { }
        }

        ps.Dispose();
    }
}

Task.Run으로 감싼 뒤에 Register하는 형태로 만들지 마세요. Task.Run 안에서 「이미 취소됐는지 확인하기 → 등록하기 → Invoke()」로 쓰면, 등록과 시작 사이에 들어온 취소가 무시됩니다. 아직 돌지 않는 파이프라인으로 BeginStop이 날아가고, 그다음 Invoke()가 명령을 시작해 중지 신호는 이미 소비된 상태 ── 위의 InvokeAsync 예에서 피한 함정이 그대로 재현됩니다.

어느 형태든 지켜야 할 방침은 같습니다. UI 스레드에서 장시간 Invoke()를 하지 않는다, PowerShell을 호출하는 메서드에서 UI를 직접 건드리지 않는다, 그리고 호출 쪽이 멈출 수 있는 수단을, 파이프라인을 시작한 뒤에 연다, 이 세 가지입니다.

동시 실행과 최초 기동 비용

관리 도구에서는 「한 대씩 처리하면 느리니 병렬로 하고 싶다」는 요구가 바로 나옵니다. 여기서 막히기 쉬운 점을 먼저 적어 둡니다.

먼저 Invoke()는 느리다는 성질이 있습니다. Runspace 초기화와 모듈 탐색이 첫 실행 때 돌기 때문입니다. 기동 직후 한 번만 시간이 걸리고 두 번째부터는 빠른 것은 이상이 아닙니다. 측정한다면 첫 번째를 빼고 비교합니다. 화면에서 호출하는 앱에서는, 기동 때 가벼운 명령을 한 번 돌려 워밍업해 두는 방법도 있습니다.

다음으로, 하나의 PowerShell 인스턴스를 여러 스레드에서 공유하지 않는다는 점입니다. 실행 중인 인스턴스에 다시 InvokeInvokeAsync를 호출하면 「명령이 이미 시작됐다」는 이유로 InvalidOperationException이 됩니다. 병렬로 돌리고 싶다면 조작마다 PowerShell.Create()합니다.

다만 조작마다 PowerShell.Create()하면 그때마다 Runspace가 만들어집니다. 수가 늘어나면 RunspacePool을 마련해 거기서 재사용합니다.

using System.Management.Automation;
using System.Management.Automation.Runspaces;

static async Task<IReadOnlyList<PSObject>> GetServiceAsync(
    RunspacePool pool,
    string serviceName)
{
    using PowerShell ps = PowerShell.Create();
    ps.RunspacePool = pool;

    ps.AddCommand("Get-Service")
      .AddParameter("Name", serviceName)
      .AddCommand("Select-Object")
          .AddParameter("Property", new[] { "Name", "Status" });

    PSDataCollection<PSObject> output = await ps.InvokeAsync();

    return output.ToList();
}

쓰는 쪽에서는 풀을 연 뒤 병렬로 던집니다.

using System.Management.Automation;
using System.Management.Automation.Runspaces;

string[] serviceNames = { "Spooler", "W32Time", "EventLog" };

using RunspacePool pool = RunspaceFactory.CreateRunspacePool(1, 4);
pool.Open();

IReadOnlyList<PSObject>[] results = await Task.WhenAll(
    serviceNames.Select(name => GetServiceAsync(pool, name)));

foreach (IReadOnlyList<PSObject> rows in results)
{
    foreach (PSObject row in rows)
    {
        Console.WriteLine($"{row.Properties["Name"]?.Value}: {row.Properties["Status"]?.Value}");
    }
}

여기서는 설명을 위해 Get-Service를 쓰고 있지만, 실제로는 대상마다 시간이 걸리는 명령을 전제로 하세요. 핵심은 ps.Runspace가 아니라 ps.RunspacePool에 설정한다는 것과, PowerShell 인스턴스 자체는 조작마다 만들어 해제한다는 것입니다.

마지막으로, 풀 크기를 올린다고 반드시 빨라지지는 않습니다. 실제 상한은 대상 서버 부하, 인증 처리, 네트워크, 대상 모듈의 병렬 실행 내성으로 정해집니다. 먼저 작은 값으로 돌리고, 실측한 뒤에 올리세요.

앱 배포 시 크기

Microsoft.PowerShell.SDK는 편리하지만, 앱에 포함되는 의존성도 늘어납니다. 작은 유틸리티에서는 허용돼도, 배포 형식이나 업데이트 방식에 따라서는 크기가 거슬릴 수 있습니다. ClickOnce, MSIX, 단독 exe 배포, 사내 배포 도구 등, 실제 배포 방식으로 일찍 검증해 두면 안심입니다.

14. 문자열이 아니라 객체로 받는 이점

마지막으로, 왜 여기까지 PSObject에 집착하는가. 외부 프로세스로 PowerShell을 실행하고 표준 출력을 읽는 방법은 간단합니다.

PowerShell의 출력
  ↓
문자열
  ↓
Split / 정규 표현식 / Substring
  ↓
C#의 값

다만 이 방법은 표시 형식에 의존합니다. 열 너비, 로케일, 줄바꿈, 공백, 오류 메시지, 값 안의 구분 문자에 따라 깨지기 쉬워집니다.

한편 PowerShell SDK를 쓰면 다음 흐름이 됩니다.

PowerShell의 출력
  ↓
PSObject
  ↓
Properties / BaseObject
  ↓
C#의 타입

이쪽은 표시 형식이 아니라 데이터 구조를 기준으로 값을 꺼낼 수 있습니다. 업무 애플리케이션이나 관리 도구에서는 후자가 유지하기 쉬워집니다.

15. 정리

C#에서 PowerShell을 실행해 결과를 다룬다면, 단순히 powershell.exe를 시작해 표준 출력을 읽는 것만이 아니라, PowerShell SDK를 쓰는 방법을 검토할 가치가 있습니다.

기본 흐름은 다음과 같습니다.

Microsoft.PowerShell.SDK를 추가한다
  ↓
PowerShell.Create()로 실행 객체를 만든다
  ↓
AddCommand / AddParameter / AddScript로 처리를 조립한다
  ↓
Invoke()로 실행한다
  ↓
Collection<PSObject>로 받는다
  ↓
BaseObject 또는 Properties에서 값을 꺼낸다
  ↓
C#의 DTO / record / class로 변환한다

실무에서 특히 중요한 것은 다음 세 가지입니다.

  • C#에서 후속 처리에 쓴다면 Format-Table이 아니라 Select-Object[pscustomobject]를 사용한다
  • 사용자 입력을 AddScript 문자열에 직접 넣지 않고, 되도록 AddParameter로 전달한다
  • PSObject는 경계에서 다루고, 앱 내부에서는 C# 타입으로 변환한다

PowerShell은 Windows 관리와 기존 자산 활용에 강하고, C#은 앱 구현, UI 구현, 타입 안전한 업무 처리에 강합니다. 둘을 잘 이으면 기존 PowerShell 스크립트를 버리지 않고, 조금씩 .NET 앱으로 다듬을 수 있습니다.

참고 정보

  • 이 글의 샘플 코드 일체(라이브러리, 데모, 유닛 테스트) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-run-powershell-receive-objects
  • Microsoft Learn: Windows PowerShell 호스트 퀵 스타트
    https://learn.microsoft.com/ja-jp/powershell/scripting/developer/hosting/windows-powershell-host-quickstart
  • Microsoft Learn: 명령을 추가하고 호출하기
    https://learn.microsoft.com/ja-jp/powershell/scripting/developer/hosting/adding-and-invoking-commands
  • Microsoft Learn: PowerShell Class
    https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell
  • Microsoft Learn: PSObject Class
    https://learn.microsoft.com/ja-jp/dotnet/api/system.management.automation.psobject
  • Microsoft Learn: PowerShell.InvokeAsync Method
    https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.invokeasync
  • Microsoft Learn: PowerShell.BeginStop Method(실행 중인 명령을 비동기로 중지한다. 반환되는 IAsyncResultEndStop으로 받는다)
    https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.beginstop
  • Microsoft Learn: PowerShell.Stop Method(동기 버전. 중지가 끝날 때까지 반환하지 않는다)
    https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.stop
  • Microsoft Learn: 여러 실행 공간을 만들기
    https://learn.microsoft.com/ja-jp/powershell/scripting/developer/hosting/creating-multiple-runspaces
  • NuGet Gallery: Microsoft.PowerShell.SDK
    https://www.nuget.org/packages/Microsoft.PowerShell.SDK/
  • NuGet Gallery: Microsoft.PowerShell.5.1.ReferenceAssemblies
    https://www.nuget.org/packages/Microsoft.PowerShell.5.1.ReferenceAssemblies/

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

C#에서 PowerShell을 실행할 때 어떤 방법이 좋은가요?
크게 나누면, ProcessStartInfo로 powershell.exe/pwsh.exe를 외부 프로세스로 시작하는 방법과, System.Management.Automation.PowerShell(PowerShell SDK)을 사용하는 방법이 있습니다. 표준 출력을 문자열로 읽기만 한다면 전자로도 동작하지만, C# 쪽에서 결과를 가공하는 관리 도구나 업무 애플리케이션에서는 결과를 PSObject 컬렉션으로 받을 수 있는 후자가 맞습니다. 문자열 파싱이 필요 없어지고, 표시 형식에 의존하지 않는 안전한 처리를 작성할 수 있습니다.
PSObject의 BaseObject와 Properties는 어떻게 구분해서 사용하나요?
BaseObject는 PowerShell이 반환한 원래 .NET 객체를 그대로 쓰고 싶을 때 사용합니다. 예를 들어 Get-Process를 그대로 실행한 결과는 System.Diagnostics.Process로 꺼낼 수 있습니다. 한편, Select-Object나 [pscustomobject]로 열을 정리한 결과는 PowerShell의 커스텀 객체로 반환되는 경우가 많기 때문에, Properties["열 이름"]?.Value처럼 열 이름을 지정해 꺼내는 쪽이 자연스럽습니다.
C#에서 PowerShell에 사용자 입력을 전달할 때 무엇에 주의해야 하나요?
사용자 입력을 문자열 연결로 AddScript의 스크립트에 넣는 것은 피해야 합니다. 입력이 PowerShell 코드로 해석될 여지가 있어 위험하기 때문입니다. 값을 전달할 때는 AddCommand와 AddParameter를 사용하면, 값이 코드 문자열이 아니라 매개변수 값으로 처리됩니다. AddScript는 고정된 짧은 스크립트나 기존 스크립트를 불러오는 용도로 한정하는 것이 무난합니다.
PowerShell의 결과를 C#에서 받을 때 Format-Table을 쓰면 안 되는 이유는 무엇인가요?
Format-Table이나 Format-List를 거치면 결과가 원래 객체가 아니라 화면 표시용 서식 정보로 바뀌어, C# 쪽에서 속성으로 값을 꺼낼 수 없게 되기 때문입니다. C#에서 후속 처리에 쓸 경우에는 Select-Object로 열을 좁히거나, PowerShell 쪽에서 [pscustomobject]를 반환하는 형태로 맞춥니다. 「화면에서 보기만 한다면 Format 계열, C#에 넘긴다면 Select-Object」라는 식으로 구분합니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기