본문으로 건너뛰기
Advertisement

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 개발 환경을 설정하고 실제 프로젝트를 시작해봅니다.

Advertisement