수정 이력(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.exe나 pwsh.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을 호출하는 방법은 크게 두 가지입니다.
| 방법 | 특징 | 맞는 상황 |
|---|---|---|
ProcessStartInfo로 powershell.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을 사용합니다.
flowchart LR
accTitle: C#에서 PowerShell을 PSObject로 실행하는 지식 맵
accDescr: PowerShell SDK를 쓰면 PowerShell 실행 결과를 문자열이 아니라 PSObject로 받을 수 있다는 점, BaseObject와 Properties의 용도 구분, AddParameter로 코드 주입을 피하는 방법, Select-Object와 Format-Table의 용도 구분, InvokeAsync에서의 취소와 RunspacePool에 의한 병렬 실행의 요점을 보여주는 그림
powershell_sdk["PowerShell SDK(System.Management.Automation.PowerShell)"]
psobject["PSObject"]
addcommand_addparameter["AddCommand/AddParameter"]
addscript["AddScript"]
csharp_side_result_processing["C# 측 결과 가공 처리"]
psobject_baseobject["PSObject.BaseObject"]
psobject_properties["PSObject.Properties"]
format_table["Format-Table/Format-List"]
select_object["Select-Object"]
pscustomobject["[pscustomobject]"]
user_input_to_powershell["PowerShell 사용자 입력 전달 요건"]
script_injection_risk["스크립트 주입 리스크"]
invokeasync["PowerShell.InvokeAsync"]
windows_powershell_5_1["Windows PowerShell 5.1"]
begininvoke_endinvoke["BeginInvoke/EndInvoke"]
dotnet_framework[".NET Framework"]
pipeline_stop_api["파이프라인 중지 API(Stop/BeginStop/EndStop)"]
pipelinestoppedexception["PipelineStoppedException"]
runspacepool["RunspacePool"]
concurrent_powershell_execution["PowerShell 병렬 실행 요건"]
erroraction_stop["ErrorAction Stop"]
process_based_powershell_invocation["외부 프로세스로서의 PowerShell 기동"]
cancellable_pipeline_pattern["CancellationToken으로 파이프라인을 안전하게 중지하는 구현 패턴"]
cancellationtoken_dotnet["CancellationToken(.NET)"]
premature_cancellation_registration["파이프라인 기동 전 취소 등록"]
cancellation_race_miss["등록과 시작 사이 취소 미적용"]
powershell_sdk -->|"이용한다"| psobject
powershell_sdk -->|"이용한다"| addcommand_addparameter
powershell_sdk -->|"이용한다"| addscript
csharp_side_result_processing -->|"이용한다"| psobject_baseobject
csharp_side_result_processing -->|"이용한다"| psobject_properties
format_table -->|"사용은 비권장"| csharp_side_result_processing
select_object -->|"권장되는 대응"| csharp_side_result_processing
pscustomobject -->|"권장되는 대응"| csharp_side_result_processing
addcommand_addparameter -->|"권장되는 대응"| user_input_to_powershell
addscript -.->|"원인이 될 수 있다"| script_injection_risk
addcommand_addparameter -->|"방지한다"| script_injection_risk
powershell_sdk -->|"이용한다"| invokeasync
windows_powershell_5_1 -->|"양립하지 않는다"| invokeasync
windows_powershell_5_1 -->|"이용한다"| begininvoke_endinvoke
dotnet_framework -.->|"이용한다"| windows_powershell_5_1
powershell_sdk -->|"이용한다"| pipeline_stop_api
pipeline_stop_api -->|"원인이 될 수 있다"| pipelinestoppedexception
runspacepool -->|"권장되는 대응"| concurrent_powershell_execution
powershell_sdk -.->|"사용은 비권장"| concurrent_powershell_execution
powershell_sdk -->|"에서 구성할 수 있다"| erroraction_stop
process_based_powershell_invocation -->|"사용은 비권장"| csharp_side_result_processing
powershell_sdk -->|"권장되는 대응"| csharp_side_result_processing
cancellable_pipeline_pattern -->|"이용한다"| pipeline_stop_api
cancellable_pipeline_pattern -->|"이용한다"| cancellationtoken_dotnet
premature_cancellation_registration -->|"원인이 될 수 있다"| cancellation_race_miss
cancellable_pipeline_pattern -->|"방지한다"| cancellation_race_miss
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 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 명령에 값을 넘길 때는 되도록 AddCommand와 AddParameter를 사용합니다.
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# 쪽에서 예외로 다루고 싶을 때는 ErrorAction에 Stop을 지정하는 방법이 있습니다.
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.InvokeAsync는 Task<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);
}
짚어 둘 점은 네 가지입니다.
await에서 돌아온 지점은 WinForms든 WPF든 UI 스레드입니다. 동기화 컨텍스트가 되돌려 주므로Invoke나Dispatcher.Invoke는 필요 없습니다.- 실행 중에는 버튼을 비활성화합니다. 비활성화하지 않으면 같은 처리를 이중으로 돌리게 됩니다.
- 예외는
RuntimeException으로 받습니다.ErrorAction을Stop으로 두지 않은 경우에는 오류 스트림도 확인합니다(10장). - 중지할 수단을 반드시 마련합니다.
PowerShell인스턴스를 메서드 지역 변수에 숨긴 채로 두면 호출 쪽은Stop()을 호출할 수 없습니다. 명령이 hang되거나 사용자가 창을 닫았을 때, 버튼을 비활성화한 채로 계속 기다리는 화면이 됩니다.
중지하면 기다리고 있던 InvokeAsync는 PipelineStoppedException을 던집니다. 위와 같이, 이는 실패가 아니라 정상적인 경로로 잡으세요. 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 인스턴스를 여러 스레드에서 공유하지 않는다는 점입니다. 실행 중인 인스턴스에 다시 Invoke나 InvokeAsync를 호출하면 「명령이 이미 시작됐다」는 이유로 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(실행 중인 명령을 비동기로 중지한다. 반환되는
IAsyncResult는EndStop으로 받는다)
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/
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
PowerShell에서 COM과 .NET을 호출하는 실무 ── 스크립트가 닿는 범위를 한 번에 넓히기
PowerShell에서 .NET 클래스를 호출하는 방법, Add-Type으로 C#과 Win32 API를 넣는 방법, COM 조작, Excel 프로세스 잔류와 뒷정리, Office 무인 실행이 지원되지 않는 이유, 5.1과 7의 차이까지 실무 관점...
WMI/CIM을 C#·PowerShell에서 쓰기 ── 하드웨어 정보 가져오기·프로세스 모니터링·원격 조회의 실무 가이드
PC 시리얼 번호 조회, 디스크 여유 공간 모니터링, 프로세스 시작 감지의 흔한 답이 WMI/CIM입니다. Get-CimInstance 등 CIM cmdlet 사용법과 구 Get-WmiObject에서의 이전, C#의 System.Managemen...
PowerShell 스크립트의 인수 설계와 모듈화 ── 「돌아가는 스크립트」에서 「남에게 넘길 수 있는 스크립트」로
PowerShell 스크립트를 다른 사람에게 넘길 수 있는 품질로 끌어올리는 절차를 정리합니다. param 블록과 [CmdletBinding()], 입력 검증, 파이프라인 입력, -WhatIf 지원, .psm1 모듈화, 사내 공유와 Git 관리의...
Windows PowerShell 5.1과 PowerShell 7의 차이 ── 사내 스크립트 마이그레이션 실무 가이드
Windows PowerShell 5.1과 PowerShell 7의 관계(공존과 pwsh.exe), 5.1에는 신기능을 추가하지 않는다는 공식 방침, 인코딩 차이로 인한 문자 깨짐, #Requires로 막는 방어, 작업 스케줄러 업데이트까지 마이...
PowerShell로 Excel・CSV 업무 처리를 자동화한다 ── 집계・대조・장표 출력의 실무 레시피
PowerShell로 CSV 집계・대조와 Excel 장표 출력을 자동화하는 실무 레시피입니다. Import-Csv/Export-Csv의 문자 코드 기본값(5.1과 7의 차이), Group-Object 집계, Compare-Object와 해시테이블...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기존 자산 활용 & 이관 지원
COM / ActiveX / OCX 자산, 네이티브 코드, 32비트 의존성을 유지하면서 단계적인 이관 계획을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 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」라는 식으로 구분합니다.