테마 전환

Next.js 관리자 페이지 실전: RBAC 권한 시스템 설계부터 구현까지 완벽 가이드

Easton editorial illustration: one admin console with a central role gate and three protected modules

편집기에서 23번째 if (user.role === 'admin')를 바라보고 있었습니다.

작년에 인수한 관리자 페이지 프로젝트에는 이전 동료가 남긴 권한 제어 코드가 있었습니다. UserRole 판단이 20개가 넘는 파일에 흩어져 있어 역할을 하나 추가할 때마다 전체 검색으로 코드를 수정해야 했습니다. 한 번은 한 곳을 빠뜨리는 바람에 일반 사용자에게 재무 보고서가 그대로 노출됐고, 한밤중에 전화 세례를 받고 일어나 버그를 고쳐야 했습니다.

그 무렵 Next.js 관리자 페이지 구현 방식을 샅샅이 찾아보면서 권한 시스템 때문에 골머리를 앓는 사람이 정말 많다는 것을 알았습니다. RBAC를 써야 한다는 건 알지만 데이터 테이블은 어떻게 설계할까요? 미들웨어는 어떻게 작성할까요? 동적 메뉴는 어떻게 만들까요? 테이블은 Ant Design과 shadcn/ui 중 무엇을 선택해야 할까요? 이런 질문에는 정답이 없어서 시행착오를 피하기 어렵습니다.

2주에 걸쳐 권한 시스템 리팩터링을 마친 뒤에야 편히 잘 수 있었습니다. 이 글에는 그때의 경험을 정리했습니다. RBAC 아키텍처 설계부터 Next.js 15 미들웨어 구현, 동적 메뉴 생성, 테이블 컴포넌트 선택까지 전체 방안을 한데 담았습니다.

RBAC 권한 모델 설계(왜 이렇게 설계할까)

RBAC란 무엇이며 왜 널리 사용할까

RBAC는 Role-Based Access Control, 즉 역할 기반 접근 제어의 약자입니다. 핵심 개념은 아주 간단합니다. 사용자 → 역할 → 권한 → 리소스입니다.

사용자에게 권한을 직접 할당하면 안 되냐고 물을 수 있습니다. 할 수는 있지만 번거롭습니다.

회사에 상담원 5명이 새로 들어왔다고 생각해 봅시다. 사용자에게 권한을 직접 연결하면 주문 조회, 댓글 답변, 보고서 내보내기 등을 사람마다 따로 설정해야 합니다. 다섯 명이면 같은 설정을 다섯 번 해야 합니다. RBAC를 쓰면 어떨까요? ‘상담원’ 역할을 하나 만들고 권한을 역할에 연결한 뒤, 신입에게 역할만 할당하면 됩니다. 한 번 설정하면 계속 쓸 수 있습니다.

더 중요한 것은 유지 관리 비용입니다. 제품 관리자가 “앞으로 상담원은 보고서를 내보낼 수 없게 해 주세요. 데이터가 너무 민감해요”라고 요청해도 RBAC에서는 역할 권한을 한 번만 수정하면 모든 상담원에게 즉시 반영됩니다. 사용자에게 권한을 직접 연결했다면 한 명씩 수정해야 하고, 한 사람이라도 빠뜨리면 프로덕션 사고로 이어집니다.

80%+
엔터프라이즈 SaaS 애플리케이션의 RBAC 사용률
유연성과 유지 관리성의 균형을 이루며 ABAC보다 단순하고 사용자-권한 직접 연결보다 유연함

해외 엔터프라이즈 SaaS 애플리케이션의 80% 이상은 RBAC 또는 그 변형을 사용합니다. 이유는 현실적입니다. 유연성과 유지 관리성 사이에서 균형이 좋기 때문입니다. ABAC(Attribute-Based Access Control, 속성 기반 접근 제어)보다 단순하고 사용자-권한을 직접 연결하는 방식보다 유연합니다.

지치지 않으려면 권한 세분성을 어떻게 설계해야 할까

권한 세분성은 묘한 문제입니다. 너무 거칠게 설계하면 필요한 제어를 할 수 없고, 너무 세밀하게 설계하면 유지 관리 비용이 치솟습니다.

제 경험으로는 세 단계로 나누는 것이 좋습니다.

페이지 단위 권한(라우트 단계)

  • 사용자가 특정 페이지에 접근할 수 있는지 제어하는 가장 기본적인 권한입니다.
  • 예를 들어 /admin/users에는 관리자만 접근할 수 있습니다.
  • 뒤에서 자세히 설명할 Next.js 미들웨어로 구현합니다.

모듈 단위 권한(메뉴 단계)

  • 사이드바에 어떤 메뉴 항목을 표시할지 제어합니다.
  • 접근 권한이 없는 메뉴를 사용자에게 보여 주지 않으므로 경험이 더 좋아집니다.
  • 프런트엔드에서 권한에 따라 메뉴 설정을 동적으로 필터링합니다.

작업 단위 권한(버튼 단계)

  • 구체적인 작업 버튼까지 세밀하게 제어합니다.
  • 예를 들어 ‘사용자 삭제’ 버튼은 최고 관리자에게만 표시합니다.
  • 모든 버튼에 권한 제어가 필요한 것은 아니므로 신중하게 사용해야 합니다.

솔직히 말해 제가 본 가장 과도한 사례는 테이블의 열마다 권한 제어를 적용한 경우였습니다. 결국 설정은 터무니없이 복잡해지고 성능도 크게 떨어졌습니다. 한 가지 원칙을 기억하세요. 과도하게 설계하지 마세요.

권한 이름은 resource:action 형식을 권장합니다.

  • user:create - 사용자 생성
  • order:delete - 주문 삭제
  • report:export - 보고서 내보내기

한눈에 이해할 수 있고 정렬과 검색도 편리합니다.

데이터베이스 테이블 구조는 이렇게 설계합니다

핵심은 사용자, 역할, 권한, 리소스를 나타내는 테이블 4개입니다. 여기에 다대다 관계를 처리할 관계 테이블 2개를 추가합니다.

// 사용자 테이블
User {
  id: string
  name: string
  email: string
  // 기타 사용자 정보
}

// 역할 테이블
Role {
  id: string
  name: string  // "관리자", "상담원", "운영 담당자"
  code: string  // "admin", "service", "operator"
  description: string
}

// 권한 테이블
Permission {
  id: string
  name: string  // "사용자 생성"
  code: string  // "user:create"
  resource: string  // "user"
  action: string  // "create"
}

// 리소스 테이블(선택 사항, 비즈니스 복잡도에 따라 결정)
Resource {
  id: string
  name: string  // "사용자 관리"
  code: string  // "user"
  type: string  // "page" | "api" | "menu"
}

// 사용자-역할 관계 테이블
UserRole {
  userId: string
  roleId: string
}

// 역할-권한 관계 테이블
RolePermission {
  roleId: string
  permissionId: string
}

