Architecture Deep Dive · oxibrowser 0.21.0

oxibrowser는 뭘로 브라우저 흉내를 내는가

oxibrowser는 Puppeteer가 붙는 CDP 서버인데, 정작 뒤에 Chromium도 V8도 없습니다. HTML 파싱부터 CSS 레이아웃, JS 실행, 렌더링까지 전부 순수 Rust 라이브러리(html5ever, Blitz/Stylo, boa_engine)로 직접 구현한 물건이에요. 이 페이지에서는 4개 크레이트가 무슨 일을 나눠 맡는지, "GC 포인터를 쓰는 JS 엔진을 async 런타임 위에서 어떻게 안전하게 돌리는가"라는 핵심 난제를 어떻게 풀었는지, 그리고 그 과정에서 나온 설계 결정들을 하나씩 뜯어봅니다.

4 crates · workspace 0.21.0 boa_engine — 순수 Rust JS 엔진 Blitz/Stylo — 순수 Rust 렌더 스택 wreq + reqwest — HTTP 클라이언트 2개
01

한눈에 보기 — 뭘 만들려고 한 물건인가

루트 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를 직접 갖다 붙였다는 건, 브라우저 엔진 하나를 통째로 재구현하는 대가를 치르더라도 단일 정적 바이너리라는 배포 형태를 포기하지 않겠다는 선언이거든요.

Cargo.toml — workspace 구성
// 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)까지 전부 한 워크스페이스 안에 들어 있어요. 이 페이지의 뒷부분 절반은 사실 "왜 이렇게 중복처럼 보이는 선택들을 했는가"를 설명하는 내용입니다.

workspace.dependencies (발췌)
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"
oxibrowser — CLI + lib facade clap 9개 서브커맨드 · feature: "browser" search 모듈 (기본) reqwest만 — boa_engine 컴파일 안 함 oxibrowser-cdp hyper WS/HTTP · 12개 도메인 라우터 oxibrowser-core — 엔진의 두뇌 Browser→Session→Page→Frame boa_engine · wreq · wasmi 17개 모듈 노출 oxibrowser-render Blitz/Stylo/vello — 여기만 격리 항상 컴파일 browser feature일 때만 DispatchContext 스크린샷/PDF용 facade도 core를 직접 re-export 항상 포함 feature 게이트 뒤에서만
4개 크레이트는 순수 의존성 방향(위→아래)으로만 쌓입니다. cdp는 core 없이 존재할 수 없고, core는 render 없이 존재할 수 없어요 — 반대는 성립 안 함.
4
workspace 크레이트
14,505
js/runtime.rs 줄 수
1,810
session.rs 줄 수
12
CDP 도메인 라우터
16
CDP 동시 연결 캡
2
공존하는 HTTP 클라이언트
02

4개 크레이트가 나눠 맡은 일

누가 무엇을 소유하는지가 크레이트 경계 그 자체입니다. 하나씩 순서대로 봅니다 — core(두뇌) → cdp(프로토콜) → render(픽셀) → facade(사용자 접점).

2.1

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()로 구독
2.2

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
2.3

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는 반드시 단일 스레드에 살아야 한다
2.4

oxibrowser — CLI와 라이브러리 파사드

책임: 사용자용 CLI(fetch/extract/run/session/serve/search/describe/skill/version, 9개 서브커맨드) + 라이브러리 re-export. 목표는 단일 정적 바이너리 하나.

결정적인 건 feature 게이팅입니다 — browser feature 없이 컴파일하면 search 모듈만 남고 boa_engine은 아예 컴파일 안 됩니다. 그래서 다운스트림이 검색만 필요할 때 JS 엔진 무게를 안 짊어져도 됩니다.

crates/oxibrowser/Cargo.toml — feature 게이팅이 결정적이다
[features]
default = []
# browser 없이는 search 모듈만 컴파일 — boa_engine 의존성 없음
browser = ["dep:oxibrowser-core", "dep:oxibrowser-cdp"]

