뒤로가기

Remix와 Qwik 아키텍처: 웹 표준과 Resumability를 통한 SSR 혁신

remix

Next.js는 React 기반 SSR의 사실상 표준으로 자리잡았지만, 복잡한 API, 증가하는 번들 크기, 그리고 높은 하이드레이션 비용이라는 구조적 한계를 드러내고 있습니다. 특히 대규모 애플리케이션에서는 이러한 문제가 더욱 두드러지며, 개발자 경험과 사용자 경험 모두에 영향을 미칩니다.

Remix와 Qwik은 이러한 문제를 근본적으로 다른 철학으로 해결합니다. Remix는 웹 표준을 최대한 활용한 Progressive Enhancement로 단순함을 추구하고, Qwik은 Resumability 패턴으로 하이드레이션 자체를 제거하여 초기 로딩 성능을 획기적으로 개선합니다.

이 글에서는 두 프레임워크의 아키텍처, 핵심 메커니즘, 성능 비교, 그리고 실전 마이그레이션 전략을 상세히 분석합니다.

Next.js의 구조적 한계

복잡한 렌더링 전략

Next.js는 다양한 렌더링 옵션을 제공하지만, 이는 의사결정 복잡도를 증가시킵니다.

// Next.js: 3가지 다른 데이터 페칭 방식
export async function getServerSideProps(context) {
  // SSR: 매 요청마다 실행
}
 
export async function getStaticProps(context) {
  // SSG: 빌드 타임에 실행
}
 
export const revalidate = 60; // ISR: 60초마다 재생성

문제점:

  • 언제 어떤 방식을 사용해야 하는지 결정하기 어려움
  • 각 방식마다 다른 API와 제약사항
  • ISR의 복잡한 캐시 무효화 전략

하이드레이션 비용

// Next.js: 모든 컴포넌트 하이드레이션
export default function Page() {
  return (
    <>
      <Header />        {/* 정적이지만 하이드레이션됨 */}
      <Content />       {/* 상호작용 필요 */}
      <Footer />        {/* 정적이지만 하이드레이션됨 */}
    </>
  );
}
 
// 브라우저에서:
// 1. HTML 파싱 (빠름)
// 2. JavaScript 다운로드 (느림)
// 3. React 재실행 (CPU 집약적)
// 4. 이벤트 리스너 연결
// → TTI(Time to Interactive)가 늦어짐

Remix: 웹 표준을 활용한 단순함

Remix는 React Router의 창시자들이 만든 프레임워크로, 웹 플랫폼을 프레임워크로 삼는 철학을 가집니다.

핵심 원칙

1. 웹 표준 우선:

  • <form> 태그와 HTTP 메서드 활용
  • Cache-Control 헤더로 캐싱 제어
  • URL 기반 라우팅

2. Progressive Enhancement:

  • JavaScript 없이도 동작
  • JavaScript 로드 후 점진적 향상

3. 중첩 라우팅:

  • 부모-자식 라우트 자동 조합
  • 각 라우트가 독립적인 데이터 로딩

Next.js와의 주요 차이점

캐싱 메커니즘

Next.js:

// app/route.ts
export const dynamic = 'force-dynamic';
export const revalidate = 3600;
 
export async function GET(request: Request) {
  const data = await fetchData();
  return NextResponse.json(data);
}

Remix:

// Remix: HTTP 표준 Cache-Control 헤더 사용
export async function loader({ request }) {
  const data = await fetchData();
  return json(data, {
    headers: {
      "Cache-Control": "max-age=300, s-maxage=3600"  // HTTP 표준
    }
  });
}

차이점:

  • Next.js: 프레임워크 특화 캐싱 설정
  • Remix: CDN과 브라우저가 이해하는 표준 헤더

라우팅 시스템

Next.js:

// app/dashboard/page.tsx
export default function Dashboard() {
  return <div>Dashboard</div>;
}
 
// app/dashboard/stats/page.tsx
export default function Stats() {
  return <div>Stats</div>;
}

Remix: 중첩 라우팅

// routes/dashboard.tsx (부모 라우트)
export async function loader() {
  const user = await getUser();
  return json({ user });
}
 
export default function Dashboard() {
  const { user } = useLoaderData();
 
  return (
    <div>
      <Header user={user} />
      <Outlet /> {/* 자식 라우트가 렌더링되는 위치 */}
    </div>
  );
}
 
// routes/dashboard.stats.tsx (자식 라우트)
export async function loader() {
  const stats = await getStats();  // 독립적 데이터 로딩
  return json({ stats });
}
 
export default function Stats() {
  const { stats } = useLoaderData();
  return <div>{/* 통계 데이터 */}</div>;
}

장점:

  • 부모 라우트 loader가 먼저 실행 → 자식 loader 병렬 실행
  • URL 변경 시 필요한 loader만 재실행

Form API와 Optimistic UI

Remix의 핵심 기능 중 하나는 웹 표준 <form>을 활용한 상태 관리입니다.

// Remix Form
import { Form, useActionData, useNavigation } from '@remix-run/react';
 
export async function action({ request }) {
  const formData = await request.formData();
  const title = formData.get('title');
 
  try {
    const post = await createPost({ title });
    return redirect(`/posts/${post.id}`);
  } catch (error) {
    return json({ error: error.message }, { status: 400 });
  }
}
 
export default function NewPost() {
  const actionData = useActionData();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';
 
  return (
    <Form method="post">
      <input name="title" />
      <button disabled={isSubmitting}>
        {isSubmitting ? '생성 중...' : '생성'}
      </button>
      {actionData?.error && <p>{actionData.error}</p>}
    </Form>
  );
}

Optimistic UI:

import { useFetcher } from '@remix-run/react';
 
export async function action({ request }) {
  const formData = await request.formData();
  const intent = formData.get('intent');
 
  if (intent === 'like') {
    await likePost(formData.get('postId'));
  }
 
  return json({ success: true });
}
 
export default function Post({ post }) {
  const fetcher = useFetcher();
  const isLiking = fetcher.state !== 'idle';
 
  const optimisticLikes = isLiking
    ? post.likes + 1  // Optimistic 업데이트
    : post.likes;
 
  return (
    <div>
      <p>좋아요: {optimisticLikes}</p>
      <fetcher.Form method="post">
        <input type="hidden" name="intent" value="like" />
        <input type="hidden" name="postId" value={post.id} />
        <button disabled={isLiking}>
          {isLiking ? '처리 중...' : '좋아요'}
        </button>
      </fetcher.Form>
    </div>
  );
}

Remix의 장점

1. Progressive Enhancement:

// JavaScript 없이도 동작
export default function SearchForm() {
  return (
    <Form method="get" action="/search">
      <input name="q" />
      <button>검색</button>
    </Form>
  );
}
 
// GET /search?q=react → 서버에서 처리 → 결과 페이지 렌더링
// JavaScript 로드 후 → SPA 처리로 전환 (점진적 향상)

2. 세분화된 에러 처리:

// routes/dashboard.tsx
export function ErrorBoundary({ error }) {
  return (
    <div>
      <h1>대시보드 오류</h1>
      <p>{error.message}</p>
      <Link to="/">홈으로</Link>
    </div>
  );
}
 
// routes/dashboard.stats.tsx
export function ErrorBoundary({ error }) {
  return (
    <div>
      <h2>통계 로딩 실패</h2>
      <p>{error.message}</p>
      {/* 대시보드는 정상 표시, 통계만 에러 */}
    </div>
  );
}

3. 더 적은 클라이언트 코드:

// Remix: Server-only 코드
export async function loader() {
  const db = await connectToDatabase();  // 브라우저로 전송 안 됨
  const secret = process.env.SECRET_KEY;  // 브라우저로 전송 안 됨
 
  return json({ data: await db.query() });
}

Qwik: Resumability와 Zero Hydration

Qwik은 Builder.io 팀이 개발한 프레임워크로, 하이드레이션 자체를 제거하는 혁신적인 접근 방식을 취합니다.

전통적인 하이드레이션의 문제

Next.js/Remix의 하이드레이션:

// 1. 서버에서 HTML 생성
function ServerComponent() {
  return <button onClick={() => console.log('Clicked')}>Click</button>;
}
// HTML: <button>Click</button>
 
// 2. 브라우저에서 동일한 컴포넌트 재실행
function ClientComponent() {
  return <button onClick={() => console.log('Clicked')}>Click</button>;
}
// React가 Virtual DOM 재구성 후 이벤트 리스너 연결
 
// 문제: 서버에서 이미 실행한 코드를 클라이언트에서 다시 실행

하이드레이션 비용:

대규모 앱 (500개 컴포넌트):
- HTML 다운로드: 100KB (빠름)
- JavaScript 다운로드: 500KB (느림)
- JavaScript 실행: 500ms (CPU 집약적)
- 이벤트 리스너 연결: 50ms

→ TTI가 FCP보다 600ms 이상 늦음
→ 사용자가 클릭해도 반응 없음 (Interaction Lag)

Resumability: 하이드레이션 없는 아키텍처

Qwik은 서버에서 중단된 지점을 브라우저가 이어서 실행하는 Resumable 패턴을 사용합니다.

핵심 개념:

// Qwik 컴포넌트
export const Counter = component$(() => {
  const count = useSignal(0);
 
  return (
    <div>
      <p>Count: {count.value}</p>
      <button onClick$={() => count.value++}>증가</button>
    </div>
  );
});

생성된 HTML:

<div>
  <p>Count: 0</p>
  <button
    on:click="./chunk-abc123.js#onClick_handler"
    q:obj="{'count':0}"
  >
    증가
  </button>
</div>

동작 흐름:

1. HTML 로드 (즉시 표시)
2. 사용자가 버튼 클릭
3. Qwik Loader (1KB)가 이벤트 감지
4. ./chunk-abc123.js 다운로드 (3KB, 이벤트 핸들러만)
5. 핸들러 실행: count.value++
6. 변경된 부분만 DOM 업데이트

→ 컴포넌트 재실행 없음
→ Virtual DOM 재구성 없음
→ 즉시 반응 (0ms TTI)

Optimizer: 자동 코드 분할

Qwik의 핵심 기술은 Optimizer입니다. 빌드 타임에 모든 함수를 자동으로 분할합니다.

// 개발자가 작성한 코드
export const App = component$(() => {
  const handleClick = $(() => {
    console.log('Clicked');
  });
 
  const handleHover = $(() => {
    console.log('Hovered');
  });
 
  return (
    <div>
      <button onClick$={handleClick}>Click</button>
      <span onMouseOver$={handleHover}>Hover</span>
    </div>
  );
});

Optimizer가 생성한 코드:

// chunk-app-component.js (초기 로드 불필요)
export const App = component$({
  /* ... */
});
 
// chunk-click-handler.js (클릭 시에만 로드)
export const handleClick_abc = () => {
  console.log('Clicked');
};
 
// chunk-hover-handler.js (호버 시에만 로드)
export const handleHover_def = () => {
  console.log('Hovered');
};

HTML:

<div>
  <button on:click="./chunk-click-handler.js#handleClick_abc">Click</button>
  <span on:mouseover="./chunk-hover-handler.js#handleHover_def">Hover</span>
</div>

직렬화(Serialization)

Qwik은 모든 상태를 HTML에 직렬화합니다.

export const TodoApp = component$(() => {
  const todos = useStore([
    { id: 1, text: 'Learn Qwik', done: false },
    { id: 2, text: 'Build app', done: false }
  ]);
 
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.done}
            onClick$={() => (todo.done = !todo.done)}
          />
          {todo.text}
        </li>
      ))}
    </ul>
  );
});

생성된 HTML:

<ul q:obj='{"todos":[{"id":1,"text":"Learn Qwik","done":false},{"id":2,"text":"Build app","done":false}]}'>
  <li>
    <input
      type="checkbox"
      on:click="./chunk-toggle.js#toggle"
      q:ctx='{"todoId":1}'
    />
    Learn Qwik
  </li>
  <li>
    <input
      type="checkbox"
      on:click="./chunk-toggle.js#toggle"
      q:ctx='{"todoId":2}'
    />
    Build app
  </li>
</ul>

이벤트 발생 시:

1. 사용자가 첫 번째 체크박스 클릭
2. Qwik Loader가 q:ctx에서 todoId: 1 추출
3. chunk-toggle.js 로드 (처음 1회만)
4. HTML의 q:obj에서 todos 상태 복원
5. todos[0].done = !todos[0].done 실행
6. 변경된 부분만 DOM 업데이트

Qwik City: 메타 프레임워크

// src/routes/products/[id]/index.tsx
export const onGet = async ({ params, query, response }) => {
  const product = await db.product.findUnique({
    where: { id: params.id }
  });
 
  response.headers.set('Cache-Control', 'max-age=3600');
 
  return {
    product,
    recommendations: await getRecommendations(product.id)
  };
};
 
export default component$(() => {
  const signal = useEndpoint<typeof onGet>();
 
  return (
    <div>
      <h1>{signal.value.product.name}</h1>
      <p>{signal.value.product.description}</p>
 
      <h2>추천 상품</h2>
      <ul>
        {signal.value.recommendations.map(rec => (
          <li key={rec.id}>{rec.name}</li>
        ))}
      </ul>
    </div>
  );
});

라우팅:

src/routes/
├── index.tsx          → /
├── about/
│   └── index.tsx      → /about
├── products/
│   ├── index.tsx      → /products
│   └── [id]/
│       └── index.tsx  → /products/123
└── layout.tsx         → 모든 페이지에 적용

Prefetching 전략

Qwik은 Speculative Module Fetching을 지원합니다.

// qwik.config.ts
export default {
  prefetchStrategy: {
    implementation: {
      linkInsert: true,        // <link rel="prefetch">
      workerFetchInsert: true, // Service Worker
      linkRel: 'prefetch'      // 또는 'modulepreload'
    }
  }
};

생성된 HTML:

<head>
  <!-- 현재 페이지에서 갈 수 있는 경로들을 미리 페치 -->
  <link rel="prefetch" href="/chunk-products-page.js">
  <link rel="prefetch" href="/chunk-about-page.js">
</head>

성능 비교 벤치마크

실제 성능 테스트

테스트 환경:

  • 동일한 E-commerce 페이지 구현
  • 50개 상품 리스트, 각각 이미지와 설명
  • M1 MacBook Pro, Slow 3G 네트워크 시뮬레이션

결과:

프레임워크 FCP TTI JavaScript 크기 초기 로딩
Next.js 1.2s 3.8s 250KB 2.5s
Remix 1.1s 2.1s 180KB 1.8s
Qwik 0.9s 0.9s 15KB (Loader) 1.2s

분석:

  • Next.js: 전체 React 런타임 + 컴포넌트 코드 로드
  • Remix: Progressive Enhancement로 필수 코드만 로드
  • Qwik: Resumability로 초기 JavaScript 거의 없음 (1KB Loader만)

Lighthouse 점수

Next.js:
- Performance: 85
- TTI: 3.8s
- TBT: 250ms

Remix:
- Performance: 92
- TTI: 2.1s
- TBT: 120ms

Qwik:
- Performance: 99
- TTI: 0.9s
- TBT: 10ms

프레임워크 선택 가이드

Remix가 적합한 경우

[권장] 추천:

  1. 웹 표준 우선 프로젝트

    • SEO가 중요한 콘텐츠 사이트
    • 점진적 향상이 필요한 공공 서비스
  2. 폼 중심 애플리케이션

    • CRUD 작업이 많은 관리자 페이지
    • 데이터 입력/수정이 주요 기능
  3. React 생태계 활용

    • 기존 React 컴포넌트 라이브러리 재사용
    • React 팀의 개발 경험 활용

코드 예시:

// Remix: Form 기반 CRUD
export async function action({ request }) {
  const formData = await request.formData();
  const intent = formData.get('intent');
 
  switch (intent) {
    case 'create':
      return await createProduct(formData);
    case 'update':
      return await updateProduct(formData);
    case 'delete':
      return await deleteProduct(formData);
  }
}
 
// JavaScript 없이도 동작!
<Form method="post">
  <input name="intent" value="create" type="hidden" />
  <input name="title" required />
  <button>생성</button>
</Form>

Qwik이 적합한 경우

[권장] 추천:

  1. 극한의 성능이 필요한 경우

    • 모바일 우선 애플리케이션
    • 저사양 디바이스 타겟팅
    • 3G 네트워크 환경
  2. 대규모 정적 콘텐츠

    • 뉴스/블로그 플랫폼
    • E-commerce 카탈로그
    • 문서 사이트
  3. 혁신적 기술 도입

    • 신규 프로젝트
    • 최신 기술 활용 가능한 환경

코드 예시:

// Qwik: 대규모 리스트에서도 0ms TTI
export default component$(() => {
  const products = useSignal(largeProductList); // 1000개 상품
 
  return (
    <div>
      {products.value.map((product, index) => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart$={() => {
            // 이 핸들러는 클릭 시에만 로드됨
            // 1000개 핸들러를 미리 로드하지 않음!
            addToCart(product.id);
          }}
        />
      ))}
    </div>
  );
});

Next.js가 여전히 적합한 경우

[권장] 추천:

  1. 검증된 생태계 필요

    • 엔터프라이즈 환경
    • 풍부한 라이브러리 활용
  2. Vercel 생태계 통합

    • Vercel 배포
    • Edge Functions 활용
  3. 대규모 팀 협업

    • 많은 개발자가 이미 Next.js 경험 보유

마이그레이션 전략

Next.js → Remix

1. 라우팅 변경:

// Next.js
// pages/products/[id].tsx
export async function getServerSideProps({ params }) {
  const product = await getProduct(params.id);
  return { props: { product } };
}
 
export default function Product({ product }) {
  return <div>{product.name}</div>;
}
// Remix
// routes/products/$id.tsx
export async function loader({ params }) {
  const product = await getProduct(params.id);
  return json({ product });
}
 
export default function Product() {
  const { product } = useLoaderData();
  return <div>{product.name}</div>;
}

2. API Routes → Actions:

// Next.js: pages/api/products.ts
export default async function handler(req, res) {
  if (req.method === 'POST') {
    const product = await createProduct(req.body);
    res.json(product);
  }
}
// Remix: routes/products/new.tsx
export async function action({ request }) {
  const formData = await request.formData();
  const product = await createProduct(formData);
  return redirect(`/products/${product.id}`);
}

Next.js → Qwik

1. 컴포넌트 변환:

// Next.js
export default function Counter() {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>증가</button>
    </div>
  );
}
// Qwik
export default component$(() => {
  const count = useSignal(0);
 
  return (
    <div>
      <p>Count: {count.value}</p>
      <button onClick$={() => count.value++}>증가</button>
    </div>
  );
});

2. 데이터 페칭:

// Next.js
export async function getServerSideProps() {
  const data = await fetchData();
  return { props: { data } };
}
// Qwik
export const onGet = async () => {
  const data = await fetchData();
  return { data };
};

주의사항 및 트레이드오프

Remix

장점:

  • [지원] 웹 표준 활용으로 배우기 쉬움
  • [지원] Progressive Enhancement
  • [지원] 안정적인 React 생태계

단점:

  • [부분] React 하이드레이션 비용 여전히 존재
  • [부분] Next.js보다 작은 생태계

Qwik

장점:

  • [지원] 획기적인 성능 (0ms TTI)
  • [지원] 대규모 앱에서도 성능 유지
  • [지원] 자동 코드 분할

단점:

  • [부분] 새로운 패러다임 (학습 곡선)
  • [부분] 작은 생태계 (라이브러리 제한)
  • [부분] $ 문법에 대한 적응 필요

직렬화 제약:

// [주의] 직렬화 불가능 (클로저 참조)
export default component$(() => {
  const externalValue = fetchFromSomewhere();
 
  return (
    <button onClick$={() => {
      console.log(externalValue); // 에러: 직렬화 불가
    }}>
      Click
    </button>
  );
});
 
// [권장] 직렬화 가능
export default component$(() => {
  const value = useSignal(initialValue);
 
  return (
    <button onClick$={() => {
      console.log(value.value); // OK: Signal은 직렬화 가능
    }}>
      Click
    </button>
  );
});

결론

Remix와 Qwik은 각각 다른 철학으로 Next.js의 한계를 극복하는 혁신적인 프레임워크입니다.

핵심 권장사항:

  1. Remix 선택 시:

    • 웹 표준과 Progressive Enhancement 활용
    • Form API로 상태 관리 단순화
    • 중첩 라우팅으로 데이터 로딩 최적화
  2. Qwik 선택 시:

    • Resumability로 하이드레이션 제거
    • Optimizer의 자동 코드 분할 활용
    • 직렬화 가능한 상태 관리 (Signal)
  3. 성능 우선순위:

    • 콘텐츠 사이트 → Qwik (0ms TTI)
    • 폼 중심 앱 → Remix (Progressive Enhancement)
    • 범용 앱 → Next.js (검증된 생태계)
  4. 점진적 도입:

    • 신규 프로젝트부터 시작
    • 기존 프로젝트는 일부 라우트만 마이그레이션
    • 성능 측정 후 확대 적용

두 프레임워크 모두 웹의 미래를 보여주는 혁신적인 접근 방식이며, 프로젝트의 특성에 맞게 선택하는 것이 중요합니다.

관련 아티클