작업 스케줄러는 “성공”인데 아무 일도 안 일어났을 때

윈도우 작업 스케줄러를 열어 보면 마지막 실행 결과: 0x0 (작업이 완료되었습니다)라고 적혀 있습니다. 그런데 만들어졌어야 할 파일이 없습니다. 보내졌어야 할 메일이 없습니다. 심지어 스크립트가 제일 먼저 만드는 로그 폴더조차 생기지 않았습니다. 작업 스케줄러가 성공이라는데 실행이 안 된 것처럼 보이는, 가장 짜증나는 종류의 고장입니다.

저도 매일 아침 9시에 도는 자동화에서 이걸 겪었습니다. 스케줄러 기록에는 8월 14일 09:00 실행, 결과 0x0. 실제로는 그날 결과물이 통째로 비어 있었고, 로그 폴더가 안 생긴 탓에 원인을 찾는 데 반나절이 걸렸습니다. 이 글은 그때 정리한 확인 순서입니다. 실행 자체가 아예 안 되는 경우(기록에 아무것도 안 남거나 0x1, 0x41301이 뜨는 경우)는 별도 글에서 다뤘으니, 여기서는 스케줄러는 성공이라고 말하는데 결과가 없는 상황만 파고듭니다.

먼저: 0x0은 “스크립트가 잘 돌았다”는 뜻이 아니다

이 오해가 모든 헛수고의 출발점입니다. 0x0이 보증하는 것은 딱 하나입니다. 스케줄러가 지정된 프로그램을 띄웠고, 그 프로세스가 종료 코드 0으로 끝났다. 그 프로세스가 무엇을 했는지, 안에서 에러가 났는지는 스케줄러의 관심사가 아닙니다.

문제는 powershell.exe와 cmd.exe가 웬만한 내부 실패에도 0을 반환한다는 점입니다. 스크립트 중간에서 예외가 나도, 첫 줄에서 파일을 못 찾아도, 심지어 파일 내용이 전부 깨져서 한 줄도 실행되지 않아도 셸 자체는 “나는 할 일을 마쳤다”며 0으로 끝납니다. 그래서 스케줄러 화면만 보면 매일 성공으로 기록됩니다.

스케줄러의 0x0은 “프로그램을 띄우는 데 성공했다”는 뜻입니다. “일이 처리됐다”는 뜻이 아닙니다.

1단계. 작업이 실제로 뭘 실행하는지 그대로 꺼내 본다

GUI의 요약 화면 말고, 등록된 원본을 봅니다. 여기서 이미 답이 나오는 경우가 절반쯤 됩니다.

:: 작업이 "실제로" 무엇을 실행하는지 그대로 꺼내 본다
schtasks /Query /TN "내작업이름" /XML

:: 출력에서 이 세 줄만 보면 된다
::   <Command>    실행 파일 경로
::   <Arguments>  넘어가는 인자 (여기 경로 따옴표가 빠져 있는 경우가 많다)
::   <WorkingDirectory>  ← 이게 비어 있으면 1순위 용의자

:: 마지막 실행 결과 코드만 빠르게 확인
schtasks /Query /TN "내작업이름" /FO LIST /V | findstr /C:"마지막 결과"

XML을 보면서 확인할 것은 세 가지입니다. 실행 파일 경로가 맞는지, 인자에 넘어가는 스크립트 경로에 따옴표가 제대로 붙어 있는지(경로에 공백이 있으면 따옴표 없이는 중간에서 잘립니다), 그리고 WorkingDirectory가 비어 있지 않은지.

2단계. 시작 위치가 비어 있는지 본다 — 1순위 용의자

작업 스케줄러에서 시작 위치를 지정하지 않으면, 스크립트는 C:\Windows\System32에서 시작합니다. 내 콘솔에서는 잘 돌던 상대경로가 전부 엉뚱한 곳을 가리키게 됩니다. 설정 파일을 못 읽고, 결과 파일은 System32 아래에 쓰려다 권한 때문에 실패하고, 그 실패가 조용히 삼켜집니다.

# [나쁜 예] 시작 위치가 비어 있으면 이 경로들은 C:\Windows\System32 기준으로 풀린다
Get-Content ".\config.json"          # 스케줄러에서는 파일을 못 찾는다
New-Item -ItemType Directory "logs"   # C:\Windows\System32\logs 에 만들려다 권한 오류

# [좋은 예] 스크립트 자신의 위치를 기준으로 고정한다. 맨 첫 줄에 넣는다.
Set-Location -LiteralPath $PSScriptRoot

# 파이썬이라면
# import os, sys
# os.chdir(os.path.dirname(os.path.abspath(__file__)))