[[bin]]
name = "oxibrowser"
required-features = ["browser"]
인용 · REQUEST_search_pub_mod.md 이 feature 분리를 요청한 이유가 문서에 그대로 남아 있습니다 — "default-features = false로 사용 시 CLI 의존성(clap, tracing-subscriber)은 빠져야 합니다." 즉 oxios 같은 다운스트림이 boa_engine을 컴파일하지 않고도 search 라이브러리만 가져다 쓸 수 있게 만드는 게 목적이었습니다. 바이너리는 required-features = ["browser"]로 묶어서, 실수로 브라우저 없이 oxibrowser 바이너리를 빌드하는 사고 자체를 컴파일 타임에 막아버립니다.
03

boa_engine을 async 런타임 위에서 돌리는 법

이 프로젝트에서 가장 흥미로운 엔지니어링 지점입니다. boa_engine의 Context는 내부 GC 포인터가 NonNull이라 !Send예요 — 즉 스레드 간 이동이 불가능합니다. 근데 oxibrowser는 tokio async 런타임 위에서 도는 라이브러리라 JsRuntime: Send + Sync가 반드시 성립해야 합니다. 모순처럼 보이는 이 두 요구를 어떻게 풀었을까요?

답은 "회피하지 않고 정면으로 받아들인다"였습니다. Context를 전용 std::thread 하나에 영구히 고정시켜버리고, 그 스레드와는 오직 mpsc 채널로만 대화합니다. js/runtime.rs:7-10 주석이 이 설계를 정확히 요약합니다.

crates/oxibrowser-core/src/js/runtime.rs — 아키텍처 주석
//! `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.
메인 스레드 (tokio, async) JsRuntime: Send + Sync evaluate(script) → JSON 값 set_global() / set_dom() console_output 스트림 수신 Browser/Session/Page가 이 위에서 동작 JS 스레드 (전용 std::thread) 이 스레드를 절대 떠나지 않음 boa_engine::Context (영구, !Send) thread_local!: 이벤트 리스너 / pending fetch·WS wasmi Engine/Module/Store (같은 스레드) JobQueue → setTimeout/Promise variables/closures가 호출 간 그대로 살아있음 fetch bridge localStorage bridge DOM snapshot bridge 각각 독립된 mpsc 채널 — 3개로 나눈 이유는 데드락 격리
fetch 응답 처리가 밀려도 localStorage sync는 안 막힙니다 — 채널 3종 분리가 그 격리를 담당합니다.

이 구조가 낳는 결과가 네 가지 있습니다. 첫째, 영속 컨텍스트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가 아니라서, 정확히 같은 제약을 그대로 물려받았기 때문입니다.

인용 · src/js/stealth.rs 봇 탐지 우회 로직도 이 구조 위에서 자연스럽게 따라옵니다. oxibrowser는 순수 Rust 엔진(boa_engine + html5ever)이라 실제 Chrome을 패치해서 탐지를 우회하는 스텔스 스크립트들이 건드리는 API(navigator.webdriver, navigator.plugins, window.chrome, WebGLRenderingContext...)가 애초에 존재하지 않습니다. 그래서 이 모듈은 그 API들을 진짜 Chrome이 노출하는 값 그대로 새로 만들어 넣습니다.
04

HTTP 클라이언트가 왜 두 개인가

workspace 의존성에 reqwestwreq가 동시에 들어 있습니다. 중복처럼 보이지만 역할이 명확히 갈립니다 — 하나는 "봇처럼 안 보이게", 다른 하나는 "가볍게 표준적으로".

wreq오직 oxibrowser-coreHttpClient에서만 쓰입니다. 이유는 Chrome 149 TLS/HTTP2 핑거프린트 에뮬레이션이에요 — 페이지 로드는 실제 Chrome처럼 보여야 Cloudflare나 DataDome 같은 봇 탐지를 통과합니다.

crates/oxibrowser-core/src/network/client.rs:17-18, 118-119
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라고 하는데 다른 쪽은 다른 값을 말하면, 그 불일치 자체가 탐지 시그널이 되거든요.