User 테이블에 roleId를 바로 추가하면 안 되냐고 묻는 사람도 있습니다. 답은 한 사용자가 여러 역할을 가질 수 있기 때문입니다.

예를 들어 김철수는 ‘기술 책임자’이면서 ‘콘텐츠 검수자’일 수 있고 두 역할의 권한을 합쳐야 합니다. 중간 테이블을 사용하면 이런 상황을 자연스럽게 지원할 수 있고, 나중에 조회할 때 JOIN 한 번이면 됩니다.

비즈니스에 조직 구조(부서, 직책)도 필요하다면 Department와 Position 테이블을 추가할 수 있습니다. 하지만 처음부터 모든 테이블을 만들지는 마세요. 필요에 따라 확장하는 것이 바른 접근입니다. Prisma 같은 ORM을 사용하면 나중에 필드와 테이블을 추가하기도 꽤 쉽습니다.

Next.js 미들웨어로 라우트 보호 구현(핵심 기술 구현)

미들웨어가 꼭 필요한 이유

처음 권한 제어를 구현했을 때는 페이지 컴포넌트마다 판단 로직을 잔뜩 작성했습니다. 대략 이런 모습이었습니다.

// ❌ 잘못된 예
export default function UsersPage() {
  const { user } = useSession()

  if (!user) {
    redirect('/login')
  }

  if (user.role !== 'admin') {
    return <div>접근 권한이 없습니다</div>
  }

  return <div>사용자 목록...</div>
}

문제가 없어 보이나요? 하지만 다음과 같은 문제가 생깁니다.

  1. 모든 페이지에 같은 코드를 작성해야 해서 복사·붙여넣기에 지칩니다.
  2. 특정 페이지를 빠뜨리기 쉬워 보안 위험이 생깁니다.
  3. 페이지를 렌더링한 뒤 권한을 판단하므로 사용자에게 화면 깜빡임이 보입니다.
  4. 서버 측 렌더링에서는 로직이 더 복잡해집니다.

Next.js 미들웨어는 이런 문제를 완벽하게 해결합니다. 요청이 페이지에 도달하기 전에 실행되어 한곳에서 차단하고 처리합니다. 성능이 좋고 코드가 깔끔하며 유지 관리 비용도 낮습니다.

6080%
응답 속도 향상
미들웨어 권한 검증은 컴포넌트 단위 판단보다 6080% 빠르고 불필요한 컴포넌트 렌더링을 줄임

middleware.ts 전체 구현

Next.js 15의 미들웨어는 프로젝트 루트 디렉터리의 middleware.ts에 작성합니다. 여기서는 NextAuth로 인증하지만 Clerk나 다른 방법으로 바꿔도 됩니다.

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getToken } from 'next-auth/jwt'

// 라우트 권한 매핑 정의
const ROUTE_PERMISSIONS = {
  '/admin': ['admin'],  // admin 역할만 접근 가능
  '/admin/users': ['admin', 'operator'],  // admin과 operator 모두 접근 가능
  '/dashboard': ['admin', 'operator', 'viewer'],  // 세 역할 모두 접근 가능
  '/reports': ['admin'],
} as const

// 로그인이 필요 없는 공개 라우트
const PUBLIC_ROUTES = ['/login', '/register', '/forgot-password']

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // 1. 공개 라우트는 바로 통과
  if (PUBLIC_ROUTES.includes(pathname)) {
    return NextResponse.next()
  }

  // 2. 사용자 세션 가져오기
  const token = await getToken({
    req: request,
    secret: process.env.NEXTAUTH_SECRET,
  })

  // 3. 로그인하지 않았으면 로그인 페이지로 리디렉션
  if (!token) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('from', pathname)  // 로그인 후 돌아올 원래 위치 기록
    return NextResponse.redirect(loginUrl)
  }

  // 4. 라우트 권한 확인
  const userRole = token.role as string
  const requiredRoles = ROUTE_PERMISSIONS[pathname as keyof typeof ROUTE_PERMISSIONS]

  if (requiredRoles && !requiredRoles.includes(userRole)) {
    // 권한이 부족하면 403 반환
    return NextResponse.rewrite(new URL('/403', request.url))
  }

  // 5. 권한 검증을 통과했으므로 요청 계속 처리
  return NextResponse.next()
}

