๋ฐ˜์‘ํ˜•

Next.js์—์„œ ์ œ๊ณตํ•˜๋Š” Learn Next.js App Router ํŠœํ† ๋ฆฌ์–ผ์„ ๋ถ€์—ฐ ์„ค๋ช…๊ณผ ํ•จ๊ป˜ ํ•œ๊ตญ์–ด๋กœ ์ •๋ฆฌํ•ด ๋ดค๋‹ค. ๊ณต์‹ ๊ฐ€์ด๋“œ๋Š” ์ด 16๊ฐœ ์ฑ•ํ„ฐ๋กœ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์ง€๋งŒ, ์ด๋ฒˆ ํฌ์ŠคํŒ…์—์„  ํ”„๋กœ์ ํŠธ ์„ธํŒ…์„ ๋‹ค๋ฃจ๋Š” ์ฑ•ํ„ฐ 1๊ณผ CSS ์Šคํƒ€์ผ๋ง ๋ฐฉ๋ฒ•์„ ์„ค๋ช…ํ•˜๋Š” ์ฑ•ํ„ฐ 2๋Š” ์ƒ๋žตํ–ˆ๋‹ค.

 

 

Optimizing Fonts and Images


Cumulative Layout Shift(CLS, ๋ˆ„์  ๋ ˆ์ด์•„์›ƒ ์ด๋™)๋Š” ๊ตฌ๊ธ€์ด ์›น์‚ฌ์ดํŠธ์˜ ์„ฑ๋Šฅ๊ณผ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ํ‰๊ฐ€ํ•˜๋Š” ๋ฐ ์‚ฌ์šฉํ•˜๋Š” ์ง€ํ‘œ๋กœ, ํŽ˜์ด์ง€ ๋กœ๋“œ ์ค‘ ๋ ˆ์ด์•„์›ƒ ์ด๋™์œผ๋กœ ์ธํ•ด ๋ฐœ์ƒํ•˜๋Š” ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์˜ ๋ถˆํŽธํ•จ์„ ์ธก์ •ํ•œ๋‹ค.

 

์˜ˆ๋ฅผ ๋“ค์–ด, ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ํด๋ฐฑ(fallback) ํฐํŠธ๋‚˜ ์‹œ์Šคํ…œ ํฐํŠธ๋กœ ํ…์ŠคํŠธ๋ฅผ ๋จผ์ € ๋ Œ๋”๋ง ํ•œ ๋’ค ์‚ฌ์šฉ์ž ์ง€์ • ํฐํŠธ๋กœ ๊ต์ฒดํ•  ๋•Œ ํ…์ŠคํŠธ ํฌ๊ธฐ, ๊ฐ„๊ฒฉ ๋˜๋Š” ๋ ˆ์ด์•„์›ƒ์ด ๋ณ€๊ฒฝ๋˜์–ด ์š”์†Œ๊ฐ€ ์ด๋™ํ•˜๋Š” ํ˜„์ƒ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋‹ค. ์ด๋Ÿฌํ•œ ๋ ˆ์ด์•„์›ƒ ์ด๋™์˜ ๋นˆ๋„์™€ ์‹ฌ๊ฐ๋„๋ฅผ ํ‰๊ฐ€ํ•˜๋Š” ๊ฒƒ์ด CLS์˜ ๋ชฉ์ ์ธ ๊ฒƒ.

 

Next.js์˜ next/font ๋ชจ๋“ˆ์„ ์‚ฌ์šฉํ•˜๋ฉด ๋นŒ๋“œ ์‹œ์ ์— ํฐํŠธ ํŒŒ์ผ์„ ๋‹ค์šด๋กœ๋“œํ•˜์—ฌ ๋‹ค๋ฅธ ์ •์  ์—์…‹๊ณผ ํ•จ๊ป˜ ํ˜ธ์ŠคํŒ… ํ•œ๋‹ค. ์ฆ‰, ์‚ฌ์šฉ์ž๊ฐ€ ์›น์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๋ฐฉ๋ฌธํ•  ๋•Œ ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์ค„ ์ˆ˜ ์žˆ๋Š” ํฐํŠธ ๊ด€๋ จ ์ถ”๊ฐ€ ๋„คํŠธ์›Œํฌ ์š”์ฒญ์ด ์—†๋‹ค๋Š” ๊ฒƒ์„ ์˜๋ฏธํ•œ๋‹ค.

 

Adding a primary font

์ผ๋ฐ˜์ ์œผ๋กœ /app/ui/fonts.ts ๊ฐ™์€ ํŒŒ์ผ์„ ๋งŒ๋“ค์–ด์„œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ „์ฒด์— ์‚ฌ์šฉํ•  ๊ธ€๊ผด์„ ์ •์˜ํ•œ๋‹ค. ์•„๋ž˜๋Š” next/font/google ๋ชจ๋“ˆ์—์„œ Inter ํฐํŠธ๋ฅผ ๋ถˆ๋Ÿฌ์˜จ ํ›„ latin ์„œ๋ธŒ์…‹์„ ์ง€์ •ํ•œ ์˜ˆ์‹œ.

// app/ui/fonts.ts
import { Inter } from 'next/font/google';

// ํฐํŠธ ํŒŒ์ผ์€ ๋‹ค์–‘ํ•œ ์–ธ์–ด์™€ ๋ฌธ์ž(๊ธ€๋ฆฌํ”„)๋ฅผ ํฌํ•จํ•˜๊ณ  ์žˆ์–ด ํฌ๊ธฐ๊ฐ€ ์ปค์งˆ ์ˆ˜ ์žˆ๋‹ค.
// subsets ์˜ต์…˜์„ ์‚ฌ์šฉํ•˜๋ฉด ํ•„์š”ํ•œ ๋ฌธ์ž ์„ธํŠธ๋งŒ ํฌํ•จํ•˜๋„๋ก ์ œํ•œํ•˜์—ฌ ํŒŒ์ผ ํฌ๊ธฐ๋ฅผ ์ค„์ผ ์ˆ˜ ์žˆ๋‹ค.
// ์•„๋ž˜๋Š” ๋ผํ‹ด ๋ฌธ์ž ์„ธํŠธ๋งŒ ํฌํ•จ๋œ Inter ํฐํŠธ๋ฅผ ๊ฐ€์ ธ์˜จ๋‹ค.
export const inter = Inter({ subsets: ['latin'] });

 

๊ทธ๋Ÿฐ ๋‹ค์Œ <body> ์—˜๋ฆฌ๋จผํŠธ์— ํฐํŠธ๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค. ํฐํŠธ๋ฅผ body ์š”์†Œ์— ์ถ”๊ฐ€ํ–ˆ์œผ๋ฏ€๋กœ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ „์ฒด์— ์ ์šฉ๋œ๋‹ค. ์ฐธ๊ณ ๋กœ antialiased Tailwind ํด๋ž˜์Šค๋Š” ํ…์ŠคํŠธ ๋ Œ๋”๋ง์„ ๋ถ€๋“œ๋Ÿฝ๊ฒŒ ๋งŒ๋“ค์–ด ๊ธ€์ž๋ฅผ ๋” ๊น”๋”ํ•˜๊ฒŒ ๋ Œ๋”๋ง ํ•ด์ค€๋‹ค.

// app/layout.tsx

import '@/app/ui/global.css';
import { inter } from '@/app/ui/fonts';

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

 

ํŠน์ • ์š”์†Œ์— secondary font๋ฅผ ์ ์šฉํ•  ์ˆ˜๋„ ์žˆ๋‹ค. ์•„๋ž˜๋Š” Lusitana ํฐํŠธ๋ฅผ <p> ํƒœ๊ทธ์—๋งŒ ์ ์šฉํ•œ ์˜ˆ์‹œ.

// app/ui/fonts.ts

import { Inter, Lusitana } from 'next/font/google';

export const inter = Inter({ subsets: ['latin'] });

export const lusitana = Lusitana({
  weight: ['400', '700'],
  subsets: ['latin'],
});
// app/page.tsx

// ...
import { lusitana } from "@/app/ui/fonts";

export default function Page() {
  return (
    // ...
    <p
      className={`${lusitana.className} text-xl text-gray-800 md:text-3xl md:leading-normal`}
    >
      {/* ... */}
    </p>
    // ...
  );
}

 

Why optimize images?

์ตœ์ƒ์œ„ ๋ ˆ๋ฒจ /public ํด๋”์— ์ด๋ฏธ์ง€ ๊ฐ™์€ ํŒŒ์ผ์„ ํฌํ•จ์‹œํ‚ค๋ฉด, Next.js๊ฐ€ ์ด๋ฅผ ์ฐธ์กฐํ•˜์—ฌ ์ •์  ํŒŒ์ผ๋กœ ์ œ๊ณตํ•  ์ˆ˜ ์žˆ๋‹ค. ์ผ๋ฐ˜์ ์ธ HTML์—์„  ์•„๋ž˜์ฒ˜๋Ÿผ ์ด๋ฏธ์ง€๋ฅผ ๊ฒฝ๋กœ๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ์‚ฌ์šฉํ•œ๋‹ค.

<img
  src="/hero.png"
  alt="Screenshots of the dashboard project showing desktop version"
/>

 

ํ•˜์ง€๋งŒ ์œ„์ฒ˜๋Ÿผ <img> ํƒœ๊ทธ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋‹ค์–‘ํ•œ ๋””๋ฐ”์ด์Šค ํฌ๊ธฐ์— ๋”ฐ๋ผ ์ด๋ฏธ์ง€๋ฅผ ์ ์ ˆํžˆ ์กฐ์ •ํ•ด์•ผ ํ•˜๊ณ , ๋ ˆ์ด์•„์›ƒ ์‹œํ”„ํŠธ ๋ฐฉ์ง€, ์ด๋ฏธ์ง€ ์ง€์—ฐ ๋กœ๋”ฉ ์ฒ˜๋ฆฌ ๋“ฑ ์ตœ์ ํ™”๋ฅผ ์ˆ˜๋™์œผ๋กœ ๊ด€๋ฆฌํ•ด์•ผ ํ•˜๋Š” ๋ถˆํŽธํ•จ์ด ์žˆ๋‹ค.

 

Next.js์—์„œ ์ œ๊ณตํ•˜๋Š” <Image> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ์ด๋ฏธ์ง€ ์ตœ์ ํ™”๋ฅผ ์ž๋™์œผ๋กœ ์ง„ํ–‰ํ•ด ์ค€๋‹ค.

  • ๋ ˆ์ด์•„์›ƒ ์‹œํ”„ํŠธ ๋ฐฉ์ง€: ์ด๋ฏธ์ง€๋ฅผ ๋กœ๋“œํ•  ๋•Œ ๋ฐœ์ƒํ•˜๋Š” ๋ ˆ์ด์•„์›ƒ ์ด๋™ ํ˜„์ƒ ๋ฐฉ์ง€
  • ์ด๋ฏธ์ง€ ์‚ฌ์ด์ฆˆ ์ž๋™ ์กฐ์ •: ๊ธฐ๊ธฐ ๋ทฐํฌํŠธ ํฌ๊ธฐ์— ๋”ฐ๋ผ ์ ํ•ฉํ•œ ์‚ฌ์ด์ฆˆ์˜ ์ด๋ฏธ์ง€ ์ „์†ก
  • ์ง€์—ฐ ๋กœ๋”ฉ(Lazy Loading) ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์ œ๊ณต
  • WebP, AVIF ๋“ฑ ๋ชจ๋˜ ์ด๋ฏธ์ง€ ํฌ๋งท์œผ๋กœ ์ œ๊ณต (๋ธŒ๋ผ์šฐ์ € ์ง€์› ์‹œ)

 

์•„๋ž˜๋Š” <Image> ์ปดํฌ๋„ŒํŠธ ์‚ฌ์šฉ ์˜ˆ์‹œ. ๋ ˆ์ด์•„์›ƒ ์‹œํ”„ํŠธ๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด width, height ์ง€์ •์„ ๊ถŒ์žฅํ•˜๋ฉฐ, ์›๋ณธ ์ด๋ฏธ์ง€์˜ ๊ฐ€๋กœ/์„ธ๋กœ ๋น„์œจ(aspect ratio)์— ๋งž๊ฒŒ ๊ฐ’์„ ์„ค์ •ํ•ด์•ผ ํ•œ๋‹ค.

import AcmeLogo from "@/app/ui/acme-logo";
import { ArrowRightIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
import { lusitana } from "@/app/ui/fonts";
import Image from "next/image";

export default function Page() {
  return (
    // ...
    <div className="flex items-center justify-center p-6 md:w-3/5 md:px-28 md:py-12">
      <Image
        src="/hero-desktop.png"
        width={1000}
        height={760}
        className="hidden md:block"
        alt="Screenshots of the dashboard project showing desktop version"
      />
    </div>
    //...
  );
}

 

๐Ÿ’ก hidden md:block ํด๋ž˜์Šค๊ฐ€ ์ ์šฉ๋์œผ๋ฏ€๋กœ ๋ชจ๋ฐ”์ผ์—์„  ์ˆจ๊น€ ์ฒ˜๋ฆฌ๋˜๊ณ , md ์‚ฌ์ด์ฆˆ(≥ 768px)๋ถ€ํ„ฐ ๋ณด์ธ๋‹ค.

 

 

Creating Layouts and Pages


Next.js์—์„œ ์„ธ๊ทธ๋จผํŠธ(Segment)๋Š” ํŽ˜์ด์ง€ ๊ฒฝ๋กœ์˜ ํ•œ ๋ถ€๋ถ„์„ ๊ตฌ์„ฑํ•˜๋Š” ํด๋” ๋˜๋Š” ํŒŒ์ผ์„ ์˜๋ฏธํ•œ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด /dashboard/settings ๊ฒฝ๋กœ์—์„œ dashboard์™€ settings๊ฐ€ ๊ฐ๊ฐ ์„ธ๊ทธ๋จผํŠธ๋‹ค.

 

  • Next.js๋Š” ํด๋”๋ฅผ ์‚ฌ์šฉํ•ด์„œ ์ค‘์ฒฉ ๊ฒฝ๋กœ๋ฅผ ์ƒ์„ฑํ•˜๋Š” ํŒŒ์ผ ์‹œ์Šคํ…œ ๋ผ์šฐํŒ…
  • ๊ฐ ํด๋”๋Š” URL ์„ธ๊ทธ๋จผํŠธ์™€ ๋งคํ•‘๋˜๋Š” ๋ผ์šฐํŠธ ์„ธ๊ทธ๋จผํŠธ ์˜๋ฏธ

 

  • ๊ฐ ๊ฒฝ๋กœ์— ๋Œ€ํ•ด layout.tsx, page.tsx ํŒŒ์ผ์„ ์‚ฌ์šฉํ•˜์—ฌ ๋ณ„๋„์˜ UI๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Œ
  • ๋ ˆ์ด์•„์›ƒ ์‚ฌ์šฉํ•˜๋ฉด ๋‚ด๋น„๊ฒŒ์ด์…˜ ์‹œ page ์ปดํฌ๋„ŒํŠธ๋งŒ ์—…๋ฐ์ดํŠธ๋˜๊ณ  layout์€ ๋‹ค์‹œ ๋ Œ๋”๋ง ์•ˆ๋จ → ๋ถ€๋ถ„ ๋ Œ๋”๋ง
  • ๋ผ์šฐํŠธ ์„ธ๊ทธ๋จผํŠธ๋ณ„๋กœ ์ฝ”๋“œ ์ž๋™ ๋ถ„ํ•  → ํŽ˜์ด์ง€ ๋ถ„๋ฆฌ
  • ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ Link ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋ธŒ๋ผ์šฐ์ € ๋ทฐํฌํŠธ์— ๋‚˜ํƒ€๋‚˜๋ฉด ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ๋งํฌ๋œ ๊ฒฝ๋กœ์˜ ์ฝ”๋“œ๋ฅผ ์ž๋™์œผ๋กœ ๋ฏธ๋ฆฌ ๊ฐ€์ ธ์˜ด

 

 

Rendering


  • Static Rendering: ๋นŒ๋“œ ํƒ€์ž„ ๋˜๋Š” ์žฌ๊ฒ€์ฆ(revalidating) ์‹œ ๋ฐ์ดํ„ฐ ํŒจ์นญ๊ณผ ๋ Œ๋”๋ง์„ ์ˆ˜ํ–‰ํ•˜๋Š” ๋ฐฉ์‹. ์ด๋ฏธ ์ƒ์„ฑ๋œ HTML์„ ์บ์‹ฑํ•˜์—ฌ ์ œ๊ณตํ•˜๋ฏ€๋กœ, ์ดํ›„ ๋ฐฉ๋ฌธํ•˜๋Š” ์œ ์ €๋Š” ๋ฏธ๋ฆฌ ๋ Œ๋”๋ง ๋œ ์ •์ ์ธ ์ฝ˜ํ…์ธ ๋ฅผ ๋น ๋ฅด๊ฒŒ ๋ฐ›์„ ์ˆ˜ ์žˆ์Œ.
  • Dynamic Rendering: ์š”์ฒญ ์‹œ์ (Request Time)์— ์ฝ˜ํ…์ธ ๋ฅผ ๋ Œ๋”๋ง ํ•˜๋Š” ๋ฐฉ์‹. ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ, ์‚ฌ์šฉ์ž ๋งž์ถคํ˜•(personalized) ์ฝ˜ํ…์ธ , ์ฟ ํ‚ค, URL Search Parameters ๋“ฑ์„ ํ™œ์šฉํ•˜์—ฌ ํด๋ผ์ด์–ธํŠธ๋ณ„ ๋™์ ์ธ ์‘๋‹ต์„ ์ƒ์„ฑํ•จ.

 

 

Streaming


๋ฐ์ดํ„ฐ๋ฅผ ์ ์ง„์ ์œผ๋กœ ํด๋ผ์ด์–ธํŠธ๋กœ ์ „์†กํ•˜์—ฌ ์ดˆ๊ธฐ ๋ Œ๋”๋ง ์‹œ๊ฐ„์„ ์ค„์—ฌ์ฃผ๋Š” ๊ธฐ๋Šฅ. ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์‹œ๊ฐ„์ด ๊ธธ๊ฑฐ๋‚˜ ์ ์ง„์  ๋กœ๋“œ๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ ์‚ฌ์šฉ.

 

  • Streaming: ๋ผ์šฐํŠธ๋ฅผ ์ž‘์€ ์ฒญํฌ๋กœ ๋‚˜๋ˆ„๊ณ , ์ค€๋น„๋˜๋Š” ๋Œ€๋กœ ์„œ๋ฒ„์—์„œ ํด๋ผ์ด์–ธํŠธ๋กœ ์ ์ง„์ ์œผ๋กœ ์ „์†กํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” ๋ฐ์ดํ„ฐ ์ „์†ก ๊ธฐ์ˆ . ์ŠคํŠธ๋ฆฌ๋ฐ์€ ํŽ˜์ด์ง€ ๋ ˆ๋ฒจ์˜ loading.tsx ํŒŒ์ผ ํ˜น์€ <Suspense> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ฌ์šฉํ•ด์„œ ๊ตฌํ˜„.

 
  • loading.tsx ํŒŒ์ผ์„ ์ด์šฉํ•ด ํŽ˜์ด์ง€ ์ฝ˜ํ…์ธ ๊ฐ€ ๋กœ๋“œ๋˜๋Š” ๋™์•ˆ ๋Œ€์ฒด๋กœ ํ‘œ์‹œํ•  ํด๋ฐฑ UI๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Œ
  • loading.tsx์— ์ถ”๊ฐ€ํ•˜๋Š” ๋ชจ๋“  UI๋Š” ์ •์  ํŒŒ์ผ์˜ ์ผ๋ถ€๋กœ ํฌํ•จ๋˜์–ด ๋จผ์ € ์ „์†ก๋˜๊ณ , ๋‚˜๋จธ์ง€ ๋™์  ์ฝ˜ํ…์ธ ๊ฐ€ ์„œ๋ฒ„์—์„œ ํด๋ผ์ด์–ธํŠธ๋กœ ์ŠคํŠธ๋ฆฌ๋ฐ ๋จ
  • ์ƒ์œ„ ๋ ˆ๋ฒจ์— loading.tsx ํŒŒ์ผ์„ ๋‘๋ฉด ํ•ด๋‹น ๋ ˆ๋ฒจ ์ดํ•˜์˜ page์— ๋กœ๋”ฉ ์ƒํƒœ๊ฐ€ ๊ณตํ†ต์ ์œผ๋กœ ์ ์šฉ๋จ. Route Groups(์†Œ๊ด„ํ˜ธ) ํด๋”๋ฅผ ์‚ฌ์šฉํ•ด page.tsx, loading.tsx๋ฅผ ํŠน์ • ๊ทธ๋ฃน ํด๋” ์•ˆ์œผ๋กœ ์˜ฎ๊ธฐ๋ฉด, ํ•ด๋‹น ํด๋” ๋ ˆ๋ฒจ์—๋งŒ ์ ์šฉ๋˜๊ณ  ๋‹ค๋ฅธ ํ•˜์œ„ ๋ ˆ๋ฒจ์—๋Š” ์˜ํ–ฅ ์•ˆ ๋ฏธ์นจ

์œ„ ๊ฐ™์€ ํด๋” ๊ตฌ์กฐ์—์„œ loading.tsx๋Š” /dashboard ๊ฒฝ๋กœ์—๋งŒ ์˜ํ–ฅ์„ ์คŒ

  • ๊ด„ํ˜ธ ()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ƒˆ ํด๋”๋ฅผ ๋งŒ๋“ค๋ฉด(๋ผ์šฐํŠธ ๊ทธ๋ฃน) URL ๊ฒฝ๋กœ ๊ตฌ์กฐ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š๊ณ  ํŒŒ์ผ์„ ๋…ผ๋ฆฌ์  ๊ทธ๋ฃน์œผ๋กœ ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ์Œ.
  • ์˜ˆ๋ฅผ ๋“ค์–ด /dashboard/(overview)/page.tsx ๊ฒฝ๋กœ๋Š” /dashboard๊ฐ€ ๋จ. ๋ผ์šฐํŠธ ๊ทธ๋ฃน ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•ด์„œ (marketing), (shop) ๋“ฑ์˜ ์„น์…˜์„ ์ƒ์„ฑํ•˜๋ฉด URL ๊ฒฝ๋กœ ๊ตฌ์กฐ๋ฅผ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š๊ณ  ํŒŒ์ผ์„ ๋…ผ๋ฆฌ์ ์œผ๋กœ ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ์Œ.
  • Suspense๋ฅผ ์‚ฌ์šฉํ•ด์„œ ์ŠคํŠธ๋ฆฌ๋ฐ์„ ๋” ์„ธ๋ถ„ํ™”ํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๊ฐ™์€ ์„น์…˜์œผ๋กœ ๋ถ„๋ฅ˜๋˜๋Š” ์ปดํฌ๋„ŒํŠธ๋“ค์„ ๋™์ผํ•œ ์„œ์ŠคํŽœ์Šค๋กœ ๊ทธ๋ฃนํ•‘ํ•ด์„œ ์ž‘์„ฑํ•  ์ˆ˜๋„ ์žˆ์Œ.

 

<Suspense>
  <Comp1 />
  <Comp2 />
</Suspense>

 

 

Partial Prerendering (PPR)


์ •์  ์ฝ˜ํ…์ธ ๋ฅผ ๋ฏธ๋ฆฌ ์ƒ์„ฑํ•˜๊ณ , ๋‚˜๋จธ์ง€๋Š” ๋™์ ์œผ๋กœ ๋กœ๋“œํ•˜์—ฌ ์ •์  ๋ฐ์ดํ„ฐ์™€ ๋™์  ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒฐํ•ฉ. ์ •์  ์ฝ˜ํ…์ธ ์™€ ๋™์  ์ฝ˜ํ…์ธ ๊ฐ€ ํ˜ผํ•ฉ๋œ ๊ฒฝ์šฐ ์‚ฌ์šฉ.

 

  • dynamic function(๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ฟผ๋ฆฌ์ฒ˜๋Ÿผ ๋Ÿฐํƒ€์ž„์‹œ ๋™์ ์œผ๋กœ ์‹คํ–‰๋˜์–ด ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” ํ•จ์ˆ˜)์„ ๋ผ์šฐํŠธ์—์„œ ํ˜ธ์ถœํ•˜๋ฉด ํ•ด๋‹น ๋ผ์šฐํŠธ๋Š” ๋™์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋จ
  • ์ •์  ๋ผ์šฐํŠธ Shell(๋ผˆ๋Œ€) ๋ฏธ๋ฆฌ ๋กœ๋”ฉ → ๋น ๋ฅธ ์ดˆ๊ธฐ ๋กœ๋“œ ๋ณด์žฅ
  • ์ •์  Shell์€ ๋™์  ์ฝ˜ํ…์ธ ๊ฐ€ ๋น„๋™๊ธฐ์ ์œผ๋กœ ๋กœ๋“œ๋  ์ˆ˜ ์žˆ๋„๋ก ๋ณ„๋„ Hole(๊ณต๊ฐ„)์„ ๋‚จ๊น€
  • ๋น„๋™๊ธฐ Hole์€ ๋ณ‘๋ ฌ๋กœ ์ŠคํŠธ๋ฆฌ๋ฐ ๋ผ์„œ ํŽ˜์ด์ง€ ์ „์ฒด ๋กœ๋“œ ์‹œ๊ฐ„์„ ์ค„์ž„
  • ๋ถ€๋ถ„ ํ”„๋ฆฌ๋ Œ๋”๋ง(PPR)์€ Suspense๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์กฐ๊ฑด์„ ์ถฉ์กฑํ•  ๋•Œ๊นŒ์ง€ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ Œ๋”๋ง์„ ์ง€์—ฐ์‹œํ‚ด

 

  • Suspense fallback์€ ์ •์  ์ฝ˜ํ…์ธ ์™€ ํ•จ๊ป˜ ์ดˆ๊ธฐ HTML ํŒŒ์ผ์— ์ž„๋ฒ ๋“œ๋จ
  • ๋นŒ๋“œ ์‹œ ์ •์  ์ฝ˜ํ…์ธ ๊ฐ€ ๋ฏธ๋ฆฌ ๋ Œ๋”๋ง ๋ผ์„œ ์ •์  Shell์„ ๋งŒ๋“ฆ
  • ๋™์  ์ฝ˜ํ…์ธ ์˜ ๋ Œ๋”๋ง์€ ์‚ฌ์šฉ์ž๊ฐ€ ํ•ด๋‹น ๊ฒฝ๋กœ๋ฅผ ์š”์ฒญํ•  ๋•Œ๊นŒ์ง€ ์ง€์—ฐ๋จ
  • Suspense๋Š” ์ •์  ์ฝ”๋“œ์™€ ๋™์  ์ฝ”๋“œ ์‚ฌ์ด์˜ ๊ฒฝ๊ณ„๋กœ ์‚ฌ์šฉ๋จ. Suspense๋กœ ์ปดํฌ๋„ŒํŠธ๋ฅผ ๊ฐ์‹ผ๋‹ค๊ณ  ํ•ด์„œ ์ปดํฌ๋„ŒํŠธ ์ž์ฒด๋ฅผ ๋™์ ์œผ๋กœ ๋งŒ๋“œ๋Š” ๊ฒƒ์ด ์•„๋‹˜.

 

PPR์€ ์•„์ง ์‹คํ—˜ ๋‹จ๊ณ„. ์•„๋ž˜ ์„ค์ •๋งŒ์œผ๋กœ ํ™œ์„ฑํ™”ํ•  ์ˆ˜ ์žˆ์Œ.

// app/dashboard/layout.tsx
// ppr ์˜ต์…˜ ํ™œ์„ฑํ™”

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    ppr: 'incremental',
  },
};
 
export default nextConfig;

// app/dashboard/layout.tsx
// ๋ ˆ์ด์•„์›ƒ ํŒŒ์ผ์— experimental_ppr ์˜ต์…˜ ์ถ”๊ฐ€

import SideNav from '@/app/ui/dashboard/sidenav';
 
export const experimental_ppr = true;
 
// ...

 

๋ฐ์ดํ„ฐ ํŒจ์นญ ์ตœ์ ํ™” ๋ฐฉ๋ฒ• 6๊ฐ€์ง€

  • DB๋ฅผ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ฝ”๋“œ์™€ ๋™์ผํ•œ ์œ„์น˜์— ๋ฐฐ์น˜ํ•˜์—ฌ ์„œ๋ฒ„/DB ๊ฐ„ ์ง€์—ฐ ์‹œ๊ฐ„์„ ์ค„์ž„
  • SQL์„ ์‚ฌ์šฉํ•˜์—ฌ ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋งŒ ๊ฐ€์ ธ์™€์„œ ์ „์†ก ๋ฐ์ดํ„ฐ๋ฅผ ์ค„์ด๊ณ , ๋ฉ”๋ชจ๋ฆฌ ๋‚ด ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜์— ํ•„์š”ํ•œ ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ๋ฅผ ์ตœ์†Œํ™”ํ•จ
  • ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ๋ฅผ ์ด์šฉํ•˜์—ฌ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ‘๋ ฌ๋กœ ๊ฐ€์ ธ์™€์„œ ์„ฑ๋Šฅ ์ตœ์ ํ™”
  • ์ŠคํŠธ๋ฆฌ๋ฐ์„ ์ด์šฉํ•ด ๋Š๋ฆฐ ๋ฐ์ดํ„ฐ ์š”์ฒญ์ด ์ „์ฒด ํŽ˜์ด์ง€๋ฅผ ์ฐจ๋‹จํ•˜์ง€ ์•Š๋„๋ก ํ•˜๊ณ , ์ผ๋ถ€ ๋ฐ์ดํ„ฐ๋ฅผ ๋จผ์ € ๋กœ๋“œํ•˜์—ฌ UI๋ฅผ ๋น ๋ฅด๊ฒŒ ์ƒํ˜ธ์ž‘์šฉ ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•จ
  • ๋ฐ์ดํ„ฐ ํŒจ์นญ ์ฝ”๋“œ๋ฅผ ์ปดํฌ๋„ŒํŠธ ๋‚ด์—์„œ ์ง„ํ–‰ํ•˜๋„๋ก ํ•ด์„œ ๋‹ค์ด๋‚˜๋ฏน ๋ผ์šฐํŠธ ๋ถ€๋ถ„๋งŒ ๋”ฐ๋กœ ๋ถ„๋ฆฌ

 

 

Adding Search and Pagination


  • URL params๋ฅผ ์ด์šฉํ•œ ๊ฒ€์ƒ‰ ๊ตฌํ˜„ ๊ณผ์ •
    1. ์œ ์ € ์ž…๋ ฅ๊ฐ’ ํš๋“
    2. URL ์—…๋ฐ์ดํŠธ
    3. URL โ‡† Input ํ•„๋“œ ๋™๊ธฐํ™”
    4. search query๋ฅผ ๋ฐ˜์˜ํ•œ ํ…Œ์ด๋ธ” ์—…๋ฐ์ดํŠธ
  • ์•„๋ž˜๋Š” ํ…Œ์ด๋ธ” ๊ฒ€์ƒ‰ ๊ตฌํ˜„ ์˜ˆ์‹œ. Search ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์—์„œ ์ƒˆ๋กœ์šด ์ž…๋ ฅ๊ฐ’์„ ๋ฐ›์•„ search parameter๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๋ฉด Page ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์—์„œ props๋กœ ๋ฐ›์•„์„œ ์ด ๊ฐ’์„ ์ด์šฉํ•ด ํ…Œ์ด๋ธ”์„ ์—…๋ฐ์ดํŠธํ•˜๋Š” ๋ฐฉ์‹.

 

// invoices/page.tsx

interface PageProps {
  searchParams?: Promise<{
    query?: string,
    page?: string,
  }>;
}

// ํŽ˜์ด์ง€ ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ๋Š” params, searchParams๋ฅผ props๋กœ ๋ฐ›์Œ
export default async function Page(props: PageProps) {
  const searchParams = await props.searchParams;
  const query = searchParams?.query || "";
  const currentPage = Number(searchParams?.page || 1);

  return (
    <div className="w-full">
      {/* ...์ƒ๋žต */}
      <div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search invoices..." />
        <CreateInvoice />
      </div>
      <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense>
      {/* ...์ƒ๋žต */}
    </div>
  );
}
// app/ui/search.tsx

"use client";

import { usePathname, useSearchParams, useRouter } from "next/navigation";
// ...

export default function Search({ placeholder }: { placeholder: string }) {
  // ํ˜„์žฌ URL์˜ search parameter ์กฐํšŒ
  const searchParams = useSearchParams();
  // ํ˜„์žฌ URL์˜ pathname ์กฐํšŒ
  const pathname = usePathname();
  // ํŽ˜์ด์ง€ ์ƒˆ๋กœ๊ณ ์นจ ์—†์ด ํ˜„์žฌ URL ๋Œ€์ฒด
  const { replace } = useRouter();

  const handleSearch = useDebouncedCallback((term: string) => {
    const params = new URLSearchParams(searchParams);
    if (term) params.set("query", term);
    else params.delete("query");

    replace(`${pathname}?${params.toString()}`);
  }, 300);

  return (
    <div className="relative flex flex-1 flex-shrink-0">
      <input
        className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
        placeholder={placeholder}
        onChange={(e) => handleSearch(e.target.value)}
        defaultValue={searchParams.get("query")?.toString()}
      />
      {/* ... */}
    </div>
  );
}

 

 

Mutating Data


  • React ์„œ๋ฒ„ ์•ก์…˜์€ ๋น„๋™๊ธฐ ์ฝ”๋“œ๋ฅผ ์„œ๋ฒ„์—์„œ ์ง์ ‘ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด ์คŒ. ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ€๊ฒฝํ•˜๊ธฐ ์œ„ํ•ด API ์—”๋“œํฌ์ธํŠธ๋ฅผ ์ƒ์„ฑํ•˜์ง€ ์•Š๊ณ  ์„œ๋ฒ„์—์„œ ์‹คํ–‰๋˜๋Š” ๋น„๋™๊ธฐ ํ•จ์ˆ˜๋ฅผ ์ž‘์„ฑํ•˜์—ฌ ํด๋ผ์ด์–ธํŠธ/์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์—์„œ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด ์คŒ
  • ๋ฆฌ์•กํŠธ์—์„  <form> ์š”์†Œ์˜ action ์–ดํŠธ๋ฆฌ๋ทฐํŠธ๋ฅผ ํ†ตํ•ด์„œ ์•ก์…˜์„ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ์Œ. ์ด ์•ก์…˜์€ ์ž๋™์œผ๋กœ ์บก์ฒ˜๋œ ๋ฐ์ดํ„ฐ๋ฅผ ํฌํ•จํ•˜๋Š” ๋„ค์ดํ‹ฐ๋ธŒ FormData ๊ฐ์ฒด๋ฅผ ๋ฐ›์Œ.

 

export default function Page() {
  // Action
  async function create(formData: FormData) {
    "use server";
    // mutate data ๋กœ์ง...
  }

  // action ์–ดํŠธ๋ฆฌ๋ทฐํŠธ๋ฅผ ์‚ฌ์šฉํ•ด์„œ action ํ˜ธ์ถœ
  return <form action={create}>{/* ... */}</form>;
}

 

  • ์œ„์ฒ˜๋Ÿผ ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์—์„œ ์„œ๋ฒ„ ์•ก์…˜์„ ํ˜ธ์ถœํ•˜๋ฉด ์ ์ง„์  ํ–ฅ์ƒ์ด ๊ฐ€๋Šฅํ•จ. ์ฆ‰, ํด๋ผ์ด์–ธํŠธ์—์„œ ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ๊ฐ€ ๋น„ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์–ด๋„ ํผ์€ ์ž‘๋™ํ•จ. ๐Ÿ’ก ์ ์ง„์  ํ–ฅ์ƒ์ด๋ž€? ํ•ต์‹ฌ ๋กœ์ง์„ ์„œ๋ฒ„์—์„œ ์ฒ˜๋ฆฌํ•˜๋ฏ€๋กœ ํด๋ผ์ด์–ธํŠธ ํ™˜๊ฒฝ์— ์ œ์•ฝ(๋„คํŠธ์›Œํฌ ์ƒํƒœ๊ฐ€ ์ข‹์ง€ ์•Š์€ ๋“ฑ)์ด ์žˆ๋”๋ผ๋„ ๊ธฐ๋ณธ์ ์ธ ๊ธฐ๋Šฅ์ด ์œ ์ง€๋จ.
  • ์„œ๋ฒ„ ์•ก์…˜์€ Next.js ์บ์‹ฑ๊ณผ๋„ ๊นŠ์ด ํ†ตํ•ฉ๋˜์–ด ์žˆ์Œ. ์„œ๋ฒ„ ์•ก์…˜์„ ํ†ตํ•ด ํผ์ด ์ œ์ถœ๋˜๋ฉด ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์„ ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ revalidatePath, revalidateTag์™€ ๊ฐ™์€ API๋ฅผ ์‚ฌ์šฉํ•ด์„œ ๊ด€๋ จ ์บ์‹œ๋ฅผ ๋‹ค์‹œ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ์Œ.

 

์„œ๋ฒ„ ์•ก์…˜์„ ํ™œ์šฉํ•œ /create ๋กœ์ง

๐Ÿ’ก ํผ ์ƒ์„ฑ ํ”Œ๋กœ์šฐ

  1. ์œ ์ € ์ž…๋ ฅ ํ•„๋“œ ๊ฐ’์„ ์กฐํšŒํ•˜๊ธฐ ์œ„ํ•ด ํผ ์ƒ์„ฑ
  2. ์„œ๋ฒ„ ์•ก์…˜ ์ƒ์„ฑ ํ›„ ์ž…๋ ฅ ํผ์—์„œ ํ˜ธ์ถœ
  3. ์„œ๋ฒ„ ์•ก์…˜ ๋‚ด๋ถ€์—์„œ formData ๊ฐ์ฒด์— ์žˆ๋Š” ๋ฐ์ดํ„ฐ ์ถ”์ถœ
  4. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์‚ฝ์ž…ํ•  ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒ€์ฆํ•˜๊ณ  ์ค€๋น„
  5. ๋ฐ์ดํ„ฐ ์‚ฝ์ž… ํ›„ ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ
  6. ์บ์‹œ ์žฌ๊ฒ€์ฆ ํ›„ ์‚ฌ์šฉ์ž ์ธ๋ณด์ด์Šค ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋””๋ ‰์…˜

 

โ‘  ๋ผ์šฐํ„ฐ ์ •์˜ ๋ฐ ํผ ํ•„๋“œ์— ์‚ฌ์šฉํ•  ๋ชฉ๋ก ๋ฐ์ดํ„ฐ ์กฐํšŒ ํ›„ <Form /> ์ปดํฌ๋„ŒํŠธ๋กœ ์ „๋‹ฌ

// app/dashboard/invoices/create/page.tsx

// dashboard/invoices/create ๋ผ์šฐํ„ฐ ์ •์˜
export default async function Page() {
  // [{ id: '...', name: '...' }, { ... }]
  const customers = await fetchCustomers();

  return (
    <main>
      {/* ... */}
      <Form customers={customers} />
    </main>
  );
}

 

โ‘ก ์–‘์‹์„ ์ œ์ถœํ•  ๋•Œ ํ˜ธ์ถœ๋  ์„œ๋ฒ„ ์•ก์…˜ ์ƒ์„ฑ

// app/lib/actions.ts

// use server ์ง€์‹œ์–ด๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ํŒŒ์ผ ๋‚ด์—์„œ ๋‚ด๋ณด๋‚ธ ๋ชจ๋“  ํ•จ์ˆ˜๋ฅผ ์„œ๋ฒ„ ์•ก์…˜์œผ๋กœ ํ‘œ์‹œ
// ์„œ๋ฒ„ ํ•จ์ˆ˜๋Š” ํด๋ผ์ด์–ธํŠธ/์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Œ
"use server";

export async function createInvoice(formData: FormData) {
  // ...
}

 

โ‘ข <Form /> ์ปดํฌ๋„ŒํŠธ action ์†์„ฑ์— createInvoice ์•ก์…˜ ํ˜ธ์ถœ ํ•จ์ˆ˜ ํ• ๋‹น

// app/ui/invoices/create-form.tsx

// ...
import { createInvoice } from "@/app/lib/actions";

export default function Form({ customers }: { customers: customerField[] }) {
  return <form action={createInvoice}>{/* ... */}</form>;
}

 

HTML <form> ํƒœ๊ทธ์˜ action ์†์„ฑ์€ ๋ฐ์ดํ„ฐ ์ „์†ก์„ ์œ„ํ•œ URL์„ ์ง€์ •ํ•˜๋Š” ๋ฐ ์‚ฌ์šฉํ•˜์ง€๋งŒ, ๋ฆฌ์•กํŠธ <form> ํƒœ๊ทธ์˜ action ์†์„ฑ์€ ๋‚ด๋ถ€์ ์œผ๋กœ POST API ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ค์–ด์ฃผ๋Š” ์—ญํ• ์„ ํ•จ.

 

โ‘ฃ formData์—์„œ ๋ฐ์ดํ„ฐ ์ถ”์ถœ → ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ/์ €์žฅ → ์บ์‹œ ๋ฌดํšจํ™”/๋ฆฌ๋‹ค์ด๋ ‰ํŠธ

// app/lib/actions.ts

"use server";

// ...

const FormSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),
  status: z.enum(["pending", "paid"]),
  date: z.string(),
});

// FormSchema ์Šคํ‚ค๋งˆ์—์„œ id, date ํ•„๋“œ ์ œ์™ธ
const CreateInvoice = FormSchema.omit({ id: true, date: true });

export async function createInvoice(formData: FormData) {
  // parse ๋ฉ”์„œ๋“œ๋กœ ์Šคํ‚ค๋งˆ ๊ฒ€์ฆ
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get("customerId"),
    amount: formData.get("amount"),
    status: formData.get("status"),
  });

  const amountInCents = amount * 100; // ๊ธˆ์•ก์„ cents ๋‹จ์œ„๋กœ ๋ณ€ํ™˜
  const [date] = new Date().toISOString().split("T"); // ISO ๋ฌธ์ž์—ด์—์„œ ๋‚ ์งœ ๋ถ€๋ถ„๋งŒ ์ถ”์ถœ

  // ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ƒˆ ์ธ๋ณด์ด์Šค ๋ฐ์ดํ„ฐ ์‚ฝ์ž…
  await sql`
    INSERT INTO invoices (customer_id, amount, status, date)
    VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
  `;

  // Next.js๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์‚ฌ์šฉ์ž ๋ธŒ๋ผ์šฐ์ €์— ํ˜„์žฌ ํŽ˜์ด์ง€์™€ ๊ด€๋ จ๋œ ๊ฒฝ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•จ
  // ์ฆ‰, ์‚ฌ์šฉ์ž๊ฐ€ ์ด๋™ํ•œ ๊ฒฝ๋กœ์˜ ์ •๋ณด๋ฅผ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ž ๊น ๊ธฐ์–ตํ•˜๊ณ  ์žˆ๋Š” ๊ฒƒ
  // ์ด๋ฅผ Client-Side Router Cache๋ผ๊ณ  ๋ถ€๋ฆ„
  // revalidatePath ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์บ์‹œ๋ฅผ ๊ฐ•์ œ๋กœ ๋ฌดํšจํ™”ํ•จ
  revalidatePath("/dashboard/invoices"); // /dashboard/invoices ๊ฒฝ๋กœ์˜ ํด๋ผ์ด์–ธํŠธ ์บ์‹œ ๋ฌดํšจํ™”
  redirect("/dashboard/invoices"); // ๋ฌดํšจํ™” ํ›„ ํ•ด๋‹น ๊ฒฝ๋กœ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ
}

 

๋™์  ๋ผ์šฐํŠธ๋ฅผ ํ™œ์šฉํ•œ /update ๋กœ์ง

๐Ÿ’ก ํผ ์—…๋ฐ์ดํŠธ ๋กœ์ง ํ”Œ๋กœ์šฐ

  1. URL์—์„œ ๋ณ€์ˆ˜ ๊ฐ’์„ ์ถ”์ถœํ•˜๊ธฐ ์œ„ํ•ด ๋™์  ๋ผ์šฐํŠธ ์„ธ๊ทธ๋จผํŠธ ์ƒ์„ฑ
  2. ํŽ˜์ด์ง€ ์ปดํฌ๋„ŒํŠธ์—์„œ ์ธ๋ณด์ด์Šค ID ํš๋“
  3. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ํ•ด๋‹น ์ธ๋ณด์ด์Šค ๋ฐ์ดํ„ฐ ์กฐํšŒ
  4. ์กฐํšŒํ•œ ์ธ๋ณด์ด์Šค ๋ฐ์ดํ„ฐ๋กœ ํผ ์–‘์‹ ์ฑ„์šฐ๊ธฐ
  5. ํผ ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ ์ธ๋ณด์ด์Šค ์ •๋ณด ์—…๋ฐ์ดํŠธ

 

ํด๋” ์ด๋ฆ„์„ ๋Œ€๊ด„ํ˜ธ๋กœ ๊ฐ์‹ธ๋ฉด ๋™์  ๋ผ์šฐํŠธ ์„ธ๊ทธ๋จผํŠธ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. invoices/[id]/edit/page.tsx๋กœ ์ •์˜ํ•˜๋ฉด ํ•ด๋‹น URL๋กœ ์ ‘๊ทผํ–ˆ์„ ๋•Œ page.tsx ์ปดํฌ๋„ŒํŠธ์—์„œ props.params.id๋ฅผ ํ†ตํ•ด id ๊ฐ’์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค.

// app/dashboard/invoices/[id]/edit/page.tsx

// ...
import Form from '@/app/ui/invoices/edit-form';

interface EditInvoicePageProps {
  params: Promise<{ id: string }>
}

export default async function Page(props: EditInvoicePageProps) {
  const params = await props.params;
  const id = params.id;

  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);

  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          {label: 'Invoices', href: '/dashboard/invoices'},
          {
            label: 'Edit Invoice',
            href: `/dashboard/invoices/${id}/edit`,
            active: true,
          },
        ]}
      />
      <Form invoice={invoice} customers={customers}/>
    </main>
  );
}

 

๋ฆฌ์•กํŠธ์—์„œ <form> ํƒœ๊ทธ action ์†์„ฑ์— ์ „๋‹ฌํ•œ ํ•ธ๋“ค๋Ÿฌ๋Š” formData๋ฅผ ์ธ์ž๋กœ ๋ฐ›๋Š”๋‹ค. ์•„๋ž˜์ฒ˜๋Ÿผ bind ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ฒซ ๋ฒˆ์งธ ์ธ์ž๋ฅผ ๊ณ ์ •ํ•ด ๋‘๋ฉด ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋” ๊ฐ„๊ฒฐํ•˜๊ฒŒ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค.

// app/ui/invoices/edit-form.tsx

'use client';

// ...
import { updateInvoice } from '@/app/lib/actions';

export default function EditInvoiceForm({
  invoice,
  customers,
}: {
  invoice: InvoiceForm;
  customers: CustomerField[];
}) {
  // updateInvoice ํ•จ์ˆ˜์˜ ์ฒซ ๋ฒˆ์งธ ์ธ์ž(id)๋ฅผ invoice.id๋กœ ๊ณ ์ •ํ•˜์—ฌ ์ƒˆ๋กœ์šด ํ•จ์ˆ˜ ์ƒ์„ฑ
  const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);

  return (
    <form action={updateInvoiceWithId}>
      { /* ... */ }
    </form>
  );
}

 

 

Handling Errors


Next.js์—์„œ redirect() ํ•จ์ˆ˜๋Š” ๋‚ด๋ถ€์ ์œผ๋กœ ์—๋Ÿฌ๋ฅผ ๋˜์ง€๋Š” ๋ฐฉ์‹์œผ๋กœ ๋™์ž‘ํ•˜์—ฌ ํ”„๋กœ์„ธ์Šค๋ฅผ ์ค‘๋‹จํ•˜๊ณ  ๋ธŒ๋ผ์šฐ์ €๋ฅผ ํŠน์ • URL๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ํ•œ๋‹ค. ๋”ฐ๋ผ์„œ redirect()๋Š” try/catch ๋ธ”๋ก ์•ˆ์—์„œ ํ˜ธ์ถœ๋  ๊ฒฝ์šฐ, catch ๋ธ”๋ก์— ์˜ํ•ด ํฌ์ฐฉ๋˜์–ด ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ๊ฐ€ ์˜๋„๋Œ€๋กœ ์‹คํ–‰๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ๋‹ค. ์ด๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด redirect()๋Š” try/catch ๋ธ”๋ก ๋ฐ”๊นฅ์—์„œ ํ˜ธ์ถœํ•ด์•ผ ํ•œ๋‹ค.

// app/lib/actions.ts

export async function updateInvoice(id: string, formData: FormData) {
  const { customerId, amount, status } = UpdateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });

  const amountInCents = amount * 100;

  try {
    await sql`
        UPDATE invoices
        SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
        WHERE id = ${id}
      `;
  } catch (error) {
    return { message: 'Database Error: Failed to Update Invoice.' };
  }

  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

 

error.tsx ํŒŒ์ผ์€ ๋ผ์šฐํŠธ ์„ธ๊ทธ๋จผํŠธ์˜ ์—๋Ÿฌ ๊ฒฝ๊ณ„๋ฅผ ์ •์˜ํ•œ๋‹ค. ์—๋Ÿฌ ๊ฒฝ๊ณ„๋Š” ํŠน์ • ์ปดํฌ๋„ŒํŠธ ๋˜๋Š” ์„ธ๊ทธ๋จผํŠธ์—์„œ ๋ฐœ์ƒํ•œ ์—๋Ÿฌ๋ฅผ ํฌ๊ด„์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๋ฉฐ, ์ƒ์œ„๋กœ ์—๋Ÿฌ๊ฐ€ ์ „ํŒŒ๋˜์ง€ ์•Š๋„๋ก ๋ง‰๋Š” ์—ญํ• ์„ ํ•œ๋‹ค.

// app/dashboard/invoices/error.tsx

"use client";

// ...

interface ErrorProps {
  /** ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ native Error object.
   * digest ์„ ํƒ์  ์†์„ฑ์€ ์—๋Ÿฌ์˜ ๊ณ ์œ  ์‹๋ณ„์ž๋ฅผ ์ œ๊ณตํ•˜์—ฌ ๋””๋ฒ„๊น…/๋กœ๊น…์— ํ™œ์šฉ๋œ๋‹ค.
   * */
  error: Error & { digest?: string };
  /** ์—๋Ÿฌ ๋ฐ”์šด๋”๋ฆฌ ๋ฆฌ์…‹ ํ•จ์ˆ˜. ํ•จ์ˆ˜ ํ˜ธ์ถœ์‹œ ๋ผ์šฐํŠธ ์„ธ๊ทธ๋จผํŠธ ๋ฆฌ๋ Œ๋”๋ง */
  reset: () => void;
}

export default function Error({ error, reset }: ErrorProps) {
  useEffect(() => {
    // Optionally log the error to an error reporting service
    console.error(error);
  }, [error]);

  return (
    <main className="flex h-full flex-col items-center justify-center">
      ...
    </main>
  );
}

 

๋งŒ์•ฝ invoices/[id]/edit/page.tsx์—์„œ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ๋™์ผ ํ˜น์€ ์ƒ์œ„ ์„ธ๊ทธ๋จผํŠธ์— ์ •์˜๋œ error.tsx๊ฐ€ ํ˜ธ์ถœ๋œ๋‹ค. ์—๋Ÿฌ๋Š” ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด error.tsx ํŒŒ์ผ์—์„œ ์ฒ˜๋ฆฌ๋˜๋ฏ€๋กœ ํŒŒ์ผ ๊ตฌ์กฐ์—์„œ์˜ ์œ„์น˜๊ฐ€ ์ค‘์š”ํ•˜๋‹ค.

๐Ÿ“ฆ app/
โ””โ”€ invoices/
   โ”œโ”€ [id]/
   โ”‚  โ””โ”€ edit/
   โ”‚     โ””โ”€ page.tsx
   โ”œโ”€ error.tsx
   โ””โ”€ page.tsx

 

ํ•œํŽธ Next.js์—์„œ ์ œ๊ณตํ•˜๋Š” notFound() ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด 404 ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋””๋ ‰์…˜ ์‹œํ‚ฌ ์ˆ˜ ์žˆ๋‹ค.

// app/dashboard/invoices/[id]/edit/page.tsx

// ...
import { notFound } from "next/navigation";

export default async function Page(props: { params: Promise<{ id: string }> }) {
  const params = await props.params;
  const id = params.id;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);

  if (!invoice) notFound();

  // ...
}

 

๋ณ„๋„์˜ not-found.tsx ํŒŒ์ผ์„ ๋งŒ๋“ค์–ด์„œ ์ปค์Šคํ…€ 404 ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•  ์ˆ˜๋„ ์žˆ๋‹ค. ๋งŒ์•ฝ page.tsx ์ปดํฌ๋„ŒํŠธ ๋‚ด๋ถ€์—์„œ notFound()๋ฅผ ํ˜ธ์ถœํ–ˆ๋‹ค๋ฉด ๋™์ผ ์„ธ๊ทธ๋จผํŠธ์— ์žˆ๋Š” not-found.tsx ํŒŒ์ผ์„ ๋ Œ๋”๋ง ํ•œ๋‹ค.

๐Ÿ“ฆ app/
โ””โ”€ invoices/
   โ”œโ”€ [id]/
   โ”‚  โ””โ”€ edit/
   โ”‚     โ”œโ”€ page.tsx
   โ”‚     โ””โ”€ not-found.tsx
   โ”œโ”€ error.ts
   โ””โ”€ page.tsx

 

 

Improving Accessibility


Client-Side validation

ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด๋“œ์—์„  HTML5 ํ‘œ์ค€์— ์ •์˜๋œ required ๊ฐ™์€ ์–ดํŠธ๋ฆฌ๋ทฐํŠธ๋ฅผ ํ†ตํ•ด์„œ ๊ธฐ๋ณธ์ ์ธ ํผ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ๋ฅผ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ๋‹ค.

<input
  id="amount"
  name="amount"
  type="number"
  placeholder="Enter USD amount"
  className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
  required
/>

 

ํ•„์ˆ˜ ์ž…๋ ฅ ํ•„๋“œ์— ์•„๋ฌด๊ฒƒ๋„ ์ž…๋ ฅํ•˜์ง€ ์•Š๊ณ  ์ œ์ถœํ–ˆ์„ ๋•Œ ๋‚˜์˜ค๋Š” ๋ฉ”์‹œ์ง€

ํ•˜์ง€๋งŒ ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด๋“œ์˜ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ๋ธŒ๋ผ์šฐ์ € ๊ฐœ๋ฐœ์ž ๋„๊ตฌ๋ฅผ ํ†ตํ•ด ๊ฒ€์‚ฌ๋ฅผ ๋น„ํ™œ์„ฑํ™”ํ•˜๊ฑฐ๋‚˜ ํผ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ€์กฐํ•  ์ˆ˜๋„ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๊ฒ€์ฆ์„ ์ด์šฉํ•˜๋ฉด ์ด๋Ÿฌํ•œ ๋ณด์•ˆ ๋ฌธ์ œ๋ฅผ ๋ฐฉ์ง€ํ•  ์ˆ˜ ์žˆ๋‹ค.

 

Server-Side validation

์„œ๋ฒ„์—์„œ ํผ์„ ๊ฒ€์ฆํ–ˆ์„ ๋•Œ์˜ ์žฅ์ 

  1. ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•˜๊ธฐ ์ „์— ์˜ˆ์ƒ๋œ ํ˜•์‹์ธ์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Œ
  2. ์•…์˜์ ์ธ ์‚ฌ์šฉ์ž๊ฐ€ ํด๋ผ์ด์–ธํŠธ ์ธก ๊ฒ€์ฆ์„ ์šฐํšŒํ•  ์œ„ํ—˜์„ ์ค„์ผ ์ˆ˜ ์žˆ์Œ
  3. ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ ๊ธฐ์ค€์„ ์„œ๋ฒ„์—์„œ ์ผ๊ด„์ ์œผ๋กœ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์Œ (SSOT)

 

useActionState ํ›…์€ ํผ ์•ก์…˜์˜ ๊ฒฐ๊ณผ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธํ•ด ์ฃผ๋Š” ํ›…์ด๋‹ค. ์•„์ง React 19 Canary ๋ฒ„์ „์—์„œ๋งŒ ์ง€์›ํ•˜๋Š” ์‹คํ—˜์ ์ธ ๊ธฐ๋Šฅ์ด์ง€๋งŒ Next.js 15์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

// app/lib/actions.ts

const FormSchema = z.object({
  id: z.string(),
  /** ํ•„๋“œ๊ฐ€ ๋ฌธ์ž์—ด์ด ์•„๋‹Œ ๊ฒฝ์šฐ invalid_type_error ์†์„ฑ ๊ฐ’์œผ๋กœ ์„ค์ •๋œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ๋ฐ˜ํ™˜ */
  customerId: z.string({
    invalid_type_error: "Please select a customer.",
  }),
  /**
   * z.coerce.<type>์€ ์ž…๋ ฅ ๊ฐ’์„ ์ง€์ •ํ•œ <type>์œผ๋กœ ๋ณ€ํ™˜ ์‹œ๋„.
   * ๋ณ€ํ™˜ํ•œ ํ›„ 0๋ณด๋‹ค ํฐ์ง€ ํ™•์ธ. ๊ฐ’์ด ๋น„์–ด์žˆ๋‹ค๋ฉด ๊ธฐ๋ณธ๊ฐ’ 0์œผ๋กœ ์„ค์ •.
   * */
  amount: z.coerce.number().gt(0, {
    message: "Please enter an amount greater than $0.",
  }),
  status: z.enum(["pending", "paid"], {
    invalid_type_error: "Please select an invoice status.",
  }),
  date: z.string(),
});

// FormSchema ์Šคํ‚ค๋งˆ์—์„œ id, date ํ•„๋“œ ์ œ์™ธ
const CreateInvoice = FormSchema.omit({ id: true, date: true });

export type State = {
  errors?: { customerId?: string[]; amount?: string[]; status?: string[] };
  message?: string | null;
};

// useActionState ํ›…์˜ ์ฝœ๋ฐฑ์€ prevState, formData๋ฅผ ์ธ์ž๋กœ ๋ฐ›๋Š”๋‹ค.
export async function createInvoice(prevState: State, formData: FormData) {
  /**
   * parse: ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒ€์ฆํ•˜๊ณ  ์œ ํšจํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ์˜ˆ์™ธ throw
   * safeParse: ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒ€์ฆํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ ๊ฐ์ฒด ํ˜•ํƒœ๋กœ ๋ฐ˜ํ™˜. ์˜ˆ์™ธ๋ฅผ throw ํ•˜์ง€ ์•Š์Œ
   * */
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get("customerId"),
    amount: formData.get("amount"),
    status: formData.get("status"),
  });

  // If form validation fails, return errors early. Otherwise, continue.
  // ์‹คํŒจ์‹œ: { success: false, error: [Getter] }
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: "Missing Fields. Failed to Create Invoice.",
    };
  }

  // ์„ฑ๊ณต์‹œ: { success: true, data: { customerId: '...', amount: '...', ... } }
  // ...
}

 

useActionState ๋ฐ˜ํ™˜๊ฐ’์ธ formAction ํ•จ์ˆ˜๋ฅผ <form> ํƒœ๊ทธ action ์–ดํŠธ๋ฆฌ๋ทฐํŠธ์— ํ• ๋‹นํ•˜๋ฉด, ์‚ฌ์šฉ์ž ํผ ์ œ์ถœ ์‹œ ์•„๋ž˜ ๊ณผ์ •์œผ๋กœ ์œ ํšจ์„ฑ ๊ฒ€์ฆ์ด ์ง„ํ–‰๋œ๋‹ค.

 

โ‘ ํด๋ผ์ด์–ธํŠธ์—์„œ ํผ ์ œ์ถœ/์„œ๋ฒ„๋กœ ๋ฐ์ดํ„ฐ ์ „์†ก → โ‘กcreateInvoice ์„œ๋ฒ„ ์•ก์…˜ ์‹คํ–‰ → โ‘ข์„œ๋ฒ„์—์„œ ํผ ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ → โ‘ฃ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๊ฒฐ๊ณผ๋ฅผ ํฌํ•จํ•œ formState ๋ฐ˜ํ™˜ → โ‘ค๋ฐ˜ํ™˜ํ•œ ์ƒํƒœ๋ฅผ ํด๋ผ์ด์–ธํŠธ state์— ๋ฐ˜์˜

// app/ui/invoices/create-form.tsx

import { createInvoice, State } from "@/app/lib/actions";

// useActionState ํ›…์„ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ๋กœ ๋ณ€๊ฒฝ
("use client");

export default function Form({ customers }: { customers: CustomerField[] }) {
  const initialState: State = { message: null, errors: {} };
  const [state, formAction] = useActionState(createInvoice, initialState);

  return (
    <form action={formAction}>
      <div className="rounded-md bg-gray-50 p-4 md:p-6">
        <div className="mb-4">
          <label htmlFor="customer" className="mb-2 block text-sm font-medium">
            Choose customer
          </label>
          <div className="relative">
            <select
              id="customer"
              name="customerId"
              className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
              defaultValue=""
              aria-describedby="customer-error"
            >
              <option value="" disabled>
                Select a customer
              </option>
              {customers.map((customer) => (
                <option key={customer.id} value={customer.id}>
                  {customer.name}
                </option>
              ))}
            </select>
          </div>
          <div id="customer-error" aria-live="polite" aria-atomic="true">
            {state.errors?.customerId?.map((error: string) => (
              <p className="mt-2 text-sm text-red-500" key={error}>
                {error}
              </p>
            ))}
          </div>
        </div>

        {/* ... */}
      </div>
    </form>
  );
}

 

  • aria-live ์†์„ฑ: ์ฝ˜ํ…์ธ ๊ฐ€ ๋™์ ์œผ๋กœ ๋ณ€๊ฒฝ๋  ๋•Œ ์Šคํฌ๋ฆฐ ๋ฆฌ๋”๊ฐ€ ์‚ฌ์šฉ์ž์—๊ฒŒ ์•Œ๋ฆด์ง€ ์—ฌ๋ถ€. ๋ณ€๊ฒฝ๋œ ๋‚ด์šฉ์ด ์‚ฌ์šฉ์ž ์ž‘์—…์„ ๋ฐฉํ•ดํ•˜์ง€ ์•Š์„ ๋•Œ ์ฝ์–ด์ฃผ๋„๋ก ํ•˜๋ ค๋ฉด polite๋กœ ์„ค์ •.
  • aria-atomic ์†์„ฑ: ์ฝ˜ํ…์ธ  ์—…๋ฐ์ดํŠธ ์‹œ ์Šคํฌ๋ฆฐ ๋ฆฌ๋”๊ฐ€ ๋ณ€๊ฒฝ๋œ ๋ถ€๋ถ„๋งŒ ์ฝ์„์ง€, ์ „์ฒด ๋‚ด์šฉ์„ ์ฝ์„์ง€ ์„ค์ •. true ์„ค์ • ์‹œ ์ „์ฒด ์š”์†Œ์˜ ๋‚ด์šฉ ์ฝ์Œ.
  • aria-describedby ์†์„ฑ: ์Šคํฌ๋ฆฐ ๋ฆฌ๋”๊ฐ€ ์—ฐ๊ฒฐ๋œ ์š”์†Œ์˜ ์ถ”๊ฐ€ ์„ค๋ช…์„ ์ฝ์„ ์ˆ˜ ์žˆ๋„๋ก ์ง€์ •๋œ id๋ฅผ ๊ฐ€์ง„ ํƒœ๊ทธ์˜ ์ฝ˜ํ…์ธ  ์ฐธ์กฐ.
  • fieldset ํƒœ๊ทธ: ํผ ๋‚ด ์—ฌ๋Ÿฌ ์š”์†Œ๋ฅผ ๊ทธ๋ฃนํ™”ํ•˜์—ฌ ๋…ผ๋ฆฌ์ ์œผ๋กœ ๋ฌถ๋Š” ์ปจํ…Œ์ด๋„ˆ ์—ญํ• 
  • legend ํƒœ๊ทธ: fieldset์˜ ์ œ๋ชฉ์ด๋‚˜ ์„ค๋ช…

 

 

Adding Authentication


NextAuth ๊ธฐ๋ณธ ์„ค์ •

NextAuth.js๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์„ธ์…˜ ๊ด€๋ฆฌ, ๋กœ๊ทธ์ธ/๋กœ๊ทธ์•„์›ƒ ๋“ฑ ์ธ์ฆ ๊ด€๋ จ ๊ธฐ๋Šฅ์˜ ๋ณต์žก์„ฑ์„ ๋Œ€๋ถ€๋ถ„ ์ถ”์ƒํ™”ํ•˜์—ฌ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค. ์ด๋Ÿฌํ•œ ๊ธฐ๋Šฅ๋“ค์€ ์ˆ˜๋™์œผ๋กœ ๊ตฌํ˜„ํ•  ์ˆ˜๋„ ์žˆ์ง€๋งŒ, ์‹œ๊ฐ„์ด ๋งŽ์ด ๊ฑธ๋ฆฌ๊ณ  ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๊ธฐ ์‰ฝ๋‹ค. NextAuth.js๋Š” ์ด๋Ÿฌํ•œ ๊ณผ์ •์„ ๊ฐ„์†Œํ™”ํ•˜์—ฌ Next.js ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ธ์ฆ์„ ์œ„ํ•œ ํ†ตํ•ฉ ์†”๋ฃจ์…˜์„ ์ œ๊ณตํ•œ๋‹ค. NextAuth๋ฅผ ์‚ฌ์šฉํ•ด ์•„์ด๋””, ํŒจ์Šค์›Œ๋“œ ์ธ์ฆ์„ ๊ตฌํ˜„ํ•ด ๋ณด์ž.

 

โ‘  next-auth ์„ค์น˜

pnpm i next-auth@beta

 

โ‘ก secret key ์ƒ์„ฑ

# ๋ฐฉ๋ฒ• 1
openssl rand -base64 32
# ๋ฐฉ๋ฒ• 2
npx auth secret

 

โ‘ข .env ํŒŒ์ผ๋‚ด AUTH_SECRET ๋ณ€์ˆ˜์— ์‹œํฌ๋ฆฟํ‚ค ํ• ๋‹น

# JWT ํ‚ค๋ฅผ ์„œ๋ช…ํ•˜๊ฑฐ๋‚˜ ๊ฒ€์ฆํ•  ๋•Œ ์‚ฌ์šฉํ•˜๋Š” ํ‚ค
AUTH_SECRET=your-secret-key

 

โ‘ฃ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์— auth.config.ts ํŒŒ์ผ ์ƒ์„ฑ ํ›„ ์ปค์Šคํ…€ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€, ์„ธ์…˜ ์ด๋ฒคํŠธ์— ๋Œ€ํ•œ ์ปค์Šคํ…€ ๋กœ์ง ์ •์˜

// auth.config.ts

import { NextAuthConfig } from "next-auth";

export const authConfig = {
  // ๋กœ๊ทธ์ธ ๋ฐฉ์‹ ์ •์˜ (OAuth, Credentials ๋“ฑ)
  providers: [],
  pages: {
    // NextAuth.js์—์„œ ์ œ๊ณตํ•˜๋Š” ๊ธฐ๋ณธ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€ ๋Œ€์‹  ์ปค์Šคํ…€ ๊ฒฝ๋กœ /login ์‚ฌ์šฉ
    signIn: "/login",
  },
  // ์ธ์ฆ ๋ฐ ์„ธ์…˜ ์ด๋ฒคํŠธ์— ๋Œ€ํ•œ ์‚ฌ์šฉ์ž ์ •์˜ ์ฝœ๋ฐฑ
  callbacks: {
    // ์‚ฌ์šฉ์ž ์ ‘๊ทผ ๊ถŒํ•œ์„ ์ œ์–ดํ•˜๋Š” authorized ํ•จ์ˆ˜
    // auth๋Š” ์‚ฌ์šฉ์ž ์„ธ์…˜ ์ •๋ณด๋ฅผ, request๋Š” ์š”์ฒญ ๋ฐ์ดํ„ฐ(URL ๋“ฑ)๋ฅผ ํฌํ•จํ•จ
    authorized({ auth, request: { nextUrl } }) {
      // auth.user๋Š” ํ˜„์žฌ ์„ธ์…˜์˜ ์‚ฌ์šฉ์ž ์ •๋ณด ๋‚˜ํƒ€๋ƒ„
      // undefined์ผ ๊ฒฝ์šฐ, ์„ธ์…˜์ด ์—†๊ฑฐ๋‚˜ ๋งŒ๋ฃŒ๋œ ์ƒํƒœ๋กœ ๊ฐ„์ฃผ(๋กœ๊ทธ์•„์›ƒ)
      const isLoggedIn = !!auth?.user;
      const isOnDashboard = nextUrl.pathname.startsWith("/dashboard");

      // ๋Œ€์‹œ๋ณด๋“œ ํŽ˜์ด์ง€๋กœ ์ ‘๊ทผํ–ˆ๊ณ , ๋กœ๊ทธ์ธ ์ƒํƒœ๋ฉด ์ ‘๊ทผ ํ—ˆ์šฉ
      if (isOnDashboard) return isLoggedIn;
      // ๋Œ€์‹œ๋ณด๋“œ ํŽ˜์ด์ง€๊ฐ€ ์•„๋‹ˆ๊ณ , ๋กœ๊ทธ์ธ ์ƒํƒœ๋ฉด ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ
      else if (isLoggedIn) {
        return Response.redirect(new URL("/dashboard", nextUrl));
      }

      // true ๋ฐ˜ํ™˜์‹œ ํ•ด๋‹น ์š”์ฒญ์ด ์ธ์ฆ๋˜์—ˆ์Œ์„ ์˜๋ฏธ
      // false ๋ฐ˜ํ™˜ ์‹œ ๊ธฐ๋ณธ์ ์œผ๋กœ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ
      return true;
    },
    // ์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์‹œ๋„์‹œ ํ˜ธ์ถœ. true ๋ฐ˜ํ™˜์‹œ ๋กœ๊ทธ์ธ ์„ฑ๊ณต false ๋ฐ˜ํ™˜์‹œ ๋กœ๊ทธ์ธ ์‹คํŒจ
    signIn: async () => {
      // ...
      return true;
    },
    // JWT๊ฐ€ ์ƒ์„ฑ๋˜๊ฑฐ๋‚˜ ์—…๋ฐ์ดํŠธ๋  ๋•Œ ํ˜ธ์ถœ. ๋ฐ˜ํ™˜ ๊ฐ’์€ ์ฟ ํ‚ค์— ์ €์žฅ๋จ
    jwt: async ({ token, user }) => {
      // ...
      return token;
    },
    // jwt ์ฝœ๋ฐฑ์ด ๋ฐ˜ํ™˜ํ•˜๋Š” token์„ ๋ฐ›์•„ ์„ธ์…˜์ด ํ™•์ธ๋  ๋•Œ๋งˆ๋‹ค ํ˜ธ์ถœ๋จ. 
    // ๋ฐ˜ํ™˜ํ•˜๋Š” ๊ฐ’์€ ํด๋ผ์ด์–ธํŠธ์—์„œ ํ™•์ธ ๊ฐ€๋Šฅ (2๋ฒˆ ์ด์ƒ ํ˜ธ์ถœ๋  ์ˆ˜ ์žˆ์Œ)
    session: async ({ session, token }) => {
      // ...
      return session;
    },
  },
} satisfies NextAuthConfig;

 

๊ฐ ์ฝœ๋ฐฑ์˜ ํ˜ธ์ถœ ์ˆœ์„œ๋Š” ์•„๋ž˜์™€ ๊ฐ™๋‹ค. ์ฐธ๊ณ ๋กœ ์„ธ์…˜์€ ํด๋ผ์ด์–ธํŠธ์™€ ์„œ๋ฒ„ ๊ฐ„ ์ƒํƒœ๋ฅผ ์œ ์ง€ํ•˜๊ธฐ ์œ„ํ•œ ๊ธฐ์ˆ . ์„œ๋ฒ„์— ์ €์žฅ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž์˜ ์ธ์ฆ ์ƒํƒœ๋‚˜ ์ •๋ณด๋ฅผ ๊ด€๋ฆฌํ•œ๋‹ค. ์ฃผ๋กœ ์„ธ์…˜ ID๋ฅผ ํ†ตํ•ด ํด๋ผ์ด์–ธํŠธ๋ฅผ ์‹๋ณ„ํ•˜๋ฉฐ, ๋กœ๊ทธ์ธ ์œ ์ง€๋‚˜ ๊ถŒํ•œ ํ™•์ธ ๋“ฑ์— ์‚ฌ์šฉํ•œ๋‹ค.

  • ์‚ฌ์šฉ์ž ๋กœ๊ทธ์ธ/ํšŒ์›๊ฐ€์ž…: signIn → (redirect) → jwt → session
  • ์„ธ์…˜ ์—…๋ฐ์ดํŠธ: jwt → session
  • ์„ธ์…˜ ํ™•์ธ: session

 

โ‘ค ๋ฏธ๋“ค์›จ์–ด ํŒŒ์ผ ์ •์˜. ๋ฏธ๋“ค์›จ์–ด๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž๋งŒ ๋ณดํ˜ธ๋œ ๊ฒฝ๋กœ์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์–ด์„œ ๋ณด์•ˆ์„ ๊ฐ•ํ™”ํ•  ์ˆ˜ ์žˆ๊ณ , ์„œ๋ฒ„ ์ธก์—์„œ ์š”์ฒญ์„ ๋ฏธ๋ฆฌ ์ฐจ๋‹จํ•˜๋ฏ€๋กœ ๋ถˆํ•„์š”ํ•œ ํด๋ผ์ด์–ธํŠธ ๋ Œ๋”๋ง์„ ๋ฐฉ์ง€ํ•  ์ˆ˜ ์žˆ๋‹ค.

// middleware.ts

import NextAuth from "next-auth";
import { authConfig } from "./auth.config";

// NextAuth์— ์ •์˜ํ•ด๋‘” authConfig๋ฅผ ์ „๋‹ฌํ•˜์—ฌ ์ธ์ฆ ์„ค์ • ์ ์šฉ
export default NextAuth(authConfig).auth;

// Next.js์˜ Middleware ์„ค์ •์„ ์œ„ํ•œ config ๊ฐ์ฒด
// matcher ์˜ต์…˜์„ ์ ์šฉํ•˜์—ฌ ํŠน์ • ๊ฒฝ๋กœ์—๋งŒ ๋ฏธ๋“ค์›จ์–ด ์ ์šฉ
export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};

 

๋ฏธ๋“ค์›จ์–ด๋Š” ํด๋ผ์ด์–ธํŠธ ์š”์ฒญ๊ณผ ์„œ๋ฒ„ ์‘๋‹ต ์‚ฌ์ด์—์„œ ์‹คํ–‰๋˜์–ด ๋ฐ์ดํ„ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ฑฐ๋‚˜ ์ˆ˜์ •ํ•˜๋Š” ํ•จ์ˆ˜๋‹ค. ์ฃผ๋กœ ์ธ์ฆ, ์š”์ฒญ ๊ฒ€์ฆ, ๋กœ๊น… ๊ฐ™์€ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜๋ฉฐ, ์š”์ฒญ์„ ๋‹ค์Œ ์ฒ˜๋ฆฌ ๋‹จ๊ณ„๋กœ ์ „๋‹ฌํ•˜๊ฑฐ๋‚˜ ํŠน์ • ์กฐ๊ฑด์— ๋”ฐ๋ผ ์š”์ฒญ์„ ์ฐจ๋‹จํ•  ์ˆ˜ ์žˆ๋‹ค.

 

Password hashing

ํ•ด์‹ฑ์€ ํŠน์ • ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ํ†ตํ•ด ๋ฐ์ดํ„ฐ๋ฅผ ๊ณ ์ • ๊ธธ์ด ๋ฌธ์ž์—ด(ํ•ด์‹œ ๊ฐ’)๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ๊ณผ์ •์„ ๋งํ•œ๋‹ค. ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅํ•˜๊ธฐ ์ „์— ํ•ด์‹ฑ์„ ์ ์šฉํ•˜๋ฉด, ๋ฐ์ดํ„ฐ๊ฐ€ ์œ ์ถœ๋˜๋”๋ผ๋„ ์›๋ž˜ ๊ฐ’์„ ๋ณต์›ํ•˜๊ธฐ ์–ด๋ ต๊ธฐ ๋•Œ๋ฌธ์— ๋ณด์•ˆ์ด ๊ฐ•ํ™”๋œ๋‹ค(์ผ๋ฐ˜์ ์œผ๋กœ ๋ฌด์ž‘์œ„ ๊ฐ’์ธ ์†”ํŠธ๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ํ•ด์‹ฑํ•œ๋‹ค).

 

๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹ฑ์—๋Š” bcrypt ๋ผ๋Š” ํŒจํ‚ค์ง€๋ฅผ ์ž์ฃผ ์‚ฌ์šฉํ•œ๋‹ค. ํ•˜์ง€๋งŒ bcrypt๋Š” Node.js API๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ๋•Œ๋ฌธ์— ๋ธŒ๋ผ์šฐ์ € ํ™˜๊ฒฝ์ด๋‚˜ Next.js ๋ฏธ๋“ค์›จ์–ด ํ™˜๊ฒฝ์—์„œ๋Š” ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋‹ค. ์ด๋Ÿฌํ•œ ์ œํ•œ์„ ํ•ด๊ฒฐํ•˜๋ ค๋ฉด ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ ๋ฐ ํ•ด์‹ฑ ๋กœ์ง์€ ์„œ๋ฒ„ ํ™˜๊ฒฝ์—์„œ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•œ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด ์„œ๋ฒ„ ํ™˜๊ฒฝ์ธ auth.ts ํŒŒ์ผ์„ ๋ณ„๋„๋กœ ๋งŒ๋“ค์–ด์„œ ๊ธฐ์กด authConfig ์ธ์ฆ ์„ค์ • ํ™•์žฅํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค.

// auth.ts

import NextAuth from "next-auth";
import { authConfig } from "./auth.config";

export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  // ...
});

 

Next.js ๋ฏธ๋“ค์›จ์–ด๋Š” Vercel Edge Runtime ์œ„์—์„œ ์‹คํ–‰๋œ๋‹ค. ์ด ๋Ÿฐํƒ€์ž„์€ V8 JavaScript ์—”์ง„ ๊ธฐ๋ฐ˜์œผ๋กœ ๋™์ž‘ํ•˜๋ฉฐ, Web APIs๋ฅผ ์ง€์›ํ•˜์ง€๋งŒ Node.js API๋Š” ์ง€์›ํ•˜์ง€ ์•Š๋Š”๋‹ค.

 

Adding the sign in functionality

ID, Password ๊ธฐ๋ฐ˜ ์ธ์ฆ์€ Credential ํ”„๋กœ๋ฐ”์ด๋”๋ฅผ ์‚ฌ์šฉํ•˜๋ฉฐ, Credential์˜ authorize ๋ฉ”์„œ๋“œ์—์„œ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ „๋‹ฌํ•œ ์ธ์ฆ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒ€์ฆํ•œ๋‹ค. ๊ฒ€์ฆ์— ์„ฑ๊ณตํ•˜๋ฉด ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ณ , ์‹คํŒจํ•˜๋ฉด null์„ ๋ฐ˜ํ™˜ํ•˜์—ฌ ์ธ์ฆ ์‹คํŒจ๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค.

// auth.ts

// ...
import NextAuth from "next-auth";
import { authConfig } from "./auth.config";
import Credential from "next-auth/providers/credentials";
import bcrypt from "bcrypt";
import {sql} from "@vercel/postgres";

async function getUser(email: string): Promise<User | undefined> {
  try {
    // ์ด๋ฉ”์ผ ๊ธฐ์ค€์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ
    const user = await sql<User>`
      SELECT * 
      FROM users 
      WHERE email = ${email}
    `;

    return user.rows[0]; // ์ฒซ ๋ฒˆ์งธ ํ–‰(์‚ฌ์šฉ์ž ์ •๋ณด) ๋ฐ˜ํ™˜
  } catch (error) {
    console.error("Failed to fetch user:", error);
    throw new Error("Failed to fetch user.");
  }
}

// NextAuth ์„ค์ • ๊ฐ์ฒด์—์„œ auth, signIn, signOut ์ถ”์ถœ
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig, // ๊ธฐ์กด ์ธ์ฆ ์„ค์ • ๋ณ‘ํ•ฉ
  // ID, Password ๊ธฐ๋ฐ˜ ์ธ์ฆ์—๋Š” Credential ์‚ฌ์šฉ
  providers: [
    Credential({
      // ์‚ฌ์šฉ์ž ์ž…๋ ฅ๊ฐ’์„ ๊ฒ€์ฆํ•˜๊ณ  ์ธ์ฆ์„ ์ฒ˜๋ฆฌํ•˜๋Š” authorize ๋ฉ”์„œ๋“œ, null ๋ฐ˜ํ™˜ ์‹œ ์ธ์ฆ ์‹คํŒจ๋กœ ์ฒ˜๋ฆฌ๋จ
      async authorize(credentials) {
        // ํด๋ผ์ด์–ธํŠธ์—์„œ ์ „๋‹ฌ๋œ ์ธ์ฆ ๋ฐ์ดํ„ฐ(credentials) ๊ฒ€์ฆ
        const parsedCredentials = z
          .object({ email: z.string().email(), password: z.string().min(6) })
          .safeParse(credentials);

        if (parsedCredentials.success) {
          const { email, password } = parsedCredentials.data;
          // ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์‚ฌ์šฉ์ž ์กฐํšŒ
          const user = await getUser(email);
          if (!user) return null;
          // ์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ๋œ ํ•ด์‹œ๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋น„๊ต
          const passwordsMatch = await bcrypt.compare(password, user.password);
          // ๋น„๋ฐ€๋ฒˆํ˜ธ ์ผ์น˜ํ•˜๋ฉด ์‚ฌ์šฉ์ž ๊ฐ์ฒด ๋ฐ˜ํ™˜
          if (passwordsMatch) return user;
        }

        console.log("Invalid credentials");
        return null; // null ๋ฐ˜ํ™˜ ์‹œ ์ธ์ฆ ์‹คํŒจ ์ฒ˜๋ฆฌ
      },
    }),
  ],
});

 

Updating the login form

actions.ts ํŒŒ์ผ์— authenticate ๋ผ๋Š” ์ด๋ฆ„์˜ ์„œ๋ฒ„ ์•ก์…˜์„ ์ถ”๊ฐ€ํ•œ๋‹ค. ์ด ์•ก์…˜์€ useActionState ํ›…์˜ ์ฒซ ๋ฒˆ์งธ ์ธ์ž(์•ก์…˜ ํ•จ์ˆ˜)๋กœ ์ „๋‹ฌ๋˜๋ฏ€๋กœ, ํ•จ์ˆ˜ ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๊ณ ๋ คํ•ด์„œ ์ž‘์„ฑํ•œ๋‹ค(์ฒซ ๋ฒˆ์งธ ์ธ์ž๋Š” ์ด์ „ ๋˜๋Š” ์ดˆ๊ธฐ ์ƒํƒœ, ๋‘ ๋ฒˆ์งธ ์ธ์ž๋Š” ํผ ์ œ์ถœ ์‹œ ์ „๋‹ฌ๋˜๋Š” ๋ฐ์ดํ„ฐ).

// app/lib/actions.ts

"use server";

import { signIn } from "@/auth";
import { AuthError } from "next-auth";
// ...

export async function authenticate(
  prevState: string | undefined,
  formData: FormData, // ํผ ๋ฐ์ดํ„ฐ๋กœ ์ „๋‹ฌ๋œ ์‚ฌ์šฉ์ž ์ž…๋ ฅ ๊ฐ’
) {
  try {
    await signIn("credentials", formData);
  } catch (error) {
    // ์ธ์ฆ ๊ด€๋ จ ์—๋Ÿฌ ์ฒ˜๋ฆฌ
    if (error instanceof AuthError) {
      switch (error.type) {
        case "CredentialsSignin": // ์ž๊ฒฉ ์ฆ๋ช… ์—๋Ÿฌ
          return "Invalid credentials.";
        default:
          return "Something went wrong."; // ๊ธฐํƒ€ ์ธ์ฆ ๊ด€๋ จ ์—๋Ÿฌ
      }
    }

    throw error; // ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์—๋Ÿฌ
  }
}

 

๋กœ๊ทธ์ธ ํผ ์ปดํฌ๋„ŒํŠธ์—์„  useActionState ํ›…์—์„œ ๋ฐ˜ํ™˜๋œ formAction์„ <form> ์š”์†Œ์˜ action ์†์„ฑ์— ํ• ๋‹นํ•˜์—ฌ ํผ ์ œ์ถœ ์‹œ ๋น„๋™๊ธฐ ์ธ์ฆ ์ž‘์—…์ด ์‹คํ–‰๋˜๋„๋ก ํ•œ๋‹ค. authenticate ์•ก์…˜ ํ•จ์ˆ˜๋Š” ์ธ์ฆ ์ž‘์—… ๊ฒฐ๊ณผ๋กœ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š”๋ฐ, ์ด๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ํ•„๋“œ์— ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€๋ฅผ ๋™์ ์œผ๋กœ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋‹ค.

// app/ui/login-form.tsx

"use client";

// ...
import { authenticate } from "@/app/lib/actions";
import { useActionState } from "react";

export default function LoginForm() {
  // isPending ๋ฐ˜ํ™˜๊ฐ’: ๋น„๋™๊ธฐ ์ž‘์—…์ด ์ง„ํ–‰ ์ค‘์ธ์ง€ ์—ฌ๋ถ€๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” ๋ถˆ๋ฆฌ์–ธ ๊ฐ’
  // true๋ผ๋ฉด authenticate ์ž‘์—…์ด ์‹คํ–‰ ์ค‘์ด๊ณ , ์•„์ง ์™„๋ฃŒ๋˜์ง€ ์•Š์•˜๋‹ค๋Š” ์˜๋ฏธ
  const [errorMessage, formAction, isPending] = useActionState(
    authenticate,
    undefined,
  );

  return (
    <form className="space-y-3" action={formAction}>
      <div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
        <h1 className={`${lusitana.className} mb-3 text-2xl`}>
          Please log in to continue.
        </h1>
        <div className="w-full">{/* ... */}</div>
        {/* aria-disabled: ์š”์†Œ๊ฐ€ ๋น„ํ™œ์„ฑํ™” ์ƒํƒœ์ž„์„ ํ™”๋ฉด ๋ฆฌ๋”๊ธฐ์— ์•Œ๋ฆฌ๋Š” ์†์„ฑ */}
        <Button className="mt-4 w-full" aria-disabled={isPending}>
          Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
        </Button>
        <div
          className="flex h-8 items-end space-x-1"
          aria-live="polite"
          aria-atomic="true"
        >
          {errorMessage && (
            <>
              <ExclamationCircleIcon className="h-5 w-5 text-red-500" />
              <p className="text-sm text-red-500">{errorMessage}</p>
            </>
          )}
        </div>
      </div>
    </form>
  );
}

 

๐Ÿ’ก aria-disabled: ํ•ด๋‹น ์š”์†Œ๊ฐ€ ์ƒํ˜ธ์ž‘์šฉํ•  ์ˆ˜ ์—†๋Š”(๋น„ํ™œ์„ฑ) ์ƒํƒœ ์ž„์„ ๋‚˜ํƒ€๋‚ด๋Š” ARIA ์†์„ฑ. true์ด๋ฉด ์š”์†Œ๊ฐ€ ๋น„ํ™œ์„ฑํ™” ์ƒํƒœ์ž„์„ ์˜๋ฏธํ•จ.

 

Adding the logout functionality

auth.ts ํŒŒ์ผ์—์„œ ๋ฐ˜ํ™˜ํ•œ signOut() ํ•จ์ˆ˜๋ฅผ ๋ถˆ๋Ÿฌ์˜จ ํ›„ ํ˜ธ์ถœํ•˜๋ฉด ๋กœ๊ทธ์•„์›ƒ์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค. ์ฐธ๊ณ ๋กœ ์„œ๋ฒ„ ์•ก์…˜์„ ์ง€์ •ํ•˜๋Š” 'use server' ์ง€์‹œ์–ด๋ฅผ ํŒŒ์ผ ๊ฐ€์žฅ ์ƒ๋‹จ์— ์„ ์–ธํ•˜๋ฉด ํ•ด๋‹น ํŒŒ์ผ์˜ ๋ชจ๋“  export๋œ ํ•จ์ˆ˜๊ฐ€ ์„œ๋ฒ„ ์•ก์…˜์œผ๋กœ ์ง€์ •๋˜๊ณ , ํ•จ์ˆ˜ ๋‚ด๋ถ€์— ์„ ์–ธํ•˜๋ฉด ํ•ด๋‹น ํ•จ์ˆ˜๋งŒ ์„œ๋ฒ„ ์•ก์…˜์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค.

// app/ui/dashboard/sidenav.tsx

// ...
import { signOut } from "@/auth";

export default function SideNav() {
  return (
    <div className="flex h-full flex-col px-3 py-4 md:px-2">
      {/* ... */}
      <div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
        <NavLinks />
        <div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
        <form
          action={async () => {
            "use server";
            await signOut();
          }}
        >
          <button className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
            <PowerIcon className="w-6" />
            <div className="hidden md:block">Sign Out</div>
          </button>
        </form>
      </div>
    </div>
  );
}

 

 

Adding Metadata


๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋Š” ์›นํŽ˜์ด์ง€์— ๋Œ€ํ•œ ์ถ”๊ฐ€ ์ •๋ณด๋กœ, ์ฃผ๋กœ HTML์˜ <head> ์š”์†Œ์— ํฌํ•จ๋œ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ํŽ˜์ด์ง€๋ฅผ ๋ฐฉ๋ฌธํ•  ๋•Œ๋Š” ๋ณด์ด์ง€ ์•Š์ง€๋งŒ ๊ฒ€์ƒ‰ ์—”์ง„๊ณผ ์†Œ์…œ ๋ฏธ๋””์–ด ํ”Œ๋žซํผ์ด ์›นํŽ˜์ด์ง€๋ฅผ ๋” ์‰ฝ๊ฒŒ ์ ‘๊ทผํ•˜๊ณ  ์ดํ•ดํ•  ์ˆ˜ ์žˆ๋„๋ก ๋•๋Š”๋‹ค. ์ ์ ˆํ•œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋Š” ๊ฒ€์ƒ‰ ์—”์ง„์ด ์›นํŽ˜์ด์ง€๋ฅผ ํšจ๊ณผ์ ์œผ๋กœ ์ƒ‰์ธํ•˜๊ณ , ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ๋” ๋†’์€ ์ˆœ์œ„์— ์œ„์น˜ํ•  ์ˆ˜ ์žˆ๋„๋ก ์ง€์›ํ•œ๋‹ค.

 

Types of metadata

์ž์ฃผ ์‚ฌ์šฉํ•˜๋Š” ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ข…๋ฅ˜๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค:

 

โ‘  Title Metadata: ๋ธŒ๋ผ์šฐ์ € ํƒญ๊ณผ ๊ฒ€์ƒ‰ ์—”์ง„ ๊ฒฐ๊ณผ์— ํ‘œ์‹œ๋  ํŽ˜์ด์ง€ ์ œ๋ชฉ

<title>Page Title</title>

 

โ‘ก Description Metadata: ๊ฒ€์ƒ‰ ์—”์ง„์—์„œ ํŽ˜์ด์ง€์— ๋Œ€ํ•œ ์„ค๋ช…์œผ๋กœ ํ‘œ์‹œ๋  ๋‚ด์šฉ

<meta name="description" content="A brief description of the page content." />

 

โ‘ข Keyword Metadata: ํŽ˜์ด์ง€์™€ ๊ด€๋ จ๋œ ํ‚ค์›Œ๋“œ. ๊ฒ€์ƒ‰ ์—”์ง„์—์„œ ํŽ˜์ด์ง€ ๊ด€๋ จ์„ฑ์„ ๋†’์ด๋Š”๋ฐ ์‚ฌ์šฉ๋˜๋Š” ํ‚ค์›Œ๋“œ

<meta name="keywords" content="keyword1, keyword2, keyword3" />

 

โ‘ฃ Open Graph Metadata: ์†Œ์…œ ๋ฏธ๋””์–ด์— ๊ณต์œ ํ•  ๋•Œ ํ‘œ์‹œ๋  ์ฝ˜ํ…์ธ 

<meta property="og:title" content="Title Here" />
<meta property="og:description" content="Description Here" />
<meta property="og:image" content="image_url_here" />

 

โ‘ค Favicon Metadata: ๋ธŒ๋ผ์šฐ์ € ์ฃผ์†Œ ํ‘œ์‹œ์ค„ ํ˜น์€ ํƒญ์— ํ‘œ์‹œ๋˜๋Š” ์•„์ด์ฝ˜(ํŒŒ๋น„์ฝ˜)

<link rel="icon" href="path/to/favicon.ico" />

 

Adding metadata

Next.js์—์„  ์•„๋ž˜ 2๊ฐ€์ง€ ๋ฐฉ๋ฒ•์œผ๋กœ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, Next.js๋Š” ์ด๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ด€๋ จ๋œ <head> ์š”์†Œ๋ฅผ ์ž๋™์œผ๋กœ ์ƒ์„ฑํ•œ๋‹ค.

 

  1. Config-based: ์ฝ”๋“œ ๊ธฐ๋ฐ˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(layout.js ๋˜๋Š” page.js ํŒŒ์ผ์— ์ •์˜)
    1. Static Metadata Object: ์ •์  ๊ฐ’์œผ๋กœ ๊ตฌ์„ฑ๋œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ์ฒด ํ˜•ํƒœ๋กœ ์ƒ์„ฑ
    2. Dynamic generateMetadata Function: ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ๋™์ ์œผ๋กœ ์ƒ์„ฑ
  2. File-based: ํŒŒ์ผ ๊ธฐ๋ฐ˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ
    • favicon.ico, apple-icon.jpg, icon.jpg: ๋ธŒ๋ผ์šฐ์ € ๋ฐ ๋””๋ฐ”์ด์Šค ์•„์ด์ฝ˜
    • opengraph-image.jpg, twitter-image.jpg: ์†Œ์…œ ๋ฏธ๋””์–ด ๊ณต์œ  ์‹œ ํ‘œ์‹œ๋˜๋Š” ์ด๋ฏธ์ง€
    • robots.txt: ๊ฒ€์ƒ‰ ์—”์ง„ ํฌ๋กค๋Ÿฌ์˜ ํฌ๋กค๋ง ํ—ˆ์šฉ ๋ฐ ์ฐจ๋‹จ ๊ทœ์น™
    • sitemap.xml: ์›น์‚ฌ์ดํŠธ ๊ตฌ์กฐ ์ •๋ณด

 

Favicon / Open Graph Image

favicon.ico, opengraph-image.jpg ํŒŒ์ผ์„ /app ํด๋”์— ์œ„์น˜์‹œํ‚ค๋ฉด Next.js๊ฐ€ ์ด๋ฅผ ์ž๋™์œผ๋กœ ์ธ์‹ํ•˜๊ณ  ๊ฐ๊ฐ ํŒŒ๋น„์ฝ˜๊ณผ OG ์ด๋ฏธ์ง€๋กœ ์‚ฌ์šฉ๋œ๋‹ค. ImageResponse ์ƒ์„ฑ์ž๋ฅผ ์ด์šฉํ•ด์„œ ๋‹ค์ด๋‚˜๋ฏน OG ์ด๋ฏธ์ง€๋ฅผ ๋งŒ๋“ค ์ˆ˜๋„ ์žˆ๋‹ค.

 

Page title and descriptions

๐Ÿ’ก ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๊ฐ์ฒด๋ฅผ export ํ•ด์ค˜์•ผ ํŽ˜์ด์ง€๋‚˜ ๋ ˆ์ด์•„์›ƒ์— ์ ์šฉ๋œ๋‹ค.

 

layout.js ํ˜น์€ page.js ํŒŒ์ผ์— ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๊ฐ์ฒด๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด title, description ๊ฐ™์€ ํŽ˜์ด์ง€ ์ •๋ณด๋ฅผ ์ •์˜ ํ•  ์ˆ˜ ์žˆ๋‹ค. layout.js์— ์ •์˜ํ•œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋Š” ๋ชจ๋“  ํ•˜์œ„ ํŽ˜์ด์ง€๋กœ ์ƒ์†๋œ๋‹ค.

// app/layout.tsx

import { Metadata } from "next";

// layout.js ๋˜๋Š” page.js ํŒŒ์ผ์— ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๊ฐ์ฒด๋ฅผ ํฌํ•จํ•  ์ˆ˜ ์žˆ์Œ
// layout.js์—์„œ ์ •์˜๋œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋Š” ๋ชจ๋“  ํ•˜์œ„ ํŽ˜์ด์ง€๋“ค์— ์ƒ์†๋จ
export const metadata: Metadata = {
  // ๋ธŒ๋ผ์šฐ์ € ํƒญ๊ณผ ๊ฒ€์ƒ‰ ์—”์ง„ ๊ฒฐ๊ณผ์— ํ‘œ์‹œ๋  ํŽ˜์ด์ง€ ์ œ๋ชฉ
  title: "Acme Dashboard",
  // ๊ฒ€์ƒ‰ ์—”์ง„์—์„œ ํŽ˜์ด์ง€์— ๋Œ€ํ•œ ์„ค๋ช…์œผ๋กœ ํ‘œ์‹œ๋  ๋‚ด์šฉ
  description: "The official Next.js Course Dashboard, built with App Router.",
  // ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์—์„œ ์ƒ๋Œ€ ๊ฒฝ๋กœ๋ฅผ ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋กœ ๋ณ€ํ™˜ํ•˜๊ธฐ ์œ„ํ•œ ๊ธฐ์ค€ URL
  metadataBase: new URL("https://next-learn-dashboard.vercel.sh"),
};

export default function RootLayout() {
  // ...
}

 

๋งŒ์•ฝ ํ•˜์œ„ ํŽ˜์ด์ง€์—์„œ ๋ณ„๋„์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ •์˜ํ•˜๋ฉด ๋ถ€๋ชจ ๋ ˆ์ด์•„์›ƒ์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ๋ฎ์–ด์“ด๋‹ค. ์•„๋ž˜ ์˜ˆ์‹œ์—์„  title ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋งŒ ๋ฎ์–ด์“ฐ๊ณ  ๋‚˜๋จธ์ง€ ์†์„ฑ์€ ๋ถ€๋ชจ๋กœ๋ถ€ํ„ฐ ์ƒ์†๋ฐ›๋Š”๋‹ค.

// app/dashboard/invoices/page.tsx

import { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Invoices | Acme Dashboard',
};

 

๋ฃจํŠธ ๋ ˆ์ด์•„์›ƒ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๊ฐ์ฒด์— template ์†์„ฑ์„ ์‚ฌ์šฉํ•˜๋ฉด ํŽ˜์ด์ง€ ํƒ€์ดํ‹€์— ํ…œํ”Œ๋ฆฟ์„ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. ํ…œํ”Œ๋ฆฟ์—์„œ %s๋Š” ํ•˜์œ„ ํŽ˜์ด์ง€์—์„œ ์ •์˜ํ•œ title ๊ฐ’์œผ๋กœ ๋Œ€์ฒด๋œ๋‹ค. default ์†์„ฑ์€ ํ•˜์œ„ ํŽ˜์ด์ง€์—์„œ title์„ ์ •์˜ํ•˜์ง€ ์•Š์•˜์„ ๋•Œ ์‚ฌ์šฉํ•  ๊ธฐ๋ณธ ์ œ๋ชฉ.

// app/layout.tsx

export const metadata: Metadata = {
  title: {
    // ๊ฐœ๋ณ„ title ์ œ๋ชฉ์— ํ…œํ”Œ๋ฆฟ ์ ์šฉ (ํ•˜์œ„ ํŽ˜์ด์ง€์—์„œ ํƒ€์ดํ‹€์„ ์ง€์ •ํ•˜๋ฉด %s ๋ถ€๋ถ„์— ๋“ค์–ด๊ฐ)
    template: '%s | Acme Dashboard',
    // ๊ธฐ๋ณธ title (๊ฐœ๋ณ„ ํŽ˜์ด์ง€์—์„œ ํƒ€์ดํ‹€์„ ์ •์˜ํ•˜์ง€ ์•Š์•˜์„ ๋•Œ ์‚ฌ์šฉ๋จ)
    default: 'Acme Dashboard',
  },
  description: 'The official Next.js Course Dashboard, built with App Router.',
  metadataBase: new URL('https://next-learn-dashboard.vercel.sh'),
}

 

์˜ˆ๋ฅผ๋“ค์–ด /dashboard/invoices ํŽ˜์ด์ง€์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ •์˜ํ•  ์ˆ˜ ์žˆ๋‹ค. ์ตœ์ข…์ ์œผ๋กœ title์€ ํ…œํ”Œ๋ฆฟ์ด ์ ์šฉ๋˜์–ด Invoices | Acme Dashboard ๋กœ ์„ค์ • ๋œ๋‹ค.

// app/dashboard/invoices/page.tsx

export const metadata: Metadata = {
  title: 'Invoices',
};

 

 

๋ฏธ๋ฆฝ์ž ํŒ


์ตœ์ดˆ ํŽ˜์ด์ง€ ์ง„์ž… ์‹œ์—๋Š” SSR, SSG ๋“ฑ์œผ๋กœ ์ดˆ๊ธฐ ๋ Œ๋”๋ง์ด ์ด๋ฃจ์–ด์ง€์ง€๋งŒ, ์ดํ›„ next/link ๊ฐ™์€ ๋‚ด๋ถ€ ๋งํฌ๋ฅผ ํ†ตํ•ด ๋‹ค๋ฅธ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•  ๋• ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด๋“œ ๋ผ์šฐํŒ…์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค(์ƒˆ๋กœ๊ณ ์นจ ์—†์ด ํŽ˜์ด์ง€ ์ „ํ™˜). ํ—ท๊ฐˆ๋ฆด ์ˆ˜ ์žˆ์œผ๋‹ˆ ์ฃผ์˜ํ•˜์ž. 

 

 


๊ธ€ ์ˆ˜์ •์‚ฌํ•ญ์€ ๋…ธ์…˜ ํŽ˜์ด์ง€์— ๊ฐ€์žฅ ๋น ๋ฅด๊ฒŒ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค. ๋งํฌ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”
๋ฐ˜์‘ํ˜•