들어가며
블로그 자동 포스팅 도구를 만들고 있었다. Tauri(Rust) 앱에서 Python(Selenium) 스크립트를 호출하고, 결과를 받아서 UI에 반영하는 구조다. 단순해 보이는 이 구조에서 invoke가 영원히 resolve 안 되는 기괴한 버그를 만났다.
환경
- Frontend: Svelte 5 + TypeScript
- Backend: Rust (Tauri 2)
- 자동화: Python 3.11 + Selenium
- 브라우저: Chrome (독립 프로세스, remote-debugging-port)
- OS: Windows 10
문제 / 증상
Svelte에서 invoke("fetch_categories")를 호출하면, Rust가 Python을 spawn하고 stdout을 파이프로 읽는다. Python은 정상적으로 실행되고 로그도 찍힌다. 그런데 invoke가 영원히 끝나지 않는다.
// Svelte — 이 await가 영원히 안 끝남
const result = await invoke<string[]>("fetch_categories");
// 여기 도달 못함
log(`카테고리: ${result}`);
Rust 쪽 로그를 보면 Python의 print 출력이 정상적으로 emit되고 있었다. "카테고리 5개 발견"까지 찍히는데, 함수가 반환을 안 한다.
카테고리 조회 중...
브라우저 연결 완료
인증 유효
카테고리 5개 발견: ['현업 개발 회고', 'Algorithm', 'Backend', 'Embedded']
← 여기서 멈춤. 영원히.원인 분석
1차: stderr 파이프 버퍼 포화
Rust에서 Python을 spawn할 때 stdout과 stderr를 모두 파이프로 열어놓고, stdout만 읽고 있었다.
// Before — stderr를 열어놓고 안 읽음
Command::new("py")
.stdout(Stdio::piped())
.stderr(Stdio::piped()) // ← 열어놓고 안 읽음
.spawn()?;
// stdout만 읽음
for line in BufReader::new(stdout).lines() {
emit_log(&app, line);
}
Selenium은 stderr에 경고 메시지를 대량으로 뿜는다. 파이프 버퍼(보통 64KB)가 꽉 차면 Python의 stderr write가 블로킹되고, Python 전체가 멈춘다. Python이 멈추니 stdout에 EOF가 안 오고, Rust의 BufReader::lines() 루프도 영원히 안 끝난다.
stderr(Stdio::null())로 버리니 이 문제는 해결됐다. 하지만 여전히 안 끝났다.
2차: Selenium __del__의 함정
Python 코드를 보면 driver.quit()을 명시적으로 호출하지 않는다. Chrome을 독립 프로세스로 띄우고, Python이 끝나도 Chrome은 살아있게 하려는 의도였다.
def main():
driver = create_driver() # 기존 Chrome에 연결
cats = fetch_categories(driver)
save_to_file(cats)
return # driver.quit() 안 함
문제는 Python의 가비지 컬렉터다. main()이 반환되면 driver 변수가 스코프를 벗어나고, Selenium WebDriver의 __del__ 메서드가 호출된다. 이 del이 내부적으로 driver.quit()을 시도한다.
# selenium/webdriver/remote/webdriver.py (내부 코드)
def __del__(self):
try:
self.quit() # ← 이게 문제
except Exception:
pass
Chrome은 독립 프로세스라 quit 요청에 응답하지 않고, self.quit()이 타임아웃까지 기다린다. Python이 종료되지 않는다. Python이 안 죽으니 stdout 파이프에 EOF가 안 오고, Rust의 BufReader 루프가 영원히 돈다.
흐름 정리
Svelte invoke() → Rust spawn Python → Python 작업 완료
→ Python return → __del__ → driver.quit() 시도
→ Chrome 응답 없음 → Python 행 → EOF 안 옴
→ Rust BufReader 무한 대기 → invoke 영원히 안 끝남
→ Svelte UI 업데이트 불가해결
Before: stdout 파이프 직접 통신
// Rust — Python 끝날 때까지 블로킹
async fn fetch_categories(app: AppHandle) -> Result<Vec<String>, String> {
let output = run_python(&app, &["--list-categories"]).await?;
// ↑ Python이 안 죽어서 여기 도달 못함
parse_categories(output)
}
After: 파일 기반 + 폴링
Python: 결과를 파일로 저장하고 os._exit(0)으로 강제 종료
cats = fetch_categories(driver)
with open("categories.json", "w") as f:
json.dump(cats, f)
# main() 끝
os._exit(0) # Selenium __del__ 건너뛰고 즉시 종료
Rust: Python을 fire-and-forget으로 실행
#[tauri::command]
async fn fetch_categories(app: AppHandle) -> Result<(), String> {
// Python 실행하고 기다리지 않음
Command::new("py")
.args(&["--list-categories"])
.spawn()?;
Ok(())
}
// 별도 커맨드로 파일 읽기
#[tauri::command]
fn read_categories_file() -> Result<Vec<String>, String> {
let content = std::fs::read_to_string("categories.json")?;
Ok(serde_json::from_str(&content)?)
}
Svelte: 폴링으로 파일 확인
async function loadCategories() {
await invoke("fetch_categories"); // 즉시 반환
// 파일이 생길 때까지 2초 간격 폴링
for (let i = 0; i < 15; i++) {
await new Promise(r => setTimeout(r, 2000));
try {
const result = await invoke<string[]>("read_categories_file");
if (result.length > 0) {
categories = [...result];
return;
}
} catch {}
}
}

결과
- Before: invoke 호출 시 UI가 영원히 멈춤
- After: 2~4초 내에 카테고리 로드 완료, UI 즉시 업데이트
- Python 프로세스 종료 지연 문제 완전 해소
- Chrome 독립 프로세스 유지하면서 안정적으로 통신
배운 점
프로세스 간 통신에서 파이프는 양날의 검이다. stdout/stderr 모두 소비하지 않으면 교착 상태에 빠진다. 특히 자식 프로세스가 라이브러리(Selenium 등) 내부에서 예상치 못한 출력을 할 때 위험하다.
Python의
__del__은 믿을 수 없다. 특히 네트워크 연결이 있는 객체의 소멸자는 블로킹될 수 있다. 명시적으로 정리하거나,os._exit(0)으로 우회해야 한다.복잡한 IPC보다 파일이 나을 때가 있다. 파이프 스트리밍이 더 "우아"해 보이지만, 프로세스 수명 관리가 복잡한 상황에서는 파일 기반 통신이 훨씬 안정적이다. 특히 fire-and-forget + 폴링 패턴은 프로세스 간 결합도를 완전히 끊어준다.