// 미들웨어 매칭 규칙 설정
export const config = {
  matcher: [
    // 정적 파일과 API 라우트를 제외한 모든 라우트 매칭(필요에 따라 조정)
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}

몇 가지 핵심을 살펴보겠습니다.

라우트 권한 매핑: 상수 객체로 작성해 한눈에 파악할 수 있습니다. 새 라우트를 추가할 때 여기에 바로 추가하면 되므로 코드 전체를 뒤질 필요가 없습니다.

공개 라우트 화이트리스트: 로그인과 회원가입처럼 인증이 필요 없는 라우트를 따로 나열해 무한 루프를 방지합니다. 사용자가 로그인하려는데 미들웨어가 다시 로그인 페이지로 보내 버리는 상황을 막는 것입니다.

로그인 출처 기록: loginUrl.searchParams.set('from', pathname)은 매우 중요합니다. /admin/users에 접근하다 차단된 사용자는 로그인에 성공한 뒤 홈이 아니라 /admin/users로 돌아가야 합니다. 사용자 경험을 좌우하는 세부 사항입니다.

권한 부족 처리: 여기서는 redirect 대신 NextResponse.rewrite를 사용했습니다. URL은 바뀌지 않고 콘텐츠만 403 페이지로 바뀝니다. 전용 권한 부족 안내 페이지로 리디렉션해도 됩니다.

프런트엔드와 백엔드 권한 검증의 연계

중요한 점이 있습니다. 미들웨어는 첫 번째 방어선일 뿐이며 백엔드 API에서 반드시 한 번 더 검증해야 합니다.

프런트엔드 권한 판단의 본질은 사용자 경험 개선입니다. 브라우저에서 실행되는 코드는 모두 수정할 수 있고 개발자 도구만 열면 권한 판단을 쉽게 우회할 수 있습니다. 실제 보안 방어선은 서버에 있습니다.

Next.js의 Server Actions와 API 라우트에서는 반드시 권한을 다시 검증해야 합니다.

// app/actions/deleteUser.ts
'use server'

import { auth } from '@/lib/auth'
import { db } from '@/lib/db'

export async function deleteUser(userId: string) {
  // 사용자 권한 다시 검증
  const session = await auth()

  if (!session || session.user.role !== 'admin') {
    throw new Error('이 작업을 실행할 권한이 없습니다')
  }

  // 삭제 실행
  await db.user.delete({ where: { id: userId } })

  return { success: true }
}

이렇게 프런트엔드와 백엔드에 이중 보안 장치가 생깁니다.

  • 프런트엔드 미들웨어: 빠르게 피드백하고 사용자가 접근 권한이 없는 페이지를 보지 않게 합니다.
  • 백엔드 검증: 실제 보안 방어선으로 악의적인 요청을 차단합니다.

권한 설정을 공유 모듈로 분리해 프런트엔드와 백엔드가 같은 설정을 참조하도록 하는 팀도 있습니다. 그러면 규칙의 일관성을 유지할 수 있으며 monorepo에서 특히 편리합니다.

성능 최적화: 권한 정보를 어디에 둘까

요청마다 데이터베이스에서 사용자 권한을 가져와야 할까요? 그러지 마세요. 너무 느립니다.

두 가지 방법이 있습니다.

방식 1: 권한 정보를 JWT에 인코딩

// NextAuth callbacks
callbacks: {
  async jwt({ token, user }) {
    if (user) {
      token.role = user.role
      token.permissions = user.permissions  // 권한 목록을 그대로 포함
    }
    return token
  }
}

장점은 미들웨어에서 데이터베이스를 다시 조회하지 않아도 된다는 것입니다. 단점은 권한을 수정해도 token이 만료될 때까지 기다려야 반영된다는 점입니다. 권한 변경이 잦지 않은 환경에 적합합니다.

방식 2: Redis에 사용자 권한 캐시

권한이 자주 바뀐다면 사용자 권한을 Redis에 캐시하고 미들웨어에서 캐시를 조회할 수 있습니다. 빠르고 실시간성이 좋지만 의존성이 한 단계 늘어납니다.

제 프로젝트에서는 방식 1을 사용했고 token 만료 시간을 1시간으로 설정했습니다. 관리자가 권한을 바꾼 뒤 사용자에게 다시 로그인하라고 안내하면 됩니다. 권한 조정은 자주 일어나는 작업이 아니기 때문입니다.

동적 메뉴 생성과 권한 연동(사용자 경험의 핵심)

메뉴 설정 데이터 구조

동적 메뉴의 핵심은 사용자 권한에 따라 메뉴 항목을 필터링하는 것입니다. 먼저 전체 메뉴 설정을 준비한 뒤 현재 사용자의 권한에 따라 동적으로 필터링합니다.

제 메뉴 설정은 다음과 같습니다.

// config/menu.ts
import { Home, Users, Settings, FileText } from 'lucide-react'

export interface MenuItem {
  key: string
  label: string
  icon: React.ComponentType
  path?: string
  permission?: string  // 필요한 권한
  children?: MenuItem[]
}

export const MENU_CONFIG: MenuItem[] = [
  {
    key: 'dashboard',
    label: '대시보드',
    icon: Home,
    path: '/dashboard',
    // permission을 설정하지 않으면 로그인한 모든 사용자가 볼 수 있음
  },
  {
    key: 'users',
    label: '사용자 관리',
    icon: Users,
    permission: 'user:read',  // user:read 권한 필요
    children: [
      {
        key: 'users-list',
        label: '사용자 목록',
        path: '/admin/users',
        permission: 'user:read',
      },
      {
        key: 'users-roles',
        label: '역할 관리',
        path: '/admin/roles',
        permission: 'role:read',
      },
    ],
  },
  {
    key: 'reports',
    label: '보고서 센터',
    icon: FileText,
    path: '/reports',
    permission: 'report:read',
  },
  {
    key: 'settings',
    label: '시스템 설정',
    icon: Settings,
    path: '/settings',
    permission: 'system:config',
  },
]

이 구조에는 몇 가지 핵심이 있습니다.

플랫 구조와 트리 구조 중 무엇을 선택할까? 저는 트리 구조를 선택했습니다. 중첩 관계가 명확하고 렌더링할 때 재귀적으로 처리하면 됩니다. parentKey로 관계를 나타내는 플랫 구조를 선호하는 사람도 있으며 각각 장단점이 있습니다.

permission 필드는 선택 사항입니다. permission을 설정하지 않은 메뉴 항목은 로그인한 모든 사용자에게 표시됩니다. ‘대시보드’ 같은 기본 페이지는 일반적으로 제한하지 않습니다.

아이콘은 문자열이 아니라 컴포넌트를 사용합니다. lucide-react의 아이콘 컴포넌트를 직접 가져오면 타입 안전성이 보장되고 렌더링도 편리합니다.

메뉴 필터링 알고리즘

설정을 만들었으니 이제 핵심인 사용자 권한에 따른 메뉴 필터링을 구현합니다.

여기에는 한 가지 함정이 있습니다. 상위 메뉴 권한은 없지만 하위 메뉴 권한은 있다면 어떻게 처리해야 할까요?

예를 들어 사용자는 user:read 권한이 없지만 role:read 권한은 가지고 있습니다. 이때 ‘사용자 관리’ 상위 메뉴를 표시해야 할까요, 숨겨야 할까요?

제 전략은 표시할 수 있는 하위 메뉴가 하나라도 있으면 상위 메뉴도 표시하는 것입니다. 그러면 사용자가 권한이 있는 하위 메뉴를 보고 접근할 수 있습니다.

// lib/menu.ts
export function filterMenuByPermissions(
  menuItems: MenuItem[],
  userPermissions: string[]
): MenuItem[] {
  return menuItems
    .map((item) => {
      // 하위 메뉴 처리
      const filteredChildren = item.children
        ? filterMenuByPermissions(item.children, userPermissions)
        : undefined

      // 현재 항목의 표시 여부 판단
      const hasPermission =
        !item.permission || userPermissions.includes(item.permission)

      const hasVisibleChildren =
        filteredChildren && filteredChildren.length > 0

      // 권한도 없고 표시 가능한 하위 메뉴도 없으면 제거
      if (!hasPermission && !hasVisibleChildren) {
        return null
      }

      // 필터링한 메뉴 항목 반환
      return {
        ...item,
        children: filteredChildren,
      }
    })
    .filter((item): item is MenuItem => item !== null)
}

재귀적으로 필터링하므로 로직이 명확합니다. 성능도 좋습니다. 메뉴 항목이 아주 많아 봐야 수십 개 정도이기 때문입니다.

컴포넌트에서 사용하기

재사용하기 쉽도록 메뉴 필터링 로직을 React Hook으로 래핑했습니다.

// hooks/usePermissionMenu.ts
'use client'

import { useMemo } from 'react'
import { useSession } from 'next-auth/react'
import { filterMenuByPermissions } from '@/lib/menu'
import { MENU_CONFIG } from '@/config/menu'

export function usePermissionMenu() {
  const { data: session } = useSession()

  const filteredMenu = useMemo(() => {
    if (!session?.user?.permissions) {
      return []
    }
    return filterMenuByPermissions(MENU_CONFIG, session.user.permissions)
  }, [session?.user?.permissions])

  return filteredMenu
}

useMemo로 결과를 캐시해 렌더링할 때마다 다시 계산하지 않도록 했습니다. 권한 목록이 바뀌지 않으면 메뉴도 다시 필터링하지 않습니다.

사이드바 컴포넌트에서는 간단하게 사용할 수 있습니다.

// components/Sidebar.tsx
'use client'

import { usePermissionMenu } from '@/hooks/usePermissionMenu'

export function Sidebar() {
  const menu = usePermissionMenu()

  return (
    <nav>
      {menu.map((item) => (
        <MenuItem key={item.key} item={item} />
      ))}
    </nav>
  )
}

깔끔합니다.

라우트 강조 표시와 브레드크럼

메뉴 필터링을 마쳤다면 현재 라우트 강조 표시브레드크럼 탐색이라는 세부 사항도 처리해야 합니다.

라우트 강조 표시는 pathname을 매칭해 구현합니다.

'use client'

import { usePathname } from 'next/navigation'

function MenuItem({ item }: { item: MenuItem }) {
  const pathname = usePathname()
  const isActive = item.path === pathname

  return (
    <Link
      href={item.path || '#'}
      className={isActive ? 'bg-blue-100 text-blue-600' : 'text-gray-700'}
    >
      <item.icon />
      {item.label}
    </Link>
  )
}

브레드크럼은 조금 더 복잡합니다. 현재 라우트에 해당하는 메뉴 경로를 찾아야 합니다.

// lib/menu.ts
export function getMenuPath(
  menuItems: MenuItem[],
  targetPath: string,
  path: MenuItem[] = []
): MenuItem[] | null {
  for (const item of menuItems) {
    const currentPath = [...path, item]

    if (item.path === targetPath) {
      return currentPath
    }

    if (item.children) {
      const result = getMenuPath(item.children, targetPath, currentPath)
      if (result) return result
    }
  }

  return null
}

재귀적으로 검색해 루트 노드부터 현재 노드까지의 경로를 반환합니다. 브레드크럼 컴포넌트에서는 이 경로를 그대로 렌더링하면 됩니다.

동적 라우트(예: /admin/users/123)는 별도로 처리해야 합니다. 매칭할 때 동적 매개변수 부분을 제거해야 하며, 구체적인 비즈니스 요구 사항에 따라 조정하면 됩니다.

데이터 테이블 컴포넌트 선택과 실전(유용한 도구)

2026년 주요 테이블 솔루션 비교

관리자 페이지에는 테이블이 빠질 수 없습니다. 사용자 목록, 주문 목록, 로그 목록 등 어디에나 테이블이 있습니다. 적절한 테이블 라이브러리를 고르면 많은 수고를 덜 수 있습니다.

주요 솔루션을 모두 사용해 보고 느낀 점을 정리해 보겠습니다.

Ant Design Table

  • 장점: 기능이 풍부하고 문서가 상세하며 중국어 지원이 좋습니다. 정렬, 필터링, 페이지네이션, 행 펼치기, 열 고정 기능을 모두 제공합니다.
  • 단점: 스타일을 맞춤 설정하기 번거롭고 bundle 크기가 큽니다(antd 전체가 번들에 포함됨). 디자인 스타일도 고정적입니다.
  • 적합한 경우: 전통적인 관리자 페이지, 팀이 Ant Design에 익숙한 프로젝트.

MUI DataGrid

  • 장점: Material Design 스타일이고 기능이 강력하며 가상 스크롤, 열 순서 변경 같은 엔터프라이즈 기능을 제공합니다.
  • 단점: 고급 기능은 유료(Pro 버전)이며 학습 곡선이 가파르고 스타일을 덮어쓰기도 복잡합니다.
  • 적합한 경우: 예산이 충분하고 엔터프라이즈 기능이 필요한 대형 프로젝트.

shadcn/ui + TanStack Table

  • 장점: 스타일 제약이 없어 자유롭게 맞춤 설정할 수 있고 TypeScript 친화적이며 성능이 뛰어납니다. 컴포넌트를 직접 제어하고 필요한 것만 가져옵니다.
  • 단점: 스타일과 UI를 직접 작성해야 하므로 초기 투자 시간이 큽니다.
  • 적합한 경우: 유연성과 성능을 중시하고 코드를 작성할 준비가 된 팀의 현대적인 프로젝트.

React-Admin

  • 장점: CRUD와 권한을 통합한 올인원 솔루션으로 즉시 사용할 수 있습니다.
  • 단점: 프레임워크에 종속되고 유연성이 부족하며 맞춤 설정에 제약이 있습니다.
  • 적합한 경우: 빠른 프로토타입, 표준화된 CRUD 애플리케이션.
300%+
shadcn/ui 성장률
2024~2026년에 shadcn/ui + TanStack Table 조합이 300% 이상 성장하며 현대적인 관리자 페이지의 대표 솔루션으로 자리 잡음

저는 최종적으로 shadcn/ui + TanStack Table을 선택했습니다. 이유는 간단합니다. 프로젝트에서 Tailwind CSS를 사용하고 있어 shadcn/ui를 자연스럽게 통합할 수 있고, 스타일을 직접 제어해 원하는 대로 바꿀 수 있기 때문입니다. TanStack Table의 API 설계도 매우 훌륭합니다. 로직과 UI가 분리되어 있어 UI 라이브러리를 바꿔도 로직을 다시 작성할 필요가 없습니다.

shadcn/ui 테이블 구현 상세 설명

shadcn/ui의 Data Table은 완성된 컴포넌트를 바로 주는 대신 구축 방법을 알려 줍니다. 핵심은 TanStack Table이고 shadcn/ui는 기본 Table UI 컴포넌트를 제공합니다.

먼저 의존성을 설치합니다.

npx shadcn@latest add table
npm install @tanstack/react-table

그런 다음 DataTable 컴포넌트를 만듭니다(전체 코드는 글 앞부분의 코드 예시를 참고하세요).

사용법은 간단합니다. 열 설정만 정의하면 됩니다.

// app/admin/users/page.tsx
'use client'

import { ColumnDef } from '@tanstack/react-table'
import { DataTable } from '@/components/DataTable'
import { Button } from '@/components/ui/button'
import { usePermission } from '@/hooks/usePermission'

interface User {
  id: string
  name: string
  email: string
  role: string
}

const columns: ColumnDef<User>[] = [
  {
    accessorKey: 'name',
    header: '이름',
  },
  {
    accessorKey: 'email',
    header: '이메일',
  },
  {
    accessorKey: 'role',
    header: '역할',
  },
  {
    id: 'actions',
    cell: ({ row }) => {
      const user = row.original
      const { hasPermission } = usePermission()

      return (
        <div className="flex gap-2">
          {hasPermission('user:update') && (
            <Button size="sm" variant="outline">
              수정
            </Button>
          )}
          {hasPermission('user:delete') && (
            <Button size="sm" variant="destructive">
              삭제
            </Button>
          )}
        </div>
      )
    },
  },
]

export default function UsersPage() {
  // 실제 프로젝트에서는 여기서 데이터를 비동기로 가져와야 함
  const users: User[] = [
    { id: '1', name: '김철수', email: '[email protected]', role: 'admin' },
    { id: '2', name: '이영희', email: '[email protected]', role: 'user' },
  ]

  return (
    <div className="container mx-auto py-10">
      <DataTable columns={columns} data={users} />
    </div>
  )
}

actions 열을 보세요. usePermission Hook으로 버튼 표시를 제어했습니다. 권한에 따라 사용자에게 서로 다른 작업 버튼이 보입니다.

서버 측 페이지네이션과 필터링

앞의 예시는 모든 데이터를 프런트엔드로 불러오는 클라이언트 측 페이지네이션입니다. 데이터가 많아지면 이 방식은 사용할 수 없습니다.

프로덕션 환경에서는 일반적으로 서버 측 페이지네이션을 사용합니다. 백엔드 API는 대략 다음과 같습니다.

// app/api/users/route.ts
import { NextRequest } from 'next/server'
import { db } from '@/lib/db'

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const page = parseInt(searchParams.get('page') || '0')
  const size = parseInt(searchParams.get('size') || '10')

  const [data, total] = await Promise.all([
    db.user.findMany({
      skip: page * size,
      take: size,
    }),
    db.user.count(),
  ])

  return Response.json({ data, total })
}

앞의 미들웨어 장에서 설명한 것처럼 권한 검증도 반드시 추가해야 합니다.

테이블 권한 제어 모범 사례

테이블의 권한 제어에는 두 단계가 있습니다.

열 권한: 일부 열은 특정 역할만 볼 수 있습니다(예: 민감한 전화번호나 주민등록번호).

const columns: ColumnDef<User>[] = [
  {
    accessorKey: 'name',
    header: '이름',
  },
  // admin만 민감한 정보 열을 볼 수 있음
  ...(hasPermission('user:view-sensitive')
    ? [
        {
          accessorKey: 'phone',
          header: '전화번호',
        },
      ]
    : []),
]

작업 권한: 권한에 따라 작업 열의 버튼을 표시합니다.

앞의 예시처럼 usePermission Hook으로 버튼 렌더링을 제어하면 됩니다.

핵심은 공통 권한 판단 Hook을 만드는 것입니다.

// hooks/usePermission.ts
'use client'

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

export function usePermission() {
  const { data: session } = useSession()

  const hasPermission = (permission: string) => {
    return session?.user?.permissions?.includes(permission) ?? false
  }

  const hasAnyPermission = (permissions: string[]) => {
    return permissions.some((p) => hasPermission(p))
  }

  const hasAllPermissions = (permissions: string[]) => {
    return permissions.every((p) => hasPermission(p))
  }

  return { hasPermission, hasAnyPermission, hasAllPermissions }
}

컴포넌트에서 편리하게 사용할 수 있고 로직도 일관되게 유지됩니다.

프로덕션 환경 주의 사항과 모범 사례(실수 방지 가이드)

흔한 오류와 안티패턴

제가 겪었던 실수를 정리했습니다. 같은 실수를 반복하지 않기를 바랍니다.

❌ 오류 1: 프런트엔드에서만 권한 판단

가장 위험한 실수입니다. 프런트엔드 코드는 브라우저에서 실행되므로 개발자 도구를 열어 얼마든지 수정할 수 있습니다.

한번은 경쟁사 분석 담당자가 일반 사용자로 위장해 가입한 뒤 개발자 도구로 role: 'user'role: 'admin'으로 바꾸고 밤새 우리 관리자 데이터를 들여다봤습니다. 다음 날 제품 관리자의 얼굴이 사색이 됐습니다.

올바른 방법: 프런트엔드 권한 판단은 UX 개선일 뿐입니다. 백엔드 API에서 반드시 다시 검증해야 합니다. 민감한 작업은 모두 Server Actions 또는 API 라우트에서 권한을 검사하세요.

❌ 오류 2: 권한 판단 코드가 곳곳에 흩어짐

if (user.role === 'admin')이 20개 파일에 흩어져 있으면 역할을 하나 추가할 때마다 코드를 수정하느라 진이 빠집니다.

올바른 방법: 통합된 권한 설정과 통합된 권한 판단 함수를 사용합니다. 앞에서 설명한 usePermission Hook이 바로 이런 접근입니다.

❌ 오류 3: 권한 설정 하드코딩

// 잘못된 예
const ADMIN_USERS = ['[email protected]', '[email protected]']
if (ADMIN_USERS.includes(user.email)) {
  // 관리자 로직
}

대표가 이메일 주소를 바꾸면 코드를 수정하고 다시 배포해야 합니다. 터무니없는 방식입니다.

올바른 방법: 권한 설정은 데이터베이스에 저장하고 동적으로 조회합니다. 역할과 권한의 관계도 하드코딩하지 말고 설정으로 관리합니다.

성능 최적화 전략

권한 시스템을 잘못 구현하면 성능이 크게 떨어집니다. 몇 가지 최적화 방법을 살펴보겠습니다.

1. 권한 정보를 Token에 인코딩

앞서 설명했듯이 사용자 역할과 권한 목록을 JWT에 인코딩해 요청마다 데이터베이스를 조회하지 않도록 합니다.

2. 메뉴 필터링 결과 캐시

메뉴 필터링은 재귀 작업입니다. 복잡하지 않더라도 렌더링할 때마다 다시 계산할 필요는 없습니다.

앞의 usePermissionMenu Hook에서 useMemo를 사용한 이유가 바로 캐시입니다. 권한 목록이 바뀌지 않으면 메뉴 필터링 결과도 다시 계산하지 않습니다.

3. 라우트 단위 코드 분할

Next.js App Router는 기본적으로 라우트 단위 코드 분할을 지원합니다. 각 페이지는 독립된 chunk이며, 사용자가 해당 페이지에 접근할 때만 코드를 불러옵니다.

관리자 페이지가 많은데 코드 분할을 하지 않으면 초기 화면 로딩이 매우 느려집니다.

4. 불필요한 권한 판단 줄이기

권한 판단을 지나치게 세밀하게 하는 팀도 있습니다. 예를 들어 사용자가 이미 접근할 수 있는 읽기 전용 페이지인데도 내부 버튼마다 권한을 또 검사합니다.

간소화할 수 있습니다. 페이지에 접근할 수 있다는 것은 기본 권한이 있다는 뜻이므로 페이지 안의 작업 버튼에서는 삭제, 수정 같은 추가 권한만 판단하면 됩니다.

보안 점검 목록

출시 전에 다음 목록을 확인하세요.

✅ 백엔드 API에서 반드시 권한 검증

  • 모든 Server Actions에 권한 검사 적용
  • 모든 API 라우트에 권한 검사 적용
  • 민감한 작업에 2차 검증 적용(예: 사용자 삭제)

✅ 권한 상승 공격(Privilege Escalation) 방지

  • 사용자가 자신의 역할을 수정하지 못하도록 제한
  • 사용자가 자신에게 권한을 추가하지 못하도록 제한
  • 낮은 권한의 사용자가 높은 권한의 리소스에 접근하지 못하도록 제한

✅ 감사 로그

  • 주요 작업(사용자 생성, 데이터 삭제, 권한 수정) 기록
  • 로그에 작업자, 작업 시간, 작업 내용 포함
  • 로그 변조 방지(추가만 허용)

✅ 세션 관리

  • Token 만료 시간을 적절히 설정(1시간 권장)
  • 강제 로그아웃 지원(모든 세션 삭제)
  • 비밀번호 변경 후 기존 Token 무효화

✅ 입력 검증

  • 프런트엔드와 백엔드 모두 입력 검증
  • Zod 같은 라이브러리로 데이터 구조 정의
  • SQL 인젝션 방지(Prisma 같은 ORM을 사용하면 자연스럽게 방지됨)

모니터링과 알림

권한 시스템 출시는 끝이 아니라 시작입니다. 이상 징후를 제때 발견할 수 있어야 합니다.

모니터링 지표:

  • 403 오류 수의 비정상적인 증가 → 누군가 시스템을 탐색하고 있을 수 있음
  • 특정 사용자가 짧은 시간에 대량 요청 → 크롤러 또는 악의적인 공격일 수 있음
  • 잦은 권한 변경 → 누군가 설정을 무분별하게 바꾸고 있을 수 있음

알림 전략:

  • 최고 관리자 작업 실시간 알림
  • 권한 설정 변경 시 이메일 알림
  • 비정상 로그인(낯선 지역, 비정상 시간대) 시 문자 알림

이 모든 기능은 Sentry, DataDog 같은 모니터링 플랫폼으로 구현할 수 있습니다.

실제로 겪은 교훈

마지막으로 몇 가지 프로덕션 장애 사례를 공유합니다. 모두 뼈아픈 경험에서 얻은 교훈입니다.

사례 1: 메뉴 권한과 라우트 권한 불일치

메뉴 설정에서는 ‘재무 보고서’를 운영 담당자 역할에 허용했지만 미들웨어의 라우트 권한에 추가하는 것을 빠뜨렸습니다. 운영 담당자는 메뉴에서 진입점을 볼 수 있었지만 클릭하면 403이 나타났습니다. 사용자 피드백이 들어온 지 일주일 뒤에야 원인을 발견했습니다.

교훈: 권한 설정은 한곳에서 관리해야 합니다. 메뉴 권한과 라우트 권한이 같은 설정을 사용하도록 하세요.

사례 2: 권한 캐시로 인한 적용 지연

권한 정보를 JWT에 인코딩하고 만료 시간을 24시간으로 설정했습니다. 운영팀에서 어떤 사용자의 권한을 취소했지만 사용자는 계속 접근할 수 있었습니다. 다음 날 Token이 만료될 때까지 사용자는 이미 해서는 안 될 일을 모두 끝낸 뒤였습니다.

교훈: 민감한 작업에서는 Token에만 의존하지 말고 백엔드에서 데이터베이스를 다시 조회하세요. 또는 Redis로 ‘취소된 권한’ 블랙리스트를 관리하세요.

사례 3: API 권한 검증 누락

프런트엔드 페이지의 권한 제어는 잘 구현했지만 한 API에 권한 검증을 추가하는 것을 빠뜨렸습니다. 누군가 Postman으로 API를 직접 호출해 프런트엔드의 모든 보호 장치를 우회했습니다.

교훈: 백엔드 API는 마지막 방어선이므로 반드시 권한을 검증해야 합니다. 개발자가 모든 API에서 잊지 않고 추가하기를 기대하지 말고 middleware나 데코레이터로 통합 처리하세요.

긴 이야기를 한 문장으로 정리하면 이렇습니다. 프런트엔드 권한은 사용자 경험이고 백엔드 권한은 보안 방어선입니다. 둘 다 필요하지만 백엔드가 더 중요합니다.

결론

돌이켜 보면 권한 시스템은 아주 심오한 기술은 아니지만 제대로 구현하기는 쉽지 않습니다.

이 글에서는 RBAC 설계부터 Next.js 15 미들웨어 구현, 동적 메뉴와 테이블 컴포넌트까지 관리자 페이지 권한 시스템의 다양한 측면을 다뤘습니다. 핵심은 세 가지입니다.

  1. 설계를 과도하게 하지 않기: 필요에 따라 확장하고 처음부터 지나치게 복잡한 권한 모델을 만들지 않습니다.
  2. 구현을 단계별로 나누기: 미들웨어는 라우트를 차단하고, 메뉴는 권한에 따라 필터링하며, 버튼은 필요할 때 표시하도록 각자 역할을 나눕니다.
  3. 이중 보안 장치 마련하기: 프런트엔드 판단은 경험을 개선하고 백엔드 검증은 보안을 지킵니다. 둘 다 확실하게 구현해야 합니다.

관리자 페이지를 개발하고 있다면 다음과 같이 시작해 보세요.

  • 먼저 RBAC의 테이블 4개(사용자, 역할, 권한, 리소스)를 만듭니다.
  • Next.js 미들웨어로 라우트를 보호하고 권한 설정을 상수로 분리합니다.
  • 동적 메뉴 필터링을 구현하고 재사용하기 쉽도록 Hook으로 래핑합니다.
  • 테이블에는 유연성이 가장 높은 shadcn/ui + TanStack Table을 사용합니다.

권한 시스템을 리팩터링한 2주는 고됐지만 그만한 가치가 있었습니다. 지금은 역할을 추가할 때 데이터베이스만 설정하면 되고 코드 한 줄도 바꿀 필요가 없습니다. 제품 관리자가 ‘감사 담당자’ 역할을 추가해 달라고 했을 때도 10분 만에 끝냈습니다.

권한 시스템을 잘 구축하면 팀 전체의 개발 효율도 높아집니다. 보안 사고가 난 뒤에야 중요성을 깨닫는다면 이미 늦습니다.

글에서 언급한 오픈 소스 프로젝트 HaloLight를 참고해 보세요. Next.js 15 + React 19 + TypeScript + RBAC의 전체 구현을 제공합니다. 코드 품질이 좋아 학습 가치도 높습니다.

마지막으로 구현 과정에서 문제가 생기면 댓글로 이야기해 주세요. 권한 시스템의 함정은 거의 다 겪어 봤으니 도울 수 있는 부분은 최대한 돕겠습니다.

Next.js 관리자 페이지 RBAC 권한 시스템 구현 절차

Next.js 관리자 페이지 RBAC 권한 시스템을 처음부터 구축하는 전체 단계

⏱️ Estimated time: 120 min

  1. 1

    Step 1: 1단계: RBAC 데이터베이스 테이블 구조 설계

    핵심 테이블 4개와 관계 테이블 2개를 만듭니다.

    **핵심 테이블**:
    • User 테이블: 사용자 기본 정보
    • Role 테이블: 역할 정의(admin, operator, viewer 등)
    • Permission 테이블: 권한 정의(resource:action 형식 사용, 예: user:create)
    • Resource 테이블(선택 사항): 리소스 정의

    **관계 테이블**:
    • UserRole: 사용자-역할 다대다 관계
    • RolePermission: 역할-권한 다대다 관계

    **명명 규칙**:
    권한은 관리와 검색이 쉬운 resource:action 형식을 사용합니다.

    **확장성 고려**:
    초기에는 단순하게 유지하고, 이후 필요에 따라 Department(부서)와 Position(직책) 테이블을 추가해 조직 구조를 지원합니다.

    Prisma 같은 ORM으로 데이터베이스 구조를 관리하면 나중에 쉽게 조정할 수 있습니다.
  2. 2

    Step 2: 2단계: Next.js 미들웨어 라우트 보호 구현

    프로젝트 루트 디렉터리에 middleware.ts 파일을 만듭니다.

    **라우트 권한 매핑 설정**:
    • ROUTE_PERMISSIONS 상수 객체 생성
    • 각 라우트에 필요한 역할 목록 정의
    • PUBLIC_ROUTES 화이트리스트 설정(로그인, 회원가입 페이지 등)

    **미들웨어 핵심 로직**:
    1. 공개 라우트인지 확인하고 맞으면 통과
    2. getToken으로 사용자 세션 정보 가져오기
    3. 로그인하지 않은 사용자를 로그인 페이지로 리디렉션(원래 페이지 기록)
    4. 사용자 역할이 라우트에 필요한 권한과 일치하는지 확인
    5. 권한이 부족하면 403 페이지 반환

    **성능 최적화**:
    • 사용자 권한을 JWT에 인코딩
    • 요청마다 데이터베이스를 조회하지 않도록 처리
    • 적절한 Token 만료 시간 설정(1시간 권장)

    **matcher 설정**:
    정적 파일과 API 라우트를 제외하고 페이지 라우트만 검증합니다.
  3. 3

    Step 3: 3단계: 동적 메뉴 생성과 권한 필터링 구현

    메뉴 설정과 필터링 로직을 만듭니다.

    **메뉴 설정 구조**(config/menu.ts):
    • 트리 구조로 메뉴 정의
    • 각 메뉴 항목에 key, label, icon, path, permission 포함
    • permission은 선택 사항이며, 설정하지 않으면 로그인한 모든 사용자에게 표시

    **메뉴 필터링 알고리즘**(lib/menu.ts):
    • filterMenuByPermissions 재귀 함수 구현
    • 상위·하위 메뉴의 권한 관계 처리(상위 권한이 없어도 하위 권한이 있으면 상위 메뉴 표시)
    • 필터링한 메뉴 트리 반환

    **커스텀 Hook으로 래핑**(hooks/usePermissionMenu.ts):
    • useSession으로 사용자 권한 가져오기
    • useMemo로 필터링 결과 캐시
    • 권한이 바뀌지 않으면 중복 계산 방지

    **라우트 강조 표시와 브레드크럼 구현**:
    • usePathname으로 현재 라우트 가져오기
    • getMenuPath 함수로 브레드크럼 경로 생성
    • 동적 라우트 매개변수 처리 지원
  4. 4

    Step 4: 4단계: shadcn/ui + TanStack Table 테이블 컴포넌트 통합

    재사용 가능한 데이터 테이블 컴포넌트를 구현합니다.

    **의존성 설치**:
    • npx shadcn@latest add table
    • npm install @tanstack/react-table

    **DataTable 컴포넌트 생성**:
    • TanStack Table의 useReactTable Hook 사용
    • 정렬, 페이지네이션, 필터링 등 기본 기능 지원
    • TypeScript 타입 안전성 제공

    **테이블 권한 제어**:
    • 열 단위 권한: 조건부 렌더링으로 민감한 열 표시 제어
    • 작업 권한: usePermission Hook으로 버튼 표시 제어
    • hasPermission, hasAnyPermission, hasAllPermissions 등의 메서드 지원

    **서버 측 페이지네이션 구현**:
    • API 라우트에서 page와 size 매개변수 수신
    • Prisma의 skip과 take로 페이지네이션 구현
    • 데이터 목록과 전체 개수 반환

    **권한 검증**:
    프런트엔드 테이블의 권한 제어는 UX 개선일 뿐이므로 백엔드 API에서 반드시 권한을 다시 검증해야 합니다.
  5. 5

    Step 5: 5단계: 백엔드 API와 Server Actions 권한 검증

    백엔드 보안 방어선을 확보합니다.

    **Server Actions 권한 검증**:
    • 각 Server Action 시작 부분에서 auth()를 호출해 세션 가져오기
    • 사용자 역할과 권한 확인
    • 권한이 부족하면 오류 발생

    **API 라우트 권한 검증**:
    • getToken으로 사용자 정보 가져오기
    • 요청의 유효성 검증
    • 민감한 작업에는 2차 검증 추가

    **프런트엔드와 백엔드 권한 설정 통합**:
    • 권한 설정을 공유 모듈로 분리
    • 프런트엔드와 백엔드가 동일한 설정 참조
    • monorepo 구조에서 특히 편리

    **감사 로그 기록**:
    • 주요 작업(생성, 삭제, 권한 수정) 기록
    • 작업자, 시간, 내용 포함
    • 로그는 추가만 가능하고 수정은 금지
  6. 6

    Step 6: 6단계: 성능 최적화와 보안 강화

    프로덕션 환경을 최적화합니다.

    **성능 최적화**:
    • JWT에 사용자 권한 정보 인코딩
    • useMemo로 메뉴 필터링 결과 캐시
    • 라우트 단위 코드 분할로 초기 화면 로딩 감소
    • 불필요한 중복 권한 검사 축소

    **보안 점검 목록**:
    • 모든 백엔드 API에서 권한 검증
    • 권한 상승 공격 방지
    • Token 만료 시간을 적절하게 설정
    • 프런트엔드와 백엔드 모두 사용자 입력 검증
    • Zod로 데이터 구조 정의

    **모니터링과 알림**:
    • 403 오류 수 모니터링
    • 사용자의 비정상 요청 모니터링
    • 최고 관리자 작업 실시간 알림
    • 권한 설정 변경 시 이메일 알림

    **흔한 실수 방지**:
    • 프런트엔드에서만 권한을 판단하지 않기
    • 권한 설정을 하드코딩하지 않기
    • 메뉴 권한과 라우트 권한을 일치시키기
    • 민감한 작업에서 Token 캐시에만 의존하지 않기

FAQ

사용자에게 권한을 직접 할당하지 않고 RBAC를 사용해야 하는 이유는 무엇인가요?
RBAC의 핵심 장점은 유지 관리 비용입니다.

• **일괄 관리**: 상담원 5명을 추가할 때 역할만 할당하면 되며 권한을 5번 따로 설정할 필요가 없습니다.
• **일괄 업데이트**: 역할 권한을 수정하면 해당 역할을 가진 모든 사용자의 권한이 즉시 동기화됩니다.
• **높은 확장성**: 한 사용자가 여러 역할을 가질 수 있고 권한은 자동으로 합쳐집니다.
• **오류 감소**: 사용자-권한을 직접 연결하면 변경 누락이 발생하기 쉬워 보안 위험으로 이어집니다.

