본문으로 건너뛰기

19.1 Qwik 소개

Qwik은 웹 성능의 패러다임을 바꾸기 위해 설계된 차세대 JavaScript 프레임워크입니다. 기존 프레임워크들이 갖는 "하이드레이션(Hydration)" 비용을 완전히 제거하고, 어떤 앱 크기에서도 즉각적인 초기 로딩을 보장합니다.


Qwik란 무엇인가?​

Qwik 은 2021년 Misko Hevery가 창안한 오픈소스 웹 프레임워크입니다. Misko는 Google에서 AngularJS(현재 Angular)를 만든 개발자로, 프레임워크 설계 분야의 세계적 권위자입니다.

그는 Angular와 React를 수년간 사용하면서 하나의 근본적인 질문에 부딪혔습니다.

"왜 우리는 서버에서 이미 렌더링된 HTML을 브라우저에서 또 다시 실행해야 하는가?"

이 질문이 Qwik 탄생의 출발점이었습니다.

Qwik의 핵심 철학​

Qwik의 핵심은 재개 가능성(Resumability) 입니다. 서버가 앱의 전체 상태를 HTML에 직렬화(serialize)하여 내보내고, 브라우저는 그 상태를 재개(resume) 합니다. 처음부터 다시 실행하는 것이 아닙니다.

기존 방식 (Hydration):
서버: HTML 렌더링 → 브라우저: HTML 표시 → JS 다운로드 → JS 파싱 →
앱 재실행 (재렌더링) → 이벤트 연결 → 인터랙티브

Qwik 방식 (Resumability):
서버: HTML 렌더링 + 상태 직렬화 → 브라우저: HTML 표시 → 즉시 인터랙티브
(JS는 사용자가 실제로 상호작용할 때만 로드)

재개 가능성(Resumability) vs 하이드레이션(Hydration)​

하이드레이션이란?​

하이드레이션은 서버에서 렌더링된 "정적" HTML을 브라우저에서 동적으로 만드는 과정입니다.

1. 서버가 HTML을 생성하여 브라우저에 전달
2. 브라우저가 HTML을 화면에 표시 (빠름 - FCP 달성)
3. 브라우저가 JavaScript 번들을 다운로드 (느릴 수 있음)
4. JavaScript를 파싱하고 실행
5. 가상 DOM을 재구성
6. 실제 DOM과 가상 DOM을 비교 (reconciliation)
7. 이벤트 핸들러를 DOM 노드에 연결
8. 이제서야 사용자가 클릭/입력 가능 (TTI 달성)

이 과정에서 3~8단계 가 "하이드레이션 비용"입니다. 앱이 클수록 이 비용도 커집니다.

하이드레이션의 문제점​

// Next.js (React) 방식의 하이드레이션 문제 예시
// 서버에서 렌더링된 HTML:
// <div id="root"><h1>안녕하세요</h1><button>클릭</button></div>

// 브라우저에서 해야 하는 일:
import React from 'react'; // ~40KB
import ReactDOM from 'react-dom'; //
import App from './App'; // 앱 전체 코드

// 앱 전체를 다시 실행
ReactDOM.hydrateRoot(
document.getElementById('root'),
<App /> // 서버에서 이미 렌더링했지만 브라우저에서 재실행
);

// 결과: 버튼 하나를 클릭하기 위해 수백 KB의 JS를 실행해야 함

실제 비용:

  • Amazon의 경우 하이드레이션에 수초가 걸릴 수 있음
  • 저사양 기기(모바일 중저가)에서 3~5배 느림
  • TTI(Time to Interactive) 지연 = 이탈율 증가
  • Core Web Vitals 점수 하락

재개 가능성의 원리​

Qwik은 서버가 앱의 실행 상태 전체를 HTML에 포함 시킵니다.

<!-- Qwik이 생성하는 HTML 예시 -->
<html>
<body>
<div q:container="paused" q:version="1.0" q:render="ssr">
<!-- 컴포넌트 트리가 직렬화됨 -->
<button
on:click="./chunk-abc123.js#Counter_onClick"
q:id="1"
>
카운트: 0
</button>
</div>

<!-- 상태와 컨텍스트가 JSON으로 직렬화됨 -->
<script type="qwik/json">
{
"refs": {"1": "0"},
"ctx": {},
"objs": [0]
}
</script>

<!-- 이벤트 맵 직렬화 -->
<script id="qwikloader">/* 최소 1KB 로더 */</script>
</body>
</html>

