iBetter Books
수정

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를 이용해 구글, 깃허브 소셜 로그인을 추가합니다.