본문으로 건너뛰기
Advertisement

19.4 Qwik City

QwikCity는 Qwik 위에 구축된 공식 메타프레임워크입니다. Next.js가 React의 메타프레임워크이듯, QwikCity는 Qwik에 파일 기반 라우팅, SSR, API 라우트, 미들웨어 등을 추가합니다.


QwikCity란?

QwikCity는 Qwik 앱을 풀스택 웹 애플리케이션으로 만들기 위한 모든 도구를 제공합니다.

Qwik       = 반응성 UI 프레임워크 (컴포넌트, Signal, 직렬화)
QwikCity = Qwik + 라우팅 + SSR + API + 미들웨어 + 최적화

QwikCity가 제공하는 것

1. 파일 기반 라우팅 (src/routes/)
2. 중첩 레이아웃 시스템
3. routeLoader$ (서버 데이터 로딩)
4. routeAction$ (폼 처리 / 뮤테이션)
5. API 엔드포인트 (REST, RPC)
6. 미들웨어 (인증, 로깅 등)
7. server$ (서버 전용 함수)
8. Link 컴포넌트 (프리페칭 포함)
9. Head 관리 (SEO, Open Graph)
10. 서비스 워커 기반 프리페칭

파일 기반 라우팅

src/routes/ 구조

src/routes/
├── index.tsx → /
├── layout.tsx → 루트 레이아웃
├── about/
│ └── index.tsx → /about
├── blog/
│ ├── index.tsx → /blog (목록)
│ ├── layout.tsx → /blog/* 레이아웃
│ └── [slug]/
│ └── index.tsx → /blog/:slug (상세)
├── products/
│ ├── index.tsx → /products
│ └── [id]/
│ ├── index.tsx → /products/:id
│ └── reviews/
│ └── index.tsx → /products/:id/reviews
└── [...catchAll]/
└── index.tsx → 매칭되지 않는 모든 경로

기본 라우트 컴포넌트

// src/routes/index.tsx
import { component$ } from '@builder.io/qwik';
import type { DocumentHead } from '@builder.io/qwik-city';

export default component$(() => {
return (
<main>
<h1>홈페이지</h1>
</main>
);
});

// 페이지 메타데이터
export const head: DocumentHead = {
title: '홈 - My App',
meta: [
{ name: 'description', content: '홈페이지 설명' },
{ property: 'og:title', content: '홈 - My App' },
{ property: 'og:description', content: '홈페이지 설명' },
{ property: 'og:image', content: 'https://myapp.com/og-image.jpg' },
],
links: [
{ rel: 'canonical', href: 'https://myapp.com' },
],
};

특수 파일명 규칙

index.tsx        → 기본 페이지 컴포넌트 (export default)
layout.tsx → 레이아웃 컴포넌트 (Slot 포함)
plugin.ts → 플러그인 미들웨어
service-worker.ts → 서비스 워커
middleware.ts → 미들웨어 (루트 레벨)

동적 라우트

[param] — 단일 파라미터

// src/routes/blog/[slug]/index.tsx
import { component$ } from '@builder.io/qwik';
import { useLocation, routeLoader$ } from '@builder.io/qwik-city';

export const usePost = routeLoader$(async ({ params, status }) => {
const { slug } = params; // URL에서 [slug] 파라미터 추출

const res = await fetch(`https://api.example.com/posts/${slug}`);

if (!res.ok) {
status(404); // HTTP 상태 코드 설정
return null;
}

return res.json();
});

export default component$(() => {
const post = usePost();
const location = useLocation();

if (!post.value) {
return <h1>포스트를 찾을 수 없습니다</h1>;
}

return (
<article>
<h1>{post.value.title}</h1>
<p>슬러그: {location.params.slug}</p>
<div dangerouslySetInnerHTML={post.value.content} />
</article>
);
});

[...catchAll] — 캐치올 라우트

// src/routes/[...path]/index.tsx
import { component$ } from '@builder.io/qwik';
import { useLocation } from '@builder.io/qwik-city';

export default component$(() => {
const location = useLocation();

return (
<div>
<h1>404 - 페이지를 찾을 수 없습니다</h1>
<p>요청한 경로: {location.url.pathname}</p>
<a href="/">홈으로 돌아가기</a>
</div>
);
});

중첩 동적 라우트

// src/routes/shop/[category]/[productId]/index.tsx
// URL: /shop/electronics/phone-123

import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';

export const useProduct = routeLoader$(async ({ params }) => {
const { category, productId } = params;
// category = 'electronics', productId = 'phone-123'

return fetchProduct(category, productId);
});

export default component$(() => {
const product = useProduct();
return <div>{product.value?.name}</div>;
});

레이아웃(Layout)

루트 레이아웃

// src/routes/layout.tsx
import { component$, Slot } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';

// 레이아웃에서도 routeLoader$ 사용 가능
export const useCurrentUser = routeLoader$(async ({ cookie }) => {
const token = cookie.get('auth_token');
if (!token) return null;

const res = await fetch('/api/me', {
headers: { Authorization: `Bearer ${token.value}` }
});
if (!res.ok) return null;
return res.json();
});

export default component$(() => {
const user = useCurrentUser();

return (
<>
<header class="site-header">
<nav>
<a href="/"></a>
<a href="/blog">블로그</a>
<a href="/products">제품</a>
{user.value
? <a href="/dashboard">{user.value.name}</a>
: <a href="/login">로그인</a>
}
</nav>
</header>

<main>
<Slot /> {/* 자식 라우트 컴포넌트가 여기 렌더링 */}
</main>

