Void 라우팅: pages로 화면을, routes로 API를

Void 라우팅: pages로 화면을, routes로 API를

Void: Vite 네이티브 배포 플랫폼에서 프로젝트 구조를 훑어볼 때 pages/routes/ 디렉토리를 잠깐 스쳐 지나갔는데요. 사실 이 두 디렉토리가 Void로 앱을 만드는 거의 모든 것을 담당합니다. 화면을 그리는 일도, API를 여는 일도 결국 “어떤 파일을 어디에 두느냐”로 결정되거든요.

이번 글에서는 Void의 라우팅을 제대로 파헤쳐볼게요. 파일 하나가 어떻게 URL이 되는지, 서버에서 가져온 데이터가 어떻게 화면 컴포넌트로 흘러가는지, API 핸들러는 어떻게 쓰고 입력 검증과 미들웨어는 어떻게 거는지까지 차근차근 살펴보겠습니다.

화면은 pages, API는 routes

Void의 라우팅은 두 갈래로 나뉩니다. 사용자에게 보여줄 화면은 pages/에, 데이터를 주고받는 API는 routes/에 둡니다. 둘 다 파일 기반(file-based)이라 파일의 위치가 그대로 URL 경로가 돼요.

둘의 성격은 꽤 다릅니다. pages/Inertia.js에서 영감을 받았는데요. 핵심 아이디어는 단순합니다. 서버가 데이터를 반환하면 컴포넌트가 그걸 props로 받아 화면을 그린다는 거예요. 따로 API를 호출하거나 로딩 상태를 들고 다닐 필요가 없습니다. 반면 routes/Hono 위에서 동작하는 순수한 API 레이어라, REST 엔드포인트를 만들기에 어울립니다.

그래서 같은 프로젝트 안에서 “회원 프로필 화면”은 pages에, “외부 앱이 호출할 회원 조회 API”는 routes에 두는 식으로 역할이 자연스럽게 갈립니다. 이제 각각을 들여다볼게요.

파일이 곧 URL이 되는 pages

먼저 화면 쪽입니다. pages/ 안에 파일을 만들면 그 위치가 URL이 됩니다.

pages/
├─ layout.tsx          # 모든 페이지를 감싸는 루트 레이아웃
├─ index.tsx           # → /
├─ index.server.ts     # / 의 데이터 로더
├─ about.tsx           # → /about
├─ users/
│  ├─ layout.tsx       # /users/* 전용 레이아웃
│  ├─ [id].tsx         # → /users/:id
│  └─ [id].server.ts   # /users/:id 의 로더
└─ blog/
   └─ hello.md         # → /blog/hello

규칙은 직관적이에요.

  • about.tsx — 평범한 파일은 /about이 됩니다.
  • about/index.tsx — 디렉토리 안의 index도 똑같이 /about이 됩니다.
  • [id].tsx — 대괄호로 감싸면 /users/:id 같은 동적 경로가 됩니다.
  • [...param].tsx — 점 세 개를 붙이면 여러 경로 조각을 한꺼번에 받는 catch-all 라우트가 됩니다.
  • (group)/ — 괄호로 묶으면 코드를 디렉토리로 정리하되 URL에는 영향을 주지 않습니다.
  • hello.md.md 파일을 두면 마크다운이 그대로 페이지가 됩니다.

별도의 라우트 설정 파일을 작성하거나 경로를 손으로 등록할 일이 없습니다. 파일을 옮기면 URL이 따라 바뀌는 셈이죠.

페이지와 서버 로더

Void 라우팅에서 가장 특징적인 부분이 바로 이 .tsx.server.ts의 짝꿍 구조예요. 화면 컴포넌트(.tsx) 옆에 같은 이름의 .server.ts를 두면, 그게 그 페이지의 서버 로더가 됩니다.

pages/users/[id].server.ts
import { defineHandler } from "void";

export const loader = defineHandler(async ({ params }) => {
  const user = await db.users.findById(params.id);
  const posts = await db.posts.findByUserId(params.id);
  return { user, posts }; // 이 객체가 그대로 컴포넌트의 props가 됩니다
});
pages/users/[id].tsx
export default function UserPage({ user, posts }) {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>글 {posts.length}개</p>
    </div>
  );
}

로더는 GET 요청이 들어올 때 서버에서 실행되고, 반환한 객체가 그대로 페이지 컴포넌트의 props로 넘어갑니다. 컴포넌트 안에서 직접 fetch를 부르거나 로딩 상태를 관리할 필요가 없어요. 데이터를 가져오는 일과 화면을 그리는 일이 파일 두 개로 깔끔하게 분리되는 거죠.

