Topcoat는 단순성과 생산성을 우선하는, Rust 기반의 모듈식 풀스택 반응형 웹 앱 프레임워크입니다. 서버 렌더링 중심 설계와 자산 파이프라인, 컴포넌트, 재활성 기능을 함께 제공합니다.
Topcoat는 풀스택 반응형 웹 앱을 구축하기 위한 모듈식의 필요한 기능을 갖춘 Rust 프레임워크입니다. 단순성과 생산성을 우선합니다. Topcoat는 전적으로 서버 렌더링됩니다. 반응성은 서버에서 HTML 조각을 렌더링하고 메타데이터로 "반응형 지시문"을 추가하는 방식으로 더해집니다(아이디어 측면에서 HTMX와 유사합니다).
다음은 Topcoat로 만든 "hello world" 앱입니다:
#[tokio::main]
async fn main() {
topcoat::start(Router::builder().discover().build()).await.unwrap();
}
#[page("/")]
async fn home() -> Result {
view! {
<!DOCTYPE html>
<html>
<head>
<title>"Hello world"</title>
topcoat::dev::script()
</head>
<body>
hello(name: "World")
</body>
</html>
}
}
#[component]
async fn hello(name: &str) -> Result {
view! {
<h1>"Hello, " (name) "!"</h1>
}
}
더 많은 기술적 세부 사항으로 들어가기 전에, Topcoat의 배경이 된 동기를 이야기할 필요가 있습니다. 제 TokioConf 발표를 보셨다면(제가 Rust가 모든 그린필드 개발에서 상위 3개 언어가 될 수 있다고 예측했던 그 발표입니다), 이것은 전혀 놀라운 이야기가 아닐 것입니다.
3년 전만 해도 제가 Rust가 웹 앱을 만들기에 훌륭한 언어라고 말했다면, 아마도 저를 미쳤다고 했을 것이고, 그것은 충분히 타당했을 것입니다. 결국 웹 앱은 전통적으로 성능 민감형 애플리케이션이 아닙니다. 그래서 이 작업에 맞는 도구는 빠르게 출시할 수 있게 해주는 것이어야 했습니다. 성능은 있으면 좋은 요소였습니다. 놀랍지 않게도, 가장 풍부한 웹 앱 생태계는 JavaScript, Ruby, PHP처럼 생산성에 큰 비중을 두는 언어들에 있습니다.
하지만 AI는 그 계산법을 완전히 바꾸어 놓았습니다. AI는 학습 장벽과 생산성 격차를 지웁니다. 현대의 코딩 AI 도구가 무언가를 만드는 데 걸리는 시간의 차이는 주로 프로그래밍 언어가 아니라 이용 가능한 라이브러리 집합에 의해 결정됩니다. 심지어 작업자의 구체적인 전문성도 덜 중요해집니다. 저는 Rust를 한 번도 써본 적 없는 숙련된 소프트웨어 엔지니어들이 AI 도구와 함께 Rust로 첫날부터 무언가를 만드는 모습을 보았습니다. 제가 말하는 것은 감각에만 의존한 코딩이 아니라, 범용 엔지니어링 경험을 바탕으로 AI 도구와 상호작용하며 언어를 배우는 동시에 진전을 만들어 가는 방식입니다.
저는 Rust가 훌륭한 언어라고 생각하고, 실제로 매우 뛰어난 속성들(속도와 신뢰성)을 갖추고 있다고 생각합니다. 하지만 그렇다고 해서 여러분이 이미 잘 작동하는 스택을 갖고 있고 Rust의 성능과 신뢰성이 필요하지 않은데도 Rust로 바꿔야 한다고 생각하는 것은 아닙니다(물론 저는 바꾸어야 한다고 생각하지만, 그것이 실용적인 제안이라기보다 Rust에 대한 제 애정이라는 것도 알고 있습니다). 그러나 많은 조직들은 바로 그 문제들 때문에 Rust를 도입하고 있습니다. 성능과 신뢰성이 있는 언어가 필요한 문제를 해결해야 하기 때문입니다. 이미 Rust를 도입한 조직은 더 높은 수준의 앱에서도 Rust를 유지할 이유가 많습니다. 성능이 필요해서가 아니라, 이미 그 언어를 중심으로 내부 인프라(라이브러리, 빌드 시스템, 프로세스, ...)를 갖추고 있기 때문이며, 조직 내 프로그래밍 언어와 도구의 수를 최소화하는 것은 실제로 생산성 향상에 도움이 됩니다.
그래서 이제 웹 앱을 Rust로 생산성 있게 만들기 위해 필요한 것은 풍부한 라이브러리 생태계입니다. 그것이 제가 구축해 온 것입니다. 저는 먼저 Rust용 ORM인 Toasty부터 시작했습니다. 아마 가장 어려운 구성 요소이기 때문입니다. Toasty는 2026년 4월부터 사용 가능한 상태였습니다. 로드맵의 다음 단계는 웹 프레임워크이며, 바로 그 지점에서 Topcoat가 등장합니다.
작년 말, 저는 Julien Scholz를 만났고 Rust를 위한 훌륭한 웹 앱 프레임워크를 만들고자 하는 그의 안목과 열정에 깊은 인상을 받았습니다. 그래서 저는 그가 시간을 내어 하나를 만들도록 설득했습니다. 그것이 바로 Topcoat입니다.
Leptos와 Dioxus 같은 프레임워크는 코드를 WebAssembly로 컴파일해 브라우저에서 실행하는 방식으로 Rust를 사용해 매우 상호작용이 풍부한 웹 애플리케이션을 만드는 데 탁월합니다. 하지만 많은 애플리케이션은 이 정도 수준의 상호작용성을 필요로 하지 않습니다. 그런 사용 사례에서는 별도의 타깃으로 컴파일하고, 번들 크기와 분할을 신경 쓰고, 클라이언트/서버 경계 너머로 데이터를 직렬화하는 일이 부담이 됩니다. Topcoat는 더 단순한 접근을 추구합니다. 모든 마크업은 서버에서 렌더링되므로 컴포넌트는 async가 될 수 있고, 데이터베이스에 접근하거나, 사용자 권한을 안전하게 검증할 수 있습니다. 여기에 반응성을 살짝 더하기 위해, 매크로를 사용해 완전히 타입 검사된 Rust 표현식의 일부를 JavaScript로 교차 컴파일하므로 WebAssembly를 건드리지 않고도 Rust 안에 머무를 수 있습니다. 다음 예제에서는 <button>을 클릭하면 아래의 <p> 태그가 드러납니다:
view! {
// Declare a client-side state variable:
signal open = false;
<button
// Configure a Rust closure as the "on click" handler for this button.
// Code inside the $(...) is run as JavaScript inside the browser:
@click=$(|_e| open.set(!open.get()))
>
"What is Topcoat?"
</button>
// The `hidden` attribute tracks the value of `open` and updates
// each time the button is pressed.
<p :hidden=$(!open.get())>"A fullstack Rust framework."</p>
}
토글 로직은 전적으로 브라우저 안에서 실행되며, 서버 왕복이 필요하지 않습니다.
Topcoat는 클라이언트 상태가 바뀔 때마다 UI의 전체 일부를 서버에서 다시 렌더링하고, 페이지에서 필요한 부분만 교체할 수도 있습니다. 여기서는 사용자가 검색 <input> 필드에 입력할 때 검색 결과가 갱신됩니다:
#[component]
async fn search() -> Result {
view! {
signal query = String::new();
// Write the current text input into the `query` signal:
<input @input=$(|e: Event| query.set(e.target.value))>
// Updates as the user types.
search_results(query: $(query.get()))
}
}
// Shards are a special type of component that exposes an API endpoint from your router.
#[shard]
async fn search_results(cx: &Cx, query: String) -> Result {
// This function runs on the server. It can access the database asynchronously.
view! {
<ul>
for product in search_products(cx, &query).await? {
<li>(product.name)</li>
}
</ul>
}
}
클라이언트 측 반응성 시스템은 아직 개발 초기 단계이며 몇 가지 제약이 있습니다. 앞으로 이를 개선하기 위한 많은 아이디어를 가지고 있습니다. 그동안에는 HTMX 및 Alpine.js 통합도 활용할 수 있습니다.
웹 인터페이스를 구축하는 일은 HTML을 렌더링하는 것보다 훨씬 더 복잡합니다. 웹 앱은 사용자 지정 폰트, 스타일시트, 이미지 등을 불러와야 합니다. Topcoat는 asset 매크로를 이용한 전체 자산 파이프라인을 제공합니다:
const FERRIS: Asset = asset!("./ferris.png");
view! { <img src=(FERRIS)> }
빌드 시점에 Topcoat CLI는 모든 자산을 수집하거나 다운로드하여 하나의 디렉터리에 저장합니다. 애플리케이션이 실행되는 동안에는 브라우저 캐싱을 최적화하기 위해 콘텐츠 해시를 사용해 제공됩니다.
좋은 디자인에는 각자의 폰트와 아이콘이 필요합니다. Fontsource와 Iconify는 사용할 수 있는 방대한 무료 폰트 및 아이콘 라이브러리를 제공합니다. 이것들은 Topcoat 앱에 아주 쉽게 포함할 수 있습니다:
// Loads the "Roboto" web font from Fontsource.
const ROBOTO: Font = fontsource_font!(ROBOTO);
// Creates a Rust module containing the "feather" icon set.
iconify::include!("feather");
일관된 디자인 시스템과 그 모든 컴포넌트를 구상하는 일은 어려울 수 있습니다. 컴포넌트 라이브러리는 빠르게 시작할 수 있게 해주지만, 디자인이 바뀌기 시작하면 종종 유연성이 부족하다는 문제가 드러납니다. shadcn/ui에서 영감을 받은 Topcoat의 내장 컴포넌트 라이브러리는 Tailwind를 기반으로 하며, 미리 준비된 컴포넌트를 여러분의 소스 디렉터리로 직접 복사합니다. 이는 필요한 어떤 것이든 수정할 수 있고 디자인을 진정으로 여러분만의 것으로 만들 수 있다는 뜻입니다:
#[component]
async fn delete_card() -> Result {
view! {
card(
card_header(
card_title("Delete workspace")
card_description("This permanently removes the workspace and all of its data.")
)
card_footer(
attrs: attributes! { class="justify-end" },
button(variant: ButtonVariant::Ghost, "Cancel")
button(variant: ButtonVariant::Destructive, "Delete workspace")
)
)
}
}
사람과 AI 모두 코드의 작은 영역을 가로질러 추론할 때 더 잘 작동합니다. Topcoat는 로직을 지역적으로 유지하고 조합 가능하게 만들 수 있도록 처음부터 그렇게 설계되었습니다. 예를 들어 우리는 데이터를 컴포넌트 인자로 아래로 전달하기보다, 컴포넌트가 스스로 데이터를 가져오도록 권장합니다:
#[component]
async fn user_profile(cx: &Cx, user_id: &str) -> Result {
// Only this component knows what user data it needs.
let user = load_user(cx, user_id).await?;
view! {
<h1>(user.name)</h1>
...
}
}
데이터를 여러 번 가져오는 일을 피하기 위해 Topcoat에는 React의 cache에서 영감을 받은 요청 수준 메모이제이션이 내장되어 있습니다:
#[memoize]
async fn load_user(cx: &Cx, user_id: &str) -> Result<User> {
// This database call is made only once per unique `user_id`.
db(cx).load_user_by_id(user_id).await
}
이 원칙은 인증으로도 더 확장될 수 있습니다. 실행될 수도 있고 아닐 수도 있는 코드베이스의 완전히 다른 위치에 있는 미들웨어에 의존하는 대신, 데이터를 컴포넌트 안에서 직접 보호할 수 있습니다:
async fn require_auth(cx: &Cx) -> Result<User> {
if let Some(Session { user_id }) = current_session(cx).await? {
Ok(load_user(cx, user_id).await?)
} else {
// Data is kept secret, redirect to login.
Err(redirect("/login").into())
}
}
#[component]
async fn user_profile(cx: &Cx) -> Result {
// `user_profile` protects itself from misuse if the user is not logged in!
let user = require_auth(cx).await?;
view! {
<h1>(user.name)</h1>
...
}
}
React의 훅과 마찬가지로(하지만 악명 높은 hooks 규칙은 없이), 함수들은 요청 컨텍스트(cx)를 주고받으며 잘 조합됩니다.
Axum은 서버 애플리케이션 구축을 위한 또 다른 tokio-rs 호스팅 크레이트입니다. 이 때문에 Topcoat와 Axum은 매우 다른 사용 사례를 다룬다는 점을 짚고 넘어가고 싶습니다. 실제로 저는 많은 Topcoat 사용자가 프로젝트 안에서 결국 Axum도 함께 사용하게 될 것이라고 예상합니다. Axum은 저수준 HTTP 라우터로, HTTP API 엔드포인트를 쉽게 만들 수 있게 해줍니다. 물론 Axum으로도 반응형 웹 앱을 만들 수는 있겠지만, 훨씬 더 많은 상용구 코드와 설정이 필요할 것입니다. Topcoat의 목표는 그 상용구를 제거하는 것이지만, 여러분이 그와 같은 저수준 HTTP 엔드포인트만 만들고 싶다면 여전히 Axum을 선택해야 합니다.
이것은 Topcoat의 첫 번째 릴리스에 불과합니다. Toasty와의 더 긴밀한 통합, 유효성 검사, 이메일 등 로드맵에는 많은 항목이 있습니다. 그중 일부는 Readme에서 감을 잡을 수 있습니다.
그동안 Topcoat는 지금 바로 사용할 준비가 되어 있으며, 데이터베이스가 필요하다면 Toasty도 함께 가져오면 됩니다. 한번 써 보시고, 질문이나 피드백을 보내 주세요. 저희는 모두 Tokio Discord의 #topcoat 채널에 있습니다.
즐겁게 만들어 보세요.