브라우저가 이 HTML을 받으면:

  1. 화면에 즉시 표시 (FCP)
  2. qwikloader(~1KB)만 실행
  3. 사용자가 버튼을 클릭하면, 그 버튼의 핸들러 코드만 레이지 로드
  4. 상태는 이미 HTML에 있으므로 재실행 불필요

O(1) 로딩 개념​

Qwik의 가장 혁신적인 특성은 O(1) 초기 로딩 입니다.

기존 프레임워크의 로딩 복잡도​

React/Next.js: O(n) - 앱 크기에 비례하여 하이드레이션 시간 증가
Vue/Nuxt: O(n) - 동일
Svelte: O(n) - 번들 크기는 작지만 하이드레이션 필요
Angular: O(n) - 가장 큰 번들, 가장 느린 하이드레이션

Qwik: O(1) - 앱 크기와 무관한 초기 로딩 시간

O(1)이 가능한 이유​

// Qwik 컴포넌트 예시
import { component$, useSignal } from '@builder.io/qwik';

export default component$(() => {
const count = useSignal(0);

// onClick$의 $ 표시 = 이 함수를 별도 청크로 분리
// 브라우저는 클릭이 발생할 때까지 이 코드를 다운로드하지 않음
return (
<button onClick$={() => count.value++}>
카운트: {count.value}
</button>
);
});

Qwik Optimizer(빌드 도구)가 위 코드를 변환하면:

// 메인 청크 (초기 로드 불필요, qwikloader가 처리)
// chunk-main.js (거의 비어있음)

// 이벤트 핸들러 청크 (클릭 시에만 로드)
// chunk-abc123.js
export const Counter_onClick = () => {
// count.value++ 코드
};

결과: 버튼이 100개든 1000개든, 초기 페이지 로드 시 실행되는 JS는 ~1KB qwikloader뿐입니다.


기존 SSR 프레임워크의 하이드레이션 문제점 심층 분석​

문제 1: 이중 렌더링(Double Rendering)​

서버: 전체 앱 렌더링 → HTML 전송
브라우저: 전체 앱 다시 렌더링 (하이드레이션을 위해)
→ CPU 자원 낭비, 시간 낭비

문제 2: 워터폴 로딩​

HTML 수신 → CSS 로드 → JS 번들 다운로드 → JS 파싱 →
React 초기화 → 컴포넌트 트리 구성 → DOM 비교 → 이벤트 바인딩
↑ 이 전체 과정이 순차적으로 발생

문제 3: 부분 하이드레이션의 한계​

일부 프레임워크는 "아일랜드 아키텍처"로 부분 하이드레이션을 구현하지만:

// Astro의 아일랜드 방식 (부분 하이드레이션)
---
import Counter from './Counter.jsx';
---
<html>
<body>
<h1>정적 콘텐츠</h1>
<!-- 이 컴포넌트만 하이드레이션 -->
<Counter client:visible />
</body>
</html>

// 문제: Counter 컴포넌트가 복잡하면 그 하이드레이션 비용은 여전히 O(n)
// 게다가 컴포넌트 간 상태 공유가 어려움

Qwik은 이를 컴포넌트 단위가 아닌 이벤트 핸들러 단위 로 세분화합니다.


Qwik의 직렬화(Serialization) 원리​

직렬화 가능한 것과 불가능한 것​

import { component$, useStore, useSignal } from '@builder.io/qwik';

export default component$(() => {
// 직렬화 가능 - 원시값
const name = useSignal('홍길동');
const age = useSignal(30);

// 직렬화 가능 - 단순 객체
const user = useStore({
name: '홍길동',
email: 'hong@example.com',
preferences: {
theme: 'dark',
language: 'ko'
}
});

// 직렬화 불가능 - 함수 (QRL로 처리)
// const handler = () => { ... }; // 이렇게 하면 안됨
// onClick$={() => { ... }} // 이렇게 $ 사인으로 처리해야 함

// 직렬화 불가능 - 클래스 인스턴스
// const date = useSignal(new Date()); // 주의 필요

return <div>{user.name}</div>;
});

HTML에 상태가 직렬화되는 방식​

<!-- useStore의 데이터가 HTML에 포함됨 -->
<script type="qwik/json">
{
"refs": {
"1": "#0", // 시그널 참조
"2": "#1" // 스토어 참조
},
"ctx": {},
"objs": [
"홍길동", // 인덱스 0: name 시그널 값
{ // 인덱스 1: user 스토어 값
"name": "홍길동",
"email": "hong@example.com",
"preferences": {"theme": "dark", "language": "ko"}
}
]
}
</script>

Lazy Execution (지연 실행) — $ 사인의 역할​

$는 Qwik에서 가장 중요한 개념 중 하나입니다.

