Nextjs Layouts RFC Update

9분

9/13/2022

Thumbnail

들어가면서

Next.js Layouts RFC가 5월 24일에 공개되었습니다. 그로부터 약 4개월이 지난 시점에 Next.js 12.3 버전이 릴리즈되면서 새로운 소식이 포함되었습니다.

현재 새로운 Layouts 개발이 진행 중이며, Layouts RFC에 대한 내용도 같이 업데이트 되었습니다.

Layouts RFC에 대한 첫 번째 블로그 글에서 Part1에 대한 내용을 다루었습니다. 그리고 Part2에 대한 내용이 이번 12.3 버전 릴리즈 소식과 함께 공개되었습니다.

이 글에서는 Layouts RFC 업데이트된 내용을 옮겨보고 정리해보겠습니다.

Layouts RFC 업데이트 내용

  • Route Groups
  • Server-Centric Routing
  • Instant Loading States
  • Error Handling
  • Templates
  • Intercepting Routes, Parallel Routes, Conditional Routes

Route Groups

https://nextjs.org/blog/layouts-rfc#route-groups

기본적으로 app 폴더의 계층 구조는 URL 경로와 바로 매핑되게 됩니다. 그러나 route group으로 만들어진 구조는 URL 경로에 매핑되지 않게 할 수 있습니다. Route groups는 다음과 같이 사용가능합니다:

  • URL 구조에 영향 없이 route를 구성할 수 있습니다.
  • Layout을 route segment에 따라 구분하여 구성할 수 있습니다.
  • 어플리케이션을 나누어 여러 root layout을 만들 수 있습니다.

tip

Remix에서는 pathless layout routes를 제공하는데 double underscore convention(__pathless/cart.js)를 통해 이와 같은 기능을 구현할 수 있습니다. 생각해보면 Remix 영향을 안 받을 수 없었을 것 같다는 생각이 들게됩니다.

Convention

route group은 괄호로 폴더이름을 감싸서 만들 수 있습니다: (folderName)

note

route group 이름은 URL 경로에 영향을 미치지 않기 때문에 group 목적으로만 사용됩니다.

Example: URL 구조에 영향 없이 route를 구성

해당 라우트를 구성하기 위해서 관련된 라우트와 함께 group을 만듭니다. 괄호안 폴더명은 URL에서 제외됩니다.(예. (marketing), (shop))

route-group-organisation

Example: Layout을 route segment에 따라 구분하여 구성

Layout을 route segment에 따라 구분하기 위해 route group으로 묶어서 구성할 수 있습니다.

예를 들어 /account 라우트와 /cart 라우트에는 동일한 layout으로 구성하고, /checkout 라우트에는 다른 layout으로 구성하고 싶을 경우 route group으로 구분하여 구성할 수 잇습니다.

Before:

route-group-opt-out-before

After:

route-group-opt-out-after

Example: 어플리케이션을 나누어 여러 root layout을 만들 수 있습니다.

여러 root layout 만들기 위해서 2개 이상의 route group을 app 폴더 최상단에 구성할 수 있습니다. 이것은 같은 어플리케이션 내에 완전히 다른 UI 또는 사용자 경험을 제공할 때 유용합니다. 각 root layout <html>, <body>, <head> 태그를 각각 커스터마이즈할 수 있습니다.

route-group-multiple-root

Server-Centric Routing

https://nextjs.org/blog/layouts-rfc#server-centric-routing

현재 Next.js는 client-side routing을 사용합니다. 일단 초기 로드와 그 이후 navigation 시에 새로운 페이지의 리소스에 대한 요청이 서버에서 이루어집니다. 이것은 모든 컴포넌트에 대한 JavaScript(특정 조건에서만 보이는 컴포넌트도 포함해서)와 그것의 props(getServerSideProps, getStaticProps에서의 JSON data도 마찬가지)를 포함합니다. 그리고 JavaScript와 데이터 모두 서버로부터 로드된 이후에 React는 컴포넌트를 client-side에서 렌더링합니다.

새로운 모델에서는, Next.js가 client-side transition을 유지하면서 server-centric routing을 사용합니다. 이는 서버에서 평가되는 Server Component와 일치합니다.

navigation할 때, 데이터가 fetch되고 React는 server-side에서 컴포넌트를 렌더링합니다. 클라이언트 React가 DOM을 업데이트 하도록 특별한 명령어(HTML도 아니고, JSON 형태도 아닌)로 서버 응답이 옵니다. 이 명령어는 렌더링된 Server Component의 결과물 입니다. 해당 컴포넌트를 렌더링하기 위해 JavaScript를 브라우저에 로드할 필요가 없다는 것을 의미합니다.

