How to build single-page applications with Next.js

김동현·2026년 3월 4일

next.js 공식문서 번역

목록 보기
34/79

Next.js는 싱글 페이지 애플리케이션(SPA)을 만드는 것을 완벽하게 지원해요.

여기에는 데이터를 미리 가져와서(prefetching) 빠르게 화면을 전환하는 기능, 클라이언트 측에서 데이터를 가져오는 기능, 브라우저 API 사용, 외부 클라이언트 라이브러리와의 통합, 정적 라우트 생성 등 정말 다양한 기능들이 포함되어 있죠.

만약 여러분이 이미 만들어둔 SPA가 있다면, 코드를 크게 갈아엎지 않고도 Next.js로 마이그레이션(이전)할 수 있어요. 그런 다음 필요할 때마다 Next.js가 자랑하는 서버 측 기능들을 점진적으로 추가해 나가면 됩니다.

SPA(싱글 페이지 애플리케이션)란 뭘까요?

SPA의 정의는 사람마다 조금씩 다를 수 있어요. 하지만 여기서 우리는 "엄격한 의미의 SPA(Strict SPA)"를 다음과 같이 정의해 볼게요.

  • 클라이언트 사이드 렌더링 (CSR): 앱이 단 하나의 HTML 파일(예: index.html)로 제공되는 방식이에요. 모든 라우팅(화면 이동), 페이지 전환, 그리고 데이터 로딩은 전부 브라우저에서 자바스크립트가 알아서 처리하죠.
  • 전체 페이지 새로고침 없음: 페이지를 이동할 때마다 서버에 새로운 HTML 문서를 달라고 요청하는 게 아니에요. 클라이언트 측 자바스크립트가 현재 페이지의 DOM(문서 객체 모델)을 직접 조작하고, 필요한 데이터만 서버에서 쏙쏙 골라옵니다.

💡 강사의 팁 & 보충 설명:
"엄격한 SPA"의 가장 큰 단점은 사용자가 첫 화면을 보기 전까지 아주 큰 자바스크립트 뭉치(Bundle)를 다 다운로드하고 실행할 때까지 기다려야 한다는 거예요. 화면이 렌더링되기 전까지는 사용자가 아무것도 상호작용할 수 없죠.

또한 원본에서 말하는 '클라이언트 데이터 워터폴(Waterfall)' 현상도 문제예요. 컴포넌트가 렌더링되고 -> 데이터를 요청하고 -> 데이터를 받아와서 자식 컴포넌트를 렌더링하고 -> 자식 컴포넌트가 또 데이터를 요청하는 식으로 로딩이 꼬리에 꼬리를 무는 현상을 뜻합니다. 폭포수처럼 데이터 로딩이 순차적으로 지연되는 거죠. Next.js로 SPA를 만들면 바로 이런 고질적인 문제들을 아주 깔끔하게 해결할 수 있답니다!

SPA를 만들 때 왜 Next.js를 써야 할까요?

Next.js는 자바스크립트 번들을 자동으로 코드 스플리팅(Code Splitting, 코드 분할) 해주고, 여러 라우트에 맞춰 여러 개의 HTML 진입점(Entry points)을 생성해 줘요. 이렇게 하면 클라이언트가 굳이 필요 없는 자바스크립트 코드까지 한꺼번에 다운로드할 필요가 없어서, 번들 크기도 줄어들고 페이지 로딩 속도도 훨씬 빨라집니다.

그리고 next/link 컴포넌트를 사용하면 연결된 라우트의 데이터를 자동으로 미리 가져와서(prefetch) 대기시켜 줍니다. 덕분에 엄격한 SPA에서 느끼던 그 번개 같은 페이지 전환 속도를 그대로 누릴 수 있죠. 게다가 애플리케이션의 라우팅 상태를 URL에 유지시켜 주기 때문에, 링크를 복사해서 친구에게 공유하는 것도 아주 자연스럽게 동작해요.

Next.js는 처음엔 단순히 정적인 사이트나 모든 게 클라이언트에서 렌더링되는 엄격한 SPA로 시작할 수도 있어요. 그러다가 프로젝트 덩치가 커지면 필요에 따라 서버 기능들(React 서버 컴포넌트(RSC), 서버 액션(Server Actions) 등)을 아주 스무스하게 점진적으로 추가할 수 있다는 게 엄청난 매력입니다.

실제 사례 (Examples)

자, 그럼 실무에서 SPA를 만들 때 자주 쓰이는 패턴들을 Next.js가 어떻게 멋지게 해결해 주는지 한 번 살펴볼까요?

Context Provider 안에서 React의 use 사용하기

저희는 부모 컴포넌트(혹은 레이아웃)에서 데이터를 먼저 가져오기 시작하고(Promise를 반환), 그 값을 클라이언트 컴포넌트 안에서 React의 use API로 풀어서(unwrap) 사용하는 패턴을 강력히 추천해요.

Next.js는 서버에서 데이터 통신을 아주 일찍 시작할 수 있어요. 아래 예시를 보면, 앱의 가장 첫 진입점인 루트 레이아웃(Root Layout)에서 데이터를 부르죠. 그럼 서버는 즉시 클라이언트에게 응답(스트리밍)을 보내기 시작합니다.

데이터 요청을 루트 레이아웃으로 "끌어올림(hoisting)"으로써, Next.js는 앱 안의 다른 어떤 컴포넌트가 렌더링되기도 전에 지정된 데이터 요청을 서버에서 미리 시작해 버립니다. 이렇게 하면 위에서 말씀드린 악명 높은 클라이언트 워터폴 현상이 싹 사라지고, 클라이언트와 서버가 여러 번 데이터를 주고받으며 통신할 일도 방지할 수 있어요. 게다가 Next.js가 돌아가는 서버는 보통 데이터베이스와 물리적으로 가깝게(혹은 같은 위치에) 있기 때문에 성능이 폭발적으로 좋아질 수밖에 없죠.

예를 들어, 루트 레이아웃을 업데이트해서 Promise를 호출해 보세요. 단, 여기서 절대 await를 하시면 안 됩니다!

💡 강사의 팁:
여기서 await를 안 쓰는 게 정말 중요해요! await를 써버리면 서버에서 데이터를 다 받아올 때까지 화면 렌더링이 통째로 막혀버리거든요. Promise 자체를 넘겨서 자식 컴포넌트가 필요할 때 처리하도록 해야 스트리밍의 이점을 제대로 살릴 수 있습니다.

//filename="app/layout.tsx" switcher
import { UserProvider } from './user-provider'
import { getUser } from './user' // 서버 사이드 함수

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  let userPromise = getUser() // ⚠️ 절대 await 하지 마세요!

  return (
    <html lang="en">
      <body>
        <UserProvider userPromise={userPromise}>{children}</UserProvider>
      </body>
    </html>
  )
}
//filename="app/layout.js" switcher
import { UserProvider } from './user-provider'
import { getUser } from './user' // 서버 사이드 함수

export default function RootLayout({ children }) {
  let userPromise = getUser() // ⚠️ 절대 await 하지 마세요!

  return (
    <html lang="en">
      <body>
        <UserProvider userPromise={userPromise}>{children}</UserProvider>
      </body>
    </html>
  )
}

클라이언트 컴포넌트에 단일 Promise를 props로 넘겨주고 처리를 지연시키는 방법(defer)도 가능하지만, 실무에서는 보통 이 패턴을 React의 Context Provider와 짝지어 쓰는 걸 많이 봅니다. 이렇게 하면 커스텀 훅(Hook)을 만들어서 어떤 클라이언트 컴포넌트에서든 아주 쉽게 데이터에 접근할 수 있거든요.

아래처럼 Promise를 React Context Provider로 넘겨보세요.

//filename="app/user-provider.ts" switcher
'use client';

import { createContext, useContext, ReactNode } from 'react';

type User = any;
type UserContextType = {
  userPromise: Promise<User | null>;
};

const UserContext = createContext<UserContextType | null>(null);

export function useUser(): UserContextType {
  let context = useContext(UserContext);
  if (context === null) {
    throw new Error('useUser 훅은 반드시 UserProvider 내부에서 사용되어야 합니다');
  }
  return context;
}

export function UserProvider({
  children,
  userPromise
}: {
  children: ReactNode;
  userPromise: Promise<User | null>;
}) {
  return (
    <UserContext.Provider value={{ userPromise }}>
      {children}
    </UserContext.Provider>
  );
}
//filename="app/user-provider.js" switcher
'use client'

import { createContext, useContext, ReactNode } from 'react'

const UserContext = createContext(null)

export function useUser() {
  let context = useContext(UserContext)
  if (context === null) {
    throw new Error('useUser 훅은 반드시 UserProvider 내부에서 사용되어야 합니다')
  }
  return context
}

export function UserProvider({ children, userPromise }) {
  return (
    <UserContext.Provider value={{ userPromise }}>
      {children}
    </UserContext.Provider>
  )
}

마지막으로, 클라이언트 컴포넌트 아무 곳에서나 커스텀 훅인 useUser()를 호출해서 Promise를 풀어주기(use)만 하면 됩니다.

//filename="app/profile.tsx" switcher
'use client'

import { use } from 'react'
import { useUser } from './user-provider'

export function Profile() {
  const { userPromise } = useUser()
  const user = use(userPromise)

  return '...'
}
//filename="app/profile.js" switcher
'use client'

import { use } from 'react'
import { useUser } from './user-provider'

export function Profile() {
  const { userPromise } = useUser()
  const user = use(userPromise)

  return '...'
}

이렇게 Promise를 소비하는 컴포넌트(위 예시의 Profile)는 데이터가 올 때까지 렌더링이 "일시 중지(Suspended)" 됩니다. 이를 통해 점진적 수화(Partial Hydration)가 가능해져요. 즉, 자바스크립트 로딩이 완전히 끝나기도 전에 스트리밍되어 미리 렌더링된(prerendered) HTML을 사용자가 먼저 볼 수 있다는 뜻이죠. 아주 매끄러운 사용자 경험을 줄 수 있겠죠?


SWR을 활용한 SPA 구축

SWR은 데이터 페칭(fetching)을 위해 정말 많이 쓰이는 인기 만점 React 라이브러리입니다.

SWR 2.3.0 (그리고 React 19 이상) 버전부터는, 기존에 클라이언트에서 SWR로 데이터를 불러오던 코드에 서버 기능들을 점진적으로 도입할 수 있어요. 이건 방금 위에서 배운 use() 패턴을 조금 더 추상화(쉽게 포장)해 놓은 것이라고 보시면 돼요. 다시 말해 데이터 페칭을 클라이언트와 서버 사이에서 자유롭게 옮기거나, 아니면 둘 다 섞어 쓸 수 있다는 뜻입니다.

  • 클라이언트 전용: useSWR(key, fetcher)
  • 서버 전용: useSWR(key) + RSC가 제공한 데이터
  • 혼합 방식(Mixed): useSWR(key, fetcher) + RSC가 제공한 데이터

예를 들어 볼까요? 앱 전체를 <SWRConfig>로 감싸고 fallback 데이터를 넣어주는 겁니다.

//filename="app/layout.tsx" switcher
import { SWRConfig } from 'swr'
import { getUser } from './user' // 서버 사이드 함수

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <SWRConfig
      value={{
        fallback: {
          // ⚠️ 여기서 getUser()를 await 하지 않습니다.
          // 이 데이터를 읽으려는 컴포넌트만 일시 중지(suspend) 됩니다.
          '/api/user': getUser(),
        },
      }}
    >
      {children}
    </SWRConfig>
  )
}
//filename="app/layout.js" switcher
import { SWRConfig } from 'swr'
import { getUser } from './user' // 서버 사이드 함수

export default function RootLayout({ children }) {
  return (
    <SWRConfig
      value={{
        fallback: {
          // ⚠️ 여기서 getUser()를 await 하지 않습니다.
          // 이 데이터를 읽으려는 컴포넌트만 일시 중지(suspend) 됩니다.
          '/api/user': getUser(),
        },
      }}
    >
      {children}
    </SWRConfig>
  )
}

여기가 서버 컴포넌트이기 때문에 getUser()는 쿠키나 헤더를 안전하게 읽을 수 있고, 데이터베이스와 직접 소통할 수도 있어요. 별도의 API 라우트를 만들 필요가 전혀 없는 거죠!

이제 <SWRConfig> 하위에 있는 클라이언트 컴포넌트들은 똑같은 키(/api/user)로 useSWR()을 호출해서 유저 데이터를 빼오기만 하면 됩니다.
가장 멋진 점은, 여러분이 기존 클라이언트 페칭 방식에서 작성했던 useSWR 코드를 단 한 줄도 수정할 필요가 없다는 거예요.

//filename="app/profile.tsx" switcher
'use client'

import useSWR from 'swr'

export function Profile() {
  const fetcher = (url) => fetch(url).then((res) => res.json())
  // 여러분이 이미 잘 알고 계신 기존의 SWR 패턴 그대로입니다!
  const { data, error } = useSWR('/api/user', fetcher)

  return '...'
}
//filename="app/profile.js" switcher
'use client'

import useSWR from 'swr'

export function Profile() {
  const fetcher = (url) => fetch(url).then((res) => res.json())
  // 여러분이 이미 잘 알고 계신 기존의 SWR 패턴 그대로입니다!
  const { data, error } = useSWR('/api/user', fetcher)

  return '...'
}

fallback으로 넘겨진 초기 데이터는 미리 렌더링되어 첫 HTML 응답에 쏙 포함되어 클라이언트로 내려옵니다. 그러면 자식 컴포넌트에서 useSWR을 이용해 즉시 그 데이터를 읽어 들이는 거죠. 게다가 SWR이 제공하는 강력한 기능들인 주기적 재요청(polling), 포커스 시 재검증(revalidation), 캐싱 기능들은 클라이언트 측에서 그대로 유지되며 실행됩니다. 즉, SPA에서 기대하는 모든 동적이고 즉각적인 상호작용이 완벽하게 보존되는 셈이죠.

초기 fallback 데이터를 Next.js가 알아서 처리해 주니까, 예전처럼 dataundefined인지 체크하며 로딩 스피너를 보여주던 조건부 렌더링 로직을 이제 다 지워버려도 됩니다. 데이터를 불러오는 동안엔 트리 상에서 가장 가까운 <Suspense> 경계가 알아서 일시 중지 상태를 예쁘게 처리해 주거든요.

보충 설명: 아래는 각 기술 조합 시 지원되는 기능들을 비교하는 표입니다.

SWRRSCRSC + SWR
SSR 데이터 제공xoo
SSR 중 스트리밍 처리xoo
요청 중복 제거ooo
클라이언트 사이드 고유 기능oxo

React Query를 활용한 SPA 구축

Next.js에서는 React Query를 클라이언트와 서버 양쪽 모두에서 사용할 수 있습니다. 이를 통해 엄격한 의미의 SPA를 만들면서도, React Query와 짝을 이룬 Next.js의 서버 기능들을 동시에 적극 활용할 수 있어요.

자세한 내용은 React Query 공식 문서 가이드를 참고해 보세요!


브라우저 환경에서만 컴포넌트 렌더링하기

클라이언트 컴포넌트도 기본적으로 next build 과정 중에 미리 렌더링(prerendered) 됩니다.
그런데 만약 특정 클라이언트 컴포넌트를 서버에서 미리 렌더링하지 않도록 막고, 오직 브라우저 환경에서만 로드하고 싶다면 next/dynamic을 사용할 수 있어요.

import dynamic from 'next/dynamic'

const ClientOnlyComponent = dynamic(() => import('./component'), {
  ssr: false, // 💡 핵심 포인트! 서버 사이드 렌더링을 끕니다.
})

💡 강사의 팁:
이 방법은 언제 유용할까요? windowdocument 같은 브라우저 전용 API에 의존하는 외부 라이브러리(예: 복잡한 차트 라이브러리, 위지윅 에디터 등)를 사용할 때 구세주 같은 역할을 합니다. 만약 dynamic을 안 쓰면 렌더링 중에 "window is not defined"라는 짜증 나는 에러를 만나게 되거든요!

또 다른 방법으로는, useEffect 안에서 이런 브라우저 API가 존재하는지 체크하고, 존재하지 않는다면(즉 서버 환경이라면) null을 반환하거나 로딩 UI를 보여주게끔 만들 수도 있습니다. 이 역시 좋은 방법이에요.


클라이언트 측 얕은 라우팅 (Shallow routing)

혹시 Create React App이나 Vite 같은 도구로 만든 엄격한 SPA에서 넘어오고 계신가요? 그렇다면 URL 상태를 업데이트하기 위해 '얕은 라우팅(Shallow routing)'을 사용하는 코드가 꽤 있을 거예요. 화면 깜빡임 없이 URL 쿼리 파라미터만 살짝 바꾸면서 뷰를 이동할 때 아주 유용하죠.

Next.js에서는 페이지를 새로고침하지 않고 브라우저의 히스토리 스택을 업데이트할 때, 자바스크립트 기본 API인 window.history.pushStatewindow.history.replaceState 메서드를 아주 자연스럽게 쓸 수 있도록 열어두었습니다.

pushStatereplaceState를 호출하면 Next.js 라우터와 완벽하게 통합되어서, usePathname 이나 useSearchParams 같은 훅들이 알아서 동기화(sync) 되어 최신 값을 반영해 줘요!

//fileName="app/ui/sort-products.tsx" switcher
'use client'

import { useSearchParams } from 'next/navigation'

export default function SortProducts() {
  const searchParams = useSearchParams()

  function updateSorting(sortOrder: string) {
    const urlSearchParams = new URLSearchParams(searchParams.toString())
    urlSearchParams.set('sort', sortOrder)
    // 💡 페이지 새로고침 없이 URL만 업데이트!
    window.history.pushState(null, '', `?${urlSearchParams.toString()}`)
  }

  return (
    <>
      <button onClick={() => updateSorting('asc')}>오름차순 정렬</button>
      <button onClick={() => updateSorting('desc')}>내림차순 정렬</button>
    </>
  )
}
//fileName="app/ui/sort-products.js" switcher
'use client'

import { useSearchParams } from 'next/navigation'

export default function SortProducts() {
  const searchParams = useSearchParams()

  function updateSorting(sortOrder) {
    const urlSearchParams = new URLSearchParams(searchParams.toString())
    urlSearchParams.set('sort', sortOrder)
    // 💡 페이지 새로고침 없이 URL만 업데이트!
    window.history.pushState(null, '', `?${urlSearchParams.toString()}`)
  }

  return (
    <>
      <button onClick={() => updateSorting('asc')}>오름차순 정렬</button>
      <button onClick={() => updateSorting('desc')}>내림차순 정렬</button>
    </>
  )
}

Next.js에서 라우팅과 내비게이션이 어떻게 동작하는지 더 궁금하시다면 문서를 꼭 확인해 보세요!


클라이언트 컴포넌트에서 서버 액션(Server Actions) 사용하기

