개발 취재 노트

시작 프로그램에 넣은 VBS 파이썬 스크립트가 가끔 에러 창을 띄우고, 부팅 때만 조용히 안 뜰 때

윈도우에서 늘 켜져 있다가 호출어를 들으면 대답하는 로컬 음성 비서(voice-assistant)를 만들고 있었다. 창 없이 뜨게 하려고 시작 프로그램 폴더에 숨김 VBS를 넣어 파이썬(pythonw)을 띄웠는데, 가끔 VBS 에러 창이 뜨고, 어떤 날은 부팅 후 비서가 아예 뜨지 않았다. 로그에는 아무것도 남지 않았다.

결론

  1. 시작 폴더 VBS → 작업 스케줄러: 로그온 45초 뒤 실행, 실패하면 1분 간격 최대 3회 재시도, 이미 실행 중이면 새 실행 무시
  2. 앱 쪽: 시작할 때마다 타임스탬프 로그, 입력 장치를 최대 60초 재시도, Windows 뮤텍스로 인스턴스 하나만 허용
  3. 스케줄러가 배치(cmd)를 거치면 창이 남을 수 있다. pythonw를 직접 실행하고 로그는 파이썬 안에서 파일로 쓴다

아래는 위 설정을 PowerShell로 옮긴 예시다(세션의 원문 코드가 아니다. 경로는 자기 환경에 맞게 바꾼다).

