Next.js 14 강좌 2편. 레이아웃의 모든 것 그리고 Link 컴포넌트

안녕하세요?

Next.js 14 강좌 두 번째입니다.

오늘은 전체적인 HTML의 구조를 짤 수 있는 Layout과 페이지 간 이동에 쓰이는 Link 컴포넌트에 대해 알아보겠습니다.

전체 강좌 리스트입니다.

전체 강좌 리스트입니다.

  1. Next.js 14 강좌 1편. 라우팅의 모든 것

  2. Next.js 14 강좌 2편. 레이아웃의 모든 것 and Link 컴포넌트

  3. Next.js 14 강좌 3편. Template과 Loading 스페셜 파일

  4. Next.js 14 강좌 4편. 에러(Error) 처리의 모든 것

  5. Next.js 14 강좌 5편. 병렬 라우팅(Parallel Routes), 일치하지 않는 라우팅(Unmatched Routes), 조건부 라우팅(Conditional Routes) 알아보기

  6. Next.js 14 강좌 6편. 인터셉팅 라우팅(Intercepting Routes)과 병렬 인터셉팅 라우팅(Parallel Intercepting Routes) 살펴보기

  7. Next.js 14 강좌 7편. 라우트 핸들러의 기본(GET, POST, PATCH, DELETE)과 동적 라우트 핸들러 알아보기

  8. Next.js 14 강좌 8편, 라우트 핸들러에서 URL 쿼리 파라미터와 redirect, Headers, Cookies 그리고 캐싱 방식 알아보기

  9. Next.js 14 강좌 9편. 미들웨어(middleware) 설정 방법과 미들웨어에서의 rewrite, cookies, headers 처리 방법

  10. Next.js 14 강좌 10편. CSR부터 SSR, RSC까지 React의 렌더링의 역사 살펴보기

  11. Next.js 14 강좌 11편. 렌더링 라이프사이클(Rendering Lifecycle)과 서버 렌더링 전략 세가지(정적 렌더링, 다이내믹 렌더링, 스트리밍)

  12. Next.js 14 강좌 12편. 서버 컴포넌트 패턴 - 서버 전용 코드(server-only), 써드 파티 패키지, 컨텍스트 프로바이더(Context Provider) 활용하기

  13. Next.js 14 강좌 13편. 클라이언트 컴포넌트 패턴 - 클라이언트 전용 코드(client-only), 컴포넌트 배치, 서버-클라이언트 컴포넌트 섞어 활용하기


** 목 차 **


1. Layout이란

레이아웃을 공부하기 전에 먼저, 페이지가 먼지 정의해야 하는데요.

페이지는 라우팅에 대응하는 유니크한 UI입니다.

그러면, 레이아웃은 뭘까요?

바로 앱상에서 여러 페이지 간 공유되는 UI입니다.

가장 일반적인 레이아웃의 구성은 바로, Header, Footer 구성입니다.

이 Header, Footer는 앱상의 모든 페이지에 들어가야 하는 거죠.

그걸 레이아웃 페이지에 넣으면 매번 따로 넣어야 하는 번거로움이 없어집니다.

Next.js에서는 layout.js(tsx) 등 layout 이름으로 된 파일이 있으면 그게 레이아웃 파일입니다.

레이아웃은 무조건 children을 prop으로 가져야 합니다.

왜냐하면 자식 컴포넌트가 레이아웃의 children 자리에 위치하기 때문이죠.

Next.js에서는 최소 한 개의 레이아웃이 있어야 하는데요.

그래서 강제로 만들어지는 layout.tsx 파일이 있습니다.

바로 app 폴더 바로 밑에 있는 layout.tsx 파일이 그것입니다.

이 파일은 지워도 다시 생기는 무조건 있어야 하는 layout 파일입니다.

그래서 이 레이아웃을 RootLayout이라고 하죠.

그러면 Header, Footer를 넣어 볼까요?

아래 파일은 Next.js가 자동으로 만든 루트 레이아웃 파일입니다.

export const metadata = {
  title: 'Next.js',
  description: 'Generated by Next.js',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

이 파일에 Header와 Footer 부분을 넣어 보겠습니다.

import "./global.css";
export const metadata = {
  title: "Next.js",
  description: "Generated by Next.js",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <header className="bg-yellow-400 p-2 my-2">Header</header>
        <main className="flex p-4">{children}</main>
        <footer className="bg-cyan-400 p-2 my-2">Footer</footer>
      </body>
    </html>
  );
}

TailwindCSS를 이용해서 좀 더 멋지게 꾸몄습니다.

위와 같이 아주 잘 나오는데요.

이게 레이아웃이기 때문에 about 페이지나 profile 페이지에도 똑같이 나와야 합니다.

레이아웃이 아주 잘 작동하네요.

그래서 보통 헤더 부분에서 공통된 링크 메뉴를 위치시켜서 페이지 간 이동을 쉽게 할 수 있게 내비게이션 메뉴를 많이 만듭니다.


2. Nested Layouts(중첩 레이아웃)

중첩 라우팅과 비슷하게 중첩 레이아웃도 특정 라우팅 밑에 layout.tsx 파일을 작성하면 해당 라우팅에만 적용되는 레이아웃을 만들 수 있습니다.

기존에 만들었던 '[productId]' 라는 다이내믹 라우팅 바로 아래 layout.tsx 파일을 아래와 같이 만들어 볼까요?

export default function ProductDetailLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex flex-col">
      <h1>Layout of productId</h1>
      {children}
    </div>
  );
}

위와 같이 '[productId]'의 레이아웃을 지정하면 해당 다이내믹 라우팅에 모두 적용됩니다.

위 그림과 같이 중첩 레이아웃 모드는 전체 앱의 복잡한 레이아웃 구성에 많은 도움이 됩니다.


3. Route Group Layout (라우팅 그룹에 레이아웃 적용하기)

기존 시간에 'auth'라는 라우팅 그룹을 '(auth)' 폴더를 만들어서 그 밑에 register, login, fogot-password 라우팅을 만들었는데요.

이 세 개의 라우팅에 적용하는 레이아웃은 '(auth)' 폴더 밑에 layout.tsx 파일을 만들면 됩니다.

그러면, register, login 라우팅에만 적용되고, forgot-password 라우팅에는 적용되지 않는 레이아웃을 만들어볼까요?

방법은 '(auth)' 폴더 밑에 다사 괄호로 시작하는 그룹 라우팅을 하나 더 만들면 됩니다.

'(auth)/(with-layout)' 폴더를 만들고 '(with-layout)' 폴더 밑에다가 layout.tsx 파일을 만들면 됩니다.

그리고 login, register 폴더를 '(with-layout)' 폴더 밑으로 이동시키면 되죠.

export default function WithLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex flex-col">
      <h2>With Layout</h2>
      {children}
    </div>
  );
}

이제 register 라우팅과 forgot-password 라우팅 두 개를 비교해 보겠습니다.

위 그림과 같이 register 라우팅에만 새로운 레이아웃이 적용되는 걸 볼 수 있습니다.

폴더 구조를 tree 명령어로 보여드리면 아래와 같습니다.

➜  (auth) git:(main) ✗ tree -L 3
.
├── (with-layout)
│   ├── layout.tsx
│   ├── login
│   │   └── page.tsx
│   └── register
│       └── page.tsx
└── forgot-password
    └── page.tsx

5 directories, 4 files

'(auth)' 폴더 밑에서 3단계 폴더 트리를 보여주는 건데요.

layout.tsx 파일이 어느 위치에 적용되는지 쉽게 이해할 수 있을 겁니다.


4. Metadata

웹 페이지에 있어 메타데이터가 아주 중요한데요.

SEO(Search Engine Optimization)가 중요한 이유는 블로그를 운영해 보면 그 중요도가 체감될 건데요.

Next.js에서도 페이지별로 메타데이터를 관리해 줍니다.

메타데이터는 layout.tsx 파일이나 page.tsx 파일 어느 곳에서도 설정할 수 있는데요.

Next.js에서는 metadata 라는 객체를 export 하면 됩니다.

근데, 메타데이터 관련 가장 기본적인 룰이 있습니다.

탑 다운 방식의 순서대로 메타데이터가 읽힙니다.

그래서 layout.tsx 파일에 정의된 메타데이터 보다, page.tsx 파일에 정의된 메타데이터가 최종적으로 살아남는 방식입니다.

단 layout.tsx 파일에는 있고, page.tsx 파일에는 없는 항목일 경우 당연히 그 항목은 끝까지 살아남게 되죠.

루트레이아웃 파일에 보시면 아래와 같이 Next.js가 자동으로 만들어준 메타데이터가 있는데요.

export const metadata = {
  title: "Next.js",
  description: "Generated by Next.js",
};

이 루트 레이아웃에 정의되면 모든 페이지에 다 적용되기 때문에 각 페이지별 메타데이터를 설정할 필요가 없습니다.

그러나, 페이지별 상세 메타데이터를 관리하는 게 SEO에 매우 좋기 때문에 'about' 폴더의 page.tsx 파일에 재정의 해보겠습니다.

export const metadata = {
  title: "About Page",
};

export default function About() {
  return <h1>About</h1>;
}

위와 같이 title 항목만 재정의했습니다.

그러면 description 항목은 루트 레아웃에 정의된 그대로 남아 있게 됩니다.

위 그림과 같이 title 부분만 'About Page'로 바뀐 게 보이실 겁니다.


4.1. Dynamic Metadata(다이내믹 메타데이터)

다이내믹 라우팅이 있듯이 메타데이터도 다이내믹 메타데이터가 있는데요.

다이내믹 라우팅에서 'params'를 이용해서 유용한 정보를 표시했었는데요.

다이내믹 메타데이터에서도 똑같이 'params'를 이용해서 유용한 메타데이터를 표시하면 됩니다.

다이내믹 메타데이터를 만드는 방식은 기존처럼 metadata 객체를 export 하면 되는 게 아니라 Next.js에서 제공해 주는 generateMetadata 함수를 이용해야 합니다.

generateMetadata 함수를 쓸 경우 기존처럼 metadata 객체를 export 하는 방식은 사용 못 합니다.

두 개의 경우가 겹치면 안 되는 거죠.

이제 '[productId]' 부분의 메타데이터를 다이내믹하게 만들어 보겠습니다.

import { Metadata } from "next";

type Props = {
  params: { productId: string };
  searchParams?: { country: string };
};

export const generateMetadata = ({ params }: Props): Metadata => {
  return {
    title: `Product ${params.productId}`,
  };
};

export default function ProductDetails({ params, searchParams }: Props) {
  return (
    <h1>
      Product {params.productId} / {searchParams?.country} Details
    </h1>
  );
}

먼저, 위 코드와 같이 Metadata라는 타입을 import 합니다.

그리고 Props 타입을 정의해 주면 코드가 훨씬 깔끔하고 보기 좋아지는데요.

searchParams는 옵셔널로 지정했습니다.

그리고 generateMetadata 함수를 위와 같이 만들고, 객체를 리턴하면 됩니다.

위 그림과 같이 title 항목만 다이내믹하게 생성되었습니다.

참고로, description은 루트레이아웃의 것을 그대로 물려받은 상태입니다.

여기서 우리가 다이내믹하게 정보를 얻어오는 곳은 params 라는 동적 변수인데요.

웹 앱은 다른 사이트나 DB에서도 정보를 가져올 수 있습니다.

보통 DB에서 정보를 가져오는 게 흔한 경우죠.

이럴 경우 async 함수로 지정해서 적용할 수 있는데요.

보통 fetch 함수를 이용해서 API Endpoint에서 JSON 형태의 데이터를 가져온 다음 가공해서 title이나 description에 반영하면 됩니다.

아래 코드는 async 방식의 generateMetadata 함수입니다.

export const generateMetadata = async ({
  params,
}: Props): Promise<Metadata> => {
  const title = await new Promise((resolve) => {
    setTimeout(() => {
      resolve(`Product Car ${params.productId}`);
    }, 100);
  });
  return {
    title: `Product ${title}`,
  };
};

예를 위해 setTimeout 함수를 사용했습니다.

위와 같이 하면 async 하게 다이내믹 메타데이터를 설정할 수 있습니다.

위와 같이 잘 적용되고 있네요.


4.2. title 메타데이터 깊게 살펴보기

title 항목이 가장 일반적인 메타데이터인데요.

Next.js에서도 title에 대해서는 아주 깊은 방식의 구현 방법을 제공해 줍니다.

title 항목은 문자열이나, 객체가 될 수 있는데요.

객체로 정의해 봅시다.

루트 레이아웃 파일에 아래와 같이 정하면 됩니다.

import { Metadata } from "next";
import "./global.css";

export const metadata: Metadata = {
  title: {
    absolute: "",
    default: "Next.js Title",
    template: "",
  },
  description: "Generated by Next.js",
};

title을 객체로 정의했고 default 값만 넣어두었습니다.

default 값에 내용을 적는 게 title을 그냥 문자열로 지정하는 것과 같은 방식입니다.

이렇게 되면 default 값이 모든 페이지에 적용되죠.

만약, 상세 페이지에서 title 항목을 오버라이딩 하지 않으면 말입니다.

그러면 template 항목은 뭘까요?

export const metadata: Metadata = {
  title: {
    absolute: "",
    default: "Next.js Title",
    template: "%s | Next.js",
  },
  description: "Generated by Next.js",
};

template는 C언어의 printf 함수 안에 들어가는 표현식과 비슷합니다.

이렇게 템플릿을 루트 레이아웃에 만들어 주면 각 상세페이지에서 생성된 title 항목은 바로 template의 '%s' 부분에 들어가게 되죠.

'about' 페이지로 이동해서 결과를 볼까요?

위 그림과 같이 상세 페이지의 title 항목에 대한 템플릿 역할을 하게 됩니다.

마지막으로 absolute 항목은 template 항목을 무력화시키는 건데요.

'profile' 페이지에 적용해 볼까요?

absolute 항목이 없다면 'about' 페이지와 비슷하게 템플릿이 적용된 상태로 나와야 할 겁니다.

absolute 항목이 있다면 template 항목이 무력화 됩니다.

import { Metadata } from "next";

export const metadata: Metadata = {
  title: {
    absolute: "Absolute Profile Page",
  },
};

export default function Profile() {
  return <h1>Profile</h1>;
}

이렇게 하면 'profile' 페이지의 title은 template을 무시하게 됩니다.


Next.js에서 사용하는 클라이언트 사이드 내비게이션은 Link 컴포넌트를 사용하면 됩니다.

Link 컴포넌트는 HTML의 <a> 태그의 확장판이라고 보시면 됩니다.

이제 Link를 이용해서 Header 부분에 내비게이션을 지정해 볼까요?

import { Metadata } from "next";
import "./global.css";
import Link from "next/link";

export const metadata: Metadata = {
  title: {
    default: "Next.js Title",
    template: "%s | Next.js",
  },
  description: "Generated by Next.js",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <header className="bg-yellow-400 p-2 my-2">
          <nav>
            <ul className="flex space-x-2">
              <li>
                <Link href="/">Home</Link>
              </li>
              <li>
                <Link href="/about">About</Link>
              </li>
              <li>
                <Link href="/profile">Profile</Link>
              </li>
              <li>
                <Link href="/products">Products</Link>
              </li>
            </ul>
          </nav>
        </header>
        <main className="flex p-4">{children}</main>
        <footer className="bg-cyan-400 p-2 my-2">Footer</footer>
      </body>
    </html>
  );
}

루트 레이아웃의 header 부분에 nav 태그를 추가했습니다.

Link 컴포넌트는 props로 'replace' 값을 받을 수 있는데요.

이 'replace' 값이 props로 전달되면 브라우저의 History를 초기화하게 됩니다.

<Link href="products/1" replace>
Product 1
</Link>

브라우저의 History는 우리가 '뒤로가기' 버튼을 눌렀을 때 딱 맞게 작동하는 이유인 거죠.

replace 옵션을 주면 '뒤로가기' 버튼을 누르면 바로 최상단 URL로 이동하게 될 겁니다.


6. Active Links(현재 링크에 따라 스타일 다르게 주기)

내비게이션에는 현재 어느 위치에 있는지 따로 보여주는 게 UI적으로 아주 좋은 습관인데요.

Next.js에서는 usePathname 훅을 지원해 줍니다.

이 usePathname 훅을 이용해서 현재 url 주소를 찾을 수 있거든요.

대신 이 훅을 사용하려면 'use client' 디렉티브를 사용해야 합니다.

즉, 클라이언트 컴포넌트로 만들어야 하는 거죠.

예를 들어 볼까요?

우리가 예전에 auth 부분에 3가지의 라우팅이 있다고 했는데요.

'/register', '/login', '/forgot-password' 주소가 그것입니다.

이 3개의 주소를 '(auth)' 폴더 바로 밑에 layout.tsx 파일을 만들어서 공통적인 UI로 만들겠습니다.

import Link from "next/link";

const navLinks = [
  { name: "Register", href: "/register" },
  { name: "Login", href: "/login" },
  { name: "Forgot Password", href: "/forgot-password" },
];

export default function AuthLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div>
      <nav className="flex space-x-2 border-b-2 mb-2">
        {navLinks.map((link) => {
          return (
            <Link href={link.href} key={link.name}>
              {link.name}
            </Link>
          );
        })}
      </nav>
      {children}
    </div>
  );
}

위와 같이 만들면 아래 그림과 같이 멋진 내비게이션이 완성됩니다.

이제, 현재 위치에 따른 스타일을 다르게 적용해 볼까요?

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

const navLinks = [
  { name: "Register", href: "/register" },
  { name: "Login", href: "/login" },
  { name: "Forgot Password", href: "/forgot-password" },
];

export default function AuthLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const pathname = usePathname();

  return (
    <div>
      <nav className="flex space-x-2 border-b-2 mb-2">
        {navLinks.map((link) => {
          const isActive = pathname.startsWith(link.href);
          return (
            <Link
              href={link.href}
              key={link.name}
              className={isActive ? "font-bold underline text-xl" : "text-md"}
            >
              {link.name}
            </Link>
          );
        })}
      </nav>
      {children}
    </div>
  );
}

위의 두 개의 그림에서처럼 activeLink가 제대로 작동하고 있습니다.


7. useRouter 사용법

내비게이션에 있어서 사용자의 의도 데로 링크를 타고 가야 할 때가 있고, 아니면 개발자가 의도적으로 라우팅을 강제로 작동시킬 때가 있는데요.

예를 들어, 오더를 주문했고 "Buy" 버튼을 눌렀을 때 자바스크립트 이걸 처리하고자 한다면 useRouter 훅을 이용해서 강제로 라우팅 주소를 이동할 수 있습니다.

useRouter 훅이기 때문에 클라이언트 컴포넌트 방식이어야 합니다.

"use client";

import { useRouter } from "next/navigation";

export default function OrderProduct() {
  const router = useRouter();

  const handleClick = () => {
    console.log("Placing your order!");
    router.push("/");
  };
  return (
    <>
      <h1>Order Product</h1>
      <button onClick={handleClick}>Place Order</button>
    </>
  );
}

위 코드가 useRouter 훅의 가장 일반적인 사용법입니다.

router.push()가 원하는 주소로 이동하는 거고,

router.replacce() 함수는 브라우저 히스토리를 클리어하고 이동하는 거고,

router.back(), router.forward()는 각각 뒤로가기, 앞으로 가기 버튼을 누른 것과 같습니다.