/app
: 애플리케이션에 대한 모든 경로, 구성 요소 및 논리가 포함되어 있으며 여기서 주로 작업하게 됩니다.
/app/lib
: 재사용 가능한 유틸리티 함수, 데이터 가져오기 함수 등 애플리케이션에서 사용되는 함수가 포함되어 있습니다.
/app/ui
: 카드, 테이블, 양식 등 애플리케이션의 모든 UI 구성 요소가 포함되어 있습니다. 시간을 절약하기 위해 이러한 구성 요소의 스타일이 미리 지정되어 있습니다.
/public
: 이미지와 같은 애플리케이션의 모든 정적 자산을 포함합니다.
/script
: 이후 장에서 데이터베이스를 채우는 데 사용할 시드 스크립트가 포함되어 있습니다.
next.config.js
: 애플리케이션 루트에 구성 파일도 있습니다 .
create-next-app
: 해당 파일을 사용하여 새 프로젝트를 시작할 때 생성되고 사전 구성됩니다
app/lib/placeholder-data.js
의 경우 외부에서는 mock-data를 해당 이름으로 붙이는 것을 확인하였습니다. 데이터베이스에서 해당 데이터들을 초기 값으로 사용합니다.
// placeholder-data.js
const invoices = [
{
customer_id: customers[0].id,
amount: 15795,
status: 'pending',
date: '2022-12-06',
},
{
customer_id: customers[1].id,
amount: 20348,
status: 'pending',
date: '2022-11-14',
},
// ...
];
/app/lib/definitions.ts
해당 파일에서 데이터베이스에서 반환될 유형들을 수동으로 정의합니다.
export type Invoice = {
id: string;
customer_id: string;
amount: number;
date: string;
status: 'pending' | 'paid';
};
현재 해당 파일에서는 수동으로 데이터 유형들을 선언하고 있습니다. 하지만 더 나은 안전성을 위해서는 Prisma를 권장합니다. 데이터베이스 스키마를 기반으로 유형을 자동으로 생성합니다.
/app/ui
폴더 안에 global.css
파일이 작성이 되어있습니다. 해당 파일은 CSS 재설정 규칙, HTML 요소에 대한 사이트 전체 스타일 등이 적혀져 있습니다. /app/layout.tsx
에 추가하면 전역으로 CSS가 설정이 됩니다.
import '@/app/ui/global.css';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
global.css
파일에는 @tailwind
지시문이 적혀져있는 것을 볼 수 있습니다.
// /app/ui/global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind
라이브러리를 이용하면 클래스 이름을 통하여 쉽게 스타일을 지정할 수 있습니다.
import AcmeLogo from '@/app/ui/acme-logo';
import { ArrowRightIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
export default function Page() {
return (
// These are Tailwind classes:
<main className="flex min-h-screen flex-col p-6">
<div className="flex h-20 shrink-0 items-end rounded-lg bg-blue-500 p-4 md:h-52">
// ...
)
}
만약 전통적인 CSS 규칙을 작성하거나 스타일을 JSX와 별도로 유지하는 것을 선호한다면 CSS 모듈이 훌륭한 대안입니다.
CSS 모듈을 사용 하면 고유한 클래스 이름을 자동으로 생성하여 CSS 범위를 구성 요소로 지정할 수 있으므로 스타일 충돌에 대해서도 걱정할 필요가 없습니다.
/app/ui/home.module.css
.shape {
height: 0;
width: 0;
border-bottom: 30px solid black;
border-left: 20px solid transparent;
border-right: 20px solid transparent;
}
import styles from '@/app/ui/home.module.css';
//...
<div className="flex flex-col justify-center gap-6 rounded-lg bg-gray-50 px-6 py-10 md:w-2/5 md:px-20">
<div className={styles.shape}></div>;
// ...
상태나 다른 조건에 따라 요소의 스타일을 조건부로 지정해야 하는 경우가 있을 수 있습니다.
[clsx](https://www.npmjs.com/package/clsx)
클래스 이름을 쉽게 전환할 수 있는 라이브러리입니다. 문서를 살펴보는 것이 좋습니다.자세한 내용은 기본 사용법은 다음과 같습니다.
import clsx from 'clsx';
export default function InvoiceStatus({ status }: { status: string }) {
return (
<span
className={clsx(
'inline-flex items-center rounded-full px-2 py-1 text-sm',
{
'bg-gray-100 text-gray-500': status === 'pending',
'bg-green-500 text-white': status === 'paid',
},
)}
>
// ...
)}
status
는 두 가지의 pending 또는 paid가 될 수 있습니다. 해당 조건이 true일 경우 앞의 클래스 이름이 추가되게 할 수 있습니다. pending상태이면 'bg-gray-100 text-gray-500'
가 적용이 됩니다.
만약 조건문을 붙이지 않고 클래스 이름만 붙이게 된다면 해당 클래스는 곧바로 적용이 됩니다.
Next.js를 사용하지 않고 페이지를 작성할 때는 폰트가 이후에 로드가 되면서 ui가 변경이 되는 불상사가 발생합니다. 하지만 Next.js는 next/font
module을 사용할 경 자동으로 최적화 합니다.
다음 파일을 추가합니다.
/app/ui/fonts.ts
import { Inter } from 'next/font/google';
export const inter = Inter({ subsets: ['latin'] });
마지막으로 사용할 최상위 태그에 해당 글꼴을 추가해줍니다.
/app/layout.tsx
import '@/app/ui/global.css'
import {inter} from "@/app/ui/font";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${inter.className} antialiased`} >{children}</body>
</html>
);
}
inter.className
으로 추가하였습니다. antialiased
은 @tailwind에서 지원하는 글꼴을 부드럽게 해주는 클래스입니다.
개발자도구를 사용해 적용이 잘 되는 것을 확인할 수 있습니다.
폰트에 적용이 가능한 옵션은 다음과 같습니다.
Next.js는 public
폴더내에 있는 이미지와 같은 정적 자산을 제공할 수 있습니다.
<img
src="/hero.png"
alt="Screenshots of the dashboard project showing desktop version"
/>
그러나 다음과 같은 상황들을 수동으로 해야합니다.
이미지 최적화는 그 자체로 전문 분야로 간주될 수 있는 웹 개발의 큰 주제입니다. 이러한 최적화를 수동으로 구현하는 대신 next/image
구성 요소를 사용하여 이미지를 자동으로 최적화할 수 있습니다.
<Image>
태그의 구성요소
<Image>
태그는 <img>
의 확장 버전이며 다음과 같은 자동 이미지 최적화 기능을 제공합니다.
다음과 같이 설정 할 수 있습니다.
/app/page.tsx
import Image from "next/image";
<Image
src="/hero-desktop.png"
width={1000}
height={760}
className="hidden md:block"
alt="Screenshots of the dashboard project showing desktop version"
/>
레이아웃 변경을 방지하기 위해 미리 width와 height의 값을 집어넣어주어야 합니다. 또한 원본 이미지와 가로 세로의 비율이 동일해야합니다.
반응형 이미지 설정하기
<Image
src="/hero-desktop.png"
width={1000}
height={760}
className="hidden md:block"
alt="Screenshots of the dashboard project showing desktop version"
/>
<Image
src="/hero-mobile.png"
width={560}
height={620}
className="block md:hidden"
alt="Screenshot of the dashboard project showing mobile version"
/>
찾아보면 좋은 문서
Next.js는 폴더가 중첩된 경로를 만드는 데 사용되는 파일 시스템 라우팅을 사용합니다 . 각 폴더는 URL 세그먼트 에 매핑되는 경로 세그먼트를 나타냅니다 .
layout.tsx
와 page.tsx
를 사용하여 각 경로에 대한 별도의 ui를 만들 수 있습니다.
page.tsx
은 React 구성 요소를 내보내주는 특수한 Next.js의 파일이며 경로에 엑세스하려면 반드시 필요합니다. /app/page.tsx
해당 경로는 루트 경로 /
와 연결된 페이지입니다.
중첩된 경로를 만들려면 다음과 같이 중첩하고 page.tsx
를 추가하면 됩니다.
/dashboard
경로로 이동할 시에 /dashboard/page.tsx
의 React 구성 요소파일이 보여지게 됩니다.
폴더와 파일을 다음과 같이 구성할 시 /dashboard/customers
, /dashboard/invoices
와 같은 경로로 이동을 할 수 있습니다.
나눠진 폴더구조 내에서 font
, test
, img
파일등을 관리할 수 있습니다.
나눠진 폴더에 layout을 추가하게 되면 하위 폴더들에게도 적용이 됩니다.
Next.js에서 레이아웃을 사용할 때의 이점 중 하나는 탐색 시 페이지 구성 요소만 업데이트되고 다시 렌더링되지 않는다는 것입니다. 이를 부분 렌더링이라고 합니다.
루트 레이아웃의 경우 글꼴을 추가할 때 사용했던 app폴더에 있는 layout을 말합니다. 해당 레이아웃을 사용하여 html 태그를 수정하고 메타데이터를 추가할 수 있습니다. 방금 생성한 새 레이아웃은 대시보드 페이지마다 고유하므로 위의 루트 레이아웃의 ui를 추가할 필요가 없습니다.
기본적으로 html에서 페이지를 이동할 때는 <a/>
태그를 이용합니다. 하지만 이는 문제가 있으니 바로 전체 페이지를 새로고침 한다는 것입니다.
Next.js에서는 <Link/>
구성요소를 이용하여 페이지 간의 연결을 할 수 있습니다.
사용은 <a/>
태그와 비슷하게 사용할 수 있습니다. <a href="..."/>
하듯이 <Link href="..."/>
하면 됩니다.
UI 경험을 향상 시키기 위해서 Next.js는 경로 세그먼트 별로 애플리케이션을 자동으로 코드 분할을 합니다. 기존 React SPA를 사용할 경우 초기에 모든 데이터를 전부 받았기에 초기 화면 로딩이 느릴 수 있었습니다. 하지만 코드를 분할 함으로서 필요한 부분만 로드해서 가져올 수 있습니다.
또한 오류가 발생하더라도 애플리케이션의 나머지 부분은 계속해서 동작합니다.
또 <Link/>
구성 요소가 브라우저 뷰 포트에 나타날 때 마다 Next.js
는 백그라운드에서 연결된 경로에 대한 코드를 자동으로 가져옵니다. 때문에 링크를 클릭하면 대상 페이지의 코드가 이미 백그라운드에 로드되어 페이지 전환이 즉각적으로 이루어집니다.
Link의 탐색 작동 방식에 대해 알 수 있습니다.
일반적인 UI 패턴은 사용자가 어떤 페이지에 있는지 알려주는 활성 링크를 표시하는 것입니다.
아래 사진과 같이 현재 스포츠 페이지를 보고 있음을 알려줍니다.
Next.js에서는 usePathName()
을 지원하여 Next.js의 경로를 확인하고 이 패턴을 구현하는데 사용할 수 있는 후크를 제공합니다.
/app/ui/dashboard/nav-links.tsx
'use client';
import {
UserGroupIcon,
HomeIcon,
InboxIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
// ...
이전에 알려주었던 clsx
를 사용하면 다음과 같이 활성화 할 수 있습니다.
'use client';
import {
UserGroupIcon,
HomeIcon,
DocumentDuplicateIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import clsx from 'clsx';
// ...
export default function NavLinks() {
const pathname = usePathname();
return (
<>
{links.map((link) => {
const LinkIcon = link.icon;
return (
<Link
key={link.name}
href={link.href}
className={clsx(
'flex h-[48px] grow items-center justify-center gap-2',
{
'bg-sky-100 text-blue-600': pathname === link.href,
},
)}
>
<LinkIcon className="w-6" />
<p className="hidden md:block">{link.name}</p>
</Link>
);
})}
</>
);
}
기존에 Github 저장소가 없다면 추가합니다.
Github으로 계속하기를 통하여 Vercel 계정과 통합합니다.
다음으로, 방금 생성한 GitHub 저장소를 선택하고 가져올 수 있는 이 화면으로 이동하게 됩니다.
프로젝트 이름을 지정하고 배포를 클릭합니다.
이렇게 하면 프로젝트는 정상적으로 배포가 됩니다.
Github 저장소를 연결해두었을 경우 default 브랜치에 push 가 될 때마다 Vercel에서 구성이 필요 없으 자동으로 애플리케이션을 재배포합니다.
다음으로, 데이터베이스를 설정하려면 대시보드로 계속을 클릭 하고 프로젝트 대시보드에서 스토리지 탭을 선택하세요. Connect Store → 새로 만들기 → Postgres → 계속을 선택합니다 .
약관에 동의하고, 데이터베이스에 이름을 할당하고, 데이터베이스 지역이 워싱턴 DC(iad1) 로 설정되어 있는지 확인하세요 . 이는 기본 지역 이기도 합니다.모든 새로운 Vercel 프로젝트에 대해. 데이터베이스를 동일한 지역에 배치하거나 애플리케이션 코드에 가깝게 배치하면 데이터 요청을 위한 대기 시간을 줄일 수 있습니다.
💡 데이터베이스 영역이 초기화되면 변경할 수 없습니다. 다른 지역을 이용하고 싶다면, 데이터베이스를 생성하기 전에 설정해야 합니다.
연결이 되면 데이터베이스에 대한 정보를 가져오기위해 .env.local
를 클릭합니다.
코드 편집기로 이동하여 .env.example
파일 이름을 .env
. Vercel에서 복사한 내용을 붙여넣습니다.
💡 GitHub에 푸시할 때 데이터베이스 비밀이 노출되는 것을 방지하려면
.gitignore
파일 로 이동하여 무시된 파일에 있는지.env
파일을 확인합니다.
마지막으로 npm i @vercel/postgres
터미널에서 실행하여 Vercel Postgres SDK를 설치합니다..
템플릿을 사용하여 프로젝트를 만들었다면 /scripts
프로젝트 폴더 에 seed.js
. invoices
파일이 들어가 있습니다. 해당 파일는 테이블을 생성하고 시드를 생성하기 위한 코드들이 포함되어 있습니다.
/package.json
"scripts": {
"build": "next build",
"dev": "next dev",
"start": "next start",
"seed": "node -r dotenv/config ./scripts/seed.js"
},
데이터베이스가 어떻게 생겼는지 살펴보려면 Vercel로 돌아가 사이드 탐색에서 Data를 클릭하여 볼 수 있습니다.
이 섹션에서는 사용자, 고객, 송장 및 수익이라는 4개의 새로운 테이블을 찾을 수 있습니다.
기존에 placeholder-data.js
파일이 들어간 것을 확인 할 수 있습니다.
Query 탭으로 전환하여 데이터베이스와 상호 작용할 수 있습니다. 이 섹션에서는 표준 SQL 명령을 지원합니다. 아래는 예시코드 입니다.
SELECT invoices.amount, customers.name
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
WHERE invoices.amount = 666;
API 레이어
: API는 애플리케이션과 데이터 베이스 사이의 중간 계층입니다.
다음과 같은 경우에 사용합니다.
Next.js에서는 Route Handlers를 사용하여 API 엔드포인트를 생성할 수 있습니다.
풀 스택 애플리케이션을 만들때에는 데이터베이스와 상호 작용하는 코드를 작성해야합니다. Postgres나 SQL, ORM, Prisma 등을 이용하여 만들 수 있습니다.
다음과 같은 상황들을 고려해보아야 합니다.
Next.js의 기본 Component는 Server Component입니다. 때문에 서버 구성요소를 사용하여 데이터를 가져올 수도 있습니다.
다음과 같은 장점이 있습니다.
async/await
문법으로 데이터를 가져올 수 있습니다.해당 프로젝트에서는 SQL을 사용하는데 다음과 같은 이점들이 있습니다.
@vercel/postgres
의 sql
를 이용하면 쿼리할 수 있습니다.
// /app/lib/data.ts
export async function fetchLatestInvoices() {
try {
const data = await sql<LatestInvoiceRaw>`
SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
ORDER BY invoices.date DESC
LIMIT 5`;
const latestInvoices = data.rows.map((invoice) => ({
...invoice,
amount: formatCurrency(invoice.amount),
}));
return latestInvoices;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch the latest invoices.');
}
}
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import {
fetchCardData,
fetchLatestInvoices,
fetchRevenue,
} from '@/app/lib/data';
export default async function Page() {
const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices();
const {
totalPaidInvoices,
totalPendingInvoices,
numberOfInvoices,
numberOfCustomers,
} = await fetchCardData();
return (
<main>
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Dashboard
</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Card title="Collected" value={totalPaidInvoices} type="collected" />
<Card title="Pending" value={totalPendingInvoices} type="pending" />
<Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
<Card
title="Total Customers"
value={numberOfCustomers}
type="customers"
/>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<RevenueChart revenue={revenue} />
<LatestInvoices latestInvoices={latestInvoices} />
</div>
</main>
);
}
하지만 문제점이 두 가지가 있습니다.
폭포수란 이전 요청의 완료에 따라 달라지는 일련의 네트워크 요청을 나타냅니다. 데이터 가져오기의 경우 각 요청의 이전 요청이 있어야지 해당 값을 반환한 후에 사용을 할 수 있습니다.
예를 들어, fetchLatestInvoices()
실행을 시작하려면 fetchRevenue()
먼저 실행될 때까지 기다려야 합니다.
그래도 async/await
을 이용하면 해당 문제를 완벽히는 아니지만 바로 잡을 수 있습니다.
/app/dashboard/page.tsx
const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices(); // wait for fetchRevenue() to finish
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData(); // wait for fetchLatestInvoices() to finish
이 패턴이 반드시 나쁜 것은 아닙니다. 다음 요청을 하기 전에 조건이 충족되기를 원하기 때문에 폭포수를 원하는 경우가 있을 수 있습니다. 예를 들어 사용자 ID와 프로필 정보를 먼저 가져오고 싶을 수 있습니다. ID가 있으면 친구 목록을 가져올 수 있습니다. 이 경우 각 요청은 이전 요청에서 반환된 데이터에 따라 달라집니다.
그러나 이 동작은 의도하지 않은 것일 수도 있으며 성능에 영향을 미칠 수도 있습니다.
Promise.All()
을 사용하는 경우 비동기 함수를 동시에 실행할 수 있습니다.
/app/lib/data.js
export async function fetchCardData() {
try {
const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
const invoiceStatusPromise = sql`SELECT
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
FROM invoices`;
const data = await Promise.all([
invoiceCountPromise,
customerCountPromise,
invoiceStatusPromise,
]);
// ...
}
}
하지만 한 가지 단점이 있습니다. 만약 데이터를 불러오는데 하나가 느리다면? 이러한 경우 나머지 api들도 느리게 불러와야할 수도 있습니다.
빌드 또는 재렌더링시에 데이터를 가져오거나 렌더링이 서버에서 발생하는 것을 말합니다.
사용자가 애플리케이션을 방문할 때마다 캐시된 결과를 제공합니다.
다음과 같은 이점들이 있습니다.
정적 렌더링은 정적 블로그 게시물이나 제품 페이지와 같이 사용자 간에 공유되는 데이터나 데이터가 없는 UI에 유용합니다 정기적으로 업데이트되는 개인화된 데이터가 있는 대시보드에는 적합하지 않을 수 있습니다.
동적 렌더링을 사용하면 사용자가 데이터를 요청할 시 (페이지를 방문할 때) 각 사용자의 콘텐츠가 서버에서 렌더링 됩니다. 동적 렌더링과는 차이점이 있습니다.
기본적으로 @vercel/postgres
자체 캐싱 의미 체계를 설정하지 않습니다. 이를 통해 프레임워크는 자체 정적 및 동적 동작을 설정할 수 있습니다.
서버 구성 요소 내부에서 호출되는 Next.js API unstable_noStore
나 데이터 가져오기 기능을 사용하여 정적 렌더링을 거부할 수 있습니다. 이것을 추가해 보겠습니다.
data.ts
에서 next/cache
의 unstable_noStore
를 추가하여 데이터의 가져옴으로서 캐싱된 정보가 없으니 매번 새롭게 요청할 수 있습니다.
/app/lib/data.ts
// ...
import { unstable_noStore as noStore } from 'next/cache';
export async function fetchRevenue() {
// Add noStore() here to prevent the response from being cached.
// This is equivalent to in fetch(..., {cache: 'no-store'}).
noStore();
// ...
}
export async function fetchLatestInvoices() {
noStore();
// ...
}
export async function fetchCardData() {
noStore();
// ...
}
export async function fetchFilteredInvoices(
query: string,
currentPage: number,
) {
noStore();
// ...
}
export async function fetchInvoicesPages(query: string) {
noStore();
// ...
}
export async function fetchFilteredCustomers(query: string) {
noStore();
// ...
}
export async function fetchInvoiceById(query: string) {
noStore();
// ...
}
💡
unstable_noStore
은 실험적인 api이며 추후 변경할 수도 있습니다. 안정적인 API를 사용하고 싶다면 다음과 같이 변경해줍니다.export const dynamic = "force-dynamic"
데이터가 오는데 일부로 3초가 걸리게 설정을 해주었습니다.
export async function fetchRevenue() {
try {
// We artificially delay a response for demo purposes.
// Don't do this in a real application
console.log('Fetching revenue data...');
await new Promise((resolve) => setTimeout(resolve, 3000));
const data = await sql<Revenue>`SELECT * FROM revenue`;
console.log('Data fetch complete after 3 seconds.');
return data.rows;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch revenue data.');
}
}
다음과 같이 설정을 할 경우 해당 데이터를 가져오는 동안 전체 페이지가 차단됩니다. 따라서 개발자가 해결해야하는 공통 과제는 다음과 같습니다.
동적 렌더링을 사용하면 애플리케이션 속도는 가장 느린 데이터를 가져오는 속도에 맞춰집니다. (이후에 동작을 하기 때문에)
스트리밍 : 인터넷에서 데이터를 연속적으로 전송하여 실시간으로 재생하는 것
스트리밍은 경로를 더 작은 경로로 나누고 서버에서 클라이언트로 점진적으로 스트리밍 할 수 있는 것을 이야기 합니다.
스트리밍을 하게 될 경우 하나의 데이터를 불러오는 것이 늦어져 페이지 전체가 멈추는 일을 사전에 차단할 수 있습니다.
Next.js에서는 다음과 같은 방법들로 구현을 할 수 있습니다.
loading.tsx
파일을 사용합니다.<Suspense>
을 사용합니다.최상위 폴더에 다음과 같은 파일을 만듭니다.
/app/dashboard/loading.tsx
export default function Loading() {
return <div>Loading...</div>;
}
loading.tsx
Suspense를 기반으로 구축된 특별한 Next.js 파일로, 페이지 콘텐츠가 로드되는 동안 대체 UI로 표시할 fallback UI를 생성할 수 있습니다.<Sidebar/>
즉시 표시됩니다. 사용자는 동적 콘텐츠가 로드되는 동안 상호 작용할 수 있습니다.app
에 중첩된 폴더는 일반적으로 URL 경로에 매핑이 됩니다. 그러나 폴더가 경로 URL에 포함되지 않도록 폴더를 경로 그룹으로 표시할 수 있습니다.
이를 통해 URL 경로 구조에 영향을 주지 않고 세그먼트와 프로젝트 파일을 논리 그룹으로 구성할 수 있습니다.
경로 그룹은 폴더 이름을 괄호로 묶어 생성할 수 있습니다.(folderName)
URL에 영향을 주지 않고 경로를 구성하려면 관련 경로를 함께 유지하는 그룹을 만드는 방법은 다음과 같습니다. 괄호 안의 폴더는 URL에서 생략됩니다(예: (marketing)
또는 (shop)
.
특정 경로를 레이아웃으로 선택하려면 새 경로 그룹 (shop)
을 만들고 동일한 레이아웃을 공유하는 경로를 그룹(예: account
및 cart
)으로 이동합니다. 그룹 외부의 경로는 레이아웃을 공유하지 않습니다(예: checkout
).
여러 루트 레이아웃을 생성하려면 최상위 layout.js
파일을 제거하고 layout.js
각 경로 그룹 내에 파일을 추가합니다. 이는 완전히 다른 UI나 경험을 가진 섹션으로 애플리케이션을 분할하는 데 유용합니다. 각 루트 레이아웃에 및 **<html>
태그 <body>
를 추가해야 합니다.
위의 예에서 (marketing)
및 둘 다 (shop)
자체 루트 레이아웃을 갖습니다.
💡알아두면 좋을 팁
1. 경로 그룹을 설정하는 것은 그룹을 만드는 이외에 효과는 없습니다. URL 경로에 영향을 주지 않습니다.
2. 경로 그룹을 포함하는 경로는 다른 경로와 동일한 URL 경로로 해석이 되서는 안됩니다. 해당 그룹은 경로에 영향을 주지 않기때문에 동일한 경로가 있다면 오류가 발생합니다.
예시(marketing)/about/page.js
(shop)/about/page.js
⇒ /about 으로 접속하기에 오류발생
3. 최상위layout.js
파일이 없이 여러 루트 레이아웃을 사용하는 경우page.js
는 그룹 내에서 반드시 정의되어야 사용이 가능합니다.예: app/(marketing)/page.js.
4. 다수의 루트 레이아웃이 있을 경우 루트 레이아웃a
을 벗어나 다른 루트 레이아웃b
으로 이동을 하고 다시 루트 레이아웃a
으로 돌아왔을 때 루트 레이아웃a
의 전체 페이지가 다시 로드가 됩니다. 이는 클라이언트에서 사용하는 네비게이션과 달리 서버 측에서 전체 페이지 로드를 발생시키는 것입니다.
loading.tsx
는 /invoices/page.tsx
, /customers/page.tsx
에도 각각 적용을 할 수 있습니다.
위와 같이 설정을 한 경우 dashboard.tsx
만 로딩 페이지가 적용이 됩니다.
지금까지 loading.tsx
를 이용하여 전체 페이지를 스트리밍을 하고 있습니다. 만약 작은 단위로 로딩 상태를 알려주고 싶다면 React Suspense를 사용하면 더욱 세분화되고 특정 구성 요소를 스트리밍할 수 있습니다.
Suspense를 사용하면 일부 조건이 충족될 때까지(예: 데이터 로드) 애플리케이션의 렌더링 부분을 연기할 수 있습니다. Suspense에서 동적 구성요소를 래핑할 수 있습니다. 그런 다음 동적 구성요소가 로드되는 동안 표시할 대체 구성요소를 전달합니다.
/app/dashboard/(overview)/page.tsx
<Suspense fallback={<RevenueChartSkeleton/>}>
<RevenueChart/>
</Suspense>
기존에 느린 데이터 요청을 하는 RevenueChart의 props를 지워주고 해당 파일 내에서 요청을 하도록 변경하였습니다.
/app/ui/dashboard/revenue-chart.tsx
export default async function RevenueChart() {
const chartHeight = 350;
const revenue = await fetchRevenue();
...
}
이제 새로고침을 할 경우 느리게 데이터를 가져오는 <RevenueChart/>
만 대체 컴포넌트 (fallback)을 보여줍니다.
Suspense 경계를 배치하는 위치는 다음 몇 가지 사항에 따라 달라집니다.
정답은 없습니다.
loading.tsx
스트리밍할 수 있지만 구성 요소 중 하나의 데이터 가져오기 속도가 느린 경우 로드 시간이 길어질 수 있습니다.서스펜스 경계를 배치하는 위치는 애플리케이션에 따라 달라집니다. 일반적으로 데이터 가져오기를 필요한 구성 요소로 이동한 다음 Suspense에서 해당 구성 요소를 래핑하는 것이 좋습니다. 그러나 애플리케이션에 필요한 경우 섹션이나 전체 페이지를 스트리밍하는 데 아무런 문제가 없습니다.
현재 경로에서 동적 함수 ex: noStroe(), cookies()
를 호출 한다면 전체 경로가 동적이 됩니다.
이는 오늘날 대부분의 웹 앱이 구축되는 방식과 일치합니다. 전체 애플리케이션 또는 특정 경로 에 대해 정적 렌더링과 동적 렌더링 중에서 선택합니다 .
그러나 대부분의 경로는 완전히 정적이거나 동적이지 않습니다. 정적 콘텐츠와 동적 콘텐츠가 모두 포함된 경로가 있을 수 있습니다. 예를 들어 소셜 미디어 피드가 있고 게시물은 정적이지만 게시물에 대한 좋아요 수는 동적이라고 가정해 보겠습니다. 또는 제품 세부 정보는 정적이지만 사용자 장바구니는 동적인 전자 상거래 사이트입니다.
기존에 만들었던 페이지를 분리를 해보면다음과 같습니다.
• 구성 <SideNav>
요소는 데이터에 의존하지 않으며 사용자에게 맞춤화되지 않으므로 정적 일 수 있습니다 .
• 구성 요소는 <Page>
자주 변경되는 데이터에 의존하고 사용자에게 개인화되므로 동적 일 수 있습니다 .
일부 부분을 동적으로 유지하면서 정적 로딩 셸을 사용하여 경로를 렌더링을 할 수 있는 실험적 기능입니다. 즉, 경로의 동적 부분을 분리할 수 있습니다.
사용자가 경로를 방문할 때:
이는 전체 경로가 완전히 정적이거나 동적인 오늘날 애플리케이션의 작동 방식과 다릅니다.
부분 사전 렌더링은 매우 빠른 정적 에지 전달과 완전한 동적 기능을 결합하며 웹 애플리케이션의 기본 렌더링 모델이 될 가능성이 있다고 믿습니다. 최고의 정적 사이트 생성과 동적 전달을 결합합니다.
ppr을 활성화 한 것으로 부분 사전 렌더링을 할 수 있습니다.
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: true,
},
}
module.exports = nextConfig
/app/page.tsx
export default function Page() {
return (
<main>
<header>
<h1>My Store</h1>
<Suspense fallback={<CartSkeleton />}>
<ShoppingCart />
</Suspense>
</header>
<Banner />
<Suspense fallback={<ProductListSkeleton />}>
<Recommendations />
</Suspense>
<NewProducts />
</main>
);
}
ppr이 활성화되면, 이 페이지는 <Suspense />
경계를 기반으로 정적 로딩 셸을 생성합니다. Suspense의 fallback을 사전 렌더링(prerendered)합니다.
요청을 할 경우 다음과 같은 정적 HTML 셸을 즉시 제공합니다.
<main>
<header>
<h1>My Store</h1>
<div class="cart-skeleton">
<!-- Hole -->
</div>
</header>
<div class="banner" />
<div class="product-list-skeleton">
<!-- Hole -->
</div>
<section class="new-products" />
</main>
기존에 작성한 <ShoppingCart />
에서 사용자의 세션을 확인하기 위해 Cookie를 읽지만 위 컴포넌트에서 정적 셸과 동일한 HTTP 요청의 일부로 스트리밍됩니다. 추가적인 네트워크 왕복이 필요하지 않습니다.
app/cart.tsx
import { cookies } from 'next/headers'
export default function ShoppingCart() {
const cookieStore = cookies()
const session = cookieStore.get('session')
return ...
}
NextJS에서는 여기까지 애플리케이션에서 데이터 가져오기를 최적화하기 위해 몇 가지 작업을 수행했습니다.
검색 기능을 구현하는 데 사용할 Next.js 클라이언트 hook은 다음과 같습니다.
'use client'
import { useEffect } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
export function NavigationEvents() {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
const url = `${pathname}?${searchParams}`
console.log(url)
// You can now use the current URL
// ...
}, [pathname, searchParams])
return null
}
useSearchParams()
/dashboard/invoices?page=1&query=pending
이 URL에 대한 검색 매개변수는 다음과 같습니다. {page: '1', query: 'pending'}
usePathname()
/dashboard/invoicesuse
경로의 경우 Pathname'/dashboard/invoices'
을 반환합니다useRouter()
const router = useRouter();
router.push(href: string, { scroll: boolean })
// 제공된 경로(href)로 이동합니다. 브라우저 history의 기록이 남습니다.
router.replace(href: string, { scroll: boolean })
// 제공된 경로(href)로 이동합니다. 브라우저 history의 기록이 남지 않습니다.
router.prefetch(href: string)
// 더 빠른 클라이언트 측 전환을 위해 제공된 경로를 미리 가져옵니다. Link와 비슷합니다.
router.back()
// 브라우저 history의 이전 페이지로 이동합니다.
router.forword()
// 브라우저 기록 스택의 다음 페이지로 이동합니다.
💡scroll 같은 경우 기본적으로 Next에서 제공하는 페이지를 이동할 경우 scroll은 top 0 으로 이동하게 됩니다. 해당 값을
scroll : false
로 변경하게 될 경우 해당 동작을 막을 수 있습니다.
💡 App Router를 사용할 때에는
next/router
경로가 아닌next/navigation
경로에서 가져와야 합니다.
검색 기능을 구현하는 방법은 다음과 같습니다.
'use client'; // 1.
import {MagnifyingGlassIcon} from '@heroicons/react/24/outline';
import {usePathname, useRouter, useSearchParams} from "next/navigation";
export default function Search({placeholder}: { placeholder: string }) {
const searchParams = useSearchParams();
const pathname = usePathname(); // 2
const { replace } = useRouter();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term); // 3
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`); // 4
}
return (
<div className="relative flex flex-1 flex-shrink-0">
<label htmlFor="search" className="sr-only">
Search
</label>
<input
className="peer block w-full rounded-md bord ..."
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value);
}}
defaultValue={searchParams.get('query')?.toString()} {// 5}
/>
<MagnifyingGlassIcon
className="absolute left-3 top-1/2 h-[18px] w-[18px] ..."/>
</div>
);
}
"/dashboard/invoices"
를 가져옵니다. /dashboard/invoices?query=lee
사용자가 "Lee"
를 검색하는 경우입니다.dafaultValue
의 값에 전달할 수 있습니다.💡 상태를 사용하는 경우 value 속성을 사용하여 제어되는 구성 요소를 만듭니다. 이는 React가 입력 상태 관리한다는 얘기입니다. 하지만 상태를 사용하지 않으므로 defalutValue를 사용합니다.
검색을 할 때에는 debounce를 사용하여 검색을 최적화 할 수 있습니다.
디바운싱 작동 방식
Trigger Event : 디바운싱 되어야 하는 이벤트(검색창의 키 입력 등)가 발생하면 타이머가 시작됩니다.
use-debounce
라이브러리를 설치하여 쉽게 관리할 수 있습니다.
// ...
import { useDebouncedCallback } from 'use-debounce';
// Inside the Search Component...
const handleSearch = useDebouncedCallback((term) => {
console.log(`Searching... ${term}`);
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
만약 client가 아닌 일반적인 페이지라면 어떻게 가져와야하나 걱정을 할 수 있습니다. page의 기본 props를 이용해서 가져올 수 있습니다.
// ...
import { useDebouncedCallback } from 'use-debounce';
// Inside the Search Component...
const handleSearch = useDebouncedCallback((term) => {
console.log(`Searching... ${term}`);
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
이 함수는 의 내용을 래핑 handleSearch
하고 사용자가 입력을 중지한 후 특정 시간(300ms) 후에만 코드를 실행합니다.
이를 통하여 키를 누를 때마다 데이터베이스의 쿼리가 요청이 되는 것을 방지 할 수 있습니다.
[params](https://nextjs.org/docs/app/api-reference/file-conventions/page#params-optional)
: 루트 세그먼트부터 해당 페이지까지의 동적 경로 매개변수를 포함하는 객체입니다.
예시 | URL | params |
---|---|---|
app/shop/[slug]/page.js | /shop/1 | { slug: '1' } |
app/shop/[category]/[item]/page.js | /shop/1/2 | { category: '1', item: '2' } |
app/shop/[...slug]/page.js | /shop/1/2 | { slug: ['1', '2'] } |
searchParams
: 현재 url의 검색 매개변수를 포함하는 객체입니다.
URL | searchParams |
---|---|
/shop?a=1 | { a : ‘1’ } |
/shop?a=1&b=2 | { a : ‘1’, b : ‘2’ } |
/shop?a=17&a=2 | { a : [’1’, ‘2’] } |
React Server Action을 사용하면 서버에서 직접 비동기 코드를 실행할 수 있습니다. 이를 위해 API 엔드 포인트를 따로 생성할 필요가 없습니다. 대신 서버에서 실행되고 클라이언트 또는 서버 구성 요소에서 호출될 수 있는 비동기 함수를 작성해야 합니다.
React에서는 요소 action의 속성을 사용하여 <form/>
작업을 호출할 수 있습니다.
// Server Component
export default function Page() {
// Action
async function create(formData: FormData) {
'use server';
// Logic to mutate data...
}
// Invoke the action using the "action" attribute
return <form action={create}>...</form>;
}
서버 구성 요소 내에서 서버 작업을 호출하면 점진적인 향상이 가능하다는 장점이 있습니다. 클라이언트에서 JavaScript가 비활성화된 경우에도 양식이 작동합니다.
또한 서버 작업은 Next.js의 캐싱과도 긴밀하게 연결되어 있어 서버 작업을 통해 양식을 제출할 경우 데이터를 변경할 수 있을 뿐만이 아니라 같은 API를 사용하여 관련 캐시의 유효성을 다시 검사할 수도 있습니다. revalidatePath
, revalidateTag
와 마찬가지로요.
error.tsx 파일로 오류 처리가 가능합니다. 예상치 못한 오류에 대한 포괄적인 역할을 하며 사용자에게 대체 UI를 표시할 수 있습니다.
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
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">
<h2 className="text-center">Something went wrong!</h2>
<button
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-w ..."
onClick={
// Attempt to recover by trying to re-render the invoices route
() => reset()
}
>
Try again
</button>
</main>
);
}
Error.tsx는 다음과 같은 특징을 가지고 있습니다.
“use client”
를 사용합니다.에러를 고의적으로 발생시킬 경우 다음과 같은 UI가 노출이 됩니다.
export async function deleteInvoice(id: string) {
throw new Error('Failed to Delete Invoice');
try {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
return { message: 'Deleted Invoice.' };
} catch (error) {
return {message: 'Database Error: Failed to Delete Invoice.'};
}
}
오류를 처리할 수 있는 다른 방법으로는 notFound()
함수를 사용하는 것입니다. 존재하지 않는 리소스를 불러오려고 할 때 사용할 수 있습니다.
다음과 같이 없는 not-found.tsx 파일을 만들어 줍니다.
관련된 데이터를 받아오지 못할 경우 빈 배열인 []
를 받아옵니다. 따라서 notFound()
함수를 통해 오류를 발생 시킬 수 있습니다.
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
import { updateInvoice } from '@/app/lib/actions';
import { notFound } from 'next/navigation';
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
if (!invoice) {
notFound();
}
// ...
}
이제 해당하는 경로를 못 찾을 시에는 404 Page가 뜨게 됩니다.
오류 처리에 대한 API 모음
[error.js API 참조]
(https://nextjs.org/docs/app/api-reference/file-conventions/error)[notFound() API 참조]
(https://nextjs.org/docs/app/api-reference/functions/not-found)[not-found.js API 참조]
(https://nextjs.org/docs/app/api-reference/file-conventions/not-found)접근하기 힘든 사용자를 포함하여 모든 사람들이 사용할 수 있는 웹 애플리케이션을 설계하고 구현하는 것을 의미합니다.
해당 과정에선 접근성 기능과 애플리케이션의 접근성을 높이는 방법에 대해서 알아갑니다.
Next.js에는 기본적으로 [eslint-plugin-jsx-a11y](https://www.npmjs.com/package/eslint-plugin-jsx-a11y)
접근성 문제를 조기에 발견하는 데 도움이 되는 플러그인이 있습니다. 예를 들어, 이 플러그인은 텍스트가 없는 이미지가 있는 경우 alt
, aria-*
및 role
속성을 잘못 사용하는 경우 등을 경고합니다.
양식의 접근성을 개선하기 위해 세 가지 작업을 수행중에 있습니다.
<div/>
대신 <img/>
나 <option/>
태그들을 사용하고 있습니다. 이를 통해 보조 기술(AT)는 입력 요소에 집중하고 사용자에게 적절한 상황 정보를 제공하여 양식을 더 쉽게 탐색하고 이해할 수 있습니다.<label>
과 htmlFor
속성을 포함하면 각 양식 필드에 설명 텍스트 라벨이 포함됩니다. 이는 컨텍스트를 제공하여 AT 지원을 향상시키고 사용자가 레이블을 클릭하여 해당 입력 필드에 집중할 수 있게 함으로써 유용성을 향상시킵니다.양식을 빈 상태로 요청을 보내면 에러가 발생합니다. 따라서 클라이언트에서 사전에 차단을 할 수 있습니다.
클라이언트에서 양식의 유효성을 검사할 수 있는 몇 가지 방법이 있습니다. 가장 간단한 방법은 <input>
, <select>
양식 및 요소에 required
속성을 추가하여 브라우저에서 제공하는 양식 유효성 검사에 의존하는 것입니다 .
<input
id="pending"
name="status"
type="radio"
value="pending"
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
required
/>
서버에서 양식의 유효성을 검사할 수 있습니다.
const FormSchema = z.object({
id: z.string(),
customerId: z.string({invalid_type_error: 'Please select a customer.'}),
// 타입에 오류가 있을 시에 에러를 발생 시킬 수 있습니다.
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(),
});
const CreateInvoice = FormSchema.omit({id: true, date: true});
export type State = {
errors?: {
customerId?: string[];
amount?: string[];
status?: string[];
};
message?: string | null;
};
export async function createInvoice(prevState: State, formData: FormData) {
// prevState의 경우 hook에서 전달된 상태를 나타냅니다. 사용하지는 않지만 필수입니다.
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.
if (!validatedFields.success) {
// 에러가 있을 시에 조기 종료 해줍니다.
return {
// 에러 객체들만 꺼내와 줍니다. form 과 field의 에러들만 꺼내와줍니다.
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Create Invoice.',
};
}
// 에러가 없는 검증된 데이터들 입니다.
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch (error) {
return {
message: 'Database Error: Failed to Create Invoice.',
};
}
revalidatePath('/dashboard/invoices');
// /dashboard/invoices 해당 경로의 유효성을 다시 검사합니다.
redirect('/dashboard/invoices');
// 해당 경로로 이동시킵니다.
}
const Component = () => {
return <div className="relative">
<select
id="customer"
name="customerId"
className="peer block w-full cursor-pointer rounded-md border ..."
defaultValue={invoice.customer_id}
aria-describedby="customer-error"
>
{// aria-describedby 속성으로 아래 div와 연결을 해줍니다.}
<option value="" disabled>
Select a customer
</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
<UserCircleIcon
className="pointer-events-none absolute left-3 top-1/2..."/>
</div>
<div id="customer-error" aria-live="polite" aria-atomic="true">
{// aria-describedby 속성으로 연결이 됩니다.}
{// aria-live="polite"를 통해서 변동된 사항을 사용자에게 알려줍니다.}
{state.errors?.customerId &&
state.errors.customerId.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
}
시스템이 사용자가 자신이 누구 인지를 확인하는 방법입니다.
웹 개발에서 인증과 권한 부여는 서로 다른 역할을 합니다.
NextAuth를 사용합니다.애플리케이션에 인증을 추가하려면 세션관리, 로그인, 로그아웃, 기타 인증과 같이 복잡한 도구들을 관리해야하지만 NextAuth.js는 이러한 인증을 위한 통합 솔루션을 제공하여 프로세스를 단순화합니다.
npm install next-auth@beta
Next.js 14와 호환되는 NextAuth.js 버전을 설치합니다 .
다음으로 애플리케이션에 대한 비밀 키를 생성합니다. 이 키는 쿠키를 암호화하여 사용자 세션의 보안을 보장하는 데 사용됩니다. 터미널에서 다음 명령을 실행하면 됩니다.
openssl rand -base64 32
그런 다음 생성된 키를 .env
파일 AUTH_SECRET
변수에 추가합니다.
AUTH_SECRET=your-secret-key
프로덕션에서 인증이 작동하려면 Vercel 프로젝트에서도 환경 변수를 업데이트해야 합니다. 이 가이드를 확인하세요Vercel에 환경 변수를 추가하는 방법에 대해 알아보세요.
authConfig
객체를 내보내는 프로젝트 루트에 auth.config.ts
파일을 만듭니다. 이 객체에는 NextAuth.js에 대한 구성 옵션이 포함됩니다. 지금은 다음 pages
옵션만 포함됩니다.
/auth.config.ts
import type { NextAuthConfig } from 'next-auth';
export const authConfig = {
pages: {
signIn: '/login',
},
};
pages
옵션을 사용하여 사용자 정의 로그인, 로그아웃 및 오류 페이지에 대한 경로를 지정할 수 있습니다. 이는 필수는 아니지만 pages
에 signIn: '/login'
옵션을 추가하면 사용자는 NextAuth.js 기본 페이지가 아닌 사용자 정의 로그인 페이지로 리디렉션됩니다.
다음으로 경로를 보호하는 로직을 추가합니다. 이렇게 하면 사용자가 로그인 하지않으면 대시보드 페이지에 액세스할 수 없습니다.
/auth.ts
import type { NextAuthConfig } from 'next-auth';
export const authConfig = {
pages: {
signIn: '/login',
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
// 로그인이 되는 유저인지 확인합니다.
const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
if (isOnDashboard) {
if (isLoggedIn) return true;
return false; // Redirect unauthenticated users to login page
} else if (isLoggedIn) {
return Response.redirect(new URL('/dashboard', nextUrl));
}
return true;
},
},
providers: [],
// 위 코드에서는 현재는 빈 배열로 providers를 설정하고 있습니다.
// 여기에는 실제로 사용할 인증 제공자 (예: Google, Facebook 등)를 설정할 수 있습니다.
} satisfies NextAuthConfig;
callbacks
섹션에서는 authorized
콜백 함수를 정의하고 있습니다. 이 함수는 페이지에 액세스할 권한이 있는지를 확인합니다. 사용자가 대시보드 페이지(/dashboard
)에 접근하려고 할 때 로그인 여부를 확인하고, 로그인되어 있지 않다면 로그인 페이지로 리디렉션합니다. 이미 로그인되어 있는 경우 대시보드 페이지로 리디렉션합니다.
/middleware.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
export default NextAuth(authConfig).auth;
export const config = {
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};
여기서는 객체로 NextAuth.js를 초기화하고 속성을 내보내고 있습니다.
이 작업에 미들웨어를 사용하면 미들웨어가 인증을 확인할 때까지 보호된 경로가 렌더링을 시작하지 않아 애플리케이션의 보안과 성능이 모두 향상된다는 이점이 있습니다.
비밀번호를 안전하게 저장하려면 비밀번호를 해시해야 합니다.
이전 seed.js
파일에서 우리는 bcrypt
비밀번호를 데이터베이스에 저장하기 전에 해시하는 데 사용했습니다. bcrypt
다시 사용하여 사용자가 입력한 비밀번호가 데이터베이스에 있는 비밀번호와 일치하는지 비교할 수 있습니다.
그러나 bcrypt
Next.js 미들웨어에서는 사용할 수 없는 Node.js API를 사용합니다. 이문제를 해결하려면 bcrypt
를 가져오는 별도의 파일을 만들어야 합니다.
/auth.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
});
다음으로 NextAuth.js에 대한 providers
옵션을 추가해야 합니다. providers
는 Google 또는 GitHub와 같은 다양한 로그인 옵션을 나열하는 배열입니다. 이 과정에서는 자격 증명 공급자를 사용하는 데 중점을 둡니다.
자격 증명 공급자를 사용하면 사용자가 사용자 이름과 비밀번호를 사용하여 로그인할 수 있습니다.
/auth.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [Credentials({})],
});
💡 자격 증명 공급자를 사용하고 있지만 일반적으로 OAuth 와 같은 대체 공급자를 사용하는 것이 좋습니다. 전체 옵션 목록을 보려면 이메일공급자. NextAuth.js 문서를 참조하세요.
authorize
이 함수를 사용하여 인증 논리를 처리 할 수 있습니다 . zod
라이브러리를 통하여 서버 작업과 마찬가지로 사용자가 데이터베이스에 존재하는지 확인하기 전에 이메일과 비밀번호의 유효성을 검사하는 데 사용할 수 있습니다 .
/auth.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
async authorize(credentials) {
const parsedCredentials = z
.object({ email: z.string().email(), password: z.string().min(6) })
.safeParse(credentials);
},
}),
],
});
zod
라이브러리를 사용하여 1차로 자격 증명을 확인한 후에 데이터베이스에서 사용자를 쿼리하는 새 함수를 만듭니다.
/auth.ts
import type { User } from '@/app/lib/definitions';
import bcrypt from 'bcrypt';
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.');
}
}
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
async authorize(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;
},
}),
],
});
인증 로직과 로그인 양식과 연결합니다.
import { signIn } from '@/auth';
// ...
export async function authenticate(
prevState: string | undefined,
formData: FormData,
) {
try {
await signIn('credentials', Object.fromEntries(formData));
} catch (error) {
if ((error as Error).message.includes('CredentialsSignin')) {
return 'CredentialsSignin';
}
throw error;
}
}
'use client';
export default function LoginForm() {
const [state, dispatch] = useFormState(authenticate, undefined);
return (
<form action={dispatch} className="space-y-3">
<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>
<LoginButton />
<div
className="flex h-8 items-end space-x-1"
aria-live="polite"
aria-atomic="true"
>
{state === 'CredentialsSignin' && (
<>
<ExclamationCircleIcon className="h-5 w-5 text-red-500" />
<p className="text-sm text-red-500">Invalid credentials</p>
</>
)}
</div>
</div>
</form>
);
}
function LoginButton() {
const { pending } = useFormStatus();
return (
<Button className="mt-4 w-full" aria-disabled={pending}>
Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
</Button>
);
}
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>
);
}
메타데이터는 SEO 및 공유성에 매우 중요합니다.
웹 개발에서 메타데이터는 페이지를 방문하기 이전에 웹페이지에 대한 추가 세부정보를 제공합니다. <head>
안에서 작동하므로 사용자에게 보이지는 않습니다. 이 숨겨진 정보는 웹페이지의 콘텐츠를 더 잘 이해해야 하는 검색 엔진 및 기타 시스템에 매우 중요합니다.
메타데이터는 웹페이지의 SEO를 향상시키는 데 중요한 역할을 하여 검색 엔진과 소셜 미디어 플랫폼에서 웹페이지에 대한 접근성과 이해도를 높입니다. 검색 엔진이 웹 페이지를 효과적으로 색인화하여 검색 결과에서 순위를 높이는 데 도움이 됩니다. 또한 Open Graph와 같은 메타데이터는 소셜 미디어에서 공유 링크의 모양을 개선하여 사용자에게 콘텐츠를 더욱 매력적이고 유익하게 만듭니다.
다양한 유형의 메타데이터가 있으며 각각 고유한 목적을 제공합니다. 몇 가지 일반적인 유형은 다음과 같습니다.
Title Metadata : 브라우저 탭에 표시되는 웹페이지의 제목을 담당합니다. 이는 검색 엔진이 웹페이지의 내용을 이해하는 데 도움이 되므로 SEO에 매우 중요합니다.
<title>Page Title</title>
설명 메타데이터 : 이 메타데이터는 웹페이지 콘텐츠에 대한 간략한 개요를 제공하며 검색 엔진 결과에 자주 표시됩니다.
<meta name="description" content="A brief description of the page content." />
키워드 메타데이터 : 이 메타데이터에는 웹페이지 콘텐츠와 관련된 키워드가 포함되어 있어 검색 엔진이 페이지를 색인화하는 데 도움이 됩니다.
<meta name="keywords" content="keyword1, keyword2, keyword3" />
오픈 그래프 메타데이터 : 이 메타데이터는 제목, 설명, 미리보기 이미지 등의 정보를 제공하여 소셜 미디어 플랫폼에서 공유할 때 웹페이지가 표시되는 방식을 향상시킵니다.
<meta property="og:title" content="Title Here" />
<meta property="og:description" content="Description Here" />
<meta property="og:image" content="image_url_here" />
파비콘 메타데이터 : 이 메타데이터는 파비콘(작은 아이콘)을 브라우저의 주소 표시줄이나 탭에 표시되는 웹페이지에 연결합니다.
<link rel="icon" href="path/to/favicon.ico" />
/app/layout.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Acme Dashboard',
description: 'The official Next.js Course Dashboard, built with App Router.',
metadataBase: new URL('https://next-learn-dashboard.vercel.sh'),
};
export default function Page() {
// ...
}
Next.js는 애플리케이션에 제목과 메타데이터를 자동으로 추가합니다.
metadata
페이지 자체에 개체를 추가하면 됩니다 . 중첩된 페이지의 메타데이터는 상위 페이지의 메타데이터보다 우선 적용됩니다.
예를 들어 /dashboard/invoices
페이지에서 페이지 제목을 업데이트할 수 있습니다.
/app/dashboard/invoices/page.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Invoices | Acme Dashboard',
};
이것은 작동하지만 모든 페이지에서 애플리케이션 제목을 반복하고 있습니다. 회사 이름과 같은 사항이 변경되면 모든 페이지에서 이를 업데이트해야 합니다.
대신 title
객체 template
의 필드를 사용하여 metadata
페이지 제목에 대한 템플릿을 정의할 수 있습니다. 이 템플릿에는 페이지 제목과 포함하려는 기타 정보가 포함될 수 있습니다.
루트 레이아웃에서 metadata
템플릿을 포함하도록 객체를 업데이트합니다.
/app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s | Acme Dashboard',
default: 'Acme Dashboard',
},
description: 'The official Next.js Learn Dashboard built with App Router.',
metadataBase: new URL('https://next-learn-dashboard.vercel.sh'),
};
더 많은 metadata들을 추가할 수 있습니다.
더 많은 공부를 지속하려면 다음 문서를 참조하세요
Email: user@nextmail.com
Password: 123456
예제코드 출처 - Next.js 소개와 14 버전 변경사항 – React 인기 라이브러리