저는 얼마 전까지 React로만 개발하다가 최근에 이직을 하며 Next를 처음 제대로 사용해 보게 되었어요.
Vue를 사용하다가 React로 넘어왔을 때 만큼의 변화는 아니지만, Next가 버전이 올라가면서 큼직한 변화들이 많이 있었고 그 과정에서 버전별 히스토리가 남아있는 것 같더라구요. 특히 Next13 버전을 기점으로 App Router이 도입되면서 패러다임이 크게 변했기 때문에 이를 특히 주의해야 했습니다.
이러한 변화들을 인지하고 있어야 올바른 개발이 가능할 것 같아서 간략하게 Next의 히스토리와 개발할 때 주의해야할 점에 대해 정리해 보았습니다!
Next는 왜 만들어졌을까?
React만 사용할 때 겪는 몇 가지 문제점을 해결하기 위해 2016년 Vercel(당시 ZEIT)에서 Next를 만들었습니다.
- SEO 문제
서버에서는 빈 HTML을 내려주기 때문에 검색 엔진이 컨텐츠를 제대로 읽지 못함 - 초기 로딩 속도
클라이언트에서 빈 HTML에 JavaScript로 모든 걸 그려야하기 때문에 초기 로딩 속도가 느림 - 프로덕션 최적화의 어려움
이미지 최적화, 폰트 최적화 등을 직접 구현해야 함
Next가 React와 다른 점
1. 다양한 렌더링 방식
React는 기본적으로 CSR(Client-Side Rendering)이지만, Next는 여러 렌더링 방식을 제공합니다.
// Pages Router에서의 렌더링 방식들
// SSG (Static Site Generation) - 빌드 시 생성
export async function getStaticProps() {
const data = await fetchData();
return { props: { data } };
}
// SSR (Server-Side Rendering) - 요청마다 생성
export async function getServerSideProps(context) {
const data = await fetchData(context.params);
return { props: { data } };
}
// ISR (Incremental Static Regeneration) - 주기적 재생성
export async function getStaticProps() {
return {
props: { data },
revalidate: 60 // 60초마다 재검증
};
}
2. 파일 기반 라우팅
별도의 React Router 설정이 없어도, 파일 구조가 곧 라우트가 됩니다.
app/
├── page.tsx // /
├── about/
│ └── page.tsx // /about
├── blog/
│ ├── page.tsx // /blog
│ └── [slug]/
│ └── page.tsx // /blog/:slug
3. 내장 최적화 기능
Next에서 제공하는 Image와 같은 내장 컴포넌트를 사용하면 최적화를 손쉽게 할 수 있어요.
// 1. Image 컴포넌트 - 자동 최적화
import Image from 'next/image';
<Image
src="/photo.jpg"
width={500}
height={300}
alt="설명"
priority //LCP(Largest Contentful Paint) 이미지에 사용. preload 대상이 되어 초기 로딩 속도 개선.
/>
// 2. Link 컴포넌트 - 자동 prefetch
import Link from 'next/link';
<Link href="/about" prefetch={false}>
About
</Link>
// 3. 동적 import를 통한 코드 스플리팅
import dynamic from 'next/dynamic';
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
loading: () => <p>Loading...</p>,
ssr: false // 필요시 SSR 비활성화
});
React Server Components(RSC)의 장점
앞서 말씀드린 것 처럼 Next13에서 App Router가 도입된 이후부터 Next의 큰 패러다임이 바뀌었어요. App Router가 도입되면서 생긴 가장 큰 변화는 React Server Components(RSC)를 기본으로 채택한 것입니다. App Router에서는 모든 컴포넌트가 기본적으로 서버 컴포넌트이며, 클라이언트 기능이 필요한 경우에만 'use client' 지시문을 추가합니다.
1. 번들 크기 감소
서버 컴포넌트를 사용하면 아래 코드에서 사용 중인 date-fns, marked, highlight.js 같은 라이브러리들이 클라이언트 번들에 포함되지 않습니다. 즉, 서버에서만 실행된 후 HTML 결과만 전송되니까 400KB+ 절약이 가능해요.
⚠️ 만약 'use client'를 추가하면 이 모든 라이브러리가 사용자 브라우저로 전송되기 때문에, 무분별하게 'use client'를 사용하지 않도록 주의해야 해요!
// app/posts/page.tsx - 서버 컴포넌트
import { format } from 'date-fns'; // 75KB 라이브러리
import marked from 'marked'; // 36KB 라이브러리
import hljs from 'highlight.js'; // 292KB 라이브러리
async function PostsPage() {
const posts = await fetchPosts();
return posts.map(post => (
<article>
<h2>{post.title}</h2>
<time>{format(post.date, 'yyyy-MM-dd')}</time>
<div dangerouslySetInnerHTML={{
__html: marked(post.content)
}} />
</article>
));
}
2. 데이터 페칭 최적화
클라이언트 컴포넌트와 서버 컴포넌트의 렌더링 흐름은 다음과 같습니다.
- 클라이언트 컴포넌트
최초 렌더링된 후 브라우저에서 API 호출 → DB 등 내부 로직을 통해 응답 전달 → 브라우저에서 응답값 렌더링
- 서버 컴포넌트
최초 진입 시 DB 등 내부 로직을 통해 완성된 HTML 전달 → 브라우저에서 응답값 렌더링
위의 흐름을 통해서 네트워크 왕복이 감소하고, 서버컴포넌트는 처음부터 데이터가 있는 HTML을 받기 때문에 초기 로딩처리가 불필요해집니다.
// 클라이언트 컴포넌트의 데이터 페칭
'use client';
function ProductList() {
const [products, setProducts] = useState(null);
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(setProducts);
}, []);
if (!products) return <Spinner />; // state가 업데이트될 때 까지 사용자는 빈 화면을 봄
return products.map(p => <div>{p.name}</div>);
}
// 서버 컴포넌트 데이터 페칭
async function ProductDetail({ id }) {
// 서버에서만 가능한 작업들을 수행
const [product, inventory, reviews] = await Promise.all([
db.getProduct(id), // 내부 DB
warehouseAPI.getStock(id), // 내부 API 호출
]);
// 복잡한 비즈니스 로직도 서버에서 처리
const finalPrice = calculatePrice(product, inventory);
// 아래의 완성된 HTML을 브라우저로 전달하여 바로 화면을 노출할 수 있음
return (
<div>
<h1>{product.name}</h1>
<Price value={finalPrice} />
<Stock count={inventory.available} />
<Reviews items={reviews} />
</div>
);
}
3. 보안 강화
서버에서는 모든 로직의 결과값만 브라우저로 전달하기 때문에 민감한 로직 또는 정보를 클라이언트에서 숨길 수 있어요.
// 서버 컴포넌트 - API 키와 비즈니스 로직이 안전함
async function PricingCalculator({ userId }) {
const SECRET_API_KEY = process.env.STRIPE_SECRET_KEY; // 노출 안됨!
// 복잡한 가격 계산 로직 (경쟁사에 노출하고 싶지 않은)
const specialDiscount = await calculateEnterpriseDiscount(userId, {
algorithm: 'proprietary-model-v2'
});
return <PricingDisplay discount={specialDiscount} />;
}
개발 시 주의해야 할 점들
1. 서버 vs 클라이언트 환경 구분
서버컴포넌트에서는 말그대로 서버에서 동작하는 코드이기 때문에 브라우저 API를 사용할 수 없어요. 따라서 서버 컴포넌트에서 `localStorage`, `window.location`과 같은 브라우저 API를 사용하지 않도록 주의해야 해요!
// ❌ 잘못된 예 - 서버 컴포넌트에서 브라우저 API 사용
async function ServerComponent() {
localStorage.getItem('key'); // 에러!
window.addEventListener(...); // 에러!
}
// ✅ 올바른 예
'use client';
function ClientComponent() {
useEffect(() => {
localStorage.getItem('key'); // OK!
}, []);
}
2. 컴포넌트 경계 설계
서버 컴포넌트와 클라이언트 컴포넌트의 경계를 잘 설계하는 것이 중요합니다. 모든 컴포넌트를 클라이언트 컴포넌트로 설정할 경우 SSR의 장점을 잃는 것이기 때문에 필요한 부분만 클라이언트 컴포넌트로 지정해야 해요.
// ❌ 나쁜 예 - 전체를 클라이언트 컴포넌트로 지정
'use client';
export default function ProductPage() {
// 모든 것이 클라이언트 번들에 포함됨
return (
<div>
<ProductList />
<AddToCartButton />
</div>
);
}
// ✅ 좋은 예 - 필요한 부분만 클라이언트 컴포넌트로 지정
// app/products/page.tsx (서버 컴포넌트)
export default async function ProductPage() {
const products = await getProducts();
return (
<div>
<ProductList products={products} />
<AddToCartButton /> {/* 이것만 클라이언트 컴포넌트 */}
</div>
);
}
// components/AddToCartButton.tsx
'use client';
export function AddToCartButton() {
return <button onClick={() => {...}}>장바구니 추가</button>;
}
3. 하이드레이션 불일치 문제
하이드레이션은 서버에서 렌더링된 정적 HTML에 클라이언트 측 JavaScript를 연결하여 인터랙티브 한 React 앱으로 만드는 과정입니다. 만약 서버에서 보내준 HTML과 하이드레이션 과정에서 만들어지는 HTML이 일치하지 않는다면 전체 컴포넌트를 다시 렌더링하기 때문에 SSR의 성능 이점이 사라지게 되어요. 또한 화면이 깜빡이거나 레이아웃이 갑자기 변경되는 등의 문제가 발생하기도 합니다.
따라서 서버에서 렌더링 된 HTML과 클라이언트에서 첫 렌더링 시 생성되는 HTML이 다른 현상인 하이드레이션 불일치 문제를 주의해야 합니다. 이를 해결하는 방법은 useEffect를 활용하여 클라이언트 전용 로직을 첫 렌더링 이후에 동작하도록 하여 해결할 수 있습니다.
// ❌ 문제가 되는 코드
function Component() {
// 서버와 클라이언트 시간이 달라서 에러!
return <div>{new Date().toLocaleTimeString()}</div>;
}
// ✅ 해결 방법1. useEffect 사용
function Component() {
const [time, setTime] = useState(null);
useEffect(() => {
setTime(new Date().toLocaleTimeString());
}, []);
return <div>{time || 'Loading...'}</div>;
}
// ✅ 해결 방법2. suppressHydrationWarning 속성 사용
function Component() {
return (
<time suppressHydrationWarning>
{new Date().toLocaleTimeString()}
</time>
);
}
마치며
처음 Next를 사용하게 되면 기존 React에서 하던 것과 다르게 서버컴포넌트를 작성할 때의 제약이 생깁니다.
예를 들어 Button의 onClick과 같이 너무나 당연하게 작성하던 코드인데, 이를 서버 컴포넌트에서 작성하지 못하니까 그냥 클라이언트 컴포넌트로 변경해버리고 싶기도 했어요.
하지만 이렇게 되면 앞서 말씀드렸던 것처럼 SSR의 이점이 사라지는 것이기 때문에 서버 컴포넌트와 클라이언트 컴포넌트의 경계를 잘 구분하여 컴포넌트 구조를 잡아야 합니다. 이러한 고비를 넘겨 익숙해진다면 다음과 같은 장점을 얻을 수 있게 됩니다!
- 번들 크기가 줄어들고
- 초기 로딩이 빨라지고
- SEO가 개선되고
- 보안이 강화됩니다
도구를 사용하기 위해선 도구의 사용법을 알아야 잘 활용할 수 있는 것처럼 Next의 사용법도 차근차근 이해해 나가기 위해 글을 작성하며 개념을 정리해 보았습니다. 이 글이 다른 분들께도 Next의 간단한 개념을 이해하는 데 도움이 되었길 바라며 이만 마치겠습니다.
읽어주셔서 감사합니다! ☺️
'개발 > Next' 카테고리의 다른 글
[Next14] 검색 기능 만들기 with Supabase (0) | 2024.11.04 |
---|---|
[Next14] To Do List 만들기 with Supabase (2) Next API Route 사용하여 할 일 목록 구현하기 (2) | 2024.07.08 |
[Next14] To Do List 만들기 with Supabase (1) 프로젝트 세팅 (0) | 2024.07.07 |
[Next14] 페이지와 레이아웃 : Pages and Layouts (0) | 2024.05.26 |
[Next14] 라우트 정의하기 : Defining Routes (0) | 2024.05.26 |