icon
10장 : 인증 및 권한 관리

보호된 라우트 생성

이전 절에서 NextAuth.js의 기본 설정과 signIn(), signOut() 함수를 사용하여 로그인 및 로그아웃 기능을 구현했습니다. 이제 애플리케이션의 핵심적인 부분인 보호된 라우트(Protected Routes), 즉 로그인된 사용자 또는 특정 권한을 가진 사용자만 접근할 수 있는 페이지를 만드는 방법을 자세히 다루겠습니다. Next.js App Router는 서버 컴포넌트와 미들웨어 기능을 통해 강력하고 유연한 보호된 라우트 구현을 가능하게 합니다.

이 절에서는 다음 내용을 중점적으로 살펴봅니다.

  • 서버 컴포넌트에서 getServerSession을 사용한 라우트 보호
  • 미들웨어(Middleware)를 활용한 전역 라우트 보호
  • 클라이언트 컴포넌트에서 라우트 보호
  • 권한 기반 접근 제어의 기초

getServerSession을 사용한 라우트 보호

Next.js App Router의 가장 큰 장점 중 하나는 서버 컴포넌트에서 데이터를 가져오는 것 외에도 인증 상태를 직접 확인하여 페이지 접근을 제어할 수 있다는 점입니다. 이는 클라이언트 측에서 발생할 수 있는 "깜빡임(Flash of Unstyled Content)" 없이 서버에서 즉시 리다이렉트가 가능하게 하여 더 안전하고 사용자 경험 측면에서도 우수합니다.

getServerSession 함수는 서버 컴포넌트(또는 Route Handler, Server Action) 내에서 현재 사용자의 세션 정보를 가져오는 데 사용됩니다.

기본 사용법

src/app/dashboard/page.tsx
// src/app/dashboard/page.tsx (보호할 서버 컴포넌트 예시)
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation'; // Next.js에서 제공하는 서버 측 리다이렉트 함수
import { authOptions } from '../api/auth/[...nextauth]/route'; // NextAuth.js 설정 파일 임포트

export default async function DashboardPage() {
  // 1. 서버에서 현재 세션 정보 가져오기
  const session = await getServerSession(authOptions);

  // 2. 세션이 없는 경우 로그인 페이지로 리다이렉트
  if (!session) {
    // 사용자가 로그인 성공 후 다시 이 페이지로 돌아올 수 있도록 callbackUrl 설정
    redirect('/api/auth/signin?callbackUrl=/dashboard');
  }

  // 3. 로그인된 경우에만 페이지 콘텐츠 렌더링
  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', border: '1px solid #28a745', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)', textAlign: 'center' }}>
      <h1 style={{ color: '#28a745', marginBottom: '20px' }}>대시보드</h1>
      <p className="text-gray-700">환영합니다, <span style={{ fontWeight: 'bold' }}>{session.user?.name}</span>님! 이곳은 로그인 사용자만 접근할 수 있는 보호된 대시보드 페이지입니다.</p>
      {session.user?.image && (
        <img
          src={session.user.image}
          alt="User Avatar"
          style={{ width: '80px', height: '80px', borderRadius: '50%', marginTop: '20px', display: 'block', margin: '0 auto' }}
        />
      )}
      <p style={{ marginTop: '15px', fontSize: '0.9em', color: '#555' }}>
        (이 페이지는 서버에서 인증 상태를 확인하여 보호됩니다.)
      </p>
    </div>
  );
}

장점

  • 강력한 보안: 서버에서 요청이 들어오자마자 인증 상태를 확인하므로, 클라이언트 측 자바스크립트가 로드되기 전에 접근을 제어할 수 있습니다.
  • SEO 친화적: 로그인되지 않은 사용자에게는 보호된 콘텐츠가 노출되지 않으므로, 검색 엔진이 로그인 페이지나 관련 없는 내용을 인덱싱하는 것을 방지할 수 있습니다.
  • 부드러운 사용자 경험: 리다이렉트가 서버에서 발생하므로, 사용자는 보호된 페이지의 콘텐츠가 잠시 노출되는 것을 경험하지 않습니다.

