Leptos PostgreSQL 통합

Leptos PostgreSQL 통합

1. 서론: Leptos와 PostgreSQL의 만남, 풀스택 Rust의 미래

1.1 Leptos 프레임워크의 핵심 가치 소개

Leptos는 단순한 사용자 인터페이스(UI) 라이브러리를 넘어, 서버와 클라이언트 양단에서 원활하게 동작하도록 설계된 완전한 풀스택(full-stack) 웹 프레임워크이다.1 이 프레임워크의 핵심 가치는 동형성(isomorphism)에 있다. 즉, 동일한 Rust 코드를 사용하여 서버사이드 렌더링(SSR), 클라이언트사이드 렌더링(CSR), 그리고 이 둘을 결합하여 초기 로딩 속도와 상호작용성을 모두 잡는 수화(Hydration) 방식을 지원한다.2 이러한 아키텍처는 Rust 언어가 제공하는 강력한 타입 시스템, 메모리 안정성, 그리고 탁월한 성능이라는 이점을 웹 개발의 전 과정에 걸쳐 일관되게 적용할 수 있게 하는 기반이 된다. 개발자는 서버 로직과 클라이언트 UI를 분리된 언어나 프레임워크로 개발할 때 발생하는 컨텍스트 전환 비용 없이, 단일 언어와 통합된 타입 시스템 내에서 전체 애플리케이션을 구축할 수 있다.6

1.2 데이터 지속성을 위한 PostgreSQL의 역할

현대 웹 애플리케이션에서 데이터의 영구적인 저장과 관리는 필수적이다. PostgreSQL은 관계형 데이터베이스 관리 시스템(RDBMS)의 사실상 표준으로, 수십 년간 검증된 안정성, 뛰어난 확장성, 그리고 JSONB, PostGIS와 같은 풍부한 기능 집합을 제공한다. Leptos 애플리케이션의 상태를 관리하고, 사용자 데이터를 저장하며, 복잡한 비즈니스 로직을 완성하는 데 있어 PostgreSQL과 같은 견고한 데이터베이스와의 통합은 선택이 아닌 필수 단계이다.

1.3 핵심 과제: 동형적 환경과 서버 전용 리소스의 통합

Leptos의 동형적 특성은 강력한 장점이지만, 데이터베이스와 같은 서버 전용 리소스(server-only resource)를 통합할 때 독특한 기술적 과제를 제시한다. Leptos 프로젝트는 cargo-leptos와 같은 빌드 도구를 통해 두 개의 별도 바이너리로 컴파일된다. 하나는 서버에서 실행될 네이티브 바이너리이며, 다른 하나는 사용자의 웹 브라우저에서 실행될 웹어셈블리(WebAssembly, WASM) 바이너리이다.4

문제의 핵심은 데이터베이스 드라이버와 같은 네이티브 의존성이 WASM 타겟(wasm32-unknown-unknown)으로 컴파일될 수 없다는 점에 있다.8 데이터베이스 드라이버는 운영체제의 네트워킹 스택과 직접 상호작용해야 하지만, 브라우저의 WASM 샌드박스 환경은 이러한 저수준 접근을 허용하지 않는다. 따라서, 아무런 조치 없이 데이터베이스 관련 코드를 프로젝트에 포함하면 클라이언트용 WASM을 빌드하는 과정에서 컴파일 오류가 발생하게 된다.10

이 보고서는 바로 이 문제를 해결하는 방법을 체계적으로 제시한다. Leptos의 아키텍처를 깊이 이해하고, 조건부 컴파일과 상태 관리 패턴을 활용하여 PostgreSQL 데이터베이스를 안전하고 효율적으로 통합하는 표준적인 방법을 상세히 다룰 것이다.

2. Leptos 서버 함수의 이해: 클라이언트와 서버의 교각

Leptos에서 서버 전용 리소스를 다루는 핵심적인 기능은 바로 서버 함수(Server Functions)이다. 이는 클라이언트 측 코드와 서버 측 로직을 자연스럽게 연결하는 교각 역할을 수행한다.

2.1 #[server] 매크로의 작동 원리

#[server] 매크로는 함수의 동작 방식을 컴파일 타겟에 따라 근본적으로 변화시키는 강력한 도구이다.

  • 서버 빌드 (ssr 기능 활성화 시): #[server]로 지정된 함수는 일반적인 Rust 비동기 함수로 컴파일된다. 함수의 본문 코드는 그대로 유지되며, 서버 환경에서 직접 실행되어 데이터베이스 조회, 파일 시스템 접근 등 서버에서만 가능한 작업을 수행한다.11
  • 클라이언트 빌드 (hydrate 또는 csr 기능 활성화 시): 동일한 함수는 완전히 다른 코드로 변환된다. 함수의 실제 본문은 제거되고, 대신 서버의 특정 API 엔드포인트로 네트워크 요청(예: JavaScript의 fetch API 호출)을 보내는 스텁(stub) 코드로 대체된다.11 이 스텁은 함수의 인자들을 직렬화하여 요청 본문에 담고, 서버로부터 받은 응답을 역직렬화하여 함수의 반환 값으로 돌려준다.