시작 위치 칸을 채우는 것보다 스크립트 첫 줄에서 스스로 작업 폴더를 고정하는 쪽을 권합니다. 작업을 다시 만들거나 다른 PC로 옮겨도 따라다니기 때문입니다.

3단계. 파일 인코딩을 의심한다 (한글 경로를 쓴다면 특히)

이게 제 경우의 범인이었습니다. .ps1 파일을 BOM 없는 UTF-8로 저장했는데, Windows PowerShell 5.1은 BOM이 없으면 그 파일을 시스템 코드페이지(한국어 윈도우에서는 CP949)로 읽습니다. 스크립트 안의 한글 경로와 문자열이 전부 깨진 글자로 해석되고, 스크립트는 존재하지 않는 경로를 향해 일하다가 에러 메시지 없이 종료 코드 0으로 끝납니다. 스케줄러 기록에는 그대로 “성공”이 남습니다.

# 이 .ps1 파일이 BOM을 갖고 있는지 앞 3바이트로 확인한다
# UTF-8 BOM이면 239 187 191 (EF BB BF)이 찍힌다
Get-Content ".\daily-publish.ps1" -Encoding Byte -TotalCount 3

# BOM 없이 저장돼 있다면, 다시 BOM 포함 UTF-8로 저장한다
$text = Get-Content ".\daily-publish.ps1" -Raw -Encoding UTF8
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText((Resolve-Path ".\daily-publish.ps1"), $text, $utf8Bom)

편집기를 쓴다면 저장 형식을 “UTF-8 with BOM”으로 고정해 두세요. VS Code 하단 상태바의 인코딩 표시를 눌러 Save with Encoding > UTF-8 with BOM을 고르면 됩니다. PowerShell 7 이상은 BOM 없는 UTF-8을 제대로 읽지만, 작업 스케줄러에 powershell.exe로 등록해 두면 버전 7을 설치했더라도 5.1이 실행됩니다. 7을 쓰려면 pwsh.exe로 등록해야 합니다.

4단계. 실행 계정이 내 계정과 다르다는 걸 잊지 않는다

“사용자의 로그온 여부에 관계없이 실행”으로 걸어 두면, 스크립트는 내가 평소 쓰는 데스크톱 환경이 아닌 곳에서 돕니다. 여기서 사라지는 것들이 있습니다.

  • PATH — 사용자 단위로 설치한 python, node, git을 못 찾습니다. 명령을 못 찾아도 셸은 0으로 끝날 수 있습니다.
  • 매핑된 네트워크 드라이브 — Z:\ 같은 드라이브 문자는 로그온 세션에 붙어 있어서 그 환경에는 아예 없습니다. UNC 경로(\\서버\공유)로 바꾸세요.
  • 사용자 폴더 기준 경로 — %APPDATA%, %USERPROFILE%가 다른 곳을 가리킵니다. 토큰이나 인증 파일을 여기 두었다면 읽지 못합니다.
  • GUI가 필요한 작업 — 브라우저 창을 띄우거나 화면을 캡처하는 스크립트는 세션이 없어 그대로 멈추거나 빈 결과를 냅니다.

의심되면 짐작하지 말고 그 계정에서 직접 찍어 봅니다.

# 스케줄러가 쓰는 계정에서는 PATH부터 다르다. 작업에 이 줄만 잠깐 넣고 한 번 돌려 본다.
"실행 계정 : $env:USERNAME"                | Out-File "C:\temp\ctx.txt"
"작업 폴더 : $(Get-Location)"              | Out-File "C:\temp\ctx.txt" -Append
"python   : $((Get-Command python -ErrorAction SilentlyContinue).Source)" | Out-File "C:\temp\ctx.txt" -Append
"node     : $((Get-Command node -ErrorAction SilentlyContinue).Source)"   | Out-File "C:\temp\ctx.txt" -Append
"Z 드라이브: $(Test-Path Z:\)"             | Out-File "C:\temp\ctx.txt" -Append

# 내 콘솔에서 찍은 값과 한 줄씩 비교하면 범인이 거의 바로 나온다

증상별로 어디부터 볼지

증상의심 지점확인 방법
로그 폴더조차 안 생김스크립트가 사실상 한 줄도 안 돌았다 (인코딩·경로)앞 3바이트로 BOM 확인, XML의 Command 경로 확인
로그는 생겼는데 중간에서 끊김예외가 났지만 종료 코드가 0으로 나감래퍼에 try/catch + exit 코드 추가
파일을 못 찾는다는 기록시작 위치 미지정 (System32 기준)Set-Location $PSScriptRoot 추가
내 콘솔에선 되는데 스케줄러만 실패실행 계정·PATH·네트워크 드라이브 차이실행 계정에서 환경값 파일로 출력