$ 사인의 의미​

// $ 없는 일반 함수 - 번들에 포함됨
const normalFunction = () => {
console.log('항상 로드됨');
};

// $ 있는 함수 - 별도 청크로 분리, 필요할 때만 로드
const lazyFunction = $(() => {
console.log('필요할 때만 로드됨');
});

$ 사인이 사용되는 곳​

import {
component$, // 컴포넌트 정의
useTask$, // 사이드 이펙트
useVisibleTask$, // 클라이언트 전용 태스크
useComputed$, // 계산된 값
$ // 임의 함수 지연 로드
} from '@builder.io/qwik';

export const MyComponent = component$(() => {
// 모든 $ 함수는 별도 JS 청크로 분리됨

useTask$(({ track }) => {
// 서버/클라이언트 모두 실행, 반응형 추적
});

useVisibleTask$(() => {
// 컴포넌트가 뷰포트에 보일 때만 실행
// 클라이언트 전용 코드 (window, document 등)
});

return (
<div>
<button onClick$={() => alert('클릭!')}>
클릭하면 이 핸들러 코드가 로드됨
</button>
</div>
);
});

Optimizer가 $ 변환 시 하는 일​

// 변환 전 (개발자가 작성하는 코드)
export const Counter = component$(() => {
const count = useSignal(0);
return (
<button onClick$={() => count.value++}>
{count.value}
</button>
);
});

// 변환 후 (Qwik Optimizer가 생성하는 코드)
// --- chunk-counter.js ---
export const Counter = componentQrl(qrl(() => import('./chunk-counter_render.js'), 'Counter_render'));

// --- chunk-counter_render.js ---
export const Counter_render = () => {
const count = useSignal(0);
return jsx('button', {
'onClick$': qrl(() => import('./chunk-counter_onClick.js'), 'Counter_onClick'),
children: count.value
});
};

// --- chunk-counter_onClick.js ---
export const Counter_onClick = (count) => {
count.value++;
};

기본 컴포넌트 예제​

Hello World​

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

export default component$(() => {
return (
<main>
<h1>안녕하세요, Qwik!</h1>
<p>차세대 웹 프레임워크에 오신 것을 환영합니다.</p>
</main>
);
});

export const head: DocumentHead = {
title: 'Qwik 시작하기',
meta: [
{
name: 'description',
content: 'Qwik으로 만든 첫 번째 페이지',
},
],
};

카운터 컴포넌트​

// src/components/counter/counter.tsx
import { component$, useSignal } from '@builder.io/qwik';

interface CounterProps {
initialValue?: number;
step?: number;
}

export const Counter = component$<CounterProps>(({
initialValue = 0,
step = 1
}) => {
const count = useSignal(initialValue);

return (
<div class="counter">
<h2>카운터</h2>
<div class="controls">
<button
onClick$={() => count.value -= step}
disabled={count.value <= 0}
>
-{step}
</button>
<span class="value">{count.value}</span>
<button onClick$={() => count.value += step}>
+{step}
</button>
</div>
<p>현재 값: {count.value}</p>
</div>
);
});

간단한 Todo 앱​

// src/components/todo/todo.tsx
import { component$, useStore } from '@builder.io/qwik';

interface Todo {
id: number;
text: string;
done: boolean;
}

interface TodoState {
items: Todo[];
input: string;
nextId: number;
}

export const TodoApp = component$(() => {
const state = useStore<TodoState>({
items: [],
input: '',
nextId: 1,
});

return (
<div class="todo-app">
<h1>할 일 목록</h1>

<div class="add-todo">
<input
type="text"
value={state.input}
onInput$={(ev) => {
state.input = (ev.target as HTMLInputElement).value;
}}
placeholder="할 일을 입력하세요..."
/>
<button
onClick$={() => {
if (state.input.trim()) {
state.items.push({
id: state.nextId++,
text: state.input.trim(),
done: false,
});
state.input = '';
}
}}
>
추가
</button>
</div>

<ul>
{state.items.map((todo) => (
<li key={todo.id} class={todo.done ? 'done' : ''}>
<input
type="checkbox"
checked={todo.done}
onChange$={() => {
todo.done = !todo.done;
}}
/>
<span>{todo.text}</span>
<button
onClick$={() => {
const idx = state.items.indexOf(todo);
state.items.splice(idx, 1);
}}
>
삭제
</button>
</li>
))}
</ul>

<p>
완료: {state.items.filter(t => t.done).length} / {state.items.length}
</p>
</div>
);
});

프레임워크 비교표​