이러한 조건부 컴파일 메커니즘 덕분에 개발자는 클라이언트 컴포넌트 내에서 서버 함수를 마치 일반적인 로컬 비동기 함수처럼 호출할 수 있다. 데이터베이스 연결 코드가 클라이언트의 WASM 번들에 포함될 걱정 없이, 논리적으로 관련된 코드를 한곳에 배치하는 것이 가능해진다.

이러한 관점에서 #[server] 매크로는 단순한 편의 기능이 아니라, Leptos 애플리케이션의 아키텍처를 강제하는 장치로 이해해야 한다. 데이터베이스 드라이버와 같은 서버 전용 의존성을 서버 함수 외부에서 사용하려고 시도하면 WASM 컴파일 과정에서 오류가 발생한다. 이는 버그가 아니라, 프레임워크가 개발자에게 클라이언트와 서버의 경계를 명확히 하도록 유도하고, 서버 로직이 클라이언트 번들로 유출되는 것을 원천적으로 방지하는, 의도된 설계의 결과이다.9

2.2 서버 함수의 제약사항과 설계 철학

#[server] 매크로의 강력함에는 몇 가지 중요한 제약사항이 따른다. 이는 네트워크 통신과 분산 시스템의 본질적인 특성을 반영한 설계 철학의 결과이다.

  1. 비동기 실행 및 Result 반환: 모든 서버 함수는 반드시 async 키워드로 선언되어야 하며, Result<T, ServerFnError> 타입을 반환해야 한다.11 서버에서 수행하는 작업이 동기적이더라도, 클라이언트 입장에서는 네트워크 왕복이 필요한 비동기 작업이기 때문이다. 또한, 네트워크 오류, 직렬화 실패, 서버 측 로직 오류 등 실패 가능성이 항상 존재하므로 Result 타입을 통해 오류 처리를 강제한다.
  2. 직렬화 가능한 인자와 반환 값: 서버 함수에 전달되는 인자와 함수가 반환하는 값은 반드시 직렬화(Serializable) 및 역직렬화(Deserializable)가 가능해야 한다.11 이는 클라이언트와 서버가 HTTP와 같은 프로토콜을 통해 데이터를 교환해야 하기 때문이다. serde 크레이트의 SerializeDeserialize 트레이트를 구현하는 타입만 사용할 수 있다.
  3. 상태 접근의 제약: 서버 함수는 클라이언트 측의 반응형 상태(reactive state, 예: leptos::Signal)에 직접 접근할 수 없다.11 서버는 클라이언트의 UI 상태를 알지 못하며, 이는 분산 시스템의 당연한 원리이다. 필요한 모든 데이터는 반드시 함수의 인자를 통해 명시적으로 전달되어야 한다. 이는 상태 관리의 흐름을 명확하게 하고 예측 가능성을 높이는 효과를 가져온다.

3. Rust PostgreSQL 라이브러리 심층 비교 분석

Leptos 프로젝트에 PostgreSQL을 통합하기 전에, 어떤 데이터베이스 라이브러리를 사용할지 결정하는 것은 중요한 아키텍처 결정이다. Rust 생태계에는 sqlx, Diesel, tokio-postgres라는 세 가지 주요 선택지가 있으며, 각각 뚜렷한 장단점을 가진다.

Table 1: Rust PostgreSQL 라이브러리 비교

라이브러리API 스타일타입 안정성성능 프로파일안정성 (버전)핵심 강점이상적인 사용 사례
sqlx비동기, SQL 매크로컴파일 타임 쿼리 검증우수0.x (안정적)인체공학, 컴파일 타임 검증, 생태계 지원대부분의 Leptos 풀스택 애플리케이션
Diesel동기, 쿼리 빌더 DSL최상 (SQL 문법 오류 방지)최상 (ORM 모드)>2.0 (매우 안정적)강력한 타입 시스템, ORM 기능, 성숙도타입 안정성이 최우선인 복잡한 백엔드
tokio-postgres비동기, 저수준 드라이버낮음 (수동 관리)최상 (최소 오버헤드)0.x (안정적)최고의 성능, 완전한 제어권극단적인 성능 최적화가 필요한 경우

3.1 sqlx: 인체공학적 비동기 SQL

sqlx는 순수 SQL 문자열을 사용하면서도, query!query_as! 매크로를 통해 컴파일 타임에 데이터베이스 스키마와 비교하여 쿼리의 유효성을 검증하는 독창적인 접근 방식을 제공한다.13 이는 개발자가 SQL의 모든 기능을 활용하면서도 오타나 잘못된 타입으로 인한 런타임 오류를 사전에 방지할 수 있게 해준다. Leptos 공식 문서와 대부분의 커뮤니티 예제에서 sqlx를 채택하고 있어, 사실상 Leptos 생태계의 표준 데이터베이스 라이브러리로 자리매김했다.1