<footer>
<p>© 2024 My App</p>
</footer>
</>
);
});

중첩 레이아웃

// src/routes/blog/layout.tsx — /blog/* 모든 경로에 적용
import { component$, Slot } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';

export const useBlogCategories = routeLoader$(async () => {
const res = await fetch('https://api.example.com/categories');
return res.json();
});

export default component$(() => {
const categories = useBlogCategories();

return (
<div class="blog-layout">
<aside class="sidebar">
<h3>카테고리</h3>
<ul>
{categories.value.map((cat: { id: number; name: string; slug: string }) => (
<li key={cat.id}>
<a href={`/blog/category/${cat.slug}`}>{cat.name}</a>
</li>
))}
</ul>
</aside>

<div class="content">
<Slot /> {/* 블로그 페이지 내용 */}
</div>
</div>
);
});

Named Slot (Named Outlet)

// 레이아웃에서 named slot 정의
export default component$(() => {
return (
<div class="page">
{/* 이름 없는 기본 슬롯 */}
<main>
<Slot />
</main>

{/* 이름 있는 슬롯 */}
<aside>
<Slot name="sidebar" />
</aside>

<div class="actions">
<Slot name="actions" />
</div>
</div>
);
});

// 자식 컴포넌트에서 named slot 채우기
export const Page = component$(() => {
return (
<>
{/* 기본 슬롯 */}
<h1>메인 콘텐츠</h1>

{/* sidebar 슬롯 채우기 */}
<div q:slot="sidebar">
<p>사이드바 내용</p>
</div>

{/* actions 슬롯 채우기 */}
<div q:slot="actions">
<button>저장</button>
<button>취소</button>
</div>
</>
);
});

routeLoader$ — 서버 데이터 로딩

기본 사용법

// src/routes/products/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
import type { DocumentHead } from '@builder.io/qwik-city';

interface Product {
id: number;
name: string;
price: number;
stock: number;
category: string;
}

// routeLoader$: 페이지 렌더링 전 서버에서 실행
export const useProducts = routeLoader$(async ({ query, env }) => {
const page = Number(query.get('page') || '1');
const category = query.get('category') || '';
const apiKey = env.get('API_KEY'); // 서버 환경 변수 접근

const url = new URL('https://api.example.com/products');
url.searchParams.set('page', String(page));
url.searchParams.set('limit', '10');
if (category) url.searchParams.set('category', category);

const res = await fetch(url.toString(), {
headers: { 'X-API-Key': apiKey }
});

if (!res.ok) throw new Error('제품 목록을 가져올 수 없습니다');

return {
products: await res.json() as Product[],
currentPage: page,
totalPages: Number(res.headers.get('X-Total-Pages') || '1'),
};
});