이는 컴포넌트 JavaScript를 client-side에서 렌더링하는 브라우저의 현재 기본값인 Client Component와 비교됩니다.

React Server Component를 사용하는 server-centric routing에는 몇 가지 이점이 있습니다:

  • Routing은 Server Component에 사용된 것과 동일한 요청을 사용합니다.(추가적인 서버 요청이 없습니다)
  • 라우트 navigating이 단지 변경된 segment만 fetch하고 렌더링하기 때문에 서버 작업이 줄어듭니다.
  • 새로운 클라이언트 컴포넌트를 사용하지 않을 경우 client-side navigating할 때 브라우저에 추가적인 JavaScript가 로드되지 않습니다.
  • 라우터는 모든 데이터가 로드되기 이전에 렌더링을 시작할 수 있기 때문에 새로운 스트리밍 프로토콜을 활용합니다.

사용자가 앱에서 navigate할 때, 라우터는 인메모리 client-side 캐시에 React Server Component payload의 결과를 저장합니다. 캐시는 라우트 segment에 의해 분할됩니다. 라우트 segment는 어떤 레벨에서든 무효화를 허용하고 concurrent 렌더링에서 일관성을 보장합니다. 이것은 특정 경우에 이전에 fetch된 segment의 캐시를 재사용할 수 있다는 것을 의미합니다.

note

  • 정적 생성(Static Generation)과 server-side 캐싱은 data fetching을 최적화하기 위해 사용될 수 있습니다.

  • 위 설명은 후속 navigation에 대한 것입니다. 초기 로드에는 HTML 생성하기 위해 Server Side 렌더링을 포함하는 다른 프로세스가 있습니다.

  • client-side 라우팅은 Next.js에서 잘 동작했지만 클라이언트가 route map을 다운로드 해야하기 때문에 잠재적인 라우트의 수가 많을 경우 제대로 확장되지 않습니다.

  • 전반적으로 React Server Component를 사용하면 브라우저에 더 적은 컴포넌트를 로드하고 렌더링하기 때문에 client-side navigation이 더 빨라집니다.

Instant Loading States

https://nextjs.org/blog/layouts-rfc#instant-loading-states

서버사이드 라우팅에서 navigation은 data fetching과 렌더링 이후에 발생합니다. 그래서 data fetching 동안에 loading UI를 보여주는 것은 중요합니다. 그렇게 하지 않는다면 어플리케이션이 멈춘 것처럼 보일 수 있습니다.

server-side-routing

그래서 새로운 router는 instant loading states와 default skeletons을 위해서 Suspense를 사용합니다. 이것은 loading UI가 새로운 segement의 content를 로드할 때 즉시 보여질 수 있다는 것을 의미합니다. 그런 다음 서버에서 렌더링이 완료되면 새로운 content로 교체됩니다.

렌더링이 진행되는 동안:

  • 새로운 route로 navigation은 즉시 이루어집니다.
  • 새로운 route segment가 로드되는 동안 shared layouts은 interactive로 유지됩니다.
  • navigation은 중단될 수 있습니다
  • 즉, 사용자가 route의 content가 로딩되는 동안 route를 이동할 수 있다는 것을 의미합니다.

Default loading skeletons

새로운 파일 convention인 loading.js를 통해 Suspense boundaries를 자동으로 처리합니다.

Example:

폴더 안에 loading.js 파일을 추가하고 default loading skeleton을 만들 수 있습니다.

loading

loading.js 에서는 React 컴포넌트를 export 해야합니다.

// loading.js
export default function Loading() {
  return <YourSkeleton />
}

// layout.js
export default function Layout({children}) {
  return (
    <>
      <Sidebar />
      {children}
    </>
  )
}

// 결과
<>
  <Sidebar />
  <Suspense fallback={<Loading />}>{children}</Suspense>
</>

이를 통해 폴더 내 모든 segment가 suspense boundary로 감싸집니다. default skeleton은 layout이 처음 로드될 때와 같은 레벨 page를 이동할 때 사용됩니다.

<Suspense> 컴포넌트를 굳이 따로 사용하지 않아도 되게끔 loading.js 컨벤션을 구성한게 인상적입니다. loading.js 컨벤션과 사용자가 직접 suspense boundary를 함께 쓰더라도 <Suspense> 사용 의도에 따르기 때문에 잘 동작할 것 같습니다.

Error Handling

