본문으로 건너뛰기
Previous
Next
Qwik 완전 가이드 | 0ms JavaScript로 초고속 웹 만들기

Qwik 완전 가이드 | 0ms JavaScript로 초고속 웹 만들기

Qwik 완전 가이드 | 0ms JavaScript로 초고속 웹 만들기

이 글의 핵심

JavaScript를 거의 보내지 않는 혁명적인 프레임워크 Qwik. Hydration 없이 Resumability로 즉시 인터랙티브하며, 초기 로딩 시 0-1KB의 JavaScript만 전송합니다. Next.js보다 10배 빠른 첫 로딩을 제공합니다.

이 글의 핵심

Qwik은 JavaScript를 거의 보내지 않는 혁명적인 프레임워크입니다. Hydration 없이 Resumability로 즉시 인터랙티브하며, 초기 로딩 시 0-1KB의 JavaScript만 전송합니다. Next.js보다 10배 빠른 첫 로딩을 제공합니다.

Resumability를 한 단계 더 이해하기

Qwik의 핵심은 “서버가 끝낸 일을 클라이언트가 처음부터 다시 수행하지 않는다”는 점입니다. 전통적인 SSR 프레임워크는 HTML을 보낸 뒤, 같은 컴포넌트 트리를 브라우저에서 다시 실행해 이벤트를 붙이고 상태를 복원합니다. 이를 Hydration이라 부릅니다.

Resumability는 직렬화(serialization) 를 전제로 합니다. 서버는 렌더 단계에서 컴포넌트 경계, 시그널/스토어의 스냅샷, 그리고 이벤트가 어느 청크의 어느 심볼에 연결되는지를 HTML과 함께 내보냅니다. 클라이언트는 “전체 앱을 실행”하는 대신, 필요한 이벤트가 발생했을 때만 해당 JavaScript 모듈을 가져옵니다. 이 덕분에 초기에 실행하는 JS 양이벤트 연결에 필요한 작업이 기존 방식과 다른 스케일이 됩니다.

Qwik이 생성하는 HTML에는 개념적으로 on:click과 같이 지연 연결(lazy attachment) 을 가리키는 힌트가 들어갑니다. 실제로는 Qwik이 내부 포맷으로 직렬화하며, 개발자는 onClick$component$$를 붙여 “여기는 경계이니 청크로 쪼개라”는 의도를 프레임워크에 맡깁니다.

Hydration vs Resumability: 실무 관점에서 비교

구분Hydration(일반적인 SSR)Qwik (Resumability)
첫 화면 이후전체 앱(또는 큰 섹션)을 다시 실행해 트리를 맞춤재실행 없이 HTML에 실린 힌트로 이벤트만 점진 연결
TTI(상호작용 준비)번들+실행+hydrate에 좌우초기 JS가 작고, 상호작용은 퍼져 있는 청크로 분산
설계 민감도“서버/클라이언트가 같은 컴포넌트”가 전제직렬화 가능한 경계지연 핸들러 설계에 익숙해져야 함
디버깅흔한 패턴, 도구·자료 풍부Devtool은 성숙해졌으나, React 대비 레퍼런스/스택오버플로는 상대적으로 적음

Hydration이 나쁜 것은 아닙니다. React 생태계의 성숙도, 채용 시장, 라이브러리 다양성은 압도적입니다. 다만 “첫 화면부터 모든 인터랙션까지 한 번에 준비”하는 비용이 큰 앱에선, Qwik이 말하는 0에 가까운 하이드레이션이 체감으로 남는 경우가 많습니다. 반면 팀이 이미 Design System, 상태관리, 인증 플로우를 React에 맞춰 올렸다면, Qwik의 학습·모듈 경계($) 비용이 이득을 잠식할 수 있습니다.

signals와 stores: 실전 컴포넌트

useSignal: 단일 값의 반응성

useSignal은 숫자, 문자열, 객체 참조 한 덩이를 다룰 때 간단합니다. UI에서 .value로 읽고 쓰는 패턴이 React useState와 비슷하지만, Qwik는 $ 경계와 결합됩니다.

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

export default component$((props: { base: number }) => {
  const qty = useSignal(1);
  const tax = useSignal(0.1);

  return (
    <section>
      <p>수량: {qty.value}</p>
      <button onClick$={() => qty.value++}>+</button>
      <button onClick$={() => (qty.value = Math.max(1, qty.value - 1))}>-</button>
      <p>합계(세후): {Math.round(props.base * qty.value * (1 + tax.value))}</p>
    </section>
  );
});

파생 값을 시그널로 따로 두고 싶다면 useTask$qty·tax를 추적해 계산하는 패턴도 흔합니다. 중요한 점은 이벤트·비동기 경계가 청크로 나뉠 수 있다는 것입니다.

useStore: 객체/배열 트리

폼, 테이블, TODO처럼 필드가 여럿인 UI는 useStore가 읽기 쉽습니다. store의 속성이 바뀌면 Qwik이 반응성을 추적합니다.

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

type Profile = { name: string; role: 'dev' | 'pm' | 'design' };

export default component$(() => {
  const form = useStore<Profile>({ name: '', role: 'dev' });
  const submitted = useSignal<Profile | null>(null);

  const onSubmit = $(() => {
    submitted.value = { name: form.name, role: form.role };
  });

  return (
    <form preventdefault:submit onSubmit$={onSubmit}>
      <input
        name="name"
        value={form.name}
        onInput$={(_, e) => (form.name = (e.target as HTMLInputElement).value)}
      />
      <select
        value={form.role}
        onChange$={(_, e) => (form.role = (e.target as HTMLSelectElement).value as Profile['role'])}
      >
        <option value="dev">Dev</option>
        <option value="pm">PM</option>
        <option value="design">Design</option>
      </select>
      <button type="submit">저장</button>
      {submitted.value && <pre>{JSON.stringify(submitted.value, null, 2)}</pre>}
    </form>
  );
});

useSignal vs useStore를 단순화하면 다음과 같습니다. 한 값이면 시그널, 필드가 여럿이면 스토어를 우선 검토하십시오. 깊은 트리를 자주 갱신한다면, 불필요한 렌더/직렬화 범위를 넓히지 않도록 컴포넌트를 잘게 나누는 것이 유리합니다.

Qwik City: 라우팅·레이아웃·로더

Qwik City는 파일 기반 라우터와 layout.tsx로 구성됩니다. /blog/[id] 같은 동적 세그먼트src/routes/blog/[id]/index.tsx에 둡니다. 공통 껍데기는 상위 layout.tsx에서 <Slot />로 자식을 끼웁니다. 인증, 공통 GNB, 푸터는 여기에 두는 편이 자연스럽습니다.

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

export const useBlogContext = routeLoader$(async () => {
  return { siteName: 'My Blog' } as const;
});

export default component$(() => {
  return (
    <article class="prose">
      <Slot />
    </article>
  );
});

routeLoader$요청마다(또는 정책에 따라) 서버에서 데이터를 준비하고, 그 결과는 HTML과 함께 직렬화되어 클라이언트의 Resumable 트리와 맞물립니다. “페이지에 필요한 데이터를 서버에서 미리” 가져오는 말은 React의 RSC와 겉핥기로 비슷해 보이지만, Qwik는 UI 경계+직렬화에 더 강하게 최적화되어 있습니다.

서버 액션과 폼: server$routeAction$

서버에서만 비밀 키·DB·파일시스템에 접근해야 할 때 server$로 함수를 감쌉니다. 클라이언트는 RPC처럼 호출되지만, 실제 실행은 서버로 위임됩니다. 폼 제출·뮤테이션에는 routeAction$+<Form action> 패턴이 잘 맞습니다.

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

// 폼 name과 동일한 키로 data에 전달됩니다(공식 문서 패턴).
export const useFeedbackAction = routeAction$(async (data, { fail }) => {
  const message = String(data.message ?? '').trim();
  if (message.length < 3) {
    return fail(400, { message: '3자 이상 입력하세요.' });
  }
  await new Promise((r) => setTimeout(r, 50));
  return { success: true as const };
});

export default component$(() => {
  const action = useFeedbackAction();
  return (
    <Form action={action}>
      <textarea name="message" rows={5} required />
      <button type="submit" disabled={action.isRunning}>
        {action.isRunning ? '전송 중…' : '보내기'}
      </button>
      {action.value?.failed && <p role="alert">서버 검증에 실패했습니다. 3자 이상 입력하세요.</p>}
      {action.value?.success && <p>감사합니다. 전송이 완료되었습니다.</p>}
      <p>
        <Link href="/">돌아가기</Link>
      </p>
    </Form>
  );
});

server$는 가벼운 서버 유틸 호출에, routeAction$HTML 폼·리다이렉트와 궁합이 좋습니다. 실제 앱에서는 Zod 등으로 스키마 검증을 두거나, CSRF·레이트 리밋을 미들웨어·엣지에서 처리하는 편이 일반적입니다. Qwik City는 프로젝트 설정에 따라 zod 연동 헬퍼를 쓸 수 있으니 공식 문서의 최신 예제를 확인하십시오.

성능 최적화 전략(실전 체크리스트)

  • $ 경계는 의도적으로: 모든 함수에 $를 남용하면 청크가 잘게 쪼개지나, 오히려 요청 수가 늘 수 있습니다. 이벤트·비동기·지연이 이득인 지점에 둡니다.
  • 큰 데이터는 routeLoader$+직렬화 비용을 점검: 불필요하게 큰 객체를 클라이언트로 보내면, Resumability의 “작은 JS” 이점을 데이터 전송이 상쇄할 수 있습니다.
  • 이미지·폰트: LCP(최대 콘텐츠 페인트)는 여전히 에셋 전략이 좌우합니다. Qwik이 JS를 아껴줘도 이미지 5MB면 느립니다.
  • Qwik + Partytown 등: 서드파티 스크립트(분석)를 메인 스레드 밖으로 빼는 전략은 Qwik의 철학과 잘 맞습니다.
  • React 인터롭(“qwikify”): React 위젯을 쓰면 그 아일랜드는 React 번들이 붙습니다. “첫 1KB”를 지키려면 경계를 잘게 쪼갠 뒤 꼭 필요한 곳에만 쓰는 것이 낫습니다.

React와의 차이, 마이그레이션 시 사전에 알아둘 것

  • 렌더 모델: React는(클라이언트 측에서) 상태 변화 → 재렌더가 중심입니다. Qwik는 집약된 시그널/스토어지연 핸들러가 중심이며, “전역 단일 트리 재실행” 느낌이 약합니다.
  • 훅 규칙: React의 use 규칙과 비슷하게, Qwik에도 use*component$ 동기 본문에서 호출하는 패턴이 기본입니다. useTask$track은 의존성을 명시적으로 추적하는 데 익숙해져야 합니다.
  • 마이그레이션: @builder.io/qwik-react점진 이식이 가능하나, 래핑된 React 섹션은 그만큼 클라이언트 비용이 듭니다. 먼저 정적/읽기 전용 페이지를 Qwik로 옮기고, 인터랙티브 위젯을 시그널 기반으로 옮기는 단계를 추천합니다.
  • DX: React+Next 팀 대비 Qwik 채용·레퍼런스는 작습니다. 장기 운용 시 “문제가 생겼을 때 검색으로 답이 나오는가”는 리스크 요인입니다.

솔직한 프로덕션 사용 후기(일반화된 시나리오)

필자가 여러 마케팅·랜딩·콘텐츠 중심 프로젝트에서 Qwik City를 쓸 때, 첫 JS 전송·TTI는 기대에 가깝게 나온 경우가 많았습니다. 특히 Lighthouse/CRUX의 “TBT(총 블로킹 시간)”에서 체감이 큽니다. 다만 복잡한 클라이언트 상태/실시간 협업/거대한 폼처럼 “브라우저에서 항상 무거운 일”이 핵심이면, 프레임워크만 바꿔서 근본이 해결되지는 않습니다. 또 routeLoader$·직렬화 경계·$ 심볼에 익숙해지기까지 1~2스프린트는 진입비용이 있습니다. 팀에 React·Next에 강한 엔지니어가 대부분이면, 교육·코드리뷰 룰을 미리 써 두는 것이 실패 확률을 낮춥니다.

언제 Qwik을 선택할 것인가

Qwik이 특히 잘 맞는 경우는 다음과 같습니다. SEO·첫 화면·광고/전환이 민감한 B2C 랜딩/콘텐츠/커머스 카탈로그처럼, “가볍고 빨리”가 비즈니스와 직결될 때. 모바일 저사양 비중이 크고, TBT·TTI를 실측으로 줄여야 할 때. MPA에 가깝고 대부분의 페이지는 읽기 위주, 일부에만 아일랜드가 필요한 구조도 잘 맞습니다.

다시 생각해볼 때는 이런 경우입니다. 팀이 React 생태계에 강하게 묶인 Design System, 인증, 표준 패턴이 있고, 즉시 인력 투입이 중요한 신규 제품. WebSocket/실시간, 복잡한 권한·오프라인·대형 SPA로 설계한 제품. 이 경우 Qwik이 답이 아니라, Remix, Next, 혹은 Astro Islands가 더 낮은 리스크일 수 있습니다.

장점 요약: 초기 JS/TTI, 지연된 상호액션, City의 파일 라우팅+로더+액션이 한 흐름으로 이어짐.

한계 요약: 생태·채용, $·직렬화 모델 학습, React “호환”이 완전 무비용은 아님. 성능은 에셋·API·서드파티와 함께 봐야 함.

목차(본문 흐름)

  1. 이 글의 핵심 · 2. Resumability 심화 · 3. Hydration 비교
  2. signals / stores · 5. Qwik City · 6. 서버·폼
  3. 성능 · 8. React·마이그레이션 · 9. 후기·선택 기준 · 10. 하단 예제·정리

Qwik이란?

Qwik은 2021년 Miško Hevery (Angular 창시자)가 개발한 차세대 웹 프레임워크입니다.

핵심 혁신: Resumability

기존 프레임워크 (Hydration)

1. 서버: HTML 생성
2. 클라이언트: HTML 수신
3. 클라이언트: JavaScript 다운로드 (50KB+)
4. 클라이언트: 전체 앱 다시 실행 (Hydration)
5. 인터랙티브 가능
→ 총 3-5초

Qwik (Resumability)

1. 서버: HTML + 이벤트 정보 생성
2. 클라이언트: HTML 수신
3. 즉시 인터랙티브 (JavaScript 0-1KB)
4. 필요한 코드만 지연 로드
→ 총 0.5초

핵심 원리

<!-- Qwik이 생성한 HTML (개념) -->
<button on:click="./chunk-abc123.js#handleClick_xyz">
  Click me
</button>
  • 클릭 시 필요한 코드만 로드
  • 사용하지 않는 코드는 절대 로드 안 됨
  • 0 JavaScript by default에 가깝다는 것이 체감 목표입니다

Qwik 시작하기

프로젝트 생성

# Qwik 프로젝트 생성
npm create qwik@latest

# 옵션 예시
# - Project name: my-qwik-app
# - Template: Basic
# - Install dependencies: Yes

cd my-qwik-app
npm run dev

프로젝트 구조

my-qwik-app/
├── src/
│   ├── components/
│   ├── routes/
│   │   ├── index.tsx       # 홈페이지
│   │   └── layout.tsx      # 레이아웃
│   └── root.tsx
├── public/
├── package.json
└── vite.config.ts

기본 문법

컴포넌트 정의

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

// component$로 컴포넌트 정의 ($ 붙음!)
export default component$(() => {
  // useSignal로 상태 관리
  const count = useSignal(0);

  return (
    <div>
      <p>Count: {count.value}</p>
      <button onClick$={() => count.value++}>
        Increment
      </button>
    </div>
  );
});

중요: $ 접미사

  • component$: 컴포넌트를 청크로 분리
  • onClick$: 이벤트 핸들러를 청크로 분리
  • useTask$: Effect를 청크로 분리

이벤트 핸들러

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

export default component$(() => {
  const message = useSignal('');

  // $ 함수로 핸들러 정의
  const handleClick = $(() => {
    console.log('Button clicked!');
  });

  const handleInput = $((event: Event) => {
    message.value = (event.target as HTMLInputElement).value;
  });

  return (
    <div>
      <input onInput$={handleInput} value={message.value} />
      <button onClick$={handleClick}>Submit</button>
      <p>Message: {message.value}</p>
    </div>
  );
});

useTask$ (Effects)

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

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

  // count가 변경될 때마다 실행
  useTask$(({ track }) => {
    track(() => count.value);
    console.log('Count changed:', count.value);
  });

  // 컴포넌트 마운트 시 한 번만 실행
  useTask$(() => {
    console.log('Component mounted');
  });

  return (
    <button onClick$={() => count.value++}>
      Count: {count.value}
    </button>
  );
});

useResource$ (데이터 페칭)

import { component$, useResource$, Resource } from '@builder.io/qwik';

interface User {
  id: number;
  name: string;
  email: string;
}

export default component$(() => {
  // 비동기 데이터 로드
  const usersResource = useResource$<User[]>(async () => {
    const res = await fetch('https://jsonplaceholder.typicode.com/users');
    return res.json();
  });

  return (
    <div>
      <h1>Users</h1>
      <Resource
        value={usersResource}
        onPending={() => <div>Loading...</div>}
        onRejected={(error) => <div>Error: {error.message}</div>}
        onResolved={(users) => (
          <ul>
            {users.map((user) => (
              <li key={user.id}>
                {user.name} - {user.email}
              </li>
            ))}
          </ul>
        )}
      />
    </div>
  );
});

라우팅 (파일 기반, Qwik City)

페이지 생성

// src/routes/index.tsx (홈페이지)
import { component$ } from '@builder.io/qwik';

export default component$(() => {
  return <h1>Welcome to Qwik!</h1>;
});

// src/routes/about/index.tsx
export default component$(() => {
  return <h1>About Page</h1>;
});

// src/routes/blog/[id]/index.tsx (동적 라우트)
import { useLocation } from '@builder.io/qwik-city';

export default component$(() => {
  const loc = useLocation();
  
  return <h1>Post ID: {loc.params.id}</h1>;
});

레이아웃

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

export default component$(() => {
  return (
    <div>
      <header>
        <nav>
          <a href="/">Home</a>
          <a href="/about">About</a>
        </nav>
      </header>

      <main>
        <Slot /> {/* 페이지 내용이 여기에 삽입됨 */}
      </main>

      <footer>
        <p>&copy; 2026 My App</p>
      </footer>
    </div>
  );
});

서버 함수 (server$)

// src/routes/users/index.tsx
import { component$, useSignal, $ } from '@builder.io/qwik';
import { server$ } from '@builder.io/qwik-city';

// 서버에서만 실행되는 함수
const getUsers = server$(async function() {
  // 환경 변수 접근 (서버에서만)
  const apiKey = this.env.get('API_KEY');
  // const users = await db.users.findMany();
  // return users;
  return [{ id: 1, name: 'Ada' }]; // 예시
});

export default component$(() => {
  const users = useSignal<{ id: number; name: string }[]>([]);

  const loadUsers = $(async () => {
    users.value = await getUsers();
  });

  return (
    <div>
      <button onClick$={loadUsers}>Load Users</button>
      <ul>
        {users.value.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
});

db 예시는 팀의 데이터 계층에 맞게 교체하십시오. import { $ } from '@builder.io/qwik' 누락 시 loadUsers에서 타입/번들 오류가 날 수 있습니다.


React 통합

npm install @builder.io/qwik-react
npm install react react-dom @types/react @types/react-dom
// src/integrations/react/mui.tsx
/** @jsxImportSource react */
import { qwikify$ } from '@builder.io/qwik-react';
import { Button as MuiButton } from '@mui/material';

// React 컴포넌트를 Qwik에서 사용
export const Button = qwikify$(MuiButton);
// src/routes/index.tsx
import { component$ } from '@builder.io/qwik';
import { Button } from '../integrations/react/mui';

export default component$(() => {
  return (
    <div>
      <h1>Qwik + React</h1>
      <Button variant="contained">MUI Button</Button>
    </div>
  );
});

성능 벤치마크(참고)

초기 JavaScript 크기(개략)

Next.js(예시): 첫 페이로드 JS 수십 KB~100KB+ (앱/페이지에 따라 가변)
Qwik(목표): HTML 직후 실행 코드를 극소화, 상호액션은 지연 청크

벤치마크는 동일 콘텐츠·동일 이미지·동일 써드파티일 때만 의미가 있습니다. 반드시 실측(RUM, Lighthouse, CRUX) 을 병행하십시오.

Time to Interactive (TTI)

필드와 제품에 따라 수치는 크게 달라집니다. Qwik의 강점은 “초기에 앱 전체를 재실행하지 않는다”는 점이 체감 비용에 직접으로 연결되는 경우가 많다는 데 있습니다.


Qwik vs Next.js vs Astro

기능QwikNext.jsAstro
초기 JS매우 작게 유지(목표)앱/페이지 규모에 비례0(정적)·아일랜드만 추가
HydrationResumabilityApp Router/페이지에 따라 광범위Islands(필요 시만)
SSRQwik CityNextAstro(어댑터)
인터랙티브점진적보통 광범위섬만
생태계성장 중매우 큼프론트/콘텐츠 강함

실전 프로젝트: Todo 앱

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

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

export default component$(() => {
  const store = useStore({
    todos: [] as Todo[],
    input: '',
  });

  const addTodo = $(() => {
    if (store.input.trim()) {
      store.todos.push({
        id: Date.now(),
        text: store.input,
        completed: false,
      });
      store.input = '';
    }
  });

  const toggleTodo = $((id: number) => {
    const todo = store.todos.find(t => t.id === id);
    if (todo) {
      todo.completed = !todo.completed;
    }
  });

  const deleteTodo = $((id: number) => {
    store.todos = store.todos.filter(t => t.id !== id);
  });

  return (
    <div>
      <h1>Qwik Todo App</h1>
      
      <form
        preventdefault:submit
        onSubmit$={addTodo}
      >
        <input
          type="text"
          value={store.input}
          onInput$={(e) => store.input = (e.target as HTMLInputElement).value}
          placeholder="New todo..."
        />
        <button type="submit">Add</button>
      </form>

      <ul>
        {store.todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange$={() => toggleTodo(todo.id)}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
            <button onClick$={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
});

핵심 정리

Qwik의 장점

  1. 초고속 로딩·TTI: 초기 실행 JS를 아주 작게 가져갈 구조가 기본
  2. Resumability: “전 앱 하이드레이트”에 덜 의존
  3. 지연된 상호액션: 이벤트·청크 단위로 필요한 때만 로드
  4. 자동화된 경계($): 올바르게 쓰면 코드 스플리팅이 자연스럽게 수반
  5. React 인터롭(qwikify): 점진 이식·위젯 재사용

Qwik의 단점(솔직히)

  • 팀/시장/레퍼런스는 React·Next에 비해 작음
  • $·직렬화·City 로더/액션은 학습·규율이 필요
  • qwikify한 React는 “무료”가 아님(번들·하이드레이션 경계)
  • 성능은 이미지·API·광고 스크립트에 여전히 좌우됨

다음 단계


시작하기: npm create qwik@latest로 프로젝트를 띄운 뒤, 위의 signals/storesrouteLoader$ / routeAction$ 를 최소한으로 다뤄 보십시오. 첫 주차에 “왜 Resumable한지”가 팀 머릿속에 그림으로 남는지가 도입 성패를 가늠합니다.