특성React/Next.jsSvelteKitSolid.jsQwik/QwikCity
초기 JS 크기크다 (~100KB+)작다 (no runtime)중간 (~25KB)최소 (~1KB)
하이드레이션전체 하이드레이션전체 하이드레이션전체 하이드레이션없음 (재개)
렌더링 모델가상 DOM diffing컴파일타임 반응성세밀한 반응성세밀한 반응성 + 지연
SSR지원 (Next.js)기본 지원지원 (SolidStart)기본 지원
학습 곡선중간쉬움중간가파름 ($ 개념)
성숙도매우 높음높음중간성장 중
에코시스템방대함큼중간성장 중
TypeScript우수우수우수우수
Core Web Vitals앱 크기 의존좋음좋음최상
DX(개발 경험)매우 좋음매우 좋음좋음좋음 (개선 중)
사용 회사Meta, VercelVercel 지원-Builder.io

번들 크기 비교 (동일 앱 기준)​

프레임워크         초기 JS (gzipped)   하이드레이션 비용
─────────────────────────────────────────────────────────
React + Next.js ~130KB O(n) 앱 크기 비례
Vue 3 + Nuxt ~75KB O(n) 앱 크기 비례
Svelte + SvelteKit ~50KB O(n) 앱 크기 비례
Solid + SolidStart ~25KB O(n) 앱 크기 비례
Qwik + QwikCity ~1KB O(1) 앱 크기 무관

Qwik이 적합한 프로젝트​

강력 추천 상황​

1. 콘텐츠 중심 웹사이트

- 뉴스/미디어 플랫폼
- 마케팅 랜딩 페이지
- 블로그, 문서 사이트
- 이커머스 제품 목록 페이지
→ 콘텐츠는 많고 인터랙션은 적은 경우 Qwik 효과 극대화

2. 성능이 매우 중요한 프로젝트

- Core Web Vitals 점수가 SEO/수익에 직결되는 서비스
- 저사양 기기 사용자가 많은 경우 (개발도상국 시장 등)
- 3G/4G 네트워크 환경을 고려해야 하는 경우

3. 대규모 앱

- 앱이 커질수록 기존 프레임워크의 하이드레이션 비용 증가
- Qwik은 규모와 무관하게 O(1) 로딩 유지

덜 적합한 경우​

1. 매우 인터랙티브한 SPA

- 실시간 협업 도구 (Figma, Google Docs 스타일)
- 복잡한 드래그앤드롭 인터페이스
→ 이런 경우 어차피 JS를 많이 로드해야 하므로 Qwik의 장점이 희석

2. 작은 규모의 프로젝트

- 단순한 랜딩 페이지 하나 → Astro나 정적 HTML이 더 간단
- 팀원 모두가 React에 익숙한 경우 → 학습 비용 vs 성능 이득 계산 필요

3. 성숙한 에코시스템이 필요한 경우

- 방대한 React 생태계(라이브러리, 도구, 커뮤니티)가 필요
- 많은 기존 React 컴포넌트를 재사용해야 하는 경우
→ Qwik은 React 컴포넌트 직접 사용 불가 (Qwikify$로 일부 감싸기 가능)

실전 예제: 검색 자동완성​

// src/components/search/search-autocomplete.tsx
import {
component$,
useSignal,
useStore,
useTask$,
$
} from '@builder.io/qwik';

interface SearchResult {
id: number;
title: string;
category: string;
}

export const SearchAutocomplete = component$(() => {
const query = useSignal('');
const state = useStore<{
results: SearchResult[];
loading: boolean;
selected: SearchResult | null;
}>({
results: [],
loading: false,
selected: null,
});

// useTask$는 query.value가 변경될 때마다 실행
useTask$(async ({ track, cleanup }) => {
const q = track(() => query.value);

if (q.length < 2) {
state.results = [];
return;
}

state.loading = true;

// abort controller로 이전 요청 취소
const controller = new AbortController();
cleanup(() => controller.abort());

try {
const res = await fetch(
`/api/search?q=${encodeURIComponent(q)}`,
{ signal: controller.signal }
);
state.results = await res.json();
} catch (e) {
if ((e as Error).name !== 'AbortError') {
console.error('검색 오류:', e);
}
} finally {
state.loading = false;
}
});

const selectResult = $((result: SearchResult) => {
state.selected = result;
query.value = result.title;
state.results = [];
});

return (
<div class="search-container">
<input
type="search"
value={query.value}
onInput$={(ev) => {
query.value = (ev.target as HTMLInputElement).value;
}}
placeholder="검색어 입력..."
/>

{state.loading && <span class="spinner">검색 중...</span>}

{state.results.length > 0 && (
<ul class="autocomplete-list">
{state.results.map((result) => (
<li
key={result.id}
onClick$={() => selectResult(result)}
>
<strong>{result.title}</strong>
<small>{result.category}</small>
</li>
))}
</ul>
)}

{state.selected && (
<div class="selected">
선택됨: {state.selected.title}
</div>
)}
</div>
);
});

