Leptos 가이드 — Rust 풀스택 웹 프레임워크·Signals·SSR·Server Functions

이 글의 핵심

Leptos는 Rust로 UI·상태·서버 로직을 한 언어에서 다루는 풀스택 웹 프레임워크입니다. 이 글은 Signals 기반 반응성, 컴포넌트, Server Functions, 라우팅, SSR·하이드레이션, 실전 앱 구조까지 한 흐름으로 연결합니다.

Leptos는 Rust로 반응형 UI와 서버 로직을 함께 작성하는 풀스택 웹 프레임워크입니다. 브라우저에서는 컴포넌트를 WebAssembly(WASM)로 실행하고, 서버에서는 같은 컴포넌트로 HTML을 렌더링(SSR)해 보낸 뒤 브라우저에서 하이드레이션(hydration)으로 상호작용을 연결합니다.

Leptos는 0.7에서 반응성 시스템과 라우터 API가 크게 바뀌었습니다. 예전 글이나 예제에서 보이는 create_signal, create_memo, create_effect 같은 함수는 0.6까지의 API이고, 0.7부터는 signal(), Memo::new, Effect::new 형태를 씁니다. 이 글의 코드는 0.7 이후 API를 기준으로 하므로, 프로젝트의 leptos 버전과 공식 문서를 함께 확인하시기 바랍니다.


Leptos를 이해하는 출발점

왜 “Rust 풀스택”인가

일반적인 웹 스택은 언어가 둘 이상입니다. 프런트엔드를 TypeScript로, API를 Go나 Python으로 나누면 DTO 스키마, 검증 규칙, 에러 형식을 경계마다 따로 정의하고 맞춰야 합니다. Leptos는 UI, 상태, 서버 함수 시그니처를 모두 Rust 타입으로 두기 때문에, 서버 함수의 인자나 반환 타입을 바꾸면 호출하는 클라이언트 코드에서 바로 컴파일 오류가 납니다.

다만 Rust의 학습 비용과, WASM과 서버 바이너리를 함께 빌드하는 파이프라인이 따라옵니다. 팀이 Rust에 익숙한지, 브라우저에 WASM 번들을 내려보내는 배포를 감당할 수 있는지를 먼저 판단하는 것이 좋습니다.

렌더링과 반응성의 큰 그림

Leptos 컴포넌트 함수는 처음에 한 번만 실행되어 DOM을 만들고, 그 과정에서 어떤 DOM 조각이 어떤 시그널을 읽는지 반응성 그래프에 기록합니다. 이후 시그널이 바뀌면 그 시그널을 읽은 조각(클로저)만 다시 실행되어 해당 DOM 노드를 직접 갱신합니다. 가상 DOM을 만들어 비교(diff)하는 단계가 없습니다.

이 모델은 상태가 바뀔 때마다 컴포넌트 함수 전체를 다시 호출하는 React의 멘탈 모델과 다릅니다. Leptos에서는 컴포넌트 본문에 쓴 일반 코드는 한 번만 실행되고, 갱신되어야 하는 부분은 move || ... 클로저로 감싸야 한다는 점을 기억해야 합니다.


핵심 개념: view!, IntoView, 반응성 그래프

view! 매크로

view!는 HTML과 비슷한 문법으로 UI 트리를 선언하며, 컴파일 시점에 DOM을 만드는 Rust 코드로 전개됩니다.

use leptos::prelude::*;

#[component]
fn Greeting(name: String) -> impl IntoView {
    view! {
        <section class="card">
            <h1>"안녕하세요, " {name} "!"</h1>
        </section>
    }
}

텍스트는 따옴표로 감싼 문자열 리터럴로 씁니다. 여기서 name은 일반 String이므로 렌더링 시점에 한 번 들어가고 끝납니다. 값이 바뀔 때 텍스트도 바뀌게 하려면 시그널을 받아 {move || name.get()}처럼 클로저로 넣어야 합니다. 0.7부터는 시그널 자체를 {name}처럼 넣어도 반응형으로 처리됩니다.

IntoView와 조합 가능한 뷰

