Skip to content

Instantly share code, notes, and snippets.

@ellemedit
Last active December 18, 2021 11:02
Show Gist options
  • Save ellemedit/7a33cf5e114e5f1b2616b7239d1633de to your computer and use it in GitHub Desktop.
Save ellemedit/7a33cf5e114e5f1b2616b7239d1633de to your computer and use it in GitHub Desktop.

제 경험이 당신의 next.js 프로젝트에 도움이 되거나 영감을 주면 좋겠습니다.

왜 퍼시스턴트 레이아웃이 필요한가요?

가장 근본적인 이유는 퍼시스턴트하지 않으면 모든 DOM 노드가 파괴되고 다시 생성된다는 점 입니다.

  1. 이전 페이지의 DOM Node가 버려지기 때문에 항상 DOM 상태를 잃어버립니다. 예를들어 레이아웃에 흔하게 있을 수 있는 검색창 입력, 네비게이션 메뉴의 포커스 상태가 페이지 이동마다 사라집니다.
  2. 당연하게도 CSS Transition이 불가능합니다. 애니메이션이 필요하다면 전혀 다른 방법으로 구현해야 합니다.

위 이유로 인해 애플리케이션의 사용성과 접근성이 떨어질 수 있습니다.

어떤게 퍼시스턴트 하지 않은 레이아웃인가요?

Next.js 프로젝트를 아래와 같이 구성해봅시다.

// pages/index.js
const HomePage = () => (
  <>
    <header>
      <h1>Awesome Title</h1>
    </header>
    <nav><input placeholder="Search ..." /></nav>
    <main>
      Home Page, <Link to="/second"><a>to Second Page</a></Link>
    </main>
  </>
)
// pages/second.js
const SecondPage = () => (
  <>
    <header>
      <h1>Awesome Title</h1>
    </header>
    <nav><input placeholder="Search ..." /></nav>
    <main>
      Second Page, <Link to="/home"><a>to Home Page</a></Link>
    </main>
  </>
)

HomePageSecondPage는 같은 자식 엘리먼트 header, nav, main을 가지지만 서로 다른 컴포넌트이기 때문에 다른 페이지로 서로 페이지를 이동할 때 모든 자식 엘리먼트는 버려지고 새로 생성됩니다.

어떻게 퍼시스턴트한 레이아웃을 만들 수 있나요?

next.js 공식문서에 적힌 대로 pages/_app.js에 래퍼를 추가하는 것으로 가능합니다.

// pages/_app.js
const App = ({ Component, pageProps }) => (
  <MyLayout>
    <Component {...pageProps} />
  </MyLayout>
);

const MyLayout = ({ children }) => (
  <>
    <header>
      <h1>Puesdo GitHub</h1>
    </header>
    <nav><input placeholder="Search ..." /></nav>
    <main>
      {children}
    </main>
  </>
);
// pages/index.js
const HomePage = () =>
  <>Home Page, <Link to="/second"><a>to Second Page</a></Link></>;
// pages/second.js
const SecondPage = () =>
  <>Second Page, <Link to="/home"><a>to Home Page</a></Link></>;

위 코드처럼 작성하면 레이아웃의 header, nav, main 엘리먼트가 항상 유지됩니다. 하지만 모든 페이지가 동일한 레이아웃 구성을 가졌다고 가정할 수 없습니다. 예를 들어 여러분이 깃헙과 유사한 프로젝트를 개발한다고 합시다. GitHub을 처음 접속했을 때 보이는 페이지와 Organization 페이지의 레이아웃을 떠올려봅시다. 상단 네비게이션을 제외하면 페이지의 나머지 부분은 일치하는 부분이 없습니다. Organization 페이지는 Repository 목록 외에도 Packages, Projects 그리고 Settings 탭이 존재하고 각 탭은 레이아웃을 공유하고 고유 페이지로 존재합니다. 이 요구사항을 만족하기 위해 빠르게 구현해봅시다.

// pages/_app.js
const App = ({ Component, pageProps }) => {
  const router = useRouter();
  const Layout = DefaultLayout;
  if (router.pathname.startsWith('/[owner]')) {
    Layout = WorkspaceLayout;
  }
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  );
}

const DefaultLayout = ({ children }) => (
  <header>
    <h1>Puesdo GitHub</h1>
  </header>
  <nav>
    <Link href="/my-workspace">
      <a>My Workspace</a>
    </Link>
  </nav>
  <main>
    {children}
  </main>
);

const OrganizationLayout = ({ children }) => (
  <>
    <header>
      <h1>Puesdo GitHub</h1>
    </header>
    <nav>
      <a>Repositories</a>
      <a>Packages</a>
      <a>Settings</a>
    </nav>
    <main>
      {children}
    </main>
  </>
);

위 예제로 레이아웃을 분리할 수 있게 됬지만 세 가지 문제점이 있습니다.

  1. 어떤 레이아웃이 렌더링되는지 페이지 컴포넌트만 보고 알 수 없습니다. pages/_app.js를 확인해야 알 수 있습니다. colocation을 할 수 없고, 페이지를 개발하는데 알아야 하는 내용이 많아져서 유지보수성이 떨어집니다.
  2. DefaultLayout, OrganizationLayout 두 페이지의 레이아웃이 자식 엘리먼트들이 재사용되지 않습니다.
  3. 레이아웃 단위에서 data fetching이 불가능합니다.

레이아웃 전환시에 같은 엘리먼트를 유지하기

페이지 컴포넌트에 레이아웃을 명시하도록 바꿉니다. 다음과 같이 만들 수 있습니다:

// pages/index.js
const HomePage = () => {...};
HomePage.withLayout = {
  render: (children) => (
    <>
      <header>
        <h1>Puesdo GitHub</h1>
      </header>
      <nav>
        <Link href="/my-workspace">
          <a>My Workspace</a>
        </Link>
      </nav>
      <main>
        {children}
      </main>
    </>
  )
}
// pages/[owner]/index.js
const OrganizationPage = () => {...};
OrganizationPage.withLayout = {
  render: (children) => (
    <>
      <header>
        <h1>Puesdo GitHub</h1>
      </header>
      <nav>
        <a>Repositories</a>
        <a>Packages</a>
        <a>Settings</a>
      </nav>
      <main>
        {children}
      </main>
    </>
  )
}
// pages/_app.js
const App = ({ Component, pageProps, layoutData }) => {
  const renderLayout = Component.withLayout?.render
    || (page) => page;
  return renderLayout(<Component {...pageProps} />);
};

이렇게 구성하면 레이아웃을 페이지에서 명시할 수 있습니다. 그리고 레이아웃의 엘리먼트 위치가 트리 내에서 항상 일정하기 때문에 DOM 노드를 재사용하게 됩니다.

레이아웃에서 필요한 데이터를 Fetching하기

몇몇 레이아웃은 SSR중에 비동기적으로 데이터를 가져와야 할 수 있습니다. 아주 간단하게 getInitialProps 와 비슷한 녀석을 구현할 수 있습니다.

const App = ({ Component, pageProps, layoutData }) => {
  const renderLayout = Component.withLayout?.render
    || (page) => page;
  return renderLayout(<Component {...pageProps} />, layoutData);
};

App.getInitialProps = async ({ ctx, Component }) => {
  const [pageProps, layoutData] = await Promise.all([
    Component.getInitialProps?.(ctx),
    Component.withLayout?.fetchInitialData?.(ctx),
  ]);
  return { pageProps, layoutData };
}

7줄을 추가하여 아래와 같이 사용할 수 있습니다.

// pages/[owner]/index.js
const OrganizationPage = () => {...};
OrganizationPage.withLayout = {
  render: (children, { status, organization }) => status === 'stand-by' ? (
    <>
      <header>
        <h1>{organization.name}</h1>
        <p>{organization.description}</p>
      </header>
      <nav>
        <a>Repositories</a>
        <a>Packages</a>
        <a>Settings</a>
      </nav>
      <main>
        {children}
      </main>
    </>
  ) : (
    <>
      <header>
        <h1>404 Not Found</h1>
        <p>Requested organization does not exist</p>
      </header>
    </>
  ),
  fetchInitialData: async (context) => {
    const ownerId = context.query.owner;
    try {
      const organization = await fetchOrganizationByOwnerId(ownerId);
      return { organization, status: 'stand-by' };
    } catch (error) {
      if (error.response.status === 404) {
        return { status: 'not-found' };
      }
      throw error;
    }
  },
}

이제 대부분의 레이아웃 요구사항이 충족되었습니다.

  1. SSR 과정중에 Data Fetching 가능
  2. 페이지 단위로 레이아웃을 명시할 수 있음
  3. 레이아웃 하위 요소가 재사용됨

이것만으로도 대부분의 상황에서 훌륭하게 페이지 레이아웃을 구성할 수 있습니다.

레이아웃 분리

여러 레이아웃을 합성할 수 있으면 레이아웃을 분리하고 재사용하기 훨씬 쉬워집니다. 다음과 같은 레이아웃들이 있다고 해봅시다.

const DefaultLayout = {
  render: (children) => (
    <>
      <TopNavigation />
      <div>{children}</div>
    </>
  ),
};

const OrganizationLayout = {
  render: (children) => (
    <>
      <TopNavigation />
      <WorkspaceSideNavigation />
      {children}
    </>
  ),
};

const OrganizationProjectLayout = {
  render: (children) => (
    <>
      <TopNavigation />
      <WorkspaceSideNavigation />
      <div>
        <ProjectNavigation />
        {children}
      </div>
    </>
  ),
};

각 레이아웃이 이전 레이아웃과 굉장히 유사합니다. OrganizationProjectLayoutOrganizationLayout가 확장된 형태고 OrganizationLayoutDefaultLayout가 확장된 형태입니다. 위 레이아웃에서 중복되는 부분을 분리해서 사용할 수 있으면 더욱 편리할 것입니다.

const DefaultLayout = {
  render: (children) => (
    <>
      <TopNavigation />
      {children}
    </>
  ),
};

const OrganizationLayout = {
  render: (children) => (
    <>
      <WorkspaceSideNavigation />
      {children}
    </>
  ),
  parent: DefaultLayout,
};

const OrganizationProjectLayout = {
  render: (children) => (
    <div>
      <ProjectNavigation />
      {children}
    </div>
  ),
  parent: OrganizationLayout,
};

복잡한 CSS나 렌더링 로직이 포함되어 있지 않아 줄어든 부분이 부각되지 않지만, 일반적으로 문제를 분리할 수 있으면 단순화시켜서 더 쉽게 개발할 수 있듯이 레이아웃도 비슷하게 생각할 수 있습니다. 각 레이아웃별로 담당하는 부분은 다르지만 최종적으로 OrganizationProjectLayout가 아래 처럼 결과를 반환하면 되는거죠.

// 실제론 이렇게 동작하지 않습니다.
// 우리가 정의한 Layout들은 Component가 아니니까요.
<DefaultLayout>
  <OrganizationLayout>
    <OrganizationProjectLayout>
      {children}
    </OrganizationProjectLayout>
  </OrganizationLayout>
</DefaultLayout>

이렇게 되면 레이아웃 단계별로 관심사를 분리할 수 있게 됩니다. 아주 간단하게 구현할 수 있습니다.

// pages/_app.js
const App = ({ Component, pageProps, layoutDataList }) => {
  const layouts = getLayoutList(Component.withLayout);
  return layouts.reduce(
    (children, { render }) => render(children, layoutDataList),
    <Component {...pageProps}>,
  );
};

App.getInitialProps = async ({ ctx, Component }) => {
  const [pageProps, layoutDataList] = await Promise.all([
    Component.getInitialProps?.(ctx),
    // 비동기 처리에 유의하세요
    Promise.all(
      getLayoutList(Component).map(
        layout => layout.withLayout?.fetchInitialData?.(ctx)
      )
    ),
  ]);
  return { pageProps, layoutDataList };
}

const getLayoutList = (layout) => {
  const list = [];
  let lastLayout = layout;
  while (lastLayout) {
    list.push(layout);
    lastLayout = layout.parent;
  }
  return list;
}

이 글에서 다루지 않은 요청 중복 방지나 다른 요구사항도 있지만, 제 경험상 위 기능들만 충족되면 대부분의 레이아웃을 커버할 수 있었습니다.

@spilist
Copy link

spilist commented Apr 24, 2021

일전에 말씀드린 캐시노트 재구축하면서 CRA + react-router -> next.js 를 고민하던 차에 이 문제를 어떻게 해결하지? 하다가 김성현님이 바환님 글을 찾아서 보내주셨습니다. 명쾌하네요. 좋은 글 감사합니다. :)

다른 문제도 두 개 정도 더 있었는데..

  • pages/ 안에 해당 페이지의 모든 관심사를 colocate하고 싶다 -> 이걸로 해결
  • URL을 한글로 만들고 싶다 -> vercel/next.js#10084 아직 해결 못함

한글 URL은 next.js 를 fork떠서 수정하는 수밖엔 없나.. 하는 생각이 드네요. 바환님은 혹시 nextjs 에서 한글로 URL을 시도해보신 적 있나요?

@ellemedit
Copy link
Author

한글 URL은 PR 날리는 것 외에 방법이 없습니다. 힘내세요 ㅠㅠ

@spilist
Copy link

spilist commented Apr 24, 2021

방법이 없나.. 허허.. 아무튼 감사합니다 ㅎㅎ

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment