Qwik의 Resumability: 하이드레이션 없이 재개하는 원리, $와 QRL, 시리얼라이제이션, 엣지 배포

이 글의 핵심

Qwik은 전체 트리를 클라이언트에서 다시 실행하는 전통적 하이드레이션 대신, 서버에서 멈춘 실행 상태를 HTML에 담아 브라우저에서 이어서 재개합니다. $로 경계를 표시하고 QRL로 지연 로딩하며, Qwik City로 라우팅합니다. 이 글은 개념·직렬화·성능·Next.js 대비·엣지 실전까지 고급 독자를 기준으로 연결합니다.

이 글의 범위

Qwik은 Resumability(재개 가능성)라는 실행 모델을 중심에 둔 프런트엔드 프레임워크입니다. 이 글은 Resumability가 기존 하이드레이션과 무엇이 다른지, $ 경계와 QRL이 빌드 결과물과 어떻게 연결되는지, 상태가 어떻게 직렬화되는지를 먼저 설명하고, Qwik City의 데이터 로딩, Next.js와의 비교, 엣지 배포로 이어 갑니다.

예제는 Qwik 1.x(@builder.io/qwik, @builder.io/qwik-city)를 기준으로 합니다. Qwik 2에서는 패키지가 @qwik.dev/core와 @qwik.dev/router로 옮겨 가고 Qwik City가 Qwik Router로 이름이 바뀌는 등 import 경로와 일부 API가 달라지므로, 사용하는 버전의 공식 문서와 함께 보세요. 개념은 그대로입니다.


Resumability의 핵심 개념

하이드레이션이 하는 일과 그 비용

대부분의 컴포넌트 기반 프레임워크는 서버에서 HTML을 만들어 보낸 뒤, 브라우저에서 같은 컴포넌트 트리를 다시 실행해 내부 상태를 만들고 이벤트 핸들러를 DOM에 연결합니다. 이것이 하이드레이션입니다. HTML은 이미 화면에 보이지만, 하이드레이션이 끝나기 전에는 버튼을 눌러도 반응하지 않습니다.

비용은 세 가지로 나뉩니다. 페이지의 모든 인터랙티브 컴포넌트 코드를 내려받고 파싱해야 하고, 서버에서 이미 계산한 렌더링을 브라우저에서 한 번 더 실행해야 하며, 그동안 메인 스레드가 점유되어 입력 반응이 늦어집니다. 스트리밍, 서버 컴포넌트, 아일랜드 같은 기법은 하이드레이션 대상을 줄이는 방향으로 이 비용을 낮춥니다. 그래도 남는 질문은 “사용자가 아직 건드리지도 않은 컴포넌트를 왜 미리 실행해야 하는가”입니다.

Qwik의 답: 실행을 이어서 재개한다

Qwik은 이 문제를 실행 모델에서 다르게 풉니다. 서버 렌더링이 끝난 시점에 프레임워크가 알고 있는 정보, 즉 어떤 DOM 요소에 어떤 이벤트 핸들러가 붙는지, 반응형 상태의 값, 어떤 컴포넌트가 어떤 상태에 의존하는지를 직렬화해 HTML에 넣습니다. 브라우저는 컴포넌트를 다시 실행하지 않고, 사용자가 이벤트를 일으켰을 때 그 이벤트에 해당하는 코드 조각만 불러와 서버가 멈춘 지점부터 실행을 이어 갑니다.

브라우저에서 처음 실행되는 것은 qwikloader라는 1KB 남짓한 스크립트뿐입니다. qwikloader는 문서 전체에 전역 이벤트 리스너를 걸어 두고, 이벤트가 발생하면 대상 요소의 on:click 같은 속성에서 핸들러를 가리키는 QRL을 읽어 해당 청크를 import한 뒤 실행합니다. 컴포넌트 렌더링 함수는 그 컴포넌트가 의존하는 상태가 바뀌어 다시 그려야 할 때 비로소 클라이언트에서 실행됩니다.

”제로 하이드레이션”이라는 표현

