이미지 로딩 중...
AI Generated
2025. 11. 5. · 9 Views
Next.js 핵심 개념 완벽 정리
Next.js의 핵심 개념과 기능을 실무 관점에서 깊이 있게 다룹니다. App Router, Server Components, 데이터 페칭, 최적화 전략 등 고급 개발자가 알아야 할 모든 내용을 담았습니다.
목차
- App Router와 파일 기반 라우팅
- Server Components와 Client Components
- 데이터 페칭과 캐싱 전략
- Streaming SSR과 Suspense
- Image와 Font 최적화
- API Routes와 Route Handlers
- 미들웨어와 인증
- 성능 최적화와 번들 분석
1. App Router와 파일 기반 라우팅
시작하며
여러분이 대규모 React 애플리케이션을 개발하다 보면 라우팅 설정이 점점 복잡해지는 상황을 겪어본 적 있나요? React Router의 설정 파일이 수백 줄에 달하고, 중첩 라우트를 관리하느라 머리가 아픈 경험 말입니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 라우팅 로직이 한 곳에 집중되어 있으면 유지보수가 어렵고, 새로운 팀원이 프로젝트 구조를 파악하는 데 오랜 시간이 걸립니다.
특히 동적 라우팅이나 중첩 레이아웃을 구현할 때 복잡도가 기하급수적으로 증가하죠. 바로 이럴 때 필요한 것이 Next.js의 App Router입니다.
파일 시스템 자체가 라우팅 구조가 되어, 폴더 구조만 보고도 전체 애플리케이션의 라우팅을 한눈에 파악할 수 있습니다.
개요
간단히 말해서, App Router는 Next.js 13부터 도입된 새로운 라우팅 시스템으로, 폴더 구조가 곧 URL 구조가 되는 직관적인 방식입니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 대규모 팀에서 협업할 때 각자 담당 기능의 폴더만 관리하면 되어 충돌이 최소화됩니다.
예를 들어, 전자상거래 사이트에서 상품 페이지 팀과 결제 페이지 팀이 독립적으로 작업할 수 있습니다. 기존 Pages Router에서는 pages 폴더 하나에 모든 파일을 넣어야 했다면, 이제는 app 폴더 내에서 각 라우트별로 독립적인 레이아웃, 로딩, 에러 처리를 구현할 수 있습니다.
App Router의 핵심 특징은 중첩 레이아웃, 서버 컴포넌트 우선, 스트리밍 SSR 지원입니다. 이러한 특징들이 페이지 로딩 속도를 획기적으로 개선하고 사용자 경험을 향상시킵니다.
코드 예제
// app/products/[id]/page.tsx
// 동적 라우트와 서버 컴포넌트 활용
export default async function ProductPage({
params,
}: {
params: { id: string }
}) {
// 서버에서 직접 데이터 페칭
const product = await getProduct(params.id)
return (
<div className="product-container">
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* 클라이언트 컴포넌트 분리 */}
<AddToCartButton productId={product.id} />
</div>
)
}
설명
이것이 하는 일: App Router는 파일 시스템 기반으로 자동으로 라우트를 생성하고, 각 라우트마다 독립적인 레이아웃과 에러 처리를 가능하게 합니다. 첫 번째로, app/products/[id]/page.tsx 경로는 자동으로 /products/123 같은 동적 URL로 매핑됩니다.
대괄호 [id]는 동적 세그먼트를 나타내며, params 객체를 통해 해당 값에 접근할 수 있습니다. 이렇게 하면 별도의 라우팅 설정 없이도 동적 페이지를 만들 수 있습니다.
그 다음으로, async function을 사용한 것을 보세요. 이는 서버 컴포넌트의 특징으로, 컴포넌트 레벨에서 직접 데이터를 페칭할 수 있습니다.
getProduct 함수는 데이터베이스나 API에서 직접 데이터를 가져오며, 이 과정이 서버에서 실행되어 클라이언트로는 렌더링된 HTML만 전송됩니다. 마지막으로, AddToCartButton은 클라이언트 컴포넌트로 분리되어 있습니다.
이는 사용자 인터랙션이 필요한 부분만 클라이언트 사이드에서 실행되도록 하여, 초기 페이지 로드 시 JavaScript 번들 크기를 줄입니다. 여러분이 이 코드를 사용하면 SEO 최적화된 동적 페이지를 쉽게 만들 수 있고, 서버 사이드에서 데이터를 안전하게 처리하며, 클라이언트 번들 크기를 최소화할 수 있습니다.
실전 팁
💡 layout.tsx 파일을 활용하면 여러 페이지가 공통 레이아웃을 공유할 수 있어 코드 중복을 줄일 수 있습니다
💡 loading.tsx와 error.tsx 파일을 추가하면 자동으로 로딩 상태와 에러 바운더리가 적용됩니다
💡 generateStaticParams 함수를 사용하면 동적 라우트도 빌드 시점에 정적으로 생성할 수 있어 성능이 향상됩니다
💡 route.ts 파일로 API 엔드포인트를 같은 폴더 구조 내에서 관리할 수 있습니다
💡 middleware.ts를 통해 인증, 리다이렉션 등의 로직을 라우트 진입 전에 처리할 수 있습니다
2. Server Components와 Client Components
시작하며
여러분이 React 애플리케이션을 개발하면서 "이 컴포넌트는 정말 클라이언트에서 실행될 필요가 있을까?"라고 생각해본 적 있나요? 단순히 데이터를 보여주기만 하는 컴포넌트인데도 수십 KB의 JavaScript를 다운로드해야 하는 상황 말입니다.
이런 비효율성은 특히 모바일 환경에서 치명적입니다. 느린 네트워크에서 큰 JavaScript 번들을 다운로드하고 파싱하는 동안 사용자는 빈 화면을 보게 됩니다.
Time to Interactive(TTI)가 늘어나고, 결과적으로 이탈률이 증가하죠. Next.js의 Server Components는 이런 문제를 근본적으로 해결합니다.
서버에서 렌더링이 완료된 HTML만 전송하여, 클라이언트의 JavaScript 부담을 획기적으로 줄입니다. 실제로 많은 기업들이 Server Components 도입 후 초기 로딩 속도를 50% 이상 개선했다고 보고하고 있습니다.
개요
간단히 말해서, Server Components는 서버에서만 실행되고 클라이언트로는 렌더링된 HTML만 전송되는 새로운 React 컴포넌트 타입입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 데이터베이스 쿼리나 파일 시스템 접근 같은 서버 전용 작업을 컴포넌트 레벨에서 직접 수행할 수 있습니다.
예를 들어, 관리자 대시보드에서 복잡한 통계 데이터를 계산하고 표시하는 경우, 모든 로직을 서버에서 처리하여 보안과 성능을 동시에 확보할 수 있습니다. 기존에는 getServerSideProps나 getStaticProps를 페이지 레벨에서만 사용할 수 있었다면, 이제는 어떤 컴포넌트든 async/await를 사용해 데이터를 직접 페칭할 수 있습니다.
Server Components의 핵심 특징은 제로 번들 사이즈, 직접적인 백엔드 리소스 접근, 자동 코드 분할입니다. 이러한 특징들이 애플리케이션의 성능을 극대화하면서도 개발 경험을 단순하게 만듭니다.
반면 Client Components는 'use client' 지시문으로 명시하며, 사용자 인터랙션이나 브라우저 API가 필요한 경우에만 사용합니다.
코드 예제
// app/dashboard/Analytics.tsx - Server Component
async function Analytics() {
// 서버에서 직접 데이터베이스 쿼리
const stats = await db.query('SELECT * FROM analytics')
const processed = processComplexData(stats)
return (
<div className="analytics">
<h2>월간 통계</h2>
<StaticChart data={processed} />
{/* Client Component 통합 */}
<InteractiveFilter defaultData={processed} />
</div>
)
}
// app/dashboard/InteractiveFilter.tsx
'use client'
import { useState } from 'react'
export function InteractiveFilter({ defaultData }) {
const [filter, setFilter] = useState('all')
// 클라이언트 사이드 인터랙션 로직
return <select onChange={(e) => setFilter(e.target.value)}>...</select>
}
설명
이것이 하는 일: Server Components는 서버에서 데이터를 페칭하고 처리한 후 순수 HTML을 생성하며, Client Components는 필요한 경우에만 JavaScript를 포함시킵니다. 첫 번째로, Analytics 컴포넌트를 보면 'use client' 지시문이 없습니다.
이는 기본적으로 Server Component로 처리된다는 의미입니다. async/await를 사용해 데이터베이스에 직접 접근하고 있는데, 이 코드는 절대 클라이언트에 노출되지 않아 보안상 안전합니다.
두 번째로, processComplexData 같은 무거운 연산도 서버에서 실행됩니다. 만약 이것이 클라이언트에서 실행된다면 사용자의 디바이스 성능에 따라 경험이 달라지겠지만, 서버에서 처리하면 일관된 성능을 보장할 수 있습니다.
세 번째로, InteractiveFilter는 'use client' 지시문이 있는 Client Component입니다. useState 같은 React 훅을 사용하고 있으며, 사용자의 선택에 따라 UI가 변경됩니다.
이 컴포넌트만 JavaScript 번들에 포함됩니다. 마지막으로, Server Component 안에서 Client Component를 자유롭게 import하고 사용할 수 있습니다.
defaultData를 props로 전달하여 서버에서 처리한 데이터를 클라이언트 컴포넌트에서 활용합니다. 여러분이 이 패턴을 사용하면 초기 페이지 로드 속도가 빨라지고, SEO가 개선되며, 서버 리소스를 안전하게 활용할 수 있습니다.
특히 콘텐츠 중심의 페이지에서 극적인 성능 향상을 경험할 수 있습니다.
실전 팁
💡 Server Component는 기본값이므로 'use client'가 없으면 자동으로 서버에서 실행됩니다
💡 Client Component에서 Server Component를 import할 수 없지만, children이나 props로 전달받을 수는 있습니다
💡 환경 변수나 데이터베이스 연결 같은 민감한 정보는 Server Component에서만 사용하세요
💡 Server Component는 이벤트 핸들러를 가질 수 없으므로 인터랙션이 필요하면 Client Component로 분리하세요
💡 큰 라이브러리(차트, 에디터 등)는 dynamic import와 함께 Client Component로 만들어 번들 분할을 최적화하세요
3. 데이터 페칭과 캐싱 전략
시작하며
여러분이 복잡한 웹 애플리케이션을 개발하다 보면 "같은 데이터를 여러 컴포넌트에서 필요로 하는데, 매번 API를 호출해야 하나?"라는 고민을 하게 됩니다. 혹은 "이 데이터는 자주 바뀌지 않는데 매번 서버에서 가져와야 할까?" 같은 의문도 들죠.
이런 문제는 특히 대규모 트래픽을 처리하는 서비스에서 심각합니다. 불필요한 API 호출은 서버 부하를 증가시키고, 사용자는 느린 응답 속도에 불만을 가지게 됩니다.
데이터 일관성과 성능 사이에서 균형을 맞추는 것이 정말 어렵습니다. Next.js의 데이터 페칭 시스템은 이런 고민을 우아하게 해결합니다.
자동 중복 제거, 다층 캐싱, 세밀한 재검증 전략을 통해 최적의 성능을 제공합니다. 실제로 적절한 캐싱 전략을 적용하면 API 호출을 80% 이상 줄이면서도 항상 최신 데이터를 보여줄 수 있습니다.
개요
간단히 말해서, Next.js의 데이터 페칭은 fetch API를 확장하여 자동 캐싱과 재검증 기능을 제공하는 시스템입니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 전자상거래 사이트의 상품 정보처럼 자주 바뀌지 않는 데이터와 재고 수량처럼 실시간성이 중요한 데이터를 다르게 처리해야 합니다.
Next.js는 이를 간단한 옵션으로 제어할 수 있게 해줍니다. 기존에는 Redux나 React Query 같은 별도의 상태 관리 라이브러리로 캐싱을 구현했다면, 이제는 Next.js가 제공하는 네 가지 캐싱 레이어(Data Cache, Full Route Cache, Router Cache, Request Memoization)를 활용할 수 있습니다.
Next.js 데이터 페칭의 핵심 특징은 자동 요청 중복 제거, 시간 기반/온디맨드 재검증, 병렬 및 순차 데이터 페칭 패턴입니다. 이러한 특징들이 복잡한 데이터 요구사항을 간단하게 처리할 수 있게 합니다.
코드 예제
// app/products/page.tsx
// 다양한 캐싱 전략 활용
async function ProductList() {
// 1시간 동안 캐시 (ISR)
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }
})
// 항상 최신 데이터 (SSR)
const inventory = await fetch('https://api.example.com/inventory', {
cache: 'no-store'
})
// 빌드 시점에 캐시 (SSG)
const categories = await fetch('https://api.example.com/categories', {
cache: 'force-cache'
})
return <ProductGrid
products={await products.json()}
inventory={await inventory.json()}
categories={await categories.json()}
/>
}
설명
이것이 하는 일: Next.js의 데이터 페칭 시스템은 각 fetch 요청에 대해 적절한 캐싱 전략을 적용하고, 필요할 때 자동으로 데이터를 재검증합니다. 첫 번째로, products를 가져오는 fetch는 next: { revalidate: 3600 } 옵션을 사용합니다.
이는 Incremental Static Regeneration(ISR)을 구현하여, 데이터를 1시간(3600초) 동안 캐시하고 그 이후 첫 요청 시 백그라운드에서 재생성합니다. 상품 정보처럼 어느 정도 캐시해도 되는 데이터에 적합합니다.
두 번째로, inventory fetch는 cache: 'no-store' 옵션을 사용합니다. 이는 매 요청마다 서버에서 최신 데이터를 가져오는 Server-Side Rendering(SSR) 방식입니다.
재고 수량처럼 실시간성이 중요한 데이터에 필수적입니다. 세 번째로, categories fetch는 cache: 'force-cache' 옵션을 사용합니다.
이는 Static Site Generation(SSG) 방식으로, 빌드 시점에 한 번만 데이터를 가져오고 계속 재사용합니다. 카테고리처럼 거의 변하지 않는 데이터에 최적입니다.
네 번째로, 이 세 가지 fetch가 동시에 실행되어도 Next.js는 자동으로 요청을 병렬 처리합니다. 만약 다른 컴포넌트에서 같은 URL로 fetch를 호출하면, Request Memoization이 작동하여 중복 요청을 방지합니다.
여러분이 이 패턴을 사용하면 각 데이터의 특성에 맞는 최적의 캐싱 전략을 적용할 수 있고, 서버 부하를 줄이면서도 사용자에게는 항상 적절한 데이터를 제공할 수 있습니다.
실전 팁
💡 revalidate 값을 0으로 설정하면 cache: 'no-store'와 같은 효과를 얻을 수 있습니다
💡 revalidatePath()나 revalidateTag()를 사용하면 특정 캐시를 수동으로 무효화할 수 있습니다
💡 unstable_cache()를 사용하면 fetch가 아닌 함수의 결과도 캐시할 수 있습니다
💡 개발 모드에서는 모든 캐시가 비활성화되므로 프로덕션 빌드로 캐싱 동작을 테스트하세요
💡 너무 긴 revalidate 시간은 오래된 데이터를, 너무 짧은 시간은 높은 서버 부하를 야기하므로 적절한 균형점을 찾으세요
4. Streaming SSR과 Suspense
시작하며
여러분이 대용량 데이터를 표시하는 대시보드를 개발한다고 상상해보세요. 여러 API에서 데이터를 가져와야 하는데, 가장 느린 API 때문에 전체 페이지 로딩이 지연되는 경험을 해보셨나요?
전통적인 SSR에서는 모든 데이터가 준비될 때까지 사용자는 빈 화면을 보게 됩니다. 하나의 느린 API가 전체 사용자 경험을 망치는 것이죠.
이는 특히 복잡한 엔터프라이즈 애플리케이션에서 심각한 문제입니다. Next.js의 Streaming SSR은 이런 문제를 혁신적으로 해결합니다.
준비된 부분부터 점진적으로 전송하여, 사용자는 즉시 콘텐츠를 보기 시작할 수 있습니다. Netflix나 Facebook 같은 대규모 서비스들이 이미 이런 패턴을 적극 활용하여 체감 성능을 크게 개선했습니다.
개요
간단히 말해서, Streaming SSR은 서버에서 HTML을 한 번에 보내는 대신 준비된 부분부터 점진적으로 스트리밍하는 기술입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 복잡한 대시보드나 피드 형태의 UI에서 각 섹션이 독립적으로 로드될 수 있습니다.
예를 들어, 소셜 미디어 피드에서 헤더는 즉시 표시하고, 포스트들은 준비되는 대로 순차적으로 나타나게 할 수 있습니다. 기존 SSR에서는 전체 페이지가 준비될 때까지 기다려야 했다면, 이제는 React Suspense와 함께 컴포넌트 단위로 스트리밍할 수 있습니다.
Streaming SSR의 핵심 특징은 점진적 렌더링, Time to First Byte(TTFB) 개선, 선택적 하이드레이션입니다. 이러한 특징들이 특히 느린 네트워크 환경에서 극적인 성능 향상을 가져옵니다.
코드 예제
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { AnalyticsSkeleton, ActivitySkeleton } from './skeletons'
export default function Dashboard() {
return (
<div className="dashboard">
<h1>대시보드</h1>
{/* 빠른 데이터는 즉시 표시 */}
<QuickStats />
{/* 느린 데이터는 Suspense로 감싸기 */}
<Suspense fallback={<AnalyticsSkeleton />}>
<SlowAnalytics />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
)
}
// 3초 걸리는 컴포넌트
async function SlowAnalytics() {
const data = await fetch('https://slow-api.com/analytics')
return <AnalyticsChart data={data} />
}
설명
이것이 하는 일: Streaming SSR은 HTML을 청크 단위로 나누어 전송하고, Suspense 바운더리를 만나면 fallback을 먼저 보낸 후 실제 컴포넌트가 준비되면 교체합니다. 첫 번째로, 페이지가 로드되면 Dashboard 컴포넌트의 정적 부분(h1 태그)과 QuickStats가 즉시 렌더링되어 전송됩니다.
사용자는 페이지 구조를 바로 볼 수 있어 체감 속도가 빨라집니다. 두 번째로, SlowAnalytics와 RecentActivity는 Suspense로 감싸져 있습니다.
이들이 데이터를 가져오는 동안 각각의 fallback(스켈레톤 UI)이 먼저 표시됩니다. 이는 사용자에게 "무언가 로딩 중"이라는 시각적 피드백을 제공합니다.
세 번째로, SlowAnalytics가 3초 후 데이터를 받아오면, Next.js는 자동으로 스켈레톤을 실제 컴포넌트로 교체하는 JavaScript 코드를 스트리밍합니다. 이 과정이 점진적 하이드레이션입니다.
네 번째로, 각 Suspense 바운더리는 독립적으로 작동합니다. RecentActivity가 먼저 준비되면 SlowAnalytics를 기다리지 않고 즉시 렌더링됩니다.
이는 전체 페이지 로딩을 블로킹하지 않습니다. 여러분이 이 패턴을 사용하면 First Contentful Paint(FCP)가 극적으로 개선되고, 사용자는 페이지가 "빠르다"고 느끼게 됩니다.
특히 여러 데이터 소스를 조합하는 복잡한 UI에서 큰 효과를 볼 수 있습니다.
실전 팁
💡 Suspense 바운더리는 중첩할 수 있어 더 세밀한 로딩 상태 관리가 가능합니다
💡 loading.tsx 파일은 자동으로 페이지 전체를 Suspense로 감싸는 것과 같은 효과입니다
💡 스켈레톤 UI는 실제 콘텐츠와 비슷한 레이아웃을 가져야 레이아웃 시프트를 방지할 수 있습니다
💡 너무 많은 Suspense 바운더리는 오히려 복잡도를 높이므로 논리적 단위로 그룹화하세요
💡 generateMetadata 함수도 비동기로 작동하므로 메타데이터 생성이 페이지 렌더링을 블로킹하지 않습니다
5. Image와 Font 최적화
시작하며
여러분이 웹사이트를 개발하면서 "이미지 로딩이 너무 느려서 레이아웃이 깨진다"거나 "폰트가 늦게 로드되어 텍스트가 깜빡인다"는 피드백을 받아본 적 있나요? 특히 모바일에서 이런 문제가 심각하죠.
이런 문제들은 Core Web Vitals 점수를 떨어뜨리고, 결과적으로 SEO 순위와 사용자 이탈률에 직접적인 영향을 미칩니다. Cumulative Layout Shift(CLS)와 Largest Contentful Paint(LCP) 지표가 나빠지면 Google은 여러분의 사이트를 낮게 평가합니다.
Next.js의 Image와 Font 최적화 시스템은 이런 문제들을 자동으로 해결합니다. 이미지는 자동으로 최적화되고, 폰트는 플래시 없이 로드됩니다.
실제로 Next.js Image 컴포넌트를 사용한 사이트들은 평균적으로 이미지 용량을 75% 줄이고, LCP를 30% 개선했다고 보고됩니다.
개요
간단히 말해서, Next.js의 Image와 Font 최적화는 자동 포맷 변환, 지연 로딩, 레이아웃 시프트 방지를 기본으로 제공하는 시스템입니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 전자상거래 사이트에서 수백 개의 상품 이미지를 효율적으로 로드하거나, 브랜드 폰트를 깜빡임 없이 적용해야 하는 경우가 많습니다.
Next.js는 이를 설정 없이 자동으로 처리합니다. 기존에는 이미지 최적화 서비스를 별도로 구축하고, font-display 속성을 수동으로 조정했다면, 이제는 next/image와 next/font를 사용하여 모든 최적화가 자동으로 이루어집니다.
Image 컴포넌트의 핵심 특징은 자동 WebP/AVIF 변환, 반응형 이미지 생성, 자동 지연 로딩, 블러 플레이스홀더입니다. Font 시스템은 자동 폰트 최적화, CSS 변수 생성, 폰트 스왑 없는 로딩을 제공합니다.
코드 예제
// app/components/OptimizedContent.tsx
import Image from 'next/image'
import { Inter, Noto_Sans_KR } from 'next/font/google'
// 폰트 최적화 설정
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter'
})
const notoSansKR = Noto_Sans_KR({
weight: ['400', '700'],
subsets: ['latin'],
variable: '--font-noto-kr'
})
export function ProductCard({ product }) {
return (
<div className={`${inter.variable} ${notoSansKR.variable} card`}>
<Image
src={product.image}
alt={product.name}
width={300}
height={200}
placeholder="blur"
blurDataURL={product.blurData}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={product.featured}
/>
<h3 className="font-sans">{product.name}</h3>
</div>
)
}
설명
이것이 하는 일: Next.js의 Image 컴포넌트는 이미지를 최적의 포맷과 크기로 자동 변환하고, Font 시스템은 폰트를 최적화하여 렌더링 차단 없이 로드합니다. 첫 번째로, Image 컴포넌트의 sizes 속성을 보세요.
이는 반응형 이미지를 자동으로 생성합니다. 뷰포트 크기에 따라 적절한 크기의 이미지를 로드하여, 모바일에서는 작은 이미지를, 데스크톱에서는 큰 이미지를 제공합니다.
Next.js가 자동으로 srcset을 생성합니다. 두 번째로, placeholder="blur"와 blurDataURL은 이미지가 로드되는 동안 블러 처리된 프리뷰를 보여줍니다.
이는 레이아웃 시프트를 방지하고 체감 성능을 향상시킵니다. 블러 데이터는 빌드 시 자동 생성하거나 직접 제공할 수 있습니다.
세 번째로, priority={product.featured}는 조건부로 우선 로딩을 설정합니다. 주요 상품 이미지는 지연 로딩 없이 즉시 로드되어 LCP를 개선합니다.
나머지는 뷰포트에 가까워질 때 자동으로 로드됩니다. 네 번째로, 폰트 설정을 보면 variable 속성으로 CSS 변수를 생성합니다.
이 변수들은 className에 추가되어 해당 컴포넌트와 하위 요소에서 사용할 수 있습니다. 폰트는 빌드 시 최적화되어 외부 요청 없이 로드됩니다.
여러분이 이 패턴을 사용하면 이미지 대역폭을 크게 절약하고, 레이아웃 시프트를 방지하며, 폰트 플래시 현상을 제거할 수 있습니다. 결과적으로 Core Web Vitals 점수가 크게 개선됩니다.
실전 팁
💡 외부 이미지를 사용할 때는 next.config.js에 도메인을 등록해야 합니다
💡 fill 속성을 사용하면 부모 컨테이너에 맞춰 이미지 크기가 자동 조정됩니다
💡 로컬 이미지는 import하면 자동으로 width, height, blurDataURL이 설정됩니다
💡 폰트는 가능한 한 variable fonts를 사용하여 파일 크기를 줄이세요
💡 중요한 이미지는 priority를 설정하되, 너무 많이 설정하면 오히려 성능이 저하됩니다
6. API Routes와 Route Handlers
시작하며
여러분이 풀스택 애플리케이션을 개발할 때 "프론트엔드와 백엔드를 따로 관리하는 것이 번거롭다"고 느낀 적 있나요? 간단한 API 하나 추가하려고 별도의 Express 서버를 띄우거나, 서버리스 함수를 따로 배포해야 하는 상황 말입니다.
이런 분리는 개발 환경 설정을 복잡하게 만들고, CORS 설정, 인증 토큰 관리, 환경 변수 동기화 등 추가적인 작업을 요구합니다. 특히 작은 팀이나 개인 프로젝트에서는 이런 오버헤드가 부담스럽죠.
Next.js의 Route Handlers는 이런 문제를 우아하게 해결합니다. 프론트엔드 코드와 같은 프로젝트 내에서 API 엔드포인트를 정의하고, 타입 안정성까지 보장받을 수 있습니다.
많은 스타트업들이 Next.js Route Handlers만으로 MVP를 구축하고, 필요할 때만 별도의 백엔드로 마이그레이션하는 전략을 사용합니다.
개요
간단히 말해서, Route Handlers는 App Router 내에서 커스텀 요청 핸들러를 만들 수 있게 해주는 기능으로, route.ts 파일을 통해 RESTful API를 구현합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 폼 제출, 파일 업로드, 외부 API 프록시, 웹훅 처리 같은 서버 사이드 로직을 프론트엔드와 같은 코드베이스에서 관리할 수 있습니다.
예를 들어, Stripe 결제 웹훅을 처리하거나 이미지 업로드 API를 구현할 때 매우 유용합니다. 기존 Pages Router의 API Routes와 달리, Route Handlers는 App Router의 레이아웃과 같은 위치에 공존할 수 있고, 더 유연한 응답 타입을 지원합니다.
Route Handlers의 핵심 특징은 Web Request/Response API 지원, 스트리밍 응답, 정적/동적 라우트 핸들러, TypeScript 완벽 지원입니다. 이러한 특징들이 서버리스 환경에서도 강력한 백엔드 기능을 구현할 수 있게 합니다.
코드 예제
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { writeFile } from 'fs/promises'
import { join } from 'path'
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json(
{ error: '파일이 없습니다' },
{ status: 400 }
)
}
// 파일 처리 로직
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const path = join('/tmp', file.name)
await writeFile(path, buffer)
// S3 업로드 등 추가 처리
const url = await uploadToS3(buffer, file.name)
return NextResponse.json({
message: '업로드 성공',
url
})
} catch (error) {
return NextResponse.json(
{ error: '업로드 실패' },
{ status: 500 }
)
}
}
설명
이것이 하는 일: Route Handlers는 HTTP 요청을 받아 처리하고 응답을 반환하는 서버 사이드 함수로, Next.js 앱 내에서 완전한 기능의 API를 구현합니다. 첫 번째로, route.ts 파일 이름이 중요합니다.
이 파일이 app/api/upload 폴더에 있으므로, /api/upload 엔드포인트로 자동 매핑됩니다. HTTP 메서드 이름(GET, POST, PUT, DELETE 등)으로 함수를 export하면 해당 메서드를 처리합니다.
두 번째로, NextRequest와 NextResponse는 Web API를 확장한 Next.js 전용 타입입니다. request.formData()로 multipart 폼 데이터를 쉽게 파싱할 수 있고, TypeScript가 완벽하게 지원되어 타입 안정성을 보장합니다.
세 번째로, File 객체를 직접 다룰 수 있습니다. arrayBuffer()로 바이너리 데이터를 추출하고, Buffer로 변환하여 파일 시스템에 쓰거나 클라우드 스토리지에 업로드할 수 있습니다.
이는 이미지 업로드, 문서 처리 등에 필수적입니다. 네 번째로, 에러 처리가 체계적입니다.
try-catch로 에러를 잡고, NextResponse.json()으로 적절한 상태 코드와 함께 JSON 응답을 반환합니다. 클라이언트는 일관된 형식의 응답을 받게 됩니다.
여러분이 이 패턴을 사용하면 별도의 백엔드 서버 없이도 완전한 기능의 API를 구현할 수 있고, 프론트엔드와 백엔드 코드를 한 곳에서 관리하여 개발 속도가 크게 향상됩니다.
실전 팁
💡 export const dynamic = 'force-dynamic'을 추가하면 항상 동적으로 실행되어 캐싱을 방지합니다
💡 미들웨어와 함께 사용하면 인증, 레이트 리미팅 등을 중앙에서 관리할 수 있습니다
💡 Response 대신 NextResponse를 사용하면 cookies와 headers를 더 쉽게 조작할 수 있습니다
💡 스트리밍 응답을 위해서는 ReadableStream을 반환하면 됩니다
💡 generateStaticParams와 함께 사용하면 정적 API 응답도 가능합니다
7. 미들웨어와 인증
시작하며
여러분이 복잡한 웹 애플리케이션을 운영하면서 "모든 API 라우트에 인증 체크를 일일이 추가해야 하나?"라고 고민해본 적 있나요? 혹은 "특정 지역에서 오는 요청만 다른 서버로 라우팅하고 싶은데 어떻게 해야 할까?" 같은 요구사항을 받아본 적이 있나요?
이런 크로스커팅 관심사(cross-cutting concerns)는 애플리케이션 전체에 걸쳐 일관되게 적용되어야 하는데, 각 컴포넌트나 API에서 개별적으로 처리하면 코드 중복과 일관성 문제가 발생합니다. Next.js의 미들웨어는 이런 문제를 Edge Runtime에서 해결합니다.
요청이 라우트 핸들러에 도달하기 전에 가로채서 처리할 수 있어, 인증, 리다이렉션, 로깅 등을 중앙에서 관리할 수 있습니다. 실제로 미들웨어를 적절히 활용하면 인증 로직을 90% 이상 줄이고, 보안을 크게 강화할 수 있습니다.
개요
간단히 말해서, Next.js 미들웨어는 요청이 완료되기 전에 코드를 실행할 수 있게 해주는 기능으로, Edge Runtime에서 실행되어 매우 빠릅니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 사용자 인증 확인, A/B 테스팅, 지역별 리다이렉션, 봇 감지 같은 작업을 모든 요청에 대해 일관되게 처리해야 합니다.
예를 들어, 관리자 페이지 접근 권한을 체크하거나, 유지보수 모드를 구현할 때 필수적입니다. 기존에는 각 페이지의 getServerSideProps에서 인증을 체크했다면, 이제는 미들웨어에서 한 번에 처리할 수 있습니다.
Edge Runtime에서 실행되므로 콜드 스타트가 거의 없고 전 세계적으로 분산 실행됩니다. 미들웨어의 핵심 특징은 요청/응답 수정, 조건부 리다이렉션, 헤더/쿠키 조작, 경로 매칭입니다.
이러한 특징들이 복잡한 라우팅 로직과 보안 요구사항을 간단하게 구현할 수 있게 합니다.
코드 예제
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyAuth } from '@/lib/auth'
export async function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')
const { pathname } = request.nextUrl
// 보호된 라우트 체크
if (pathname.startsWith('/admin')) {
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
const verified = await verifyAuth(token.value)
if (!verified) {
return NextResponse.redirect(new URL('/login', request.url))
}
// 사용자 정보를 헤더에 추가
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-id', verified.userId)
return NextResponse.next({
request: { headers: requestHeaders }
})
}
// 지역별 리다이렉션
const country = request.geo?.country || 'US'
if (pathname === '/') {
return NextResponse.redirect(new URL(`/${country.toLowerCase()}`, request.url))
}
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
}
설명
이것이 하는 일: 미들웨어는 요청이 실제 페이지나 API 라우트에 도달하기 전에 실행되어, 인증 확인, 리다이렉션, 요청 수정 등을 수행합니다. 첫 번째로, middleware 함수는 모든 매칭되는 요청에 대해 실행됩니다.
request.cookies.get()으로 쿠키를 읽고, request.nextUrl로 현재 경로를 파악합니다. 이 정보로 요청을 어떻게 처리할지 결정합니다.
두 번째로, /admin으로 시작하는 경로는 보호된 라우트로 처리됩니다. 토큰이 없거나 유효하지 않으면 /login으로 리다이렉션합니다.
이렇게 하면 모든 관리자 페이지에서 인증 로직을 반복할 필요가 없습니다. 세 번째로, 인증이 성공하면 사용자 ID를 커스텀 헤더에 추가합니다.
이 헤더는 후속 라우트 핸들러나 서버 컴포넌트에서 읽을 수 있어, 사용자 컨텍스트를 전달하는 데 유용합니다. 네 번째로, request.geo를 사용한 지역별 리다이렉션을 보세요.
Next.js가 자동으로 IP 기반 지역 정보를 제공하므로, 국가별 랜딩 페이지로 자동 라우팅할 수 있습니다. 다섯 번째로, config.matcher는 미들웨어가 실행될 경로를 정의합니다.
정적 파일과 API 라우트는 제외하여 성능을 최적화합니다. 여러분이 이 패턴을 사용하면 보안을 중앙에서 관리하고, 복잡한 라우팅 로직을 간단하게 구현하며, Edge에서 실행되어 지연 시간을 최소화할 수 있습니다.
실전 팁
💡 미들웨어는 Edge Runtime에서 실행되므로 Node.js API를 사용할 수 없습니다
💡 미들웨어에서 너무 복잡한 로직을 수행하면 응답 시간이 늘어나므로 간단하게 유지하세요
💡 NextResponse.rewrite()를 사용하면 URL을 변경하지 않고 다른 페이지를 렌더링할 수 있습니다
💡 여러 미들웨어 로직이 필요하면 함수를 체이닝하여 순차적으로 실행하세요
💡 환경 변수는 process.env가 아닌 Edge Runtime 호환 방식으로 접근해야 합니다
8. 성능 최적화와 번들 분석
시작하며
여러분이 Next.js 애플리케이션을 프로덕션에 배포했는데 "초기 로딩이 너무 느리다"는 피드백을 받아본 적 있나요? 혹은 "특정 페이지만 유독 무겁다"는 보고를 받고 원인을 찾느라 고생한 경험이 있나요?
이런 성능 문제는 종종 불필요한 JavaScript 번들, 잘못된 코드 분할, 과도한 써드파티 라이브러리 사용에서 비롯됩니다. 문제는 이런 원인을 찾기가 쉽지 않다는 것이죠.
Next.js의 성능 최적화 도구들은 이런 문제를 체계적으로 해결할 수 있게 도와줍니다. 번들 분석, 자동 코드 분할, 동적 임포트를 통해 최적의 성능을 달성할 수 있습니다.
실제로 적절한 최적화를 적용하면 초기 번들 크기를 60% 이상 줄이고, Time to Interactive를 절반으로 단축할 수 있습니다.
개요
간단히 말해서, Next.js의 성능 최적화는 자동 코드 분할, 트리 셰이킹, 번들 분석을 통해 애플리케이션의 로딩 속도를 극대화하는 기술입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 대규모 애플리케이션에서 모든 코드를 한 번에 로드하면 초기 로딩이 매우 느려집니다.
예를 들어, 관리자 대시보드의 복잡한 차트 라이브러리를 일반 사용자 페이지에서도 로드한다면 불필요한 낭비입니다. 기존에는 webpack 설정을 직접 조작하여 최적화했다면, Next.js는 대부분의 최적화를 자동으로 수행하고, 필요할 때만 세밀한 조정을 할 수 있게 합니다.
성능 최적화의 핵심 특징은 자동 코드 분할, 동적 임포트, 번들 분석, 프리페칭입니다. 이러한 특징들이 사용자가 실제로 필요한 코드만 로드하도록 보장합니다.
코드 예제
// app/dashboard/page.tsx
import dynamic from 'next/dynamic'
import { Suspense } from 'react'
// 무거운 차트 라이브러리 동적 임포트
const HeavyChart = dynamic(
() => import('@/components/HeavyChart').then(mod => mod.HeavyChart),
{
loading: () => <div>차트 로딩중...</div>,
ssr: false // 클라이언트에서만 렌더링
}
)
// 조건부 동적 임포트
export default function Dashboard({ userRole }) {
const AdminPanel = userRole === 'admin'
? dynamic(() => import('@/components/AdminPanel'))
: () => null
return (
<div>
<h1>대시보드</h1>
{/* 지연 로딩되는 차트 */}
<Suspense fallback={<div>Loading...</div>}>
<HeavyChart />
</Suspense>
{/* 조건부 컴포넌트 */}
<AdminPanel />
</div>
)
}
// next.config.js
module.exports = {
// 번들 분석 활성화
webpack: (config, { isServer }) => {
if (!isServer) {
config.optimization.splitChunks = {
chunks: 'all',
cacheGroups: {
default: false,
vendors: false,
// 써드파티 라이브러리 분리
vendor: {
name: 'vendor',
test: /node_modules/,
priority: 10
}
}
}
}
return config
}
}
설명
이것이 하는 일: Next.js의 성능 최적화는 코드를 작은 청크로 나누고, 필요할 때만 로드하여 초기 로딩 속도를 개선합니다. 첫 번째로, dynamic 함수를 사용한 HeavyChart 임포트를 보세요.
이 차트 라이브러리는 페이지 로드 시 즉시 필요하지 않으므로, 사용자가 스크롤하거나 특정 조건이 충족될 때 로드됩니다. ssr: false 옵션은 서버 사이드 렌더링을 비활성화하여 클라이언트 전용 라이브러리의 오류를 방지합니다.
두 번째로, 조건부 동적 임포트를 보세요. AdminPanel은 관리자인 경우에만 로드됩니다.
일반 사용자는 이 컴포넌트의 코드를 전혀 다운로드하지 않아 번들 크기가 줄어듭니다. 세 번째로, Suspense와 함께 사용하면 로딩 상태를 우아하게 처리할 수 있습니다.
차트가 로드되는 동안 fallback UI를 표시하여 사용자 경험을 개선합니다. 네 번째로, next.config.js의 webpack 설정을 보면 splitChunks를 커스터마이징하고 있습니다.
node_modules의 써드파티 라이브러리를 별도의 vendor 청크로 분리하여 캐싱을 최적화합니다. 다섯 번째로, 이런 최적화의 효과를 측정하려면 @next/bundle-analyzer를 사용할 수 있습니다.
어떤 라이브러리가 번들 크기를 차지하는지 시각적으로 확인하고 최적화 포인트를 찾을 수 있습니다. 여러분이 이 패턴을 사용하면 초기 페이지 로드 속도가 극적으로 개선되고, 사용자는 필요한 기능만 점진적으로 로드하는 부드러운 경험을 하게 됩니다.
실전 팁
💡 next/dynamic의 loading 옵션보다 Suspense를 사용하면 더 유연한 로딩 상태 관리가 가능합니다
💡 크리티컬하지 않은 컴포넌트는 Intersection Observer와 함께 사용하여 뷰포트에 들어올 때 로드하세요
💡 @next/bundle-analyzer를 사용하여 정기적으로 번들 크기를 모니터링하세요
💡 큰 라이브러리는 CDN에서 로드하거나 더 가벼운 대안을 찾아보세요
💡 프리페칭을 비활성화하려면 Link 컴포넌트에 prefetch={false}를 추가하세요