export default component$(() => {
const data = useProducts(); // 서버에서 로드된 데이터

return (
<div>
<h1>제품 목록</h1>

<div class="product-grid">
{data.value.products.map(product => (
<div key={product.id} class="product-card">
<h2>{product.name}</h2>
<p>{product.price.toLocaleString()}</p>
<p>재고: {product.stock}</p>
<a href={`/products/${product.id}`}>상세보기</a>
</div>
))}
</div>

<div class="pagination">
{data.value.currentPage > 1 && (
<a href={`?page=${data.value.currentPage - 1}`}>이전</a>
)}
<span>{data.value.currentPage} / {data.value.totalPages}</span>
{data.value.currentPage < data.value.totalPages && (
<a href={`?page=${data.value.currentPage + 1}`}>다음</a>
)}
</div>
</div>
);
});

export const head: DocumentHead = ({ resolveValue }) => {
const data = resolveValue(useProducts);
return {
title: `제품 목록 (${data.products.length}개)`,
};
};

routeLoader$의 컨텍스트 객체

export const useData = routeLoader$(async ({
params, // URL 파라미터 { id: '123' }
query, // 쿼리 스트링 URLSearchParams
request, // Request 객체 (헤더, 메서드 등)
cookie, // 쿠키 읽기/쓰기
env, // 환경 변수 (process.env 대신)
platform, // 플랫폼별 기능 (Cloudflare 등)
status, // HTTP 상태 코드 설정 함수
redirect, // 리다이렉트 함수
error, // 에러 응답 함수
headers, // 응답 헤더 설정
locale, // 현재 locale
sharedMap, // loader 간 데이터 공유 Map
}) => {
// 예: 인증 확인 후 리다이렉트
const token = cookie.get('auth_token');
if (!token) {
throw redirect(302, '/login');
}

// 예: 파라미터 검증
const id = Number(params.id);
if (isNaN(id)) {
throw error(400, '잘못된 ID 형식');
}

return { data: 'hello' };
});

여러 routeLoader$ 조합

// 한 페이지에서 여러 loader 사용 가능
export const useUser = routeLoader$(async ({ params }) => {
return fetchUser(params.id);
});

export const usePosts = routeLoader$(async ({ params }) => {
return fetchUserPosts(params.id);
});

export const useFollowers = routeLoader$(async ({ params }) => {
return fetchFollowers(params.id);
});

// 모든 loader가 병렬로 실행됨 (워터폴 없음)
export default component$(() => {
const user = useUser();
const posts = usePosts();
const followers = useFollowers();

return (
<div>
<h1>{user.value.name}</h1>
<p>게시물 {posts.value.length}</p>
<p>팔로워 {followers.value.length}</p>
</div>
);
});

routeAction$ — 폼 처리

기본 폼 처리

// src/routes/contact/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeAction$, Form, zod$, z } from '@builder.io/qwik-city';

// zod$로 유효성 검사 스키마 정의
export const useContactAction = routeAction$(
async (data, { redirect, env }) => {
// 서버에서만 실행
const { name, email, message } = data;

// 이메일 발송 (서버 로직)
await sendEmail({
to: env.get('CONTACT_EMAIL'),
from: email,
subject: `문의: ${name}`,
body: message,
});

// 성공 시 리다이렉트
throw redirect(302, '/contact/success');
},
// Zod 스키마로 자동 유효성 검사
zod$({
name: z.string().min(2, '이름은 2글자 이상'),
email: z.string().email('올바른 이메일을 입력하세요'),
message: z.string().min(10, '메시지는 10글자 이상'),
})
);

export default component$(() => {
const action = useContactAction();

return (
<div>
<h1>문의하기</h1>

{/* Form 컴포넌트: Progressive Enhancement 자동 처리 */}
<Form action={action}>
<div>
<label>이름</label>
<input type="text" name="name" />
{/* 유효성 검사 에러 표시 */}
{action.value?.fieldErrors?.name && (
<span class="error">{action.value.fieldErrors.name}</span>
)}
</div>

<div>
<label>이메일</label>
<input type="email" name="email" />
{action.value?.fieldErrors?.email && (
<span class="error">{action.value.fieldErrors.email}</span>
)}
</div>

<div>
<label>메시지</label>
<textarea name="message" rows={5} />
{action.value?.fieldErrors?.message && (
<span class="error">{action.value.fieldErrors.message}</span>
)}
</div>

<button type="submit" disabled={action.isRunning}>
{action.isRunning ? '전송 중...' : '문의 전송'}
</button>
</Form>
</div>
);
});

Action의 상태 관리