Qwik을 소개할 때 자주 쓰는 “Zero Hydration”은 페이지 로드 시 컴포넌트 트리를 재실행하는 단계가 없다는 뜻입니다. 프레임워크 코드가 전혀 실행되지 않는다는 뜻은 아닙니다. qwikloader와, 상호작용 시 필요한 프레임워크 런타임 일부는 여전히 내려옵니다. 성능을 평가할 때는 초기 JavaScript 바이트, 첫 입력의 응답 시간(INP), 상호작용마다 추가로 내려받는 바이트를 함께 봐야 합니다.

클릭할 때 코드를 받으면 느리지 않을까

가장 흔한 의문은 “클릭한 다음에 네트워크로 코드를 받으면 첫 클릭이 느려지지 않는가”입니다. Qwik은 이를 프리페치로 해결합니다. 페이지가 로드된 뒤 화면에 보이는 요소의 핸들러 청크를 미리 내려받아 브라우저 캐시에 넣어 두므로(버전에 따라 서비스 워커 또는 modulepreload 방식), 실제 클릭 시점에는 네트워크를 기다리지 않고 캐시에서 바로 실행됩니다. 즉 “코드 다운로드”와 “코드 실행”을 분리해, 다운로드는 미리 하되 실행은 필요할 때만 하는 구조입니다. 프리페치가 제대로 동작하지 않는 환경(설정 누락, 캐시 헤더 문제)에서는 첫 상호작용이 눈에 띄게 늦어질 수 있으므로 배포 후 네트워크 탭으로 확인하는 것이 좋습니다.


$와 QRL

$ 접미사의 역할

component$, useTask$, onClick$, routeLoader$처럼 $가 붙은 함수는 단순한 이름 규칙이 아니라 Qwik 옵티마이저(빌드 시 동작하는 컴파일러)에게 보내는 신호입니다. 옵티마이저는 $에 넘긴 함수를 원래 파일에서 떼어 내 별도의 심볼로 내보내고, 원래 자리에는 그 심볼을 가리키는 지연 참조(QRL)를 넣습니다. 그래서 $ 안의 코드는 독립적으로 로드될 수 있는 단위가 됩니다.

모든 함수에 $를 붙여야 하는 것은 아닙니다. $는 프레임워크가 나중에, 다른 시점에, 어쩌면 다른 환경에서 실행할 수 있어야 하는 코드에만 붙습니다. 일반 헬퍼 함수는 평소처럼 작성하면 되고, $ 함수 안에서 import해 쓰면 옵티마이저가 의존성을 따라가 함께 묶습니다.

QRL(Qwik URL)

QRL은 “어떤 청크 파일의 어떤 내보낸 심볼”을 가리키는 직렬화 가능한 참조입니다. 이름에 URL이 들어가는 이유는 실제로 청크 파일의 경로와 심볼 이름으로 구성되어, 브라우저가 그 값만 보고 해당 모듈을 동적으로 import할 수 있기 때문입니다. 개발자가 QRL 문자열을 직접 쓰는 일은 없고, 옵티마이저가 생성해 HTML 속성에 넣습니다.

예시: 클릭 카운터

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

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

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

이 코드가 빌드되면 onClick$에 넘긴 화살표 함수는 별도 심볼로 분리되고, 서버가 렌더링한 <button>에는 그 심볼을 가리키는 on:click 속성이 붙습니다. count 시그널의 현재 값과, 이 버튼의 텍스트가 count에 의존한다는 정보는 직렬화되어 HTML에 함께 들어갑니다. 사용자가 버튼을 누르면 qwikloader가 핸들러 청크를 불러와 실행하고, 핸들러는 직렬화된 상태에서 복원한 count를 1 늘립니다. 시그널은 자신이 연결된 텍스트 노드를 알고 있으므로, Counter 컴포넌트 함수 전체를 다시 실행하지 않고 해당 텍스트만 갱신합니다.

직렬화할 수 없는 값을 캡처하지 않기