컴포넌트는 반환 타입으로 impl IntoView를 씁니다. 요소, 문자열, 조건부 뷰, 리스트, 클로저처럼 서로 다른 타입을 모두 “뷰로 바꿀 수 있는 것”으로 다룰 수 있게 해 주는 트레이트입니다.

반응성 그래프가 해결하는 문제

모든 화면이 하나의 전역 상태에 의존하고 상태가 바뀔 때마다 넓은 범위를 다시 렌더링하면, 작은 변경에도 불필요한 작업이 퍼집니다. 시그널은 누가 자신을 읽었는지 추적하므로 변경의 원인에 연결된 부분만 갱신됩니다. 디버깅할 때도 “이 텍스트는 어떤 시그널을 읽는가”를 따라가면 갱신 경로가 드러납니다.


반응성 시스템: Signals

읽기·쓰기 시그널

signal()은 읽기 핸들과 쓰기 핸들을 쌍으로 돌려줍니다. 반응형 컨텍스트(클로저, 메모, 이펙트) 안에서 읽기 핸들의 .get()을 호출하면 구독이 생기고, 쓰기 핸들로 값을 바꾸면 구독자에게 알립니다.

use leptos::prelude::*;

#[component]
fn Counter(initial: i32) -> impl IntoView {
    let (count, set_count) = signal(initial);

    view! {
        <button on:click=move |_| set_count.update(|n| *n += 1)>
            {move || count.get()}
        </button>
    }
}

이벤트 핸들러와 반응형 블록에 move 클로저가 많은 이유는, 클로저가 컴포넌트 함수보다 오래 살아남기 때문입니다. 컴포넌트 함수는 한 번 실행되고 끝나지만, 클릭 핸들러와 텍스트 갱신 클로저는 DOM이 살아 있는 동안 계속 호출되므로 캡처한 값을 소유해야 합니다. 시그널 핸들은 Copy 타입이라 여러 클로저에 move해도 문제없습니다.

파생 값: Memo

여러 시그널에서 계산되는 값은 Memo::new로 만듭니다. 입력 시그널이 바뀔 때만 다시 계산되고, 결과가 이전과 같으면 구독자에게 알리지 않습니다.

use leptos::prelude::*;

#[component]
fn PriceTag(unit_price: f64) -> impl IntoView {
    let qty = RwSignal::new(1_i32);
    let subtotal = Memo::new(move |_| unit_price * qty.get() as f64);

    view! {
        <input type="number"
            prop:value=move || qty.get()
            on:input=move |ev| {
                if let Ok(v) = event_target_value(&ev).parse() {
                    qty.set(v);
                }
            }
        />
        <p>"소계: " {move || format!("{:.2}", subtotal.get())}</p>
    }
}

RwSignal은 읽기와 쓰기를 하나의 핸들로 다루는 시그널입니다. 계산이 가벼운 파생 값이라면 let double = move || count.get() * 2;처럼 클로저(파생 시그널)로도 충분하고, Memo는 계산 비용이 크거나 여러 곳에서 읽을 때 씁니다.

부수 효과: Effect

로깅이나 브라우저 API 호출처럼 화면에 직접 나타나지 않는 작업은 Effect로 분리합니다. 이펙트 안에서 읽은 시그널이 바뀔 때마다 다시 실행됩니다.

use leptos::prelude::*;

#[component]
fn LogOnChange() -> impl IntoView {
    let (value, set_value) = signal(0_i32);

    Effect::new(move |_| {
        leptos::logging::log!("value = {}", value.get());
    });

    view! {
        <button on:click=move |_| set_value.update(|v| *v += 1)>"+1"</button>
    }
}

0.7부터 Effect는 브라우저에서만 실행되고 SSR 중에는 실행되지 않습니다. 그래서 window나 localStorage 같은 브라우저 전용 API를 다루기에 맞는 자리입니다. 반대로 이펙트 안에서 네트워크 요청을 보내면, 의존하는 시그널이 바뀔 때마다 요청이 나가 호출이 폭주할 수 있습니다. 데이터 로딩은 뒤에서 설명할 Resource와 서버 함수에 맡기는 편이 낫습니다.