근본 대책: 조용히 실패할 수 없게 래퍼를 씌운다

원인을 찾아 고쳤더라도, 다음에 또 다른 이유로 조용히 실패하면 똑같이 반나절을 씁니다. 스케줄러에는 실제 스크립트를 직접 걸지 말고 로그를 남기고 종료 코드를 전달하는 래퍼를 거는 편이 낫습니다.

# run-task.ps1 - 스케줄러에는 항상 이 래퍼를 등록한다
Set-Location -LiteralPath $PSScriptRoot

$logDir = Join-Path $PSScriptRoot "logs"
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir | Out-Null }
$log = Join-Path $logDir ("run-" + (Get-Date -Format "yyyyMMdd") + ".log")

Start-Transcript -Path $log -Append
$code = 0
try {
    & python ".\main.py"           # 실제로 돌리고 싶은 명령
    $code = $LASTEXITCODE           # 외부 프로그램의 종료 코드를 그대로 받는다
    if ($code -ne 0) { throw "main.py 가 코드 $code 로 종료" }
}
catch {
    "[FAIL] $(Get-Date -Format s) $($_.Exception.Message)" | Out-File $log -Append
    if ($code -eq 0) { $code = 1 }  # 실패인데 0이 나가는 것을 막는다
}
finally {
    Stop-Transcript
}

exit $code                          # 이 줄이 있어야 스케줄러 결과 코드가 의미를 갖는다

핵심은 마지막 exit $code 한 줄입니다. 이게 있어야 스크립트의 실패가 스케줄러 화면의 0x1로 올라오고, 그제서야 “마지막 실행 결과”라는 칸이 쓸모를 갖습니다. Start-Transcript는 화면에 찍히는 모든 출력을 파일로 함께 남겨 주므로, 다음에 문제가 생겼을 때 추측 대신 기록을 보고 시작할 수 있습니다.

작업을 새로 등록할 때는 경로 따옴표에 주의하세요. 공백이 들어간 경로는 따옴표가 없으면 중간에서 잘려 엉뚱한 파일을 실행하려다 조용히 끝납니다.

:: 작업을 등록할 때 시작 위치(/SD 아님, XML의 WorkingDirectory)를 확실히 하려면
:: GUI: 작업 속성 > 동작 > 편집 > "시작 위치(옵션)" 칸에 스크립트 폴더 경로를 적는다

:: 명령줄로 등록한다면 인자 전체를 이렇게 감싼다 (경로에 공백이 있을 때 필수)
schtasks /Create /TN "daily-publish" /SC DAILY /ST 09:00 /F ^
  /TR "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"D:\작업\run-task.ps1\""

주의할 점

  • 스케줄러에서 테스트할 때 “선택한 작업 지금 실행”만으로 확인하면 놓치는 게 있습니다. 수동 실행은 현재 로그온 세션의 영향을 받을 수 있어서, 실제 예약 시각의 무인 환경과 다르게 동작합니다. 1~2분 뒤 시각으로 트리거를 걸어 진짜 예약 실행을 한 번 봐야 합니다.
  • $ErrorActionPreference를 Continue(기본값)로 두면 오류가 나도 스크립트가 계속 진행합니다. 자동화 스크립트에서는 맨 위에 $ErrorActionPreference = "Stop"을 두는 편이 안전합니다.
  • 로그 파일은 날짜별로 나누고 오래된 것은 지우세요. 매일 도는 작업의 로그를 한 파일에 계속 붙이면 몇 달 뒤 수백 MB가 됩니다.
  • 노트북이라면 작업 속성의 “컴퓨터가 배터리로 실행 중이면 작업을 시작하지 않음” 옵션을 확인하세요. 이 경우는 아예 실행되지 않아 기록도 남지 않습니다.

마무리

지금 당장 할 수 있는 가장 작은 한 걸음은, 문제가 되는 스크립트 맨 위에 Set-Location -LiteralPath $PSScriptRoot 한 줄을 넣고 파일을 UTF-8 with BOM으로 다시 저장하는 것입니다. 조용한 실패의 상당수가 이 둘에서 나옵니다. 그다음에 래퍼를 씌워 로그와 종료 코드를 확보해 두면, 같은 고장이 나더라도 원인을 찾는 데 반나절이 아니라 몇 분이 걸립니다.

다음 글에서는 여기서 만든 로그를 파일로만 쌓아 두지 않고, 디스코드 웹훅으로 실행 결과를 바로 받아보는 방법을 다루겠습니다.

댓글 남기기