$ 함수는 나중에 다른 환경에서 실행될 수 있으므로, 그 함수가 캡처하는 외부 변수도 직렬화할 수 있어야 합니다. 원시값, 평범한 객체와 배열, Date, 시그널과 스토어, 다른 QRL은 직렬화되지만, 임의 클래스 인스턴스, 함수(QRL이 아닌), DOM 노드, 열린 소켓은 그렇지 않습니다. 이런 값을 캡처하면 옵티마이저가 빌드 단계에서 오류를 내거나 런타임 직렬화에서 실패합니다. 직렬화할 수 없는 객체가 꼭 필요하다면 noSerialize()로 감싸 직렬화 대상에서 제외하고, 클라이언트에서 다시 만들도록 설계합니다.


직렬화

HTML에 무엇이 남는가

서버 렌더링 결과에는 화면용 마크업 외에 재개에 필요한 정보가 들어갑니다. Qwik 1.x 기준으로는 요소 속성에 이벤트별 QRL(on:click="...")이 붙고, 페이지 끝의 <script type="qwik/json">에 시그널·스토어 값, 객체 사이의 참조 관계, 어떤 컴포넌트와 DOM 노드가 어떤 상태를 구독하는지가 담깁니다. 컴포넌트 경계와 슬롯 위치는 HTML 주석 마커로 표시됩니다. 구체적인 형식은 버전에 따라 바뀌었고 Qwik 2에서도 다시 설계되었습니다.

이 정보가 있기 때문에 클라이언트는 컴포넌트를 처음부터 실행해 상태를 다시 만들 필요 없이, 필요한 조각만 이어 붙일 수 있습니다.

개발자가 신경 써야 할 것

상태는 Qwik이 직렬화할 수 있는 타입으로 유지합니다. 직렬화된 상태는 HTML 크기에 그대로 더해지므로, 서버에서 불러온 큰 데이터를 통째로 스토어에 넣으면 화면에 쓰지 않는 필드까지 HTML에 실립니다. 필요한 필드만 골라 넣는 것이 좋습니다.

같은 이유로 직렬화된 상태는 페이지 소스를 열면 누구나 볼 수 있습니다. 서버 전용 비밀이나 다른 사용자의 데이터가 상태나 로더 반환값에 섞이지 않도록, 서버에서만 쓰는 값은 routeLoader$나 server$ 안에서만 다루고 결과로 돌려주지 않아야 합니다.

useVisibleTask$를 남용하지 않기

useVisibleTask$는 컴포넌트가 화면에 보이면 브라우저에서 즉시 실행되는 훅입니다. 서드파티 위젯 초기화처럼 브라우저 API가 꼭 필요한 경우에 쓰는 탈출구인데, React의 useEffect처럼 습관적으로 쓰면 해당 컴포넌트의 코드가 페이지 로드 직후 내려받아져 실행되므로 Resumability의 이점이 사라집니다. 서버에서도 실행 가능한 로직은 useTask$로, 사용자 이벤트에 반응하는 로직은 이벤트 핸들러로 옮기는 것이 원칙입니다.


Qwik City 라우팅과 데이터

Qwik City는 Qwik 위의 메타프레임워크로, 파일 기반 라우팅, 레이아웃, 서버 데이터 로딩, 폼 액션을 제공합니다. src/routes 아래의 디렉터리가 URL 경로가 되고, 각 디렉터리의 index.tsx가 페이지, layout.tsx가 하위 라우트를 감싸는 레이아웃입니다. 동적 세그먼트는 [id], 나머지 경로 전체는 [...rest]로 씁니다.

routeLoader$

routeLoader$는 라우트에 붙는 서버 전용 데이터 로더입니다. 페이지를 렌더링하기 전에 서버에서 실행되고, 결과는 직렬화되어 컴포넌트에 전달됩니다. 로더는 해당 라우트 파일(index.tsx나 layout.tsx)에서 export해야 Qwik City가 인식합니다.

// src/routes/products/[id]/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';

type Product = { name: string; price: number };

export const useProduct = routeLoader$(async ({ params, status }) => {
  const res = await fetch(`https://api.example.com/products/${encodeURIComponent(params.id)}`);
  if (!res.ok) {
    status(res.status === 404 ? 404 : 502);
    return null;
  }
  const data = (await res.json()) as Product;
  // 화면에 필요한 필드만 반환 (반환값은 HTML에 직렬화됨)
  return { name: data.name, price: data.price };
});

export default component$(() => {
  const product = useProduct();
  if (!product.value) {
    return <p>상품을 찾을 수 없습니다.</p>;
  }
  return (
    <article>
      <h1>{product.value.name}</h1>
      <p>{product.value.price.toLocaleString()}원</p>
    </article>
  );
});

상품이 없을 때 /404로 리다이렉트하면 응답 코드가 302가 되어 검색엔진이 “존재하지 않는 페이지”로 인식하지 못합니다. 위처럼 상태 코드를 404로 지정하고 같은 URL에서 안내 화면을 그리는 편이 정확합니다. 로더 결과는 라우트와 함께 직렬화되므로 클라이언트에서 같은 데이터를 다시 요청하는 단계가 없습니다. 다만 외부 API가 느리면 그만큼 첫 바이트가 늦어지므로 타임아웃과 캐시 헤더(cacheControl)를 함께 설계해야 합니다.

routeAction$과 server$

폼 제출처럼 서버에서 데이터를 바꾸는 작업은 routeAction$으로 정의하고 <Form action={...}>과 연결합니다. JavaScript가 로드되기 전에도 일반 HTML 폼 제출로 동작하고, 로드된 뒤에는 페이지 새로고침 없이 처리됩니다. zod$로 입력 스키마를 지정하면 검증까지 함께 할 수 있습니다. 라우트와 무관하게 클라이언트 코드에서 서버 함수를 호출하고 싶다면 server$를 씁니다.

두 경우 모두 서버 코드라는 점을 잊지 말아야 합니다. 입력 검증, 권한 확인, CSRF 방어는 프레임워크가 일부 도와주더라도 애플리케이션이 책임져야 하고, 엣지에서 실행된다면 해당 런타임의 API 제약과 CPU 시간 제한 안에서 동작해야 합니다.


성능 특징

첫 로드에서는 HTML이 콘텐츠를 바로 보여 주고, 초기 JavaScript는 qwikloader 수준으로 작습니다. 페이지에 인터랙티브 컴포넌트가 많아도 초기 JS가 늘지 않는다는 점이 하이드레이션 기반 프레임워크와 가장 크게 다른 부분이며, 그래서 상호작용 요소가 많은 랜딩·커머스 페이지에서 효과가 큽니다.

반대로 직렬화된 상태가 커지면 HTML이 무거워지고, 프리페치가 잘못 설정되면 첫 상호작용이 느려지며, 데이터 API가 느리면 프레임워크와 관계없이 첫 바이트가 늦어집니다. 정적 자산, HTML, API 응답의 CDN 캐시 키를 잘못 잡으면 데이터 계층에서 이점을 다 잃을 수도 있습니다.

프로덕션에서는 Core Web Vitals(LCP, INP, CLS)와 함께, 초기 JS 바이트, HTML 크기(직렬화 상태 포함), 상호작용별로 로드되는 청크 목록을 같이 보는 것이 좋습니다.


Next.js vs Qwik

어느 쪽이 항상 빠르다기보다, 문제 정의와 제약에 따라 맞는 쪽이 다릅니다. 두 프레임워크 모두 서버에서 HTML을 만들고, 초기 JavaScript를 줄이고, 스트리밍과 점진적 로딩을 지원한다는 목표는 같습니다. 다른 것은 메커니즘과 주변 조건입니다.

관점Next.js (React)Qwik
클라이언트 실행 모델클라이언트 컴포넌트는 로드 시 하이드레이션상호작용 시점에 필요한 핸들러만 실행
생태계매우 큼. 패키지·자료·인력이 풍부작음. UI 라이브러리 호환을 직접 확인해야 하는 경우가 많음
기존 자산 활용React 컴포넌트를 그대로 사용React 컴포넌트는 qwikify$로 감쌀 수 있지만 그 부분은 하이드레이션되므로, 이점을 살리려면 재작성에 가까움
잘 맞는 경우React 인력과 컴포넌트 자산이 많은 조직초기 상호작용 비용을 극단적으로 줄여야 하는 콘텐츠·커머스 사이트