게다가 로더의 반환 타입이 컴포넌트 props의 타입을 자동으로 결정합니다. 로더에서 user에 필드를 하나 추가하면 컴포넌트 쪽 props 타입에도 바로 반영되기 때문에, 서버에서 화면까지 타입이 끊기지 않고 이어집니다. 데이터 모양이 어긋나면 빌드 단계에서 걸리고요.

무거운 데이터 하나 때문에 화면 전체가 늦어지는 걸 막고 싶을 때는 defer()를 씁니다. 외부 API 호출이나 AI 추론처럼 느린 작업을 defer()로 감싸면, 빠른 데이터는 먼저 화면에 그리고 느린 값은 준비되는 대로 스트리밍해줘요. React에서는 Suspense와 use()로, Vue·Svelte·Solid에서는 { loading, value, error } 상태로 받아 처리합니다.

데이터를 읽기만 하는 게 아니라 폼 제출이나 변경 처리가 필요하면 loader 대신 action을 내보냅니다. actionPOST, PUT, DELETE 요청을 받아 처리해요. 폼과 액션을 본격적으로 다루는 법은 Void 폼과 액션에서 따로 정리했습니다. 또 화면을 서버에서 렌더링하지 않고 클라이언트에서만 그리고 싶다면 ssrfalse로 내보내면 됩니다.

동적 라우트와 레이아웃

[id].tsx 같은 동적 라우트에서 경로 값을 꺼내는 방법은 두 가지예요. 서버 로더에서는 위 예제처럼 params.id로 바로 접근하고, 컴포넌트 안에서는 useParams 훅을 씁니다.

import { useParams } from "@void/react";

export default function PostPage() {
  const { id } = useParams<{ id: string }>();
  return <h1>{id}번 글</h1>;
}

여러 페이지가 공통으로 쓰는 골격은 layout.tsx로 묶습니다. pages/layout.tsx는 모든 페이지를, pages/users/layout.tsx/users/ 아래 페이지만 감싸요. 레이아웃은 중첩되기 때문에 /users/3 페이지는 루트 레이아웃과 users 레이아웃을 둘 다 거쳐 렌더링됩니다. 헤더나 사이드바처럼 반복되는 UI를 한곳에 모아두기 좋죠.

SPA처럼 움직이는 클라이언트 내비게이션

Void 페이지는 첫 로드에선 서버 렌더링된 HTML을 받아 하이드레이션하지만, 그 뒤의 페이지 이동은 단일 페이지 앱(SPA)처럼 동작합니다. <Link> 컴포넌트로 이동하면 전체 페이지를 새로 받지 않고, 컴포넌트 이름과 props만 담긴 JSON을 가져와 화면만 다시 그려요.

import { Link } from "@void/react";

<Link href="/users">사용자 목록</Link>;
<Link href={`/users/${id}`}>자세히</Link>;

코드로 이동해야 할 때는 useRouter를 씁니다.

const router = useRouter();
router.visit("/users"); // 다른 페이지로 이동
router.refresh(); // 현재 페이지의 로더만 다시 실행

뒤로·앞으로 가기를 할 때 스크롤 위치도 알아서 보존됩니다. 첫 로드의 서버 렌더링 성능과 이후 전환의 매끄러움을 둘 다 챙기는 셈이에요. 우리가 평소 React 앱에서 직접 만들어야 했던 라우터와 데이터 페칭 계층이 라우팅 규칙 안에 녹아 있는 거죠.

API는 routes에

이제 API 쪽입니다. routes/ 디렉토리도 파일 기반이고, 내부적으로는 Hono가 HTTP를 처리해요.

routes/
└─ api/
   ├─ hello.ts          # → /api/hello
   ├─ users/
   │  ├─ index.ts       # → /api/users
   │  └─ [id].ts        # → /api/users/:id
   └─ search/
      └─ [...query].ts   # → /api/search/* (catch-all)

핸들러는 HTTP 메서드 이름(GET, POST, PUT, DELETE)을 named export로 내보내고, defineHandler로 감쌉니다.

routes/api/hello.ts
import { defineHandler } from "void";

export const GET = defineHandler((c) => {
  return { message: "안녕하세요!", now: Date.now() };
});

한 파일에 여러 메서드를 둘 수도 있어요.

routes/api/users/index.ts
import { defineHandler } from "void";
import { db } from "void/db";
import { users } from "@schema";

export const GET = defineHandler(async () => {
  return await db.select().from(users); // 객체·배열은 자동으로 JSON 응답
});

export const POST = defineHandler(async (c) => {
  const body = await c.req.json();
  await db.insert(users).values({ name: body.name });
  return { created: true };
});

defineHandler에 넘어오는 c는 Hono의 Context인데요. c.req.json()으로 요청 본문을, c.req.param("id")로 동적 경로 값을, c.req.query()로 쿼리 문자열을 꺼냅니다. c.env로는 D1이나 KV 같은 Cloudflare 바인딩에 타입까지 붙어 접근할 수 있고요.

