「내 컴퓨터의 명령 프롬프트에서는 통하는데, PowerShell 스크립트로 옮긴 순간 외부 도구가 오류를 반환한다」── 배치 파일의 PowerShell화나, 사내 EXE・OSS 제작 CLI의 자동화에서 거의 반드시 마주치는 현상입니다. 원인의 대부분은 로직이 아니라 인자가 프로그램에 도달하기까지의 경로에 있습니다. 경로에 공백이 들어간다, 인자에 큰따옴표가 포함된다, %나 ( )를 포함한 문자열을 전달한다. 그중 무엇이 방아쇠가 되어, 전달했다고 생각한 인자가 다른 형태로 변해 버립니다.
게다가 골치 아픈 것은, 이 동작이 PowerShell 7.3에서 변경되었다는 점입니다. 5.1에서 동작하도록 작성한 회피책이 7에서는 이중 이스케이프가 되고, 7에서 작성한 스크립트가 5.1에서는 망가집니다. 한국어(일본어) 환경에서는 출력의 문자 깨짐까지 겹칩니다.
이 글에서는 PowerShell에서 외부 프로그램(네이티브 명령)을 호출할 때 인자가 전달되는 방식을 사양부터 짚고, --%의 올바른 사용법, 확실하게 전달하고 싶을 때의 ProcessStartInfo, 종료 코드와 stderr의 처리, 그리고 문자 깨짐 대책까지를 실무 관점에서 정리합니다. 오류 처리 자체의 설계는 「PowerShell의 오류 처리와 재실행 설계」에서 다루었으므로, 이 글은 「외부 프로세스와의 경계」로 범위를 좁힙니다.
먼저 자신의 환경을 확인한다
이 글은 버전 차이가 주제이므로, 읽기 전에 자신이 어느 쪽 세계에 있는지 확인하세요.
$PSVersionTable.PSVersion
5.1.x라면 Windows PowerShell 5.1, 7.x라면 PowerShell 7입니다. 7.3이 인자 전달 방식의 경계이므로, 7.0~7.2는 3장의 새 방식에 포함되지 않는다는 점에 주의하세요. 둘 다 설치된 환경이라면 실행 중인 호스트가 어느 쪽이냐에 따라 동작이 달라집니다.
1. 먼저 결론
- PowerShell은 외부 프로그램의 인자도 한 번 스스로 해석합니다. 명령 호출 이후는 「인자 모드(argument mode)」로 해석되며, 공백을 포함한 값은 따옴표로 감싸야 합니다.
,(){}|&<>@#등은 메타 문자이므로, 리터럴로 전달하려면 백틱으로 이스케이프합니다.1 - PowerShell 7.3에서 인자 전달 방식이 변경되었습니다(호환성 파괴 변경). 포함된 따옴표와 빈 문자열 인자가 보존되도록 바뀌었고,
$PSNativeCommandArgumentPassing으로 동작을 선택할 수 있습니다. Windows의 기본값은Windows입니다.12 Windows모드에서는 cmd.exe・cscript.exe・wscript.exe와.bat.cmd.js.vbs.wsf만 기존(Legacy) 방식으로 전달됩니다. 기존 배치 자산과의 호환을 위한 예외입니다.1- 정지 구문 분석 토큰
--%는 「이후를 통째로 리터럴로 취급」하는 최후의 수단입니다. 다만%VAR%형식의 환경 변수만은 전개되고, PowerShell 변수는 전혀 사용할 수 없으며, 효과는 줄바꿈이나 파이프까지입니다. 리다이렉션도 쓸 수 없습니다.1 - 변수의 값을 확실히 전달하고 싶다면 배열로 전달하는 것이 첫 번째 선택지입니다.
& $exe @argArray의 스플래팅은 각 요소가 독립된 인자가 됩니다.ProcessStartInfo.ArgumentList라면 따옴표 조립을 .NET 쪽에 맡길 수 있지만, 이는 .NET Core 2.1 이후의 API로 5.1에서는 사용할 수 없습니다.13 - 배치 파일에 신뢰할 수 없는 입력을 전달해서는 안 됩니다. Windows에서는 배치로의 인자가 원시 명령줄 문자열로 cmd.exe에 전달되기 때문에, 공식 문서도 「신뢰할 수 없는 입력은 다른 방법으로 전달하라」고 경고하고 있습니다.1
- 성공・실패는
$LASTEXITCODE로 판정합니다. 0이 아닌 종료 코드는 기본값으로는 PowerShell의 오류가 되지 않으며, try/catch에도 잡히지 않습니다.4 - 문자 깨짐은 송신 측과 수신 측을 따로 고쳐야 합니다. 외부 명령 출력의 복호화는
[Console]::OutputEncoding, PowerShell에서 외부 명령으로 파이프로 보내는 문자열은$OutputEncoding이 담당합니다.2 - 막히면
Trace-Command -Name ParameterBinding으로 실제 전달된 인자를 확인합니다. PowerShell 7.3 이후는 네이티브 명령의 인자 바인딩도 추적할 수 있습니다.15
2. 왜 인자가 깨지는가 ── 인자 모드라는 전제
PowerShell은 명령줄을 토큰으로 분해하여 식 모드와 인자 모드 중 하나로 해석합니다. 명령 호출이 나타나면 그 이후는 인자 모드로 해석됩니다. 인자 모드에서는 입력이 기본적으로 「전개 가능한 문자열」로 취급되며, $로 시작하면 변수 참조, 따옴표는 문자열의 시작, ( )는 식의 시작…… 이런 식으로 기호에 구문상의 의미가 있습니다.1
즉, 명령 프롬프트에 그대로 붙여 넣으면 동작하는 문자열이라도, PowerShell에서는 다른 것으로 해석될 수 있다는 뜻입니다. 고전적인 예가 icacls입니다.1
# cmd.exe라면 통하지만, PowerShell 2.0 시절에는 괄호가 식으로 해석되어 오류가 났다
icacls X:\VMS /grant Dom\HVAdmin:(CI)(OI)F
# 백틱으로 메타 문자를 이스케이프한다(읽기 어렵다)
icacls X:\VMS /grant Dom\HVAdmin:`(CI`)`(OI`)F
# 정지 구문 분석 토큰으로 「여기서부터는 리터럴」이라고 선언한다(PowerShell 3.0 이후)
icacls X:\VMS --% /grant Dom\HVAdmin:(CI)(OI)F
또 하나의 전제가 해석된 인자가 프로그램으로 전달되는 경로입니다. Windows PowerShell 5.1에서는 해석된 인자가 공백으로 구분되어 하나의 문자열로 다시 조립된 뒤 프로세스에 전달됩니다. 이 「재조립」 과정에서, 인자에 포함되어 있던 따옴표가 떨어지거나 빈 문자열 인자가 사라지는 것 ── 이것이 5.1 시절의 단골 사고입니다.1
3. PowerShell 7.3의 호환성 파괴 변경 ── $PSNativeCommandArgumentPassing
PowerShell 7.3에서 이 조립 방식이 변경되었습니다. 공식 문서가 명확히 「Windows PowerShell 5.1 동작으로부터의 호환성 파괴 변경」이라고 적어 놓은 부분입니다.1
새로운 동작은 $PSNativeCommandArgumentPassing 환경 설정 변수로 전환할 수 있으며, 값은 Legacy(기존)・Standard・Windows 세 가지입니다. Windows 플랫폼의 기본값은 Windows, 비Windows는 Standard입니다.12
Windows와 Standard의 차이는 한 가지뿐으로, Windows 모드에서는 다음 호출이 자동으로 Legacy 방식이 됩니다.1
| Legacy 방식이 자동 적용되는 호출 |
|---|
cmd.exe / cscript.exe / wscript.exe |
확장자가 .bat .cmd .js .vbs .wsf인 파일 |
오래된 배치나 WSH 스크립트가 「PowerShell을 7로 올린 순간 인자를 받는 방식이 바뀌어 망가지는」 사고를 막기 위한 예외입니다. 반대로 말하면, $PSNativeCommandArgumentPassing을 Standard나 Legacy로 명시적으로 설정하면 이 판정은 이루어지지 않습니다.1
새 방식에서 개선되는 것은 다음 두 가지입니다.1
아래 예시에 나오는 TestExe -echoargs는 받은 인자를 Arg 0 is <...> 형태로 하나씩 표시하기만 하는 검증용 도구입니다. PowerShell 본체의 테스트 자산에 포함된 것으로, Windows에 기본으로 들어 있는 명령이 아닙니다. 직접 같은 것을 확인하는 방법은 8장에 정리해 두었으므로, 읽어 나가는 동안은 「인자가 그대로 보이는 창」이라고 생각해 주세요.
# (1) 문자열에 삽입한 따옴표가 보존된다
$a = 'a" "b'
TestExe -echoargs $a 'c" "d' e" "f
# Arg 0 is <a" "b>
# Arg 1 is <c" "d>
# Arg 2 is <e f>
# (2) 빈 문자열 인자가 사라지지 않고 남는다
TestExe -echoargs '' a b ''
# Arg 0 is <>
# Arg 1 is <a>
# Arg 2 is <b>
# Arg 3 is <>
"C:\Program Files (x86)\Microsoft\" 같은 따옴표가 붙은 경로 문자열을 그대로 전달하고 싶은 경우, Windows / Standard 모드라면 있는 그대로 작성할 수 있습니다.1
# 7.3 이후(Windows / Standard 모드)
TestExe -echoargs '"C:\Program Files (x86)\Microsoft\"'
# 같은 결과를 Legacy 모드(5.1 상당)로 얻으려면 따옴표의 이중 이스케이프가 필요하다
TestExe -echoargs "\""C:\Program Files (x86)\Microsoft\\"""
여기서 한 가지 주의할 점이 있습니다. 백슬래시(\)는 PowerShell의 이스케이프 문자가 아닙니다. 위 예시에서 \"가 등장하는 것은, 하위 레벨의 .NET API(ProcessStartInfo.ArgumentList)가 백슬래시를 이스케이프 문자로 취급하기 때문이며, PowerShell 구문으로서의 이스케이프는 백틱(`)입니다. 이 두 종류가 뒤섞이는 것이, 이 분야가 어렵게 보이는 가장 큰 이유입니다.13
실무상의 판단은 단순합니다. 5.1과 7이 섞여 있는 환경에서는, 따옴표를 포함한 인자를 리터럴로 작성하는 것을 그만두세요. 다음 장 이후의 「배열로 전달한다」「ProcessStartInfo를 사용한다」에 맞추면, 버전 차이의 영향을 거의 받지 않습니다. 5.1과 7의 공존 방침 자체는 「Windows PowerShell 5.1과 PowerShell 7의 차이」를 참조하세요.
4. --%(정지 구문 분석 토큰)의 올바른 사용법과 한계
--%는 그 이후의 문자를 PowerShell이 해석하지 않고 그대로 전달하기 위한 토큰입니다(PowerShell 3.0 이후). 공식 문서는 「Windows 플랫폼의 네이티브 명령에서의 사용만을 상정」이라고 명시하고 있습니다.1
PS> cmd /c echo "a|b"
'b' is not recognized as an internal or external command,
operable program or batch file.
PS> cmd /c --% echo "a|b"
"a|b"
강력하지만, 제약을 정확히 알아 두어야 합니다.1
| 제약 | 내용 |
|---|---|
| 환경 변수만 전개된다 | %USERPROFILE% 같은 %<이름>%는 반드시 전개된다. %%에 의한 이스케이프는 불가능. 정의되지 않은 이름은 그대로 통과 |
| PowerShell 변수는 사용할 수 없다 | $path 등은 전개되지 않고, 리터럴 문자열로 전달된다 |
| 효과 범위 | 다음 줄바꿈이나 파이프(|)까지. 백틱에 의한 줄 연속으로 연장할 수 없고, ;로 종료할 수도 없다 |
| 리다이렉션 불가 | >file.txt 등은 인자로서 그대로 대상 명령에 전달되어 버린다 |
즉 --%를 사용할 수 있는 것은 「전달할 내용이 완전히 고정 문자열이고, %를 포함하지 않는다」 경우로 한정됩니다. 스크립트에서 변수를 조합해 전달하는 전형적인 자동화에서는, 이 조건을 거의 충족할 수 없습니다. 「일단 --%를 붙인다」는 운영은, %를 포함한 비밀번호나 와일드카드를 전달한 순간 무너집니다.
5. 변수를 확실하게 전달한다 ── 배열 스플래팅과 ProcessStartInfo
변수의 값을 포함한 인자를 전달할 때의 첫 번째 선택지는, 배열에 인자를 하나씩 넣어 스플래팅하는 방식입니다. 배열의 각 요소는 독립된 인자로 전달되기 때문에, 공백을 포함한 경로도 스스로 따옴표를 붙일 필요가 없습니다.
$exe = 'C:\Program Files\MyTool\convert.exe'
$args = @(
'--input', 'D:\수주데이터\2026년07월.csv' # 공백이나 한국어를 포함해도 문제없다
'--output', 'D:\출력\result.json'
'--mode', 'strict'
)
& $exe @args # 배열 스플래팅. 각 요소가 1개의 인자가 된다
if ($LASTEXITCODE -ne 0) { throw "변환에 실패했습니다 (ExitCode=$LASTEXITCODE)" }
호출 연산자 &는, 경로에 공백을 포함한 exe를 실행할 때도 필요합니다('C:\Program Files\...'는 그대로 두면 문자열 리터럴로 평가될 뿐 실행되지 않습니다).
제대로 전달되었는지 불안하다면, 추측으로 이스케이프를 늘리기 전에 8장의 방법으로 실제 인자를 확인하세요. 작성 방식을 바꾸기 전과 후에 도달한 인자가 같은지를 보는 것이 가장 빠른 방법입니다.
더 확실함이 필요할 때 ── 인자를 하나씩 완전히 제어하고 싶다, 출력의 문자 코드를 프로세스 단위로 지정하고 싶다 ── 는 .NET의 ProcessStartInfo를 직접 사용합니다. ArgumentList에 추가한 값은 .NET 쪽이 적절히 따옴표를 붙이기 때문에, PowerShell의 해석에도 명령줄의 재해석에도 좌우되지 않습니다.3
다만 ArgumentList는 .NET Core 2.1 이후의 API로, .NET Framework 위에서 동작하는 Windows PowerShell 5.1의 ProcessStartInfo에는 존재하지 않습니다.3 5.1에서는 다음 장의 Arguments 문자열을 스스로 조립하거나, 앞서 설명한 배열 스플래팅을 사용하세요.
# 【PowerShell 7 이후】인자 조립을 .NET에 맡긴다
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = 'C:\Program Files\MyTool\convert.exe'
foreach ($a in '--input', $inputPath, '--output', $outputPath) {
$psi.ArgumentList.Add($a) # 1요소=1인자. 따옴표는 스스로 작성하지 않는다(7 전용)
}
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
# 출력의 문자 코드를 명시할 수 있는 것도 ProcessStartInfo의 장점(6장)
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
$psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
$proc = [System.Diagnostics.Process]::Start($psi)
# 표준 출력과 표준 오류는 「동시에」 읽어 들인다. 한쪽을 동기적으로 끝까지
# 읽고 나서 다른 쪽을 읽으면, 기다리는 동안 다른 쪽의 파이프 버퍼가 가득 차서
# 자식 프로세스가 쓰기에서 블록되고, 그대로 데드락에 빠진다
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
$stderrTask = $proc.StandardError.ReadToEndAsync()
$proc.WaitForExit()
$stdout = $stdoutTask.GetAwaiter().GetResult()
$stderr = $stderrTask.GetAwaiter().GetResult()
if ($proc.ExitCode -ne 0) {
throw "변환 실패 (ExitCode=$($proc.ExitCode)): $stderr"
}
출력 리다이렉션에서 가장 흔한 사고가 데드락입니다. 파이프 버퍼에는 상한이 있어, 가득 차면 자식 프로세스는 쓰기에서 블록됩니다. 따라서 WaitForExit()을 먼저 호출하고 나중에 읽는 것은 물론이고, 한쪽 스트림을 ReadToEnd()로 동기적으로 끝까지 읽고 나서 다른 쪽을 읽는 방식도 위험합니다(읽지 않은 쪽의 버퍼가 먼저 가득 차면, 자식 프로세스가 멈추고 ReadToEnd()는 영원히 반환되지 않습니다).
멈추는 순서를 한 장으로 정리하면 다음과 같습니다.
flowchart TD
A["부모 프로세스<br/>StandardError.ReadToEnd로<br/>stderr를 끝까지 읽으려고 대기"]
B["자식 프로세스<br/>stdout으로 계속 쓴다"]
C["stdout 쪽 파이프 버퍼가 가득 참<br/>부모가 읽고 있지 않아 비워지지 않는다"]
D["자식 프로세스가 쓰기에서 블록<br/>종료도 못하고, stderr도 닫히지 않는다"]
E["부모의 ReadToEnd가 반환되지 않는다"]
F["WaitForExit도 반환되지 않는다<br/>= 데드락"]
B --> C --> D --> E
A --> E --> F
그림 1: 한쪽 스트림만 동기적으로 끝까지 읽으려고 하면 멈추는 흐름
위 코드처럼 양쪽을 비동기로 읽기 시작한 뒤 기다리거나, 한쪽만 리다이렉트하는 설계로 하세요. 자식 프로세스 처리 전반에 대해서는 「Windows 앱에서 자식 프로세스를 안전하게 다루기 위한 체크리스트」도 참조하세요.
Windows PowerShell 5.1에서 ProcessStartInfo를 사용하는 경우는, Arguments에 스스로 따옴표를 붙인 하나의 문자열을 전달하게 됩니다. 이 조립을 손으로 작성하는 것은 사고의 원인이 되므로, 5.1에서는 배열 스플래팅(& $exe @args)을 첫 번째 선택지로 삼으세요.
# 【5.1】ArgumentList가 없으므로, 따옴표를 포함한 하나의 문자열을 스스로 조립한다
$quote = {
param([string] $s)
if ($s -eq '') { return '""' } # 빈 문자열은 ""로 하지 않으면 인자째로 사라진다
if ($s -notmatch '[\s"]') { return $s } # 감쌀 필요가 없으면 그대로
# Windows의 명령줄 해석 규칙에 맞춘다:
# (1) " 직전의 백슬래시 열을 2배로 하고, " 자체를 \"로 한다
# (2) 끝의 백슬래시 열도 2배로 한다. 닫는 따옴표 직전에 오기 때문에,
# 그대로 두면 \"로 해석되어 따옴표가 닫히지 않고, 뒤따르는 인자까지 망가진다
# (예: 'C:\Program Files\input\' → "C:\Program Files\input\\")
$e = $s -replace '(\\*)"', '$1$1\"'
$e = $e -replace '(\\+)$', '$1$1'
'"' + $e + '"'
}
$psi.Arguments = (@('--input', $inputPath, '--output', $outputPath) |
ForEach-Object { & $quote $_ }) -join ' '
정규식만 봐서는 무엇을 하는지 파악하기 어려우므로, 입력과 출력의 대응을 나열합니다. 이 6줄만 이해하면, 규칙 자체를 외울 필요는 없습니다.
| 전달하고 싶은 값(변수의 내용) | $quote가 반환하는 문자열 |
적용되는 규칙 |
|---|---|---|
strict |
strict |
공백도 따옴표도 없으므로, 감싸지 않고 그대로 반환한다 |
| 빈 문자열 | "" |
아무것도 쓰지 않으면 인자째로 사라지므로, 빈 따옴표를 둔다 |
D:\수주데이터\2026년07월.csv |
D:\수주데이터\2026년07월.csv |
한글이 들어 있어도, 공백이 없으면 감싸지 않는다 |
C:\Program Files\input |
"C:\Program Files\input" |
공백이 있으므로, 전체를 따옴표로 감쌀 뿐 |
C:\Program Files\input\ |
"C:\Program Files\input\\" |
규칙(2). 끝의 \를 2배로 한다. 하나만 두면 닫는 따옴표와 붙어서 \"로 읽혀, 따옴표가 닫히지 않은 채 다음 인자까지 끌어들인다 |
say "hi" |
"say \"hi\"" |
규칙(1). 값 안의 "를 \"로 하여, 구분자가 아니라 문자로 전달한다 |
a\"b |
"a\\\"b" |
규칙(1)의 전체 모습. " 직전에 있는 \의 나열을 먼저 2배로 하고, 그 위에서 "를 \"로 한다 |
마지막 줄이 이 함수가 까다로워 보이는 이유 그 자체입니다. 백슬래시는 「다음이 "일 때만」 이스케이프 문자로 작동한다는, Windows 명령줄 해석 규칙의 비대칭성에 맞춘 결과가 이렇게 됩니다.
이 규칙의 세밀함이야말로, 5.1에서 수작업 조립을 피해야 하는 이유입니다. 다만, Windows PowerShell 5.1에서는 배열 스플래팅도 만능이 아닙니다. 전달된 값은 결국 기존 방식으로 명령줄 문자열로 재조립되기 때문에, 빈 문자열 인자는 사라지고 따옴표를 포함한 값은 변형됩니다.1 따라서 5.1에서는 다음과 같이 구분해서 사용하세요.
| 5.1에서 전달하는 인자 | 방법 |
|---|---|
| 공백이나 한글을 포함한 일반 값 | 배열 스플래팅(& $exe @args)으로 충분 |
| 빈 문자열, 따옴표를 포함한 값 | ProcessStartInfo + 위의 이스케이프, 또는 --%(고정 문자열만) |
PowerShell 7에서는 두 문제 모두 해소되어 있으므로, 이 구분이 필요 없어집니다.
또한 공식 문서는 배치 파일에 신뢰할 수 없는 입력을 전달하지 말라고 경고하고 있습니다. 배치로의 인자는 원시 명령줄 문자열로 cmd.exe에 전달되기 때문입니다.1 사용자 입력이나 파일명을 배치에 연결하여 전달하는 설계는, 명령 삽입의 온상이 됩니다. 값은 임시 파일이나 환경 변수로 주고받거나, 배치 자체를 PowerShell로 이전하세요(「그 배치 파일, PowerShell로 이전해야 할까요?」).
6. 문자 깨짐을 고친다 ── [Console]::OutputEncoding과 $OutputEncoding
한국어(일본어) 환경에서 반드시 부딪히는 것이 문자 깨짐입니다. 포인트는, 방향에 따라 사용되는 설정이 다르다는 것입니다.
| 방향 | 사용되는 설정 | 증상 |
|---|---|---|
| 외부 명령의 출력을 PowerShell이 받는다 | [Console]::OutputEncoding |
UTF-8 출력 도구의 결과가 깨진 문자로 표시된다 |
| PowerShell에서 외부 명령으로 파이프로 문자열을 보낸다 | $OutputEncoding |
보낸 문자열이 상대측에서 깨진다 |
어떻게 깨지는지는, 원래 문자열과 나란히 놓고 보면 단번에 이해하기 쉬워집니다. UTF-8로 출력된 일본어를 CP932(Shift_JIS)로 복호화하면 다음과 같이 됩니다.
| 원래 문자열 | UTF-8 출력을 CP932로 복호화한 결과 |
|---|---|
こんにちは |
縺薙s縺ォ縺。縺ッ |
エラー |
繧ィ繝ゥ繝シ |
日本語 |
譌・譛ャ隱 + 복호화할 수 없는 바이트 |
히라가나・가타카나가 縺 繧 繝로 시작하는 두 글자 조합으로 깨지는 것이 표식입니다(UTF-8의 히라가나・가타카나가 E3 81 E3 82 E3 83로 시작하고, 그 앞 2바이트가 CP932에서는 이 문자들에 해당하기 때문입니다). 한자처럼 복호화할 수 없는 바이트가 섞이면, 대체 문자가 되거나 문자가 탈락하여, 위 표의 3번째 줄처럼 길이도 맞지 않게 됩니다.
$OutputEncoding은 「PowerShell이 문자열을 네이티브 명령으로 보낼 때 사용하는 인코딩」을 결정하는 환경 설정 변수입니다.2 한편, 외부 명령이 내뱉은 바이트열을 문자열로 복호화하는 것은 [Console]::OutputEncoding의 역할입니다. 한쪽만 고쳤는데 「여전히 깨진다」가 되는 것은, 대부분 이 둘을 혼동하고 있는 경우입니다.
# UTF-8로 출력하는 외부 도구를, Windows PowerShell 5.1에서 호출할 때의 정석 대처
$prevOut = [Console]::OutputEncoding
$prevPs = $OutputEncoding
try {
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) # BOM 없는 UTF-8
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$result = & $exe --list
}
finally {
# 세션 전체에 영향을 주므로 반드시 되돌린다
[Console]::OutputEncoding = $prevOut
$OutputEncoding = $prevPs
}
ProcessStartInfo를 사용하는 경우는, 앞 장에서 설명한 대로 StandardOutputEncoding / StandardErrorEncoding을 프로세스 단위로 지정할 수 있으므로, 세션의 설정을 건드리지 않아도 됩니다. 부작용이 작은 쪽은 이쪽입니다. Windows 전반의 문자 코드 사정은 「Windows의 문자 코드와 개행 코드」에 정리해 두었습니다.
7. 종료 코드와 stderr ── 「성공했는데 실패로 처리되는」 것을 막는다
외부 프로그램의 성공・실패 판정은 $LASTEXITCODE로 합니다. 0이 아닌 종료 코드는 기본값으로는 ErrorRecord를 생성하지 않고, try/catch에도 잡히지 않습니다.4 이 기본 사항은 「PowerShell의 오류 처리와 재실행 설계」에서 자세히 다루었으므로, 여기서는 외부 프로세스에 고유한 두 가지만 보충합니다.
(1) stderr로의 출력은 「실패」가 아니다. 많은 CLI 도구는 진행 상황이나 로그를 stderr에 씁니다. PowerShell은 네이티브 명령의 stderr 출력을 오류 스트림으로 흘려보내기 때문에, 화면이 빨갛게 되어 「실패했다」처럼 보이지만, 종료 코드가 0이면 성공입니다. Windows PowerShell 5.1에서는, stderr에 쓰인 것만으로 $?가 $false가 되는 경우가 있었지만, PowerShell 7에서는 0이 아닌 종료 코드일 때만 $false가 되도록 수정되었습니다.6
(2) 2>&1로 합류시킨 후의 타입은 버전마다 다릅니다. Windows PowerShell 5.1에서는 stderr의 각 줄이 ErrorRecord로 섞이지만, PowerShell 7.4 이후는 네이티브 명령의 리다이렉트 출력이 바이트 스트림으로 취급되어, 합류 후에는 문자열 데이터가 됩니다.78 즉 「ErrorRecord인지 아닌지로 구분한다」는 방식은 7.4 이후에서는 동작하지 않습니다. 양쪽 출력이 모두 필요하다면, 합류시키지 않고 별도로 받는 것이 확실합니다.
# 【권장】stdout과 stderr를 나눠서 받는다(버전 차이의 영향을 받지 않는다)
$errFile = [System.IO.Path]::GetTempFileName()
try {
$stdout = & $exe --import $csvPath 2> $errFile
$stderr = Get-Content -Path $errFile -Raw
# 로그에는 양쪽 다 남긴다
$stdout | Add-Content -Path $logPath -Encoding utf8
if ($stderr) { $stderr | Add-Content -Path $logPath -Encoding utf8 }
if ($LASTEXITCODE -ne 0) {
throw "임포트 실패 (ExitCode=$LASTEXITCODE): $stderr"
}
# 여기에 도달하면 성공. stderr에 출력이 있어도 실패로 처리하지 않는다
}
finally {
Remove-Item $errFile -ErrorAction SilentlyContinue
}
# 【구분이 필요 없는 경우】단순히 전부 모아서 로그에 남기려면 합류로 충분하다
(& $exe --import $csvPath 2>&1) | ForEach-Object { $_.ToString() } |
Add-Content -Path $logPath -Encoding utf8
8. 실제로 무엇이 전달되었는지 확인한다
추측으로 이스케이프를 늘리면 사태는 악화됩니다. 실제로 전달된 인자를 보는 것이 가장 빠릅니다. PowerShell 7.3 이후는, 네이티브 명령의 인자 바인딩을 Trace-Command로 추적할 수 있습니다.15
Trace-Command -Name ParameterBinding -PSHost -Expression {
& $exe --input 'D:\수주데이터\2026년07월.csv' --mode strict
}
# DEBUG: ... BIND cmd line arg [--input] to position [0]
# DEBUG: ... BIND cmd line arg [D:\수주데이터\2026년07월.csv] to position [1]
호출 대상이 직접 만든 도구라면, 받은 args를 그대로 출력하는 검증용 모드를 하나 마련해 두면, 이런 종류의 조사가 순식간에 끝납니다(PowerShell의 테스트 도구 모음에 있는 TestExe -echoargs와 같은 발상입니다).1 현재 5.1 환경에서 같은 것을 하고 싶다면, $args를 나열하기만 하는 작은 .ps1이나, 인자를 그대로 표시하기만 하는 작은 EXE를 하나 준비해 두면 충분합니다.
9. 실무의 정석(판단표)
| 상황 | 선택지 | 판단 기준 |
|---|---|---|
고정 문자열 인자(%를 포함하지 않음) |
--% / 일반 호출 |
이스케이프가 번거로우면 --%가 가장 빠르다. 다만 변수는 사용할 수 없다1 |
| 변수의 값을 전달한다 | 배열 스플래팅 & $exe @args |
첫 번째 선택지. 공백・한글・기호를 포함해도 스스로 따옴표를 붙이지 않는다 |
| 5.1과 7 양쪽에서 같은 방식으로 작성하고 싶다 | 배열 스플래팅 | 작성 방식은 공통이지만, 5.1에서는 빈 문자열이나 따옴표를 포함한 인자가 망가진다(아래 주석)1 |
| 5.1에서 빈 문자열・따옴표를 포함한 인자를 전달한다 | ProcessStartInfo + 자체 이스케이프 | 5.1의 명령줄 재구성을 거치지 않으므로 확실하다(5장) |
| 인자를 하나씩 완전히 제어하고 싶다(7 전용) | ProcessStartInfo + ArgumentList |
따옴표 조립을 .NET에 맡길 수 있다. .NET Core 2.1 이후의 API3 |
| 별도 창・다른 사용자・승격이 필요하다 | Start-Process | -Wait -PassThru로 ExitCode를 얻는다. 출력은 파일로 리다이렉트9 |
| 배치(.bat)에 값을 전달한다 | 환경 변수・임시 파일 경유 | 신뢰할 수 없는 입력을 인자로 전달하지 않는다(공식 경고)1 |
| 출력이 문자 깨짐을 일으킨다 | [Console]::OutputEncoding(수신) / $OutputEncoding(송신) |
방향에 따라 설정이 다르다. 프로세스 단위라면 StandardOutputEncoding2 |
| 성공・실패 판정 | $LASTEXITCODE |
0이 아니면 기본값으로는 catch에 잡히지 않는다. stderr로의 출력은 실패를 의미하지 않는다46 |
| stdout과 stderr를 구분하고 싶다 | 별도로 리다이렉트하여 받는다 | 2>&1 합류 후의 타입은 5.1과 7.4 이후에서 다르다78 |
| 무엇이 전달되었는지 알 수 없다 | Trace-Command -Name ParameterBinding |
추측으로 이스케이프를 늘리기 전에 실측한다5 |
10. 정리
- PowerShell은 외부 프로그램의 인자도 인자 모드로 해석합니다. 기호에는 구문상의 의미가 있으므로, cmd.exe에서 동작하는 문자열이 그대로 통한다고는 할 수 없습니다.
- PowerShell 7.3에서 인자 전달 방식이 변경되어(호환성 파괴 변경), 삽입된 따옴표와 빈 문자열이 보존되도록 바뀌었습니다. Windows의 기본값은
Windows모드이며, cmd.exe나 배치 등만 Legacy 방식이 됩니다. --%는 고정 문자열 전용의 최후 수단입니다.%VAR%가 반드시 전개되고, PowerShell 변수를 사용할 수 없으며, 효과는 줄바꿈이나 파이프까지라는 제약을 이해하고 사용하세요.- 변수를 전달한다면 배열 스플래팅이 첫 번째 선택지입니다. PowerShell 7이라면 ProcessStartInfo의
ArgumentList도 사용할 수 있습니다(5.1에는 존재하지 않습니다). 백슬래시는 PowerShell의 이스케이프 문자가 아니라는 점이 혼란의 원인입니다. - 표준 출력과 표준 오류 양쪽을 리다이렉트할 때는, 반드시 양쪽을 동시에 읽어 들이세요. 한쪽을 동기적으로 끝까지 읽는 방식은 데드락에 빠집니다.
- 문자 깨짐은 방향에 따라 대처가 다릅니다. 수신은
[Console]::OutputEncoding, 송신은$OutputEncoding, 프로세스 단위라면StandardOutputEncoding. - 성공・실패는
$LASTEXITCODE. stderr로의 출력은 실패가 아닙니다. stdout과 stderr를 구분하고 싶다면,2>&1로 합류시키지 않고 별도로 받으세요(합류 후의 타입은 5.1과 7.4 이후에서 다릅니다).
샘플 코드 다운로드
이 글에서 다룬 코드는, 그대로 실행할 수 있는 형태로 정리하여 배포하고 있습니다. 인자에 따옴표를 붙이는 모듈과, ProcessStartInfo에 의한 실행이 포함되어 있습니다.
이 글의 샘플은 PowerShell 7.6에서 실제로 실행하여 검증했습니다(Pester 22건). zip에 포함된 Invoke-SampleTests.ps1을 실행하면, 여러분의 환경에서도 같은 검증을 재현할 수 있습니다.
# 구문 분석 + 정적 분석 + Pester 테스트
./Invoke-SampleTests.ps1
설정값(경로, 서버명, 테넌트 ID 등)은 예시입니다. 그대로 운영 환경에서 실행하지 말고, 자사 환경에 맞게 바꿔서 사용하세요.
관련 글
- PowerShell의 에러 처리와 재실행 설계 ── try/catch가 먹히지 않는 함정부터 exit code·재시도의 정석까지
- Windows PowerShell 5.1과 PowerShell 7의 차이 ── 사내 스크립트 이행 실무 가이드
- 그 배치 파일, PowerShell로 이전해야 할까요? ── cmd/bat 자산의 재고 조사와 이전 판단
- Windows 앱에서 자식 프로세스를 안전하게 다루기 위한 체크리스트 - Job Object, 종료 전파, 표준 입출력, watchdog의 베스트 프랙티스
- Windows의 문자 코드와 개행 코드를 정리한다 - Shift_JIS / UTF-8 / UTF-16, 문자 깨짐, CRLF / LF, 왜 혼란스러운가
- PowerShell 스크립트의 인수 설계와 모듈화 ── 「작동하는 스크립트」에서 「남에게 넘길 수 있는 스크립트」로
관련 상담 영역
합동회사 코무라소프트에서는 배치 자산의 PowerShell화, 외부 도구・사내 EXE를 조합한 자동화 처리의 설계, 「환경에 따라 동작하기도 하고 안 하기도 하는」 스크립트의 원인 조사를 다루고 있습니다.
참고 링크
-
Microsoft Learn, about_Parsing. 식 모드와 인자 모드의 구분, 인자 모드의 메타 문자, 백틱에 의한 이스케이프, 네이티브 명령으로 전달되는 인자가 해석 후 공백으로 구분된 하나의 문자열로 결합되는 것, PowerShell 3.0 이후의 정지 구문 분석 토큰
--%의 사양(환경 변수만 전개,%%이스케이프 불가, 효과는 줄바꿈이나 파이프까지, 리다이렉션 불가), PowerShell 7.3에서 네이티브 명령의 명령줄 해석이 변경된 호환성 파괴 변경,$PSNativeCommandArgumentPassing의 값(Legacy/Standard/Windows)과 Windows에서의 기본값, Windows 모드에서 cmd.exe・cscript.exe・wscript.exe 및 .bat/.cmd/.js/.vbs/.wsf가 Legacy 방식이 되는 것, 백슬래시가 PowerShell의 이스케이프 문자가 아니라는 것, 배치 파일에 신뢰할 수 없는 입력을 전달하지 말라고 경고하고 있는 것, 7.3에서 네이티브 명령의 인자 바인딩을 추적할 수 있게 된 것에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 ↩26 -
Microsoft Learn, about_Preference_Variables.
$PSNativeCommandArgumentPassing이 플랫폼에 따라 기본값이 다른 환경 설정 변수라는 것,$OutputEncoding이 PowerShell에서 다른 애플리케이션으로 문자열을 보낼 때 사용되는 인코딩을 결정한다는 것에 대해. ↩ ↩2 ↩3 ↩4 ↩5 -
Microsoft Learn, ProcessStartInfo.ArgumentList 속성. 인자를 컬렉션으로 개별 지정할 수 있고, 필요한 따옴표 붙이기와 이스케이프를 런타임이 수행한다는 것, 백슬래시가 이스케이프 문자로 취급된다는 것, 그리고 적용 대상이 .NET Core 2.1 이후(.NET Framework에는 존재하지 않음)이므로, .NET Framework 위에서 동작하는 Windows PowerShell 5.1에서는 사용할 수 없다는 것에 대해. 아울러 Process.StandardOutput 속성의, 표준 출력과 표준 오류 양쪽을 리다이렉트하여 동기적으로 읽는 경우에 발생할 수 있는 데드락과 그 회피 방법(한쪽을 비동기로 읽음)에 대한 주의 사항도 참조. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, about_Error_Handling. 네이티브 명령의 0이 아닌 종료 코드가
$?를$false로 만들고$LASTEXITCODE에 저장되는 한편, ErrorRecord를 생성하지 않고 try/catch에도 잡히지 않는다는 것에 대해. ↩ ↩2 ↩3 -
Microsoft Learn, Trace-Command.
-Name ParameterBinding에 의한 매개변수 바인딩 추적,-PSHost에 의한 호스트로의 출력,-Expression에 의한 추적 대상 지정에 대해. ↩ ↩2 ↩3 -
Microsoft Learn, Differences between Windows PowerShell 5.1 and PowerShell 7.x. PowerShell 7에서는 네이티브 명령이 stderr에 쓴 것만으로는
$?가$false가 되지 않고, 0이 아닌 종료 코드일 때만$false가 되도록 변경된 것에 대해. ↩ ↩2 -
Microsoft Learn, about_Redirection. PowerShell 출력 스트림의 번호 체계,
2>&1에 의한 오류 스트림의 성공 스트림으로의 합류, 네이티브 명령의 stderr 출력 처리에 대해. ↩ ↩2 -
Microsoft Learn, What’s New in PowerShell 7.4. 리다이렉션 연산자가 네이티브 명령의 출력을 바이트 스트림으로 유지하게 되어, PowerShell이 내용을 해석하거나 서식을 추가하지 않게 된 것(호환성 파괴 변경), 그 결과 2>&1로 합류시킨 stderr가 문자열 데이터로 취급된다는 것에 대해. ↩ ↩2
-
Microsoft Learn, Start-Process. 기본값으로는 새 프로세스의 완료를 기다리지 않는다는 것,
-Wait에 의한 대기,-PassThru에 의한 Process 객체 취득과ExitCode,-RedirectStandardOutput/-RedirectStandardError에 의한 파일로의 리다이렉션,-Verb RunAs에 의한 승격,-Credential에 의한 다른 사용자 실행에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
PowerShell 스크립트가 느릴 때 살펴볼 곳 ── 배열・파이프라인・대조의 급소
PowerShell 스크립트가 느려지는 대표적인 원인을 정리합니다. 배열의 +=가 O(n^2)이 되는 이유, 파이프라인과 foreach의 차이, 대조의 해시테이블화, 파일 I/O 개선, 그리고 올바른 측정법까지 실무 관점에서 설명합니다.
Write-Host를 그만두다 ── PowerShell의 출력 스트림과 로그 설계
PowerShell 6가지 출력 스트림의 구분 사용법, Write-Host가 안고 있는 문제와 올바른 쓰임새, 함수의 반환값이 오염되는 원인, -Verbose나 -InformationVariable을 통한 호출 측 제어, 구조화 로그를 남기는 방...
PowerShell의 병렬 처리 ── ForEach-Object -Parallel과 작업(Job)의 구분 사용
ForEach-Object -Parallel·Start-ThreadJob·Start-Job의 차이와 구분 사용, $using:와 스레드 안전성, ThrottleLimit을 정하는 법, 오히려 느려지는 경우까지 실무 관점에서 정리합니다.
PowerShell에서 자격 증명을 안전하게 다루기 ── 평문 비밀번호를 스크립트에서 추방한다
PowerShell 스크립트의 평문 비밀번호를 안전한 보관으로 옮기는 절차를 정리합니다. SecureString의 실체와 한계, Export-Clixml에 의한 DPAPI 저장의 구조, SecretManagement/SecretStore를 언제 ...
PowerShell 스크립트의 인수 설계와 모듈화 ── 「작동하는 스크립트」에서 「남에게 넘길 수 있는 스크립트」로
PowerShell 스크립트를 다른 사람에게 넘길 수 있는 품질로 끌어올리는 절차를 정리합니다. param 블록과 [CmdletBinding()], 입력 검증, 파이프라인 입력, -WhatIf 대응, .psm1 모듈화, 사내 공유와 Git 관리의...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- PowerShell에서 exe를 호출하면 인자의 큰따옴표가 사라집니다. 왜 그런가요?
- PowerShell이 인자를 해석한 뒤 외부 프로그램에 전달하는 방식 때문입니다. Windows PowerShell 5.1에서는 해석된 인자가 공백으로 구분된 하나의 문자열로 다시 조립되기 때문에, 포함된 따옴표가 사라지거나 빈 문자열 인자가 소실됩니다. PowerShell 7.3에서는 이 동작이 변경되어 포함된 따옴표와 빈 문자열 인자가 보존되도록 바뀌었습니다. 주의할 점은 5.1에서는 배열 스플래팅(& $exe @args)을 사용해도 이 재조립 과정을 거친다는 것입니다. 따옴표가 포함된 값이나 빈 문자열을 확실히 전달하고 싶다면 스플래팅은 해결책이 되지 못합니다. 고정 문자열이라면 정지 구문 분석 토큰 --%를 쓸 수 있지만, 변수가 포함되면 사용할 수 없습니다. 확실한 방법은 Windows의 명령줄 규칙에 맞춰 직접 따옴표를 붙이고, ProcessStartInfo의 Arguments 문자열로 전달하는 것입니다(ArgumentList는 .NET Core 2.1 이후의 API로 5.1에서는 사용할 수 없습니다). 본문에 그 구현 예시를 실었습니다.
- --%(정지 구문 분석 토큰)를 사용하면 어떤 인자든 안전하게 전달할 수 있나요?
- 아니요, 제약이 많아 만능은 아닙니다. --% 이후는 리터럴로 취급되지만, %USERPROFILE% 같은 환경 변수 참조만은 전개되기 때문에 %를 포함한 문자열은 의도치 않게 치환됩니다(%%에 의한 이스케이프도 사용할 수 없습니다). 또한 PowerShell 변수는 전혀 전개할 수 없고, 효과는 다음 줄바꿈이나 파이프 기호까지이며, 리다이렉션도 쓸 수 없습니다. 변수의 값을 전달해야 하는 시점에서 --%는 사용할 수 없으므로, 그럴 때는 ProcessStartInfo나 Start-Process를 검토하세요.
- 배치 파일(.bat)에 외부에서 받은 문자열을 전달해도 괜찮나요?
- 신뢰할 수 없는 입력을 배치 파일에 전달하는 것은 피하세요. Windows에서는 배치 파일로의 인자가 cmd.exe에 원시 명령줄 문자열로 전달되기 때문에, 공식 문서도 「신뢰할 수 없는 입력은 다른 방법으로 전달할 것」이라고 명시하고 있습니다. 파일명이나 사용자 입력을 그대로 연결하면 명령 삽입의 여지가 남습니다. 전달할 값은 임시 파일이나 환경 변수를 경유하게 하거나, 배치를 PowerShell 스크립트로 대체하는 것이 안전합니다.
- 외부 명령의 출력이 한국어(또는 일본어)에서 문자 깨짐을 일으킵니다. 어디를 고쳐야 하나요?
- PowerShell이 외부 명령의 표준 출력을 복호화할 때 사용하는 [Console]::OutputEncoding을, 그 명령이 실제로 출력하는 인코딩에 맞추세요. UTF-8로 출력하는 도구라면 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8을 설정한 뒤 호출합니다. 반대로 PowerShell에서 외부 명령으로 문자열을 파이프로 보낼 때는 $OutputEncoding이 사용되므로, 송신 측과 수신 측 두 가지를 따로 생각하는 것이 요령입니다. 설정은 세션 단위이므로, 스크립트 안에서 일시적으로 변경했다면 반드시 원래대로 되돌리세요.
- Start-Process와 직접 호출(&)은 어떻게 구분해서 사용하나요?
- 출력을 파이프라인으로 받고 싶다, 종료 코드만 보면 된다는 일반적인 경우에는 직접 호출(&이나 단순한 명령 이름)이 기본입니다. Start-Process는 별도 창에서 실행하고 싶다, 다른 사용자로 실행하고 싶다, 관리자 권한으로 승격하고 싶다(-Verb RunAs), 표준 출력을 파일로 리다이렉트하고 싶다는 등 「실행 방식」을 제어하고 싶을 때 사용합니다. 다만 Start-Process는 기본값으로 완료를 기다리지 않으므로, 종료 코드가 필요하다면 -Wait와 -PassThru를 함께 사용해 ExitCode 속성을 확인해야 합니다.