이미 Next.js와 React 컴포넌트 라이브러리에 깊이 투자했다면, Qwik으로의 전환은 측정 가능한 이득으로 정당화해야 합니다. 새 프로젝트이고 초기 로딩 성능이 비즈니스 지표와 직결된다면, 같은 페이지를 Qwik, Astro, SvelteKit 등으로 프로토타입해 실제 기기에서 비교해 보는 것이 합리적입니다.


엣지 배포

어댑터와 런타임 제약

Qwik City는 어댑터로 Cloudflare Pages, Netlify Edge, Vercel Edge, Deno 같은 엣지 런타임과 Node 서버에 배포할 수 있습니다. 엣지는 사용자와 가까운 곳에서 응답해 지연을 줄이지만, 실행 시간·메모리·지원 API가 제한됩니다. routeLoader$, routeAction$, server$에서 fs 같은 Node 전용 모듈이나 Node 전용 DB 드라이버를 쓰면 빌드는 통과해도 엣지에서 실행할 때 실패할 수 있으므로, 배포 대상 런타임에서 직접 요청을 보내 보는 검증 단계가 필요합니다.

데이터 계층

읽기 위주이고 캐시하기 쉬운 데이터는 CDN 캐시, HTTP 캐시, 엣지 KV와 잘 맞습니다. 반면 데이터베이스가 한 리전에만 있다면, 엣지에서 렌더링하더라도 로더마다 원본 DB까지 왕복해야 해서 엣지의 이점이 사라지거나 오히려 느려질 수 있습니다. 이 경우 읽기 복제본을 사용자 가까이 두거나, 데이터가 필요한 라우트는 DB와 같은 리전의 서버에서 렌더링하는 방식을 고려합니다.

운영에서 확인할 것

빌드 결과에서 라우트와 컴포넌트가 실제로 적절한 크기의 청크로 나뉘었는지 확인합니다. 쿠키 플래그, CSRF 방어, CSP 같은 보안 헤더는 프레임워크와 별개로 점검합니다. 엣지, 원본 API, 클라이언트 각각에 요청 ID를 이어 붙여 추적하면, 느린 요청이 어느 구간에서 생기는지 구분할 수 있습니다.

[사용자] -- HTTPS --> [CDN / 엣지 런타임]
                           ├─> 정적 청크·HTML 캐시
                           └─> SSR(routeLoader$) ──> [API / BFF] ──> [데이터 저장소]

Qwik 계층은 HTML과 청크를 잘 캐시하면 첫 방문 체감이 좋아지고, 데이터 계층은 일관성과 보안을 담당합니다. 한쪽만 최적화해서는 전체 응답 시간이 개선되지 않습니다.


흔한 오해와 디버깅

$를 많이 쓸수록 유리하다는 생각은 틀렸습니다. 청크가 지나치게 잘게 나뉘면 상호작용 한 번에 여러 파일을 받아야 할 수 있습니다. 옵티마이저와 번들러가 관련 심볼을 묶어 주지만, 결과는 네트워크 탭에서 직접 확인해야 합니다.

직렬화만 신경 쓰면 보안이 해결된다는 생각도 틀렸습니다. 직렬화는 상태를 서버에서 클라이언트로 옮기는 방법일 뿐이고, 오히려 직렬화된 상태는 누구나 볼 수 있습니다. 인가, 입력 검증, 세션 관리는 별도로 설계해야 합니다.

문제가 생기면 브라우저 개발자 도구에서 전송된 HTML의 qwik/json 크기와, 상호작용 직후 어떤 .js 청크가 로드되는지를 먼저 봅니다. 페이지 로드 직후 많은 청크가 실행된다면 useVisibleTask$가 원인인 경우가 많습니다. 프로덕션 빌드에서만 재현되는 문제는 소스맵과 릴리스 버전 태깅을 확인합니다.


참고


같이 보면 좋은 글