sqlx의 성능에 대해서는 tokio-postgres와 비교하여 특정 마이크로벤치마크에서 느리다는 보고가 존재한다.15 그러나 이 성능 차이는 대부분의 웹 애플리케이션 워크로드에서는 거의 체감하기 어려운 수준이며, 그 원인을 깊이 이해하는 것이 중요하다. 보고된 성능 문제는 sqlx 라이브러리 자체의 처리 속도가 느리다기보다는, 데이터베이스와의 상호작용 방식에서 비롯되는 경우가 많다. 예를 들어, 한 보고서에서는 sqlxCHAR(24) 타입의 열에 대한 파라미터를 TEXT 타입으로 추론하여 PostgreSQL에 전달했고, 이로 인해 PostgreSQL의 쿼리 플래너가 효율적인 IndexScan 대신 비효율적인 SeqScan을 선택하면서 100배 이상의 성능 저하가 발생했음이 밝혀졌다.16 이는 sqlx의 결함이라기보다는, 데이터베이스와의 상호작용을 최적화하기 위해 개발자가 데이터베이스의 동작 방식을 이해하고 필요시 명시적인 타입 캐스팅과 같은 조치를 취해야 함을 시사한다. 또한, sqlx의 커넥션 풀 구현은 tokio-postgres와는 다른 방식으로 Tokio 런타임과 상호작용하여 약간의 오버헤드를 가질 수 있다.16

결론적으로, sqlx는 개발 생산성과 안정성, 그리고 충분히 뛰어난 성능 사이에서 가장 이상적인 균형을 제공한다.

3.2 Diesel: 타입 안정성 및 성능의 보루

Diesel은 Rust의 강력한 타입 시스템을 최대한 활용하여 SQL 쿼리를 Rust 코드로 표현하는 쿼리 빌더 DSL(Domain-Specific Language)을 제공한다.13 이를 통해 SQL 문법 오류나 타입 불일치와 같은 문제들을 컴파일 타임에 원천적으로 차단할 수 있다. 2018년에 1.0 버전을 출시한 이래로 매우 안정적으로 유지되어 왔으며, 이는 장기적인 프로젝트에 높은 신뢰도를 제공한다.18

성능 면에서, Diesel은 순수 ORM 모드로 간단한 CRUD 작업을 수행할 때 sqlx보다 빠르다는 벤치마크 결과도 존재한다.19 하지만 Diesel의 접근 방식은 복잡한 조인이나 집계 함수를 포함하는 쿼리로 갈수록 객체-관계 임피던스 불일치(object-relational impedance mismatch) 문제를 야기할 수 있으며, 코드가 장황해지는 경향이 있다.13 Leptos와 통합할 때는 Diesel의 동기적인 API를 비동기 런타임에서 안전하게 사용하기 위해 spawn_blocking과 같은 추가적인 처리가 필요하여 sqlx에 비해 통합 복잡성이 높다.

3.3 tokio-postgres: 극한의 성능과 제어

tokio-postgres는 PostgreSQL 프로토콜에 대한 저수준 비동기 인터페이스를 제공하는 순수 데이터베이스 드라이버이다.14 sqlxDiesel과 같은 고수준 추상화 계층이 없기 때문에 오버헤드가 거의 없고, 개발자는 데이터베이스와의 모든 상호작용을 직접 제어할 수 있다. 이로 인해 최고의 성능을 달성할 수 있다.15

하지만 이러한 제어권은 개발 생산성과의 트레이드오프를 수반한다. 컴파일 타임 쿼리 검증, 자동적인 타입 매핑, 커넥션 풀링과 같은 편의 기능이 내장되어 있지 않아, 개발자가 더 많은 상용구 코드(boilerplate)를 작성해야 한다.15 따라서 tokio-postgres는 데이터베이스 통신 오버헤드를 마이크로초 단위까지 최적화해야 하는 극히 드문 고성능 애플리케이션에 적합한 선택이다.

3.4 종합 평가 및 기술 선택 가이드라인

  • 강력 권장: 대부분의 Leptos 프로젝트에는 **sqlx**를 사용하는 것이 좋다. 개발 편의성, 컴파일 타임을 통한 안정성 확보, 충분한 성능, 그리고 풍부한 커뮤니티 자료라는 네 가지 요소를 가장 이상적으로 만족시킨다.
  • 대안적 선택: 만약 프로젝트의 최우선 순위가 SQL 문법 오류를 포함한 모든 데이터베이스 관련 오류를 컴파일 타임에 잡는 것이라면 **Diesel**을 고려할 수 있다. 단, 비동기 처리와 WASM 호환성을 위한 추가적인 통합 노력을 감수해야 한다.
  • 특수 목적: 애플리케이션의 성능 병목 지점이 명확하게 데이터베이스 드라이버의 오버헤드로 분석된 경우에 한해 **tokio-postgres**를 선택하는 것이 합리적이다.

이 보고서의 나머지 부분에서는 가장 일반적이고 권장되는 sqlx를 사용한 통합 방법을 중심으로 설명한다.

4. sqlx를 이용한 Leptos-PostgreSQL 통합 표준 구현

이 섹션에서는 sqlx를 사용하여 Leptos 애플리케이션에 PostgreSQL 데이터베이스를 통합하는 전체 과정을 단계별로 상세히 안내한다. realworld-leptos와 같은 성공적인 오픈소스 프로젝트에서 검증된 모범 사례를 기반으로 한다.20

4.1 프로젝트 설정 및 의존성 격리

가장 먼저 해결해야 할 과제는 sqlx가 클라이언트 측 WASM 바이너리에 포함되지 않도록 하는 것이다. 이는 Cargo.toml 파일의 features 설정을 통해 해결할 수 있다.

Cargo.toml 파일에 다음과 같이 의존성을 추가한다.

Ini, TOML

[dependencies]
leptos = { version = "0.6", features = ["hydrate"] }
leptos_meta = { version = "0.6", features = ["hydrate"] }
leptos_router = { version = "0.6", features = ["hydrate"] }
#... 다른 의존성들

# 서버 전용 의존성
axum = { version = "0.7", optional = true }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-rustls"], optional = true }
dotenvy = { version = "0.15", optional = true }

[features]
hydrate = [
"leptos/hydrate",
"leptos_meta/hydrate",
"leptos_router/hydrate",
]
ssr = [
"dep:axum",
"dep:tokio",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:sqlx",
"dep:dotenvy"
]

여기서 핵심적인 부분은 다음과 같다.

  1. optional = true: sqlx, axum, tokio, dotenvy와 같은 서버 전용 크레이트들을 optional 의존성으로 선언한다. 이는 기본적으로 이 의존성들이 프로젝트에 포함되지 않음을 의미한다.7
  2. [features] 섹션: ssr이라는 이름의 피처를 정의한다. 이 피처가 활성화될 때만 dep:sqlx와 같이 optional로 선언된 의존성들이 실제 빌드에 포함되도록 지정한다.9

cargo-leptos 빌드 도구는 서버 바이너리를 컴파일할 때는 --features ssr 플래그를, 클라이언트 WASM을 컴파일할 때는 --features hydrate 플래그를 자동으로 사용한다. 이 설정을 통해 sqlx는 서버 빌드에만 포함되고 WASM 빌드에서는 제외되어 컴파일 오류를 원천적으로 방지한다.

4.2 데이터베이스 커넥션 풀 관리 전략

효율적인 데이터베이스 관리를 위해 애플리케이션의 수명 주기 동안 단 하나의 데이터베이스 커넥션 풀(sqlx::PgPool) 인스턴스를 유지하고, 이를 필요한 모든 서버 함수에서 공유하는 것이 중요하다.22 Leptos와 Axum 환경에서는 이를 구현하는 두 가지 주요 전략이 있다.

4.2.1 Leptos Context 활용 (Leptos-Idiomatic Approach)

이 방법은 Leptos의 내장 의존성 주입 메커니즘인 context를 사용하는 가장 순수한 방식이다.

먼저, main.rsmain 함수에서 애플리케이션이 시작될 때 커넥션 풀을 생성하고 컨텍스트에 주입한다.

Rust

// main.rs
use leptos::*;
use leptos_axum::{LeptosRoutes, leptos_routes_with_context};
use axum::{Router, routing::post};
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::env;

// 앱 상태를 위한 구조체 (여기서는 풀만 포함)
#
pub struct AppState {
pub pool: PgPool,
}

#[tokio::main]
async fn main() {
//.env 파일 로드
dotenvy::dotenv().expect("Failed to load.env file");

let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");

// 데이터베이스 커넥션 풀 생성
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to create pool.");

// sqlx 마이그레이션 실행 (프로덕션에서는 별도 스크립트로 관리하는 것이 좋음)
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Migrations failed");

let conf = get_configuration(None).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);

let app = Router::new()
.route("/api/*fn_name", post(leptos_axum::handle_server_fns))
.leptos_routes_with_context(&leptos_options, routes, move |

| {
// 모든 요청에 대해 커넥션 풀을 컨텍스트에 제공
provide_context(pool.clone());
}, App)
.with_state(leptos_options);

axum::serve(tokio::net::TcpListener::bind(&addr).await.unwrap(), app)
.await
.unwrap();
}

위 코드에서 leptos_routes_with_context 함수의 세 번째 인자로 전달된 클로저 `move |

| { provide_context(pool.clone()); }가 핵심이다.[24, 25, 26] 이 클로저는 각 요청이 처리될 때마다 실행되어, 미리 생성된 커넥션 풀의 복사본(Arc`의 클론이므로 비용이 저렴하다)을 Leptos의 리액티브 컨텍스트에 주입한다.

이제 서버 함수 내에서는 use_context 또는 expect_context를 사용하여 이 풀을 안전하게 가져올 수 있다.24

Rust

// todo.rs (서버 함수 내부)
use leptos::*;
use sqlx::PgPool;

#[server]
pub async fn get_todos() -> Result<Vec<Todo>, ServerFnError> {
// 컨텍스트에서 PgPool을 가져온다.
let pool = use_context::<PgPool>()
.ok_or_else(|| ServerFnError::ServerError("Database pool not found.".to_string()))?;

//... 쿼리 실행
Ok(todos)
}

4.2.2 Axum State 및 Context 혼합 활용 (Hybrid Approach)

이 전략은 Axum의 State 추출자와 Leptos의 context를 모두 활용하여 유연성을 극대화한다. 기존 Axum 생태계의 미들웨어나 추출자를 재사용해야 할 때 유용하다.

AppState 구조체를 정의하고 #를 추가한다.

Rust

// main.rs
use axum::extract::FromRef;
//...

#
pub struct AppState {
pub leptos_options: LeptosOptions,
pub pool: PgPool,
}

main 함수에서 라우터를 설정할 때, with_stateleptos_routes_with_context를 함께 사용한다.

Rust

// main.rs (main 함수 내부)
//... pool 생성...
let conf = get_configuration(None).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);

let app_state = AppState {
leptos_options,
pool: pool.clone(), // 풀을 AppState에 저장
};

