Next.js에서 SPA를 사용한다는 것은 무엇일까?
Next.js는 이동하는 방식에 따라 MPA가 될 수도 있고 SPA가 될 수도 있다. <a>로 이동하면 문서를 새로 받으므로 MPA 방식이고, <Link>로 이동하면 문서를 그대로 둔 채 화면만 바꾸므로 SPA 방식이다. 이 글에서는 <Link>가 무엇을 하는지, 그래서 "Next.js로 SPA를 구현한다"는 말이 정확히 무슨 뜻인지 다룬다.
SPA라는 말의 정의
- SPA는
Single Page Application의 약자로, 라우트를 이동할 때 서버에 새 문서를 요청하지 않고 현재 문서 위에서 화면만 바꾸는 구조를 가리킨다. - SPA를 가르는 기준은 "HTML 파일이 몇 장인가"가 아니라 "내비게이션에 새 문서 요청이 있는가"이다. 첫 화면을 서버가 렌더링해 내려보내더라도, 그 뒤의 이동에 문서 요청이 없다면 SPA다.
- 공식 문서는 논의를 위해 더 좁은 형태를 따로 "strict SPA"라 정의한다. 하나의
index.html로 서비스되고, 모든 라우트와 화면 전환과 데이터 페치를 브라우저의 JavaScript가 처리하는 형태다. - 이렇게 브라우저가 화면을 그리는 방식을 클라이언트 사이드 렌더링(CSR)이라 부른다. 즉 CSR은 SPA이기 위한 필요조건이 아니라, strict SPA가 택한 구현 방식이다.
- strict SPA에서는 새 문서를 받는 대신 클라이언트 JavaScript가 현재 페이지의 DOM을 갱신하고 필요한 데이터를 가져오므로, 전체 페이지가 다시 로드되지 않아 화면 전환이 끊김 없이 이어진다.
- 다만 모든 화면을 JavaScript가 그려야 하므로, 페이지가 상호작용 가능해지기 전에 비교적 많은 양의 JavaScript를 먼저 내려받아야 한다.
- 또 데이터 요청이 클라이언트에서 꼬리를 물고 일어나면, 이런 데이터 워터폴을 관리하기가 까다로워진다.
"Next.js로 SPA를 구현할 수 있다"는 말의 의미
- 앞의 한계는 결국 "하나의 거대한 번들"과 "클라이언트 단독 데이터 요청"에서 비롯되며, Next.js는 이 두 지점을 다르게 다룬다.
- 먼저 Next.js는 JavaScript 번들을 라우트 단위로 자동 코드 분할한다. 그래서 특정 화면에 필요 없는 JavaScript는 내려받지 않게 되고, 그만큼 번들 크기가 줄어 첫 로딩이 빨라진다.
- 여기에 더해 라우트마다 별도의 HTML 진입점이 생긴다. 이것은 번들 크기와는 별개의 이점으로, 방문자가 클라이언트 JavaScript 번들을 기다리지 않고 내용을 먼저 받게 해 준다.
- 단 빌드 시점에 프리렌더되는 라우트에 해당하며 (SSG 등), 요청 시점에 렌더링되는 동적 라우트는 빌드 산출물로 HTML 파일이 나오지 않는다.
- 화면 전환의 매끄러움은
next/link컴포넌트가 맡는데, 이 컴포넌트는 연결된 라우트를 미리 가져오고(prefetch) 페이지를 다시 로드하지 않은 채 화면을 바꾼다. - 이
<Link>기반 전환이 "Next.js로 SPA를 구현한다"는 말의 핵심이므로, 아래에서 단계적으로 자세히 살펴본다. - 더해서 Next.js는 정적 사이트나 거의 순수한 클라이언트 렌더링 형태로 시작한 뒤, 프로젝트가 커지면 서버 기능을 점진적으로 더할 수 있도록 설계되어 있다.
평범한 <a> 태그의 문제부터
<Link>를 이해하려면, 먼저 평범한 링크가 무엇을 못 하는지 보는 편이 빠르다.- 웹에서 페이지를 잇는 가장 기본 도구는 HTML의
<a>태그이다. - 그런데
<a href="/about">을 누르면 브라우저는 서버에 새 문서를 통째로 요청하고, 받은 문서로 화면 전체를 다시 그린다. - 이렇게 전체를 다시 그리는 동작을 “풀 페이지 리로드”라 부른다.
- 풀 페이지 리로드가 일어나면 기존 상태가 지워지고, 스크롤 위치가 초기화되며, 그 사이 상호작용이 막힌다.
- 즉 입력 중이던 값이나 열어 둔 메뉴 같은 상태가 이동할 때마다 사라지므로, 이것은 SPA가 추구하는 경험과 정반대이다.
- 따라서 SPA를 만들려면 "문서를 새로 받지 않고 화면만 바꾸는" 다른 이동 방식이 필요하다.
<Link>은 도대체 무엇인가
-
그 다른 이동 방식을 담당하는 것이
next/link에서 가져오는<Link>컴포넌트이다. -
<Link>은 HTML의<a>요소를 확장한 React 컴포넌트로, prefetching과 라우트 간 클라이언트 사이드 내비게이션을 제공한다. -
정리하면 겉모습은 평범한 링크지만, 클릭을 가로채 풀 페이지 리로드 대신 JavaScript로 화면을 바꾸도록 만든 링크이다.
-
사용법은
<a>와 거의 같아서,href에 이동할 경로를 적기만 하면 된다.// next/link 패키지에서 Link 컴포넌트를 가져온다 import Link from 'next/link' export default function Nav() { return ( <nav> {/* href에 적은 경로로 "클라이언트 사이드 이동"을 수행한다 */} {/* 즉 새 문서를 받지 않고 화면만 교체한다 */} <Link href="/blog">블로그</Link> </nav> ) } -
<a>로 작성했다면 브라우저가 처리하던 이동을, 이제 Next.js의 라우터가 대신 처리하게 된다.
클릭하면 실제로 무슨 일이 일어나는가
<Link>을 클릭하면 Next.js는 페이지를 새로 불러오지 않고, 화면 내용을 동적으로 바꾼다.- 이 동적 교체 과정을 “클라이언트 사이드 전환”이라 부른다.
- 전환할 때 Next.js는 여러 화면이 공유하는 레이아웃과 UI는 그대로 유지한다.
- 그러면서 바뀌어야 하는 페이지 부분만 새 내용으로 교체한다.
- 예를 들어 좌측 사이드바와 상단 헤더는 그대로 둔 채, 본문 영역만 갈아 끼우는 식이다.
- 레이아웃이 유지되므로 화면 깜빡임이 없고, 레이아웃 컴포넌트가 들고 있던 React 상태(열어 둔 메뉴, 입력 중이던 값 등)도 그대로 남는다.
- 바로 이 동작이 서버에서 렌더링된 앱을 클라이언트 렌더링 앱처럼 느껴지게 만드는 지점이다.
빠르게 느껴지는 비결: prefetching
-
클릭한 뒤 화면을 바꾸려면 다음 라우트의 데이터가 필요한데, 이를 미리 준비해 두는 동작이 prefetching이다.
-
prefetching은 사용자가 이동하기 전에 해당 라우트를 백그라운드에서 미리 불러오는 과정이다.
-
Next.js는
<Link>로 연결된 라우트가 화면(뷰포트)에 들어오거나 hover되면 자동으로 그 라우트를 미리 가져온다. -
단 prefetch는 프로덕션 빌드에서만 동작한다.
next dev로 띄운 개발 서버에서는 이 효과를 확인할 수 없다. -
또
<Link>는 클라이언트 컴포넌트라 hydration이 끝나야 prefetch를 시작할 수 있다. 첫 방문에 번들이 크면 그만큼 prefetch도 늦게 시작된다.- 💡 hydration에 대해서는 다음에 알아보자.
-
미리 가져오는 양은 라우트 종류에 따라 다른데, 정적 라우트는 전체가 prefetch되고, 동적 라우트는 prefetch를 건너뛰거나
loading.tsx가 있을 때 부분적으로만 prefetch된다. -
그래서 "클릭 시점에는 다음 라우트의 데이터가 이미 준비되어 있어 이동이 즉각적으로 느껴진다"는 것은 “정적 라우트”에 해당하는 이야기다.
-
동적 라우트에서는 내비게이션 전에 서버 응답을 기다리게 되어, 앱이 반응하지 않는 것처럼 보일 수 있다.
-
평범한
<a>태그에는 이런 prefetch가 없으므로, 같은 화면 안에서도 두 링크의 동작이 갈린다.import Link from 'next/link' export default function Nav() { return ( <nav> {/* Link: 뷰포트에 들어오거나 hover되면 자동으로 prefetch된다 */} {/* 클릭 시점엔 이미 데이터가 준비되어 전환이 빠르다 */} <Link href="/blog">블로그</Link> {/* a 태그: prefetch가 없고, 클릭하면 풀 페이지 리로드가 일어난다 */} <a href="/contact">문의</a> </nav> ) } -
prefetch를 끄고 싶다면
prefetch={false}를 주면 되고, 이는 링크가 매우 많은 목록(예: 무한 스크롤)에서 불필요한 자원 사용을 줄이는 데 쓴다.- 참고로 App Router에서
prefetch={false}는 뷰포트 진입과 hover 양쪽 모두를 막는다. - 완전히 끄는 대신 hover할 때만 켜는 절충도 가능하다. 평소엔
prefetch={false}로 두고,onMouseEnter에서 상태를 바꿔prefetch={null}로 되돌리면 사용자가 관심을 보인 링크만 prefetch하게 된다.
- 참고로 App Router에서
클라이언트 라우팅을 직접 흉내내기: Shallow Routing
-
<Link>가 파일 기반 라우팅을 따른다면, 파일 구조와 무관하게 URL만 직접 바꾸고 싶은 경우도 있다. -
strict SPA에서는 보통
react-router같은 라이브러리로 URL을 직접 바꾸며 화면을 전환한다. -
다만 아래에서 다루는 방식은 라우트 전환을 대체하는 수단이 아니라, 같은 화면 안에서 URL 표시만 맞춰 주는 용도라는 점을 알아두자.
-
이런 코드를 옮겨올 때를 위해, Next.js는 브라우저 기본 API인
window.history.pushState와window.history.replaceState사용을 허용한다.window.history.pushState: 새 히스토리 항목을 추가하면서 페이지 새로고침 없이 URL과 상태를 변경한다.window.history.replaceState: 현재 히스토리 항목을 새 항목 추가 없이 URL과 상태로 교체한다.
-
이 두 메서드는 페이지를 다시 로드하지 않은 채 브라우저의 history 스택만 갱신한다.
-
더 중요한 점은 이 호출이 Next.js 라우터와 연동되어,
usePathname,useSearchParams값과 동기화된다는 것이다. -
예를 들어 정렬 상태를 URL 쿼리에 반영하는 코드는 다음과 같이 작성한다.
'use client' import { useSearchParams } from 'next/navigation' export default function SortProducts() { const searchParams = useSearchParams() function updateSorting(sortOrder: string) { // 현재 쿼리스트링을 복사해 sort 값만 바꾼다 const params = new URLSearchParams(searchParams.toString()) params.set('sort', sortOrder) // 새로고침 없이 URL만 갱신하고 라우터와 동기화한다 window.history.pushState(null, '', `?${params.toString()}`) } return ( <> <button onClick={() => updateSorting('asc')}>오름차순</button> <button onClick={() => updateSorting('desc')}>내림차순</button> </> ) } -
버튼을 누르면 페이지 새로고침 없이
sort값만 바뀌고, 그 변화가usePathname과useSearchParams를 구독하는 클라이언트 컴포넌트에 반영된다. -
반면 서버 컴포넌트가 새
searchParams를 받아 다시 렌더되지는 않는다. -
즉,
pushState는 서버에서 데이터를 다시 가져오지 않으므로, 정렬된 결과를 “서버에서 받아야 한다면”<Link>나router.push로 실제 내비게이션을 일으켜야 한다. -
정리하면 이 기법의 적정 용도는 정렬 상태나 필터처럼 클라이언트가 이미 가진 상태를 URL에 기록해 공유·복원 가능하게 만드는 것이다.
-
해당 기능을
useState로 구현할 수도 있겠지만, 정렬 상태를 URL에 기록한다면 다른 사람에게도 정렬 상태로 공유할 수 있는 이점이 있다!
브라우저에서만 동작하는 컴포넌트 다루기
-
SPA에서는
window나document같은 브라우저 API에 의존하는 서드파티 라이브러리를 자주 쓴다. -
그런데 Next.js는 클라이언트 컴포넌트라도
next build시점에 미리 렌더링(prerender)하므로, 서버에 없는 브라우저 API를 만나면 문제가 생길 수 있다.- 💡 클라이언트 컴포넌트가 미리 렌더링 된다는 이야기는 다음에 다뤄보자.
-
이때는
next/dynamic으로 해당 컴포넌트의 prerender를 끄고, 브라우저에서만 불러오도록 지정한다. -
여기서 주의할 점은,
ssr: false는 서버 컴포넌트에서 쓸 수 없다. -
App Router에서
layout.tsx나page.tsx는 기본이 서버 컴포넌트이므로, 거기에 그대로 적으면ssr: false is not allowed with next/dynamic in Server Components에러로 빌드가 실패한다. -
따라서
'use client'를 붙인 래퍼 파일 안에서dynamic을 호출하고, 그 래퍼를 서버 컴포넌트에서 가져다 쓴다.'use client' import dynamic from 'next/dynamic' // 이 dynamic 호출은 반드시 'use client' 파일 안에 있어야 한다 // ssr: false -> 서버 prerender를 끄고 브라우저에서만 로드한다 const ClientOnlyComponent = dynamic(() => import('./component'), { ssr: false, }) export default function ClientOnlyWrapper() { return <ClientOnlyComponent /> } -
ssr: false옵션이 서버 렌더링 단계를 건너뛰게 하므로, 컴포넌트는 브라우저 환경에서만 실행된다. -
사용자 입장에서는 해당 컴포넌트가 비교적 느리게 생성된다.
정적 내보내기로 strict SPA에 가깝게 만들기
-
서버 없이 정적 파일만으로 배포하고 싶다면, Next.js의 정적 내보내기(static export) 기능을 쓴다.
-
Next.js 설정 파일에서 출력 모드를
export로 바꾸면 이 기능이 켜진다.// next.config.ts import type { NextConfig } from 'next' const nextConfig: NextConfig = { // 'export' -> 라우트별 정적 HTML을 out 폴더로 내보낸다 output: 'export', } export default nextConfig -
이 상태로
next build를 실행하면out폴더에 HTML/CSS/JS 자산이 생성된다. -
이때 단일
index.html하나가 아니라 라우트마다 HTML 파일이 만들어지므로, 방문자는 클라이언트 JavaScript 번들을 기다리지 않고 내용을 더 빨리 받는다. -
화면 간 이동은 여전히 클라이언트에서 즉각적으로 일어나, SPA다운 전환 경험은 그대로 유지된다. HTML 파일이 여러 장이어도 SPA라 부를 수 있는 이유가 여기에 있다. 기준은 파일 개수가 아니라 내비게이션 시 문서 요청 여부다.
-
다만 정적 내보내기에서는 Next.js의 서버 기능을 쓸 수 없다는 제약이 있으므로, 서버가 필요한 기능과는 구분해서 선택한다.
그래서 이것이 왜 "SPA"이고, 어디로 확장되는가
- 지금까지의 동작을 모으면 SPA의 정의와 정확히 맞물린다.
- SPA의 핵심은 "문서를 다시 받지 않고 화면만 바꾸는 것"인데,
<Link>의 client side transition이 바로 그 일을 한다. - 여기에 prefetching이 더해져 전환이 지연 없이 느껴지고, 코드 분할이 더해져 각 화면에 필요한 만큼만 JavaScript를 내려받는다.
- 직접 확인하려면
<html>의 배경색을 노란색으로 바꾼 뒤<Link>로 두 페이지를 오가 보면 되는데, 배경색이 유지되면 풀 리로드 없이 클라이언트 내비게이션이 동작한다는 뜻이다. - 같은 실험을
<a>태그로 하면 클릭할 때 브라우저가 전체를 새로고침하므로 배경색이 초기화된다. - 결국 "Next.js로 SPA를 구현할 수 있다"는 말은,
<Link>로 SPA의 사용 경험을 그대로 누리면서도 필요할 때React Server Components나Server Actions같은 서버 기능을 점진적으로 더해 갈 수 있다는 확장 가능성까지 포함한다.
마무리
- Next.js의 SPA와 React + Vite의 SPA는 앱 안을 이동하는 느낌은 같고 구현 방식이 다르다.
- "문서를 다시 받지 않고 화면만 바꾼다"는 성질은 양쪽 모두 만족하지만, Vite는 그 일을 하기 위해 CSR을 택하고 Next.js는
<Link>를 택한다. - 그 결과 첫 로딩과 SEO에서 차이가 생기고, 서버 기능으로의 확장 경로가 열려 있느냐가 갈린다.