SolidStart로 풀스택 앱 만들기: 파일 라우팅, Server Functions, 데이터 페칭, 배포, Next.js 비교

이 글의 핵심

SolidStart의 프로젝트 구조와 파일 라우팅, RPC처럼 호출하는 Server Functions, 서버 데이터 페칭 패턴, 플랫폼별 배포 어댑터, Next.js와 무엇이 다른지를 정리합니다.

이 글의 핵심

SolidStart는 SolidJS용 메타 프레임워크로, 파일 기반 라우팅, SSR과 하이드레이션, 서버 함수, 플랫폼별 배포를 한 프로젝트에서 다룹니다. Next.js App Router가 서버 컴포넌트와 캐시 규칙을 중심으로 서버 데이터를 다루는 반면, SolidStart는 Solid의 세밀한 반응성(signals)과 “컴포넌트는 한 번만 실행된다”는 모델을 그대로 유지하면서 서버 함수로 서버 경계를 표현합니다.

이 글의 예제는 Vinxi 기반의 SolidStart 1.x(app.config.ts, vinxi/http) 기준입니다. SolidStart 2는 Vinxi를 걷어내고 일반 Vite 플러그인 구조로 옮겨 가는 중이라 설정 파일과 일부 import 경로가 다르므로, 2.x를 쓴다면 공식 문서의 마이그레이션 안내를 함께 확인해야 합니다.

SolidStart를 고르는 이유와 한계

SolidStart는 UI는 Solid가, 서버 실행과 배포는 Nitro가 맡는 구조라서 한 저장소에서 페이지·API·서버 함수를 함께 관리하면서도 Solid의 작은 런타임과 가상 DOM 없는 갱신을 가져갈 수 있습니다. 반면 Next.js에 비해 학습 자료, 채용 시장, 서드파티 통합의 규모가 작습니다. Solid에 이미 익숙한 팀에는 잘 맞지만, 조직 표준 도구가 React 중심이라면 Next.js나 React Router(구 Remix)와 비교 검토하는 편이 현실적입니다.

Solid 반응성 기초: React에서 넘어올 때 헷갈리는 지점

겉모습은 JSX라 React처럼 보이지만 Solid의 컴포넌트 함수는 딱 한 번 실행되고, 이후 변경은 signal을 읽은 지점만 정밀하게 갱신됩니다. 가상 DOM 비교가 없어서 빠르지만, React 습관대로 짜면 값이 갱신되지 않는 버그가 생깁니다.

import { createSignal, createMemo, Show, For } from "solid-js"
import { createStore } from "solid-js/store"

function Counter(props: { step: number }) {
  const [count, setCount] = createSignal(0)
  const doubled = createMemo(() => count() * 2)        // 파생 값은 memo 또는 함수
  const [state, setState] = createStore({ todos: [] as { id: number; done: boolean }[] })

  const toggle = (id: number) =>
    setState("todos", (t) => t.id === id, "done", (d) => !d) // 경로 기반 부분 갱신

  return (
    <>
      <button onClick={() => setCount(count() + props.step)}>{count()} / {doubled()}</button>
      <Show when={count() > 5} fallback={<p>아직</p>}><p>5 초과</p></Show>
      <ul>
        <For each={state.todos}>{(todo) => <li onClick={() => toggle(todo.id)}>{todo.id}</li>}</For>
      </ul>
    </>
  )
}

React에서 넘어올 때 가장 흔한 실수는 props 구조 분해입니다. function Counter({ step })처럼 풀어 쓰면 그 순간의 값만 복사되고 반응성이 끊겨, 부모가 step을 바꿔도 자식은 초기값을 계속 씁니다. props.step으로 접근하거나 splitProps/mergeProps를 써야 합니다. 같은 이유로 컴포넌트 본문 최상단에서 const c = count()로 값을 한 번 읽어 두면 그 변수는 갱신되지 않습니다. 리스트에는 .map() 대신 <For>를 쓰는데, <For>는 항목 참조 단위로 DOM 노드를 재사용해 목록 일부만 바뀔 때 전체를 다시 만들지 않기 때문입니다. 중첩 객체 상태는 signal에 통째로 넣고 교체하기보다 createStore로 두면 위처럼 바뀐 필드만 갱신됩니다.


SolidStart의 구조

메타 프레임워크로서의 위치

SolidJS는 UI 라이브러리이고, SolidStart는 그 위에 라우팅·빌드·서버 실행을 얹은 메타 프레임워크입니다. 1.x에서는 Vinxi가 클라이언트 번들, SSR 서버, 서버 함수 엔드포인트를 각각의 Vite 빌드(“라우터”)로 나눠 구성하고, 서버 쪽 결과물은 Nitro가 Node, Vercel, Netlify, Cloudflare Workers 등 대상 플랫폼에 맞는 형태로 출력합니다.

UI 라우트와 API 라우트

routes 디렉터리의 파일은 두 종류로 나뉩니다. default export가 컴포넌트인 파일은 HTML을 렌더링하는 UI 라우트이고, GET, POST 같은 HTTP 메서드 이름의 함수를 export하는 파일은 API 라우트입니다. API 라우트는 JSON이나 Response를 직접 반환하므로 공개 REST 엔드포인트, 웹훅, OAuth 콜백처럼 URL 자체가 외부와의 계약인 경우에 적합합니다.

같은 경로에 UI와 API 라우트가 함께 있으면 API 라우트가 먼저 요청을 받고, GET 핸들러가 아무것도 반환하지 않으면 UI 라우트로 넘어갑니다. 이를 이용하면 Accept 헤더를 보고 JSON 요청에만 API로 응답하는 식의 콘텐츠 협상을 할 수 있습니다.

직렬화 모드와 CSP

서버 함수의 인자와 반환값, SSR 중 서버에서 가져온 데이터는 Seroval로 직렬화되어 클라이언트로 전달됩니다. 문서에 따르면 app.config.ts의 serialization.mode로 형식을 고를 수 있습니다. js 모드는 JavaScript 코드 형태로 직렬화해 더 많은 타입을 작고 빠르게 표현하지만, 클라이언트에서 이를 평가해야 하므로 엄격한 CSP(Content Security Policy) 환경에서는 unsafe-eval을 허용해야 할 수 있습니다. json 모드는 JSON.parse로 읽을 수 있는 형식이라 CSP와 충돌하지 않지만 페이로드가 조금 커질 수 있습니다. 기본값은 버전에 따라 다르므로 사용하는 버전의 설정 문서를 확인하는 것이 좋습니다.


프로젝트 구조와 진입점

스캐폴딩

npm create solid@latest my-app
cd my-app
npm install
npm run dev

템플릿에 따라 src/app.tsx, src/routes, app.config.ts 등이 생성되며, routes 디렉터리가 URL 구조의 단일 출처입니다.

FileRoutes와 루트 레이아웃

UI 라우트는 @solidjs/start/router의 FileRoutes가 수집하고, @solidjs/router의 Router가 렌더링합니다. 라우트 컴포넌트는 지연 로드되므로 루트를 Suspense로 감싸야 로딩 중 상태와 하이드레이션이 올바르게 처리됩니다.

import { Suspense } from "solid-js";
import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";

export default function App() {
  return (
    <Router root={(props) => <Suspense>{props.children}</Suspense>}>
      <FileRoutes />
    </Router>
  );
}

root는 앱 전체의 최상위 레이아웃입니다. 공통 헤더, 전역 프로바이더, 에러 경계(ErrorBoundary)를 여기에 두면 모든 페이지에 적용됩니다.


파일 기반 라우팅

기본 규칙

routes/index.tsx는 /, routes/about.tsx는 /about, routes/blog/article-1.tsx는 /blog/article-1에 매핑됩니다. 폴더 안의 index.tsx는 그 세그먼트까지만 있는 경로에 매칭됩니다.

중첩 레이아웃

routes/blog.tsx와 routes/blog/ 폴더처럼 폴더와 같은 이름의 파일이 있으면, 그 파일이 하위 라우트 전체의 레이아웃이 됩니다. 자식 라우트는 props.children 자리에 렌더링됩니다.

import type { RouteSectionProps } from "@solidjs/router";

export default function BlogLayout(props: RouteSectionProps) {
  return (
    <article>
      <header>Blog</header>
      {props.children}
    </article>
  );
}

동적 세그먼트, 선택적 파라미터, catch-all

routes/users/[id].tsx는 /users/:id에 매칭되는 동적 세그먼트입니다. routes/users/[[id]].tsx처럼 이중 대괄호를 쓰면 /users와 /users/123 모두에 매칭되는 선택적 파라미터가 됩니다. routes/blog/[...post].tsx는 여러 세그먼트를 하나의 파라미터로 받는 catch-all로, /blog/a/b이면 post가 "a/b"가 됩니다. 파라미터는 useParams()로 읽습니다.

URL 파라미터는 사용자가 마음대로 바꿀 수 있는 값이므로, 서버 함수에서 이 값으로 데이터를 조회할 때는 세션을 기준으로 접근 권한을 다시 확인해야 합니다.

라우트 그룹과 인덱스 파일 이름

(marketing)처럼 폴더 이름을 괄호로 감싸면 URL에 포함되지 않는 그룹이 되어, URL은 그대로 둔 채 파일만 정리할 수 있습니다. 또 index.tsx가 많아 편집기에서 구분하기 어렵다면 blog/(blog).tsx처럼 괄호로 감싼 이름을 인덱스 파일로 쓸 수 있습니다.

route export

라우트 파일에서 route 객체를 export하면 라우트별 설정을 붙일 수 있습니다. 가장 많이 쓰는 것은 preload로, 링크에 마우스를 올리거나 이동이 시작될 때 데이터를 미리 가져오게 합니다. 예제는 데이터 페칭 절에서 봅니다.


Server Functions

서버에서만 실행되는 함수

함수 본문 첫 줄에 "use server"를 두면 그 함수는 서버에서만 실행됩니다. 빌드 시 함수 본문은 클라이언트 번들에서 빠지고, 클라이언트에서의 호출은 서버 함수 엔드포인트로 가는 HTTP 요청으로 바뀝니다. 개발자는 URL을 직접 관리하지 않고 일반 함수처럼 호출하므로 RPC에 가까운 사용감을 주며, 인자와 반환값의 TypeScript 타입도 그대로 유지됩니다.

query와의 결합

@solidjs/router의 query는 서버 함수를 캐시 키가 있는 데이터 조회 단위로 감쌉니다. 아래는 세션에서 사용자를 조회하고, 로그인하지 않았으면 리다이렉트하는 예입니다.

import { query, redirect } from "@solidjs/router";
import { useSession } from "vinxi/http";

export const getCurrentUser = query(async () => {
  "use server";
  const session = await useSession({
    password: process.env.SESSION_SECRET as string, // 32자 이상
    name: "session",
  });
  const userId = session.data.userId;
  if (!userId) throw redirect("/login");
  // return await db.users.get({ id: userId });
  return { id: userId };
}, "currentUser");

호출하는 쪽에서는 함수처럼 보이지만 실제로는 요청과 직렬화가 끼어 있으므로, 자잘한 서버 함수를 루프에서 여러 번 부르면 그만큼 네트워크 왕복이 생깁니다. 한 화면에 필요한 데이터는 한 번의 호출로 묶는 편이 좋습니다.

서버 함수는 공개 엔드포인트다

"use server" 함수는 클라이언트에서 HTTP로 호출되므로, 누구나 네트워크 탭에서 요청 형식을 보고 임의의 인자로 직접 호출할 수 있습니다. “우리 페이지에서만 부르니 안전하다”는 가정은 성립하지 않습니다. 서버 함수 안에서 인자를 검증하고 권한을 확인해야 하며, 위 예처럼 사용자 식별은 인자가 아니라 서버에서 다시 읽은 세션을 기준으로 해야 합니다. 서버에서 가져온 HTML을 innerHTML로 넣는다면, 출처를 신뢰할 수 없을 때 서버 쪽에서 sanitize해야 XSS를 막을 수 있습니다.

"use server"는 함수 본문의 첫 문장이어야 하며, 다른 문장 뒤에 오면 지시어로 인식되지 않아 그 함수가 클라이언트에서 실행됩니다. 파일 전체를 서버 전용으로 만들려면 파일 맨 위에 둡니다. 또 DB 클라이언트처럼 서버에서만 써야 하는 모듈은 서버 전용 파일로 분리하거나 서버 함수 안에서 동적으로 import하면, 클라이언트 번들에 섞이는 실수를 구조적으로 막을 수 있습니다.

action으로 폼 처리하기

데이터를 바꾸는 쪽은 action으로 감쌉니다. <form action={...}>에 그대로 연결할 수 있어서, JavaScript가 로드되기 전에도 일반 폼 전송으로 동작합니다(progressive enhancement).

// src/routes/posts.tsx
import { action, useSubmission } from "@solidjs/router";
import { Show } from "solid-js";

const createPost = action(async (form: FormData) => {
  "use server";
  const title = String(form.get("title") ?? "").trim();
  if (!title) throw new Error("제목이 필요합니다");
  const db = await import("~/db");
  const row = await db.insertPost(title);
  return { id: row.id };
}, "createPost");