let app = Router::new()
//... 서버 함수 라우트...
.leptos_routes_with_context(&app_state.leptos_options, routes, move |

| {
// Leptos 컨텍스트에도 풀을 주입
provide_context(pool.clone());
}, App)
.fallback(file_and_error_handler)
.with_state(app_state); // Axum의 State로 AppState 전체를 주입

//... 서버 실행...

이 패턴은 다소 중복적으로 보일 수 있지만, 강력한 유연성을 제공한다. with_state를 통해 기존 Axum 핸들러나 미들웨어는 State<PgPool> 또는 State<AppState>를 통해 데이터베이스 풀에 접근할 수 있다. 동시에 provide_context를 통해 Leptos 서버 함수는 use_context를 사용하여 관용적인 방식으로 풀에 접근할 수 있다. 이는 순수 Axum 백엔드에서 풀스택 Leptos 애플리케이션으로 점진적으로 마이그레이션하거나, 두 생태계의 장점을 모두 활용하고자 할 때 매우 효과적인 아키텍처 선택이다.24

4.3 서버 함수를 통한 CRUD 작업 구현

이제 데이터베이스 풀을 공유하는 방법을 알았으니, 실제 CRUD(Create, Read, Update, Delete) 작업을 수행하는 서버 함수를 구현할 차례이다. todo.rs라는 파일을 만들어 Todo 항목을 관리하는 로직을 작성한다.

Rust

// todo.rs
use leptos::*;
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, FromRow};

#[cfg(feature = "ssr")]
pub mod ssr {
use leptos::ServerFnError;
use sqlx::PgPool;

pub fn pool() -> Result<PgPool, ServerFnError> {
use_context::<PgPool>()
.ok_or_else(|| ServerFnError::ServerError("Pool missing.".into()))
}
}

#
pub struct Todo {
pub id: i32,
pub title: String,
pub completed: bool,
}

// CREATE
#
pub async fn add_todo(title: String) -> Result<(), ServerFnError> {
let pool = ssr::pool()?;
sqlx::query("INSERT INTO todos (title, completed) VALUES ($1, false)")
.bind(title)
.execute(&pool)
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
Ok(())
}

// READ
#
pub async fn get_todos() -> Result<Vec<Todo>, ServerFnError> {
let pool = ssr::pool()?;
let todos = sqlx::query_as::<_, Todo>("SELECT * FROM todos ORDER BY id")
.fetch_all(&pool)
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
Ok(todos)
}

// UPDATE
#
pub async fn update_todo(id: i32, completed: bool) -> Result<(), ServerFnError> {
let pool = ssr::pool()?;
sqlx::query("UPDATE todos SET completed = $1 WHERE id = $2")
.bind(completed)
.bind(id)
.execute(&pool)
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
Ok(())
}


// DELETE
#
pub async fn delete_todo(id: i32) -> Result<(), ServerFnError> {
let pool = ssr::pool()?;
sqlx::query("DELETE FROM todos WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
Ok(())
}

각 함수는 #[server] 매크로를 통해 서버 함수로 정의되며, ssr::pool()?을 호출하여 컨텍스트로부터 데이터베이스 풀을 획득한 후 sqlx를 사용하여 비동기 쿼리를 실행한다.

4.4 견고한 오류 처리 아키텍처

위 예제의 map_err를 사용한 오류 변환은 간단하지만, 애플리케이션이 복잡해질수록 한계에 부딪힌다. 더 나은 방법은 애플리케이션 전반에 걸쳐 사용될 통합된 오류 타입을 정의하는 것이다.

thiserror 크레이트를 사용하여 AppError 열거형을 정의한다.

Rust

// error.rs (새 파일)
use leptos::{ServerFnError, ServerFnErrorErr};
use serde::{Deserialize, Serialize};
use thiserror::Error;

#
pub enum AppError {
#
Sqlx(String),
#[error("Not Found")]
NotFound,
#
ServerError(String),
}

// sqlx::Error를 AppError로 변환
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
AppError::Sqlx(e.to_string())
}
}

// ServerFnErrorErr를 AppError로 변환
impl From<ServerFnErrorErr> for AppError {
fn from(e: ServerFnErrorErr) -> Self {
AppError::ServerError(e.to_string())
}
}

이제 FromServerFnError 트레이트를 AppError에 대해 구현하여, Leptos 서버 함수가 이 커스텀 오류 타입을 직접 반환할 수 있도록 한다.11

Rust

// error.rs (계속)
use server_fn::codec::{Json, SerdeAny};
use server_fn::error::FromServerFnError;

impl FromServerFnError<Json> for AppError {
fn from_server_fn_error(err: ServerFnError) -> Self {
match err {
ServerFnError::ServerError(s) => AppError::ServerError(s),
_ => AppError::ServerError("An unknown server error occurred.".to_string()),
}
}
}

이제 서버 함수들을 리팩토링하여 Result<T, AppError>를 반환하도록 수정할 수 있다. ? 연산자를 통해 sqlx::Error가 자동으로 AppError::Sqlx로 변환되어 코드가 훨씬 간결하고 명확해진다.

Rust

// todo.rs (수정된 버전)
use crate::error::AppError; // 커스텀 에러 임포트