고수 팁 섹션​

팁 1: 직렬화 경계를 의식하라​

// 나쁜 예: 직렬화 불가능한 값을 상태로 관리
const state = useStore({
date: new Date(), // Date 객체는 직렬화 주의
fn: () => {}, // 함수 절대 불가
element: document.body, // DOM 참조 불가
});

// 좋은 예: 직렬화 가능한 형태로
const state = useStore({
dateString: new Date().toISOString(), // 문자열로
element: useSignal<Element>(), // useSignal로 ref 처리
});
// 함수는 $ 사인 사용
const handleClick = $(() => {});

팁 2: $ 사인의 황금률 — 클로저 규칙​

// 나쁜 예: 직렬화 불가능한 값을 클로저로 캡처
const MyComponent = component$(() => {
const localObj = { value: 42 }; // 일반 객체

// 이 클로저가 localObj를 캡처하지만
// localObj는 직렬화되지 않음 → 에러 가능성
return <button onClick$={() => console.log(localObj.value)}>클릭</button>;
});

// 좋은 예: Signal/Store로 관리
const MyComponent = component$(() => {
const value = useSignal(42); // 직렬화 가능

return <button onClick$={() => console.log(value.value)}>클릭</button>;
});

팁 3: Qwikify$로 React 컴포넌트 통합​

// React 생태계 컴포넌트를 Qwik에서 사용하기
import { qwikify$ } from '@builder.io/qwik-react';
import { SomeReactComponent } from 'some-react-library';

// React 컴포넌트를 Qwik 컴포넌트로 래핑
export const QwikSomeComponent = qwikify$(SomeReactComponent, {
eagerness: 'hover', // 마우스 오버 시 하이드레이션
});

// 또는 eagerness 없이 (클릭 시 하이드레이션)
export const QwikSomeComponent2 = qwikify$(SomeReactComponent);

팁 4: 서버/클라이언트 분기​

import { component$, useVisibleTask$, isServer } from '@builder.io/qwik';
import { server$ } from '@builder.io/qwik-city';

// server$로 서버 전용 함수 정의
const getServerData = server$(async function() {
// 이 코드는 절대 클라이언트에 노출되지 않음
const secret = process.env.API_SECRET;
const data = await fetch(`https://api.example.com/data?key=${secret}`);
return data.json();
});

export const SecureComponent = component$(() => {
useVisibleTask$(async () => {
// 클라이언트에서 호출하지만 실행은 서버에서
const data = await getServerData();
console.log(data);
});

return <div>보안 컴포넌트</div>;
});

팁 5: 성능 모니터링​

// Qwik 앱 성능 측정
// 브라우저 DevTools → Network 탭에서 확인:
// 1. 초기 HTML 요청 크기
// 2. qwikloader.js 크기 (~1KB)
// 3. 사용자 인터랙션 시 로드되는 청크들

// Chrome DevTools Performance 탭:
// - FCP(First Contentful Paint): Qwik은 빠름
// - TTI(Time to Interactive): Qwik은 즉각적
// - TBT(Total Blocking Time): Qwik은 거의 0

팁 6: 빌드 분석으로 청크 최적화​

# 빌드 후 번들 분석
npm run build
# dist/ 폴더에서 각 청크 크기 확인

# 청크가 너무 크다면 → 더 작은 컴포넌트로 분리
# 청크가 너무 많다면 → 관련 기능 묶기

요약​

개념설명
재개 가능성서버 상태를 HTML에 직렬화, 브라우저가 재시작 없이 재개
O(1) 로딩앱 크기와 무관한 초기 JS 로딩 (~1KB)
$ 사인함수를 별도 청크로 분리하는 Optimizer 지시자
직렬화앱 상태를 HTML에 JSON으로 포함시키는 메커니즘
지연 실행이벤트 핸들러는 실제 이벤트 발생 시에만 로드
QRLQwik URL Reference — 지연 로드되는 함수의 참조

Qwik은 웹 성능에 대한 근본적인 재고에서 출발한 프레임워크입니다. "하이드레이션이 필요없다면?"이라는 질문의 답이 Qwik입니다. 다음 장에서는 Qwik 개발 환경을 설정하고 실제 프로젝트를 시작해봅니다.