반환값은 알아서 변환됩니다. 객체나 배열은 JSON으로, 문자열은 HTML로, null이나 undefined는 204 No Content로 나가고, 직접 만든 Response 객체는 그대로 전달돼요. 그래서 간단한 엔드포인트는 return { ... } 한 줄이면 끝납니다.

입력 검증과 미들웨어

API를 열었으면 들어오는 데이터를 검증해야겠죠. defineHandler.withValidator()로 본문과 쿼리, 파라미터를 검사할 수 있는데, Void는 Drizzle 스키마에서 검증기를 바로 뽑아내는 방식을 권합니다. 스키마에서 createInsertSchema로 검증기를 만드는 자세한 과정은 Void 데이터베이스에서 다룹니다.

routes/api/users/index.ts
import { defineHandler } from "void";
import { db } from "void/db";
import { users, insertUserSchema } from "@schema";

export const POST = defineHandler.withValidator({
  body: insertUserSchema, // Drizzle 스키마에서 파생한 검증기
})(async (c, { body }) => {
  const [created] = await db.insert(users).values(body).returning();
  return created;
});

스키마가 따로 없는 엔드포인트라면 Zod나 Valibot, ArkType 같은 Standard Schema 호환 라이브러리를 써도 됩니다.

import * as v from "valibot";
import { defineHandler } from "void";

export const POST = defineHandler.withValidator({
  body: v.object({
    query: v.pipe(v.string(), v.minLength(1)),
    limit: v.optional(v.pipe(v.number(), v.maxValue(100)), 10),
  }),
})(async (c, { body }) => {
  return search(body.query, body.limit);
});

검증에 실패하면 자동으로 400 응답에 어떤 필드가 잘못됐는지 담아 돌려줍니다. 직접 if (!body.query) return ... 같은 코드를 짤 필요가 없어요. 게다가 이 검증 스키마는 타입 안전한 fetch 클라이언트에도 그대로 쓰여서, API를 호출하는 쪽에서도 잘못된 요청이 타입 단계에서 걸립니다.

모든 요청에 공통으로 거는 처리는 미들웨어로 뺍니다. 전역 미들웨어는 middleware/ 디렉토리에 두고, 파일명 앞에 숫자를 붙여 실행 순서를 정해요.

middleware/01.logger.ts
import { defineMiddleware } from "void";

export default defineMiddleware(async (c, next) => {
  console.log(c.req.method, c.req.path);
  await next();
});

특정 라우트에만 거는 미들웨어는 defineHandler에 핸들러보다 앞으로 넘기면 됩니다. CORS나 응답 시간 측정처럼 일부 엔드포인트에만 필요한 처리를 이렇게 붙일 수 있어요. 미들웨어가 c.set()으로 심어둔 값은 뒤따르는 핸들러에서 타입과 함께 꺼내 쓸 수 있고요.

그래서 언제 무엇을 쓰나

정리하면 기준은 간단합니다. 사람이 볼 화면이면 pages, 데이터만 주고받는 API면 routes예요.

pages는 로더로 데이터를 받아 컴포넌트로 렌더링하고, 폼 액션과 레이아웃, 클라이언트 내비게이션까지 화면에 필요한 풀스택 파이프라인을 담당합니다. 화면을 그리면서 그 화면에 필요한 데이터를 함께 가져오는 경우라면, 굳이 별도 API를 만들지 말고 페이지 로더만 써도 충분합니다.

반대로 routes는 Hono 기반의 순수 HTTP 레이어라, 모바일 앱이나 외부 서비스가 호출할 REST API, 웹훅 수신, 백그라운드 작업처럼 화면과 무관하게 재사용되는 엔드포인트에 어울립니다. “이 데이터를 우리 화면 말고 다른 곳도 쓰는가?”를 떠올려 보면 어느 쪽에 둘지 금방 정해져요.

마치며

Void의 라우팅은 파일을 어디에 두느냐만으로 화면과 API가 모두 결정됩니다. 거기에 서버 로더부터 클라이언트까지 타입이 끊기지 않으니, 단순하면서도 안전하고요. pages의 로더-props 패턴과 routes의 defineHandler만 익히고 나면, 대부분의 풀스택 기능을 별도 보일러플레이트 없이 만들 수 있어요.

여기까지 만든 앱을 실제로 배포하는 과정은 Void: Vite 네이티브 배포 플랫폼에서, 기존 서비스를 Void로 옮긴 실전 기록은 Void로 프로덕션 앱 이전하기에서 이어집니다. 서버 라우팅의 토대가 되는 Hono가 궁금하다면 Hono 가이드도 함께 보면 좋고요.

더 자세한 내용은 Void 공식 라우팅 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord