icon
10장 : 인증 및 권한 관리

사용자 역할 기반 접근 제어

이전 절에서 NextAuth.js를 사용하여 사용자의 로그인 상태에 따라 라우트를 보호하는 방법을 알아보았습니다. 하지만 실제 애플리케이션에서는 단순히 로그인 여부만으로 접근을 제어하는 것을 넘어, 사용자의 역할(Role) 이나 권한(Permission) 에 따라 특정 기능이나 콘텐츠에 대한 접근을 세밀하게 제어해야 하는 경우가 많습니다. 이를 역할 기반 접근 제어(Role-Based Access Control, RBAC) 라고 합니다.

이 절에서는 NextAuth.js와 Next.js App Router 환경에서 RBAC를 구현하는 심화된 방법을 다룹니다.


RBAC의 개념

RBAC는 사용자에게 직접 권한을 부여하는 대신, 사용자에게 역할을 할당하고 역할에 권한을 부여하는 방식입니다.

  • 사용자 (User): 애플리케이션을 사용하는 개별 주체.
  • 역할 (Role): 특정 직무나 그룹에 할당된 권한의 집합. (예: admin, editor, viewer, guest)
  • 권한 (Permission): 특정 리소스에 대한 특정 작업 수행 능력. (예: 게시글 생성, 게시글 수정, 사용자 관리, 데이터 조회)

예를 들어, '관리자' 역할은 '사용자 관리', '게시글 수정 및 삭제' 권한을 가질 수 있고, '편집자' 역할은 '게시글 생성 및 수정' 권한만 가질 수 있습니다.


NextAuth.js 세션에 사용자 역할 정보 추가

RBAC를 구현하기 위한 첫 단계는 사용자의 역할 정보를 NextAuth.js 세션 객체에 포함시키는 것입니다. 이렇게 하면 클라이언트와 서버 컴포넌트 모두에서 현재 사용자의 역할을 쉽게 확인할 수 있습니다.

이를 위해서는 NextAuth.js 설정 파일 (src/app/api/auth/[...nextauth]/route.ts)의 callbacks를 수정해야 합니다. 특히 jwt 콜백과 session 콜백에서 사용자 정보를 처리할 때, 데이터베이스에서 가져온 역할 정보를 토큰과 세션에 주입합니다.

src/app/api/auth/[...nextauth]/route.ts 수정 예시

src/app/api/auth/[...nextauth]/route.ts
// src/app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import GitHubProvider from 'next-auth/providers/github';
// import GoogleProvider from 'next-auth/providers/google';
// import { PrismaAdapter } from '@auth/prisma-adapter'; // Prisma 사용 시
// import prisma from '@/lib/prisma'; // Prisma 클라이언트 임포트

// NextAuth.js 타입 확장을 위한 선언 (선택 사항이지만 권장)
declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
      name?: string | null;
      email?: string | null;
      image?: string | null;
      role?: 'admin' | 'editor' | 'user'; // 여기에 사용자 역할 추가
    };
    accessToken?: string;
  }

  interface JWT {
    id: string;
    accessToken?: string;
    role?: 'admin' | 'editor' | 'user'; // 여기에 JWT 토큰에 역할 추가
  }
}

const authOptions = {
  // adapter: PrismaAdapter(prisma), // 데이터베이스 연동 시 어댑터 설정

  providers: [
    GitHubProvider({
      clientId: process.env.GITHUB_ID as string,
      clientSecret: process.env.GITHUB_SECRET as string,
    }),
    // ... 다른 Provider들
  ],

  session: {
    strategy: 'jwt',
  },

  callbacks: {
    async jwt({ token, user, account }) {
      // 초기 로그인 시 (user 객체가 존재할 때)
      if (account && user) {
        token.accessToken = account.access_token;
        token.id = user.id; // DB 연동 시 사용자 ID (예: MongoDB의 _id 또는 SQL의 id)

        // --- 여기서 사용자 역할 정보를 가져와 토큰에 추가합니다 ---
        // 실제 애플리케이션에서는 데이터베이스에서 사용자 ID를 기반으로 역할을 조회합니다.
        // 예시: (데이터베이스 연동 가정)
        // const dbUser = await prisma.user.findUnique({ where: { id: user.id } });
        // if (dbUser) {
        //   token.role = dbUser.role;
        // } else {
        //   token.role = 'user'; // 기본 역할
        // }

        // 간단한 예시 (이메일 기반 역할 할당):
        if (user.email === 'admin@example.com') { // 관리자 이메일 설정
          token.role = 'admin';
        } else if (user.email === 'editor@example.com') { // 편집자 이메일 설정
          token.role = 'editor';
        } else {
          token.role = 'user'; // 기본 사용자 역할
        }
      }
      return token;
    },
    async session({ session, token }) {
      // JWT 토큰의 정보를 세션 객체에 반영
      session.accessToken = token.accessToken as string;
      session.user.id = token.id as string;
      session.user.role = token.role as 'admin' | 'editor' | 'user'; // 세션 user 객체에 역할 추가
      return session;
    },
  },

  // ... (다른 설정) ...

  secret: process.env.NEXTAUTH_SECRET,
};

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST, authOptions }; // authOptions도 export하여 getServerSession에서 사용

데이터베이스 연동 (권장): 실제 프로덕션 환경에서는 사용자 역할 정보를 하드코딩하는 대신, 데이터베이스에 사용자 테이블을 만들고 role 필드를 추가하여 관리해야 합니다. NextAuth.js는 Prisma, TypeORM 등 다양한 ORM을 위한 어댑터(Adapter) 를 제공하여 데이터베이스 연동을 쉽게 할 수 있습니다.

Prisma 어댑터 사용 예시 (간략)

  1. npm install @auth/prisma-adapter prisma 설치
  2. schema.prismaUser, Account, Session, VerificationToken 모델 추가 (NextAuth.js 문서 참조)
  3. prisma generate 실행
  4. authOptionsadapter: PrismaAdapter(prisma) 추가

서버 컴포넌트에서 역할 기반 접근 제어

가장 안전하고 권장되는 방법은 서버 컴포넌트에서 getServerSession을 통해 사용자의 역할을 확인하고 접근을 제어하는 것입니다.

src/app/admin/page.tsx (관리자 페이지) 수정 예시

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);

  // 1. 로그인 여부 확인
  if (!session) {
    redirect('/api/auth/signin?callbackUrl=/admin');
  }

  // 2. 사용자 역할 확인 (RBAC)
  if (session.user?.role !== 'admin') {
    // 관리자 역할이 아니면 권한 없음 페이지로 리다이렉트
    redirect('/unauthorized');
  }

  // 3. 관리자 역할인 경우에만 페이지 콘텐츠 렌더링
  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' }}>
        (서버 컴포넌트에서 역할 기반 접근 제어가 적용되었습니다.)
      </p>
    </div>
  );
}

미들웨어를 활용한 역할 기반 접근 제어

미들웨어는 모든 요청에 대해 실행되므로, 전역적인 역할 기반 접근 제어에 유용하게 사용될 수 있습니다. 특히 특정 경로 패턴에 대해 특정 역할만 허용하도록 설정할 수 있습니다.

src/middleware.ts 수정 예시

src/middleware.ts
// src/middleware.ts
import { withAuth } from 'next-auth/middleware';
import { NextResponse } from 'next/server';

export default withAuth(
  // `middleware` 함수는 요청이 들어올 때마다 실행됩니다.
  function middleware(req) {
    const token = req.nextauth.token; // NextAuth.js가 제공하는 JWT 토큰
    const pathname = req.nextUrl.pathname;

    // 예시: '/admin' 경로에 대한 접근 제어
    if (pathname.startsWith('/admin')) {
      // 토큰이 없거나, 토큰에 역할이 없거나, 역할이 'admin'이 아니면
      if (!token || token.role !== 'admin') {
        // 권한 없음 페이지로 리다이렉트 (URL 변경)
        return NextResponse.rewrite(new URL('/unauthorized', req.url));
      }
    }

    // 예시: '/editor' 경로에 대한 접근 제어 (admin 또는 editor만 허용)
    if (pathname.startsWith('/editor')) {
      if (!token || (token.role !== 'admin' && token.role !== 'editor')) {
        return NextResponse.rewrite(new URL('/unauthorized', req.url));
      }
    }

    // 그 외의 경우는 정상적으로 요청 처리
    return NextResponse.next();
  },
  {
    // `pages` 옵션은 인증되지 않은 사용자를 리다이렉트할 페이지를 지정합니다.
    pages: {
      signIn: '/api/auth/signin',
    },
    // `callbacks.authorized`는 미들웨어에서 인증 여부만 확인합니다.
    // 실제 역할 검사는 위의 `middleware` 함수에서 수행합니다.
    callbacks: {
      authorized: ({ token }) => !!token, // 토큰이 있으면 인증된 것으로 간주
    },
  }
);

export const config = {
  // 미들웨어가 적용될 라우트 경로를 정의합니다.
  matcher: [
    '/dashboard/:path*',
    '/profile/:path*',
    '/admin/:path*',
    '/editor/:path*', // 새로운 보호 경로 추가
  ],
};

미들웨어 사용 시 고려사항

  • 미들웨어는 모든 요청에 대해 실행되므로, 복잡한 데이터베이스 쿼리나 외부 API 호출은 성능에 영향을 줄 수 있습니다.
  • 권한 검사 로직이 너무 복잡해지면 미들웨어보다는 개별 서버 컴포넌트에서 getServerSession을 통해 상세하게 제어하는 것이 더 효율적일 수 있습니다.
  • NextResponse.rewrite는 클라이언트의 URL을 변경하지 않고 내부적으로 다른 페이지를 렌더링합니다. 만약 URL 자체를 변경하고 싶다면 NextResponse.redirect를 사용해야 합니다.

클라이언트 컴포넌트에서 역할 기반 UI 제어

클라이언트 컴포넌트에서는 useSession 훅을 사용하여 현재 사용자의 역할을 가져와 UI 요소를 조건부로 렌더링하거나, 특정 기능에 대한 접근을 제한할 수 있습니다. 이는 서버에서 이미 접근이 허용된 페이지 내에서 사용자 경험을 향상시키는 데 유용합니다.

예시: '편집' 버튼 조건부 렌더링

src/components/PostActions.tsx
// src/components/PostActions.tsx (클라이언트 컴포넌트)
"use client";

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

interface PostActionsProps {
  postId: string;
  authorId: string;
}

export default function PostActions({ postId, authorId }: PostActionsProps) {
  const { data: session, status } = useSession();

  if (status === 'loading') {
    return <p>권한 확인 중...</p>;
  }

  // 사용자가 로그인되어 있고,
  // 1. 관리자 역할이거나
  // 2. 게시글 작성자 본인이거나 (데이터베이스 연동 시 authorId와 session.user.id 비교)
  const canEdit = session && (session.user?.role === 'admin' || session.user?.id === authorId);

  if (!canEdit) {
    return null; // 편집 권한이 없으면 아무것도 렌더링하지 않음
  }

  return (
    <div style={{ marginTop: '20px', borderTop: '1px solid #eee', paddingTop: '15px' }}>
      <button
        onClick={() => alert(`게시글 ${postId} 편집`)}
        style={{
          padding: '8px 15px',
          backgroundColor: '#007bff',
          color: 'white',
          border: 'none',
          borderRadius: '5px',
          cursor: 'pointer',
          marginRight: '10px'
        }}
      >
        게시글 편집
      </button>
      <button
        onClick={() => alert(`게시글 ${postId} 삭제`)}
        style={{
          padding: '8px 15px',
          backgroundColor: '#dc3545',
          color: 'white',
          border: 'none',
          borderRadius: '5px',
          cursor: 'pointer'
        }}
      >
        게시글 삭제
      </button>
    </div>
  );
}

주의: 클라이언트 측에서의 UI 제어는 사용자 경험을 위한 것이며, 보안의 최종 방어선이 되어서는 안 됩니다. 악의적인 사용자는 클라이언트 측 JavaScript를 우회할 수 있으므로, 중요한 기능(예: 데이터 수정, 삭제)에 대한 권한 검사는 항상 서버 측(API Route Handler 또는 Server Action) 에서 다시 한번 수행해야 합니다.


실습: 사용자 역할 기반 접근 제어 확인

  1. NextAuth.js 설정 파일 (src/app/api/auth/[...nextauth]/route.ts)에서 callbacks 부분을 수정하여 admin@example.com 이메일로 로그인할 경우 'admin' 역할을 부여하도록 설정합니다. (또는 실제 데이터베이스 연동을 통해 역할을 부여합니다.)
  2. src/app/admin/page.tsx를 위 서버 컴포넌트 예시로 수정하여 'admin' 역할이 아니면 /unauthorized로 리다이렉트되도록 합니다.
  3. src/app/editor/page.tsx를 새로 생성하여 'admin' 또는 'editor' 역할만 접근 가능하도록 설정합니다. (서버 컴포넌트 또는 미들웨어 활용)
    src/app/editor/page.tsx
    // src/app/editor/page.tsx
    import { getServerSession } from 'next-auth';
    import { redirect } from 'next/navigation';
    import { authOptions } from '../api/auth/[...nextauth]/route';
    
    export default async function EditorPage() {
      const session = await getServerSession(authOptions);
    
      if (!session) {
        redirect('/api/auth/signin?callbackUrl=/editor');
      }
    
      if (session.user?.role !== 'admin' && session.user?.role !== 'editor') {
        redirect('/unauthorized');
      }
    
      return (
        <div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', border: '1px solid #17a2b8', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)', textAlign: 'center' }}>
          <h1 style={{ color: '#17a2b8', 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' }}>
            (서버 컴포넌트에서 역할 기반 접근 제어가 적용되었습니다.)
          </p>
        </div>
      );
    }
  4. src/middleware.ts 파일을 위 미들웨어 예시로 수정하여 /admin/editor 경로에 대한 역할 기반 접근 제어를 적용합니다.
  5. src/components/PostActions.tsx 파일을 생성하고, 게시글 상세 페이지 등에서 이 컴포넌트를 임포트하여 사용합니다. (예: src/app/posts/[id]/page.tsx에 추가)
  6. 개발 서버를 실행하고 다음 시나리오를 테스트합니다.
    • 일반 사용자 (user 역할)로 로그인하여 /admin/editor 페이지에 접근을 시도합니다. (/unauthorized로 리다이렉트되어야 합니다.)
    • editor@example.com으로 로그인하여 /editor 페이지에는 접근할 수 있지만, /admin 페이지에는 접근할 수 없는지 확인합니다.
    • admin@example.com으로 로그인하여 모든 페이지에 접근할 수 있는지 확인합니다.
    • 게시글 상세 페이지에서 PostActions 컴포넌트가 역할에 따라 다르게 표시되는지 확인합니다.

사용자 역할 기반 접근 제어는 복잡한 애플리케이션에서 필수적인 보안 기능입니다. Next.js와 NextAuth.js는 서버 컴포넌트, 미들웨어, 클라이언트 훅을 통해 이러한 제어를 효과적으로 구현할 수 있는 강력한 도구를 제공합니다. 항상 서버 측 검사를 보안의 최전선으로 삼고, 클라이언트 측 제어는 사용자 경험 향상을 위한 보조적인 수단으로 활용하는 것이 중요합니다.