반면 reqwestoxibrowser(CLI)의 검색 모듈 + dev-dependencies에만 등장합니다. 검색은 봇으로 위장할 이유가 없어요 — DDG/Wiki/Bing/GitHub에 정직하게 요청 하나 날리고 결과 받아오면 끝인 사용자 검색 에이전트일 뿐입니다.

crates/oxibrowser/src/search/engine.rs:92-100
/// 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::Chrome149rustls-tls (일반 UA)
User-AgentChrome 149와 동기화oxibrowser/x.y.z
SSRF 필터적용미적용 — 주석에 명시
쿠키있음 (CookieJar)없음
의존 위치core / cdpsearch 모듈, dev-deps
왜 이렇게 만들었는가 단일 클라이언트로 둘 다 커버하려면 트레이드오프가 정면 충돌합니다. 봇 우회는 핑거프린트 합성이 핵심이고, 검색은 빠른 표준 HTTPS가 핵심이에요. 분리한 덕에 (a) 검색 다운스트림이 wreq의 RC 버전을 끌어들이지 않고, (b) oxibrowser = { default-features = false }로 가볍게 쓰면 search만 reqwest로 컴파일됩니다.
05

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 게이팅이 정확히 이 비대칭을 가능하게 만드는 겁니다.

crates/oxios-kernel/src/tools/browse/engine.rs — 헤더 주석
//! 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.
인용 · oxios CHANGELOG oxios의 CHANGELOG는 이 분리를 정책 전환으로 설명합니다 — "0.73 dropped the BrowseTool/BrowseSessionTool factory re-exports + the browser/native-browser features — products own browsing themselves (RFC-046)." 다운스트림이 자체 browse 엔진을 직접 갖는 방향으로 명시적 전환이 있었고, oxibrowser의 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 분기 추가를 강제합니다.
06

설계 결정 로그 — 4가지 "왜"

면접에서 파고들 가능성이 제일 높은 부분입니다. 앞에서 이미 다룬 결정들이지만, 여기서는 각각을 근거 · 트레이드오프 · 대안이었다면 어땠을지 구조로 다시 정리합니다.

결정 1

!Send JS Context를 전용 std::thread에 고정한다

근거: js/runtime.rs:7-10 — boa_engine::Context가 !Send라서 tokio 위에서 JsRuntime을 Send+Sync로 유지하려면 전용 스레드 + mpsc 채널 조합이 필요했습니다.

Trade-off

채널 왕복 비용 — evaluate() 하나가 채널 send/recv 두 번을 거칩니다.

이득

영속 컨텍스트. evaluate() 사이에 변수/클로저가 살아남아 진짜 브라우저와 같은 상태 모델이 됩니다.

대안이었다면

매 evaluate마다 새 Context — let counter = 0; counter++ 같은 1-shot 스크립트가 세션 간 누적되지 않았을 겁니다.

결정의 본질은 "real-browser semantics"를 성능보다 우선했다는 것 — 채널 비용을 감수하는 대신 상태를 진짜로 들고 있는 JS 런타임을 얻었습니다.

결정 2

HTTP 클라이언트 2개(wreq + reqwest)를 공존시킨다

근거: workspace 의존성 + search/engine.rs:92-100 주석 — "Build a lightweight reqwest::Client for search (no SSRF filter, no cookies)."

Trade-off

빌드 크기·의존성 그래프가 소폭 증가합니다.

이득

wreq RC 버전 격리, search-only 다운스트림이 boa+wreq+Chrome TLS 코드를 안 끌어옴, 브라우저/검색 정책 분리.

대안이었다면

reqwest 하나만 — 봇 탐지 회피가 불가능해지고 Cloudflare/DataDome을 통과 못 했을 겁니다.

결정 3

렌더 스택을 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."

Trade-off

Phase 1 단계라 RenderDocument가 아직 JS 스레드와 통합되지 않고, 호출자가 직접 구동하는 단독 진입점 상태입니다.

이득

core 사용자가 Blitz 의존성을 피할 수 있고, 렌더 트리 변경 시 재컴파일 영향 범위가 render crate 하나로 제한됩니다.

대안이었다면

core에 Blitz를 직접 통합 — core가 boa+html5ever+Stylo+vello+Blitz를 전부 끌어안아 다운스트림 전체가 재컴파일 대상이 됐을 겁니다.

