19.5 실전 고수 팁
Qwik을 실무에서 능숙하게 다루기 위한 고급 패턴과 모범 사례를 배웁니다. $ 사인의 완전한 이해, 성능 최적화, 배포 전략, 그리고 JavaScript 커리큘럼 완주를 축하합니다.
$ 사인 위치 규칙 완전 정복
규칙 요약표
위치 허용 여부 이유
────────────────────────────────────────────────────────
모듈 최상위 레벨 ✓ 파일 단위 청크 분리 가능
component$ 내부 최상위 ✓ 컴포넌트 렌더 함수 최상위
조건문 안 ✗ 청크 위치가 런타임에 결정됨
반복문 안 ✗ 동적 청크 수, 최적화 불가
함수 내부 ✗ 중첩 스코프에서 직렬화 불가
올바른 패턴 예시
import { component$, useSignal, $, useTask$ } from '@builder.io/qwik';
// ✓ 모듈 최상위 레벨의 $ 함수
export const sharedHandler = $(() => {
console.log('공유 핸들러 - 청크로 분리됨');
});
// ✓ 컴포넌트 내부 최상위
export const GoodComponent = component$(() => {
const count = useSignal(0);
const visible = useSignal(true);
// ✓ 컴포넌트 렌더 함수 최상위
const increment = $(() => count.value++);
const toggle = $(() => visible.value = !visible.value);
// ✓ useTask$도 최상위에서만
useTask$(({ track }) => {
track(() => count.value);
console.log('카운트 변경:', count.value);
});
// ✗ 아래는 안됨
// if (visible.value) {
// const badHandler = $(() => {}); // 조건문 안에서 $ 사용 불가
// }
return (
<div>
<button onClick$={increment}>{count.value}</button>
{/* ✓ JSX 인라인 $ 함수는 항상 렌더링되므로 OK */}
{visible.value && (
<button onClick$={() => count.value--}>감소</button>
)}
</div>
);
});
$ 사인 종류별 역할
// 1. component$: 컴포넌트 정의 (가장 큰 단위의 청크)
export const MyComp = component$(() => { ... });
// 2. 이벤트 핸들러의 $: 이벤트 청크
<button onClick$={() => { ... }} />
<input onInput$={(e) => { ... }} />
<form onSubmit$={() => { ... }} />
// 3. useTask$: 이펙트 청크
useTask$(({ track }) => { ... });
// 4. useVisibleTask$: 클라이언트 이펙트 청크
useVisibleTask$(() => { ... });
// 5. useComputed$: 계산 청크
const result = useComputed$(() => { ... });
// 6. $(): 임의 함수를 청크로 분리
const myFn = $(() => { ... });
// 7. routeLoader$, routeAction$, server$: 서버 청크
export const useData = routeLoader$(async () => { ... });
const fetchData = server$(async () => { ... });
Closure 직렬화 문제
문제: 외부 변수 참조
// 문제가 생기는 상황
export const BadExample = component$(() => {
let counter = 0; // 일반 변수 (직렬화 불가)
// 이 클로저는 counter를 캡처하지만
// counter는 직렬화할 수 없으므로 재개 불가
return (
<button onClick$={() => {
counter++; // ✗ 일반 변수 캡처 - 재개 후 값 손실
console.log(counter);
}}>
클릭
</button>
);
});
// 해결책: Signal/Store 사용
export const GoodExample = component$(() => {
const counter = useSignal(0); // Signal (직렬화 가능)
return (
<button onClick$={() => {
counter.value++; // ✓ Signal 직접 변경
console.log(counter.value);
}}>
클릭
</button>
);
});
직렬화 불가 외부 값 처리
import { component$, useSignal, useVisibleTask$, noSerialize } from '@builder.io/qwik';
import type { NoSerialize } from '@builder.io/qwik';
interface State {
// noSerialize로 마킹된 타입
socket: NoSerialize<WebSocket> | undefined;
messages: string[];
}
export const WebSocketDemo = component$(() => {
const state = useStore<State>({
socket: undefined,
messages: [],
});
// WebSocket은 직렬화 불가 → noSerialize + useVisibleTask$
useVisibleTask$(({ cleanup }) => {
const ws = new WebSocket('wss://echo.websocket.org');
ws.onmessage = (event) => {
state.messages.push(event.data);
};
// noSerialize로 WebSocket 인스턴스 저장
state.socket = noSerialize(ws);
cleanup(() => ws.close());
});
return (
<div>
<button onClick$={() => {
state.socket?.send('안녕하세요!');
}}>
메시지 전송
</button>
<ul>
{state.messages.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</div>
);
});
QRL 클로저 캡처 규칙
export const ClosureRules = component$(() => {
// ✓ useSignal/useStore 값은 자동으로 캡처됨
const count = useSignal(0);
const user = useStore({ name: '홍길동' });
// ✓ 상수 원시값은 캡처 가능
const MAX = 100;
// ✗ 함수 결과물을 일반 변수에 담으면 직렬화 불가
// const today = new Date(); // Date 객체 → 주의
// ✓ Signal에 직렬화 가능한 형태로 저장
const todayStr = useSignal(new Date().toISOString());
return (
<button onClick$={() => {
// count: Signal → 직렬화됨 ✓
// user: Store → 직렬화됨 ✓
// MAX: 원시값 → 직렬화됨 ✓
if (count.value < MAX) {
count.value++;
console.log(user.name, todayStr.value);
}
}}>
{count.value}/{MAX}
</button>
);
});
아일랜드 아키텍처(Islands Architecture)
Qwik의 접근 방식
아일랜드 아키텍처는 정적 HTML의 바다(sea)에 인터랙티브 컴포넌트 섬(island)들이 떠 있는 형태입니다.
전통적 아일랜드 (Astro, Marko):
정적 HTML ──────────────────────────────────────
[Island A] [Island B]
React 컴포넌트 Vue 컴포넌트
하이드레이션필요 하이드레이션필요
Qwik의 접근:
정적 HTML + Qwik 직렬화 ──────────────────────────
전체 페이지가 아일랜드! 각 이벤트 핸들러가 개별 청크
버튼 클릭 청크 입력 핸들러 청크 폼 제출 청크
(하이드레이션 없음, 필요 시 로드)
다른 아일랜드 프레임워크 비교
프레임워크 아일랜드 단위 하이드레이션 주 언어
────────────────────────────────────────────────────────
Astro 컴포넌트 부분 하이드레이션 무관 (React/Vue/Svelte)
Marko 컴포넌트 스트리밍 하이드레이션 Marko
Fresh (Deno) 컴포넌트 전통 하이드레이션 Preact
Qwik 이벤트 핸들러 없음 (재개) TypeScript/JSX
Qwik에서 의도적 아일랜드 패턴
// Qwik에서 무거운 인터랙티브 컴포넌트를 격리
export const HeavyChart = component$(() => {
// 이 컴포넌트는 사용자가 클릭하기 전까지 JS가 로드되지 않음
return <canvas id="chart" />;
});
export const StaticPage = component$(() => {
return (
<main>
{/* 정적 콘텐츠: JS 불필요 */}
<h1>블로그 포스트 제목</h1>
<p>긴 정적 텍스트 내용...</p>
{/* 인터랙티브 아일랜드: 클릭 시에만 JS 로드 */}
<HeavyChart />
{/* 또 다른 아일랜드 */}
<CommentSection />
</main>
);
});
이미지 최적화
@unpic/qwik 사용
npm install @unpic/qwik
import { component$ } from '@builder.io/qwik';
import { Image } from '@unpic/qwik';
export const OptimizedImage = component$(() => {
return (
<div>
{/* 자동 크기 최적화, WebP 변환, lazy loading */}
<Image
src="https://example.com/photo.jpg"
layout="constrained"
width={800}
height={600}
alt="최적화된 이미지"
priority={false} // 중요 이미지는 true
/>
{/* 반응형 이미지 */}
<Image
src="/hero.jpg"
layout="fullWidth"
aspectRatio={16 / 9}
alt="히어로 이미지"
priority={true} // LCP 이미지는 priority
/>
</div>
);
});
Qwik 기본 이미지 최적화
// vite.config.ts에서 이미지 최적화 플러그인 추가
import { qwikVite } from '@builder.io/qwik/optimizer';
export default defineConfig(() => ({
plugins: [
qwikCity(),
qwikVite({
// 이미지 최적화 옵션 (실험적)
}),
],
}));
// 수동 이미지 최적화 패턴
export const LazyImage = component$<{
src: string;
alt: string;
width: number;
height: number;
}>(({ src, alt, width, height }) => {
const isLoaded = useSignal(false);
return (
<div
style={{ width, height }}
class={`image-wrapper ${isLoaded.value ? 'loaded' : 'loading'}`}
>
<img
src={src}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
onLoad$={() => isLoaded.value = true}
style={{ opacity: isLoaded.value ? 1 : 0, transition: 'opacity 0.3s' }}
/>
</div>
);
});
성능 측정
Lighthouse로 Qwik 앱 분석
# 빌드 후 Lighthouse 측정
npm run build
npm run preview
# Chrome DevTools → Lighthouse 탭:
# Performance 점수 확인 항목:
# - FCP (First Contentful Paint): 목표 < 1.8s
# - LCP (Largest Contentful Paint): 목표 < 2.5s
# - TBT (Total Blocking Time): Qwik은 0에 가까워야 함
# - CLS (Cumulative Layout Shift): 목표 < 0.1
# - TTI (Time to Interactive): Qwik은 FCP와 거의 동일해야 함
번들 분석
# rollup-plugin-visualizer 설치
npm install -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig(() => ({
plugins: [
qwikCity(),
qwikVite(),
process.env.ANALYZE && visualizer({
open: true,
gzipSize: true,
brotliSize: true,
filename: 'dist/stats.html',
}),
],
}));
# 번들 분석 실행
ANALYZE=true npm run build
# dist/stats.html 브라우저에서 열기
런타임 성능 측정
// 컴포넌트 렌더 성능 측정
export const PerfMonitor = component$(() => {
useVisibleTask$(() => {
// Performance API 활용
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'largest-contentful-paint') {
console.log('LCP:', entry.startTime.toFixed(2), 'ms');
}
if (entry.entryType === 'first-input') {
console.log('FID:', (entry as any).processingStart - entry.startTime, 'ms');
}
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
observer.observe({ type: 'first-input', buffered: true });
return () => observer.disconnect();
});
return null;
});
// Web Vitals 라이브러리 사용
import { onCLS, onFID, onFCP, onLCP, onTTFB } from 'web-vitals';
useVisibleTask$(() => {
onCLS(({ value }) => console.log('CLS:', value));
onFCP(({ value }) => console.log('FCP:', value, 'ms'));
onLCP(({ value }) => console.log('LCP:', value, 'ms'));
onTTFB(({ value }) => console.log('TTFB:', value, 'ms'));
});
배포 전략
Vercel 배포
# Vercel Edge Functions 어댑터 추가
npm run qwik add vercel-edge
# Vercel CLI로 배포
npm install -g vercel
vercel --prod
// vite.config.ts (Vercel 어댑터 적용 후)
import { vercelEdgeAdapter } from '@builder.io/qwik-city/adapters/vercel-edge/vite';
export default defineConfig(() => ({
plugins: [
qwikCity(),
qwikVite(),
vercelEdgeAdapter(),
],
}));
Cloudflare Pages 배포
# Cloudflare Pages 어댑터 추가
npm run qwik add cloudflare-pages
# Wrangler로 배포
npm install -g wrangler
wrangler pages deploy dist/client
// wrangler.toml
[site]
bucket = "./dist/client"
[env.production]
name = "my-qwik-app"
Cloudflare Workers 배포
// vite.config.ts
import { cloudflarePagesAdapter } from '@builder.io/qwik-city/adapters/cloudflare-pages/vite';
export default defineConfig(() => ({
plugins: [
qwikCity(),
qwikVite(),
cloudflarePagesAdapter({
// Cloudflare Workers 설정
workerUrl: './src/workers/worker.ts',
}),
],
}));
Node.js 서버 배포 (Express)
npm run qwik add express
npm run build
node dist/server/entry.express.js
// src/entry.express.ts (어댑터 추가 후 자동 생성)
import { createQwikCity } from '@builder.io/qwik-city/middleware/express';
import express from 'express';
import { manifest } from '@qwik-client-manifest';
import render from './entry.ssr';
const app = express();
const { router, notFound } = createQwikCity({ render, manifest });
app.use(express.static('dist/client'));
app.use(router);
app.use(notFound);
app.listen(3000, () => console.log('http://localhost:3000'));
Docker 배포
# Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
EXPOSE 3000
ENV PORT=3000
CMD ["node", "dist/server/entry.express.js"]
# docker-compose.yml
version: '3.8'
services:
qwik-app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- API_KEY=${API_KEY}
restart: unless-stopped
TypeScript 팁
QwikJSX 타입 시스템
import type {
QwikIntrinsicElements, // 모든 HTML 요소 타입 (Qwik 버전)
QwikHTMLAttributes, // HTML 공통 속성
QwikMouseEvent, // Qwik 마우스 이벤트
QwikKeyboardEvent, // Qwik 키보드 이벤트
JSXChildren, // children 타입
} from '@builder.io/qwik';
// 커스텀 HTML 속성 확장
declare module '@builder.io/qwik' {
interface QwikIntrinsicElements {
'custom-element': QwikHTMLAttributes<HTMLElement> & {
'data-custom': string;
};
}
}
Signal 타입 안전성
import type { Signal, ReadonlySignal } from '@builder.io/qwik';
// 읽기 전용 Signal (prop으로 전달 시)
interface DisplayProps {
value: ReadonlySignal<number>; // 자식이 값 변경 불가
}
// 읽기/쓰기 Signal (양방향 바인딩)
interface InputProps {
model: Signal<string>; // 자식이 값 변경 가능
}
// 컴포넌트 타입 정의
type ButtonProps = {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: Signal<boolean> | boolean; // Signal 또는 일반값 모두 허용
onClick$?: PropFunction<(event: QwikMouseEvent) => void>;
};
component$ 타입 패턴
import { component$ } from '@builder.io/qwik';
import type { Component, PropFunction } from '@builder.io/qwik';
// 제네릭 컴포넌트
interface ListProps<T> {
items: T[];
renderItem$: PropFunction<(item: T, index: number) => JSXNode>;
keyExtractor: (item: T) => string | number;
}
// 주의: component$는 제네릭 타입 파라미터를 직접 지원하지 않음
// 타입 단언(Type Assertion)으로 처리
export const GenericList = component$(<T,>({
items,
renderItem$,
keyExtractor,
}: ListProps<T>) => {
return (
<ul>
{items.map((item, i) => (
<li key={keyExtractor(item)}>
{/* JSX에서 함수 직접 호출 불가 → 컴포넌트로 분리 */}
</li>
))}
</ul>
);
}) as unknown as Component<ListProps<any>>;
routeLoader$ 타입 추론
// 반환 타입 자동 추론
export const useUser = routeLoader$(async ({ params }) => {
return {
id: params.id,
name: '홍길동',
email: 'hong@example.com',
};
});
// 사용 시 자동 타입 추론
export default component$(() => {
const user = useUser();
// user.value의 타입: { id: string; name: string; email: string; }
console.log(user.value.name); // ✓ 타입 안전
console.log(user.value.phone); // ✗ 타입 에러
});
Qwik vs 다른 프레임워크 마이그레이션
React에서 Qwik으로
// React 컴포넌트
function Counter({ initialCount = 0 }: { initialCount?: number }) {
const [count, setCount] = useState(initialCount);
const [doubled, setDoubled] = useState(initialCount * 2);
useEffect(() => {
setDoubled(count * 2);
}, [count]);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<p>두 배: {doubled}</p>
</div>
);
}
// Qwik 변환
const Counter = component$<{ initialCount?: number }>(({ initialCount = 0 }) => {
const count = useSignal(initialCount);
const doubled = useComputed$(() => count.value * 2);
return (
<div>
<button onClick$={() => count.value++}>{count.value}</button>
<p>두 배: {doubled.value}</p>
</div>
);
});
마이그레이션 치트 시트
React → Qwik 변환표
──────────────────────────────────────────────────────────
useState(val) → useSignal(val)
useState({...}) → useStore({...})
useReducer → useStore + $ 함수
useEffect(() => {}) → useTask$(({ track }) => {})
useEffect([dep]) → useTask$(({ track }) => { track(() => dep); })
useMemo(() => val, [dep]) → useComputed$(() => val)
useCallback(() => fn) → $ (useCallback 불필요)
useRef → useSignal<Element>()
React.memo → Qwik은 자동 (컴포넌트 재실행 없음)
useContext/createContext → createContextId/useContext/useContextProvider
useLayoutEffect → useVisibleTask$({ strategy: 'document-ready' })
Qwik 에코시스템
qwik-ui (컴포넌트 라이브러리)
npm install @qwik-ui/headless
# 또는 tailwind 스타일드
npm install @qwik-ui/styled
// Qwik UI 컴포넌트 사용 예시
import { component$ } from '@builder.io/qwik';
import { Modal, ModalContent, ModalHeader, ModalTrigger } from '@qwik-ui/headless';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@qwik-ui/headless';
export const UIExample = component$(() => {
return (
<div>
{/* 모달 */}
<Modal>
<ModalTrigger>
<button>모달 열기</button>
</ModalTrigger>
<ModalContent>
<ModalHeader>제목</ModalHeader>
<p>모달 내용</p>
</ModalContent>
</Modal>
{/* 아코디언 */}
<Accordion>
<AccordionItem value="item1">
<AccordionTrigger>질문 1</AccordionTrigger>
<AccordionContent>답변 1</AccordionContent>
</AccordionItem>
<AccordionItem value="item2">
<AccordionTrigger>질문 2</AccordionTrigger>
<AccordionContent>답변 2</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
});
qwik-speak (i18n 국제화)
npm install qwik-speak
// src/speak-config.ts
import type { SpeakConfig } from 'qwik-speak';
export const config: SpeakConfig = {
defaultLocale: { lang: 'ko', currency: 'KRW', timeZone: 'Asia/Seoul' },
supportedLocales: [
{ lang: 'ko', currency: 'KRW', timeZone: 'Asia/Seoul' },
{ lang: 'en', currency: 'USD', timeZone: 'America/New_York' },
{ lang: 'ja', currency: 'JPY', timeZone: 'Asia/Tokyo' },
],
assets: ['app', 'common'],
runtimeAssets: ['runtime'],
};
// public/i18n/ko/app.json
{
"app": {
"title": "내 앱",
"welcome": "안녕하세요, {{name}}!",
"itemCount": "{{count}}개의 항목"
}
}
// 컴포넌트에서 사용
import { component$ } from '@builder.io/qwik';
import { useTranslate, useSpeakContext } from 'qwik-speak';
export const I18nExample = component$(() => {
const t = useTranslate();
return (
<div>
<h1>{t('app.title')}</h1>
<p>{t('app.welcome', { name: '홍길동' })}</p>
<p>{t('app.itemCount', { count: 5 }, 5)}</p>
</div>
);
});
Partytown 통합 (서드파티 스크립트 최적화)
// src/root.tsx
import { component$ } from '@builder.io/qwik';
import { QwikPartytown } from '@builder.io/qwik-city';
export default component$(() => {
return (
<html>
<head>
{/* Google Analytics를 Web Worker에서 실행 */}
<QwikPartytown forward={['dataLayer.push']} />
<script
async
type="text/partytown"
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
/>
</head>
<body>
<RouterOutlet />
</body>
</html>
);
});
주요 커뮤니티 라이브러리
패키지 설명
─────────────────────────────────────────────────────────
@qwik-ui/headless 헤드리스 UI 컴포넌트
@qwik-ui/styled 스타일드 UI 컴포넌트 (Tailwind)
qwik-speak i18n 국제화
@unpic/qwik 이미지 최적화
qwik-pwa PWA 지원 (서비스 워커)
@qwikdev/astro Astro + Qwik 통합
qwik-jwt JWT 처리 유틸리티
qwik-supabase Supabase 통합
qwik-firebase Firebase 통합
실전 프로젝트: 이커머스 상품 페이지
// src/routes/shop/[productId]/index.tsx
import { component$, useSignal, useStore } 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 useProduct = routeLoader$(async ({ params, error }) => {
const product = await fetchProduct(params.productId);
if (!product) throw error(404, '상품을 찾을 수 없습니다');
return product;
});
export const useRelatedProducts = routeLoader$(async ({ params }) => {
const product = await fetchProduct(params.productId);
return fetchRelatedProducts(product.categoryId, { limit: 4 });
});
// 장바구니 추가 액션
export const useAddToCart = routeAction$(
async (data, { cookie }) => {
const cartId = cookie.get('cart_id')?.value || createCartId();
await addToCart(cartId, data);
cookie.set('cart_id', cartId, { path: '/', maxAge: 7 * 24 * 60 * 60 });
return { success: true };
},
zod$({
productId: z.string(),
quantity: z.number().min(1).max(99),
variant: z.string().optional(),
})
);
export default component$(() => {
const product = useProduct();
const related = useRelatedProducts();
const addToCart = useAddToCart();
const selectedImage = useSignal(0);
const quantity = useSignal(1);
const selectedVariant = useSignal(product.value.variants?.[0]?.id || '');
return (
<div class="product-page">
{/* 이미지 갤러리 */}
<div class="gallery">
<img
src={product.value.images[selectedImage.value]}
alt={product.value.name}
width={600}
height={600}
/>
<div class="thumbnails">
{product.value.images.map((img: string, i: number) => (
<img
key={i}
src={img}
alt={`${product.value.name} ${i + 1}`}
width={80}
height={80}
class={selectedImage.value === i ? 'active' : ''}
onClick$={() => selectedImage.value = i}
/>
))}
</div>
</div>
{/* 상품 정보 */}
<div class="product-info">
<h1>{product.value.name}</h1>
<p class="price">
{product.value.price.toLocaleString('ko-KR')}원
</p>
{/* 옵션 선택 */}
{product.value.variants && (
<div class="variants">
<label>옵션 선택</label>
{product.value.variants.map((v: any) => (
<button
key={v.id}
class={selectedVariant.value === v.id ? 'selected' : ''}
disabled={v.stock === 0}
onClick$={() => selectedVariant.value = v.id}
>
{v.name} {v.stock === 0 ? '(품절)' : ''}
</button>
))}
</div>
)}
{/* 수량 선택 */}
<div class="quantity">
<button onClick$={() => quantity.value = Math.max(1, quantity.value - 1)}>-</button>
<span>{quantity.value}</span>
<button onClick$={() => quantity.value = Math.min(99, quantity.value + 1)}>+</button>
</div>
{/* 장바구니 추가 */}
<Form action={addToCart}>
<input type="hidden" name="productId" value={product.value.id} />
<input type="hidden" name="quantity" value={quantity.value} />
{selectedVariant.value && (
<input type="hidden" name="variant" value={selectedVariant.value} />
)}
<button
type="submit"
disabled={addToCart.isRunning}
class="add-to-cart"
>
{addToCart.isRunning ? '추가 중...' : '장바구니 담기'}
</button>
</Form>
{addToCart.value?.success && (
<div class="success-message">장바구니에 추가되었습니다!</div>
)}
<div class="description"
dangerouslySetInnerHTML={product.value.description}
/>
</div>
{/* 연관 상품 */}
<section class="related-products">
<h2>관련 상품</h2>
<div class="product-grid">
{related.value.map((item: any) => (
<Link key={item.id} href={`/shop/${item.id}`} class="product-card">
<img src={item.thumbnail} alt={item.name} width={200} height={200} />
<h3>{item.name}</h3>
<p>{item.price.toLocaleString('ko-KR')}원</p>
</Link>
))}
</div>
</section>
</div>
);
});
export const head: DocumentHead = ({ resolveValue }) => {
const product = resolveValue(useProduct);
return {
title: `${product.name} - My Shop`,
meta: [
{ name: 'description', content: product.shortDescription },
{ property: 'og:title', content: product.name },
{ property: 'og:image', content: product.images[0] },
{ property: 'product:price:amount', content: String(product.price) },
{ property: 'product:price:currency', content: 'KRW' },
],
};
};
고수 팁 섹션
팁 1: 서버 코드 완전 격리
// server$로 DB 쿼리 격리 — 클라이언트에 절대 노출 안됨
import { server$ } from '@builder.io/qwik-city';
import { db } from '~/server/db'; // 서버 전용 모듈
export const queryUsers = server$(async function(searchTerm: string) {
// 이 코드는 빌드 시 서버 번들로만 분리됨
// 클라이언트 번들에 절대 포함되지 않음
const users = await db.query(
'SELECT * FROM users WHERE name LIKE ?',
[`%${searchTerm}%`]
);
return users;
});
팁 2: 조건부 컴포넌트 최적화
// 조건부 렌더링 최적화
export const Conditional = component$(() => {
const show = useSignal(false);
return (
<div>
<button onClick$={() => show.value = !show.value}>
토글
</button>
{/* 방법 1: 기본 조건부 렌더링 */}
{show.value && <HeavyComponent />}
{/* 방법 2: 항상 DOM에 두고 visibility로 숨기기
(깜빡임 없지만 항상 JS 로드) */}
<div style={{ display: show.value ? 'block' : 'none' }}>
<AnotherComponent />
</div>
</div>
);
});
팁 3: 전역 상태 관리 패턴
// src/context/app-context.ts
import { createContextId } from '@builder.io/qwik';
export interface AppContext {
user: { id: string; name: string } | null;
theme: 'light' | 'dark';
notifications: number;
}
export const AppContextId = createContextId<AppContext>('app-context');
// 루트 레이아웃에서 Provider 설정
export const RootLayout = component$(() => {
const ctx = useStore<AppContext>({
user: null,
theme: 'light',
notifications: 0,
});
useContextProvider(AppContextId, ctx);
return <Slot />;
});
// 어느 자식 컴포넌트에서나 사용
export const UserBadge = component$(() => {
const ctx = useContext(AppContextId);
return (
<div>
{ctx.user?.name || '로그인 필요'}
{ctx.notifications > 0 && (
<span class="badge">{ctx.notifications}</span>
)}
</div>
);
});
팁 4: 에러 바운더리
// src/components/error-boundary/error-boundary.tsx
// Qwik은 에러 바운더리를 라우트 수준에서 처리
// src/routes/[...path]/index.tsx 에서 404 처리
// src/routes/error/index.tsx 에서 전역 에러 처리
// 커스텀 에러 처리 컴포넌트
export const SafeComponent = component$<{ fallback?: string }>(({ fallback = '오류 발생' }) => {
const error = useSignal<string | null>(null);
useVisibleTask$(({ cleanup }) => {
const handler = (e: ErrorEvent) => {
error.value = e.message;
};
window.addEventListener('error', handler);
cleanup(() => window.removeEventListener('error', handler));
});
if (error.value) {
return <div class="error-state">{fallback}: {error.value}</div>;
}
return <Slot />;
});
팁 5: 디버깅 도구
// 개발 환경 디버그 유틸리티
export const DebugSignal = component$<{ label: string; signal: Signal<any> }>(
({ label, signal }) => {
if (!import.meta.env.DEV) return null;
return (
<div style={{
position: 'fixed', bottom: 0, right: 0,
background: 'black', color: 'lime',
padding: '4px 8px', fontSize: '12px'
}}>
{label}: {JSON.stringify(signal.value)}
</div>
);
}
);
// 사용 예
<DebugSignal label="count" signal={count} />
Ch19 전체 요약
| 챕터 | 주제 | 핵심 개념 |
|---|---|---|
| 19.1 | Qwik 소개 | 재개 가능성, O(1) 로딩, $ 사인 원리 |
| 19.2 | 환경 설정 | QwikCity 프로젝트, Optimizer, TypeScript |
| 19.3 | 기본 개념 | useSignal, useStore, useTask$, useResource$ |
| 19.4 | Qwik City | 파일 라우팅, routeLoader$, routeAction$, API |
| 19.5 | 실전 고수 팁 | 클로저, 배포, 에코시스템, 성능 최적화 |
Qwik의 강점 요약
1. O(1) 초기 로딩 — 앱 크기와 무관
2. 제로 하이드레이션 — 재개 가능성
3. 세밀한 반응성 — 필요한 DOM만 업데이트
4. 완전한 TypeScript 지원
5. 풀스택 (QwikCity) — routeLoader$, routeAction$, API 라우트
6. Progressive Enhancement — JS 없이도 폼/링크 동작
7. 서비스 워커 기반 프리페칭
8. Cloudflare Workers 네이티브 지원
JavaScript 커리큘럼 완주를 축하합니다!
여기까지 오신 여러분, 정말 대단합니다! JavaScript 커리큘럼 전체 19챕터를 완주하셨습니다.
완주한 커리큘럼 전체 여정
Ch1 - JavaScript 기초 (변수, 타입, 연산자)
Ch2 - 변수, 타입, 연산자 (ES6+ 문법)
Ch3 - 제어 흐름과 반복 (조건문, 반복문, 배열 메서드)
Ch4 - 함수 심화 (클로저, this, 제네레이터)
Ch5 - 객체, 클래스, 프로토타입 (OOP, Symbol, Proxy)
Ch6 - 비동기 프로그래밍 (Promise, async/await, 이벤트 루프)
Ch7 - 모던 JavaScript (ES2020-2024, 정규식)
Ch8 - 브라우저 API와 DOM (DOM, 이벤트, Fetch, Storage)
Ch9 - Node.js 실전 (HTTP, Express, 스트림)
Ch10 - React 기초 (컴포넌트, 훅, JSX)
Ch11 - React 심화 (고급 훅, 상태 관리, 성능)
Ch12 - Next.js 기초 (App Router, RSC, 데이터 패칭)
Ch13 - Next.js 심화 (Server Actions, 인증, 배포)
Ch14 - Vue.js 3 (Composition API, Pinia, Vue Router)
Ch15 - Nuxt 3 (파일 라우팅, SSR, 배포)
Ch16 - Angular (컴포넌트, DI, RxJS, Signals)
Ch17 - Svelte 5 (Runes, SvelteKit, 반응성)
Ch18 - Solid.js (세밀한 반응성, SolidStart)
Ch19 - Qwik (재개 가능성, O(1) 로딩, QwikCity) ← 지금 여기!
다음 단계 추천
이제 여러분은 JavaScript 프론트엔드의 전체 지형도를 이해합니다. 다음으로 나아갈 방향을 제안합니다:
심화 학습
- TypeScript 심화 (제네릭, 고급 타입, 데코레이터)
- 테스팅 (Vitest, Playwright, Testing Library)
- 성능 최적화 (Core Web Vitals, 번들 최적화)
- WebAssembly (Rust + wasm-bindgen)
특화 방향
- 풀스택: Node.js + Prisma + tRPC + Next.js
- 모바일: React Native 또는 Ionic
- 데스크탑: Tauri (Rust + Qwik/React)
- 3D/게임: Three.js, Babylon.js
- AI/ML: TensorFlow.js, Transformers.js
커리어
- 오픈소스 기여 (Qwik GitHub: github.com/BuilderIO/qwik)
- 포트폴리오 프로젝트 완성
- 기술 블로그 운영
- 컨퍼런스 발표
긴 여정을 완주하신 여러분께 진심으로 박수를 보냅니다. 코드로 세상을 바꾸는 개발자가 되시길 응원합니다!