한글이 든 PowerShell 스크립트를 실행했더니 문자열에 종결자가 없다는 구문 오류가 나는데, 파일을 열어 보면 따옴표가 멀쩡히 닫혀 있는 경우가 있습니다. 원인은 코드가 아니라 저장 방식입니다. Windows PowerShell 5.1은 BOM이 없는 .ps1 파일을 UTF-8이 아니라 시스템 코드페이지(한국어 윈도우는 CP949)로 읽고, 그 과정에서 한글 바로 뒤의 따옴표나 줄바꿈이 먹혀 버립니다. 같은 파일 안에서도 어떤 한글은 통과하고 어떤 한글은 깨지기 때문에 원인을 찾기가 유독 어렵습니다.
윈도우에서 한글이 깨지는 지점 전체(터미널 출력·파이썬·파일 저장)는 윈도우에서 클로드 코드 한글이 깨질 때 — CP949 정리에 지도처럼 정리해 두었습니다. 이 글은 그중 .ps1 파일 하나가 왜 그런 식으로 깨지는지, 어떻게 진단하고 내용을 망치지 않고 고치는지만 깊게 다룹니다.
증상 — 따옴표가 닫혀 있는데 닫히지 않았다고 한다
아래처럼 한 줄짜리 스크립트를 BOM 없는 UTF-8로 저장하고 Windows PowerShell 5.1로 실행하면 이렇게 나옵니다.
# run.ps1 (UTF-8, BOM 없음)
Write-Output "테스트"
PS> powershell -NoProfile -File .\run.ps1
위치 ...\run.ps1:1 문자:14
+ Write-Output "?뚯뒪??
+ ~~~~~~
문자열에 " 종결자가 없습니다.
+ CategoryInfo : ParserError: (:) [], ParseException
+ FullyQualifiedErrorId : TerminatorExpectedAtEndOfString
영문 UI의 PowerShell이라면 같은 오류가 The string is missing the terminator: ".로 나옵니다. 오류 ID는 어느 언어든 TerminatorExpectedAtEndOfString입니다. 화면의 "?뚯뒪??를 보면 이미 한글이 깨져 있고, 닫는 따옴표가 사라져 있습니다.
제가 처음 이걸 겪은 건 작업 스케줄러로 돌리던 발행 스크립트였습니다. 토요일 오전 9시에 작업이 정상적으로 트리거됐는데 그날 글이 하나도 안 나갔고, 남은 흔적은 작업 결과 코드 1뿐이었습니다. 스크립트가 남기는 로그 파일이 0개였습니다. 첫 줄의 로그 기록조차 실행되지 않은 것인데, PowerShell은 파일 전체를 먼저 구문 분석하고 하나라도 오류가 있으면 아무 줄도 실행하지 않기 때문입니다. 그래서 이 문제는 "스크립트 중간에서 죽었다"가 아니라 "시작조차 안 했다"로 보입니다.
원인 — 3바이트 한글을 2바이트씩 끊어 읽는다
UTF-8에서 한글 한 글자는 3바이트입니다. CP949는 한글 한 글자를 2바이트로 씁니다. BOM이 없으면 PowerShell 5.1은 파일이 UTF-8이라는 걸 모르고 CP949 규칙대로 바이트를 두 개씩 짝지어 읽습니다.
| 저장된 바이트 (UTF-8) | 5.1이 읽는 방식 (CP949) | 결과 |
|---|---|---|
| 한글 2글자 = 6바이트 | 2바이트씩 3번 → 짝이 딱 맞음 | 글자는 깨지지만 뒤의 따옴표는 살아남음 |
| 한글 3글자 = 9바이트 | 2바이트씩 4번 + 1바이트 남음 | 남은 1바이트가 뒤의 따옴표를 짝으로 끌어가 ? 한 글자가 됨 |
위 예시의 "테스트"를 바이트로 보면 22 ED 85 8C EC 8A A4 ED 8A B8 22입니다. CP949로 읽으면 마지막에 남은 B8이 닫는 따옴표 22와 묶여 ?로 바뀌고, 따옴표는 사라집니다. 파서 입장에서는 문자열이 열리기만 하고 닫히지 않았으니 TerminatorExpectedAtEndOfString을 냅니다.
직접 여러 문자열로 실험해 보면 이렇게 갈립니다. 모두 BOM 없는 UTF-8로 저장해 powershell -NoProfile -File로 실행한 결과입니다.
| 파일 내용 | 따옴표·줄바꿈 바로 앞 한글 | 결과 |
|---|---|---|
Write-Output "발행 완료" | 완료 = 6바이트 | 실행됨. 출력은 諛쒗뻾 ?꾨즺로 깨짐 |
Write-Output "한글" | 6바이트 | 실행됨. 출력은 깨짐 |
Write-Output "테스트" | 9바이트 | 종결자 오류 |
Write-Output "끝" | 3바이트 | 종결자 오류 |
# 한글 주석입니다 다음 줄에 Write-Output "ok" | 주석입니다 = 15바이트 | 오류 없이 아무것도 출력 안 됨 |
짝수·홀수로 설명했지만 이건 실험에서 그대로 맞아떨어진 경험칙이고, 바이트 조합에 따라 디코더가 짝을 다르게 끊을 수도 있습니다. 중요한 건 "한글이 있으면 깨진다"가 아니라 "어떤 한글은 운 좋게 통과한다"는 점입니다. 저도 BOM 없이 저장된 스크립트 중 일부는 멀쩡히 돌고 있었고, 그래서 한동안 인코딩을 의심하지 않았습니다.
가장 위험한 경우 — 오류 없이 줄이 사라진다
표의 마지막 줄이 가장 고약합니다. 한글 주석 끝에 남은 1바이트가 따옴표 대신 줄바꿈 문자를 먹으면, 다음 줄이 주석의 일부가 됩니다. 구문 오류가 없으니 아무 경고 없이 실행되고, 종료 코드도 0입니다. 그 줄의 명령만 조용히 빠집니다.
# 저장된 내용
# 한글 주석입니다
Write-Output "ok"
# 5.1이 실제로 본 내용 (줄바꿈이 사라져 한 줄이 됨)
# ?쒓? 二쇱꽍?낅땲??Write-Output "ok"
오류 위치가 엉뚱한 줄을 가리킨다
따옴표가 하나 사라지면 문자열이 다음 따옴표까지 이어집니다. 그래서 오류 위치가 한글이 있는 줄이 아니라 그 뒤의 엉뚱한 줄로 찍히고, 블록 안이라면 중괄호 오류까지 같이 나옵니다.
foreach ($i in 1..2) {
Write-Output "테스트" # ← 실제 원인은 2번째 줄
}
Write-Output "done" # ← 오류는 4번째 줄로 보고됨
문자열에 " 종결자가 없습니다. (4번째 줄)
문 블록 또는 형식 정의에 닫는 '}'가 없습니다. (1번째 줄)
TerminatorExpectedAtEndOfString / MissingEndCurlyBrace
오류가 가리키는 줄의 코드를 아무리 봐도 문제가 없는 이유가 이것입니다.
해결 — 내용을 UTF-8로 읽은 뒤 BOM을 붙여 다시 쓴다
BOM(바이트 순서 표시)은 파일 맨 앞의 3바이트 EF BB BF입니다. 이게 있으면 PowerShell 5.1도 파일을 UTF-8로 읽습니다. 고치는 방법은 둘입니다.
방법 A — 바이트 앞에 BOM만 붙인다 (가장 안전)
파일 내용을 글자로 해석하지 않고 바이트 그대로 둔 채 앞에 3바이트만 붙입니다. 디코딩 과정이 없으니 내용이 바뀔 여지가 없습니다. 단, 이미 BOM이 있는 파일에 또 붙이지 않도록 먼저 확인합니다.
$p = 'C:\scripts\run.ps1'
$b = [System.IO.File]::ReadAllBytes($p)
if (-not ($b.Length -ge 3 -and $b[0] -eq 0xEF -and $b[1] -eq 0xBB -and $b[2] -eq 0xBF)) {
[System.IO.File]::WriteAllBytes($p, [byte[]](0xEF,0xBB,0xBF) + $b)
}
방법 B — UTF-8로 읽어서 BOM 포함으로 다시 쓴다
$p = 'C:\scripts\run.ps1'
$txt = Get-Content $p -Raw -Encoding UTF8
[System.IO.File]::WriteAllText($p, $txt, (New-Object System.Text.UTF8Encoding($true))) # $true = BOM 포함
-Encoding UTF8을 빼면 BOM을 붙여도 고쳐지지 않습니다.5.1의
Get-Content는 BOM 없는 파일을 실행할 때와 똑같이 CP949로 읽습니다. 그렇게 이미 깨진 글자를 BOM 포함으로 저장하면 깨진 상태가 UTF-8로 확정됩니다. 실제로 이렇게 고친 파일은 맨 앞에 EF BB BF가 붙어 있는데도 똑같이 종결자 오류로 실패했습니다. 따옴표가 이미 ?로 바뀐 채 저장됐기 때문입니다. 이렇게 망가졌다면 원본(깃 이력 등)에서 다시 가져와야 합니다.또
[System.IO.File] 메서드에는 상대 경로 대신 전체 경로를 넘기세요. .NET의 현재 폴더는 PowerShell에서 cd로 옮긴 위치와 다를 수 있습니다.
처음부터 BOM으로 저장하게 하기
- VS Code: 오른쪽 아래 상태 표시줄의 인코딩(
UTF-8)을 누르고Save with Encoding→UTF-8 with BOM. PowerShell 파일에만 적용하려면 설정에 아래를 넣습니다.
"[powershell]": {
"files.encoding": "utf8bom"
}
- AI 코딩 도구가 만든 .ps1: 파일 쓰기 도구는 대개 BOM 없는 UTF-8로 저장합니다. 저는 9월에 블로그 발행 자동화용 스크립트를 새로 만들면서 이 문제를 한 번 더 밟았습니다. 그 뒤로는 .ps1을 만들면 위의 방법 B로 한 번 더 저장하는 것을 규칙으로 두었습니다.
PowerShell 7(pwsh)은 사정이 다르다
PowerShell 7은 BOM이 없는 파일을 UTF-8로 읽고, 파일을 쓸 때 기본값도 BOM 없는 UTF-8입니다. 그래서 같은 파일이 pwsh에서는 멀쩡하고 5.1에서만 깨집니다. 문제는 윈도우에 기본으로 깔린 것은 5.1이고, 작업 스케줄러나 바로 가기에 powershell.exe로 등록돼 있으면 pwsh를 설치해도 5.1로 돈다는 점입니다. 실행 명령이 powershell.exe인지 pwsh.exe인지 먼저 확인하세요. 5.1에서 돌 가능성이 조금이라도 있으면 BOM을 붙여 두는 편이 양쪽 다 안전합니다.
덤 — 5.1에서 파일 쓰기 명령마다 기본 인코딩이 다르다
스크립트로 .ps1이나 텍스트 파일을 만들 때도 같은 함정이 있습니다. Windows PowerShell 5.1에서 한글이라는 두 글자를 각 명령으로 저장하고 바이트를 확인한 결과입니다.
| 명령 (5.1) | 저장 인코딩 | 실제 앞부분 바이트 |
|---|---|---|
Set-Content (기본) | 시스템 ANSI = CP949 | C7 D1 B1 DB |
Out-File, > (기본) | UTF-16 LE + BOM | FF FE 5C D5 00 AE |
Set-Content -Encoding UTF8 | UTF-8 + BOM | EF BB BF ED 95 9C |
같은 PowerShell 안에서도 Set-Content와 Out-File의 기본값이 다릅니다. 5.1의 -Encoding UTF8은 항상 BOM을 붙이므로 .ps1을 만들 때는 오히려 맞는 선택입니다. Out-File로 만든 UTF-16 파일도 BOM이 있어서 PowerShell은 정상 실행하지만, 깃이나 다른 도구는 이진 파일로 취급하는 경우가 많아 권하지 않습니다.
확인 방법
1. BOM이 있는지 — 첫 3바이트를 본다
# 파일 하나
[System.IO.File]::ReadAllBytes('C:\scripts\run.ps1')[0..2]
# 239 187 191 이면 BOM 있음 (= EF BB BF)
# 폴더 전체
Get-ChildItem C:\scripts -Filter *.ps1 | ForEach-Object {
$b = [System.IO.File]::ReadAllBytes($_.FullName)
[pscustomobject]@{
Name = $_.Name
BOM = ($b.Length -ge 3 -and $b[0] -eq 0xEF -and $b[1] -eq 0xBB -and $b[2] -eq 0xBF)
}
}
제 스케줄러 스크립트 폴더는 지금 .ps1 19개가 전부 EF BB BF로 시작합니다. 사고 이후 일부만 고치지 않고 전부 맞췄습니다. 앞에서 본 것처럼 BOM 없이도 우연히 살아남는 파일이 있어서, 하나씩 확인하는 것보다 일괄로 통일하는 편이 낫습니다.
2. 실행하지 않고 구문만 검사한다
$t = $null; $e = $null
[System.Management.Automation.Language.Parser]::ParseFile('C:\scripts\run.ps1', [ref]$t, [ref]$e) | Out-Null
$e | Select-Object ErrorId, Message, @{n='Line'; e={$_.Extent.StartLineNumber}}
BOM 없는 파일에서는 TerminatorExpectedAtEndOfString이 나오고, BOM을 붙인 같은 파일에서는 오류가 0개로 나옵니다. 스케줄러에 걸린 스크립트가 로그 하나 없이 실패했다면 코드를 고치기 전에 이것부터 돌려 보세요.
주석이 다음 줄을 삼킨 경우는 구문상 아무 문제가 없어 오류가 0개로 나옵니다. 그래서 확인은 구문 검사가 아니라 1번의 BOM 검사가 기준입니다. 한글이 한 글자라도 들어 있는 .ps1은 BOM이 있어야 합니다.
3. 스케줄러 작업이라면 결과 코드를 본다
Get-ScheduledTaskInfo -TaskName '작업이름' | Select-Object LastRunTime, LastTaskResult
작업은 실행됐는데 LastTaskResult가 1이고 스크립트가 쓰는 로그가 하나도 없다면, 코드 버그보다 구문 분석 실패를 먼저 의심하는 게 빠릅니다.
정리
- Windows PowerShell 5.1은 BOM 없는 .ps1을 CP949로 읽습니다.
- 3바이트 한글이 2바이트씩 끊기면서 남은 1바이트가 뒤의 따옴표나 줄바꿈을 먹습니다.
- 따옴표가 먹히면
TerminatorExpectedAtEndOfString, 줄바꿈이 먹히면 오류 없이 다음 줄이 사라집니다. - 어떤 한글은 운 좋게 통과해서 일부 파일만 실패합니다. 오류 줄 번호도 엉뚱한 곳을 가리킵니다.
- 고칠 때는 바이트 앞에
EF BB BF를 붙이거나,-Encoding UTF8로 읽은 뒤 BOM 포함으로 다시 씁니다. - 5.1의
Set-Content기본은 CP949,Out-File기본은 UTF-16입니다.
자주 묻는 것
한글을 안 쓰고 영어 주석만 쓰면 BOM이 없어도 되나요?
됩니다. 영문·숫자·기호(ASCII)만 있는 파일은 UTF-8과 CP949에서 바이트가 똑같아서 어느 쪽으로 읽어도 결과가 같습니다. 다만 출력 문구 한 줄, 주석 한 글자라도 한글이 들어가는 순간 조건이 바뀝니다. 나중에 누가 한글을 넣을지 모르니 처음부터 BOM으로 두는 편이 안전합니다.
BOM을 붙였는데도 똑같은 오류가 납니다.
BOM을 붙이기 전에 파일을 CP949로 읽어서 다시 저장한 경우입니다. 이때는 따옴표가 이미 ?로 바뀐 채 저장돼 있어서 BOM으로 되살릴 수 없습니다. 파일을 메모장이나 VS Code로 열어 한글이 ?나 한자 비슷한 글자로 보이면 이 경우입니다. 깃 이력이나 백업에서 원본을 가져와 방법 A로 BOM만 붙이세요.
오류가 안 나는 스크립트는 BOM이 없어도 괜찮은 건가요?
아닙니다. 실행은 되더라도 한글 문자열은 이미 깨진 채로 처리되고 있습니다. 위 실험에서 "발행 완료"는 오류 없이 돌았지만 화면에는 諛쒗뻾 ?꾨즺로 찍혔습니다. 그 문자열을 파일 이름이나 메시지로 쓰고 있다면 결과물도 깨져 있습니다. 게다가 주석이 줄을 삼키는 경우처럼 오류 없이 명령이 빠지는 경우도 있으니, "돌아간다"는 사실이 안전하다는 뜻은 아닙니다.
.sh나 JSON 파일에도 BOM을 붙여야 하나요?
아니요. BOM이 필요한 건 Windows PowerShell 5.1이 읽는 .ps1(과 .psm1·.psd1) 쪽입니다. 셸 스크립트나 JSON·YAML 같은 파일은 BOM이 있으면 오히려 첫 줄을 잘못 읽는 도구가 많습니다. 폴더 전체에 일괄로 붙이지 말고 확장자를 골라서 붙이세요.