클라이언트 컴포넌트를 계속 사용하면서도 점진적으로 서버 액션을 도입할 수 있습니다. 이걸 쓰면 따로 API 라우트를 만들고 fetch를 호출하는 복잡한 보일러플레이트 코드를 다 날려버릴 수 있어요! 대신 useActionState 같은 React의 최신 기능들을 이용해서 로딩 상태나 에러 처리를 훨씬 우아하게 해낼 수 있죠.

예를 들어, 여러분의 첫 서버 액션을 만들어 볼까요?

//filename="app/actions.ts" switcher
'use server'

export async function create() {}
//filename="app/actions.js" switcher
'use server'

export async function create() {}

이렇게 만들어둔 서버 액션은 클라이언트에서 일반 자바스크립트 함수를 부르듯 아주 간단하게 import해서 쓸 수 있어요. 더 이상 손가락 아프게 API 엔드포인트를 만들지 않아도 됩니다.

//filename="app/button.tsx" switcher
'use client'

import { create } from './actions'

export function Button() {
  return <button onClick={() => create()}>생성하기</button>
}
//filename="app/button.js" switcher
'use client'

import { create } from './actions'

export function Button() {
  return <button onClick={() => create()}>생성하기</button>
}

서버 액션을 활용한 데이터 변경(mutating)에 대해 더 깊이 알고 싶다면 관련 가이드를 꼭 읽어보시길 권합니다.


정적 내보내기 (Static export, 선택 사항)

Next.js는 프로젝트 전체를 완벽한 정적 사이트(Static site)로 뽑아내는 기능도 지원합니다. 이건 전통적인 엄격한 SPA 구조보다 훨씬 좋은 장점들이 있어요.

  • 자동 코드 스플리팅: 달랑 index.html 파일 하나만 배포하는 대신에, Next.js는 각 라우트별로 HTML 파일을 개별적으로 만들어 줘요. 그래서 방문자들이 무거운 클라이언트 자바스크립트 번들을 다 다운로드할 때까지 기다릴 필요 없이 콘텐츠를 훨씬 빠르게 볼 수 있죠.
  • 사용자 경험 향상: 모든 라우트에 대해 똑같이 텅 빈 뼈대만 보여주는 게 아니라, 각각의 라우트에 맞게 완전히 렌더링된 예쁜 페이지를 즉시 보여줍니다. 그러면서도 사용자가 클라이언트 측에서 링크를 클릭해 이동할 때는 기존 SPA처럼 번개같이 매끄럽게 전환되죠!

정적 내보내기를 사용하려면, 설정 파일(next.config.ts 또는 next.config.js)을 다음과 같이 수정해 주세요.

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'export', // 💡 바로 이 부분입니다!
}

export default nextConfig

이렇게 설정하고 next build를 실행하면, Next.js가 out이라는 폴더를 만들고 그 안에 여러분의 애플리케이션을 구동할 HTML/CSS/JS 파일들을 곱게 담아줍니다.

주의하세요! 정적 내보내기 모드에서는 실행 시점에 서버가 필요한 Next.js의 특정 서버 기능들(예: 이미지 최적화 등)은 사용할 수 없습니다. 어떤 기능들을 지원하지 않는지는 여기서 확인해 보세요.


기존 프로젝트를 Next.js로 마이그레이션하기

기존에 다른 도구로 만들었던 앱을 Next.js로 하나씩 차근차근 옮겨오고 싶다면, 저희가 준비한 가이드를 따라 해 보세요.

만약 예전 방식인 Pages Router로 만들어진 SPA를 쓰고 계신다면, 어떻게 점진적으로 App Router로 넘어갈 수 있는지도 꼭 확인해 보시길 바랍니다!


모든 문서의 구조적인 개요를 보시려면 /docs/sitemap.md를 참고해 주세요.

사용 가능한 모든 문서의 인덱스는 /docs/llms.txt에서 확인할 수 있습니다.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글