기억보단 기록을/Next JS (Pages Router)

Routing - Pages and Layouts

_OIL 2023. 10. 17. 00:13
반응형

페이지 라우터는 페이지 개념을 기반으로 구축된 파일 시스템 기반 라우터입니다.

파일이 페이지 디렉터리에 추가되면 자동으로 경로로 사용할 수 있습니다.


Index routes

라우터는 index라는 이름의 파일을 디렉터리의 루트로 자동 라우팅합니다.

  • pages/index.js → /
  • pages/blog/index.js → /blog

Nested routes

라우터는 중첩 파일을 지원합니다. 중첩된 폴더 구조를 만들면 파일은 여전히 동일한 방식으로 자동으로 라우팅 됩니다.

  • pages/blog/first-post.js → /blog/first-post
  • pages/dashboard/settings/username.js → /dashboard/settings/username

Pages with Dynamic Routes

Next.js는 동적 경로가 있는 페이지를 지원합니다. 예를 들어 pages/posts/[id]. js라는 파일을 만들면 posts/1, posts/2 등에서 액세스 할 수 있습니다.


Layout Pattern

React 모델을 사용하면 페이지를 일련의 컴포넌트로 분해할 수 있습니다. 이러한 컴포넌트 중 상당수는 페이지 간에 재사용되는 경우가 많습니다. 예를 들어 모든 페이지에 동일한 navigation bar와 footer가 있을 수 있습니다.

import Navbar from './navbar'
import Footer from './footer'

export default function Layout({ children }) {
  return (
    <>
      <Navbar />
      <main>{children}</main>
      <Footer />
    </>
  )
}

 


Examples

Single Shared Layout with Custom App

전체 애플리케이션에 레이아웃이 하나만 있는 경우 재사용될 <Layout /> 컴포넌트를 만들어 애플리케이션 전체를 래핑 합니다.

import Layout from '../components/layout'

export default function MyApp({ Component, pageProps }) {
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  )
}

Per-Page Layouts(페이지별 레이아웃)

여러 레이아웃이 필요한 경우, 페이지에 getLayout 속성을 추가하여 레이아웃에 대한 React 컴포넌트를 반환할 수 있습니다. 이를 통해 페이지 단위로 레이아웃을 정의할 수 있습니다. 함수를 반환하기 때문에 원하는 경우 복잡한 중첩 레이아웃을 가질 수 있습니다.

import type { ReactElement } from "react";
import Layout from "@/components/Layout";
import NestedLayout from "@/components/NestedLayout";
import { NextPageWithLayout } from "@/pages/_app";

const Page: NextPageWithLayout = () => {
  return <div className="bg-amber-600">본문</div>;
};

Page.getLayout = function getLayout(page: ReactElement) {
  return (
    <Layout>
	    <NestedLayout>{page}</NestedLayout>
    </Layout>
  );
};

export default Page;

import type { ReactElement, ReactNode } from "react";
import type { NextPage } from "next";
import type { AppProps } from "next/app";
import "../styles/globlas.css";

export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
  getLayout?: (page: ReactElement) => ReactNode;
};

type AppPropsWithLayout = AppProps & {
  Component: NextPageWithLayout;
};

export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
  // Use the layout defined at the page level, if available
  const getLayout = Component.getLayout ?? ((page) => page);

  return getLayout(<Component {...pageProps} />);
}

페이지 사이를 탐색할 때 SPA(단일 페이지 애플리케이션) 경험을 위해 페이지 상태(입력 값, 스크롤 위치 등)를 유지하려고 합니다. 이 레이아웃 패턴은 페이지 전환 사이에 React 컴포넌트 트리가 유지되기 때문에 상태 지속성을 가능하게 합니다. 컴포넌트 트리를 통해 React는 상태를 유지하기 위해 어떤 요소가 변경되었는지 파악할 수 있습니다.

상세 설명

1. `NextPageWithLayout` 타입 정의:

export type NextPageWithLayout<P = {}, IP = P> = NextPage<P,IP> & {
	getLayout?: (page: ReactElement) => ReactNode;
};

 

- `NextPageWithLayout`는 `NextPage`의 확장된 타입입니다. 이 타입은 페이지 컴포넌트에 레이아웃을 적용하는 데 사용됩니다.
- `getLayout`은 페이지 컴포넌트에서 레이아웃을 정의할 때 사용할 수 있는 함수입니다.
   
2. `AppPropsWithLayout` 타입 정의:

type AppPropsWithLayout = AppProps & {
	Component: NextPageWithLayout;
};

 

 - `AppPropsWithLayout`는 앱의 props에 페이지 컴포넌트와 레이아웃을 관리하기 위한 확장된 타입입니다. `Component` 속성은 `NextPageWithLayout` 유형을 가지고 있습니다.

3. `MyApp` 컴포넌트:

export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
	// Use the layout defined at the page level, if available
	const getLayout = Component.getLayout ?? ((page) => page);
	return getLayout(<Component {...pageProps} />);
}

- `MyApp` 컴포넌트는 앱의 메인 컴포넌트로 사용됩니다.
- `Component`는 현재 페이지 컴포넌트를 나타냅니다.
- `pageProps`는 현재 페이지 컴포넌트의 프롭스(props)입니다.
- `Component.getLayout`은 페이지 컴포넌트에서 정의한 레이아웃 함수입니다. 페이지 컴포넌트가 이 함수를 정의한 경우 페이지 컴포넌트의 레이아웃 함수가 사용됩니다. 그렇지 않으면 기본적으로 페이지 컴포넌트 자체가 레이아웃으로 사용됩니다.
- 마지막으로, `getLayout` 함수를 사용하여 현재 페이지 컴포넌트와 프롭스를 레이아웃 함수에 전달하고, 레이아웃 된 페이지를 반환합니다.


이렇게 설정된 `MyApp` 컴포넌트는 전역적으로 페이지 컴포넌트에 레이아웃을 적용하는 데 사용됩니다. 페이지 컴포넌트에서 레이아웃 함수를 정의하면 해당 레이아웃이 페이지에 적용되며, 그렇지 않으면 페이지 컴포넌트 자체가 레이아웃으로 사용됩니다.

 

❓ 중첩 레이아웃을 사용하면 상태값이 유지되는 이유는 뭘까요?

레이아웃 변경은 페이지의 일부 컴포넌트 트리를 변경하므로 React는 레이아웃 변경에 따라 최소한의 DOM 조정만 수행할 것입니다.

 

예를 들어, 헤더, 사이드바 및 메인 콘텐츠로 구성된 레이아웃을 가진 페이지를 생각해 보십시오. 사용자가 이 페이지를 다른 페이지로 이동할 때, React는 현재 페이지와 새 페이지 간의 레이아웃 변경에 따라 레이아웃을 효율적으로 조정합니다. 새로운 페이지의 레이아웃이 이전 페이지와 다른 경우, React는 필요한 변경만 DOM에 반영하며 이러한 변경은 reconciliation 프로세스를 통해 관리됩니다.

 

요약하면, Next.js의 per-page layout를 사용하면 각 페이지에 대해 사용자 정의 레이아웃을 정의할 수 있으며, React의 reconciliation 프로세스는 이러한 레이아웃 변경을 효율적으로 관리하고 렌더링 성능을 최적화합니다.

[state 보존 및 재설정] 참고

 

그래서 [nextjs-nested-layout-pages-dir]의 dashboard하위의 페이지들이 고유의 레이아웃을 만들어도 인풋의 입력값이 유지되는 것을 볼 수 있습니다.

Per-page layout pattern이 생긴 배경

부제: 왜 getLayout은 함수여야 할까?

참고 블로그 링크: https://adamwathan.me/2019/10/17/persistent-layout-patterns-in-nextjs/ (저자: Adam Wathan-tailwindCSS 만든 사람)

React, Angular, Vue와 같은 SPA의 가장 큰 장점 중 하나는 페이지 이동 시에도 전체 문서를 처음부터 다시 렌더링 하지 않고도 사이트를 탐색할 수 있다는 점이었습니다.  왜냐면 페이지를 새로 받아와 렌더링 하는 것이 아니기 때문이죠.

즉, 스크롤 위치를 저장하고 다음 페이지 이동 시 스크롤 위치를 복원하는 복잡한 작업 없이도 사이드바 컴포넌트처럼 변경되지 않는 UI부분의 스크롤 위치를 보존하는 등의 작업을 수행할 수 있습니다.

그러나 NextJs를 사용해 보면 Link를 통해 페이지 이동시 전체 화면을 처음부터 다시 렌더링 한다는 것을 알 수 있습니다. 그렇다면 NextJs는 페이지 이동시에도 변하지 않는 UI의 상태값을 유지하기 위해 어떤 방법을 시도해왔을까요?

 

위 프로젝트는 크게 두 가지 섹션으로 구성되어 있습니다:

  • 홈 화면은 한 페이지로 구성되어 있습니다.
  • 가로로 스크롤할 수 있는 탭 목록이 포함된 account-settings 섹션으로, 이 섹션을 클릭하면 다른 하위 섹션으로 이동할 수 있습니다.

위 데모에서는 페이지에 필요한 레이아웃 구성 요소를 각 페이지마다 동일하게 넣어주고 있습니다. 이 접근 방식의 문제점은 account-settings 페이지 중 하나를 방문하여 탭 목록을 오른쪽 끝까지 스크롤한 다음 'Security' 탭을 클릭하면 페이지가 이동되면서 탭의 가로 스크롤이 왼쪽 끝으로 초기화됩니다.

 

분명 reconciliation개념에 따라 동일한 component tree를 갖고 있으면 스크롤 위치 같은 상태값이 유지되어야 하는데 뭔가 이상합니다.

사실 각 페이지의 가장 상위 component는 우리가 보고 있는 그 SiteLayout가 아니라 바로 _app.js파일에 전달되는 props 중에 Component(Page component)입니다. 페이지 이동시 Page component가 바뀌므로(가장 최상위 component가 바뀌므로) React는 모든 children을 버리고 처음부터 다시 그리게 됩니다. 즉, 이전 페이지에서 이미 DOM의 같은 위치에 해당 컴포넌트를 렌더링 했더라도 각 페이지가 SiteLayout 또는 AccountSettingsLayout의 새로운 복사본을 렌더링 합니다.

 

그래서 NextJs에서 위 데모처럼 코드를 짜면 서버 기반 애플리케이션 UI처럼 느껴지는 단일 페이지 애플리케이션이 돼버립니다. 으악!

방법 1: 사용자 지정 <App> 컴포넌트에서 단일 공유 레이아웃(single shared layout) 사용

사이트에 persistent layout component를 추가하는 한 가지 방법은 custom App component를 만들고 컴포넌트 트리에서 항상 현재 Page component 위에 사이트 레이아웃을 렌더링 하는 것입니다.

import SiteLayout from '../components/SiteLayout'

export default function MyApp({ Component, pageProps }) {
  return (
    <SiteLayout>
      <Component {...pageProps} />
    </SiteLayout>
  )
}

SiteLayout 컴포넌트는 페이지 전환 시 재사용되므로 검색 필드에 입력한 내용이 그대로 유지됩니다.

하지만 AccountSettingLayout처럼 일부 페이지에서 공유하는 추가적인 Layout이 있다면? Account setting의 하위 페이지들은 AccountSettingLayout을 사용해야 됩니다. 그리고 AccountSettingLayout는 가로 스크롤이 있는 tab bar를 포함하고 있습니다. AccountSettingLayout은 App component에 포함되어있지 않기 때문에 tab bar의 scroll 위치를 기억할 수 없습니다.

방법 2: 현재 URL을 기반으로 <App>에서 다른 레이아웃 렌더링하기

단일 공유 레이아웃(single shared layout)으로 가능한 것보다 요구 사항이 약간 더 복잡하다면 현재 URL을 기반으로 다른 트리를 렌더링 하는 것으로 해결할 수 있습니다.

router.pathname을 검사하여 현재 사이트의 어느 'section'에 있는지 파악하고 해당 레이아웃 트리를 렌더링 할 수 있습니다:

class MyApp extends App {
  render() {
    const { Component, pageProps, router } = this.props
    if (router.pathname.startsWith('/account-settings/')) {
      return (
        <SiteLayout>
          <AccountSettingsLayout>
            <Component {...pageProps}></Component>
          </AccountSettingsLayout>
        </SiteLayout>
      )
    }
    return (
      <SiteLayout>
        <Component {...pageProps}></Component>
      </SiteLayout>
    )
  }
}

이 경우, /account-settings/ 아래 페이지들은 같은 layout component tree를 공유할 수 있고 따라서 SiteLayout 뿐 아니라 AccountSettingsLayout 역시 재 사용 되어 페이지 이동을 하더라도 상태를 유지할 수 있습니다.

이 접근 방식의 단점은 각 페이지 component 별로 어떤 Layout을 사용하는지 Page 파일만 봐서는 알기 어려운 문제점이 있으며

레이아웃을 URL에 연결하기 때문에 레이아웃을 변경해야 하는 경우(예: /account-settings/delete가 완전히 다른 레이아웃을 사용하는 경우) 매우 구체적인 조건부 로직을 점점 더 많이 추가해야 합니다.

따라서 소규모 사이트에서는 이 방법이 효과적일 수 있지만, 대규모 사이트에서는 좀 더 선언적이고 유연한 방법이 필요할 것입니다.

방법 3: Page component에 정적 'layout' 속성 추가하기

시간이 지남에 따라 앱 컴포넌트의 복잡성이 증가하는 것을 방지하는 한 가지 방법은 페이지 레이아웃을 정의하는 책임을 앱 컴포넌트가 아닌 페이지 컴포넌트로 옮기는 것입니다.

각 Page Component가 export 하는 page component 객체에 ‘layout’이라는 property를 추가하고 그 layout property를 앱 컴포넌트 내부에서 읽어주면 됩니다 대신 Layout component를 중첩해 선언할 수는 없기 때문에, 이 경우 중첩된 Layout을 담는 하나의 새로운 Layout이 필요합니다:

//components/AccountSettingsLayout.jsx
const AccountSettingsLayout = () => <SiteLayout>{/* ... */}</SiteLayout>

export default AccountSettingsLayout

// /pages/account-settings/basic-information.js
import AccountSettingsLayout from '../../components/AccountSettingsLayout'

const AccountSettingsBasicInformation = () => <div>{/* ... */}</div>

AccountSettingsBasicInformation.layout = AccountSettingsLayout

export default AccountSettingsBasicInformation

import React from 'react'
import App from 'next/app'

class MyApp extends App {
  render() {
    const { Component, pageProps } = this.props
    const Layout = Component.layout || (children => <>{children}</>)
    return (
      <Layout>
        <Component {...pageProps}></Component>
      </Layout>
    )
  }
}
export default MyApp

이 방법은 완벽해 보이지만 한 가지 문제가 있습니다. SiteLayout의 state가 보존되지 않습니다.

그 이유는 이 예제에서 AccountSettingsLayout이 내부적으로 SiteLayout을 사용하기 때문에 실제 최상위 레이아웃 컴포넌트가 SiteLayout에서 AccountSettingsLayout으로 전환되고 원래 있던 SiteLayout이 파괴되고 AccountSettingsLayout 내부에서 생성된 새 SiteLayout 인스턴스로 대체되기 때문입니다.

즉, SiteLayout을 layout으로 쓰는 페이지에서 AccountSettingsLayout을 layout으로 쓰는 페이지로 이동할 경우, react 관점에서 최상의 component가 바뀌는 것이므로 새롭게 SiteLayout instance를 생성합니다.

방법 4: Page component에 getLayout 함수 추가하기

단순한 layout 프로퍼티 대신 정적 함수를 사용하면 복잡한 레이아웃 트리를 반환할 수 있습니다:

// /pages/account-settings/basic-information.js

import SiteLayout from '../../components/SiteLayout'
import AccountSettingsLayout from '../../components/AccountSettingsLayout'
const AccountSettingsBasicInformation = () => <div>{/* ... */}</div>

AccountSettingsBasicInformation.getLayout = page => (
	<SiteLayout>
		<AccountSettingsLayout>{page}</AccountSettingsLayout>
	</SiteLayout>
)

export default AccountSettingsBasicInformation

그런 다음 _app.js에서 현재 페이지에 전달된 함수를 호출하여 전체 트리를 가져올 수 있습니다:

// /pages/_app.js
import React from 'react'
import App from 'next/app'

class MyApp extends App {
	render() {
		const { Component, pageProps, router } = this.props
		const getLayout = Component.getLayout || (page => page)
		return getLayout(<Component {...pageProps}></Component>)
	}
}
export default MyApp

(이 예제에서는 getLayout이라는 이름을 사용했지만, 프레임워크 기능이나 다른 어떤 것이든 원하는 대로 사용할 수 있습니다).

이렇게 하면 각 페이지 컴포넌트가 전체 레이아웃을 담당하고 임의의 수준의 UI 지속성을 허용합니다:

 

 

반응형