Error Boundary는 자식 컴포넌트 트리에서 JavaScript 에러를 catch할 수 있는 React 컴포넌트입니다.

Convention

loading.js 컨벤션과 유사하게 React 컴포넌트를 default export하는 error.js 파일을 추가하여 error boundary를 구성할 수 있습니다.

error

자식 트리에서 오류가 발생하면 error.js 컴포넌트가 보여집니다. 이 컴포넌트는 에러를 logging하고 에러에 대한 유용한 정보를 보여주고 에러로부터 복구할 수 있는 방법을 제공하는데 사용될 수 있습니다.

segment와 layout의 중첩 구조에서는 Error boundary를 이용하여 UI에서 해당 에러를 격리할 수 있습니다. 에러가 발생한 동안에, boundary에 있는 layout은 interactive상태로 유지되며 상태 또한 보존됩니다.

// error.js
export default function Error({ error, reset }) {
  return (
    <>
      An error occurred: {error.message}
      <button onClick={() => reset()}>Try again</button>
    </>
  );
}

// layout.js
export default function Layout({children}) {
  return (
    <>
      <Sidebar />
      {children}
    </>
  )
}

// 결과
<>
  <Sidebar />
  <ErrorBoundary fallback={<Error />}>{children}</ErrorBoundary>
</>

note

error.js와 동일 segment에 있는 layout.js 에러는 잡을 수 없습니다. Error boundary는 layout 자체가 아니라 layout의 자식을 감싸기 때문입니다.

Templates

https://nextjs.org/blog/layouts-rfc#templates

Templates는 자식 Layout이나 Page를 감싼다는 점에서는 Layouts과 비슷합니다.

그러나 여러 라우트에서 지속되고 상태를 유지하는 Layouts과는 달리 Template는 각 자식에 대해 새로운 인스턴스를 만듭니다. 즉, 사용자가 Template을 공유하는 라우트 segment 사이를 이동할 때 해당 컴포넌트의 새로운 인스턴스가 마운트됩니다.

note

특정 이유가 아니라면 Template을 사용하기 보단 Layout 사용하기를 권장합니다.

Convention

React 컴포넌트를 default export하는 template.js 파일을 추가하여 Template을 구성할 수 있습니다. 중첩 segment에 채워질 children을 prop을 허용해야합니다.

Example

example

// template.js
export default function Template({ children }) {
  return <Container>{children}</Container>;
}

Layout과 Template이 함께 한 라우트 segment에서 렌더링된 결과는 다음과 같습니다:

<Layout>
  {/* template은 unique key가 제공됩니다. */}
  <Template key={routeParam}>{children}</Template>
</Layout>

Behavior

공유된 UI를 마운트, 언마운트해야 하는 경우에 Template이 더 적합한 옵션입니다.

예를 들어:

  • CSS나 라이브러리를 사용한 애니메이션을 enter/exit하는 경우
  • useEffect(예. 페이지 뷰 로깅)과 useState(예. 페이지별 피드백 form)에 의존하는 Feature
  • default 프레임워크 동작을 변경할 경우. 예를 들어 Layout 내에 suspense boundary를 페이지가 바뀔 때는 fallback을 보여주지 않고 최초 load인 경우에만 fallback을 보여주려 할 경우. template 경우, fallback이 각 navigation에 보여집니다.

예를 들어, 모든 sub-page에 bordered 컨테이너를 가진 중첩 Layout 디자인을 생각해보세요.

이 경우 부모 layout에 컨테이너를 추가할 수 있습니다(shop/layout.js):

// shop/layout.js
export default function Layout({ children }) {
  return <div className="container">{children}</div>;
}

// shop/page.js
export default function Page() {
  return <div>...</div>;
}

// shop/categories/layout.js
export default function CategoryLayout({ children }) {
  return <div>{children}</div>;
}

그러나 공유된 부모 layout은 리렌더링이 되지 않기 때문에 페이지가 바뀔 때 애니메이션 Enter/Exit 효과가 동작하지 않습니다.

다른 방법으로 모든 중첩 layout이나 page에 컨테니어를 놓을 수도 있습니다:

// shop/layout.js
export default function Layout({ children }) {
  return <div>{children}</div>;
}

// shop/page.js
export default function Page() {
  return <div className="container">...</div>;
}

// shop/categories/layout.js
export default function CategoryLayout({ children }) {
  return <div className="container">{children}</div>;
}

그러나 더 크고 복잡한 앱에서는 모든 곳에 수동으로 넣는 것이 번거로우며 에러가 발생하기 더 쉬워지기 때문에 복잡해집니다.

navigation할 때 새로운 인스턴스를 생성하는 여러 route에서 template을 공유할 수 있습니다. 즉, 상태가 유지되지 않고 effect가 다시 동기화되도록 DOM 엘리먼트를 재생성 합니다.

Advanced Routing Patterns

https://nextjs.org/blog/layouts-rfc#advanced-routing-patterns

edge 케이스를 다루고 더 다양한 라우팅 패턴을 구현할 수 있도록 컨벤션을 도입할 계획입니다. 아래는 적극적으로 고려하고 있는 몇 가지 예입니다.

Intercepting Routes

다른 라우트안에서 라우트 segement를 intercept하는 것이 유용할 경우가 있습니다. 라우트 이동 시에 URL은 업데이트 되지만 intercept된 segment는 현재 라우트 layout 내에 보여지게 됩니다.

Example

Before: 이미지를 클릭하면 자신만의 layout을 가진 새로운 route로 이동합니다.

intercepted-routes-before

After: 이미지를 클릭하면 현재 라우트 layout 내에 보여집니다.(예. 이미지가 Modal 보여지는 방식)

트위터에서 이미지를 클릭하면 새로운 화면으로 이동하지 않고 현재 라우트 layout 내에 보여지는 경우도 이와 동일합니다.

intercepted-routes-after

이를 구현하기 위해 예를 들어 /[username] segment에서 /photo/[id] 라우트를 intercept하기 위해서 /[username] 폴더 안에 (..) prefix를 가진 /photo/[id] 폴더를 만듭니다.

intercepted-routes

Convention

컨벤션이 흥미롭네요. 고민의 흔적이 보이는 것 같네요.

  • (..) - 한 단계 높은(부모 폴더의 sibling) 라우트 segment를 매칭합니다. 상대 경로인 ../와 흡사합니다.
  • (..)(..) - 두 단계 높은 라우트 segment를 매칭합니다. 상대 경로인 ../../와 흡사합니다.
  • (...) - root 폴더의 라우트 segment를 매칭합니다.

note

페이지를 새로고침하거나 공유하면 default layout가 라우트에 로드됩니다.

Dynamic Parallel Routes

독립적으로 navigate되는 동일한 view에 2개 이상의 leaf segment(page.js)를 보여줄 때 유용합니다.

예를 들어 동일한 대시보드에 2개 이상의 tab group이 있을 경우 한 탭 그룹을 navigate하면 다른 그룹에 영향을 주지 않아야 합니다. 앞뒤로 navigate할 때에도 올바르게 복원되어야 합니다.

parallel-routes

Convention

기본적으로 layout은 중첩 layout이나 page를 children prop로 가집니다. prop을 이름을 가진 "slot"(@ prefix를 가진 폴더)을 만들고 내부에 segment를 구성하여 prop을 rename할 수 있습니다.

parallel-routes-children

이렇게 바꾼 후에 layout은 children 대신에 customProp을 prop으로 받게 됩니다.

// analytics/layout.js
export default function Layout({ customProp }) {
  return <>{customProp}</>;
}

이런식으로 같은 레벨에 하나 이상의 named slot을 추가하여 parallel route를 만들 수 있습니다. 예를 들어 아래그림처럼 구성하면 @view@audienceanalytics layout의 prop으로 전달됩니다.

parallel-routes-complete

아래 처럼 동시에 leaf segment를 보여줄 수 있습니다.

// analytics/layout.js
export default function Layout({ views, audience }) {
  return (
    <>
      <div>
        <ViewsNav />
        {views}
      </div>
      <div>
        <AudienceNav />
        {audience}
      </div>
    </>
  );
}

사용자가 처음에 /analytics로 접근하면 각 폴더(@view, @audience)의 page.js segment를 보여줍니다. /analytics/subscribers로 이동하면 @audience 만 업데이트 됩니다. 마찬가지로 /analytics/impressions로 이동하면 @view만 업데이트 됩니다.

앞뒤로 이동하더라도 parallel route는 올바르게 복원됩니다.

Combining Intercepting and Parallel Routes

intercepting route와 parallel route를 결합하여 특정한 라우팅 동작을 만들어낼 수 있습니다.

Example

예를 들어, 모달을 만들 때 다음과 같은 몇 가지 문제가 있을 수 있습니다:

  • URL을 통해서 접근할 수 없는 모달
  • 페이지가 새로고침될 때 닫혀버리는 모달
  • 모달을 열기전의 라우트가 아닌 이전 라우트로 뒤로 가지는 경우
  • 앞으로 가기를 눌렀을 때 모달이 다시 열리지 않는 경우

모달이 열렸을 때 URL이 업데이트하고 모달을 열고 닫기 위해 뒤로/앞으로 이동을 하기를 원할 수 있습니다. 또한 URL을 공유할 때 모달이 열려 있고 그 뒤에 context가 있는 페이지를 로드하기를 원할 수도 있습니다. 또는 URL을 공유할 때 모달 없이 content가 있는 페이지를 로드하기를 원할 수도 있습니다.

이에 해당하는 좋은 예시는 소셜 미디어 사이트에서 보여지는 사진들입니다. 보통, 사진은 사용자의 피드나 프로필에서 모달 내에서 접근됩니다. 그러나 사진을 공유하게 되면 사진만 있는 페이지가 바로 표시됩니다.

컨벤션을 사용하여 default로 라우트에 모달로 동작할 수 있도록 매핑할 수 있습니다.

다음 폴더 구조를 확인하세요:

intercepted-route-modal

  • /photo/[id]의 내용은 자신의 context 내 URL을 통해 접근가능합니다. 또한 /[username] 라우트 내 모달 안에서 접근가능합니다.
  • client-side navigation을 사용하여 앞뒤로 이동하려면 모달을 닫았다가 다시 열어야 합니다.
  • 페이지를 새로고침하면(server-side navigation) 사용자가 모달을 보여주는 대신에 원래 라우트인 /photo/id로 가야 합니다.

/@modal/(..)photo/[id]/page.js에서 모달 컴포넌트로 감싼 page를 반환할 수 있습니다.

// /@modal/(..)photo/[id]/page.js
export default function PhotoPage() {
  const router = useRouter();

  return (
    <Modal
      // 모달은 페이지 로드시 항상 보여져야 합니다.
      isOpen={true}
      // 모달을 닫으면 이전 페이지로 돌아가야 합니다.
      onClose={() => router.back()}
    >
      {/* Page Content */}
    </Modal>
  );
}

이런 케이스는 그렇게 복잡한 레이아웃은 아니지만 제가 느끼기에 컨벤션 조합이 좀 많이 복잡하긴 합니다...

note

이 솔루션이 Next.js에서 모달을 만드는 유일한 방법은 아닙니다만 좀 더 복잡한 라우팅 동작을 확인하기 위해 컨벤션을 조합하는 방법을 보여주는 것을 목표로 합니다.

Conditional Routes

route를 결정하기 위해 data나 context처럼 동적인 정보가 필요할 경우가 있습니다. 이런 경우 parallel route를 사용하여 조건부로 라우트를 로드하는 것으로 해결할 수 있습니다.

Example

// profile/layout.js
export async function getServerSideProps({ params }) {
  const { accountType } = await fetchAccount(params.slug);
  return { props: { isUser: accountType === 'user' } };
}

export default function UserOrTeamLayout({ isUser, user, team }) {
  return <>{isUser ? user : team}</>;
}

@user, @team 2개의 parallel route를 만들고 slug가 user인 경우 @user로, team인 경우 @team segment를 보여줄 수 있습니다.

├-- profile
│   ├─- layout.js
│   ├── @user
│   │   ├── page.js
│   │   └── ...
│   ├── @team
│   │   ├── page.js
│   └── ...

마무리하며

Next.js의 새로운 Layouts, React 18에 대해서 살펴보았습니다. 현재 개발 진행 중이라고 하는데 개인적으로도 큰 기대가 됩니다.

최근에 Remix의 nested layout, route를 경험한게 조금은 이해할 때 도움이 된 것 같습니다. 유사한 부분과 차이점에 대해서도 생각해 볼 기회가 되었습니다.

다양한 프레임워크룰 살펴보면 이해가 되지 않던 부분이 다른 프레임워크를 통해서 이해가 되는 경우가 있는 것 같습니다. 배우는데 시간이 걸리는 것이 사실이지만, 이런 경험을 통해서 기존에 알고 있던 것들을 좀 더 다양한 관점에서 이해할 수 있는 부분도 좋은 것 같습니다.

그래서 이번 기회에 책을 몇 권 사서 읽어보려고 합니다. CS쪽도 괜찮고 다양한 분야의 책을 읽으면 좋을 것 같다는 생각이 듭니다.(어휘력이 날이갈수록 떨어지는 것 같아서...)

마지막 업데이트

9/13/2022


Avatar

JHSeo

배우는 것을 좋아하고 관심이 많은 웹 엔지니어 입니다. 느리더라도 꾸준하게 성장하려고 노력하는 개발자입니다.