컴포넌트와 Props

#[component]와 Props

컴포넌트는 함수에 #[component] 매크로를 붙여 정의하며, 함수 인자가 그대로 Props가 됩니다. 생략 가능한 Props에는 #[prop(optional)]이나 #[prop(default = ...)]를 붙입니다.

use leptos::prelude::*;

#[component]
pub fn Button(#[prop(optional)] label: Option<String>) -> impl IntoView {
    let text = label.unwrap_or_else(|| "확인".to_string());
    view! { <button>{text}</button> }
}

<Button/>처럼 label 없이 쓰면 None이 들어갑니다. #[prop(into)]를 붙이면 &str처럼 Into<String>인 값도 받을 수 있어 호출하는 쪽이 편해집니다.

자식(Children)

레이아웃 컴포넌트는 자식 뷰를 받아 감싸는 형태가 흔합니다. 헤더, 사이드바, 본문 같은 뼈대를 고정하고 페이지별 내용만 주입할 때 씁니다.

use leptos::prelude::*;

#[component]
pub fn Shell(children: Children) -> impl IntoView {
    view! {
        <div class="shell">
            <header>"My App"</header>
            <main>{children()}</main>
        </div>
    }
}

Children은 한 번만 호출할 수 있는 FnOnce입니다. 조건부로 자식을 여러 번 렌더링해야 하는 컴포넌트라면 ChildrenFn을 씁니다.


Server Functions

서버에서 실행되는 함수 경계

#[server]를 붙인 함수는 클라이언트 코드에서 일반 async 함수처럼 호출하지만, 실제로는 HTTP 요청으로 직렬화되어 서버에서 실행됩니다. 서버 빌드에서는 함수 본문이 그대로 컴파일되고, 클라이언트(WASM) 빌드에서는 본문 대신 서버로 요청을 보내는 코드가 생성됩니다. 그래서 본문에서 DB 커넥션 같은 서버 전용 의존성을 써도 WASM 번들에 들어가지 않습니다.

use leptos::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TodoInput {
    pub title: String,
}

#[server]
pub async fn create_todo(input: TodoInput) -> Result<(), ServerFnError> {
    if input.title.trim().is_empty() {
        return Err(ServerFnError::new("제목이 비어 있습니다"));
    }
    // DB 저장 등 서버 자원 접근
    leptos::logging::log!("새 할 일: {}", input.title);
    Ok(())
}

타입이 공유되더라도 호출마다 직렬화와 네트워크 왕복이 있으므로, 잦은 호출이 필요한 곳에서는 비용을 고려해야 합니다. 서버 함수는 공개된 HTTP 엔드포인트이기도 합니다. 클라이언트 코드는 사용자가 얼마든지 조작할 수 있으므로, 인증, 권한, 입력 검증은 서버 함수 안에서 다시 수행해야 합니다.

에러 모델

서버 함수는 Result<T, ServerFnError>(또는 직접 정의한 에러 타입)를 반환하고, 에러도 직렬화되어 클라이언트로 전달됩니다. 따라서 DB 에러 메시지를 그대로 담아 보내면 테이블 이름이나 쿼리 같은 내부 정보가 노출될 수 있습니다. 내부 원인은 서버 로그에 남기고, 클라이언트에는 사용자에게 보여 줄 메시지만 보내도록 나눕니다.

서버 데이터 읽기: Resource

서버에서 데이터를 읽어 화면에 보여 줄 때는 Resource를 씁니다. 리소스는 의존하는 시그널이 바뀌면 다시 로드되고, SSR 중에 로드한 데이터를 HTML과 함께 직렬화해 보내 클라이언트가 하이드레이션 때 같은 요청을 다시 보내지 않게 합니다.

#[server]
pub async fn get_todos(page: u32) -> Result<Vec<String>, ServerFnError> {
    Ok(vec![format!("page {page} item")])
}