export const useCreatePost = routeAction$(
async (data) => {
const post = await createPost(data);
return { success: true, post }; // 반환값은 action.value에 담김
},
zod$({
title: z.string().min(1),
content: z.string().min(1),
})
);

export default component$(() => {
const createPost = useCreatePost();

return (
<div>
<Form action={createPost}>
<input type="text" name="title" placeholder="제목" />
<textarea name="content" placeholder="내용" />
<button type="submit">게시</button>
</Form>

{/* action.value: 서버 반환값 */}
{createPost.value?.success && (
<p>게시물이 생성되었습니다! ID: {createPost.value.post.id}</p>
)}

{/* action.isRunning: 처리 중 상태 */}
{createPost.isRunning && <p>처리 중...</p>}

{/* action.value?.fieldErrors: 유효성 검사 에러 */}
{createPost.value?.fieldErrors && (
<pre>{JSON.stringify(createPost.value.fieldErrors, null, 2)}</pre>
)}
</div>
);
});

globalAction$ — 여러 페이지에서 공유하는 Action

// src/actions/auth.ts
import { globalAction$, zod$, z } from '@builder.io/qwik-city';

export const useLoginAction = globalAction$(
async (data, { cookie, redirect }) => {
const { email, password } = data;

const user = await authenticateUser(email, password);
if (!user) {
return { error: '이메일 또는 비밀번호가 올바르지 않습니다' };
}

const token = generateJWT(user.id);
cookie.set('auth_token', token, {
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7일
path: '/',
});

throw redirect(302, '/dashboard');
},
zod$({
email: z.string().email(),
password: z.string().min(8),
})
);

// 어느 컴포넌트에서나 사용 가능
export const LoginForm = component$(() => {
const login = useLoginAction();

return (
<Form action={login}>
<input type="email" name="email" />
<input type="password" name="password" />
{login.value?.error && <p class="error">{login.value.error}</p>}
<button type="submit">로그인</button>
</Form>
);
});

API Routes — server$ 함수와 REST 엔드포인트

server$ 함수 (RPC 스타일)

// server$는 클라이언트에서 호출하지만 서버에서 실행
import { component$, useSignal } from '@builder.io/qwik';
import { server$ } from '@builder.io/qwik-city';