$action  = New-ScheduledTaskAction -Execute "C:\myapp\.venv\Scripts\pythonw.exe" `
             -Argument "-m src.main" -WorkingDirectory "C:\myapp"
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
$trigger.Delay = "PT45S"                       # 로그온 45초 뒤
$settings = New-ScheduledTaskSettingsSet `
             -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) `
             -MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName "MyAssistant" -Action $action -Trigger $trigger -Settings $settings
# 단일 인스턴스 가드 예시 — 두 번째로 뜬 프로세스는 스스로 종료한다
import ctypes, sys
ERROR_ALREADY_EXISTS = 183
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
_mutex = kernel32.CreateMutexW(None, False, "Global\\MyAssistantSingleton")  # 프로세스가 끝날 때까지 참조 유지
if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
    print("이미 실행 중인 인스턴스가 있어 종료함")
    sys.exit(0)

상황

음성 비서는 호출어 감지 → 음성을 글자로 바꾸는 STT → LLM(Claude Code CLI) 호출 → 음성 합성(TTS)이 모두 로컬 PC에서 도는 구조다. 응답은 claude -p를 자식 프로세스로 불러 만든다 . 파이썬은 venv 안의 pythonw.exe(콘솔 창이 없는 파이썬)로 상주한다 .

자동 시작 경로는 이랬다.

시작 프로그램 폴더의 VoiceAssistant.lnk
  → wscript.exe start_assistant_hidden.vbs   (WshShell.Run, 창 스타일 0 = 숨김)
    → 배치 파일
      → pythonw.exe (로그는 배치에서 main_log.txt로 리다이렉트)

입력 장치는 USB 오디오이고, 앱은 시작할 때 장치 이름으로 장치를 찾는다 .

증상

  1. 가끔 뜨는 VBS 에러 창. "hidden_vbs 무슨 파일" 관련 에러 얼럿이 종종 떴다. 에러 문구는 기록하지 못했다 .
  2. 부팅 후 비서가 없다. 시작 프로그램에 바로가기는 그대로 있는데 pythonw.exe가 하나도 없었다 . 작업 관리자에서 비활성화된 것도 아니었다(레지스트리에 비활성 항목 없음) .
  3. 로그가 조용하다. PC는 22:33에 부팅됐는데 main_log.txt는 그 뒤로 한 줄도 늘지 않았다. 죽은 게 아니라 시작 자체를 못 한 것처럼 보였다 .
  4. 그런데 손으로 돌리면 멀쩡하다. python.exe로 직접 실행하면 "준비 완료"까지 가고, 배치를 직접 돌려도 뜬다 .

원인

확정하지 못했다. 확인된 것과 추정을 나눠 적는다.

확인된 것:

추정(검증 안 됨):

주의할 점: 같은 날 새벽, 앞선 부팅에서 같은 증상을 조사할 때 조사에 쓰던 셸이 호출이 끝나면 자기가 띄운 자식 프로세스를 같이 정리해 버려 "떴다가 사라지는" 가짜 실패가 섞였다 . 셸 밖(WMI)으로 띄우자 멀쩡히 살아 있었다 . 위의 "4번 연속 실패"도 같은 종류의 도구 셸에서 돌린 것이라, 이 영향이 섞였을 가능성을 완전히 배제하지는 못한다. 자동화 도구나 원격 셸에서 재현 테스트를 한다면, 그 셸이 프로세스를 죽이는 건 아닌지 먼저 가려야 한다.

해결

원인을 모르는 채로 안정적인 쪽으로 실행 방식을 옮겼다.

전: 증상 숨기기 — 두 VBS 맨 위에 On Error Resume Next를 넣어 에러 창을 막았다 . 창은 사라졌지만 원인은 모른다. 며칠 뒤 부팅 실패를 쫓을 때 "이게 에러를 삼키고 있나"부터 의심해야 했고 , 그 한 줄이 남아 있는 한 cscript로 돌려도 오류를 볼 수 없었다.

후: 실행 방식 교체

  1. 작업 스케줄러 VoiceAssistantJarvis 등록 — 로그온 45초 뒤, 실패 시 1분 간격 최대 3회 재시도, 중복 실행 무시. 시작 폴더 바로가기는 지우지 않고 scripts/_disabled_startup/으로 옮겨 이중 실행을 막았다 .
  2. 앱을 부팅에 강하게 — 시작할 때 [main] === 시작 <타임스탬프> (pid ...) ===를 찍는다. 다음에 또 안 뜨면 "시작 시도라도 했는지"를 로그만 보고 가릴 수 있다. 입력 스트림은 장치가 준비될 때까지 최대 60초 재시도한다 . 스케줄러로 실행하니 이 시작 로그가 실제로 찍혔다 .
  3. 뮤텍스로 두 번째 인스턴스를 막았다. 일부러 하나 더 띄우니 "이미 실행 중인 인스턴스가 있어 종료함"을 찍고 바로 끝났다 .
  4. 처음엔 스케줄러가 배치를 실행하게 했는데 cmd 창이 계속 떠 있었다. 이 PC는 기본 터미널이 Windows 터미널이라 숨김 설정이 cmd 창에 먹지 않았다는 설명이었다. 스케줄러가 cmd 없이 파이썬을 바로 실행하게 하고, 로그 저장은 파이썬 코드 안으로 옮겼다. 대시보드 서버도 같은 방식으로 옮기고 시작 폴더 방식은 껐다 .

검증 범위: 작업을 즉시 실행(on-demand)해서 기동과 로그, 뮤텍스는 확인했다. 실제 재부팅으로 확인한 기록은 없다 . "기본 터미널이 Windows 터미널이라서"라는 설명도 따로 검증하지는 않았다.

덤 — pythonw가 두 개 떠 있는 건 중복 실행이 아니다

작업 관리자에서 앱마다 pythonw.exe가 두 개씩 보여 중복 실행을 의심했다. 부모 PID와 CPU를 보니 부모는 venv의 런처 스텁(CPU 0)이고 실제 일은 자식 하나(CPU 79.9%)가 했다 . 종료할 때는 PID 하나가 아니라 명령줄(CommandLine)로 걸러 둘 다 끈다 . 진짜 중복을 막고 싶으면 위의 뮤텍스를 쓴다.

취재 후기 — 에러 창을 끄고 나니 아무것도 안 보였다

박도은
윈도우에서 늘 켜져 있다가 "헤이 자비스" 하면 대답하는 음성 비서 있잖아. 오늘 컴퓨터를 켰는데 비서가 없어. 시작 프로그램 폴더에 바로가기는 그대로 있는데 pythonw가 하나도 안 떠 있어
정하늘
죽은 건지 아예 안 뜬 건지부터 가르자. 부팅 시각이랑 로그 마지막 줄 시각을 비교하면 돼.
박도은
부팅은 22:33인데 main_log.txt는 그 뒤로 한 줄도 안 늘었어. 시작을 못 한 거야
정바다
지난번에 VBS 에러 창 막으려고 맨 위에 On Error Resume Next 넣었잖아. 오류가 나도 무시하고 다음 줄로 넘어가라는 명령이야. 그게 진짜 에러를 삼키고 있는 거 아냐?
정하늘
그럼 wscript 대신 cscript로 돌려 보자. 같은 스크립트 호스트인데 에러를 창 대신 콘솔에 찍어 줘.
박도은
cscript로 돌렸더니 에러 없이 exit 0이야. 그런데 pythonw는 여전히 안 떠
정바다
에러가 없으면 VBS는 멀쩡하다는 거네. 그럼 On Error Resume Next는 범인이 아니야.
정하늘
그렇게는 못 잘라. 그 한 줄이 스크립트에 그대로 있으면 오류는 cscript에서도 조용히 넘어가. exit 0은 "오류가 없었다"일 수도 있고 "오류가 있었는데 안 보였다"일 수도 있어.
박도현
그게 그 한 줄의 비용이야. 에러 창을 끈 순간 "무엇이 실패했나"를 알려줄 단서도 같이 꺼졌어. cscript까지 꺼내 들고도 아무것도 못 본 이유가 그거야.
정바다
그럼 숨김 모드가 문제 아닐까. VBS가 창 스타일 0, 그러니까 창을 안 보이게 띄우는 옵션을 쓰잖아.
박도은
스타일 1로 창을 보이게 하니까 떴어. 근데 다시 0으로 돌려도 이번엔 떴어
정하늘
한 번씩 돌려서는 안 갈린다. 부팅 때랑 똑같이 wscript로 VBS를 여러 번 돌리고 성공률을 세 보자.
박도은
네 번 연속 실패. 프로세스 0개, 로그 0바이트 증가. 배치를 직접 돌리면 매번 뜨는데
정바다
됐다, 재현됐으니 wscript 경로가 범인이네.
박도은
잠깐, 바로 다음 번엔 같은 wscript로 떴어. 로그에 "준비 완료"까지 찍혔고
정하늘
같은 경로가 됐다 안 됐다 하면 이건 비결정적이야. 실패할 때 로그가 0바이트였으니 파이썬 코드가 한 줄도 못 돈 거고. 원인을 이 자리에서 확정할 증거는 없어.
정바다
부팅 직후엔 백신 검사도 돌고 USB 오디오 장치도 아직 안 잡혀 있을 수 있잖아. 장치 이름을 못 찾으면 앱이 죽는 코드도 있고
정하늘
그건 정황이야. 장치 단계에서 죽었다면 그 전까지 로그가 조금은 찍혔어야 하는데 0바이트였어. 추정으로만 남겨 두자.
박도은
하나 더 걸리는 게 있어. 오늘 새벽에 처음 조사할 때는 우리가 테스트에 쓰던 셸이 명령이 끝나면 자기가 띄운 프로세스를 같이 정리해 버려서, 멀쩡한 게 죽은 것처럼 보였거든. 방금 네 번 실패도 같은 종류의 셸에서 돌린 거라 그 영향이 없었다고 장담은 못 해
박도현
그래서 원인을 파는 대신 실행 방식을 바꾸는 게 맞아. 이유를 몰라도 "늦게 시작하고, 실패하면 다시 하고, 두 번 뜨면 하나를 끄는" 구조는 원인이 뭐든 버텨.
정하늘
작업 스케줄러에 등록하자. 로그온 45초 뒤 실행, 실패하면 1분 간격으로 세 번까지 재시도, 이미 떠 있으면 새 실행은 무시
박도은
앱에도 넣었어. 시작할 때 타임스탬프랑 PID를 찍고, 입력 장치는 60초까지 기다리고. 즉시 실행해 보니 [main] === 시작 ... (pid 18320) ===이 찍혔어
정하늘
두 개 뜨는 경우도 확인하자. 뮤텍스, 그러니까 운영체제가 관리하는 이름 붙은 자물쇠를 먼저 잡은 쪽만 살아남게 했으니까 하나 더 띄워 봐.
박도은
두 번째는 "이미 실행 중인 인스턴스가 있어 종료함" 찍고 바로 꺼졌어

며칠 뒤, 대시보드 서버까지 스케줄러로 옮긴 다음.

박도은
이번엔 까만 cmd 창이 계속 떠 있어. 스케줄러가 배치를 실행하니까 cmd가 pythonw를 붙잡고 기다리는 거야
정바다
이 PC는 기본 터미널이 Windows 터미널이라서, 숨김 설정을 줘도 cmd 창이 안 숨겨지는 거 같아
정하늘
그 설명은 따로 검증 안 했어. 하지만 cmd를 빼고 pythonw를 바로 실행하면 가설이 맞든 틀리든 창이 생길 자리가 없어. 로그는 파이썬 안에서 직접 쓰게 하고.
박도은
바꾸고 나서 비서랑 대시보드 둘 다 창 없이 떠 있어. 실제 재부팅으로는 아직 못 봤고
박도현
On Error Resume Next는 에러 창을 끄는 스위치지 원인을 고치는 도구가 아니야. 원인을 못 잡겠으면 에러를 숨기지 말고, 실패해도 흔적이 남고 스스로 다시 일어나는 실행 방식으로 옮기는 게 답이야.