개발 취재 노트

파이썬 프로그램 하나만 띄웠는데 작업 관리자에 pythonw.exe가 두 개 보일 때 — Windows venv 런처 스텁 구분법

윈도우에서 늘 대기하다가 호출어를 들으면 말을 받아 적고, Claude Code CLI에게 답을 받아 음성으로 읽어주는 개인 음성 비서(voice-assistant)를 만들고 있었다. venv(가상환경)의 pythonw.exe로 창 없이 상주시키는데, 비서가 대답을 멈춰서 프로세스를 살펴보니 비서 본체도, 옆에 띄운 대시보드도 pythonw.exe가 두 개씩 떠 있었다 . 중복 실행을 의심했지만 둘은 부모와 자식이었고, 하나의 프로그램이었다 . (이 글은 "왜 두 개로 보이나"와 "진짜 중복인지 가르는 법"을 다룬다. 비서가 대답을 멈춘 진짜 원인은 끝내 밝혀지지 않았고, 그 범위는 ## 원인 끝에 적었다.)

결론

Windows venv에서 pythonw.exe(또는 python.exe) 두 개가 부모-자식 관계이고 실행 경로가 다르면 정상이다. venv의 Scripts\pythonw.exe는 진짜 인터프리터가 아니라 런처 스텁이다. 설치된 원래 파이썬을 자식 프로세스로 띄우고 자기는 끝날 때까지 기다린다. 그래서 스크립트 하나에 프로세스가 둘 보인다.

구분하는 법은 세 가지를 같이 본다.

  1. 부모 PID — 한쪽의 ParentProcessId가 다른 쪽의 ProcessId면 한 세트다
  2. 실행 경로 — 부모는 ...\venv\Scripts\pythonw.exe, 자식은 venv를 만들 때 쓴 원래 파이썬 설치 경로다
  3. 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다 . 같은 방식으로 웹 대시보드 서버도 따로 상주한다.

증상

"두 번 떠서 서로 마이크나 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 뮤텍스를 추가했다. 이미 실행 중이면 로그를 남기고 스스로 종료한다 . 작업 스케줄러의 "이미 실행 중이면 새 실행 무시" 설정과 같이 쓰되, 스케줄러 설정만 믿지 않고 스케줄러를 거치지 않은 직접 실행으로 가드를 따로 검증했다 .

아래는 같은 방식의 최소 예시다(이 비서의 실제 코드가 아니라 설명용). 가드를 짤 때 정할 것은 셋이다.

또 하나, 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. 가드가 되는지 검증한다. 순서는 이렇다.

  1. 프로그램을 평소처럼 하나 띄우고 로그 파일의 현재 끝 위치(줄 수)를 기억해 둔다.
  2. 스케줄러·바로가기를 거치지 않고, 위 재시작 예시의 실행 명령을 PowerShell에서 한 번 더 직접 실행한다. 스케줄러의 "중복 실행 무시" 설정이 대신 막아 준 것인지 가드가 막은 것인지 가르려면 직접 실행해야 한다 .
  3. 로그 파일에 "이미 실행 중인 인스턴스가 있어 종료함" 줄이 새로 생겼는지 본다. 있으면 가드 성공이다. 이 비서도 두 번째 인스턴스가 이 문구를 남기고 바로 종료했다 .
  4. 앞의 Get-CimInstance 조회를 다시 한다. 프로세스는 여전히 스텁과 자식 한 쌍(2개)이어야 한다. 이 비서에서도 두 번째 실행 뒤 개수는 2 그대로였다 .

덤 — 이름으로 세면 다른 것도 틀린다

같은 추적에서 claude.exe가 13개 보여 비서가 남긴 좀비 프로세스로 의심했지만, 그것은 Claude 데스크톱 앱과 지금 디버깅에 쓰던 claude-code 세션 자신이었다. 비서가 부르는 claude -p는 node.exe로 뜬다 . 이름으로 세서 한꺼번에 끄려 했다면 디버깅 도구까지 끊었을 것이다. 무언가를 끄기 전에는 부모 체인을 올라가서 누구의 자식인지 먼저 본다.

취재 후기 — 두 개인 줄 알았는데 한 몸이었다

박도은
윈도우에 늘 켜 두는 음성 비서 있잖아. 호출어를 부르면 내 말을 받아 적고 Claude CLI한테 답을 받아서 읽어주는 거. 오늘은 불러도 대답은 안 하고 대기음만 계속 나. 로그도 호출어 감지됨에서 멈춰 있어. 들은 건 들었는데 그다음으로 못 넘어간 거야
정바다
작업 관리자 봤어? 비서 본체 pythonw.exe가 두 개, 대시보드도 두 개씩 떠 있잖아. 둘이 동시에 떠서 마이크나 CLI 호출을 서로 뺏고 있는 거 아니야?
정하늘
이름 같은 프로세스가 두 개라고 중복이라는 보장은 없어. 두 개의 부모 PID, 그러니까 누가 누구를 띄웠는지랑 CPU 사용률을 같이 보면 갈려.

프로세스 목록에서 부모 PID와 CPU 열을 뽑았다.

박도은
나왔어. 본체 PID 2724의 부모가 33660이고, 33660은 venv 쪽 런처야. 33660은 CPU 0이고, 일은 2724 혼자 79.9로 하고 있어
정하늘
그럼 중복이 아니야. venv의 pythonw.exe는 런처 스텁, 즉 진짜 파이썬을 대신 띄워 주고 옆에서 기다리기만 하는 작은 실행 파일이야. 두 개가 한 몸이야
정바다
그럼 이것도 수상해. claude.exe가 무려 13개야. 비서가 CLI 불러 놓고 안 거둬서 좀비로 남은 거 아닐까?
정하늘
이것도 부모를 올라가 보자. 비서가 띄운 거면 조상에 pythonw가 있어야 해.
박도은
아니었어. 전부 Claude 데스크톱 앱이랑 지금 우리가 디버깅에 쓰고 있는 claude-code 세션 거야. 비서가 부르는 claude -p는 node.exe로 뜨는데, 지금 그 node.exe가 하나도 없어
박도현
이름만 보고 13개를 한꺼번에 껐으면 우리 작업 도구부터 끊겼을 거야. 그럼 남은 건 CLI 자체인지가 궁금한데, 로그에 실패가 찍혀 있었지?
정바다
응, claude CLI 호출 실패가 몇 번 찍혀 있었어. CLI가 고장 난 거 아닐까?
정하늘
비서를 빼고 claude -p를 우리가 직접 실행해 보면 갈려. 거기서도 안 되면 CLI 문제, 되면 비서 쪽 문제야.
박도은
직접 돌렸더니 6.2초 만에 정상 답이 왔어
정하늘
CLI는 멀쩡해. 범위는 CPU 79.9로 붙잡혀 있으면서 CLI 자식도 안 띄우는 2724로 좁혀졌어. 뭐에 붙잡혔는지까지는 아직 몰라
정바다
그럼 2724만 죽이고 다시 띄우면 되겠네.
박도현
PID 하나만 골라 끄면 스텁이랑 자식 중에 뭐가 남을지 헷갈려. 한 몸이면 같이 끄는 게 맞아.
정하늘
그래서 이름이 아니라 명령줄로 거른다. src.main이 들어간 pythonw만 골라 끄면, 대시보드는 건드리지 않고 비서의 스텁과 자식을 함께 내릴 수 있어. 끄기 전에 그 필터가 누구를 잡는지부터 조회로 확인하고

며칠 뒤, 비서를 작업 스케줄러로 옮기고 중복 실행 방지를 붙였다.

박도은
새로 띄우고 세어 봤는데 인스턴스 하나에 pythonw는 여전히 2야. 두 번째를 억지로 띄웠더니 "이미 실행 중" 찍고 바로 죽었고, 개수는 계속 2였어
정하늘
뮤텍스, 그러니까 이름 붙은 잠금을 먼저 잡은 프로세스만 살아남게 하는 장치가 제대로 걸린 거야. 그리고 이 PC에서는 "pythonw 2개"가 정상 기준값이라는 것도 확인됐어
박도현
프로세스 개수는 증거가 아니야. 중복인지는 부모 관계로 판정하고, 끌 때는 명령줄로 한 몸을 통째로 끄고, 진짜 중복은 앱 안의 잠금으로 막는 거야.