해외 엔터프라이즈 SaaS 애플리케이션의 80% 이상이 RBAC를 사용합니다. 유연성과 유지 관리성 사이에서 가장 좋은 균형을 제공하기 때문입니다.
Next.js 미들웨어와 컴포넌트 단위 권한 판단은 무엇이 다른가요?
두 방식의 역할은 다릅니다.

**미들웨어(권장)**:
• 요청이 페이지에 도달하기 전에 실행되어 한곳에서 차단
• 성능이 좋고 컴포넌트 단위보다 응답 속도가 60~80% 빠름
• 코드를 중앙에서 관리하므로 누락 가능성이 낮음
• 서버 측 렌더링의 권한 검증 지원

**컴포넌트 단위 판단**:
• 페이지가 렌더링된 뒤 판단하므로 화면이 깜빡일 수 있음
• 페이지마다 작성해야 하므로 빠뜨리기 쉬움
• 복사·붙여넣기로 유지 관리 비용이 커짐

단, 미들웨어는 첫 번째 방어선일 뿐이며 백엔드 API에서 반드시 권한을 다시 검증해야 합니다.
상위 메뉴 권한은 없지만 하위 메뉴 권한이 있을 때 동적 메뉴는 어떻게 처리하나요?
'하위 메뉴 우선' 전략을 권장합니다.

**표시 로직**:
• 표시할 수 있는 하위 메뉴가 하나라도 있으면 상위 메뉴를 표시합니다.
• 그러면 사용자가 권한이 있는 하위 메뉴를 보고 접근할 수 있습니다.

**구현 방법**:
재귀 알고리즘으로 하위 메뉴를 먼저 필터링한 뒤 상위 메뉴의 표시 여부를 판단합니다.
1. 하위 메뉴를 재귀적으로 처리
2. 현재 항목의 권한 확인
3. 현재 권한은 없지만 표시 가능한 하위 메뉴가 있으면 항목 유지
4. 현재 권한도 없고 표시 가능한 하위 메뉴도 없으면 제거

이 방식은 권한 제어의 엄격함을 유지하면서도 좋은 사용자 경험을 제공합니다.
shadcn/ui + TanStack Table과 Ant Design Table 중 무엇을 선택해야 하나요?
프로젝트 특성에 따라 선택하세요.

**Ant Design Table 선택**:
• 팀이 이미 Ant Design에 익숙한 경우
• 즉시 사용할 수 있는 기능으로 빠른 개발이 필요한 경우
• 전통적인 엔터프라이즈 관리자 페이지인 경우
• 비교적 큰 bundle 크기가 문제가 되지 않는 경우

**shadcn/ui + TanStack Table 선택**:
• Tailwind CSS를 사용하는 경우
• 스타일을 자유롭게 맞춤 설정해야 하는 경우
• 유연성과 성능을 중시하는 경우
• 코드를 작성하는 데 시간을 투자할 수 있는 경우

**데이터 비교**:
shadcn/ui + TanStack Table 조합은 2024~2026년에 300% 이상 성장하며 현대적인 관리자 페이지의 대표적인 선택지가 되었습니다.

둘 다 훌륭하므로 프로젝트 요구 사항과 팀의 기술 스택에 맞춰 선택하는 것이 중요합니다.
프런트엔드와 백엔드의 권한 검증은 어떻게 연계해야 하나요?
각자 역할이 다른 이중 보안 장치를 사용합니다.

**프런트엔드 권한 검증**(미들웨어 + 컴포넌트):
• 목적: 빠른 피드백을 통한 사용자 경험 개선
• 위치: 미들웨어로 라우트 차단, 컴포넌트로 버튼 표시 제어
• 한계: 개발자 도구로 우회할 수 있으므로 보안 방어선이 아님

**백엔드 권한 검증**(API + Server Actions):
• 목적: 실제 보안 방어선
• 위치: 모든 Server Action과 API 라우트
• 필수 사항: 민감한 작업은 반드시 검증하고 프런트엔드에 의존하지 않기

**설정 통합**:
• 권한 설정을 공유 모듈로 분리
• 프런트엔드와 백엔드가 동일한 설정 참조
• 규칙을 일치시켜 취약점 방지

**뼈아픈 교훈**: 어떤 프로젝트는 프런트엔드 권한 제어를 완벽하게 구현했지만 한 API에서 검증을 빠뜨렸고, 누군가 Postman으로 직접 호출해 모든 보호 장치를 우회했습니다.
권한 정보는 JWT에 넣어야 하나요, 요청마다 데이터베이스를 조회해야 하나요?
상황에 따라 나눠 처리하는 것이 좋습니다.

**방식 1: JWT 인코딩(권장)**:
• 장점: 미들웨어에서 데이터베이스를 조회하지 않아 성능이 좋음
• 단점: 권한을 수정한 뒤 Token이 만료될 때까지 기다려야 함
• 적합한 경우: 권한이 자주 변경되지 않는 환경
• 권장 사항: Token 만료 시간을 1시간으로 설정

**방식 2: Redis 캐시**:
• 장점: 실시간성이 좋아 권한이 즉시 적용됨
• 단점: 의존성이 한 단계 늘어나 복잡해짐
• 적합한 경우: 권한이 자주 변경되는 환경

**방식 3: 데이터베이스 조회**:
• 장점: 100% 실시간 반영
• 단점: 요청마다 데이터베이스를 조회해 성능이 낮음
• 비권장: 특별한 비즈니스 요구 사항이 있는 경우를 제외하면 권장하지 않음

**혼합 방식**:
JWT에 권한 저장 + Redis 블랙리스트(취소된 권한)를 함께 사용해 성능과 실시간성을 모두 확보합니다.
권한 시스템 출시 전에 어떤 보안 항목을 점검해야 하나요?
전체 보안 점검 목록은 다음과 같습니다.

**백엔드 API 검증**:
• 모든 Server Actions에 권한 검사 적용
• 모든 API 라우트에 권한 검증 적용
• 민감한 작업에 2차 검증 적용(예: 사용자 삭제)

**권한 상승 공격 방지**:
• 사용자가 자신의 역할을 수정하지 못하도록 제한
• 사용자가 자신에게 권한을 추가하지 못하도록 제한
• 낮은 권한의 사용자가 높은 권한의 리소스에 접근하지 못하도록 제한

**감사와 모니터링**:
• 주요 작업 로그 기록(변조 불가)
• 403 오류 수 모니터링
• 최고 관리자 작업 실시간 알림
• 권한 설정 변경 시 이메일 알림

**세션 관리**:
• Token 만료 시간을 적절히 설정(1시간 권장)
• 강제 로그아웃 지원
• 비밀번호 변경 후 기존 Token 무효화

**입력 검증**:
• 프런트엔드와 백엔드 모두 입력 검증
• Zod로 데이터 구조 정의
• SQL 인젝션 방지(Prisma 같은 ORM 사용)

기억하세요. 프런트엔드 권한은 사용자 경험을 위한 것이고 백엔드 권한은 보안 방어선입니다.

5분 읽기 · 게시일: 2026년 1월 7일 · 수정일: 2026년 9월 8일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog