[Next.js] App Router ๊ณต์ ํํ ๋ฆฌ์ผ ํบ์๋ณด๊ธฐ
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๋ฅผ ํน์ ๊ทธ๋ฃน ํด๋ ์์ผ๋ก ์ฎ๊ธฐ๋ฉด, ํด๋น ํด๋ ๋ ๋ฒจ์๋ง ์ ์ฉ๋๊ณ ๋ค๋ฅธ ํ์ ๋ ๋ฒจ์๋ ์ํฅ ์ ๋ฏธ์นจ
- ๊ดํธ
()
๋ฅผ ์ฌ์ฉํ์ฌ ์ ํด๋๋ฅผ ๋ง๋ค๋ฉด(๋ผ์ฐํธ ๊ทธ๋ฃน) 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๋ฅผ ์ด์ฉํ ๊ฒ์ ๊ตฌํ ๊ณผ์
- ์ ์ ์ ๋ ฅ๊ฐ ํ๋
- URL ์ ๋ฐ์ดํธ
- URL โ Input ํ๋ ๋๊ธฐํ
- 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 ๋ก์ง
๐ก ํผ ์์ฑ ํ๋ก์ฐ
- ์ ์ ์ ๋ ฅ ํ๋ ๊ฐ์ ์กฐํํ๊ธฐ ์ํด ํผ ์์ฑ
- ์๋ฒ ์ก์ ์์ฑ ํ ์ ๋ ฅ ํผ์์ ํธ์ถ
- ์๋ฒ ์ก์ ๋ด๋ถ์์ formData ๊ฐ์ฒด์ ์๋ ๋ฐ์ดํฐ ์ถ์ถ
- ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ฝ์ ํ ๋ฐ์ดํฐ๋ฅผ ๊ฒ์ฆํ๊ณ ์ค๋น
- ๋ฐ์ดํฐ ์ฝ์ ํ ์ค๋ฅ ์ฒ๋ฆฌ
- ์บ์ ์ฌ๊ฒ์ฆ ํ ์ฌ์ฉ์ ์ธ๋ณด์ด์ค ํ์ด์ง๋ก ๋ฆฌ๋๋ ์
โ ๋ผ์ฐํฐ ์ ์ ๋ฐ ํผ ํ๋์ ์ฌ์ฉํ ๋ชฉ๋ก ๋ฐ์ดํฐ ์กฐํ ํ <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 ๋ก์ง
๐ก ํผ ์ ๋ฐ์ดํธ ๋ก์ง ํ๋ก์ฐ
- URL์์ ๋ณ์ ๊ฐ์ ์ถ์ถํ๊ธฐ ์ํด ๋์ ๋ผ์ฐํธ ์ธ๊ทธ๋จผํธ ์์ฑ
- ํ์ด์ง ์ปดํฌ๋ํธ์์ ์ธ๋ณด์ด์ค ID ํ๋
- ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ํด๋น ์ธ๋ณด์ด์ค ๋ฐ์ดํฐ ์กฐํ
- ์กฐํํ ์ธ๋ณด์ด์ค ๋ฐ์ดํฐ๋ก ํผ ์์ ์ฑ์ฐ๊ธฐ
- ํผ ๋ฐ์ดํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ธ๋ณด์ด์ค ์ ๋ณด ์ ๋ฐ์ดํธ
ํด๋ ์ด๋ฆ์ ๋๊ดํธ๋ก ๊ฐ์ธ๋ฉด ๋์ ๋ผ์ฐํธ ์ธ๊ทธ๋จผํธ๋ฅผ ์์ฑํ ์ ์๋ค. 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
์๋ฒ์์ ํผ์ ๊ฒ์ฆํ์ ๋์ ์ฅ์
- ๋ฐ์ดํฐ๋ฅผ ์ ์ฅํ๊ธฐ ์ ์ ์์๋ ํ์์ธ์ง ํ์ธํ ์ ์์
- ์ ์์ ์ธ ์ฌ์ฉ์๊ฐ ํด๋ผ์ด์ธํธ ์ธก ๊ฒ์ฆ์ ์ฐํํ ์ํ์ ์ค์ผ ์ ์์
- ๋ฐ์ดํฐ ์ ํจ์ฑ ๊ธฐ์ค์ ์๋ฒ์์ ์ผ๊ด์ ์ผ๋ก ๊ด๋ฆฌํ ์ ์์ (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>
์์๋ฅผ ์๋์ผ๋ก ์์ฑํ๋ค.
- Config-based: ์ฝ๋ ๊ธฐ๋ฐ ๋ฉํ๋ฐ์ดํฐ(layout.js ๋๋ page.js ํ์ผ์ ์ ์)
- Static Metadata Object: ์ ์ ๊ฐ์ผ๋ก ๊ตฌ์ฑ๋ ๋ฉํ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ฒด ํํ๋ก ์์ฑ
- Dynamic generateMetadata Function: ๋ฐ์ดํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ๋ฉํ๋ฐ์ดํฐ๋ฅผ ๋์ ์ผ๋ก ์์ฑ
- 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 ๊ฐ์ ๋ด๋ถ ๋งํฌ๋ฅผ ํตํด ๋ค๋ฅธ ํ์ด์ง๋ก ์ด๋ํ ๋ ํด๋ผ์ด์ธํธ ์ฌ์ด๋ ๋ผ์ฐํ ์ผ๋ก ๋์ํ๋ค(์๋ก๊ณ ์นจ ์์ด ํ์ด์ง ์ ํ). ํท๊ฐ๋ฆด ์ ์์ผ๋ ์ฃผ์ํ์.
๊ธ ์์ ์ฌํญ์ ๋ ธ์ ํ์ด์ง์ ๊ฐ์ฅ ๋น ๋ฅด๊ฒ ๋ฐ์๋ฉ๋๋ค. ๋งํฌ๋ฅผ ์ฐธ๊ณ ํด ์ฃผ์ธ์
'๐ช Programming' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[React] ๋ฆฌ์กํธ 19 ์ ๋ฐ์ดํธ ๋ด์ฉ ํบ์๋ณด๊ธฐ (0) | 2025.02.08 |
---|---|
[Next.js] Dynamic Routes ๋ค์ด๋๋ฏน ๋ผ์ฐํธ (0) | 2025.01.31 |
[Dev] ์๋งจํฑ ๋ฒ์ ๋ Semantic Versioning (0) | 2025.01.27 |
[React] ๋ฆฌ์กํธ์ ์ฌ๋ฐ๋ฅธ useEffect ์ฌ์ฉํ (2) | 2025.01.21 |
[JS] ์๋ฐ์คํฌ๋ฆฝํธ ์ฝ๋ ์ต์ ํ ๊ธฐ๋ฒ ๋ชจ์ (23) | 2024.12.07 |
๋๊ธ
์ด ๊ธ ๊ณต์ ํ๊ธฐ
-
๊ตฌ๋
ํ๊ธฐ
๊ตฌ๋ ํ๊ธฐ
-
์นด์นด์คํก
์นด์นด์คํก
-
๋ผ์ธ
๋ผ์ธ
-
ํธ์ํฐ
ํธ์ํฐ
-
Facebook
Facebook
-
์นด์นด์ค์คํ ๋ฆฌ
์นด์นด์ค์คํ ๋ฆฌ
-
๋ฐด๋
๋ฐด๋
-
๋ค์ด๋ฒ ๋ธ๋ก๊ทธ
๋ค์ด๋ฒ ๋ธ๋ก๊ทธ
-
Pocket
Pocket
-
Evernote
Evernote
๋ค๋ฅธ ๊ธ
-
[React] ๋ฆฌ์กํธ 19 ์ ๋ฐ์ดํธ ๋ด์ฉ ํบ์๋ณด๊ธฐ
[React] ๋ฆฌ์กํธ 19 ์ ๋ฐ์ดํธ ๋ด์ฉ ํบ์๋ณด๊ธฐ
2025.02.08 -
[Next.js] Dynamic Routes ๋ค์ด๋๋ฏน ๋ผ์ฐํธ
[Next.js] Dynamic Routes ๋ค์ด๋๋ฏน ๋ผ์ฐํธ
2025.01.31 -
[Dev] ์๋งจํฑ ๋ฒ์ ๋ Semantic Versioning
[Dev] ์๋งจํฑ ๋ฒ์ ๋ Semantic Versioning
2025.01.27 -
[React] ๋ฆฌ์กํธ์ ์ฌ๋ฐ๋ฅธ useEffect ์ฌ์ฉํ
[React] ๋ฆฌ์กํธ์ ์ฌ๋ฐ๋ฅธ useEffect ์ฌ์ฉํ
2025.01.21