개발 취재 노트
파이썬 프로그램 하나만 띄웠는데 작업 관리자에 pythonw.exe가 두 개 보일 때 — Windows venv 런처 스텁 구분법
윈도우에서 늘 대기하다가 호출어를 들으면 말을 받아 적고, Claude Code CLI에게 답을 받아 음성으로 읽어주는 개인 음성 비서(voice-assistant)를 만들고 있었다.
venv(가상환경)의 pythonw.exe로 창 없이 상주시키는데, 비서가 대답을 멈춰서 프로세스를 살펴보니 비서 본체도, 옆에 띄운 대시보드도 pythonw.exe가 두 개씩 떠 있었다 .
중복 실행을 의심했지만 둘은 부모와 자식이었고, 하나의 프로그램이었다 . (이 글은 "왜 두 개로 보이나"와 "진짜 중복인지 가르는 법"을 다룬다. 비서가 대답을 멈춘 진짜 원인은 끝내 밝혀지지 않았고, 그 범위는 ## 원인 끝에 적었다.)
결론
Windows venv에서 pythonw.exe(또는 python.exe) 두 개가 부모-자식 관계이고 실행 경로가 다르면 정상이다. venv의 Scripts\pythonw.exe는 진짜 인터프리터가 아니라 런처 스텁이다. 설치된 원래 파이썬을 자식 프로세스로 띄우고 자기는 끝날 때까지 기다린다. 그래서 스크립트 하나에 프로세스가 둘 보인다.
구분하는 법은 세 가지를 같이 본다.
- 부모 PID — 한쪽의
ParentProcessId가 다른 쪽의ProcessId면 한 세트다 - 실행 경로 — 부모는
...\venv\Scripts\pythonw.exe, 자식은 venv를 만들 때 쓴 원래 파이썬 설치 경로다 - CPU 사용률 — 부모(스텁)는 거의 0, 실제 일은 자식이 한다. 이 사건에서는 부모 0, 자식 79.9였다
PowerShell에서 아래를 그대로 붙여 넣으면 1·2번이 한 번에 보인다. python.exe로 띄우는 프로그램이면 Name='pythonw.exe'를 Name='python.exe'로 바꾼다.
Get-CimInstance Win32_Process -Filter "Name='pythonw.exe'" |
Select-Object ProcessId, ParentProcessId, ExecutablePath, CommandLine
3번 CPU는 위 명령 결과에 없으므로 따로 본다. 아래 명령은 프로세스별 CPU 사용률을 재는 성능 카운터 조회다. PercentProcessorTime은 코어 하나를 100으로 친 값이라 멀티코어에서는 100을 넘을 수도 있다. 값이 순간마다 흔들리니 두세 번 실행해서 본다. (python.exe라면 LIKE 'python%'로 바꾼다.)
Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -Filter "Name LIKE 'pythonw%'" |
Select-Object IDProcess, PercentProcessorTime
IDProcess를 앞 명령의 ProcessId와 맞춰 보면 된다. 작업 관리자의 자세히 탭에서 PID 열과 CPU 열을 켜 봐도 같은 정보다.
진짜 중복은 서로 독립된 두 세트(4개)다. Windows 뮤텍스는 CreateMutexW(None, False, "고유이름") 호출 후 GetLastError()가 183이면 종료한다(## 해결 3번). 이 비서도 두 번째 실행이 "이미 실행 중"을 남기고 종료했다 .
끌 때는 PID 하나를 고르지 말고 명령줄(CommandLine)로 대상을 좁혀 스텁과 자식을 둘 다 끈다 . 안전하게 끄는 절차는 ## 해결 2번에 있다.
상황
음성 비서는 PC에 상주하는 파이썬 프로그램이다. 호출어를 기다렸다가 말을 받아 적고, 답은 파이썬에서 claude -p CLI를 호출해 받아 온다. 이 CLI 호출은 node.exe 프로세스로 뜬다 .
실행은 시작 프로그램 폴더의 바로가기 → 숨김 VBS 스크립트 → 배치 파일 → venv의 pythonw.exe 순서로 이어진다 . 파이썬은 3.14 배포판으로 만든 venv다 . 같은 방식으로 웹 대시보드 서버도 따로 상주한다.
증상
- 대시보드 서버를 재시작한 뒤, 비서가 호출어에는 반응하지만 대답은 안 하고 "처리 중" 효과음만 계속 냈다
- 로그 마지막 줄은
호출어 감지됨에서 멈춰 있었다 - 프로세스 목록을 보니 비서 본체(
src.main)도 대시보드도pythonw.exe가 두 개씩 떠 있었다
"두 번 떠서 서로 마이크나 CLI를 뺏고 있는 것 아닌가"라는 의심이 자연스럽게 들었다.
원인
두 pythonw.exe의 부모 PID를 확인하니, 비서 본체 쪽 PID 2724의 부모가 PID 33660이었고, 33660은 venv의 런처 스텁이었다 . 부모는 CPU 0, 자식 2724만 CPU 79.9로 실제 일을 하고 있었다 . 중복 실행이 아니라 프로그램 하나가 프로세스 두 개로 보인 것이다.
이 구조는 CPython 쪽 변경에서 왔다. Windows venv는 예전에는 python.exe를 복사해 썼지만, 이후 원래 인터프리터를 대신 실행해 주는 리다이렉터(수정된 py.exe 런처)를 복사하도록 바뀌었다. 이 런처는 원본 설치 쪽에 venvlauncher.exe / venvwlauncher.exe라는 이름으로 들어 있고, venv를 만들 때 python.exe / pythonw.exe라는 이름으로 복사된다(세션 기록이 아니라 CPython 동작에 대한 일반 지식이다). 이름이 똑같으니 작업 관리자에서는 같은 프로그램이 두 번 뜬 것처럼 보인다.
같은 착시가 나중에 한 번 더 확인됐다. 비서를 작업 스케줄러 방식으로 옮긴 뒤 인스턴스 하나만 띄운 상태에서도 pythonw 개수는 2였다 . 두 번째 인스턴스를 직접 띄워 뮤텍스가 막는지 시험한 뒤에도 개수는 그대로 2였다 . 즉 "pythonw 2개"가 정상 상태의 기준값이다.
참고로 비서가 대답을 멈춘 진짜 원인은 이 두 프로세스가 아니었다. claude -p를 직접 실행하니 6.2초 만에 정상 응답이 왔고 , 문제는 CPU 79.9%로 무언가에 붙잡힌 자식 프로세스 2724 쪽으로 좁혀졌다. 당시 그 프로세스 아래에 살아 있는 claude -p(node) 자식이 하나도 없었다 . 로그에는 그 전에 오디오 크래시(no driver installed, MME error 6)와 claude CLI 호출 실패가 반복된 흔적도 있었다 . 다만 무엇이 CPU를 붙잡았는지는 이 추적에서 끝내 밝혀지지 않았다. 그래서 이 글은 응답 정지의 해법을 주지 않고, "두 개로 보이는 것이 원인은 아니다"까지만 말한다.
해결
1. 중복 판단은 개수가 아니라 관계로 한다. ## 결론의 Get-CimInstance 명령으로 ParentProcessId와 ExecutablePath를 본다. 부모-자식 한 쌍이면 정상이다. 서로 관계없는 쌍이 두 세트 보여야 진짜 중복이다.
2. 재시작할 때는 종료 대상을 먼저 확인하고, 일치한 프로세스만 끈다. 아래 명령은 모두 PowerShell 프롬프트에서 직접 실행하는 것이다. cmd에서는 따옴표와 $_ 해석이 달라 그대로 돌지 않는다.
먼저 필터가 누구를 잡는지 끄지 않고 보기만 한다. '*src.main*'은 이 사건에서 비서 본체의 명령줄에 들어 있던 문자열이다 . 내 프로그램에서는 내 프로그램의 명령줄에만 들어 있고 다른 앱에는 없는 문자열로 바꾼다. 앞의 CommandLine 출력에서 골라 쓰면 된다. src.main처럼 흔한 모듈명은 같은 이름을 쓰는 다른 프로젝트도 잡을 수 있으니, 프로젝트 폴더 경로까지 넣어 '*내프로젝트폴더*src.main*'처럼 좁힌다.
$pattern = '*src.main*' # 내 프로그램만 가리키는 문자열로 바꾼다
Get-CimInstance Win32_Process -Filter "Name='pythonw.exe'" |
Where-Object { $_.CommandLine -like $pattern } |
Select-Object ProcessId, ParentProcessId, ExecutablePath, CommandLine
결과가 스텁과 자식 딱 두 줄(부모-자식으로 이어짐)이고 경로가 내 것이 맞을 때만 다음으로 간다. 다른 프로젝트가 섞여 있으면 $pattern을 더 좁힌다. 확인이 끝났으면 같은 PowerShell 창에서($pattern이 살아 있는 채로) 같은 필터로 끈다.
Get-CimInstance Win32_Process -Filter "Name='pythonw.exe'" |
Where-Object { $_.CommandLine -like $pattern } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
그다음 프로그램을 다시 띄운다. 이 비서는 시작 프로그램 폴더의 숨김 VBS를 wscript.exe로 실행했지만 그건 이 사건 전용이다. 내가 평소 프로그램을 띄우던 명령을 그대로 쓰면 된다.
Start-Sleep 2
# 예: & '<venv 경로>\Scripts\pythonw.exe' -m src.main ← 내 실행 명령으로 바꾼다
이 비서의 실제 재시작은 위 단계를 powershell -Command "..." 한 줄로 묶어 마지막에 wscript.exe '<경로>\start_assistant_hidden.vbs'를 부르는 형태였다. 당시에는 AI 도구가 프로세스를 임의로 종료하는 것이 보호 정책에 막혀, 이 명령은 직접 실행하도록 넘겨졌다 . 처음부터 목표는 "런처 스텁과 작업 자식을 둘 다 끄고 새로 띄운다"였다 .
이름(pythonw.exe)만으로 끄면 대시보드까지 같이 죽는다. 그래서 명령줄 문자열로 걸러야 한다.
3. 진짜 중복을 막으려면 앱 안에 단일 인스턴스 가드를 둔다. 이 비서는 Windows 뮤텍스를 추가했다. 이미 실행 중이면 로그를 남기고 스스로 종료한다 . 작업 스케줄러의 "이미 실행 중이면 새 실행 무시" 설정과 같이 쓰되, 스케줄러 설정만 믿지 않고 스케줄러를 거치지 않은 직접 실행으로 가드를 따로 검증했다 .
아래는 같은 방식의 최소 예시다(이 비서의 실제 코드가 아니라 설명용). 가드를 짤 때 정할 것은 셋이다.
- 넣을 위치: 프로그램의 진입 파일(이 사건이면
src.main모듈)에서, 모델 로딩이나 오디오 장치 열기처럼 무겁고 부작용 있는 초기화보다 앞에 둔다. 두 번째 인스턴스가 무거운 일을 시작하기 전에 죽어야 한다. - 이름: 프로그램마다 겹치지 않는 고유 문자열로 정한다. 예시의
MyAssistantSingleInstance는 자리표시자이니내앱이름SingleInstance처럼 바꾼다. 이름이 같으면 서로 다른 프로그램이 서로를 막는다. Local\접두사: 같은 로그인 세션 안에서만 유효한 이름이다. 한 사용자가 한 세션에서 쓰는 개인 프로그램에는 충분하다. 다른 로그인 세션(원격 접속 등)까지 막으려면Global\을 쓰는데, 권한 환경에 따라 생성이 거부될 수 있어 이 글에서는 다루지 않는다.
또 하나, pythonw에서는 print가 보이지 않는다. 콘솔이 없어 표준 출력이 어디에도 나오지 않으므로 종료 사유는 파일 로그로 남긴다.
import ctypes, sys, logging
logging.basicConfig(filename="assistant.log", level=logging.INFO) # 내 로그 파일 경로로
ERROR_ALREADY_EXISTS = 183
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateMutexW.restype = ctypes.c_void_p
# 핸들을 모듈 전역에 붙잡아 둬야 프로세스가 사는 동안 뮤텍스가 유지된다
_mutex = kernel32.CreateMutexW(None, False, "Local\\MyAssistantSingleInstance")
if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
logging.info("이미 실행 중인 인스턴스가 있어 종료함")
sys.exit(0)
# --- 여기부터 무거운 초기화 (모델 로딩, 장치 열기 등) ---
뮤텍스는 스텁이 아니라 실제 코드를 도는 자식 프로세스 안에서 만들어진다. 그러니 스텁 때문에 가드가 두 번 걸리지는 않는다.
4. 가드가 되는지 검증한다. 순서는 이렇다.
- 프로그램을 평소처럼 하나 띄우고 로그 파일의 현재 끝 위치(줄 수)를 기억해 둔다.
- 스케줄러·바로가기를 거치지 않고, 위 재시작 예시의 실행 명령을 PowerShell에서 한 번 더 직접 실행한다. 스케줄러의 "중복 실행 무시" 설정이 대신 막아 준 것인지 가드가 막은 것인지 가르려면 직접 실행해야 한다 .
- 로그 파일에 "이미 실행 중인 인스턴스가 있어 종료함" 줄이 새로 생겼는지 본다. 있으면 가드 성공이다. 이 비서도 두 번째 인스턴스가 이 문구를 남기고 바로 종료했다 .
- 앞의
Get-CimInstance조회를 다시 한다. 프로세스는 여전히 스텁과 자식 한 쌍(2개)이어야 한다. 이 비서에서도 두 번째 실행 뒤 개수는 2 그대로였다 .
덤 — 이름으로 세면 다른 것도 틀린다
같은 추적에서 claude.exe가 13개 보여 비서가 남긴 좀비 프로세스로 의심했지만, 그것은 Claude 데스크톱 앱과 지금 디버깅에 쓰던 claude-code 세션 자신이었다. 비서가 부르는 claude -p는 node.exe로 뜬다 . 이름으로 세서 한꺼번에 끄려 했다면 디버깅 도구까지 끊었을 것이다. 무언가를 끄기 전에는 부모 체인을 올라가서 누구의 자식인지 먼저 본다.
취재 후기 — 두 개인 줄 알았는데 한 몸이었다
호출어 감지됨에서 멈춰 있어. 들은 건 들었는데 그다음으로 못 넘어간 거야 pythonw.exe가 두 개, 대시보드도 두 개씩 떠 있잖아. 둘이 동시에 떠서 마이크나 CLI 호출을 서로 뺏고 있는 거 아니야? 프로세스 목록에서 부모 PID와 CPU 열을 뽑았다.
pythonw.exe는 런처 스텁, 즉 진짜 파이썬을 대신 띄워 주고 옆에서 기다리기만 하는 작은 실행 파일이야. 두 개가 한 몸이야 claude.exe가 무려 13개야. 비서가 CLI 불러 놓고 안 거둬서 좀비로 남은 거 아닐까?pythonw가 있어야 해.claude -p는 node.exe로 뜨는데, 지금 그 node.exe가 하나도 없어 claude CLI 호출 실패가 몇 번 찍혀 있었어. CLI가 고장 난 거 아닐까? claude -p를 우리가 직접 실행해 보면 갈려. 거기서도 안 되면 CLI 문제, 되면 비서 쪽 문제야.src.main이 들어간 pythonw만 골라 끄면, 대시보드는 건드리지 않고 비서의 스텁과 자식을 함께 내릴 수 있어. 끄기 전에 그 필터가 누구를 잡는지부터 조회로 확인하고 며칠 뒤, 비서를 작업 스케줄러로 옮기고 중복 실행 방지를 붙였다.
pythonw는 여전히 2야. 두 번째를 억지로 띄웠더니 "이미 실행 중" 찍고 바로 죽었고, 개수는 계속 2였어