RSC·SSR·하이드레이션을 이해하고 프로덕션 성능을 측정·배포하기.
서버 컴포넌트·렌더링 전략과 운영 배포
입문편에서 useState와 useEffect를 중심으로 클라이언트 렌더링의 기초를 익혔다면, 실제 프로덕션 애플리케이션에서는 그것만으로 충분하지 않습니다. 초기 로딩 속도, 검색 엔진 노출, 서버 자원 활용, 번들 크기 최적화는 모두 어디서, 어떻게 렌더링하느냐에 달려 있습니다. React 19와 Next.js App Router가 정착한 현재, RSC(React Server Components)는 단순한 옵션이 아니라 아키텍처의 기본 축이 되었습니다.
이번 레슨에서는 RSC와 클라이언트 컴포넌트의 경계를 명확히 이해하고, SSR/SSG/ISR/스트리밍 전략을 상황에 맞게 선택하는 방법을 살펴봅니다. 이어서 하이드레이션 원리와 mismatch 디버깅, 코드 스플리팅, Core Web Vitals 최적화, 그리고 캐싱과 CDN을 포함한 프로덕션 배포 고려사항까지 다룹니다.
학습 목표
- RSC(React Server Components) 와 클라이언트 컴포넌트의 경계를 설계하고 올바른 방향으로 데이터를 전달할 수 있다.
- SSR/SSG/ISR/스트리밍 각 전략의 트레이드오프를 이해하고 페이지 성질에 맞는 전략을 선택할 수 있다.
- 하이드레이션 mismatch 의 원인을 진단하고 수정할 수 있다.
React.lazy와 동적import로 코드 스플리팅을 적용해 초기 번들 크기를 줄일 수 있다.- Core Web Vitals(LCP/INP/CLS) 를 측정하고 주요 병목을 개선할 수 있다.
RSC와 클라이언트 컴포넌트의 경계
두 종류의 컴포넌트
React Server Components(RSC)는 서버에서만 실행됩니다. 브라우저에 JavaScript 번들을 전혀 포함하지 않으며, DB 직접 접근·파일 시스템 읽기·비공개 환경변수 사용이 가능합니다. 반면 클라이언트 컴포넌트는 파일 최상단에 'use client'를 선언하며, useState·useEffect 같은 훅과 이벤트 핸들러를 쓸 수 있습니다.
| 특성 | 서버 컴포넌트 (RSC) | 클라이언트 컴포넌트 |
|---|---|---|
| 실행 위치 | 서버 | 서버(SSR) + 브라우저 |
| JS 번들 포함 | ❌ | ✅ |
useState / useEffect | ❌ | ✅ |
| DB / 파일 시스템 접근 | ✅ | ❌ |
| 이벤트 핸들러 | ❌ | ✅ |
| 선언 방식 | 기본값 | 'use client' |
// ✅ 서버 컴포넌트 — DB 직접 조회, 번들 없음
// app/posts/page.tsx (Next.js App Router 기준)
import { db } from '@/lib/db';
export default async function PostsPage() {
// fetch나 ORM을 바로 사용 — 클라이언트에 노출되지 않음
const posts = await db.post.findMany({ orderBy: { createdAt: 'desc' } });
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
// ✅ 클라이언트 컴포넌트 — 인터랙션 필요
'use client';
import { useState } from 'react';
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked((v) => !v)}>
{liked ? '♥ 좋아요 취소' : '♡ 좋아요'}
</button>
);
}
컴포넌트 트리에서의 경계 설계
'use client' 선언은 경계(boundary) 를 만듭니다. 그 경계 아래의 모든 자식은 클라이언트 컴포넌트로 취급됩니다. 따라서 서버 컴포넌트는 클라이언트 컴포넌트를 import할 수 있지만, 클라이언트 컴포넌트는 서버 컴포넌트를 직접 import할 수 없습니다.
// ✅ 서버 컴포넌트가 클라이언트 컴포넌트를 포함 — 올바른 방향
// app/posts/[id]/page.tsx
import { LikeButton } from '@/components/LikeButton'; // 클라이언트
export default async function PostDetail({ params }: { params: { id: string } }) {
const post = await fetchPost(params.id);
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
{/* 서버에서 렌더한 HTML에 클라이언트 컴포넌트를 삽입 */}
<LikeButton postId={post.id} />
</article>
);
}
// ✅ 서버 컴포넌트를 children으로 전달하는 패턴 — 경계 우회
'use client';
import { ReactNode, useState } from 'react';
export function Modal({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>열기</button>
{open && <div className="modal">{children}</div>}
</>
);
}
// app/page.tsx
import { Modal } from '@/components/Modal';
import { HeavyServerContent } from '@/components/HeavyServerContent'; // 서버 컴포넌트
export default function Page() {
return (
// children은 서버에서 미리 렌더되므로 번들에 포함되지 않음
<Modal>
<HeavyServerContent />
</Modal>
);
}
💡 TIP — 인터랙션이 필요한 부분만
'use client'경계로 분리하고, 정적 콘텐츠는 서버 컴포넌트에 남겨두면 클라이언트 번들 크기를 크게 줄일 수 있습니다. "가능한 한 서버, 필요할 때만 클라이언트"가 기본 원칙입니다.
SSR / SSG / ISR / 스트리밍 렌더링 전략 비교
각 전략은 "언제 HTML을 생성하느냐"의 차이입니다. 페이지의 성질에 맞는 전략을 고르면 성능과 유지보수성 모두 잡을 수 있습니다.
| 전략 | HTML 생성 시점 | 특징 | 적합한 페이지 |
|---|---|---|---|
| CSR (Client-Side Rendering) | 브라우저 | 초기 HTML 비어 있음 | 인증된 대시보드, 관리자 페이지 |
| SSG (Static Site Generation) | 빌드 시 | 가장 빠른 TTFB | 블로그, 문서, 마케팅 랜딩 |
| SSR (Server-Side Rendering) | 요청 시 | 항상 최신 데이터 | 뉴스피드, 가격 페이지 |
| ISR (Incremental Static Regeneration) | 빌드 + 주기적 재생성 | SSG 속도 + 주기적 갱신 | 상품 상세, 공지사항 |
| 스트리밍 | 요청 시, 청크별 전송 | 첫 바이트 빠름 + 점진적 표시 | 복잡한 데이터 혼합 페이지 |
Next.js App Router에서의 전략 선택
Next.js 13+ App Router는 fetch 캐시 옵션으로 전략을 제어합니다.
// SSG — 빌드 시 데이터를 가져와 정적 HTML 생성
async function StaticPage() {
// cache: 'force-cache' 는 기본값 (SSG와 동일)
const data = await fetch('https://api.example.com/posts', {
cache: 'force-cache',
}).then((r) => r.json());
return <PostList posts={data} />;
}
// SSR — 요청마다 새로 패칭
async function DynamicPage() {
const data = await fetch('https://api.example.com/feed', {
cache: 'no-store', // ✅ 매 요청마다 새 데이터
}).then((r) => r.json());
return <Feed items={data} />;
}
// ISR — 60초마다 백그라운드 재생성
async function RevalidatingPage() {
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }, // ✅ 60초 후 stale-while-revalidate
}).then((r) => r.json());
return <ProductList products={data} />;
}
스트리밍 렌더링과 Suspense
스트리밍은 서버에서 HTML을 한 번에 보내는 대신 준비된 부분부터 순서대로 보냅니다. Next.js App Router에서는 Suspense를 배치하는 것만으로 자동으로 스트리밍이 활성화됩니다.
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { Skeleton } from '@/components/ui/Skeleton';
// ✅ 각 섹션을 Suspense로 감싸면 준비된 순서대로 스트리밍됨
export default function DashboardPage() {
return (
<main>
{/* 빠른 데이터 — 먼저 표시 */}
<Suspense fallback={<Skeleton className="h-20" />}>
<UserGreeting />
</Suspense>
{/* 느린 데이터 — 나중에 교체 */}
<Suspense fallback={<Skeleton className="h-64" />}>
<AnalyticsChart />
</Suspense>
<Suspense fallback={<Skeleton className="h-40" />}>
<RecentActivity />
</Suspense>
</main>
);
}
⚠️ 주의 — 스트리밍은 응답의
Content-Type이text/html이고 HTTP/1.1 이상이어야 합니다. 일부 CDN·프록시는 응답을 버퍼링해 스트리밍 효과를 없앱니다. Vercel, Cloudflare Workers, AWS Lambda Response Streaming처럼 스트리밍을 명시적으로 지원하는 플랫폼을 선택해야 합니다.
하이드레이션의 동작 원리와 Mismatch 디버깅
하이드레이션이란
서버에서 렌더한 정적 HTML에 React가 이벤트 핸들러와 상태를 "부착"하는 과정을 하이드레이션(hydration) 이라고 합니다. React는 서버 HTML을 다시 그리는 대신, DOM 노드를 재활용하며 onClick 같은 이벤트만 연결합니다. 이를 통해 초기 페이지가 빠르게 보이면서도 인터랙티브해집니다.
[서버] 렌더링 → HTML 문자열 전송
<div id="root"><button>카운트: 0</button></div>
[클라이언트] 하이드레이션
1. React가 DOM을 탐색하며 서버 트리와 비교
2. 일치하면 DOM 노드 재활용 + 이벤트 핸들러 부착
3. 불일치(mismatch)하면 경고 후 클라이언트에서 재렌더
Mismatch의 주요 원인과 해결
하이드레이션 mismatch는 서버에서 렌더한 HTML과 클라이언트의 첫 렌더 결과가 다를 때 발생합니다. 흔한 원인들을 살펴봅니다.
원인 1: Date.now() / Math.random() 직접 사용
// ❌ 서버와 클라이언트에서 다른 값 생성 → mismatch
function Timestamp() {
return <span>{new Date().toLocaleString()}</span>;
}
// ✅ useEffect로 클라이언트에서만 갱신
'use client';
import { useState, useEffect } from 'react';
function Timestamp() {
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
setTime(new Date().toLocaleString());
}, []);
return <span>{time ?? '...'}</span>;
}
원인 2: typeof window 분기
// ❌ 서버에서는 window가 없으므로 다른 HTML 렌더
function ThemeIcon() {
const isDark = typeof window !== 'undefined'
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
return <span>{isDark ? '🌙' : '☀️'}</span>;
}
// ✅ 클라이언트 전용 컴포넌트로 분리
'use client';
import { useState, useEffect } from 'react';
function ThemeIcon() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
setIsDark(window.matchMedia('(prefers-color-scheme: dark)').matches);
}, []);
return <span>{isDark ? '🌙' : '☀️'}</span>;
}
원인 3: 브라우저 전용 API를 렌더 중에 호출
// ❌ localStorage는 서버에 없음
function Cart() {
const count = JSON.parse(localStorage.getItem('cart') ?? '[]').length;
return <span>장바구니 {count}개</span>;
}
// ✅ suppressHydrationWarning + useEffect 패턴
'use client';
import { useState, useEffect } from 'react';
function Cart() {
const [count, setCount] = useState(0);
useEffect(() => {
const items = JSON.parse(localStorage.getItem('cart') ?? '[]');
setCount(items.length);
}, []);
return <span suppressHydrationWarning>장바구니 {count}개</span>;
}
💡 TIP — 개발 모드에서는 브라우저 콘솔에
Warning: Text content did not match메시지가 출력됩니다. 이를 무시하면 프로덕션에서 깜빡임(flash of incorrect content)과 레이아웃 시프트가 발생하므로 반드시 수정해야 합니다.
코드 스플리팅·Lazy·동적 Import로 번들 최적화
초기 JavaScript 번들이 클수록 파싱·컴파일 시간이 늘어나고 LCP가 나빠집니다. 코드 스플리팅은 필요한 시점에만 필요한 코드를 로드해 초기 번들을 줄이는 기법입니다.
React.lazy와 Suspense
import { lazy, Suspense, useState } from 'react';
// ✅ 동적 import — 이 컴포넌트가 실제 렌더될 때 청크를 로드
const HeavyEditor = lazy(() => import('./HeavyEditor'));
const PdfViewer = lazy(() => import('./PdfViewer'));
function App() {
const [view, setView] = useState<'editor' | 'pdf' | null>(null);
return (
<div>
<button onClick={() => setView('editor')}>에디터 열기</button>
<button onClick={() => setView('pdf')}>PDF 보기</button>
<Suspense fallback={<p>컴포넌트 로딩 중...</p>}>
{view === 'editor' && <HeavyEditor />}
{view === 'pdf' && <PdfViewer />}
</Suspense>
</div>
);
}
라우트 기반 스플리팅 (Next.js App Router)
Next.js App Router는 각 page.tsx를 자동으로 별도 청크로 분리합니다. 추가로 무거운 컴포넌트는 dynamic으로 지연 로드할 수 있습니다.
// Next.js dynamic import
import dynamic from 'next/dynamic';
// SSR 비활성화 — 브라우저 전용 라이브러리(차트, 에디터 등)에 유용
const ChartComponent = dynamic(() => import('@/components/Chart'), {
ssr: false,
loading: () => <div className="animate-pulse h-64 bg-gray-100" />,
});
// SSR 유지 + 스플리팅
const HeavyTable = dynamic(() => import('@/components/HeavyTable'));
export default function ReportPage() {
return (
<div>
<ChartComponent />
<HeavyTable />
</div>
);
}
사전 로드(prefetch)로 UX 개선
'use client';
import { lazy, Suspense, useState } from 'react';
const ModalContent = lazy(() => import('./ModalContent'));
// ✅ 버튼에 마우스를 올리는 순간 미리 로드 — 클릭 시 즉시 표시
function LazyModal() {
const [open, setOpen] = useState(false);
function handleMouseEnter() {
// dynamic import를 미리 트리거
import('./ModalContent');
}
return (
<>
<button onMouseEnter={handleMouseEnter} onClick={() => setOpen(true)}>
모달 열기
</button>
{open && (
<Suspense fallback={<span>로딩 중...</span>}>
<ModalContent onClose={() => setOpen(false)} />
</Suspense>
)}
</>
);
}
⚠️ 주의 —
React.lazy는 default export만 지원합니다. named export를 lazy로 로드하려면 래퍼 파일을 만들거나dynamic(() => import('./Foo').then(m => ({ default: m.NamedComponent })))처럼 변환하세요.
Core Web Vitals 측정과 개선
Google이 정의한 Core Web Vitals는 실사용자 경험을 수치화합니다. 2024년 기준 세 가지 지표가 검색 순위에 영향을 줍니다.
| 지표 | 의미 | Good 기준 | 주요 원인 |
|---|---|---|---|
| LCP (Largest Contentful Paint) | 가장 큰 콘텐츠 렌더 시간 | ≤ 2.5s | 느린 서버, 큰 이미지, 렌더 차단 JS |
| INP (Interaction to Next Paint) | 인터랙션 응답 지연 | ≤ 200ms | 무거운 이벤트 핸들러, 긴 태스크 |
| CLS (Cumulative Layout Shift) | 예상치 못한 레이아웃 이동 | ≤ 0.1 | 크기 없는 이미지·광고, 웹 폰트 |
LCP 개선
LCP는 주로 히어로 이미지나 대형 텍스트 블록이 대상입니다.
// Next.js Image 컴포넌트로 LCP 이미지 최적화
import Image from 'next/image';
function HeroBanner() {
return (
<Image
src="/hero.webp"
alt="히어로 이미지"
width={1200}
height={600}
// ✅ LCP 대상 이미지는 priority로 preload
priority
// ✅ 크기 명시 → CLS 방지
style={{ width: '100%', height: 'auto' }}
/>
);
}
<!-- HTML 수동 설정 시 — <head>에 preload 추가 -->
<link
rel="preload"
as="image"
href="/hero.webp"
imagesrcset="/hero-480.webp 480w, /hero-1200.webp 1200w"
imagesizes="100vw"
/>
INP 개선
INP는 React 18의 동시성 기능과 직접 연결됩니다. 2강에서 다룬 useTransition이 핵심 수단입니다.
'use client';
import { useState, useTransition, startTransition } from 'react';
function FilterableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(items);
const [isPending, startTransitionFn] = useTransition();
function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // 긴급 — 입력 즉시 반영
startTransitionFn(() => {
// ✅ 비긴급 — 긴급 업데이트(입력 반영)가 먼저 페인트되어 인터랙션 응답이 빨라짐(INP 개선)
setFiltered(items.filter((item) => item.label.includes(value)));
});
}
return (
<>
<input value={query} onChange={handleInput} />
<div style={{ opacity: isPending ? 0.6 : 1 }}>
{filtered.map((item) => <div key={item.id}>{item.label}</div>)}
</div>
</>
);
}
CLS 개선
// ❌ 크기를 지정하지 않으면 이미지 로드 후 레이아웃 이동 발생
<img src="/product.jpg" alt="상품" />
// ✅ aspect-ratio로 자리 확보
<div style={{ aspectRatio: '16 / 9', width: '100%' }}>
<img src="/product.jpg" alt="상품" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
/* 웹 폰트 CLS 방지 */
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2') format('woff2');
/* ✅ 폰트 로드 전 비슷한 크기 폴백 유지 */
font-display: swap;
size-adjust: 105%; /* 폴백 폰트 크기 보정 */
}
측정 도구
# Lighthouse CLI — CI 파이프라인에 통합 가능
npx lighthouse https://example.com \
--output json \
--output-path ./lighthouse-report.json \
--only-categories performance
# web-vitals 라이브러리 — 실사용자 데이터 수집
npm install web-vitals
// ✅ 실사용자 데이터를 분석 서버로 전송
import { onLCP, onINP, onCLS } from 'web-vitals';
function sendToAnalytics(metric: { name: string; value: number }) {
navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
캐싱·CDN·환경변수·번들 분석 등 프로덕션 배포 고려사항
Next.js 캐시 계층
Next.js App Router는 4개의 캐시 계층을 제공합니다.
| 캐시 | 저장소 | 무효화 방법 |
|---|---|---|
| Request Memoization | 메모리 (단일 요청 내) | 요청 종료 시 자동 |
| Data Cache | 파일 시스템 (서버) | revalidatePath / revalidateTag |
| Full Route Cache | 파일 시스템 (서버) | 재빌드 또는 revalidatePath |
| Router Cache | 브라우저 메모리 | 세션 종료, 수동 router.refresh() |
// 온디맨드 캐시 무효화 — Server Action 예시
'use server';
import { revalidateTag } from 'next/cache';
export async function publishPost(postId: string) {
await db.post.update({ where: { id: postId }, data: { published: true } });
// ✅ 'posts' 태그를 가진 모든 캐시 무효화
revalidateTag('posts');
}
// fetch에 태그 부여
const posts = await fetch('/api/posts', {
next: { tags: ['posts'], revalidate: 3600 },
}).then((r) => r.json());
환경변수 관리
# .env.local (로컬 개발, git 제외)
DATABASE_URL=postgresql://localhost:5432/mydb
NEXT_PUBLIC_API_URL=https://api.example.com # NEXT_PUBLIC_ 접두사 → 클라이언트 노출
SECRET_API_KEY=super_secret # 접두사 없음 → 서버 전용
// ✅ 서버 전용 환경변수 — 클라이언트에서 접근하면 undefined
const key = process.env.SECRET_API_KEY;
// ✅ 클라이언트에서도 접근 가능
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
⚠️ 주의 —
NEXT_PUBLIC_접두사가 붙은 값은 번들에 인라인되므로 API Key, DB 비밀번호 같은 민감 정보를 절대 포함하지 마세요.
번들 분석과 트리 셰이킹
번들 크기를 시각적으로 파악하면 최적화 대상을 빠르게 찾을 수 있습니다.
# Next.js 번들 분석기 설치
npm install --save-dev @next/bundle-analyzer
# package.json scripts에 추가
# "analyze": "ANALYZE=true next build"
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// next.js 설정
});
# 분석 실행 — treemap 브라우저가 자동으로 열림
npm run analyze
트리 셰이킹이 제대로 작동하려면 named import 를 써야 합니다.
// ❌ 라이브러리 전체를 번들에 포함
import _ from 'lodash';
const result = _.groupBy(items, 'category');
// ✅ 필요한 함수만 가져오기 — 트리 셰이킹 가능
import groupBy from 'lodash/groupBy';
const result = groupBy(items, 'category');
// ✅ ES Modules를 지원하는 라이브러리라면 named import도 OK
import { groupBy } from 'lodash-es';
CDN과 정적 에셋 최적화
// next.config.js — 이미지 CDN 도메인 허용
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.example.com',
},
],
// ✅ WebP/AVIF 자동 변환
formats: ['image/avif', 'image/webp'],
},
};
// ✅ 장기 캐시를 위한 정적 에셋 URL에 해시 포함 (Next.js 자동 처리)
// _next/static/chunks/pages/index-[hash].js
// Cache-Control: max-age=31536000, immutable
// 캐시 무효화가 필요한 데이터 API는 짧은 max-age 설정
// Cache-Control: public, max-age=60, stale-while-revalidate=300
요약
- RSC는 서버에서만 실행되며 JS 번들을 포함하지 않는다.
'use client'경계를 최소화해 번들 크기를 줄이는 것이 핵심 원칙이다. - SSG/ISR/SSR/스트리밍은 데이터 신선도와 TTFB 트레이드오프에 따라 선택한다. 스트리밍은
Suspense만 배치해도 자동 활성화된다. - 하이드레이션 mismatch는
Date.now(),localStorage,typeof window분기 등 서버·클라이언트 환경 차이에서 주로 발생한다. 클라이언트 전용 로직은useEffect로 격리한다. React.lazy와 동적import로 코드 스플리팅을 적용하면 초기 번들 크기를 줄이고 LCP를 개선할 수 있다.- LCP는 히어로 이미지
priority+ preload, INP는useTransition활용, CLS는 이미지·폰트 크기 사전 확보로 개선한다. - 번들 분석기로 비대한 의존성을 찾고, named import와 트리 셰이킹으로 배포 크기를 관리한다.
연습문제
-
Next.js App Router 프로젝트에서
ProductList서버 컴포넌트와AddToCartButton클라이언트 컴포넌트를 올바르게 구성하세요.ProductList는 DB에서 상품 목록을 조회하고,AddToCartButton은 클릭 시 장바구니에 상품을 추가합니다. -
다음 컴포넌트는 하이드레이션 mismatch를 일으킵니다. 원인을 파악하고 수정하세요.
function SessionBadge() { const user = JSON.parse(localStorage.getItem('user') ?? 'null'); return <span>{user ? user.name : '비로그인'}</span>; } -
용량이 큰
RichTextEditor컴포넌트가 특정 버튼을 눌렀을 때만 렌더됩니다.React.lazy와Suspense를 사용해 코드 스플리팅을 적용하고, 버튼에 마우스를 올릴 때 미리 로드(prefetch)되도록 구현하세요. -
블로그 상세 페이지(
/posts/[slug])를 ISR로 구성하세요. 빌드 시에는 인기 글 10개를 미리 생성하고, 나머지는 첫 방문 시 생성 후 1시간마다 재검증합니다. 새 글이 발행될 때 즉시 캐시를 무효화하는 Server Action도 함께 작성하세요.
힌트 — 1번: 서버 컴포넌트에서 클라이언트 컴포넌트로
productId를 prop으로 전달. 2번:localStorage는 서버에 없으므로useEffect로 격리. 3번:import('./RichTextEditor')를onMouseEnter에서도 호출. 4번:generateStaticParams로 인기 글 지정,fetch에next: { revalidate: 3600 },revalidatePath로 즉시 무효화.
💡 연습문제 풀이
불러오는 중…
댓글 0
“React.js 심화” 강좌에 대한 댓글입니다.