[Next.js V14] 정리 노트 - 5

라우팅과 페이지 서버/클라이언트 컴포넌트 깊게 알아보기

시작

라우팅

파일 기반 라우팅만을 해왔다면 이번에는 병렬 라우팅을 알아봅시다.

병렬 라우팅

병렬 라우트는 두 개의 콘텐츠를 동일 페이지에서 렌더링 하는 기능입니다. 병렬 라우트 갯수는 제한이 없으며 아래와 같은 방식으로 폴더 구조를 가집니다. @폴더명이 병렬 라우트입니다.

text
  app
    └ archive
      └ @archive 
        └ page.js
      └ @latest
        └ page.js
      └ layout.js

동일 라우트 폴더내 layout.js에서 @archive, @latest는 다음과 같이 동시에 렌더링 됩니다.

js
// app/archive/layout.js

export default function ArchiveLayout({ archive, latest }) {
  // 레이아웃 컴포넌트는 children을 기본적으로 인자로 받습니다.
  // 병렬 라우트 폴더의 레이아웃 컴포넌트 경우 @폴더명 에서 각 폴더명이 children 프로퍼티로 등록됩니다.

  return (
    <div>
      <h1>News Archive</h1>
      <section id='archive-filter'>{archive}</section>
      <section id='archive-latest'>{latest}</section>
    </div>
  )
}

다음과 같이 말이죠.

parallel-routing-exam

default.js

default.js 파일명은 라우팅된 경로에 구체적인 컨텐츠가 없는 경우 (not-found 등) 기본으로 보여주는 페이지 입니다. page.js와 역할이 겹친다면 default.js를 남겨두고 제거해도 무방합니다.

뉴스 페이지를 보면 본문이 있고 최하단에는 최신 뉴스가 고정적으로 표시된 경우가 많습니다. 이 경우 병렬 라우트에 적합하며 ArchiveLayout에서 또한 두 콘텐츠를 렌더링 하고 있으므로 /archive/2024 등으로 접속해도

latest섹션은 사용자가 접속한 링크에 구애받지 않고 최신 뉴스만 보여주면 됩니다. default.js 페이지를 추가하여 이와 같은 사항을 적용할 수 있습니다.

js
// app/archive/@latest/default.js

import NewsList from "@/components/news-list";
import { getLatestNews } from "@/lib/news";

export default function LatestNewsPage() {
  const latestNews = getLatestNews();

  return (
    <>
      <h2>Latest News</h2>
      <NewsList news={latestNews} />
    </>
  )
}

아래는 server_url/archive(병렬 라우트 시작점), server_url/archive/2024(2024라는 값의 동적인 경로 접속) 에 접속한 결과입니다. 최신 뉴스는 변함이 없죠.

default-page-exam

default-page-exam2

Catch-All 라우트 구성

이번엔 News Archive에서 특정 연도를 선택해 특정 연도의 글을 조회할 때 다른 연도를 네비게이션으로 표시하고 월별로도 상세 조회할 수 있도록 수정해봅시다. 파일 구조로 생각했을 때, @archive폴더 하위에 layout.js를 추가하면 되겠지만 Next.js 의 Catch-All를 이용해 구현해봅시다.

text
  app
    └ archive
      └ @archive 
        └ [year]
          └ page.js
        └ layout.js
        └ page.js
      └ @latest
        └ defeault.js
      └ layout.js

@archive 폴더 하위 [year] 폴더 이름을 Catch-All방식을 적용하기 위해 약속된 명칭 [[...동적경로값]]으로 수정합니다.

text
  app
    └ archive
      └ @archive 
        └ [[...filter]]
          └ page.js
        └ layout.js
        └ page.js
      └ @latest
        └ defeault.js
      └ layout.js

[[...filter]] 폴더의 경우 내부 경로 page.js 파일이 archive/ 이후 모든 경로에 대해 활성화되도록 보장합니다.

@archive/[[...filter]]하위 파일page.js가 말이죠. archive/의 경우 인자 0개 /archive/2024에 방문한다면 인자 하나를 캐치. /archive/2024/3인 경우 인자 2개를 캐치합니다.

단 이와 같이 적용한 경우 archive/ 경로로 접속했을 때, @archive/page.js가 화면에 보여져야 하는데, @archive/[[..filter]]/page.js 또한 렌더링하려고 하기 때문에 충돌이 발생합니다.

@archive/page.js는 제거하고 내부 내용은 @archive/[[..filter]]/page.js로 옮겨야 합니다.

js
// app/archive/@archive/[[..filter]]/page.js

import NewsList from "@/components/news-list";
import { getAvailableNewsYears, getNewsForYear } from "@/lib/news";
import Link from "next/link";

export default function FilteredNewsPage({ params }) {

  const filter = params.filter;

  // archive/ 로 접속하면 undefined
  // archive/2024 로 접속하면 ['2024']
  console.log(filter)

  const links = getAvailableNewsYears();

  return (
    // @archive/page.js 내용 포함
    <header id="archive-header">
      <nav>
        <ul>
          {links.map((link) => (
            <li key={link}>
              <Link href={`/archive/${link}`}>{link}</Link>
            </li>
          ))}
        </ul>
      </nav>
    </header>
  )
}

최종 완성 코드는 다음과 같습니다.

js
// app/archive/@archive/[[..filter]]/page.js

import NewsList from "@/components/news-list";
import { getAvailableNewsMonths, getAvailableNewsYears, getNewsForYear, getNewsForYearAndMonth } from "@/lib/news";
import Link from "next/link";

export default function FilteredNewsPage({ params }) {

  const filter = params.filter;
  
  const selectedYear = filter?.[0];
  const selectedMonth = filter?.[1];

  let news;
  let links = getAvailableNewsYears();

  if (selectedYear && !selectedMonth) {
    news = getNewsForYear(selectedYear);
    // 해당 연도로 뉴스가 개시된 월을 가져옵니다.
    links = getAvailableNewsMonths(selectedYear);
  }

  // /archive/2021/7 인 경우
  if(selectedYear && selectedMonth) {
    news = getNewsForYearAndMonth(selectedYear, selectedMonth)
    links = [];
  }

  let newsContent = <p> No news found for the selected period. </p>

  if(news && news.length > 0) {
    newsContent = <NewsList news={news} />
  }

  // + : number 타입으로 캐스팅 
  if(
    (selectedYear && !getAvailableNewsYears().includes(+selectedYear)) || 
    (selectedMonth && !getAvailableNewsMonths(selectedYear).includes(+selectedMonth))
  ) {
    // /archive/2021/~ or /archive/2021/6 이 아닌 엉뚱한 경로로 요청된 경우
    throw new Error('Invalid filter.')
  }

  return (
    // @archive/page.js 내용 포함
    <>
      <header id="archive-header">
        <nav>
          <ul>
            {links.map((link) => {
              const href = selectedYear 
              ? `/archive/${selectedYear}/${link}` 
              : `/archive/${link}`

              return (
                <li key={link}>
                  <Link href={href}>{link}</Link>
                </li>
              )
            })}
          </ul>
        </nav>
      </header>
      {newsContent}
    </>
  )
}

error.js

throw new Error부분을 보완해 실제로 잘못된 경로를 요청한 것이니 에러페이지로 이동되도록합니다. 단 이 경우 동적인 경로를 요청하는 클라이언트 사이드에서 에러를 발생시킨 상황이므로 use client로 명시해주어야 합니다.

js
// app/archive/@archive/[[..filter]]/error.js
"use client"

export default function FilterError() {
  return (
    <div id="error">
      <h2>An error occurred!</h2>
      <p>Invalid path.</p>
    </div>
  )
}

인터셉팅 라우팅

인터셉팅 라우팅의 개념을 보기전 동적 라우트 경로 안에서 중첩된 라우트가 필요한 경우를 봅시다.

동적 라우트내 중첩 라우트

news[slug]에서 뉴스 항목의 이미지를 클릭가능하게 만들고 별도 페이지에 전체 이미지화면을 볼 수 있는 기능을 구현해봅시다.

폴더 구조는 다음과 같습니다.

text
  app
    └ news
      └ [slug] 
        └ image
          └ page.js
        └ not-found.js
        └ page.js
      └ page.js

이번엔 [slug] 동적 라우트 안에 image라는 경로가 추가되었습니다.

js
// app/news/[slug]/image/page.js
import { DUMMY_NEWS } from "@/dummy-news";
import { notFound } from "next/navigation";

export default function ImeagePage({ params }) {
  
  const newsItemSlug = params.slug;
  const newsItem = DUMMY_NEWS.find(newsItem => newsItem.slug === newsSlug)

  if(!newsItem) {
    notFound();
  }

  return (
    <div className="fullscreen-image">
      <img src={`/images/news/${newsItem.image}`} alt={newsItem.title} />
    </div>
  )
}

아래와 같이 동적 라우트 페이지에서 내부 중첩된 라우트 페이지로 이동할 수 있습니다.

js
// app/news/[slug]/page.js
import { DUMMY_NEWS } from "@/dummy-news";
import Link from "next/link";
import { notFound } from "next/navigation";

export default function NewsDetailPage({
  params
}) {
  return (
    // ...
      <Link href={`/news/${newsItem.slug}/image`}>
        <img src={`/images/news/${newsItem.image}`} alt={newsItem.title} />
      </Link>
    // ...
  )
}

인터셉팅 라우팅

페이지 내부 링크 탐색에 따라 활성화되며 SP모드로 외부 링크를 통해 접근, URL을 직접 입력하는지, 외부 링크를 직접 입력하는지에 따라 표시되는 페이지가 달라집니다.

이를 인터셉터 라우팅을 이용해 구현합니다.

기본적으로 내부 네비게이션 요청을 가로채는데, 페이지를 새로 고침 하거나 웹사이트 외부에서 들어올 때, 다른 페이지가 접속되죠.

라우트 폴더명은 ()가로챌 라우트명으로 지정합니다. 아래와 같은 파일 트리를 가집니다.

text
  app
    └ news
      └ [slug] 
        └ (.)image
          └ ...
        └ image
          └ ...
        └ page.js

()안에도 가로챌 라우트의 위치를 상정하는데 다음과 같이 명시합니다.

  • (.)동일한 수준 의 세그먼트
  • (..)한 수준 위의 세그먼트
  • (..)(..)두 수준 위의 세그먼트
  • (...)루트 app 디렉터리 의 세그먼트
js
// app/news/[slug]/(.image)/page.js
import { DUMMY_NEWS } from "@/dummy-news";
import { notFound } from "next/navigation";

export default function InterceptedImeagePage({ params }) {
  
  const newsItemSlug = params.slug;
  const newsItem = DUMMY_NEWS.find(newsItem => newsItem.slug === newsItemSlug)

  if(!newsItem) {
    notFound();
  }

  return (
    <>
      <h2>Intercepted!</h2>
      <div className="fullscreen-image">
        <img src={`/images/news/${newsItem.image}`} alt={newsItem.title} />
      </div>
    </>
  )
}

이제 접속 경로를 news탭 -> 뉴스 아티클 -> 이미지 확대로 하게 되면 아래와 같이 인터셉트된 라우트 경로의 페이지로 렌더링됩니다.

intercept-route

단, 새로고침을 하거나 URL을 직접 입력하면 image/page.js페이지를 바라보게 됩니다.

intercept-route-refresh

병렬/인터셉팅 라우팅 결합

이제 좀 더 코드를 수정해서 병렬 라우트로 두 개의 콘텐츠가 렌더링되도록 수정해봅시다.

text
  app
    └ news
      └ [slug] 
        └ @modal
        └ @details
        └ (.)image
          └ ...
        └ image
          └ ...
        └ page.js
        └ layout.js

위 예제에서 app/news/page.js가 @details 내용을 포함하고 있기 때문에 @details 폴더는 굳이 만들지 않아도 됩니다. layout.js에서는 그대로 children을 이용하면 됩니다.

렌더링 되는 두 콘텐츠 중 @modal콘텐츠의 경우 인터셉트 라우팅을 설정할 것이기 때문에 아래와 같이 layout.js를 작성합니다.

js
// app/news/layout.js
export default function NewsDetailLayout({ children, modal }) {
  return (
    <>
      {modal}
      {children}
    </>
  )
}

또한 병렬 라우트 @modal, @artcle은 next.js 에서 폴더 정리와 같이 적용되지 실제 브라우저에서 경로로 취급되지는 않습니다. 때문에 아래와 같은 폴더 트리를 가집니다.

@modal/(.image)/page.js@modal과 같은 경로 폴더인 imagepage.js를 가로채게 됩니다.

text
  app
    └ news
      └ [slug] 
        └ @modal
          └ (.)image
            └ page.js
          └ default.js
        └ @details
          └ ...
        └ image
          └ page.js
        └ page.js
        └ layout.js

인터셉터 라우트 [slug]/(.)image 를 그대로 @modal로 옮겨 페이지를 올바른 경로로 접속하다 보면 다음과 같이 렌더링되는것을 볼 수 있습니다.

parallel-news-layout

백그라운드 선택시 이전 페이지로 돌아가는 기능을 추가해봅시다. 최종 완성 코드는 다음과 같습니다.

js
// app/news/[slug]/(.image)/page.js
"use client"

import { DUMMY_NEWS } from "@/dummy-news";
import { notFound } from "next/navigation";
import { useRouter } from "next/navigation";

export default function InterceptedImeagePage({ params }) {
  const router = useRouter();

  const newsItemSlug = params.slug;
  const newsItem = DUMMY_NEWS.find(newsItem => newsItem.slug === newsItemSlug)

  if(!newsItem) {
    notFound();
  }

  return (
    <>
    {/* 백그라운드 클릭시 이전 경로로 이동 */}
    <div className="modal-backdrop" onClick={router.back}/>
    <dialog className="modal" open>
      <div className="fullscreen-image">
        <img src={`/images/news/${newsItem.image}`} alt={newsItem.title} />
      </div>
    </dialog>
    </>
  )
}

라우트 그룹

랜딩 페이지에서는 네비게이션을 제외하고 페이지 소개만 넣도록 개선해봅시다.

목적이 같은 라우트의 경우 그룹 라우트로 지정하는것이 좋습니다. 파일 트리는 다음과 같이 재구성합니다.

text
  app
    └ (content)
      └ archive
      └ news
      └ layout.js
      └ not-found.js
    └ (marketing)
      └ layout.js
      └ page.js

라우트 그룹 또한 일종의 폴더 정리 구조로 가져가고 실제 브라우저에서 경로로 지정되지는 않습니다. 때문에 랜딩페이지가 잘 조회됩니다.

group-route

라우트 핸들러로 API 구현하기

라우드 핸들러는 다양한 함수를 내보내는 파일입니다. GET, PUT, POST, DELETE 등 HTTP 메서드 동작을 가진 함수들을 지원하며 특정 라우트로 진입했을때, 이런 API를 클라이언트단에서 페이지 진입 후 호출합니다.

파일명은 route.js로 생성해야합니다.

js
// app/api/route.js
export function GET(request) {
  console.log(request);

  // Node에서 기본 제공
  // return Response.json()
  return new Response('Hello!');
}

// export function POST(request) {

// }

실제로 /api로 접속해보면 아래와 같은 문자열을 가져올 수 있습니다.

route-handler

미들웨어 사용하기

프로젝트 루트 폴더에서 middleware.js파일명으로 파일을 생성합니다. 이 파일은 미들웨어로서 작동하게 됩니다. 모든 요청에 대한 진입점이 미들웨어가 되고 원래 요청지로 넘어가는 방식이죠.

이 과정에서 사용자 인증 등 별도의 로직을 구성할 수 있습니다.

만일 /news로만 진입한 경우만 미들웨어를 거쳐가게 하려면 다음과 같이 설정합니다.

js
// middleware.js
import { NextResponse } from "next/server";

export function middleware(request) {
  
  console.log(request);
  // 원래 요청 대상으로 전달
  return NextResponse.next();
}

export const config = {
  matcher: '/news'
}

렌더링

서버 사이드 렌더링, 클라이언트 사이드 렌더링 두 가지를 정확히 어느 상황에 적용할지 더 깊게 살펴보겠습니다. 기본적으로 동작 환경은 다음과 같습니다.

component-rendering

기본적으로 서버 컴포넌트이며 클라이언트 컴포넌트를 작성하는 경우 use client로 명시합니다.

주로 아래와 같이 사용자의 선택에 따른 조건부 CSS를 적용할때 리액트 훅을 아용하기 때문에 클라이언트 컴포넌트를 사용하는 경우가 많습니다.

client-component-exam

이 경우 클라이언트 컴포넌트는 최대한 작게 가져가야 합니다. 때문에 /component 하위 파일로 관리하고 export 설정을 한 뒤 /app내부에서 가져다 사용하죠.

js
// component/nav-link.js
"use client"

import Link from "next/link";
import { usePathname } from "next/navigation";

export default function NavLink({ href, children }) {

  // 현재 활성 URL
  const path = usePathname();

  return (
    <Link
    href={href}
    className={path.startsWith(href) ? 'active' : undefined}
    >
      {children}
    </Link>
  )
}
js
// component/main-header.js
import Link from 'next/link';
import NavLink from './nav-link';

export default function MainHeader() {

  return (
    <header id="main-header">
      <div id="logo">
        <Link href="/">NextNews</Link>
      </div>
      <nav>
        <ul>
          <li>
            <NavLink href="/news">News</NavLink>
          </li>
          <li>
            <NavLink href="/archive">Archive</NavLink>
          </li>
        </ul>
      </nav>
    </header>
  );
}