Ch 05. 보호된 페이지 만들기 — 라우트 가드
미들웨어가 대문을 지킨다면, 서버 컴포넌트와 클라이언트 컴포넌트는 방 안에서 한 번 더 확인합니다. 미들웨어가 모든 것을 막아주면 좋겠지만, 현실에서는 더 세밀한 제어가 필요합니다.
같은 페이지라도 로그인한 사람에게는 편집 버튼을 보여주고, 로그인하지 않은 사람에게는 숨겨야 합니다. 작성자 본인에게만 삭제 버튼을 보여줘야 합니다. 이런 세밀한 UI 제어는 미들웨어가 할 수 없습니다.
미들웨어 리다이렉트로 페이지 보호
가장 간단한 보호 방식은 미들웨어가 통째로 차단하는 것입니다. /dashboard, /profile, /settings 같은 경로는 미로그인 사용자에게 완전히 보여주지 않습니다.
이 방식은 이전 챕터의 middleware.ts에서 이미 구현했습니다.
서버 컴포넌트에서 세션 확인
미들웨어를 통과했더라도 서버 컴포넌트에서 세션을 한 번 더 확인하는 것이 좋습니다.
// 파일: app/dashboard/page.tsximport { auth } from "@/auth";import { redirect } from "next/navigation";export default async function DashboardPage() { const session = await auth(); // 세션이 없으면 로그인 페이지로 if (!session) { redirect("/login"); } return ( <main className="container mx-auto p-8"> <h2 className="text-2xl font-bold mb-4">내 대시보드</h2> <div className="bg-white rounded-lg p-6 shadow"> <p className="text-gray-600">이메일: {session.user?.email}</p> <p className="text-gray-600">이름: {session.user?.name}</p> </div> </main> );}```text`auth()`는 서버 컴포넌트에서만 사용할 수 있습니다. 클라이언트 컴포넌트에서는 다른 방법이 필요합니다.### 클라이언트 컴포넌트에서 useSession 사용클라이언트 컴포넌트에서 세션에 접근하려면 `useSession` 훅을 사용합니다. 먼저 앱 전체를 `SessionProvider`로 감싸야 합니다.```typescript// 파일: app/layout.tsximport { SessionProvider } from "next-auth/react";import { auth } from "@/auth";export default async function RootLayout({ children,}: { children: React.ReactNode;}) { const session = await auth(); return ( <html lang="ko"> <body> <SessionProvider session={session}> {children} </SessionProvider> </body> </html> );}```text이제 클라이언트 컴포넌트에서 `useSession`을 사용할 수 있습니다.```typescript// 파일: components/Header.tsx"use client";import { useSession } from "next-auth/react";import Link from "next/link";import { signOut } from "next-auth/react";export function Header() { const { data: session, status } = useSession(); if (status === "loading") { return ( <header className="h-16 flex items-center px-6 border-b"> <div className="w-24 h-8 bg-gray-200 animate-pulse rounded" /> </header> ); } return ( <header className="h-16 flex items-center justify-between px-6 border-b"> <Link href="/" className="font-bold text-xl"> 나의 블로그 </Link> <nav className="flex items-center gap-4"> {session ? ( <> <span className="text-sm text-gray-600"> {session.user?.name}님 </span> <Link href="/dashboard" className="text-sm hover:underline"> 대시보드 </Link> <button onClick={() => signOut({ callbackUrl: "/" })} className="text-sm text-red-600 hover:underline" > 로그아웃 </button> </> ) : ( <> <Link href="/login" className="text-sm hover:underline"> 로그인 </Link> <Link href="/register" className="text-sm px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" > 회원가입 </Link> </> )} </nav> </header> );}```text`status`가 `"loading"`인 동안은 스켈레톤 UI를 보여줍니다. 세션이 있으면 사용자 이름과 로그아웃 버튼, 없으면 로그인/회원가입 링크를 보여줍니다.### 권한별 접근 제어관리자와 일반 사용자를 구분하는 예시입니다.```typescript// 파일: app/admin/page.tsximport { auth } from "@/auth";import { redirect } from "next/navigation";export default async function AdminPage() { const session = await auth(); if (!session) { redirect("/login"); } // role이 "admin"이 아니면 403 페이지로 if ((session.user as { role?: string })?.role !== "admin") { redirect("/403"); } return ( <main> <h2>관리자 페이지</h2> <p>관리자 전용 콘텐츠입니다.</p> </main> );}```text작성자 본인만 편집할 수 있는 버튼을 보여주는 예시입니다.```typescript// 파일: app/posts/[id]/page.tsximport { auth } from "@/auth";type Post = { id: string; title: string; content: string; authorId: string; authorName: string;};async function getPost(id: string): Promise<Post> { // DB에서 게시글 조회 (예시) return { id, title: "샘플 게시글", content: "내용입니다.", authorId: "1", authorName: "홍길동", };}export default async function PostPage({ params,}: { params: Promise<{ id: string }>;}) { const { id } = await params; const [post, session] = await Promise.all([getPost(id), auth()]); const isAuthor = session?.user?.id === post.authorId; return ( <article className="container mx-auto p-8 max-w-3xl"> <div className="flex items-start justify-between mb-6"> <h2 className="text-3xl font-bold">{post.title}</h2> {isAuthor && ( <div className="flex gap-2"> <a href={`/posts/${id}/edit`} className="px-3 py-1 text-sm bg-gray-100 rounded hover:bg-gray-200" > 수정 </a> <button className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"> 삭제 </button> </div> )} </div> <p className="text-gray-500 mb-8">작성자: {post.authorName}</p> <div className="prose">{post.content}</div> </article> );}
isAuthor는 현재 로그인한 사용자의 ID와 게시글 작성자 ID가 같은지 비교합니다. 같을 때만 편집/삭제 버튼이 나타납니다.
다음 챕터에서는 OAuth를 이용해 구글, 깃허브 소셜 로그인을 추가합니다.