수학 과외를 하면서 AI로 자잘한 자동화를 만들어 쓰고 있어요. 그중 하나가 윈도우 작업 스케줄러에 걸어둔 야간 배치입니다. 매일 밤 정해진 시각에 .cmd 파일을 실행해서, 그 안에서 Node 스크립트가 자료를 모아 정리해주는 구조예요. 잘 돌고 있으려니 하고 신경을 안 썼죠.
이 글의 본론은 08-10 밤부터 7일 연속 실패한 이야기인데, 그 전에 같은 작업에 사고가 두 번 더 있었어요. 순서대로 갈게요.
사실 이 작업은 시작부터 한 번 조용히 멈춰 있었어요. 실행 스크립트(래퍼)를 만든 게 07-27, 예약을 실제로 건 게 08-01입니다. 그 닷새 이야기부터 할게요.

원인 — 스크립트는 만들었는데, 예약을 안 걸었다
파고들어 보니 원인이 명확했어요. 저는 그 갱신 작업을 돌려주는 실행 스크립트(래퍼)를 만들어 뒀습니다. 커밋도 됐고, 손으로 실행하면 멀쩡히 잘 돌았어요. 그래서 저는 “만들었으니 도는 거겠지” 하고 넘어갔던 거죠.
그런데 스크립트를 만든 것과 그걸 매일 자동으로 실행하도록 예약을 건 것은 완전히 별개의 일이었습니다. 저는 앞엣것만 하고 뒤엣것을 안 했어요. 윈도우로 치면 작업 스케줄러에 “이 스크립트를 매일 밤 10시 40분에 돌려라”라고 등록하는 단계가 통째로 빠져 있던 겁니다.
예약 작업(스케줄러 등록): 스크립트 파일이 존재하는 것과, 그 스크립트를 정해진 시각에 자동 실행하도록 운영체제에 등록하는 것은 다른 일이다. 등록을 안 하면 아무도 그 스크립트를 부르지 않는다.
그러니 그 스크립트를 실제로 실행한 건 제가 손으로 돌렸던 마지막 순간뿐이었어요. 상태 파일은 그때 시각에 딱 멈춰 버렸고요.
어떻게 고쳤나 — 예약을 진짜로 걸었다
고친 건 단순했어요. 빠져 있던 그 한 단계 — 스케줄러에 작업을 실제로 등록하는 것 — 을 했습니다. 윈도우 작업 스케줄러에 “이 스크립트를 매일 밤 22시 40분에 실행”으로 등록했죠. 이제야 계기판에 모터가 달린 겁니다.
여기서 배운 진짜 교훈은 하나였어요. 등록했다고 믿지 말고, 등록됐는지 확인하라. 스크립트를 만든 기억은 있는데 등록한 기억은 흐릿하다면, 십중팔구 안 한 거예요. 그래서 저는 이제 예약 작업을 걸면 반드시 정말 등록됐는지 조회해 봅니다.
- 윈도우:
schtasks /query /tn "작업이름"— 이 작업이 목록에 뜨는지 - 맥·리눅스:
crontab -l— 등록된 예약 목록에 내 줄이 있는지
등록을 마친 08-01 밤 22:40, 첫 무인 실행은 정상으로 끝났습니다.
예약 작업은 ‘커밋한 버전’이 아니라 ‘지금 폴더 상태’를 돌린다
먼저 용어부터 풀게요. 예약 작업(scheduled task): 정해진 시각에 사람이 없어도 알아서 실행되는 작업이에요. 저는 상태 집계 스크립트를 매일 밤 도는 예약 작업으로 걸어뒀습니다.
여기서 많은 사람이 착각하는 게 있어요. 예약 작업이 “내가 어제 완성해서 저장(커밋)한 그 버전”을 돌린다고 생각하기 쉬운데, 아니에요. 예약 작업은 그 폴더의 바로 지금 디스크 상태를 그대로 실행합니다. 무슨 뜻이냐면요.
- 브랜치(branch): 코드의 평행 세계 같은 거예요. 안정된 본선을
main이라고 하고, 새 기능을 실험할 땐feature라는 곁가지를 따로 만들어 거기서 작업해요. - 체크아웃(checkout): 폴더를 특정 브랜치 상태로 “갈아 끼우는” 것. feature 브랜치를 체크아웃하면 그 폴더의 파일들이 실험 중인 코드로 바뀝니다.
문제가 보이시나요? 제가 밤에 새 기능을 실험하려고 feature 브랜치를 체크아웃해둔 채 잠들면, 그 폴더는 지금 실험 코드 상태예요. 그리고 아침에 예약 작업이 깨어나면, 그 실험 중인 코드를 그대로 프로덕션(실제 운영)으로 돌립니다. 제가 “이건 아직 테스트 중인데”라고 생각하는 코드가, 사람 없는 새벽에 진짜로 실행돼버리는 거죠.
실제로 비슷한 일이 있었어요. 08-06 낮 12:47에 만들어진 상태 파일은 아직 머지하지 않은 브랜치 fix/state-consistency-04에서 돈 결과였습니다. 숫자는 멀쩡했지만(drift 0 · errors 0), 파일만 봐서는 어느 코드가 만든 결과인지 알 방법이 없었어요.
가드의 형태 — ‘무인일 때만’ 막는 플래그
그래서 브랜치 가드를 붙였어요. 핵심 아이디어는 “예약 작업이 실행되는 순간 현재 브랜치가 main이 아니면, 그냥 실행하지 말고 멈춰라”입니다.
그런데 여기서 제일 중요한 설계 결정이 있어요. 이 가드를 무조건 켜지 않았다는 것. 만약 “항상 main이 아니면 무조건 중단”으로 만들면, 제가 feature 브랜치에서 손으로 직접 스크립트를 테스트할 때도 막혀버려요. 그건 개발을 방해하죠.
그래서 가드를 플래그(flag, 실행할 때 붙이는 옵션 스위치) 뒤에 숨겼어요. 예약 작업이 부르는 무인 진입점에서만 그 플래그(--require-main)를 붙이게 했습니다. 정리하면 이래요.
- 예약 작업(무인 실행): 플래그가 붙음 → main이 아니면 중단. 엉뚱한 코드가 새벽에 도는 걸 막음.
- 내가 손으로 실행(대화형 개발): 플래그 없음 → 가드가 발동 안 함. feature 브랜치에서 자유롭게 테스트.
즉 이건 항상 main만 실행하는 게 아니라, 무인 진입점에서만 main을 강제하는 것이에요. 사람이 개입하는 자리와 개입하지 않는 자리를 구분한 게 이 설계의 핵심입니다.
그리고 하나 더. 가드가 걸려서 멈출 때 조용히 죽지 않게 했어요. 명시적인 종료 코드(exit code, 프로그램이 끝날 때 성공·실패를 알리는 숫자)로 중단해서, 나중에 로그를 보면 “아, 브랜치가 main이 아니라서 안 돌았구나”를 알 수 있게요. 조용히 넘어가면 안 돌아간 이유조차 못 찾거든요.
검증 — ‘발동할 때’와 ‘발동하지 말아야 할 때’ 둘 다 확인
가드는 만들었다고 끝이 아니에요. 두 방향을 다 확인해야 진짜 검증입니다. 한쪽만 보면 반쪽짜리예요.
첫째, 발동해야 할 때 발동하는가. feature 브랜치로 갈아 끼운 상태에서 무인 플래그를 붙여 실행해봤어요. 결과는 곧바로 중단(종료 코드 2), 평소 같으면 나올 실행 로그도 없고 네트워크 호출도 없었습니다. 일이 시작되기 전에 딱 끊긴 거죠. 가드가 제대로 문을 막았다는 뜻이에요.
둘째, 그리고 이게 자주 빠뜨리는 부분인데, 발동하지 말아야 할 때 얌전히 있는가. 플래그 없이(손으로 개발하듯) 실행하면 가드가 발동하지 않아야 해요. 안 그러면 개발을 막으니까요. 이것도 확인했습니다 — 플래그가 없으면 가드는 조용히 비켜줬어요.
브랜치를 바꾸면 파일이 실제로 바뀝니다
브랜치(branch): 같은 프로젝트의 ‘작업 사본’. 원본을 건드리지 않고 따로 실험할 수 있게 갈라놓은 갈래입니다.
애초에 왜 이런 걸 쓰냐면, 잘 돌아가는 코드를 망치지 않고 새 걸 시도하기 위해서예요. 새 기능을 만들다 보면 중간에 앱이 안 켜지는 상태를 거치잖아요. 그걸 원본에서 하면 그동안 아무것도 못 씁니다. 그래서 갈래를 하나 따서 거기서 마음껏 부수고, 다 되면 원본에 합치는 거죠. 시험지에 바로 쓰지 않고 연습장에서 풀어보는 것과 같습니다. 문제는 연습장이 여러 장이 되면 내가 지금 어느 장을 보고 있는지 헷갈린다는 거고요.
git branch --show-current # 지금 내가 있는 브랜치 이름
git branch # 내 브랜치 목록 전체 보기 (현재 위치엔 * 표시)
git switch 브랜치이름 # 그 브랜치로 이동 (예전 방식: git checkout 브랜치이름)
git switch -c 새브랜치 # 새 브랜치 만들면서 바로 이동
브랜치 가드가 들어간 08-10 오후, 같은 파일에 한 번 더 손이 갔습니다(AI 작업자가 자동 동기화 블록을 붙인 개정).
그런데 어느 날 확인해보니 7일 연속으로 실패하고 있었습니다. 결과 코드는 매번 255. 더 이상한 건 기록이었어요.
증상 — 로그에 날짜 한 줄만 남아 있다
이 작업은 실행할 때마다 기록 파일에 남깁니다. 맨 위에 날짜를 찍고, 그다음 작업 내용을 쭉 쓰고, 마지막에 종료 코드를 남기는 식이죠. 그런데 실패한 날들의 기록이 이랬습니다.
==== 2026-08-15 22:40:01 ====
날짜 한 줄이 전부였어요. 작업 내용도 없고, 마지막에 찍혀야 할 종료 코드도 없었습니다. 에러 메시지 한 줄이라도 있으면 검색이라도 할 텐데, 그냥 말 없이 사라진 겁니다.
이게 왜 무서운 신호냐면, 날짜 줄은 찍혔다는 뜻이거든요. 즉 파일 자체는 실행이 시작됐습니다. 그런데 바로 그다음 줄부터 아무 일도 일어나지 않았어요. 시작은 했는데 두 발짝을 못 갔다는 거죠.
용의자 목록 — 그럴듯한 것부터 넷
에러 메시지가 없으니 짐작부터 해야 했어요. AI에게 상황을 설명하고 같이 후보를 뽑았습니다.
①경로(작업이 파일을 못 찾았나) ②실행 파일 부재(Node 위치가 바뀌었나) ③권한(밤에 로그인 안 한 상태라 막혔나) ④작업 디렉터리(손으로 돌릴 땐 그 폴더에서 하는데 예약 작업은 아니죠).
넷 다 그럴듯했어요. 문제는 짐작으로는 하나도 못 지운다는 거였습니다. 그래서 방향을 바꿨어요. 추측을 늘리는 대신, 예약 작업과 똑같은 조건으로 직접 실행해보자고 AI에게 부탁했습니다. 손으로 아무 데서나 돌리면 잘 되니까, 똑같이 재현되는 조건을 만드는 게 먼저였거든요.
소거 — 하나씩 지워나가다
재현은 성공했습니다. 예약 작업과 같은 조건에서 돌리자 똑같이 날짜 한 줄만 찍히고 멈췄어요. 여기서부터는 후보를 지우는 일이었죠.
①②는 금방 지워졌어요. 파일도 Node도 제자리였습니다. ③도 아니었고요(손으로 돌릴 때 같은 계정으로 잘 됐으니까). ④는 재현 조건에 이미 반영했는데 그것만으로 죽지 않았습니다.
후보가 다 지워지자 남은 게 이상해졌어요. 코드는 그대로인데 어제는 되고 오늘은 안 된다면, 그 사이에 뭔가 바뀐 것이 있어야 하잖아요. 그래서 AI에게 “마지막으로 성공한 실행이 언제였고, 그 전후로 파일이 바뀐 적 있는지 봐달라”고 했습니다.
여기서 결정적인 게 나왔어요. 마지막으로 성공한 실행이 14:24:01이고, 제가 파일을 고친 게 14:24:58 — 57초 차이였습니다. 그리고 그날 밤 첫 예약(22:40)부터 전부 실패했어요. 성공과 실패 사이에 들어간 게 제 수정 하나였던 겁니다.
진범 — 가독성을 올리려고 넣은 주석
그럼 저는 그때 뭘 고쳤을까요? 기능이 아니라 주석을 넣었습니다. 나중에 봤을 때 이해하기 쉬우라고 한국어 설명을 여러 줄 달았어요.
그게 범인이었습니다.
이 파일은 윈도우 배치 파일(.cmd)이고, 이걸 실행하는 cmd.exe는 파일을 읽을 때 시스템 ANSI 코드페이지(한국어 윈도우면 CP949)로 해석합니다. 그런데 제가 저장한 한글은 UTF-8이었어요. 서로 다른 규칙으로 읽으니 한글 바이트가 엉뚱한 기호로 보였고, 그 순간 파서가 죽어버린 겁니다. 제가 넣은 건 이런 줄들이었어요.
REM --- 로컬 main 자동 동기화 (ff-only) ---
REM 예약작업은 로컬 워킹트리 코드를 그대로 돌린다. 머지해도 pull 안 하면...
보시다시피 동작에 아무 영향이 없는 주석(REM)입니다. 그런데 cmd.exe는 주석 줄도 일단 파싱하거든요. 날짜 줄 다음에 아무것도 없던 이유가 이거였어요 — 파서가 죽은 뒤에는 종료 코드를 남기는 줄까지 갈 수조차 없었던 겁니다.
코드페이지: 바이트를 어떤 문자로 해석할지 정한 표예요. 한국어 윈도우의 기본값(CP949)과 UTF-8은 한글을 다른 바이트로 표현해서, 한쪽으로 쓴 걸 다른 쪽으로 읽으면 깨집니다.
AI에게 확인시켜보니 숫자로도 갈렸습니다. 처음 만들었을 때 이 파일에는 한글이 한 줄도 없었고(그때는 잘 돌았습니다), 제가 고친 뒤에는 13줄이 되어 있었어요.
이 사고의 진짜 얼굴이 여기 있어요. 저는 뭘 고장 낸 게 아니라 읽기 쉽게 만들려고 주석을 달았습니다. 그런데 그 주석은 사람이 읽는 부분이고, 죽은 것은 기계가 읽는 부분이었어요. 가독성과 아무 상관 없는 곳이 가독성 때문에 무너진 겁니다. 기능을 건드리지 않았다는 안심이 오히려 확인을 건너뛰게 만들었고요.
수리는 허무할 만큼 단순했어요. 한글 주석을 영어로 바꾸는 것. 대신 파일 맨 위에 “여기에는 한글을 쓰지 말 것”이라는 경고를 영어로 큼직하게 남겨뒀습니다. 그리고 AI에게 예약 작업 조건 그대로 세 번 다시 돌려서 종료 코드가 정상으로 나오고 기록도 끝까지 남는지 확인했어요.
덤으로 잡힌 것 둘
고치는 김에 두 가지가 더 드러났습니다.
하나는 변수가 항상 비어 있던 문제예요. 배치 파일에서 %VAR%는 괄호 블록에 진입하는 순간 한 번에 치환됩니다. 그래서 같은 블록 안에서 값을 넣고 바로 꺼내 쓰면, 방금 넣은 값이 아니라 블록 진입 시점의 옛 값(여기서는 빈칸)이 들어가요.
if defined GITOK (
for /f %%B in ('git rev-parse --abbrev-ref HEAD') do set "BR=%%B"
echo [step] branch=%BR% REM ← 항상 빈칸. 블록 진입 시 이미 치환됨
)
해결은 지연 확장(delayed expansion)을 켜고 !BR!로 읽는 겁니다.
setlocal EnableDelayedExpansion
...
echo [step] branch=!BR! REM ← 실행 시점에 치환된다
이건 겉으로는 조용히 넘어가서, 파서 문제를 고쳤어도 그 기능은 계속 안 돌 뻔했습니다.
다른 하나가 더 중요한데, 로그 파일이 제가 볼 수 없는 자리에 있었어요. 제가 열어본 로그는 3주 전에 멈춰 있었는데, 실제 작업은 그 뒤로도 돌고 있었습니다.
원인은 로그 경로로 쓴 %LOCALAPPDATA%였어요. 제가 쓰는 AI 도구가 MSIX 패키지 앱이라, 그 안에서 보는 %LOCALAPPDATA%는 가상화된 폴더로 리다이렉트됩니다. 반면 작업 스케줄러는 패키지 밖에서 도니까 진짜 경로에 씁니다. 즉 작업이 쓴 로그와 제가 읽던 로그가 서로 다른 파일이었던 거죠. 7일 동안 못 알아챈 진짜 이유가 이겁니다. 그래서 로그 경로를 가상화가 없는 별도 드라이브 폴더로 옮겼습니다.
한 문장으로
고장은 기능을 건드릴 때만 나는 게 아닙니다. 읽기 좋으라고 단 주석이 파서와 부딪혀 자동화를 일주일간 멈춰 세웠고, 그동안 조용했던 건 비명을 지를 자리가 제 눈에 안 보이는 곳이었기 때문입니다.
혹시 밤중에 알아서 도는 작업을 만들어 두셨다면, 오늘 딱 두 가지만 확인해보세요. 그 작업이 마지막으로 성공한 게 언제인지, 그리고 그 기록을 내가 정말 열어볼 수 있는지. 저는 둘 다 확인 안 하고 있다가 일주일을 날렸거든요.