#
pub async fn add_todo(title: String) -> Result<(), ServerFnError<AppError>> {
let pool = ssr::pool().map_err(ServerFnError::from)?;
sqlx::query("...")
.bind(title)
.execute(&pool)
.await?; // '?'가 sqlx::Error를 AppError로 자동 변환
Ok(())
}

(참고: Leptos 최신 버전에서는 Result<(), ServerFnError<AppError>> 대신 Result<(), AppError>를 직접 반환할 수 있도록 API가 개선되었다.)

5. Diesel을 이용한 대안적 통합 패턴

sqlx가 아닌 Diesel을 선택한 개발팀을 위해, Leptos 환경에서 Diesel을 통합하는 방법을 다룬다. Diesel의 고유한 특성으로 인해 몇 가지 추가적인 고려사항이 필요하다.

5.1 Diesel 통합의 핵심 과제

  1. WASM 컴파일 불가: Diesel은 내부적으로 네이티브 C 라이브러리(libpq 또는 libmysqlclient)에 의존하므로 WASM 타겟으로 컴파일할 수 없다.8
  2. 동기 API: Diesel의 핵심 데이터베이스 연결 API는 동기(blocking) 방식이다. 이를 tokio와 같은 비동기 런타임에서 직접 호출하면 이벤트 루프 스레드를 점유하여 전체 애플리케이션의 응답성을 심각하게 저해할 수 있다.

5.2 컴파일 오류 해결: 조건부 컴파일

sqlx와 마찬가지로, Diesel과 관련된 모든 코드가 서버 빌드에만 포함되도록 격리해야 한다.

  1. Cargo.toml 설정: diesel 의존성을 optional = true로 선언하고 ssr 피처에 포함시킨다.

Ini, TOML

[dependencies]
diesel = { version = "2.1", features = ["postgres"], optional = true }

[features]
ssr = ["dep:diesel",...]
  1. #[cfg(feature = "ssr")] 사용: Diesel을 사용하는 모든 모듈, use 구문, 함수, 구조체 정의 앞에 #[cfg(feature = "ssr")] 어트리뷰트를 붙여야 한다.8

Rust

#[cfg(feature = "ssr")]
pub mod schema {
// diesel::table! 매크로로 생성된 스키마
}

#[cfg(feature = "ssr")]
use crate::schema::posts;

#[cfg(feature = "ssr")]
#[derive(Queryable)]
pub struct Post { /*... */ }

여기서 중요한 점은 클라이언트와 서버 간에 공유되어야 하는 데이터 모델(예: Post 구조체)의 처리이다. 서버에서는 #[derive(Queryable)]과 같은 Diesel 전용 트레이트가 필요하지만, 클라이언트에서는 이 derive 매크로가 컴파일 오류를 유발한다. 이 의존성 역설은 #[cfg_attr] 어트리뷰트를 통해 우아하게 해결할 수 있다.

Rust

use serde::{Serialize, Deserialize};

#
#
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::posts))]
pub struct Post {
pub id: i32,
pub title: String,
pub body: String,
}

#[cfg_attr(feature = "ssr",...)]는 “만약 ssr 피처가 활성화되어 있다면, 뒤따르는 어트리뷰트를 적용하라“는 의미이다.8 따라서 서버 빌드에서는 derive(Queryable,...)가 적용되어 Diesel과 호환되는 구조체가 생성되고, 클라이언트(WASM) 빌드에서는 이 어트리뷰트가 무시되어 serde 트레이트만 가진 순수한 데이터 구조체로 컴파일된다. 이는 조건부 컴파일 환경에서 타입을 안전하게 공유하는 핵심적인 기법이다.

비동기 환경에서의 동기 코드 실행

Diesel의 동기적인 데이터베이스 작업을 비동기 런타임에서 안전하게 실행하려면, tokio::task::spawn_blocking을 사용해야 한다. 이 함수는 전달된 클로저를 별도의 블로킹 스레드 풀에서 실행하여, 메인 비동기 이벤트 루프가 멈추는 것을 방지한다.

다음은 Dieselspawn_blocking을 사용한 서버 함수 예시이다.

Rust

#[cfg(feature = "ssr")]
pub mod ssr {
use diesel::pg::PgConnection;
use diesel::r2d2::{ConnectionManager, Pool};
use leptos::*;

pub type DbPool = Pool<ConnectionManager<PgConnection>>;

pub fn pool() -> Result<DbPool, ServerFnError> {
use_context::<DbPool>()
.ok_or_else(|| ServerFnError::ServerError("Pool missing.".into()))
}
}

#[server]
pub async fn add_post_diesel(title: String, body: String) -> Result<(), ServerFnError> {
use crate::schema::posts;
use diesel::prelude::*;

let pool = ssr::pool()?;

// spawn_blocking을 사용하여 동기 작업을 별도 스레드에서 실행
tokio::task::spawn_blocking(move |

| {
let mut conn = pool.get()?; // 풀에서 커넥션을 동기적으로 가져옴
diesel::insert_into(posts::table)
.values((posts::title.eq(title), posts::body.eq(body)))
.execute(&mut conn)
})
.await // 블로킹 작업이 완료될 때까지 기다림
.map_err(|e| ServerFnError::ServerError(format!("Spawn blocking error: {}", e)))? // JoinError 처리
.map_err(|e| ServerFnError::ServerError(format!("Diesel error: {}", e)))?; // Diesel/R2D2 Error 처리

Ok(())
}