// 서버 전용 함수 정의
const getWeather = server$(async function(city: string) {
// 이 코드는 클라이언트에 절대 노출되지 않음
const API_KEY = process.env.WEATHER_API_KEY;
const res = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${API_KEY}&units=metric&lang=kr`
);
const data = await res.json();
return {
temp: data.main.temp,
description: data.weather[0].description,
humidity: data.main.humidity,
};
});

export const WeatherWidget = component$(() => {
const city = useSignal('서울');
const weather = useSignal<any>(null);
const loading = useSignal(false);

return (
<div>
<input
value={city.value}
onInput$={(e) => city.value = (e.target as HTMLInputElement).value}
/>
<button onClick$={async () => {
loading.value = true;
try {
// 클라이언트에서 호출 → 서버에서 실행
weather.value = await getWeather(city.value);
} finally {
loading.value = false;
}
}}>
날씨 조회
</button>

{loading.value && <p>로딩 중...</p>}
{weather.value && (
<div>
<p>온도: {weather.value.temp}°C</p>
<p>날씨: {weather.value.description}</p>
<p>습도: {weather.value.humidity}%</p>
</div>
)}
</div>
);
});

REST API 엔드포인트 (GET/POST)

// src/routes/api/posts/index.ts
import type { RequestHandler } from '@builder.io/qwik-city';

// GET /api/posts
export const onGet: RequestHandler = async ({ json, query }) => {
const page = Number(query.get('page') || '1');
const posts = await fetchPosts(page);
json(200, { posts, page });
};

// POST /api/posts
export const onPost: RequestHandler = async ({ request, json, cookie }) => {
// 인증 확인
const token = cookie.get('auth_token');
if (!token) {
json(401, { error: '인증이 필요합니다' });
return;
}

const body = await request.json();
const { title, content } = body;

if (!title || !content) {
json(400, { error: '제목과 내용은 필수입니다' });
return;
}

const post = await createPost({ title, content });
json(201, { post });
};
// src/routes/api/posts/[id]/index.ts
import type { RequestHandler } from '@builder.io/qwik-city';

// GET /api/posts/:id
export const onGet: RequestHandler = async ({ params, json, status }) => {
const post = await getPost(Number(params.id));

if (!post) {
status(404);
json(404, { error: '포스트를 찾을 수 없습니다' });
return;
}

json(200, post);
};

// PUT /api/posts/:id
export const onPut: RequestHandler = async ({ params, request, json }) => {
const body = await request.json();
const updated = await updatePost(Number(params.id), body);
json(200, updated);
};

// DELETE /api/posts/:id
export const onDelete: RequestHandler = async ({ params, json }) => {
await deletePost(Number(params.id));
json(200, { success: true });
};

공통 응답 헤더 및 CORS 처리

// src/routes/api/layout.ts (API 라우트 공통 미들웨어)
import type { RequestHandler } from '@builder.io/qwik-city';

export const onRequest: RequestHandler = async ({ headers, next }) => {
// CORS 헤더 설정
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');

// Content-Type 기본값
headers.set('Content-Type', 'application/json');

await next();
};

import { component$ } from '@builder.io/qwik';
import { Link } from '@builder.io/qwik-city';

export const Navigation = component$(() => {
return (
<nav>
{/* Link는 서비스 워커 기반 프리페칭 자동 수행 */}
<Link href="/"></Link>

{/* prefetch 제어 */}
<Link href="/about" prefetch={false}>소개 (프리페칭 없음)</Link>

{/* reload: 전체 페이지 새로고침 */}
<Link href="/dashboard" reload>대시보드</Link>
</nav>
);
});

서비스 워커 기반 프리페칭

// src/routes/service-worker.ts
// QwikCity의 서비스 워커: 뷰포트에 보이는 링크의 JS 청크를 미리 다운로드
import { setupServiceWorker } from '@builder.io/qwik-city/service-worker';

setupServiceWorker();

// addEventListener로 push, sync 이벤트 추가 가능
self.addEventListener('push', (event) => {
// 푸시 알림 처리
});

프리페칭 동작 방식

1. 사용자가 페이지 방문
2. 서비스 워커 설치됨
3. Link 컴포넌트들이 뷰포트에 들어옴 (IntersectionObserver)
4. 해당 링크의 목적지 페이지에서 필요한 JS 청크를 백그라운드 다운로드
5. 사용자가 링크를 클릭하면 JS가 이미 캐시에 있으므로 즉시 로드

미들웨어 (middleware.ts)

루트 미들웨어

// src/middleware.ts (루트에서만 사용 가능)
import type { RequestHandler } from '@builder.io/qwik-city';

export const onRequest: RequestHandler = async ({
request,
next,
redirect,
cookie,
url,
}) => {
// 요청 로깅
const start = Date.now();
console.log(`[${request.method}] ${url.pathname}`);

// 인증 체크
const publicPaths = ['/', '/login', '/register', '/about'];
if (!publicPaths.some(p => url.pathname.startsWith(p))) {
const token = cookie.get('auth_token');
if (!token) {
throw redirect(302, `/login?redirect=${url.pathname}`);
}
}

// 요청 처리 계속
await next();

// 응답 후 처리
const duration = Date.now() - start;
console.log(`[${request.method}] ${url.pathname} - ${duration}ms`);
};

라우트별 미들웨어 (plugin.ts)

// src/routes/admin/plugin.ts (admin/* 경로에만 적용)
import type { RequestHandler } from '@builder.io/qwik-city';

export const onRequest: RequestHandler = async ({ cookie, redirect, url }) => {
const token = cookie.get('auth_token');

if (!token) {
throw redirect(302, '/login');
}

// JWT 검증
const user = verifyJWT(token.value);
if (!user || user.role !== 'admin') {
throw redirect(302, '/unauthorized');
}
};

실전 예제: 블로그 시스템

블로그 목록 페이지

// src/routes/blog/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
import { Link } from '@builder.io/qwik-city';

interface BlogPost {
id: number;
slug: string;
title: string;
excerpt: string;
publishedAt: string;
author: { name: string; avatar: string };
tags: string[];
readingTime: number;
}

export const useBlogPosts = routeLoader$(async ({ query }) => {
const tag = query.get('tag') || '';
const page = Number(query.get('page') || '1');

const posts: BlogPost[] = await fetchBlogPosts({ tag, page, limit: 10 });
const total = await countBlogPosts({ tag });

return {
posts,
total,
page,
totalPages: Math.ceil(total / 10),
currentTag: tag,
};
});

export default component$(() => {
const data = useBlogPosts();

return (
<div class="blog-list">
<h1>블로그</h1>

{data.value.currentTag && (
<div class="tag-filter">
태그: <strong>{data.value.currentTag}</strong>
<a href="/blog">전체 보기</a>
</div>
)}

<div class="posts">
{data.value.posts.map(post => (
<article key={post.id} class="post-card">
<div class="post-header">
<h2>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</h2>
<div class="meta">
<img src={post.author.avatar} alt={post.author.name} width={24} height={24} />
<span>{post.author.name}</span>
<time>{new Date(post.publishedAt).toLocaleDateString('ko-KR')}</time>
<span>{post.readingTime}분 읽기</span>
</div>
</div>
<p class="excerpt">{post.excerpt}</p>
<div class="tags">
{post.tags.map(tag => (
<Link key={tag} href={`/blog?tag=${tag}`} class="tag">
#{tag}
</Link>
))}
</div>
</article>
))}
</div>

<div class="pagination">
{data.value.page > 1 && (
<Link href={`/blog?page=${data.value.page - 1}${data.value.currentTag ? `&tag=${data.value.currentTag}` : ''}`}>
이전
</Link>
)}
<span>{data.value.page} / {data.value.totalPages} 페이지</span>
{data.value.page < data.value.totalPages && (
<Link href={`/blog?page=${data.value.page + 1}${data.value.currentTag ? `&tag=${data.value.currentTag}` : ''}`}>
다음
</Link>
)}
</div>
</div>
);
});

블로그 상세 페이지 + 댓글

// src/routes/blog/[slug]/index.tsx
import { component$ } from '@builder.io/qwik';
import {
routeLoader$,
routeAction$,
Form,
Link,
zod$,
z
} from '@builder.io/qwik-city';
import type { DocumentHead } from '@builder.io/qwik-city';

export const usePost = routeLoader$(async ({ params, status, error }) => {
const post = await fetchPostBySlug(params.slug);

if (!post) {
throw error(404, '포스트를 찾을 수 없습니다');
}

// 조회수 증가 (비동기, 응답을 기다리지 않음)
incrementViews(post.id).catch(console.error);

return post;
});

export const useComments = routeLoader$(async ({ params }) => {
return fetchComments(params.slug);
});

export const useAddComment = routeAction$(
async (data, { params, cookie }) => {
const userId = getUserFromCookie(cookie);

const comment = await createComment({
postSlug: params.slug,
userId,
content: data.content,
});

return { comment };
},
zod$({
content: z.string().min(5, '댓글은 5글자 이상'),
})
);

export default component$(() => {
const post = usePost();
const comments = useComments();
const addComment = useAddComment();

return (
<article class="blog-post">
<header>
<h1>{post.value.title}</h1>
<div class="meta">
<time>{new Date(post.value.publishedAt).toLocaleDateString('ko-KR')}</time>
<span>{post.value.author.name}</span>
<span>{post.value.readingTime}분 읽기</span>
</div>
<div class="tags">
{post.value.tags.map(tag => (
<Link key={tag} href={`/blog?tag=${tag}`} class="tag">#{tag}</Link>
))}
</div>
</header>

<div class="content" dangerouslySetInnerHTML={post.value.htmlContent} />

<section class="comments">
<h2>댓글 ({comments.value.length})</h2>

<Form action={addComment} class="comment-form">
<textarea
name="content"
placeholder="댓글을 작성하세요..."
rows={3}
/>
{addComment.value?.fieldErrors?.content && (
<span class="error">{addComment.value.fieldErrors.content}</span>
)}
<button type="submit" disabled={addComment.isRunning}>
{addComment.isRunning ? '제출 중...' : '댓글 작성'}
</button>
</Form>

<div class="comment-list">
{comments.value.map((comment: any) => (
<div key={comment.id} class="comment">
<div class="comment-header">
<strong>{comment.author.name}</strong>
<time>{new Date(comment.createdAt).toLocaleString('ko-KR')}</time>
</div>
<p>{comment.content}</p>
</div>
))}
</div>
</section>
</article>
);
});

export const head: DocumentHead = ({ resolveValue }) => {
const post = resolveValue(usePost);
return {
title: post.title,
meta: [
{ name: 'description', content: post.excerpt },
{ property: 'og:title', content: post.title },
{ property: 'og:description', content: post.excerpt },
{ property: 'og:image', content: post.coverImage },
],
};
};

고수 팁 섹션

팁 1: sharedMap으로 Loader 간 데이터 공유

// 여러 loader가 같은 데이터를 공유할 때
export const useAuth = routeLoader$(async ({ cookie, sharedMap }) => {
const token = cookie.get('auth_token');
if (!token) return null;

const user = await verifyToken(token.value);

// sharedMap에 저장: 다른 loader에서 재사용 가능
sharedMap.set('currentUser', user);
return user;
});

export const useDashboard = routeLoader$(async ({ sharedMap }) => {
// useAuth가 먼저 실행되었다고 가정
const user = sharedMap.get('currentUser');
if (!user) return null;

// 같은 user를 다시 fetch하지 않음
return fetchDashboardData(user.id);
});

팁 2: 스트리밍 응답 (Streaming SSR)

// QwikCity는 HTML 스트리밍을 지원합니다
// routeLoader$의 데이터는 준비되는 즉시 클라이언트에 전송됩니다

// Suspense와 유사한 패턴
export const useFastData = routeLoader$(async () => {
return { message: '빠른 데이터' }; // 즉시 반환
});

export const useSlowData = routeLoader$(async () => {
await new Promise(r => setTimeout(r, 2000)); // 2초 지연
return { data: '느린 데이터' }; // 2초 후 스트리밍됨
});

// 빠른 데이터는 즉시 렌더링, 느린 데이터는 준비되면 자동 업데이트

팁 3: routeLoader$ 캐싱

export const useExpensiveData = routeLoader$(async ({ headers }) => {
const data = await expensiveOperation();

// 캐시 헤더 설정
headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');

return data;
});

팁 4: Action + Optimistic Update

export const useLikeAction = routeAction$(async ({ params }) => {
await likePost(params.id);
return { liked: true };
});

export const PostCard = component$<{ post: Post }>(({ post }) => {
const likeAction = useLikeAction();
// Optimistic Update: 서버 응답 전에 UI 즉시 업데이트
const isLiked = useComputed$(() =>
likeAction.value?.liked ?? post.isLiked
);
const likeCount = useComputed$(() =>
likeAction.value?.liked ? post.likeCount + 1 : post.likeCount
);

return (
<div>
<Form action={likeAction}>
<button type="submit">
{isLiked.value ? '♥' : '♡'} {likeCount.value}
</button>
</Form>
</div>
);
});

팁 5: URL 기반 상태 관리

// URL 쿼리 파라미터를 상태로 사용
export default component$(() => {
const location = useLocation();
const navigate = useNavigate();

// URL에서 상태 읽기
const filter = location.url.searchParams.get('filter') || 'all';
const sort = location.url.searchParams.get('sort') || 'newest';

const updateFilter = $((newFilter: string) => {
const url = new URL(location.url.toString());
url.searchParams.set('filter', newFilter);
navigate(url.toString());
});

return (
<div>
<select
value={filter}
onChange$={(e) => updateFilter((e.target as HTMLSelectElement).value)}
>
<option value="all">전체</option>
<option value="active">활성</option>
<option value="done">완료</option>
</select>
</div>
);
});

요약

기능파일/함수설명
페이지src/routes/*/index.tsx기본 export가 페이지 컴포넌트
레이아웃src/routes/*/layout.tsx<Slot />으로 자식 렌더링
동적 라우트src/routes/[param]/URL 파라미터
캐치올src/routes/[...path]/모든 하위 경로 매칭
서버 로더routeLoader$페이지 렌더링 전 서버에서 데이터 로드
폼 액션routeAction$폼 처리 / 서버 뮤테이션
REST APIonGet/onPost/...HTTP 메서드별 핸들러
서버 함수server$클라이언트에서 호출하는 서버 함수
미들웨어onRequest / plugin.ts요청 가로채기
프리페칭Link 컴포넌트서비스 워커 기반 자동 프리페칭

QwikCity는 파일 시스템만으로 풀스택 앱을 구성할 수 있는 강력한 도구입니다. 다음 장에서는 실전 고수 팁과 배포 전략을 학습합니다.

Advertisement