export default function Posts() {
  const submission = useSubmission(createPost);
  return (
    <form method="post" action={createPost}>
      <input name="title" required />
      <button disabled={submission.pending}>Submit</button>
      <Show when={submission.result}>
        {(result) => <p>Created #{result().id}</p>}
      </Show>
      <Show when={submission.error}>
        {(err) => <p role="alert">{err().message}</p>}
      </Show>
    </form>
  );
}

required 속성은 브라우저 쪽 편의일 뿐이므로 서버 함수 안에서도 값을 다시 확인합니다. action이 끝나면 기본적으로 현재 페이지의 query들이 다시 검증되어 화면이 최신 데이터로 갱신됩니다. 특정 query만 갱신하고 싶다면 action에서 revalidate나 json(data, { revalidate: [...] })로 대상을 지정할 수 있습니다.

API 라우트와의 역할 분담

목적권장
페이지와 query에서만 쓰는 서버 데이터Server Functions + query
외부 클라이언트, 모바일 앱, 웹훅GET/POST를 export하는 API 라우트
GraphQL·tRPC 게이트웨이API 라우트에서 핸들러 연결

API 라우트 핸들러는 APIEvent를 받아 request, params 등에 접근합니다. tRPC는 fetchRequestHandler를 한 catch-all API 라우트에 연결하는 방식으로 붙일 수 있습니다.


데이터 페칭 패턴

createResource와 createAsync

SolidJS 기본 API인 createResource는 비동기 데이터를 반응형 소스와 연결하고 로딩·에러 상태를 제공합니다. SolidStart에서는 라우터의 query와 함께 createAsync를 쓰는 패턴이 기본입니다. 서버 함수가 DB에 직접 접근하고 UI는 createAsync로 그 결과를 소비하므로, 같은 앱 안에서 쓸 REST API를 따로 만들 필요가 없습니다.

query의 두 번째 인자(예: "dashboard")는 캐시 키의 접두어라서, 서로 다른 query에 같은 이름을 주면 캐시가 섞입니다. 라우트에 preload를 두면 이동 직전에 데이터를 미리 가져와 첫 렌더링의 대기 시간을 줄일 수 있고, query가 같은 키의 요청을 짧은 시간 동안 중복 제거하므로 preload와 컴포넌트가 같은 함수를 호출해도 요청은 한 번만 갑니다. 한 query 안에서 서로 의존하지 않는 조회를 await로 차례로 부르면 순차 실행되므로 Promise.all로 묶습니다.

// src/routes/dashboard.tsx
import { createAsync, query } from "@solidjs/router";
import { Show } from "solid-js";

const getDashboardData = query(async () => {
  "use server";
  const [stats, recent] = await Promise.all([loadStats(), loadRecent()]);
  return { stats, recent };
}, "dashboard");

export const route = { preload: () => getDashboardData() };

export default function Dashboard() {
  const data = createAsync(() => getDashboardData());
  return (
    <Show when={data()}>
      {(d) => (
        <>
          <Stats data={d().stats} />
          <RecentList items={d().recent} />
        </>
      )}
    </Show>
  );
}

스트리밍 SSR과 Suspense 경계

SolidStart는 기본적으로 스트리밍 SSR을 합니다. Suspense 경계 밖의 부분은 즉시 HTML로 전송되고, 경계 안쪽은 각자의 데이터가 준비되는 순서대로 이어서 전송됩니다.

import { Suspense } from "solid-js";

export default function Page() {
  return (
    <>
      <Hero />
      <Suspense fallback={<SkeletonFeed />}>
        <Feed />
      </Suspense>
      <Suspense fallback={<SkeletonSidebar />}>
        <Sidebar />
      </Suspense>
    </>
  );
}

느린 API 하나가 페이지 전체의 첫 바이트를 막지 않는다는 것이 장점이지만, 경계를 어디에 두느냐가 결과를 좌우합니다. 경계가 없으면 가장 느린 데이터가 준비될 때까지 가장 가까운 상위 경계 전체가 fallback으로 남고, 너무 잘게 나누면 스켈레톤이 여러 번 바뀌며 레이아웃이 흔들립니다. 또 중간 프록시나 CDN이 응답을 버퍼링하면 스트리밍 효과가 사라지므로, 배포 환경에서 응답이 실제로 나뉘어 도착하는지 확인해 볼 필요가 있습니다.

window에 의존하는 차트 라이브러리처럼 서버에서 렌더링할 수 없는 컴포넌트는 clientOnly로 감싸 브라우저에서만 불러옵니다. 이것은 SSR 에러를 피하는 도구이지, 페이지 대부분을 하이드레이션하지 않는 Astro식 아일랜드 아키텍처와는 다릅니다.

import { clientOnly } from "@solidjs/start";

const HeavyWidget = clientOnly(() => import("~/components/HeavyWidget"));

<HeavyWidget fallback={<p>Loading…</p>} />

스트리밍과 헤더 타이밍

스트리밍이 시작되면 응답 헤더는 이미 전송된 상태이므로, 그 뒤에 실행되는 코드는 쿠키를 설정하거나 리다이렉트 상태 코드를 보낼 수 없습니다. 세션 갱신이나 인증 리다이렉트를 하는 query는 createAsync(() => getCurrentUser(), { deferStream: true })처럼 deferStream을 켜서, 그 데이터가 준비될 때까지 스트리밍 시작을 미뤄야 합니다. 인증이 필요한 레이아웃을 설계할 때 특히 주의할 부분입니다.


배포: Vercel, Netlify, Cloudflare, Node

SolidStart 1.x는 app.config.ts의 server.preset으로 Nitro 배포 대상을 고릅니다.

import { defineConfig } from "@solidjs/start/config";

export default defineConfig({
  server: {
    preset: "vercel", // 또는 netlify, netlify_edge, cloudflare_module, node-server 등
  },
});

Vercel에서는 vercel preset이 서버리스 함수로, vercel_edge가 엣지 함수로 배포합니다. 서버리스 함수는 Node 런타임이므로 Node API와 AsyncLocalStorage를 그대로 쓸 수 있어 설정이 가장 단순한 편입니다. Netlify도 netlify(Node 함수)와 netlify_edge(Deno 기반 엣지 함수) 두 가지가 있습니다. 엣지 대상은 콜드 스타트가 짧고 사용자와 가깝지만, 쓸 수 있는 Node API가 제한되고 DB가 특정 리전에 있다면 오히려 왕복 지연이 늘 수 있습니다.

Cloudflare Workers는 Node 런타임이 아니어서 추가 설정이 필요합니다. SolidStart는 요청 컨텍스트를 전달하는 데 AsyncLocalStorage(node:async_hooks)를 쓰므로, 문서는 cloudflare_module preset과 함께 rollupConfig.external에 node:async_hooks를 넣고 wrangler.toml에 nodejs_compat 호환 플래그를 켜는 구성을 안내합니다. 저렴한 엣지라는 이유만으로 고르기보다, 앱이 쓰는 Node API와 세션·쿠키 처리를 스테이징에서 검증한 뒤 결정하는 것이 안전합니다.

Node 서버와 Docker

node-server preset(기본값)으로 빌드하면 .output 디렉터리에 의존성까지 번들된 서버 진입점이 만들어지므로, 컨테이너에는 이 디렉터리만 옮기면 됩니다.

FROM node:20-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/.output .output
ENV PORT=3000
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

어느 플랫폼이든 빌드 시점과 실행 시점에 필요한 환경 변수가 각각 설정되어 있는지, 서버 함수가 쓰는 비밀 값(SESSION_SECRET 등)이 클라이언트용 VITE_ 접두어 변수로 잘못 노출되지 않았는지를 배포 전에 확인합니다.


Next.js와 비교

구분Next.js (App Router)SolidStart
UI 모델React. 상태가 바뀌면 컴포넌트 함수를 다시 실행Solid. 컴포넌트 함수는 한 번 실행되고 signal을 읽은 곳만 갱신
서버 데이터React Server Components, fetch 캐시, 라우트 세그먼트 설정 등 규약이 많음Server Functions, query/action, createAsync
생태계매우 큼. 채용, 라이브러리, 예제작음. 자료와 서드파티 통합이 상대적으로 적음
배포Vercel이 1급 지원, 다른 플랫폼도 어댑터 존재Nitro preset으로 여러 대상 지원, Cloudflare는 추가 설정

Next.js를 고를 신호는 조직 표준이 React이고 디자인 시스템·인증·모니터링이 React용으로 이미 갖춰진 경우입니다. SolidStart는 Solid의 멘탈 모델에 익숙하고, 리렌더링 없는 세밀한 갱신과 작은 런타임이 중요한 팀에 유리합니다. React 생태계의 라이브러리를 Solid에서 그대로 쓸 수는 없으므로, 필요한 UI 컴포넌트와 통합 라이브러리가 Solid용으로 있는지 먼저 확인해야 합니다.


참고 문서


같이 보면 좋은 글