ClOr

ClOr

백엔드 실무 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.

Claude Code 해부학 (완결)

51만 줄 소스코드를 19편에 걸쳐 분석한 완결 시리즈

전체 시리즈 보기 →

백엔드 트러블슈팅

실무에서 겪은 장애와 해결 과정 기록

전체 시리즈 보기 →

최신 글

article thumbnail

들어가며

블로그 자동 포스팅 도구를 만들고 있었다. 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 독립 프로세스 유지하면서 안정적으로 통신

배운 점

  1. 프로세스 간 통신에서 파이프는 양날의 검이다. stdout/stderr 모두 소비하지 않으면 교착 상태에 빠진다. 특히 자식 프로세스가 라이브러리(Selenium 등) 내부에서 예상치 못한 출력을 할 때 위험하다.

  2. Python의 __del__은 믿을 수 없다. 특히 네트워크 연결이 있는 객체의 소멸자는 블로킹될 수 있다. 명시적으로 정리하거나, os._exit(0)으로 우회해야 한다.

  3. 복잡한 IPC보다 파일이 나을 때가 있다. 파이프 스트리밍이 더 "우아"해 보이지만, 프로세스 수명 관리가 복잡한 상황에서는 파일 기반 통신이 훨씬 안정적이다. 특히 fire-and-forget + 폴링 패턴은 프로세스 간 결합도를 완전히 끊어준다.

profile

ClOr

@ClOr

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

ClOr · 백엔드 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.