6. 성능 최적화 및 배포 고려사항

6.1 라이브러리별 성능 함정 및 최적화

  • sqlx 타입 추론 문제: 앞서 언급했듯이, sqlx가 데이터베이스와 상호작용하는 방식이 성능에 영향을 미칠 수 있다. 데이터베이스 스키마와 쿼리를 주의 깊게 검토하고, EXPLAIN ANALYZE를 사용하여 쿼리 플랜을 확인하는 습관이 중요하다. sqlx의 타입 추론이 비효율적인 쿼리 플랜을 유발하는 경우, ... WHERE column = $1::CHAR(24)와 같이 SQL 쿼리 내에서 명시적으로 타입을 캐스팅하여 PostgreSQL의 쿼리 플래너에 힌트를 제공할 수 있다.16
  • sqlx 커넥션 풀 오버헤드: 매우 짧고 빈번한 쿼리가 극도로 많은 워크로드에서는 sqlx의 커넥션 풀링 오버헤드가 tokio-postgres에 비해 상대적으로 클 수 있다. 이런 특수한 경우에는 프로파일링을 통해 병목 지점을 명확히 식별한 후 라이브러리 교체를 고려할 수 있다.16

6.2 커넥션 풀 튜닝

sqlx::postgres::PgPoolOptions를 사용하면 커넥션 풀의 동작을 세밀하게 제어할 수 있다. 가장 중요한 설정은 max_connections이다.

Rust

let pool = PgPoolOptions::new()
.max_connections(10) // 애플리케이션 부하와 데이터베이스 사양에 맞게 설정
.connect(&database_url)
.await?;

최대 연결 수는 애플리케이션의 동시 사용자 수, 데이터베이스 서버의 CPU 코어 수 및 가용 메모리를 고려하여 신중하게 설정해야 한다. 너무 낮게 설정하면 요청이 연결을 기다리며 지연되고, 너무 높게 설정하면 데이터베이스 서버에 과도한 부하를 줄 수 있다.23

6.3 데이터베이스 마이그레이션 관리

데이터베이스 스키마의 변경 사항을 체계적으로 관리하기 위해 sqlx-cli를 사용하는 것이 좋다.

  1. sqlx-cli 설치:

cargo install sqlx-cli –no-default-features –features rustls,postgres

  1. 마이그레이션 생성:

sqlx migrate add create_todos_table

  1. 마이그레이션 실행:

sqlx migrate run

이러한 마이그레이션 워크플로우를 CI/CD 파이프라인에 통합하면 배포 과정에서 스키마를 안정적으로 관리할 수 있다.20

6.4 컨테이너화 및 배포

Docker를 사용하여 개발 및 프로덕션 환경을 일관되게 유지하는 것이 좋다. realworld-leptos 프로젝트는 좋은 참고 자료를 제공한다.20

  • Dockerfile: Rust 툴체인, sqlx-cli, 그리고 애플리케이션 빌드에 필요한 기타 도구들을 포함하는 멀티-스테이지 Dockerfile을 작성한다. 최종 이미지에는 컴파일된 서버 바이너리와 target/site 디렉터리의 정적 에셋만 포함하여 이미지 크기를 최소화한다.
  • docker-compose.yml: PostgreSQL 데이터베이스 서비스와 Leptos 애플리케이션 서비스를 함께 정의한다. 이를 통해 단일 명령어로 전체 스택을 실행할 수 있어 개발 환경 설정이 간편해진다.

7. 결론: 최적의 스택을 위한 최종 제언

Leptos와 PostgreSQL을 통합하여 풀스택 Rust 애플리케이션을 구축하는 과정은 Leptos의 동형적 아키텍처에 대한 깊은 이해를 요구한다. 서버 전용 리소스를 클라이언트 빌드로부터 격리하는 것이 핵심이며, 이는 #[server] 매크로와 Cargo.toml의 피처 플래그를 통해 달성된다.

  • 일반적인 권장 사항: 대부분의 Leptos 프로젝트에는 **sqlx**가 최적의 선택이다. 인체공학적 API, 컴파일 타임 쿼리 검증, 풍부한 생태계, 그리고 커뮤니티의 폭넓은 지원은 개발 생산성과 애플리케이션의 안정성을 크게 향상시킨다. 보고된 성능 문제는 대부분의 웹 애플리케이션에서 문제가 되지 않으며, 데이터베이스와의 상호작용을 이해함으로써 해결 가능하다.
  • 특수 사례:
  • **Diesel**은 SQL 문법 오류까지 컴파일 타임에 방지하는 최고 수준의 타입 안정성이 프로젝트의 비기능적 요구사항 중 최우선 순위일 때 고려할 수 있다. 단, 비동기 런타임 통합과 조건부 컴파일을 위한 추가적인 복잡성을 감수해야 한다.
  • **tokio-postgres**는 프로파일링을 통해 데이터베이스 드라이버 오버헤드가 명백한 성능 병목으로 확인된 극히 드문 경우에만 사용하는 것이 합리적이다.

궁극적으로, 성공적인 Leptos-PostgreSQL 통합의 핵심은 단순히 특정 라이브러리를 선택하는 행위를 넘어선다. Leptos의 동형적 아키텍처의 원리를 이해하고, 서버와 클라이언트의 경계를 명확히 구분하며, 상태와 의존성을 체계적으로 관리하는 설계 원칙을 일관되게 적용하는 것이야말로 견고하고 확장 가능한 풀스택 Rust 애플리케이션을 구축하는 진정한 열쇠이다.

8. Works cited

  1. Leptos: Home, accessed October 27, 2025, https://leptos.dev/
  2. leptos-rs/leptos: Build fast web applications with Rust. - GitHub, accessed October 27, 2025, https://github.com/leptos-rs/leptos
  3. Leptos - GitHub, accessed October 27, 2025, https://github.com/leptos-rs
  4. brklntmhwk/leptos-practice: A playground for learning front/backend Rust web frameworks: Leptos and Axum respectively, plus, SeaORM(Postgres) for database relations. - GitHub, accessed October 27, 2025, https://github.com/brklntmhwk/leptos-practice
  5. Leptos-Chatting-Client - CodeSandbox, accessed October 27, 2025, http://codesandbox.io/p/github/bluec0re/Leptos-Chatting-Client
  6. Working with the Server - Leptos Book, accessed October 27, 2025, https://book.leptos.dev/server/index.html
  7. Building WASM web UI with Rust and Leptos, accessed October 27, 2025, https://www.rustadventure.dev/building-wasm-web-ui-with-rust-and-leptos
  8. Diesel on Leptos? : r/rust - Reddit, accessed October 27, 2025, https://www.reddit.com/r/rust/comments/1h7g5um/diesel_on_leptos/
  9. Rust dependency woes - Reddit, accessed October 27, 2025, https://www.reddit.com/r/rust/comments/14etel5/rust_dependency_woes/
  10. Leptos/WASM project error adding crate : r/rust - Reddit, accessed October 27, 2025, https://www.reddit.com/r/rust/comments/18w8ztl/leptoswasm_project_error_adding_crate/
  11. Server Functions - Leptos Book, accessed October 27, 2025, https://book.leptos.dev/server/25_server_functions.html
  12. server in leptos - Rust - Docs.rs, accessed October 27, 2025, https://docs.rs/leptos/latest/leptos/attr.server.html
  13. Community Review on Rust Database ORM crates : r/rust - Reddit, accessed October 27, 2025, https://www.reddit.com/r/rust/comments/18vzkfi/community_review_on_rust_database_orm_crates/
  14. SQLX vs Other SQL Libraries Which One is Right for You, accessed October 27, 2025, https://sqlx.dev/article/SQLX_vs_Other_SQL_Libraries_Which_One_is_Right_for_You.html
  15. Thoughts about switching from sqlx to tokio_postgres? : r/rust - Reddit, accessed October 27, 2025, https://www.reddit.com/r/rust/comments/10tsjam/thoughts_about_switching_from_sqlx_to_tokio/
  16. SQLx is significantly slower than postgres / tokio-postgres · Issue …, accessed October 27, 2025, https://github.com/launchbadge/sqlx/issues/2436
  17. sqlx fetch_all for huge amount of data much slower then tokio-postgres #2007 - GitHub, accessed October 27, 2025, https://github.com/launchbadge/sqlx/issues/2007
  18. Compare Diesel, accessed October 27, 2025, https://diesel.rs/compare_diesel.html
  19. How to reduce the gap between Diesel’s sql_query (RAW) and the ORM mode ? #4049, accessed October 27, 2025, https://github.com/diesel-rs/diesel/discussions/4049
  20. Realworld leptos application with SSR - GitHub, accessed October 27, 2025, https://github.com/Bechma/realworld-leptos
  21. Rust - CodebaseShow – RealWorld Example Apps, accessed October 27, 2025, https://codebase.show/projects/realworld?category=fullstack&language=rust
  22. Pool in sqlx - Rust - Docs.rs, accessed October 27, 2025, https://docs.rs/sqlx/latest/sqlx/struct.Pool.html
  23. Rust to PostgreSQL with SQLX | Rust By Example - GitHub Gist, accessed October 27, 2025, https://gist.github.com/jeremychone/34d1e3daffc38eb602b1a9ab21298d10
  24. Extractors - Leptos Book, accessed October 27, 2025, https://book.leptos.dev/server/26_extractors.html
  25. use_context in leptos::context - Rust - Docs.rs, accessed October 27, 2025, https://docs.rs/leptos/latest/leptos/context/fn.use_context.html
  26. Global State Management - Leptos Book, accessed October 27, 2025, https://book.leptos.dev/15_global_state.html
  27. leptos::server_fn::error - Rust - Docs.rs, accessed October 27, 2025, https://docs.rs/leptos/latest/leptos/server_fn/error/index.html
  28. Module 6.1: Database Integration with SQLx + Postgres | by Simple …, accessed October 27, 2025, https://medium.com/@ed.wacc1995/module-6-1-database-integration-with-sqlx-postgres-rust-rest-api-96a81dbe28dc

Build CRUD REST API with Rust and MySQL using Axum & SQLx - Medium, accessed October 27, 2025, https://medium.com/@raditzlawliet/build-crud-rest-api-with-rust-and-mysql-using-axum-sqlx-d7e50b3cd130