결정 4

Browser를 trait이 아니라 struct로 정의한다

근거: browser.rs:40pub struct Browser (trait이 아님). oxios의 결정("no trait abstraction, no dyn dispatch")과 정확히 같은 정책입니다.

Trade-off

백엔드가 두 개 이상으로 늘어날 수 없습니다 — 확장하려면 struct 자체를 고쳐야 합니다.

이득

구체 타입 사용으로 dynamic dispatch 비용이 사라지고, 컴파일러 인라인 최적화가 가능해집니다.

대안이었다면

dyn Browser trait object — 백엔드를 여러 개 지원할 여지는 생기지만, 백엔드가 정확히 하나뿐인 상황에선 순수 오버헤드였을 겁니다.

두 프로젝트(oxibrowser, oxios)가 같은 정책으로 독립적으로 수렴했다는 게 흥미로운 지점입니다 — "백엔드가 하나뿐이면 추상화 계층을 만들지 않는다"는 원칙이 이 워크스페이스 전반에 깔려 있다는 신호예요.

07

면접용 한 줄 요약

위 내용을 30초 안에 설명해야 한다면 이 세 문장으로 압축됩니다.

1

oxibrowser는 Chromium/V8 없는 헤드리스 브라우저 — boa_engine의 !Send Context를 전용 std::thread에 가두고 mpsc 3종(fetch/localStorage/DOM snapshot)으로 async 메인 스레드와 통신하는 구조로, 평가 간 영속 JS 상태를 보장합니다.

2

4-crate 분리는 순수 의존성 그래프로 결정됩니다 — oxibrowser-render가 Blitz/Stylo/vello 트리를, oxibrowser-core가 boa/wreq를, oxibrowser-cdp가 hyper/tungstenite를, oxibrowser가 clap + feature-gated search 라이브러리 파사드를 담당합니다.

3

HTTP 클라이언트 2개(wreq + reqwest) 공존은 의도적입니다 — wreq는 Chrome 149 TLS 핑거프린트 에뮬레이션으로 봇 탐지를 우회하는 브라우저 트래픽용, reqwest는 SSRF 필터/쿠키 없는 경량 검색용이며, 다운스트림이 default-features = false로 검색만 컴파일할 수 있게 해줍니다.

08

정리

oxibrowser를 한 문장으로 요약하면 — "Chromium을 빌리지 않고, 대신 !Send JS 엔진을 전용 스레드에 가두는 값을 치르면서까지 단일 정적 바이너리를 지킨 브라우저"입니다. 4개 크레이트 경계는 전부 순수 의존성 그래프에서 나왔고, HTTP 클라이언트 2개·JS/WASM 엔진 2개라는 겉보기 중복도 전부 "백엔드가 하나면 추상화하지 않는다"는 같은 원칙 위에서 갈라진 결과입니다. oxios가 이 파사드/코어 이분법을 그대로 재사용해서 검색은 가볍게, 브라우징은 무겁게 끌어온다는 사실이 이 경계 설계가 실전에서 통했다는 증거이기도 합니다.

소스 — /Volumes/MERCURY/PROJECTS/oxibrowser/ 로컬 워크스페이스 직접 조사, workspace 0.21.0 / edition 2024 / rust 1.96 기준, 2026-08-15
  • Cargo.toml (workspace), AGENTS.md — 정체성, feature 구조, 스레딩 정책
  • crates/oxibrowser-core/src/js/runtime.rs, job_queue.rs, wasm.rs, stealth.rs — !Send 스레드 모델
  • crates/oxibrowser-core/src/network/client.rs, crates/oxibrowser/src/search/engine.rs — wreq/reqwest 분리
  • crates/oxibrowser-cdp/src/server.rs, domains/*.rs — CDP 서버 + OXI.* 확장
  • crates/oxibrowser-render/src/lib.rs, document.rs, paint.rs — Blitz 렌더 격리
  • oxios/Cargo.toml, oxios/crates/oxios-kernel/src/tools/browse/engine.rs — 다운스트림 사용처