Next.js에서 SPA를 사용한다는 것은 무엇일까?

Next.js는 이동하는 방식에 따라 MPA가 될 수도 있고 SPA가 될 수도 있다. <a>로 이동하면 문서를 새로 받으므로 MPA 방식이고, <Link>로 이동하면 문서를 그대로 둔 채 화면만 바꾸므로 SPA 방식이다. 이 글에서는 <Link>가 무엇을 하는지, 그래서 "Next.js로 SPA를 구현한다"는 말이 정확히 무슨 뜻인지 다룬다.

#Next.js#SPA

SPA라는 말의 정의

  1. SPA는 Single Page Application의 약자로, 라우트를 이동할 때 서버에 새 문서를 요청하지 않고 현재 문서 위에서 화면만 바꾸는 구조를 가리킨다.
  2. SPA를 가르는 기준은 "HTML 파일이 몇 장인가"가 아니라 "내비게이션에 새 문서 요청이 있는가"이다. 첫 화면을 서버가 렌더링해 내려보내더라도, 그 뒤의 이동에 문서 요청이 없다면 SPA다.
  3. 공식 문서는 논의를 위해 더 좁은 형태를 따로 "strict SPA"라 정의한다. 하나의 index.html로 서비스되고, 모든 라우트와 화면 전환과 데이터 페치를 브라우저의 JavaScript가 처리하는 형태다.
  4. 이렇게 브라우저가 화면을 그리는 방식을 클라이언트 사이드 렌더링(CSR)이라 부른다. 즉 CSR은 SPA이기 위한 필요조건이 아니라, strict SPA가 택한 구현 방식이다.
  5. strict SPA에서는 새 문서를 받는 대신 클라이언트 JavaScript가 현재 페이지의 DOM을 갱신하고 필요한 데이터를 가져오므로, 전체 페이지가 다시 로드되지 않아 화면 전환이 끊김 없이 이어진다.
  6. 다만 모든 화면을 JavaScript가 그려야 하므로, 페이지가 상호작용 가능해지기 전에 비교적 많은 양의 JavaScript를 먼저 내려받아야 한다.
  7. 또 데이터 요청이 클라이언트에서 꼬리를 물고 일어나면, 이런 데이터 워터폴을 관리하기가 까다로워진다.

"Next.js로 SPA를 구현할 수 있다"는 말의 의미

  1. 앞의 한계는 결국 "하나의 거대한 번들"과 "클라이언트 단독 데이터 요청"에서 비롯되며, Next.js는 이 두 지점을 다르게 다룬다.
  2. 먼저 Next.js는 JavaScript 번들을 라우트 단위로 자동 코드 분할한다. 그래서 특정 화면에 필요 없는 JavaScript는 내려받지 않게 되고, 그만큼 번들 크기가 줄어 첫 로딩이 빨라진다.
  3. 여기에 더해 라우트마다 별도의 HTML 진입점이 생긴다. 이것은 번들 크기와는 별개의 이점으로, 방문자가 클라이언트 JavaScript 번들을 기다리지 않고 내용을 먼저 받게 해 준다.
  4. 단 빌드 시점에 프리렌더되는 라우트에 해당하며 (SSG 등), 요청 시점에 렌더링되는 동적 라우트는 빌드 산출물로 HTML 파일이 나오지 않는다.
  5. 화면 전환의 매끄러움은 next/link 컴포넌트가 맡는데, 이 컴포넌트는 연결된 라우트를 미리 가져오고(prefetch) 페이지를 다시 로드하지 않은 채 화면을 바꾼다.
  6. <Link> 기반 전환이 "Next.js로 SPA를 구현한다"는 말의 핵심이므로, 아래에서 단계적으로 자세히 살펴본다.
  7. 더해서 Next.js는 정적 사이트나 거의 순수한 클라이언트 렌더링 형태로 시작한 뒤, 프로젝트가 커지면 서버 기능을 점진적으로 더할 수 있도록 설계되어 있다.

평범한 <a> 태그의 문제부터

  1. <Link>를 이해하려면, 먼저 평범한 링크가 무엇을 못 하는지 보는 편이 빠르다.
  2. 웹에서 페이지를 잇는 가장 기본 도구는 HTML의 <a> 태그이다.
  3. 그런데 <a href="/about">을 누르면 브라우저는 서버에 새 문서를 통째로 요청하고, 받은 문서로 화면 전체를 다시 그린다.
  4. 이렇게 전체를 다시 그리는 동작을 “풀 페이지 리로드”라 부른다.
  5. 풀 페이지 리로드가 일어나면 기존 상태가 지워지고, 스크롤 위치가 초기화되며, 그 사이 상호작용이 막힌다.
  6. 즉 입력 중이던 값이나 열어 둔 메뉴 같은 상태가 이동할 때마다 사라지므로, 이것은 SPA가 추구하는 경험과 정반대이다.
  7. 따라서 SPA를 만들려면 "문서를 새로 받지 않고 화면만 바꾸는" 다른 이동 방식이 필요하다.

<Link>은 도대체 무엇인가

  1. 그 다른 이동 방식을 담당하는 것이 next/link에서 가져오는 <Link> 컴포넌트이다.

  2. <Link>은 HTML의 <a> 요소를 확장한 React 컴포넌트로, prefetching과 라우트 간 클라이언트 사이드 내비게이션을 제공한다.

  3. 정리하면 겉모습은 평범한 링크지만, 클릭을 가로채 풀 페이지 리로드 대신 JavaScript로 화면을 바꾸도록 만든 링크이다.

  4. 사용법은 <a>와 거의 같아서, href에 이동할 경로를 적기만 하면 된다.

    // next/link 패키지에서 Link 컴포넌트를 가져온다
    import Link from 'next/link'
    
    export default function Nav() {
      return (
        <nav>
          {/* href에 적은 경로로 "클라이언트 사이드 이동"을 수행한다 */}
          {/* 즉 새 문서를 받지 않고 화면만 교체한다 */}
          <Link href="/blog">블로그</Link>
        </nav>
      )
    }
  5. <a>로 작성했다면 브라우저가 처리하던 이동을, 이제 Next.js의 라우터가 대신 처리하게 된다.

클릭하면 실제로 무슨 일이 일어나는가

  1. <Link>을 클릭하면 Next.js는 페이지를 새로 불러오지 않고, 화면 내용을 동적으로 바꾼다.
  2. 이 동적 교체 과정을 “클라이언트 사이드 전환”이라 부른다.
  3. 전환할 때 Next.js는 여러 화면이 공유하는 레이아웃과 UI는 그대로 유지한다.
  4. 그러면서 바뀌어야 하는 페이지 부분만 새 내용으로 교체한다.
  5. 예를 들어 좌측 사이드바와 상단 헤더는 그대로 둔 채, 본문 영역만 갈아 끼우는 식이다.
  6. 레이아웃이 유지되므로 화면 깜빡임이 없고, 레이아웃 컴포넌트가 들고 있던 React 상태(열어 둔 메뉴, 입력 중이던 값 등)도 그대로 남는다.
  7. 바로 이 동작이 서버에서 렌더링된 앱을 클라이언트 렌더링 앱처럼 느껴지게 만드는 지점이다.

빠르게 느껴지는 비결: prefetching

  1. 클릭한 뒤 화면을 바꾸려면 다음 라우트의 데이터가 필요한데, 이를 미리 준비해 두는 동작이 prefetching이다.

  2. prefetching은 사용자가 이동하기 전에 해당 라우트를 백그라운드에서 미리 불러오는 과정이다.

  3. Next.js는 <Link>로 연결된 라우트가 화면(뷰포트)에 들어오거나 hover되면 자동으로 그 라우트를 미리 가져온다.

  4. 단 prefetch는 프로덕션 빌드에서만 동작한다. next dev로 띄운 개발 서버에서는 이 효과를 확인할 수 없다.

  5. <Link>는 클라이언트 컴포넌트라 hydration이 끝나야 prefetch를 시작할 수 있다. 첫 방문에 번들이 크면 그만큼 prefetch도 늦게 시작된다.

    • 💡 hydration에 대해서는 다음에 알아보자.
  6. 미리 가져오는 양은 라우트 종류에 따라 다른데, 정적 라우트는 전체가 prefetch되고, 동적 라우트는 prefetch를 건너뛰거나 loading.tsx가 있을 때 부분적으로만 prefetch된다.

  7. 그래서 "클릭 시점에는 다음 라우트의 데이터가 이미 준비되어 있어 이동이 즉각적으로 느껴진다"는 것은 “정적 라우트”에 해당하는 이야기다.

  8. 동적 라우트에서는 내비게이션 전에 서버 응답을 기다리게 되어, 앱이 반응하지 않는 것처럼 보일 수 있다.

  9. 평범한 <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>
      )
    }
  10. prefetch를 끄고 싶다면 prefetch={false}를 주면 되고, 이는 링크가 매우 많은 목록(예: 무한 스크롤)에서 불필요한 자원 사용을 줄이는 데 쓴다.

    • 참고로 App Router에서 prefetch={false}는 뷰포트 진입과 hover 양쪽 모두를 막는다.
    • 완전히 끄는 대신 hover할 때만 켜는 절충도 가능하다. 평소엔 prefetch={false}로 두고, onMouseEnter에서 상태를 바꿔 prefetch={null}로 되돌리면 사용자가 관심을 보인 링크만 prefetch하게 된다.

클라이언트 라우팅을 직접 흉내내기: Shallow Routing

  1. <Link>가 파일 기반 라우팅을 따른다면, 파일 구조와 무관하게 URL만 직접 바꾸고 싶은 경우도 있다.

  2. strict SPA에서는 보통 react-router 같은 라이브러리로 URL을 직접 바꾸며 화면을 전환한다.

  3. 다만 아래에서 다루는 방식은 라우트 전환을 대체하는 수단이 아니라, 같은 화면 안에서 URL 표시만 맞춰 주는 용도라는 점을 알아두자.

  4. 이런 코드를 옮겨올 때를 위해, Next.js는 브라우저 기본 API인 window.history.pushStatewindow.history.replaceState 사용을 허용한다.

    • window.history.pushState: 새 히스토리 항목을 추가하면서 페이지 새로고침 없이 URL과 상태를 변경한다.
    • window.history.replaceState: 현재 히스토리 항목을 새 항목 추가 없이 URL과 상태로 교체한다.
  5. 이 두 메서드는 페이지를 다시 로드하지 않은 채 브라우저의 history 스택만 갱신한다.

  6. 더 중요한 점은 이 호출이 Next.js 라우터와 연동되어, usePathname, useSearchParams 값과 동기화된다는 것이다.

  7. 예를 들어 정렬 상태를 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>
        </>
      )
    }
  8. 버튼을 누르면 페이지 새로고침 없이 sort 값만 바뀌고, 그 변화가 usePathnameuseSearchParams를 구독하는 클라이언트 컴포넌트에 반영된다.

  9. 반면 서버 컴포넌트가 새 searchParams를 받아 다시 렌더되지는 않는다.

  10. 즉, pushState는 서버에서 데이터를 다시 가져오지 않으므로, 정렬된 결과를 “서버에서 받아야 한다면” <Link>router.push로 실제 내비게이션을 일으켜야 한다.

  11. 정리하면 이 기법의 적정 용도는 정렬 상태나 필터처럼 클라이언트가 이미 가진 상태를 URL에 기록해 공유·복원 가능하게 만드는 것이다.

  12. 해당 기능을 useState로 구현할 수도 있겠지만, 정렬 상태를 URL에 기록한다면 다른 사람에게도 정렬 상태로 공유할 수 있는 이점이 있다!

브라우저에서만 동작하는 컴포넌트 다루기

  1. SPA에서는 windowdocument 같은 브라우저 API에 의존하는 서드파티 라이브러리를 자주 쓴다.

  2. 그런데 Next.js는 클라이언트 컴포넌트라도 next build 시점에 미리 렌더링(prerender)하므로, 서버에 없는 브라우저 API를 만나면 문제가 생길 수 있다.

    • 💡 클라이언트 컴포넌트가 미리 렌더링 된다는 이야기는 다음에 다뤄보자.
  3. 이때는 next/dynamic으로 해당 컴포넌트의 prerender를 끄고, 브라우저에서만 불러오도록 지정한다.

  4. 여기서 주의할 점은, ssr: false는 서버 컴포넌트에서 쓸 수 없다.

  5. App Router에서 layout.tsxpage.tsx는 기본이 서버 컴포넌트이므로, 거기에 그대로 적으면 ssr: false is not allowed with next/dynamic in Server Components 에러로 빌드가 실패한다.

  6. 따라서 '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 />
    }
  7. ssr: false 옵션이 서버 렌더링 단계를 건너뛰게 하므로, 컴포넌트는 브라우저 환경에서만 실행된다.

  8. 사용자 입장에서는 해당 컴포넌트가 비교적 느리게 생성된다.

정적 내보내기로 strict SPA에 가깝게 만들기

  1. 서버 없이 정적 파일만으로 배포하고 싶다면, Next.js의 정적 내보내기(static export) 기능을 쓴다.

  2. Next.js 설정 파일에서 출력 모드를 export로 바꾸면 이 기능이 켜진다.

    // next.config.ts
    
    import type { NextConfig } from 'next'
    
    const nextConfig: NextConfig = {
      // 'export' -> 라우트별 정적 HTML을 out 폴더로 내보낸다
      output: 'export',
    }
    
    export default nextConfig
  3. 이 상태로 next build를 실행하면 out 폴더에 HTML/CSS/JS 자산이 생성된다.

  4. 이때 단일 index.html 하나가 아니라 라우트마다 HTML 파일이 만들어지므로, 방문자는 클라이언트 JavaScript 번들을 기다리지 않고 내용을 더 빨리 받는다.

  5. 화면 간 이동은 여전히 클라이언트에서 즉각적으로 일어나, SPA다운 전환 경험은 그대로 유지된다. HTML 파일이 여러 장이어도 SPA라 부를 수 있는 이유가 여기에 있다. 기준은 파일 개수가 아니라 내비게이션 시 문서 요청 여부다.

  6. 다만 정적 내보내기에서는 Next.js의 서버 기능을 쓸 수 없다는 제약이 있으므로, 서버가 필요한 기능과는 구분해서 선택한다.

그래서 이것이 왜 "SPA"이고, 어디로 확장되는가

  1. 지금까지의 동작을 모으면 SPA의 정의와 정확히 맞물린다.
  2. SPA의 핵심은 "문서를 다시 받지 않고 화면만 바꾸는 것"인데, <Link>의 client side transition이 바로 그 일을 한다.
  3. 여기에 prefetching이 더해져 전환이 지연 없이 느껴지고, 코드 분할이 더해져 각 화면에 필요한 만큼만 JavaScript를 내려받는다.
  4. 직접 확인하려면 <html>의 배경색을 노란색으로 바꾼 뒤 <Link>로 두 페이지를 오가 보면 되는데, 배경색이 유지되면 풀 리로드 없이 클라이언트 내비게이션이 동작한다는 뜻이다.
  5. 같은 실험을 <a> 태그로 하면 클릭할 때 브라우저가 전체를 새로고침하므로 배경색이 초기화된다.
  6. 결국 "Next.js로 SPA를 구현할 수 있다"는 말은, <Link>로 SPA의 사용 경험을 그대로 누리면서도 필요할 때 React Server ComponentsServer Actions 같은 서버 기능을 점진적으로 더해 갈 수 있다는 확장 가능성까지 포함한다.

마무리

  1. Next.js의 SPA와 React + Vite의 SPA는 앱 안을 이동하는 느낌은 같고 구현 방식이 다르다.
  2. "문서를 다시 받지 않고 화면만 바꾼다"는 성질은 양쪽 모두 만족하지만, Vite는 그 일을 하기 위해 CSR을 택하고 Next.js는 <Link>를 택한다.
  3. 그 결과 첫 로딩과 SEO에서 차이가 생기고, 서버 기능으로의 확장 경로가 열려 있느냐가 갈린다.