#[component]
fn TodoList() -> impl IntoView {
    let (page, _set_page) = signal(1_u32);
    let todos = Resource::new(move || page.get(), |p| get_todos(p));

    view! {
        <Suspense fallback=|| view! { <p>"불러오는 중..."</p> }>
            {move || todos.get().map(|res| match res {
                Ok(items) => items.into_iter().map(|t| view! { <li>{t}</li> }).collect_view().into_any(),
                Err(e) => view! { <p>"오류: " {e.to_string()}</p> }.into_any(),
            })}
        </Suspense>
    }
}

데이터를 바꾸는 호출(생성, 수정, 삭제)에는 ServerAction과 <ActionForm/>을 쓰면 폼 제출과 서버 함수 호출을 연결할 수 있고, JavaScript나 WASM이 아직 로드되지 않은 상태에서도 일반 폼 POST로 동작합니다.


라우팅: leptos_router

Router·Routes·Route

leptos_router는 URL과 컴포넌트를 연결하고 클라이언트 측 내비게이션을 처리합니다. 0.7부터 경로는 path! 매크로로 쓰고, Routes에는 일치하는 경로가 없을 때 보여 줄 fallback이 필수입니다.

use leptos::prelude::*;
use leptos_router::components::{Route, Router, Routes, A};
use leptos_router::path;

#[component]
fn App() -> impl IntoView {
    view! {
        <Router>
            <nav>
                <A href="/">"홈"</A>
                <A href="/about">"소개"</A>
            </nav>
            <main>
                <Routes fallback=|| "페이지를 찾을 수 없습니다.">
                    <Route path=path!("/") view=Home/>
                    <Route path=path!("/about") view=About/>
                </Routes>
            </main>
        </Router>
    }
}

#[component]
fn Home() -> impl IntoView { view! { <h1>"홈"</h1> } }

#[component]
fn About() -> impl IntoView { view! { <h1>"소개"</h1> } }

중첩 라우트와 레이아웃

관리자 화면처럼 공통 레이아웃 아래에 하위 경로를 두는 경우에는 ParentRoute로 부모 경로를 정의하고, 부모 컴포넌트 안의 <Outlet/> 자리에 자식 경로의 뷰가 렌더링되게 합니다. 권한 확인을 부모 레이아웃 한곳에 두면 하위 경로마다 반복하지 않아도 됩니다. 다만 클라이언트 쪽 권한 확인은 화면을 숨기는 역할일 뿐이므로, 실제 데이터 접근 제어는 서버 함수에서 해야 합니다.

A 컴포넌트와 내비게이션

Leptos 라우터는 같은 출처를 가리키는 일반 <a href> 클릭도 가로채 클라이언트 내비게이션으로 처리합니다. <A> 컴포넌트는 여기에 더해 상대 경로를 현재 라우트 기준으로 해석하고, 현재 경로와 일치하면 aria-current="page" 속성을 붙여 활성 메뉴 스타일링을 쉽게 해 줍니다.


SSR과 Hydration

SSR이 주는 가치

서버에서 초기 HTML을 만들어 보내면 WASM 번들이 로드되기 전에 내용이 보이므로 첫 화면이 빨라지고, 검색 엔진이나 소셜 미리보기처럼 HTML 자체가 필요한 경우에도 유리합니다. 다만 사용자마다 다른 화면은 CDN 캐시와 충돌하므로, 공개 페이지와 로그인 후 페이지를 나눠 캐시 정책을 정하는 것이 일반적입니다.

Hydration이 하는 일

SSR로 받은 HTML은 그 자체로는 정적입니다. 하이드레이션은 브라우저에서 WASM이 로드된 뒤 같은 컴포넌트를 다시 실행하면서, 새 DOM을 만드는 대신 이미 있는 DOM 노드에 이벤트 핸들러와 반응성 그래프를 연결하는 과정입니다. 이때 서버가 만든 HTML 구조와 클라이언트가 기대하는 구조가 다르면 하이드레이션이 실패하거나 화면이 깨집니다.

