Leptos 완벽 가이드 — Rust 풀스택 웹 프레임워크·Signals·SSR·Server Functions
이 글의 핵심
Leptos는 Rust로 UI·상태·서버 로직을 한 언어에서 다루는 풀스택 웹 프레임워크입니다. 이 글은 Signals 기반 반응성, 컴포넌트, Server Functions, 라우팅, SSR·하이드레이션, 실전 앱 구조까지 한 흐름으로 연결합니다.
이 글의 핵심
Leptos는 Rust로 반응형 UI와 서버 로직을 함께 설계하기 위한 풀스택 웹 프레임워크입니다. 브라우저에서는 주로 WebAssembly(WASM) 로 컴포넌트를 실행하고, 서버에서는 SSR(서버 사이드 렌더링) 과 하이드레이션(hydration) 으로 초기 HTML을 빠르게 제공한 뒤, 클라이언트에서 동일한 반응성 그래프를 이어 붙입니다.
이 글에서는 다음을 실무 관점에서 연결합니다.
- 핵심 개념:
view!·IntoView, 반응성 그래프, 렌더링 모델 - Signals: 읽기·쓰기·파생 값·부수 효과
- 컴포넌트와 Props:
#[component], 자식 슬롯, 타입 안전한 속성 - Server Functions: 서버 전용 RPC, 직렬화·에러 타입
- 라우팅:
leptos_router의Route·중첩·링크 - SSR·하이드레이션: 초기 HTML·클라이언트 활성화
- 실전 풀스택:
cargo-leptos기반 프로젝트 구조와 배포 시 고려사항
참고: Leptos는 버전에 따라 크레이트·매크로 이름이 달라질 수 있습니다. 운영 전 공식 문서와 프로젝트의
leptos버전을 함께 확인하시기 바랍니다.
1. Leptos를 이해하는 출발점
1-1. 왜 “Rust 풀스택”인가
전통적인 웹 스택은 언어가 둘 이상입니다. 예를 들어 TypeScript로 프론트, Go나 Python으로 API를 나누면, DTO 스키마·검증 규칙·에러 형식이 경계마다 반복됩니다. Leptos는 UI·상태·서버 함수 시그니처를 Rust 타입 시스템 안에 두기 때문에, 컴파일 시점에 불일치를 크게 줄일 수 있습니다.
다만 Rust는 학습 비용과 빌드 파이프라인(WASM·SSR) 이 따르므로, “팀 전체가 Rust에 익숙한가”, “브라우저 WASM 배포를 감당할 수 있는가”를 먼저 판단하는 것이 좋습니다.
1-2. 렌더링과 반응성의 큰 그림
Leptos UI는 대개 다음과 같은 흐름을 가집니다.
- 컴포넌트 함수는 “한 번 실행되어” 반응성 그래프의 노드를 설치합니다.
- Signals가 변경되면, 그 신호에 구독한 뷰 조각만 다시 계산합니다.
- 가상 DOM 전체 diff를 기본으로 삼지 않고, 세밀한 업데이트를 지향합니다.
이 모델은 “컴포넌트 = 매 프레임마다 호출되는 렌더 함수”라는 React식 멘탈모델과 다릅니다. Leptos에서는 설치(install) 와 구독(subscription) 의 이미지가 더 잘 맞습니다.
2. 핵심 개념: view!, IntoView, 반응성 그래프
2-1. view! 매크로
view!는 선언적으로 UI 트리를 구성합니다. HTML과 유사한 문법으로 요소·이벤트·자식을 기술하며, 컴파일 타임에 최적화된 뷰 생성 코드로 전개됩니다.
use leptos::prelude::*;
#[component]
fn Greeting(name: String) -> impl IntoView {
view! {
<section class="card">
<h1>"안녕하세요, " {name} "!"</h1>
</section>
}
}
핵심 포인트는 name이 문자열 그대로 한 번만 끼워 넣어지는 것이 아니라, 반응형 값이면 해당 값이 바뀔 때만 관련 텍스트 노드가 갱신될 수 있다는 점입니다(타입·컨텍스트에 따라 동작이 달라지므로, 프로젝트에서 사용하는 버전의 예제를 기준으로 삼는 것이 안전합니다).
2-2. IntoView와 조합 가능한 뷰
컴포넌트는 반환 타입으로 impl IntoView를 자주 사용합니다. 조각(fragment)·조건부·리스트를 일관되게 합성할 수 있게 해 주는 경계 타입이라고 이해하면 됩니다.
2-3. “반응성 그래프”가 해결하는 문제
전역 상태 스토어에 모든 화면이 의존하면, 작은 변경에도 불필요한 렌더가 번질 수 있습니다. Signals는 의존성을 추적하여, 변경의 원인에 연결된 뷰만 갱신하는 패턴을 취합니다. 이는 성능뿐 아니라 디버깅 시 인과 관계를 좁히는 데도 도움이 됩니다.
3. 반응성 시스템: Signals
3-1. 읽기·쓰기 시그널
가장 기본은 create_signal으로 만든 읽기 핸들과 쓰기 핸들입니다. 읽기는 구독을 만들고, 쓰기는 변경을 알립니다.
use leptos::prelude::*;
#[component]
fn Counter(initial: i32) -> impl IntoView {
let (count, set_count) = create_signal(initial);
view! {
<button
on:click=move |_| set_count.update(|n| *n += 1)
>
{move || count.get()}
</button>
}
}
왜 move 클로저가 많은가에 대한 짧은 설명: 이벤트 핸들러와 반응형 블록은 소유권이 클로저로 넘어가며, 구독이 살아 있는 동안 캡처된 시그널이 안전하게 참조되어야 합니다. Rust의 소유권 모델이 UI 코드에 그대로 드러나는 지점입니다.
3-2. 파생 값: create_memo
여러 시그널을 읽어 파생 상태를 만들 때는 create_memo가 적합합니다. 입력이 바뀔 때만 재계산되도록 메모이제이션됩니다.
use leptos::prelude::*;
#[component]
fn PriceTag(unit_price: f64) -> impl IntoView {
let qty = create_rw_signal(1_i32);
let subtotal = create_memo(move |_| unit_price * qty.get() as f64);
view! {
<p>"소계: " {move || format!("{:.2}", subtotal.get())}</p>
}
}
3-3. 부수 효과: create_effect
외부 세계와 맞닿는 작업(로깅, 브라우저 API 호출 등)은 렌더 결과에 직접 나타나지 않을 수 있습니다. 이때 의존성이 바뀔 때만 실행되는 효과로 분리합니다.
use leptos::prelude::*;
#[component]
fn LogOnChange() -> impl IntoView {
let (value, set_value) = create_signal(0_i32);
create_effect(move |_| {
leptos::logging::log!("value = {}", value.get());
});
view! {
<button on:click=move |_| set_value.update(|v| *v += 1)>"+1"</button>
}
}
주의: 효과 안에서 무거운 네트워크 호출을 무분별하게 넣으면, 의존성 변화에 따라 호출 폭주가 날 수 있습니다. 데이터 페칭은 리소스/서버 함수와 역할을 나누는 편이 운영에 유리합니다.
4. 컴포넌트와 Props
4-1. #[component]와 Props
컴포넌트는 함수에 매크로를 붙여 정의합니다. 함수 인자가 Props가 되며, 기본값·옵션·자식 슬롯 등은 프로젝트에서 채택한 패턴(예: #[prop(optional)])에 따라 확장합니다.
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> }
}
4-2. 자식(Children)과 슬롯
레이아웃 컴포넌트는 자식 뷰를 받아 감싸는 형태가 흔합니다. 이는 재사용 가능한 뼈대(헤더·사이드바·메인)를 만들 때 유용합니다.
use leptos::prelude::*;
#[component]
pub fn Shell(children: Children) -> impl IntoView {
view! {
<div class="shell">
<header>"My App"</header>
<main>{children()}</main>
</div>
}
}
핵심은 “자식도 뷰 트리의 일부”라는 점입니다. 부모가 구조를 고정하고, 페이지별 내용만 슬롯으로 주입합니다.
5. Server Functions
5-1. 서버 전용 RPC 형태의 경계
Server Functions는 브라우저 코드에서 함수처럼 호출하지만, 실행은 서버에서 이루어지는 RPC에 가깝습니다. 요청·응답이 직렬화되므로, 타입은 유지되더라도 경계 비용이 존재합니다.
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
pub struct TodoInput {
pub title: String,
}
#[server]
pub async fn create_todo(input: TodoInput) -> Result<(), ServerFnError> {
// DB·파일 등 서버 자원 접근
leptos::logging::log!("새 할 일: {}", input.title);
Ok(())
}
운영 관점에서 중요한 것은 인증·권한·입력 검증을 서버에서 반드시 다시 수행한다는 점입니다. 클라이언트 Rust 코드는 조작 가능하므로, 서버 함수는 신뢰 경계입니다.
5-2. 에러 모델과 사용자 메시지
ServerFnError는 경계를 넘는 실패를 표현합니다. 사용자에게 보여 줄 메시지와 내부 원인 로그를 분리하고, 민감 정보가 직렬화되지 않게 주의합니다.
6. 라우팅: leptos_router
6-1. Router·Routes·Route
leptos_router는 클라이언트 내비게이션과 URL ↔ 컴포넌트 매핑을 제공합니다. 앱 루트에 Router를 두고, 경로별로 Route를 정의합니다.
use leptos::prelude::*;
use leptos_router::components::{Route, Router, Routes};
#[component]
fn App() -> impl IntoView {
view! {
<Router>
<nav>
<a href="/">"홈"</a>
<a href="/about">"소개"</a>
</nav>
<main>
<Routes>
<Route path="" view=Home/>
<Route path="about" view=About/>
</Routes>
</main>
</Router>
}
}
#[component]
fn Home() -> impl IntoView { view! { <h1>"홈"</h1> } }
#[component]
fn About() -> impl IntoView { view! { <h1>"소개"</h1> } }
6-2. 중첩 라우트와 레이아웃
관리자 화면처럼 공통 레이아웃 아래에 하위 경로를 두는 경우, 중첩 Route 와 부모 레이아웃 컴포넌트를 조합합니다. 이 패턴은 권한 체크를 레이아웃 한곳에 모을 때 유리합니다.
6-3. A 컴포넌트와 네비게이션
일반 <a href>도 동작하지만, SPA 경험을 위해 프레임워크가 제공하는 링크 컴포넌트(문서에서 A 등으로 안내)를 쓰면 클라이언트 라우팅과 프리페치 전략을 일관되게 가져가기 쉽습니다. 버전별 이름이 다를 수 있으니, 사용 중인 문서의 예제를 따르십시오.
7. SSR과 Hydration
7-1. SSR이 주는 가치
초기 HTML을 서버에서 생성하면 첫 페인트가 빨라지고, 검색 엔진·소셜 미리보기 등 HTML 스냅샷이 필요한 시나리오에 유리합니다. 다만 개인화된 UI는 캐시 전략과 충돌할 수 있으므로, 공개 페이지 vs 로그인 후 페이지를 나눠 설계하는 것이 일반적입니다.
7-2. Hydration이 하는 일
SSR로 내려준 HTML은 정적입니다. Hydration은 브라우저에서 WASM이 로드된 뒤, 동일한 반응성 그래프를 연결해 클릭·입력 같은 상호작용을 살리는 과정입니다. 여기서 서버 HTML과 클라이언트 트리 불일치가 나면 경고나 깨짐이 발생할 수 있으므로, 초기 상태의 단일 출처를 유지하는 것이 중요합니다.
7-3. cargo-leptos와 실행 모드
실전 프로젝트는 cargo-leptos 로 서버·클라이언트 빌드·실행을 묶는 경우가 많습니다. 개발 서버에서 핫 리로드·에셋 처리를 경험한 뒤, 배포 시에는 정적 파일·WASM·서버 바이너리를 각각 CDN·리버스 프록시 뒤에 두는 구성을 검토합니다.
8. 실전 풀스택 앱 구축: 권장 구조
8-1. 디렉터리 관점의 분리
규모가 커지면 다음과 같이 책임을 나누는 것이 유지보수에 유리합니다.
components/: 재사용 UI·디자인 시스템routes/또는pages/: URL 단위 화면server/: DB 모델·리포지토리·외부 API 클라이언트lib.rs/main.rs: 앱 부트스트랩·라우터 조립
8-2. 데이터 흐름 패턴
- 서버 함수로 권한 있는 변경·민감 조회를 처리합니다.
- 클라이언트 시그널은 UI 상태(모달 열림, 폼 입력)에 집중합니다.
- 서버와의 동기화가 필요하면 리소스/페칭 패턴(프로젝트 템플릿과 문서의
create_resource계열)을 사용합니다.
안티패턴은 시그널에 서버가 아닌 곳의 비밀을 넣거나, 서버 함수 없이 공개 API 키를 브라우저에 노출하는 것입니다.
8-3. 배포와 관측 가능성
- 로그: 구조화 로그(요청 ID 상관관계)를 서버에 남깁니다.
- 에러: 사용자 메시지와 내부 스택을 분리합니다.
- 성능: WASM 번들 크기·초기 로드·서버 TTFB를 각각 측정합니다.
9. 모범 사례와 흔한 실수
9-1. 시그널 설계
- 하나의 진실 공급원: 동일 개념을 여러 시그널에 중복 저장하지 않습니다.
- 파생은 메모로: 계산 가능한 값은
create_memo로 유도합니다. - 이펙트 남용 금지: 데이터 로딩은 리소스/서버 함수 경로로 보냅니다.
9-2. 컴포넌트 경계
- Props 폭발을 피하기 위해 컨텍스트나 작은 도메인 모듈을 도입합니다.
- 거대 단일 컴포넌트는 테스트·리뷰 모두 어려워집니다.
9-3. 보안
- 서버 함수는 인증 세션·권한을 항상 검증합니다.
- CSRF·쿠키 정책은 프록시·도메인 설정과 함께 설계합니다.
10. 정리
Leptos는 Rust의 타입 시스템과 세밀한 반응성, 서버 함수 경계를 한 축에 모은 풀스택 웹 프레임워크입니다. Signals로 UI 상태를 안전하게 쪼개고, leptos_router로 화면을 나누며, Server Functions로 서버 신뢰 경계를 명확히 하면, SSR·하이드레이션까지 포함한 실전 앱을 일관된 추상화로 유지할 수 있습니다.
다음 단계로는 공식 북의 프로젝트 템플릿을 기반으로 인증·데이터베이스·에러 바운더리를 붙이며, 팀 규모에 맞는 모듈 경계를 실험해 보시기 바랍니다.
배포 전 git add·git commit·git push 후 npm run deploy를 실행하는 것을 권장합니다(프로젝트 배포 규칙).