미들웨어를 활용한 전역 라우트 보호

Next.js의 미들웨어(Middleware) 는 요청이 완료되기 전에 코드를 실행할 수 있게 해주는 강력한 기능입니다. 이를 사용하여 모든 (또는 특정 패턴의) 라우트에 대한 인증 및 권한 검사를 중앙에서 처리할 수 있습니다. 미들웨어는 src/middleware.ts 파일에 정의됩니다.

미들웨어의 장점

  • 중앙 집중식 관리: 모든 보호된 라우트에 대한 인증 로직을 한 곳에서 관리하여 코드 중복을 줄이고 일관성을 유지할 수 있습니다.
  • 유연한 보호: 특정 라우트 패턴(matcher 설정)에만 미들웨어를 적용하여 필요한 부분만 보호할 수 있습니다.
  • 성능 최적화: 서버 컴포넌트에서 각각 getServerSession을 호출하는 것보다 미들웨어에서 한 번 처리하는 것이 특정 상황에서 더 효율적일 수 있습니다.

미들웨어 구현 단계

  1. src/middleware.ts 파일 생성

    src/middleware.ts
    // src/middleware.ts
    import { withAuth } from 'next-auth/middleware'; // NextAuth.js 미들웨어 헬퍼 함수
    
    export default withAuth(
      // `withAuth` 함수에 인증되지 않은 사용자를 리다이렉트할 경로를 지정합니다.
      // 이 예시에서는 `/login` 페이지로 리다이렉트하고, 현재 요청 URL을 `callbackUrl` 쿼리 파라미터로 전달합니다.
      {
        pages: {
          signIn: '/auth/signin', // NextAuth.js 기본 로그인 페이지
          // 또는 커스텀 로그인 페이지: signIn: '/login',
        },
      }
    );
    
    // 미들웨어가 적용될 라우트 경로를 정의합니다.
    // 여기서 정의된 경로는 인증이 필요한 경로를 의미합니다.
    export const config = {
      matcher: [
        '/dashboard/:path*', // /dashboard로 시작하는 모든 경로
        '/profile/:path*',   // /profile로 시작하는 모든 경로
        '/admin/:path*',     // /admin으로 시작하는 모든 경로 (권한 검사는 추가 필요)
        // 여기에 보호할 다른 라우트 경로를 추가하세요.
      ],
    };
  2. 보호할 페이지 생성 또는 수정: middleware.ts에 정의된 matcher 경로에 해당하는 페이지들은 이제 자동으로 미들웨어에 의해 보호됩니다. 이 페이지들에서는 더 이상 getServerSession을 사용하여 인증 여부를 직접 확인할 필요가 없습니다 (필요하다면 사용자 정보는 가져올 수 있음).

    예시: 미들웨어로 보호되는 대시보드 페이지

    src/app/dashboard/page.tsx
    // src/app/dashboard/page.tsx (수정)
    // 이 파일에는 더 이상 getServerSession 및 redirect 로직이 필요 없습니다.
    // 미들웨어에서 이미 인증 여부를 확인했기 때문입니다.
    import { getServerSession } from 'next-auth'; // 사용자 정보를 가져오기 위해 필요할 수 있음
    import { authOptions } from '../api/auth/[...nextauth]/route';
    
    export default async function DashboardPage() {
      // 이미 인증된 상태이므로, 세션 정보를 가져와서 활용하기만 하면 됩니다.
      const session = await getServerSession(authOptions);
    
      return (
        <div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', border: '1px solid #28a745', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)', textAlign: 'center' }}>
          <h1 style={{ color: '#28a745', marginBottom: '20px' }}>대시보드</h1>
          <p className="text-gray-700">환영합니다, <span style={{ fontWeight: 'bold' }}>{session?.user?.name || '사용자'}</span>님! 이곳은 미들웨어에 의해 보호되는 대시보드 페이지입니다.</p>
          {session?.user?.image && (
            <img
              src={session.user.image}
              alt="User Avatar"
              style={{ width: '80px', height: '80px', borderRadius: '50%', marginTop: '20px', display: 'block', margin: '0 auto' }}
            />
          )}
          <p style={{ marginTop: '15px', fontSize: '0.9em', color: '#555' }}>
            (미들웨어에서 인증을 처리하여 서버 컴포넌트가 더 간결해졌습니다.)
          </p>
        </div>
      );
    }

고려 사항

  • 미들웨어는 모든 요청에 대해 실행되므로, 과도한 로직이나 데이터베이스 호출은 애플리케이션 성능에 영향을 줄 수 있습니다.
  • 권한 기반 접근 제어(admin 역할 확인 등)는 미들웨어 내에서도 구현할 수 있지만, 복잡해질 경우 별도의 함수로 분리하거나, 페이지 자체에서 getServerSession을 통해 더 상세하게 제어하는 것을 고려할 수 있습니다.

클라이언트 컴포넌트에서 라우트 보호

이 방식은 이전 절에서 간략히 다루었듯이, 클라이언트 컴포넌트에서 useSession 훅을 사용하여 인증 상태를 확인하고, next/navigationuseRouter를 통해 리다이렉트를 수행합니다.

src/app/settings/page.tsx
// src/app/settings/page.tsx (클라이언트 컴포넌트 예시)
"use client";

import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

export default function SettingsPage() {
  const { data: session, status } = useSession();
  const router = useRouter();

  // 세션 로딩 중이 아니고, 세션이 없다면 로그인 페이지로 리다이렉트
  useEffect(() => {
    if (status === 'loading') return; // 로딩 중에는 아무것도 하지 않음
    if (!session) {
      router.push('/api/auth/signin?callbackUrl=/settings'); // NextAuth.js 기본 로그인 페이지로 리다이렉트
    }
  }, [session, status, router]);

  // 로딩 상태 처리
  if (status === 'loading') {
    return (
      <div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', border: '1px solid #ccc', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)', textAlign: 'center' }}>
        <p>설정 페이지를 로딩 중입니다...</p>
      </div>
    );
  }

  // 로그인되지 않은 경우 (리다이렉트되기 전 잠시 표시될 수 있음)
  if (!session) {
    return (
      <div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', border: '1px solid #dc3545', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)', textAlign: 'center' }}>
        <h1 style={{ color: '#dc3545', marginBottom: '20px' }}>접근 거부</h1>
        <p>이 페이지에 접근하려면 로그인해야 합니다.</p>
      </div>
    );
  }

  // 로그인된 경우 설정 페이지 콘텐츠 렌더링
  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', border: '1px solid #007bff', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)', textAlign: 'center' }}>
      <h1 style={{ color: '#007bff', marginBottom: '20px' }}>사용자 설정</h1>
      <p>환영합니다, <span style={{ fontWeight: 'bold' }}>{session.user?.name}</span>님! 이곳은 사용자 설정을 변경할 수 있는 페이지입니다.</p>
      <p style={{ marginTop: '10px', fontSize: '0.9em', color: '#555' }}>
        (이 페이지는 클라이언트 컴포넌트에서 인증 상태를 확인하여 보호됩니다.)
      </p>
    </div>
  );
}

장점

  • 클라이언트 컴포넌트에서 동적인 UI를 구성하며 라우트 보호를 쉽게 적용할 수 있습니다.

단점

  • 보안 취약점 (상대적): 클라이언트 측에서 JavaScript가 로드되어야만 리다이렉트 로직이 실행되므로, 잠시 동안 보호된 콘텐츠가 노출될 위험이 있습니다.
  • SEO 불리: 검색 엔진 봇은 JavaScript를 실행하지 않을 수 있으므로, 보호된 콘텐츠를 적절히 인덱싱하지 못할 수 있습니다.

결론적으로, 가능한 한 서버 컴포넌트의 getServerSession 또는 미들웨어를 사용하여 라우트를 보호하는 것이 Next.js App Router에서 권장되는 방식입니다. 클라이언트 컴포넌트 방식은 사용자 친화적인 메시지를 표시하거나, 이미 서버에서 인증된 후 추가적인 클라이언트 측 권한 검사가 필요할 때 보조적으로 사용될 수 있습니다.


권한 기반 접근 제어의 기초

단순히 로그인 여부뿐만 아니라, 특정 '역할(role)'을 가진 사용자에게만 접근을 허용하는 것을 권한 기반 접근 제어(Role-Based Access Control, RBAC) 라고 합니다.

구현 방법

  1. 세션에 사용자 역할 추가: src/app/api/auth/[...nextauth]/route.ts 파일의 callbacks.jwtcallbacks.session 함수에서 사용자 정보를 처리할 때, 데이터베이스에서 가져온 사용자 역할 정보를 세션(token, session)에 추가합니다.

    src/app/api/auth/[...nextauth]/route.ts
    // src/app/api/auth/[...nextauth]/route.ts (수정 예시)
    // ... (기존 코드) ...
    
    callbacks: {
      async jwt({ token, user, account }) {
        if (account && user) {
          // DB에서 사용자 역할 정보 가져오기 (예시)
          // 실제 환경에서는 DB 쿼리 등을 통해 사용자 ID에 맞는 역할을 가져와야 합니다.
          const userRole = user.email === 'admin@example.com' ? 'admin' : 'user';
          token.role = userRole; // JWT 토큰에 역할 추가
        }
        return token;
      },
      async session({ session, token }) {
        session.user.role = token.role; // 세션 객체에 역할 추가
        return session;
      },
    },
    // ... (기존 코드) ...

    참고: 위 예시는 user.email에 따라 역할을 임시로 부여하는 예시입니다. 실제로는 데이터베이스에서 사용자 엔티티에 저장된 role 필드를 가져와야 합니다. 이를 위해서는 NextAuth.js의 데이터베이스 어댑터 설정이 필요합니다.

  2. 보호된 라우트에서 역할 확인

    • 서버 컴포넌트

      src/app/admin/page.tsx
      // src/app/admin/page.tsx (수정)
      import { getServerSession } from 'next-auth';
      import { redirect } from 'next/navigation';
      import { authOptions } from '../api/auth/[...nextauth]/route';
      export default async function AdminPage() {
        const session = await getServerSession(authOptions);
        if (!session) {
          redirect('/api/auth/signin?callbackUrl=/admin');
        }
        // 'admin' 역할이 아니면 접근 거부
        if (session.user?.role !== 'admin') {
          redirect('/unauthorized'); // 권한 없음 페이지로 리다이렉트
        }
        return (
          <div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', border: '1px solid #ffc107', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)', textAlign: 'center' }}>
            <h1 style={{ color: '#ffc107', marginBottom: '20px' }}>관리자 페이지</h1>
            <p>환영합니다, <span style={{ fontWeight: 'bold' }}>{session.user?.name}</span>님! 귀하는 <span style={{ fontWeight: 'bold' }}>{session.user?.role}</span> 권한을 가지고 있습니다.</p>
            <p style={{ marginTop: '10px', fontSize: '0.9em', color: '#555' }}>
              (이 페이지는 'admin' 역할만 접근할 수 있습니다.)
            </p>
          </div>
        );
      }
    • 미들웨어: 미들웨어에서도 withAuth 함수에 callbacks.authorized 옵션을 사용하여 역할 기반 접근 제어를 구현할 수 있습니다.

      src/middleware.ts
      // src/middleware.ts (수정 예시)
      import { withAuth } from 'next-auth/middleware';
      import { NextResponse } from 'next/server'; // Next.js API에서 제공하는 응답 객체
      export default withAuth(
        // `authorized` 콜백에서 요청에 대한 권한을 검사합니다.
        function middleware(req) {
          const token = req.nextauth.token;
          // 예시: '/admin' 경로에 대해서는 'admin' 역할만 허용
          if (req.nextUrl.pathname.startsWith('/admin') && token?.role !== 'admin') {
            return NextResponse.rewrite(new URL('/unauthorized', req.url)); // 권한 없음 페이지로 리다이렉트 (URL 변경)
          }
          // 그 외의 경우는 정상적으로 요청 처리
          return NextResponse.next();
        },
        {
          pages: {
            signIn: '/api/auth/signin',
          },
          callbacks: {
            // 인증 여부만 확인 (세션이 있으면 true)
            // 여기서는 토큰의 존재 여부만으로 인증을 판단하고,
            // 실제 권한 검사는 `middleware` 함수 내부에서 수행합니다.
            authorized: ({ token }) => !!token,
          },
        }
      );
      export const config = {
        matcher: [
          '/dashboard/:path*',
          '/profile/:path*',
          '/admin/:path*',
        ],
      };

      미들웨어에서 권한을 검사하는 방식은 매우 강력하지만, 복잡한 권한 로직을 모두 미들웨어에서 처리하기보다는, 페이지 컴포넌트 내부에서 getServerSession을 통해 상세한 권한 검사를 수행하는 것이 더 관리하기 좋을 때도 있습니다.


실습: 보호된 라우트 및 권한 확인

  1. 이전 절의 NextAuth.js 기본 설정이 완료되어 있고, GitHub 로그인 Provider가 작동하는지 확인합니다.

  2. src/app/dashboard/page.tsx를 위 getServerSession 예시로 생성/수정합니다.

  3. src/app/settings/page.tsx를 위 클라이언트 컴포넌트 예시로 생성합니다.

  4. src/app/admin/page.tsx를 위 서버 컴포넌트 + 역할 확인 예시로 생성합니다.

    • 중요: src/app/api/auth/[...nextauth]/route.ts 파일의 callbacks 부분에 token.role = userRole;와 같이 역할을 할당하는 로직을 추가해야 합니다.
  5. src/app/unauthorized/page.tsx 파일을 생성하여 권한 없음 메시지를 표시하는 페이지를 만듭니다.

    src/app/unauthorized/page.tsx
    // src/app/unauthorized/page.tsx
    export default function UnauthorizedPage() {
      return (
        <div style={{ padding: '20px', maxWidth: '600px', margin: '20px auto', border: '1px solid #dc3545', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)', textAlign: 'center' }}>
          <h1 style={{ color: '#dc3545', marginBottom: '20px' }}>권한 없음 (Unauthorized)</h1>
          <p>이 페이지에 접근할 권한이 없습니다.</p>
          <p style={{ marginTop: '15px' }}>
            <a href="/" style={{ color: '#007bff', textDecoration: 'underline' }}>홈으로 돌아가기</a>
          </p>
        </div>
      );
    }
  6. src/middleware.ts 파일을 생성하여 전역적인 라우트 보호를 테스트해 볼 수 있습니다. (미들웨어와 getServerSession을 동시에 사용할 경우, 미들웨어에서 인증을 처리하므로 getServerSession 호출 로직을 제거해야 함).

  7. 개발 서버를 실행하고 다음 경로로 접속하며 테스트합니다:

    • 로그인하지 않은 상태에서 /dashboard, /settings, /admin에 접속하여 리다이렉트되는지 확인합니다.
    • 로그인한 상태에서 /dashboard, /settings에 접속하여 콘텐츠가 보이는지 확인합니다.
    • 로그인한 상태에서 /admin에 접속하여, 만약 admin 역할이 아니라면 /unauthorized 페이지로 리다이렉트되는지 확인합니다. (GitHub 로그인만 사용한다면, 일반 사용자 계정으로는 admin 페이지에 접근할 수 없을 것입니다.)

보호된 라우트 생성은 애플리케이션의 보안을 강화하고 사용자 경험을 관리하는 데 필수적입니다. Next.js App Router는 서버 컴포넌트와 미들웨어를 통해 이 과정을 매우 효율적으로 만들어 줍니다.