oxibrowser는 뭘로 브라우저 흉내를 내는가
oxibrowser는 Puppeteer가 붙는 CDP 서버인데, 정작 뒤에 Chromium도 V8도 없습니다. HTML 파싱부터 CSS 레이아웃, JS 실행, 렌더링까지 전부 순수 Rust 라이브러리(html5ever, Blitz/Stylo, boa_engine)로 직접 구현한 물건이에요. 이 페이지에서는 4개 크레이트가 무슨 일을 나눠 맡는지, "GC 포인터를 쓰는 JS 엔진을 async 런타임 위에서 어떻게 안전하게 돌리는가"라는 핵심 난제를 어떻게 풀었는지, 그리고 그 과정에서 나온 설계 결정들을 하나씩 뜯어봅니다.
한눈에 보기 — 뭘 만들려고 한 물건인가
루트 Cargo.toml 메타데이터부터 의도가 노골적으로 드러납니다. keywords = ["browser", "headless", "cdp", "automation", "dom"] — AI 에이전트가 스크레이핑·자동화·스크린샷·Markdown 추출을 하라고 만든 헤드리스 브라우저입니다.
AGENTS.md가 정체성을 한 줄로 못박습니다 — "headless browser built in pure Rust, designed for AI agents and automation. No Chromium, no V8. Single static binary."
이 한 문장 안에 이미 이 프로젝트의 모든 트레이드오프가 예고돼 있어요. Chromium/V8을 빌려 쓰는 대신 html5ever·boa_engine·Blitz를 직접 갖다 붙였다는 건,
브라우저 엔진 하나를 통째로 재구현하는 대가를 치르더라도 단일 정적 바이너리라는 배포 형태를 포기하지 않겠다는 선언이거든요.
// 4-crate workspace, edition 2024, rust 1.96 [workspace] resolver = "2" members = [ "crates/oxibrowser", // CLI + lib facade "crates/oxibrowser-cdp", // Chrome DevTools Protocol 서버 "crates/oxibrowser-core", // 엔진의 두뇌 "crates/oxibrowser-render", // Blitz 렌더 파이프라인 ]
그리고 workspace 의존성 목록을 보면 이 프로젝트가 안고 가는 짐이 얼마나 무거운지 감이 옵니다 — HTTP 클라이언트가 두 개(reqwest + wreq), JS/WASM 엔진이 두 개(boa_engine + wasmi), 거기에 Blitz 렌더 스택(Stylo CSS 엔진, html5ever 0.39, vello)까지 전부 한 워크스페이스 안에 들어 있어요. 이 페이지의 뒷부분 절반은 사실 "왜 이렇게 중복처럼 보이는 선택들을 했는가"를 설명하는 내용입니다.
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } wreq = { version = "=6.0.0-rc.29", features = ["json", "form", "stream"] } boa_engine = "0.20" wasmi = "1.1" // Rendering stack — isolated to oxibrowser-render so the Stylo / html5ever 0.39 // / vello dependency trees stay out of the rest of the workspace. blitz-dom = "0.3.0-beta.1" blitz-paint = "0.3.0-beta.1"
4개 크레이트가 나눠 맡은 일
누가 무엇을 소유하는지가 크레이트 경계 그 자체입니다. 하나씩 순서대로 봅니다 — core(두뇌) → cdp(프로토콜) → render(픽셀) → facade(사용자 접점).
oxibrowser-core — 엔진의 두뇌
책임: Browser → Session → Page → Frame 라이프사이클, JS 런타임, 네트워크, CSS/HTML, observability 이벤트. AGENTS.md가 계층을 명시합니다 — "Each level owns its children and has a unique atomic ID."
가장 무거운 파일은 js/runtime.rs(14,505줄) — boa_engine Context를 std::thread에서 돌리는 핵심 로직 전부가 여기 있습니다. session.rs(1,810줄)는 fetch bridge / WS bridge / localStorage sync를 한곳에 두면서, 공유 상태는 반드시 Arc<RwLock> + AtomicU64만 쓰라는 스레드 정책("No exceptions")을 따릅니다.
Browser는 32-slot broadcast 채널로 라이프사이클 이벤트를 흘려보낸다 — 관찰자(oxi-agent, CDP, MCP)가 subscribe_events()로 구독
oxibrowser-cdp — Chrome DevTools Protocol 서버
책임: WebSocket + HTTP 엔드포인트(/json/version, /json, /ws, /health) 제공. Puppeteer/Playwright가 URL만 바꿔 그대로 붙습니다. server.rs는 동시 연결을 16개로, 메시지를 1MB로 캡 걸어둡니다.
12개 도메인 핸들러(browser/dom/emulation/fetch/input/log/network/page/runtime/target/tracing) 위에, 표준 CDP엔 없는 OXI.* 네임스페이스를 하나 더 얹었습니다 — AI 에이전트 전용 확장입니다.
OXI.getMarkdown · OXI.getStructuredPage · OXI.getAccessibilityTree · OXI.getBoxModelScreenshot
oxibrowser-render — Blitz 기반 렌더 파이프라인
책임: HTML → Blitz BaseDocument → Taffy 레이아웃 → Parley 텍스트 → vello_cpu 페인트 → PNG/PDF. 딱 3개 모듈, 513줄로 끝나는 제일 작은 크레이트입니다.
paint.rs는 캡처 실패 시 blank_png로 폴백해 "never hard-fail a screenshot" 계약을 지킵니다. Blitz 자체의 캡처 패턴(apps/browser/src/capture.rs)을 그대로 답습했다고 주석에 적혀 있어요.
BaseDocument는 사실상 !Send — RenderDocument는 반드시 단일 스레드에 살아야 한다
oxibrowser — CLI와 라이브러리 파사드
책임: 사용자용 CLI(fetch/extract/run/session/serve/search/describe/skill/version, 9개 서브커맨드) + 라이브러리 re-export. 목표는 단일 정적 바이너리 하나.
결정적인 건 feature 게이팅입니다 — browser feature 없이 컴파일하면 search 모듈만 남고 boa_engine은 아예 컴파일 안 됩니다. 그래서 다운스트림이 검색만 필요할 때 JS 엔진 무게를 안 짊어져도 됩니다.
[features] default = [] # browser 없이는 search 모듈만 컴파일 — boa_engine 의존성 없음 browser = ["dep:oxibrowser-core", "dep:oxibrowser-cdp"] [[bin]] name = "oxibrowser" required-features = ["browser"]
required-features = ["browser"]로 묶어서, 실수로 브라우저 없이 oxibrowser 바이너리를 빌드하는 사고 자체를 컴파일 타임에 막아버립니다.
boa_engine을 async 런타임 위에서 돌리는 법
이 프로젝트에서 가장 흥미로운 엔지니어링 지점입니다. boa_engine의 Context는 내부 GC 포인터가 NonNull이라 !Send예요 — 즉 스레드 간 이동이 불가능합니다. 근데 oxibrowser는 tokio async 런타임 위에서 도는 라이브러리라 JsRuntime: Send + Sync가 반드시 성립해야 합니다. 모순처럼 보이는 이 두 요구를 어떻게 풀었을까요?
답은 "회피하지 않고 정면으로 받아들인다"였습니다. Context를 전용 std::thread 하나에 영구히 고정시켜버리고,
그 스레드와는 오직 mpsc 채널로만 대화합니다. js/runtime.rs:7-10 주석이 이 설계를 정확히 요약합니다.
//! `boa_engine::Context` is `!Send` (internal GC pointers use `NonNull`). //! To keep `JsRuntime: Send + Sync` for tokio, we run the `Context` on a //! dedicated **std::thread** and communicate via `mpsc` channels. //! //! This means JS state (variables, functions, closures) **persists across //! evaluate() calls** — exactly like a real browser.
이 구조가 낳는 결과가 네 가지 있습니다. 첫째, 영속 컨텍스트 — evaluate() 호출 사이에 변수·함수·클로저가 살아남아서 진짜 브라우저처럼 동작합니다.
둘째, 이벤트 리스너·pending fetch·pending WS 같은 상태가 전부 thread_local!입니다 — Context가 그 스레드를 절대 떠나지 않으니 !Send 값을 안전하게 보관할 수 있는 거예요.
셋째, 타이머와 async는 별도 트레이트 구현으로 지원합니다 — js/job_queue.rs가 boa의 JobQueue trait을 구현해서 setTimeout/Promise/마이크로태스크를 처리합니다.
넷째, WebAssembly(wasmi)도 굳이 별도 스레드를 안 만들고 같은 JS 스레드 위에서 돕니다 — wasmi의 핸들(Engine/Module/Store/Instance)도 Send가 아니라서, 정확히 같은 제약을 그대로 물려받았기 때문입니다.
navigator.webdriver, navigator.plugins, window.chrome, WebGLRenderingContext...)가 애초에 존재하지 않습니다. 그래서 이 모듈은 그 API들을 진짜 Chrome이 노출하는 값 그대로 새로 만들어 넣습니다.
HTTP 클라이언트가 왜 두 개인가
workspace 의존성에 reqwest와 wreq가 동시에 들어 있습니다. 중복처럼 보이지만 역할이 명확히 갈립니다 — 하나는 "봇처럼 안 보이게", 다른 하나는 "가볍게 표준적으로".
wreq는 오직 oxibrowser-core의 HttpClient에서만 쓰입니다. 이유는 Chrome 149 TLS/HTTP2 핑거프린트 에뮬레이션이에요 — 페이지 로드는 실제 Chrome처럼 보여야 Cloudflare나 DataDome 같은 봇 탐지를 통과합니다.
use wreq::{Client, Response}; use wreq_util::Emulation; let mut builder = Client::builder() .emulation(Emulation::Chrome149) .user_agent(&config.user_agent) .pool_max_idle_per_host(config.connection_pool_size) .timeout(config.default_timeout);
재밌는 디테일 하나 — User-Agent 기본값도 정확히 Emulation::Chrome149 프로필에 맞춰져 있습니다.
config.rs:28-32 주석이 이유를 설명하는데, transport layer(wreq)와 JS layer(boa의 navigator.userAgent) 양쪽이 정확히 같은 값을 보여줘야 봇 탐지에 안 걸린다는 겁니다.
한쪽은 Chrome 149라고 하는데 다른 쪽은 다른 값을 말하면, 그 불일치 자체가 탐지 시그널이 되거든요.
반면 reqwest는 oxibrowser(CLI)의 검색 모듈 + dev-dependencies에만 등장합니다. 검색은 봇으로 위장할 이유가 없어요 — DDG/Wiki/Bing/GitHub에 정직하게 요청 하나 날리고 결과 받아오면 끝인 사용자 검색 에이전트일 뿐입니다.
/// Build a lightweight reqwest::Client for search (no SSRF filter, no cookies). pub fn build_search_client(timeout_secs: u64) -> reqwest::Client { reqwest::Client::builder() .timeout(Duration::from_secs(timeout_secs)) .user_agent("oxibrowser/x.y.z") .https_only(true) .build() .expect("reqwest::Client::builder() failed") }
| 측면 | wreq (oxibrowser-core) | reqwest (oxibrowser search) |
|---|---|---|
| 목적 | 페이지 로드, 봇 탐지 우회 | DDG/Wiki/Bing/GitHub 검색 |
| TLS 핑거프린트 | Emulation::Chrome149 | rustls-tls (일반 UA) |
| User-Agent | Chrome 149와 동기화 | oxibrowser/x.y.z |
| SSRF 필터 | 적용 | 미적용 — 주석에 명시 |
| 쿠키 | 있음 (CookieJar) | 없음 |
| 의존 위치 | core / cdp | search 모듈, dev-deps |
wreq의 RC 버전을 끌어들이지 않고, (b) oxibrowser = { default-features = false }로 가볍게 쓰면 search만 reqwest로 컴파일됩니다.
oxios가 oxibrowser를 실제로 쓰는 방식
oxibrowser의 진짜 소비자 중 하나가 oxios입니다. 재밌는 건 oxios가 oxibrowser를 두 군데에서, 두 가지 방식으로 끌어온다는 점이에요 — 이게 사실 위 4개 크레이트 분리가 실전에서 왜 유용한지 보여주는 증거이기도 합니다.
| oxios 크레이트 | 의존 | 사용 API |
|---|---|---|
| oxios (binary) | oxibrowser = "0.21" | oxibrowser::search::dispatch() — 검색만 |
| oxios-kernel (browser feature) | oxibrowser-core = "0.21" | Browser, Tab, BrowserEvent 직접 |
바이너리 레벨(oxios)은 오직 oxibrowser::search::dispatch()만 씁니다 — /api/search 핸들러가 이 함수를 직접 호출해서, 에이전트 루프를 하나도 거치지 않고 즉시 검색 결과를 반환합니다.
반면 커널 레벨(oxios-kernel, browser feature 뒤)은 oxibrowser-core를 통째로 끌어와 browse tool과 스크린샷 백엔드를 직접 짭니다.
즉 "가볍게 쓸 땐 파사드만, 무겁게 쓸 땐 core를 직접" — 04에서 본 feature 게이팅이 정확히 이 비대칭을 가능하게 만드는 겁니다.
//! Concrete browser engine — wraps `oxibrowser-core` directly. //! No trait abstraction, no dyn dispatch. There is exactly one backend //! (`oxibrowser-core` 0.21), so concrete types are simpler and faster.
browser/default feature 구조가 그 전환의 기술적 토대가 됐다는 뜻이에요.
BrowserEvent가 #[non_exhaustive]인 것도 실전에서 효과를 냅니다 — oxios CHANGELOG에 "0.73.0 added BrowseProgress::PdfExported, surfaced end-to-end via the new BrowserEvent::PdfExported"라고 적혀 있는데, non_exhaustive 덕에 새 variant가 추가될 때마다 oxios 쪽 컴파일러가 match 분기 추가를 강제합니다.
설계 결정 로그 — 4가지 "왜"
면접에서 파고들 가능성이 제일 높은 부분입니다. 앞에서 이미 다룬 결정들이지만, 여기서는 각각을 근거 · 트레이드오프 · 대안이었다면 어땠을지 구조로 다시 정리합니다.
!Send JS Context를 전용 std::thread에 고정한다
근거: js/runtime.rs:7-10 — boa_engine::Context가 !Send라서 tokio 위에서 JsRuntime을 Send+Sync로 유지하려면 전용 스레드 + mpsc 채널 조합이 필요했습니다.
채널 왕복 비용 — evaluate() 하나가 채널 send/recv 두 번을 거칩니다.
영속 컨텍스트. evaluate() 사이에 변수/클로저가 살아남아 진짜 브라우저와 같은 상태 모델이 됩니다.
매 evaluate마다 새 Context — let counter = 0; counter++ 같은 1-shot 스크립트가 세션 간 누적되지 않았을 겁니다.
결정의 본질은 "real-browser semantics"를 성능보다 우선했다는 것 — 채널 비용을 감수하는 대신 상태를 진짜로 들고 있는 JS 런타임을 얻었습니다.
HTTP 클라이언트 2개(wreq + reqwest)를 공존시킨다
근거: workspace 의존성 + search/engine.rs:92-100 주석 — "Build a lightweight reqwest::Client for search (no SSRF filter, no cookies)."
빌드 크기·의존성 그래프가 소폭 증가합니다.
wreq RC 버전 격리, search-only 다운스트림이 boa+wreq+Chrome TLS 코드를 안 끌어옴, 브라우저/검색 정책 분리.
reqwest 하나만 — 봇 탐지 회피가 불가능해지고 Cloudflare/DataDome을 통과 못 했을 겁니다.
렌더 스택을 core가 아니라 별도 crate(render)로 격리한다
근거: Cargo.toml 주석 — "Rendering stack (Blitz integration) — isolated to oxibrowser-render so the Stylo / html5ever 0.39 / vello dependency trees stay out of the rest of the workspace."
Phase 1 단계라 RenderDocument가 아직 JS 스레드와 통합되지 않고, 호출자가 직접 구동하는 단독 진입점 상태입니다.
core 사용자가 Blitz 의존성을 피할 수 있고, 렌더 트리 변경 시 재컴파일 영향 범위가 render crate 하나로 제한됩니다.
core에 Blitz를 직접 통합 — core가 boa+html5ever+Stylo+vello+Blitz를 전부 끌어안아 다운스트림 전체가 재컴파일 대상이 됐을 겁니다.
Browser를 trait이 아니라 struct로 정의한다
근거: browser.rs:40 — pub struct Browser (trait이 아님). oxios의 결정("no trait abstraction, no dyn dispatch")과 정확히 같은 정책입니다.
백엔드가 두 개 이상으로 늘어날 수 없습니다 — 확장하려면 struct 자체를 고쳐야 합니다.
구체 타입 사용으로 dynamic dispatch 비용이 사라지고, 컴파일러 인라인 최적화가 가능해집니다.
dyn Browser trait object — 백엔드를 여러 개 지원할 여지는 생기지만, 백엔드가 정확히 하나뿐인 상황에선 순수 오버헤드였을 겁니다.
두 프로젝트(oxibrowser, oxios)가 같은 정책으로 독립적으로 수렴했다는 게 흥미로운 지점입니다 — "백엔드가 하나뿐이면 추상화 계층을 만들지 않는다"는 원칙이 이 워크스페이스 전반에 깔려 있다는 신호예요.
면접용 한 줄 요약
위 내용을 30초 안에 설명해야 한다면 이 세 문장으로 압축됩니다.
oxibrowser는 Chromium/V8 없는 헤드리스 브라우저 — boa_engine의 !Send Context를 전용 std::thread에 가두고 mpsc 3종(fetch/localStorage/DOM snapshot)으로 async 메인 스레드와 통신하는 구조로, 평가 간 영속 JS 상태를 보장합니다.
4-crate 분리는 순수 의존성 그래프로 결정됩니다 — oxibrowser-render가 Blitz/Stylo/vello 트리를, oxibrowser-core가 boa/wreq를, oxibrowser-cdp가 hyper/tungstenite를, oxibrowser가 clap + feature-gated search 라이브러리 파사드를 담당합니다.
HTTP 클라이언트 2개(wreq + reqwest) 공존은 의도적입니다 — wreq는 Chrome 149 TLS 핑거프린트 에뮬레이션으로 봇 탐지를 우회하는 브라우저 트래픽용, reqwest는 SSRF 필터/쿠키 없는 경량 검색용이며, 다운스트림이 default-features = false로 검색만 컴파일할 수 있게 해줍니다.
정리
oxibrowser를 한 문장으로 요약하면 — "Chromium을 빌리지 않고, 대신 !Send JS 엔진을 전용 스레드에 가두는 값을 치르면서까지 단일 정적 바이너리를 지킨 브라우저"입니다. 4개 크레이트 경계는 전부 순수 의존성 그래프에서 나왔고, HTTP 클라이언트 2개·JS/WASM 엔진 2개라는 겉보기 중복도 전부 "백엔드가 하나면 추상화하지 않는다"는 같은 원칙 위에서 갈라진 결과입니다. oxios가 이 파사드/코어 이분법을 그대로 재사용해서 검색은 가볍게, 브라우징은 무겁게 끌어온다는 사실이 이 경계 설계가 실전에서 통했다는 증거이기도 합니다.