Styled-components SSR: CSS-in-JS와 서버 사이드 렌더링의 통합 전략
Next.js에서 styled-components를 사용한 서버 사이드 렌더링 구현, 하이드레이션 불일치 해결 및 성능 최적화 완벽 가이드
Next.js는 React 기반 SSR의 사실상 표준으로 자리잡았지만, 복잡한 API, 증가하는 번들 크기, 그리고 높은 하이드레이션 비용이라는 구조적 한계를 드러내고 있습니다. 특히 대규모 애플리케이션에서는 이러한 문제가 더욱 두드러지며, 개발자 경험과 사용자 경험 모두에 영향을 미칩니다.
Remix와 Qwik은 이러한 문제를 근본적으로 다른 철학으로 해결합니다. Remix는 웹 표준을 최대한 활용한 Progressive Enhancement로 단순함을 추구하고, Qwik은 Resumability 패턴으로 하이드레이션 자체를 제거하여 초기 로딩 성능을 획기적으로 개선합니다.
이 글에서는 두 프레임워크의 아키텍처, 핵심 메커니즘, 성능 비교, 그리고 실전 마이그레이션 전략을 상세히 분석합니다.
Next.js는 다양한 렌더링 옵션을 제공하지만, 이는 의사결정 복잡도를 증가시킵니다.
// Next.js: 3가지 다른 데이터 페칭 방식
export async function getServerSideProps(context) {
// SSR: 매 요청마다 실행
}
export async function getStaticProps(context) {
// SSG: 빌드 타임에 실행
}
export const revalidate = 60; // ISR: 60초마다 재생성문제점:
// Next.js: 모든 컴포넌트 하이드레이션
export default function Page() {
return (
<>
<Header /> {/* 정적이지만 하이드레이션됨 */}
<Content /> {/* 상호작용 필요 */}
<Footer /> {/* 정적이지만 하이드레이션됨 */}
</>
);
}
// 브라우저에서:
// 1. HTML 파싱 (빠름)
// 2. JavaScript 다운로드 (느림)
// 3. React 재실행 (CPU 집약적)
// 4. 이벤트 리스너 연결
// → TTI(Time to Interactive)가 늦어짐Remix는 React Router의 창시자들이 만든 프레임워크로, 웹 플랫폼을 프레임워크로 삼는 철학을 가집니다.
1. 웹 표준 우선:
<form> 태그와 HTTP 메서드 활용Cache-Control 헤더로 캐싱 제어2. Progressive Enhancement:
3. 중첩 라우팅:
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:
// 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>;
}장점:
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>
);
}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은 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)
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)
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>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 업데이트
// 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 → 모든 페이지에 적용
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>테스트 환경:
결과:
| 프레임워크 | 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:
- Performance: 85
- TTI: 3.8s
- TBT: 250ms
Remix:
- Performance: 92
- TTI: 2.1s
- TBT: 120ms
Qwik:
- Performance: 99
- TTI: 0.9s
- TBT: 10ms
[권장] 추천:
웹 표준 우선 프로젝트
폼 중심 애플리케이션
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: 대규모 리스트에서도 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>
);
});[권장] 추천:
검증된 생태계 필요
Vercel 생태계 통합
대규모 팀 협업
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}`);
}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 };
};장점:
단점:
장점:
단점:
$ 문법에 대한 적응 필요직렬화 제약:
// [주의] 직렬화 불가능 (클로저 참조)
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의 한계를 극복하는 혁신적인 프레임워크입니다.
핵심 권장사항:
Remix 선택 시:
Qwik 선택 시:
성능 우선순위:
점진적 도입:
두 프레임워크 모두 웹의 미래를 보여주는 혁신적인 접근 방식이며, 프로젝트의 특성에 맞게 선택하는 것이 중요합니다.
Next.js에서 styled-components를 사용한 서버 사이드 렌더링 구현, 하이드레이션 불일치 해결 및 성능 최적화 완벽 가이드