수정 이력(6건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 기사 맨 앞에 「이 기사의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건)에 대응해 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참고하세요.
- 먼저 자기 환경을 확인하는 절을 추가했습니다(7.3이 경계이며, 7.0〜7.2는 새 방식에 들어가지 않습니다). 따옴표를 붙이는 함수의 변환 전후 대응표를 7행 추가하고, 백슬래시가 다음 문자에 따라 처리가 달라지는 비대칭성을 보완했습니다. deadlock이 발생하는 순서 그림과, UTF-8 출력을 CP932로 읽었을 때의 문자 깨짐 대응표도 추가했습니다.
- 본문의 관련 기사 링크 문구를 링크 대상의 현재 제목에 맞췄습니다.
- 제목의 「--%」가 표시상 대시 한 글자로 변환되어 본문 표기와 어긋나 보이던 표시 문제를 고쳤습니다.
- 최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22174961)
아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.
Go Komura (2026). 「PowerShell에서 외부 exe를 올바르게 호출하기 ── 인자의 따옴표·종료 코드·문자 깨짐의 함정」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/powershell-native-command-arguments/
- DOI(등록된 아카이브)
- 10.5281/zenodo.22174961
- DOI(마지막 등록 버전)
- 10.5281/zenodo.22174962
「로컬 명령 프롬프트에서는 되는데, PowerShell 스크립트로 옮긴 순간 외부 도구가 오류를 반환한다」── 배치를 PowerShell로 옮기거나, 사내 EXE·OSS CLI를 자동화할 때 거의 반드시 마주치는 현상입니다. 원인 대부분은 로직이 아니라 인자가 프로그램에 닿기까지의 경로에 있습니다. 경로에 공백이 들어가거나, 인자에 큰따옴표를 넣거나, %나 ( )가 들어 있는 문자열을 넘깁니다. 그중 하나만 있어도 넘겼다고 생각한 인자가 다른 형태로 바뀝니다.
게다가 골치 아픈 점은 이 동작이 PowerShell 7.3에서 바뀌었다는 것입니다. 5.1에서 돌아가도록 쓴 우회가 7에서는 이중 이스케이프가 되고, 7에서 쓴 스크립트는 5.1에서 깨집니다. 일본어 환경에서는 출력 문자 깨짐까지 겹칩니다.
이 글에서는 PowerShell에서 외부 프로그램(native command)을 호출할 때 인자가 넘어가는 방식을 사양부터 잡고, --%를 어디에 써야 하는지, 값을 확실히 넘기고 싶을 때의 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에서 인자 전달 방식이 바뀌었습니다(breaking change). 문자열에 넣은 따옴표와 빈 문자열 인자가 유지되며,
$PSNativeCommandArgumentPassing으로 동작을 고를 수 있습니다. Windows의 기본값은Windows입니다.12 Windows모드에서는 cmd.exe·cscript.exe·wscript.exe와.bat.cmd.js.vbs.wsf만 기존(Legacy) 전달 방식이 됩니다. 예전 배치 자산과의 호환을 위한 예외입니다.1- 구문 분석 중지 토큰
--%는 「이후를 통째로 리터럴로 다룬다」는 마지막 수단입니다. 다만%VAR%형식의 환경 변수만은 확장되고, PowerShell 변수는 전혀 쓸 수 없으며, 효과는 줄바꿈이나 파이프까지입니다. 리다이렉트도 쓸 수 없습니다.1 - 변수 값을 확실히 넘기려면 배열로 넘기는 것이 첫 후보입니다.
& $exe @argArraysplatting은 각 요소가 독립된 인자가 됩니다.ProcessStartInfo.ArgumentList라면 따옴표 조립을 .NET 쪽에 맡길 수 있지만, 이 API는 .NET Core 2.1 이후라 5.1에서는 쓸 수 없습니다.13 - 배치 파일에 신뢰할 수 없는 입력을 넘기면 안 됩니다. Windows에서는 배치 인자가 생 command line 문자열로 cmd.exe에 넘어가므로, 공식 문서도 「신뢰할 수 없는 입력은 다른 방법으로 넘겨라」고 경고합니다.1
- 성패는
$LASTEXITCODE로 판정합니다. 0이 아닌 종료 코드는 기본값으로는 PowerShell 오류가 되지 않고, try/catch에도 들어가지 않습니다.4 - 문자 깨짐은 송신 측과 수신 측을 따로 고칩니다. 외부 명령 출력의 디코드는
[Console]::OutputEncoding, PowerShell에서 외부 명령으로 파이프해 보내는 문자열은$OutputEncoding이 담당합니다.2 - 막히면
Trace-Command -Name ParameterBinding으로 실제로 넘어간 인자를 봅니다. PowerShell 7.3 이후에는 native command의 인자 바인딩도 추적할 수 있습니다.15
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 23건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 왜 인자가 바뀌는가 ── argument mode라는 전제
PowerShell은 명령줄을 토큰으로 나눈 뒤 expression mode와 argument mode 중 하나로 해석합니다. 명령 호출이 나타나면 그 이후는 argument mode로 해석됩니다. argument mode에서는 입력이 기본적으로 「확장 가능한 문자열」로 다루어지고, $로 시작하면 변수 참조, 따옴표는 문자열 시작, ( )는 식 시작……처럼 기호에 구문상의 의미가 있습니다.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의 breaking change ── $PSNativeCommandArgumentPassing
PowerShell 7.3에서 이 조립 방식이 바뀌었습니다. 공식 문서가 「Windows PowerShell 5.1 동작에서의 breaking change」라고 분명히 적은 부분입니다.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 플랫폼의 native command에서만 사용할 것을 전제」한다고 명시합니다.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. 변수를 확실히 넘기기 ── 배열 splatting과 ProcessStartInfo
변수 값이 들어 있는 인자를 넘길 때의 첫 후보는 배열에 인자 하나씩 넣고 splatting하는 작성법입니다. 배열의 각 요소는 독립된 인자로 넘어가므로, 공백이 들어 있는 경로도 따옴표를 직접 쓸 필요가 없습니다.
$exe = 'C:\Program Files\MyTool\convert.exe'
$args = @(
'--input', 'D:\受注データ\2026年07月.csv' # 공백이나 일본어가 있어도 문제 없다
'--output', 'D:\出力\result.json'
'--mode', 'strict'
)
& $exe @args # 배열 splatting. 각 요소가 1인자가 된다
if ($LASTEXITCODE -ne 0) { throw "변환에 실패했습니다 (ExitCode=$LASTEXITCODE)" }
호출 연산자 &는 경로에 공백이 있는 exe를 실행할 때도 필요합니다('C:\Program Files\...'는 그대로 두면 문자열 리터럴로만 평가되고 실행되지 않습니다).
넘어갔는지 불안하면, 추측으로 이스케이프를 늘리기 전에 8장의 방법으로 실제 인자를 확인하세요. 작성법을 바꾸기 전과 후에, 도착한 인자가 같은지를 보는 것이 가장 빠릅니다.
더 확실함이 필요할 때 ── 인자를 하나씩 완전히 제어하고 싶거나, 출력 문자 코드를 프로세스 단위로 지정하고 싶을 때 ── 는 .NET의 ProcessStartInfo를 직접 씁니다. ArgumentList에 더한 값은 .NET 쪽이 적절히 따옴표를 붙이므로, PowerShell의 해석에도 command line의 재해석에도 좌우되지 않습니다.3
다만 ArgumentList는 .NET Core 2.1 이후 API이며, .NET Framework에서 동작하는 Windows PowerShell 5.1의 ProcessStartInfo에는 없습니다.3 5.1에서는 다음 장의 Arguments 문자열을 직접 조립하거나, 앞에서 말한 배열 splatting을 쓰세요.
# 【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)
# 표준 출력과 표준 오류는 「동시에」 읽는다. 한쪽을 동기적으로 다 읽고 나서
# 다른 쪽을 읽으면, 기다리는 동안 다른 쪽 파이프 버퍼가 가득 차
# 자식 프로세스가 쓰기로 블록하고, 그대로 deadlock이 된다
$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"
}
출력 리다이렉트에서 가장 많은 사고가 deadlock입니다. 파이프 버퍼에는 상한이 있고, 가득 차면 자식 프로세스는 쓰기로 블록합니다. 따라서 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/>= deadlock"]
B --> C --> D --> E
A --> E --> F
그림 1: 한쪽 스트림만 동기적으로 다 읽으려 하면 멈추는 흐름
위 코드처럼 양쪽을 비동기로 읽기 시작한 뒤 기다리거나, 한쪽만 리다이렉트하는 설계로 하세요. 자식 프로세스 처리 전반은 「Windows 앱에서 자식 프로세스를 안전하게 다루는 체크리스트」도 참고하세요.
Windows PowerShell 5.1에서 ProcessStartInfo를 쓸 때는 Arguments에 직접 따옴표를 붙인 하나의 문자열을 넘기게 됩니다. 이 조립을 손으로 쓰는 것은 사고의 원인이므로, 5.1에서는 배열 splatting(& $exe @args)을 첫 후보로 하세요.
# 【5.1】ArgumentList가 없으므로, 따옴표가 들어 있는 하나의 문자열을 직접 조립한다
$quote = {
param([string] $s)
if ($s -eq '') { return '""' } # 빈 문자열은 ""로 만들지 않으면 인자 자체가 사라진다
if ($s -notmatch '[\s"]') { return $s } # 감쌀 필요가 없으면 그대로
# Windows의 command line 해석 규칙에 맞춘다:
# (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 command line 해석 규칙의 비대칭성에 맞추었기 때문입니다.
이 규칙의 세밀함이야말로 5.1에서 직접 조립을 피해야 하는 이유입니다. 그렇다고 Windows PowerShell 5.1에서 배열 splatting이 만능인 것도 아닙니다. 넘겨진 값은 결국 기존 방식으로 command line 문자열에 다시 조립되므로, 빈 문자열 인자는 사라지고 따옴표가 들어 있는 값은 변형됩니다.1 따라서 5.1에서는 다음과 같이 나누세요.
| 5.1에서 넘기는 인자 | 방법 |
|---|---|
| 공백이나 일본어가 들어 있는 보통 값 | 배열 splatting(& $exe @args)으로 충분 |
| 빈 문자열, 따옴표가 들어 있는 값 | ProcessStartInfo + 위의 이스케이프, 또는 --%(고정 문자열만) |
PowerShell 7에서는 두 문제 모두 해소되므로, 이 구분이 필요 없어집니다.
참고로 공식 문서는 배치 파일에 신뢰할 수 없는 입력을 넘기지 말라고 경고합니다. 배치 인자가 생 command line 문자열로 cmd.exe에 넘어가기 때문입니다.1 사용자 입력이나 파일 이름을 배치에 이어 붙여 넘기는 설계는 command injection의 온상이 됩니다. 값은 임시 파일이나 환경 변수로 주고받거나, 배치 자체를 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이 문자열을 native command로 보낼 때 쓰는 인코딩」을 정하는 환경 설정 변수입니다.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은 native command의 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 이후는 native command의 리다이렉트 출력이 바이트 스트림으로 다루어지고, 합친 뒤에는 문자열 데이터가 됩니다.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 이후에는 native command의 인자 바인딩을 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 |
| 변수 값을 넘긴다 | 배열 splatting & $exe @args |
첫 후보. 공백·일본어·기호가 있어도 직접 따옴표를 쓰지 않는다 |
| 5.1과 7 모두에서 같은 작성법으로 하고 싶다 | 배열 splatting | 작성법은 공통이지만, 5.1에서는 빈 문자열이나 따옴표가 들어 있는 인자가 깨진다(아래 주석)1 |
| 5.1에서 빈 문자열·따옴표가 들어 있는 인자를 넘긴다 | ProcessStartInfo + 자체 이스케이프 | 5.1의 command line 재구성을 거치지 않으므로 확실하다(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은 외부 프로그램의 인자도 argument mode로 해석합니다. 기호에는 구문상의 의미가 있으므로, cmd.exe에서 되는 문자열이 그대로 통한다고 할 수 없습니다.
- PowerShell 7.3에서 인자 전달 방식이 바뀌었고(breaking change), 넣은 따옴표와 빈 문자열이 유지됩니다. Windows의 기본은
Windows모드이며, cmd.exe나 배치 등만 Legacy 방식이 됩니다. --%는 고정 문자열 전용의 마지막 수단입니다.%VAR%가 반드시 확장되고, PowerShell 변수를 쓸 수 없으며, 효과는 줄바꿈이나 파이프까지라는 제약을 이해하고 쓰세요.- 변수를 넘기면 배열 splatting이 첫 후보입니다. PowerShell 7이라면 ProcessStartInfo의
ArgumentList도 쓸 수 있습니다(5.1에는 없습니다). 백슬래시는 PowerShell의 이스케이프 문자가 아니라는 점이 혼란의 원인입니다. - 표준 출력과 표준 오류를 둘 다 리다이렉트할 때는 반드시 양쪽을 동시에 읽으세요. 한쪽을 동기적으로 다 읽는 작성법은 deadlock이 됩니다.
- 문자 깨짐은 방향마다 대처가 다릅니다. 수신은
[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 앱에서 자식 프로세스를 안전하게 다루는 체크리스트
- Windows의 문자 코드와 개행 코드 ── 문자 깨짐과 CRLF/LF의 기본
- PowerShell 스크립트의 인자 설계와 모듈화 ── 「동작하는 스크립트」에서 「남에게 넘길 수 있는 스크립트」로
관련된 상담 영역
합동회사 고무라소프트에서는 배치 자산의 PowerShell화, 외부 도구·사내 EXE를 조합한 자동화 처리 설계, 「환경에 따라 되기도 하고 안 되기도 하는」 스크립트의 원인 조사를 다룹니다.
참고 링크
-
Microsoft Learn, about_Parsing. expression mode와 argument mode의 구분, argument mode의 메타 문자, 백틱 이스케이프, native command에 넘기는 인자가 해석 후 공백 구분의 한 문자열로 결합되는 것, PowerShell 3.0 이후 구문 분석 중지 토큰
--%의 사양(환경 변수만 확장,%%이스케이프 불가, 효과는 줄바꿈이나 파이프까지, 리다이렉트 불가), PowerShell 7.3에서 native command의 command line 해석이 바뀐 breaking change,$PSNativeCommandArgumentPassing의 값(Legacy/Standard/Windows)과 Windows에서의 기본값, Windows 모드에서 cmd.exe·cscript.exe·wscript.exe 및 .bat/.cmd/.js/.vbs/.wsf가 Legacy 방식이 되는 것, 백슬래시가 PowerShell의 이스케이프 문자가 아닌 것, 배치 파일에 신뢰할 수 없는 입력을 넘기지 말라고 경고하는 것, 7.3에서 native command의 인자 바인딩을 추적할 수 있게 된 것에 대해. ↩ ↩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 속성의, 표준 출력과 표준 오류를 둘 다 리다이렉트해 동기적으로 읽을 때 발생할 수 있는 deadlock과 그 회피 방법(한쪽을 비동기로 읽기)에 대한 주의 사항도 참고. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, about_Error_Handling. native command의 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에서는 native command가 stderr에 쓰기만 해서는
$?가$false가 되지 않고, 0이 아닌 종료 코드일 때만$false가 되도록 바뀐 것에 대해. ↩ ↩2 -
Microsoft Learn, about_Redirection. PowerShell 출력 스트림의 번호 체계,
2>&1에 의한 오류 스트림의 성공 스트림 합류, native command의 stderr 출력 처리에 대해. ↩ ↩2 -
Microsoft Learn, What’s New in PowerShell 7.4. 리다이렉트 연산자가 native command 출력을 바이트 스트림으로 유지하게 되어 PowerShell이 내용을 해석하거나 서식을 더하지 않게 된 것(breaking change), 그 결과 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에서는 배열 splatting(& $exe @args)을 써도 이 재조립을 거친다는 것입니다. 따옴표가 들어 있는 값이나 빈 문자열을 확실히 넘기려면 splatting으로는 해결되지 않습니다. 고정 문자열이면 구문 분석 중지 토큰 --%를 쓸 수 있지만, 변수가 있으면 쓸 수 없습니다. 확실한 방법은 Windows의 command line 규칙에 맞춰 직접 따옴표를 붙이고, ProcessStartInfo의 Arguments 문자열로 넘기는 것입니다(ArgumentList는 .NET Core 2.1 이후 API라 5.1에서는 쓸 수 없습니다). 본문에 그 구현 예를 실었습니다.
- --%(구문 분석 중지 토큰)를 쓰면 어떤 인자든 안전하게 넘길 수 있나요?
- 아니요. 제약이 많아 만능이 아닙니다. --% 이후는 리터럴로 다루지만, %USERPROFILE% 같은 환경 변수 참조만은 확장되므로 %가 들어 있는 문자열은 의도치 않게 바뀝니다(%% 이스케이프도 쓸 수 없습니다). 또한 PowerShell 변수는 전혀 확장되지 않고, 효과는 다음 줄바꿈이나 파이프 기호까지이며, 리다이렉트도 쓸 수 없습니다. 변수 값을 넘겨야 하는 시점부터 --%는 쓸 수 없으므로, 그때는 ProcessStartInfo나 Start-Process를 검토하세요.
- 배치 파일(.bat)에 외부에서 받은 문자열을 넘겨도 되나요?
- 신뢰할 수 없는 입력을 배치 파일에 넘기지 마세요. Windows에서는 배치 파일 인자가 cmd.exe에 생 command line 문자열로 넘어가므로, 공식 문서도 「신뢰할 수 없는 입력은 다른 방법으로 넘길 것」이라고 명시합니다. 파일 이름이나 사용자 입력을 그대로 이어 붙이면 command injection 여지가 남습니다. 넘길 값은 임시 파일이나 환경 변수를 거치게 하거나, 배치를 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 속성을 봐야 합니다.