SPAs#
- 상위 메뉴: Guides
- 전체 목차: Next.js 학습 문서
학습 목표#
- 엄격한 SPA와 Next.js App Router의 SPA 같은 내비게이션을 구분한다.
- 서버에서 시작한 Promise를 Client Component에서
use()로 읽어 waterfall을 줄인다. - 브라우저 전용 컴포넌트, shallow routing, 낙관적 mutation 패턴을 적용한다.
- 정적 export의 장점과 서버 기능 제한을 판단한다.
핵심 개념 및 설명#
Next.js는 클라이언트 내비게이션과 데이터 fetching으로 Single-Page Application(SPA)을 만들 수 있다. 한 앱에서 클라이언트와 서버 패턴을 함께 쓸 수 있고, 기존 SPA도 전체를 다시 쓰지 않고 점진적으로 옮길 수 있다.
SPA란 무엇인가#
이 문서에서 엄격한 SPA는 다음 두 조건을 만족한다.
- Client-side rendering(CSR): 하나의 HTML 파일을 받고 모든 라우트, 화면 전환, 데이터 fetching을 브라우저 JavaScript가 처리한다.
- 전체 페이지 reload 없음: 경로마다 새 문서를 요청하지 않고 JavaScript가 현재 DOM을 바꾸고 필요한 데이터를 가져온다.
엄격한 SPA는 상호작용 전에 많은 JavaScript를 내려받아야 할 수 있고 클라이언트 데이터 waterfall을 관리하기 어렵다.
SPA에 Next.js를 사용하는 이유#
Next.js는 JavaScript 번들을 자동으로 코드 분할하고 라우트마다 HTML 진입점을 만들 수 있다. 클라이언트가 불필요한 코드를 받지 않아 번들이 작아지고 페이지가 빨리 열린다. `<Link>`는 라우트를 자동 prefetch해 SPA 같은 빠른 전환을 제공하면서 현재 상태를 공유 가능한 URL에 보존한다.
처음에는 모든 것을 클라이언트에서 렌더링하는 정적 사이트나 엄격한 SPA로 시작할 수 있다. 필요해지면 Server Component, Server Action 같은 서버 기능을 점진적으로 추가한다.
일반적인 SPA 패턴 만들기#
Context Provider에서 React use 사용하기#
Server Component에서 데이터를 요청하고 Promise를 기다리지 않은 채 Client Component에 넘길 수 있다. Client Component는 렌더링 중 await할 수 없으므로 React use()로 Promise를 푼다. 서버에서 요청을 먼저 시작하면 응답을 바로 스트리밍하고 클라이언트 waterfall을 피할 수 있다.
export default function RootLayout({ children }: LayoutProps<'/'>) {
const userPromise = getUser() // 여기서 기다리지 않는다.
return <UserProvider userPromise={userPromise}>{children}</UserProvider>
}Provider는 Promise를 Context로 전달하고 소비자는 use()로 읽는다.
'use client'
import { use } from 'react'
import { useUser } from './user-provider'
export function Profile() {
const user = use(useUser())
return <p>{user.name}</p>
}소비자를 <Suspense>로 감싸면 Promise가 준비되는 동안 fallback을 보여준다. 스트리밍된 prerender HTML은 JavaScript가 모두 로드되기 전에도 보인다. 한 요청에서 여러 컴포넌트가 같은 데이터를 읽는다면 getUser를 React `cache`로 감싸 호출을 공유한다. focus revalidation, polling, mutation, 요청 중복 제거가 필요하면 클라이언트 데이터 fetching의 SWR나 TanStack Query를 사용한다.
알아두면 좋은 점: 트리 높은 곳에서 만든 Promise를 다시 가져오면 그 Promise를 만든 Server Component가 다시 실행된다. 일부 하위 트리만 필요한 데이터라면 Provider를 root layout이 아니라 해당 하위 트리에 둔다.
브라우저에서만 컴포넌트 렌더링하기#
Client Component도 next build 중 prerender된다. window나 document에 의존하는 라이브러리는 `next/dynamic`과 ssr: false로 브라우저에서만 불러올 수 있다.
import dynamic from 'next/dynamic'
const ClientOnlyComponent = dynamic(() => import('./component'), {
ssr: false,
})브라우저 API의 존재를 useEffect에서 확인하고, 마운트 전에는 null이나 prerender 가능한 loading 상태를 반환할 수도 있다.
클라이언트 shallow routing#
파일 시스템 라우팅 없이 URL 상태만 바꿔야 하면 window.history.pushState와 replaceState를 사용할 수 있다. 두 API는 Next.js Router와 통합되므로 `usePathname`과 `useSearchParams`에도 변경이 반영된다.
'use client'
import { useSearchParams } from 'next/navigation'
export default function SortProducts() {
const searchParams = useSearchParams()
function updateSorting(sortOrder: string) {
const next = new URLSearchParams(searchParams.toString())
next.set('sort', sortOrder)
window.history.pushState(null, '', `?${next.toString()}`)
}
return <button onClick={() => updateSorting('asc')}>Sort Ascending</button>
}Server Action으로 데이터 mutation하기#
Client Component는 Server Action을 호출해 서버에서 mutation할 수 있다. useTransition은 실행 중 상태를 표시하고, useOptimistic은 서버 응답 전에 새 상태를 보여주며, useActionState는 Action 결과와 pending 상태를 관리한다.
낙관적 UI와 서버가 같은 다음 상태를 계산하려면 순수 reducer를 공유한다.
export function applyAction(todos: Todo[], action: TodoAction): Todo[] {
if (action.type === 'toggle') {
return todos.map((todo) =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
)
}
return todos
}Client Component는 같은 reducer를 useOptimistic에 전달하고 transition 안에서 낙관적 변경과 Action dispatch를 함께 실행한다. 화면은 즉시 바뀌며 useActionState의 pending 값으로 서버 동기화 상태를 표시한다. 더 자세한 상호작용 패턴은 Interactive apps를 참고한다.
정적 export 선택 사항#
정적 export는 라우트별 HTML을 만든다. 하나의 index.html과 큰 번들만 보내는 엄격한 SPA보다 콘텐츠를 빨리 보여주면서 클라이언트 전환은 SPA처럼 유지할 수 있다.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { output: 'export' }
export default nextConfignext build는 HTML, CSS, JavaScript 자산을 out에 만든다.
참고: 정적 export에서는 Next.js 서버 기능을 사용할 수 없다. Static Exports의 지원하지 않는 기능을 확인한다.
기존 프로젝트를 Next.js로 마이그레이션하기#
Create React App이나 Vite 프로젝트는 Create React App에서 마이그레이션하거나 Vite에서 마이그레이션하는 절차로 Next.js에 점진적으로 옮길 수 있다. Pages Router 기반 SPA도 App Router 마이그레이션을 따라 단계적으로 도입할 수 있다.
Client-side data fetching#
Client Component에서 라이브러리로 데이터를 가져오고 Server Component의 초기 데이터와 서버·클라이언트 캐시를 조율한다.
Interactive apps#
Server Function, transition, 낙관적 UI, pending 피드백으로 반응성 있는 상호작용을 만든다.
Server Actions#
단일 왕복 응답, 순차 디스패치, 보안, 캐시 통합을 이해한다.
Forms#
React Server Action으로 폼을 작성한다.
Streaming#
데이터가 준비되는 순서대로 UI를 점진적으로 렌더링한다.
Static Exports#
정적 사이트나 SPA로 시작한 뒤 필요하면 서버 기능을 사용하는 형태로 확장한다.
예제 및 데모 설계#
- Phase 2에서 서버가 시작한 사용자 Promise를 Context와
use()로 읽는 프로필 화면을 만든다. - 브라우저 API 의존 컴포넌트의
ssr: false전후 빌드 결과를 비교한다. - 정렬 값을
pushState로 바꾸고useSearchParams와 뒤로 가기가 동기화되는지 확인한다. - 할 일 mutation에서 낙관적 상태, pending 표시, 서버 실패 시 복구 흐름을 관찰한다.
연습 문제#
- 서버에서 시작한 Promise를 Client Component 렌더링에서 푸는 API는 무엇인가?
- A.
use() - B.
useEffect()만 사용 - C.
redirect()
정답 보기
정답: A. Client Component는 Promise를 use()로 읽으며 준비될 때까지 suspend한다.
window에 의존하는 컴포넌트를 브라우저에서만 불러오는 설정은 무엇인가?
- A.
dynamic(..., { ssr: false }) - B.
output: 'export' - C.
cacheComponents: true
정답 보기
정답: A. next/dynamic의 ssr: false가 해당 Client Component의 prerender를 끈다.
- 정적 export에 대한 설명으로 맞는 것은 무엇인가?
- A. 라우트마다 HTML을 만들 수 있지만 서버 기능은 제한된다.
- B. 모든 라우트에 하나의 HTML만 만든다.
- C. Server Action을 별도 설정 없이 지원한다.
정답 보기
정답: A. 정적 자산만 배포하므로 라우트별 HTML의 이점은 얻지만 런타임 서버 기능은 사용할 수 없다.
챕터 요약#
- Next.js는 SPA 같은 클라이언트 전환과 라우트별 코드 분할·HTML을 함께 제공한다.
- 서버에서 Promise를 먼저 시작하고
use()로 읽으면 클라이언트 waterfall을 줄일 수 있다. - 브라우저 전용 렌더링과 shallow routing은 기존 SPA 패턴을 점진적으로 옮기는 도구다.
- transition과 낙관적 상태는 Server Action mutation을 즉각적으로 느끼게 한다.
- 정적 export는 빠른 정적 배포를 제공하지만 Next.js 서버 기능을 지원하지 않는다.