대표적인 원인은 렌더링 중에 현재 시각, 난수, localStorage 같은 값을 직접 읽는 것입니다. 서버와 브라우저에서 결과가 달라지기 때문입니다. 초기 렌더링에는 서버와 클라이언트가 같은 값을 쓰도록 하고, 브라우저에서만 알 수 있는 값은 하이드레이션 후 Effect에서 시그널을 갱신하는 방식으로 분리합니다. 서버 데이터는 Resource로 읽으면 SSR 결과가 직렬화되어 전달되므로 같은 값이 보장됩니다.

cargo-leptos와 실행 모드

SSR 프로젝트는 보통 cargo-leptos로 서버 바이너리(ssr 피처)와 클라이언트 WASM(hydrate 피처)을 함께 빌드합니다. 같은 크레이트를 피처 플래그로 두 번 컴파일하는 구조라서, 서버 전용 의존성은 ssr 피처에만 묶어 두어야 WASM 빌드가 깨지지 않습니다. 개발 중에는 cargo leptos watch로 변경 시 재빌드와 리로드를 하고, 배포할 때는 cargo leptos build --release로 만든 서버 바이너리와 site 디렉터리의 정적 에셋(WASM, JS, CSS)을 함께 배포합니다. 정적 에셋은 CDN이나 리버스 프록시에서 직접 서빙하도록 분리할 수도 있습니다.


실전 풀스택 앱 구조

규모가 커지면 다음처럼 책임을 나눠 두면 유지보수가 쉬워집니다. components/에는 재사용 UI를, routes/(또는 pages/)에는 URL 단위 화면을, server/에는 DB 모델과 저장소, 외부 API 클라이언트를 둡니다. lib.rs는 앱 컴포넌트와 라우터를 조립하고 하이드레이션 진입점을 두며, main.rs는 ssr 피처에서 Axum이나 Actix 서버를 띄웁니다. server/ 모듈 전체를 #[cfg(feature = "ssr")]로 감싸 두면 실수로 클라이언트 코드에서 서버 전용 코드를 참조할 때 컴파일 단계에서 걸러집니다.

데이터 흐름은 역할을 나누면 단순해집니다. 권한이 필요한 변경과 민감한 조회는 서버 함수로 처리하고, 클라이언트 시그널은 모달 열림이나 폼 입력 같은 UI 상태에만 씁니다. 서버 데이터를 화면에 보여 줄 때는 Resource로 읽고, 변경 후에는 액션의 결과에 따라 리소스를 다시 불러옵니다. 같은 개념을 여러 시그널에 중복 저장하지 말고, 계산 가능한 값은 메모나 파생 클로저로 유도하면 값이 어긋나는 버그를 줄일 수 있습니다. API 키 같은 비밀은 서버 함수 안에서만 쓰고 시그널이나 클라이언트 코드에 넣지 않습니다.

운영 단계에서는 WASM 번들 크기, 초기 로드 시간, 서버 TTFB를 따로 측정해야 어디가 느린지 구분할 수 있습니다. WASM 번들은 릴리스 프로필에서 opt-level = 'z', lto = true 같은 설정으로 줄일 수 있고, .wasm 파일이 application/wasm MIME 타입과 압축(gzip, brotli)으로 서빙되는지도 확인합니다. MIME 타입이 다르면 브라우저가 스트리밍 컴파일을 쓰지 못하거나 로드에 실패합니다.


트러블슈팅

증상흔한 원인점검
Hydration 오류, 화면 일부가 깨짐렌더링 중 시간·난수·브라우저 전용 API 사용초기값을 서버와 클라이언트에서 같게, 브라우저 값은 Effect로
시그널이 바뀌어도 UI가 그대로{count.get()}처럼 클로저 없이 한 번만 읽음move || count.get() 클로저로 감쌌는지 확인
WASM 로드 실패MIME 타입, 정적 파일 경로, site-root 설정네트워크 탭, 서버 정적 파일 설정
WASM 빌드에서 서버 크레이트 컴파일 오류서버 전용 의존성이 hydrate 빌드에 포함됨의존성과 모듈을 ssr 피처로 분리
서버 함수가 401/403쿠키 SameSite, 프록시 뒤의 HTTPS 판별리버스 프록시의 X-Forwarded-Proto, 쿠키 설정

같이 보면 좋은 글