Qwik 프레임워크: Resumability와 Hydration의 차이, Qwik City 라우팅·서버 액션

이 글의 핵심

일반적인 SSR 프레임워크는 HTML을 받은 뒤 컴포넌트 코드를 다시 실행하는 Hydration 비용을 치릅니다. Qwik은 이벤트가 발생한 지점의 코드만 지연 로드해 이 단계를 건너뛰지만, $ 경계와 직렬화 규칙을 지켜야 하고 React 라이브러리를 그대로 쓰기 어렵다는 제약이 따릅니다. 어떤 프로젝트에 Qwik이 맞는지 판단하는 기준까지 정리했습니다.

이 글의 핵심

Qwik은 초기 JavaScript를 최소화하도록 설계된 프레임워크입니다. Hydration 없이 Resumability로 서버가 HTML에 직렬화해 둔 상태를 이어받기 때문에, 첫 화면에서는 이벤트를 위임받는 작은 Qwikloader만 실행하고 나머지 코드는 상호작용이 일어날 때 지연 로드합니다. 앱 전체를 클라이언트에서 다시 실행하는 단계가 없어, 페이지가 커질수록 초기 로딩 비용 차이가 커집니다.

Resumability를 한 단계 더 이해하기

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

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

이 글의 예제는 @builder.io/qwik 패키지를 쓰는 Qwik 1.x 기준입니다. Qwik 2부터는 패키지 이름이 @qwik.dev/core, @qwik.dev/router 등으로 바뀌는 중이므로, 새 버전을 쓴다면 import 경로를 공식 문서와 맞춰야 합니다.

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$={(_, el) => (form.name = el.value)}
      />
      <select
        value={form.role}
        onChange$={(_, el) => (form.role = el.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>
  );
});

Qwik 이벤트 핸들러의 두 번째 인자는 이벤트가 등록된 요소 자신(currentTarget에 해당)이라, 위처럼 el.value로 바로 읽을 수 있습니다. 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 번들이 붙습니다. 초기 JS를 작게 유지하려면 React 위젯은 꼭 필요한 곳에만 쓰고, client:visible 같은 지시어로 Hydration 시점을 늦추는 편이 낫습니다.

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

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

도입할 때 예상해야 할 비용

Qwik의 이점이 가장 잘 드러나는 곳은 마케팅·랜딩·콘텐츠 중심 페이지처럼 첫 화면의 JavaScript 실행량이 성능 지표를 좌우하는 경우입니다. Hydration 단계에서 메인 스레드를 오래 점유하던 작업이 사라지므로 TBT(총 블로킹 시간) 같은 지표에 영향이 큽니다. 반면 복잡한 클라이언트 상태, 실시간 협업, 거대한 폼처럼 “브라우저에서 항상 무거운 일”이 핵심인 제품은 프레임워크를 바꿔도 그 작업량 자체는 줄지 않습니다. 또 routeLoader$, 직렬화 경계, $ 심볼 규칙은 React 경험만으로는 바로 익숙해지지 않으므로, React·Next에 익숙한 팀이라면 도입 초기에 코드 리뷰 규칙과 예제를 미리 정리해 두는 것이 좋습니다.

언제 Qwik을 선택할 것인가

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

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


Qwik이란?

Qwik은 AngularJS를 만든 Miško Hevery가 Builder.io에서 2021년에 공개한 웹 프레임워크이며, 1.0은 2023년에 나왔습니다.

핵심 개념: Resumability

기존 프레임워크 (Hydration)

1. 서버: HTML 생성
2. 클라이언트: HTML 수신
3. 클라이언트: 페이지 컴포넌트의 JavaScript 다운로드
4. 클라이언트: 전체 앱 다시 실행 (Hydration)
5. 인터랙티브 가능

Qwik (Resumability)

1. 서버: HTML + 이벤트 정보 생성
2. 클라이언트: HTML 수신
3. 즉시 인터랙티브 (Qwikloader만 실행)
4. 필요한 코드만 지연 로드

핵심 원리

<!-- Qwik이 생성한 HTML (개념) -->
<button on:click="./chunk-abc123.js#handleClick_xyz">
  Click me
</button>
  • 클릭 시 필요한 코드만 로드
  • 사용자가 상호작용하지 않은 핸들러의 코드는 실행되지 않음 (다만 Qwik은 곧 쓰일 청크를 서비스 워커로 미리 받아 두는 prefetch를 기본으로 사용)
  • 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);
  });

  // track 없이 쓰면 컴포넌트가 처음 렌더링될 때 한 번만 실행
  // (SSR이면 서버에서 실행되므로, 브라우저에서만 할 일은 useVisibleTask$ 사용)
  useTask$(() => {
    console.log('Component initialized');
  });

  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>
  );
});

초기 로딩 비용을 비교할 때

Qwik의 초기 비용이 작은 이유는 구조에 있습니다. Hydration 방식은 페이지의 인터랙티브 컴포넌트 코드를 모두 내려받아 한 번 실행해야 하므로 초기 JS가 페이지 규모에 비례해 늘어나지만, Qwik은 이벤트 위임용 Qwikloader만 즉시 실행하고 핸들러 코드는 필요할 때 가져옵니다. 다만 실제 수치는 앱 구조, 이미지, 서드파티 스크립트에 크게 좌우되므로, 프레임워크를 비교할 때는 같은 콘텐츠·같은 에셋 조건에서 Lighthouse 같은 실험실 측정과 실제 사용자 데이터(RUM, CrUX)를 함께 봐야 합니다.

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>
  );
});

같이 보면 좋은 글