React 19에서 동적 RBAC 구축: 권한 문자열에서 컴포넌트 수준 접근 제어까지

발행: (2026년 5월 23일 PM 06:22 GMT+9)
8 분 소요
원문: Dev.to

출처: Dev.to

React 19에서 동적 RBAC 구축하기: 문자열 기반 권한에서 컴포넌트 수준 접근 제어까지

문자열 기반 권한 검사가 React 코드베이스 곳곳에 흩어져 있으면 유지보수가 악몽이 됩니다. 저는 CitizenApp을 출시하면서 이 안티패턴을 사용했었고, 다섯 번째 AI 기능을 추가했을 때 거의 큰 문제에 봉착했습니다.
문제는? 권한이 컴포넌트 안에 하드코딩돼 있었습니다. 마케팅 팀이 특정 고객에게 기능을 시험 적용하고 싶어 할 때마다 코드베이스 절반을 grep으로 찾아 if (user.role === 'admin') 같은 검사를 모두 찾아 프랑켄슈타인식 조건문을 만들어야 했죠. 게다가 우리 테넌트 계층 구조 전체에서 “feature_x_access”가 실제로 무엇을 의미하는지에 대한 단일 진실 소스가 없었습니다.

이 글에서는 컴포넌트 밖에 존재하는 타입‑안전하고 조합 가능한 RBAC 레이어를 만드는 방법을 보여드립니다. UI는 “X를 할 수 있나요?”라고 묻고, 권한 엔진이 답합니다. 깔끔한 분리, 테스트 가능, 확장성 보장.

컴포넌트 트리 안에 권한을 직접 넣지 마세요. 권한을 컴포넌트가 소비하는 구성으로 취급하면 모든 것이 열립니다.

나쁜 예시

// ❌ 이렇게 하지 마세요
export function AIFeatureCard() {
  const { user } = useAuth();

  if (user.role !== 'admin' && user.role !== 'premium_subscriber') {
    return null;
  }

  return AI Feature;
}

문제점

  • 역할 이름이 매직 문자열
  • 권한 로직이 20개가 넘는 컴포넌트에 분산
  • 새로운 역할을 추가하려면 모든 검사를 찾아 수정해야 함
  • 컴포넌트를 렌더링하지 않고는 권한을 테스트할 방법이 없음
  • 어느 곳에서 어떤 권한이 체크됐는지 감사 로그가 없음

좋은 예시

// ✅ 이렇게 하세요
const canAccessAIFeature = await checkPermission('features:ai:access', {
  userId,
  tenantId,
});

if (canAccessAIFeature) {
  return <AIFeature />;
}

이제 권한은 데이터가 됩니다. 로그를 남기고, 캐시하고, 테스트하고, 감사할 수 있죠.

1. TypeScript로 시작하기

앱에 존재하는 모든 권한을 const 객체로 정의합니다.

// permissions.ts
export const PERMISSIONS = {
  // 조직 관리
  'org:create': '조직 생성',
  'org:update': '조직 설정 업데이트',
  'org:delete': '조직 삭제',
  'org:invite_members': '팀 멤버 초대',

  // AI 기능 (피처 플래그)
  'features:ai:access': '모든 AI 기능 접근',
  'features:ai:documents': '문서 분석 사용',
  'features:ai:workflows': '자동화 워크플로우 생성',
  'features:ai:exports': 'AI 생성 콘텐츠 내보내기',

  // 관리자
  'admin:billing': '청구 관리',
  'admin:audit_logs': '감사 로그 보기',
} as const;

// 타입 추출: 'org:create' | 'org:update' | ...
export type PermissionKey = keyof typeof PERMISSIONS;

자동 완성이 제공되고, 컴파일 타임에 오타를 잡아줍니다. 이제 문자열 매직 텍스트는 사라집니다.

2. 백엔드 권한 엔진 (FastAPI)

# permissions.py
from enum import Enum
from typing import Set
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session

class RoleType(str, Enum):
    OWNER = "owner"
    ADMIN = "admin"
    MEMBER = "member"
    GUEST = "guest"

# 역할 → 권한 매핑 정의
ROLE_PERMISSIONS: dict[RoleType, Set[str]] = {
    RoleType.OWNER: {
        "org:create", "org:update", "org:delete", "org:invite_members",
        "features:ai:access", "features:ai:documents", "features:ai:workflows",
        "features:ai:exports", "admin:billing", "admin:audit_logs",
    },
    RoleType.ADMIN: {
        "org:update", "org:invite_members",
        "features:ai:access", "features:ai:documents", "features:ai:workflows",
        "features:ai:exports", "admin:audit_logs",
    },
    RoleType.MEMBER: {
        "features:ai:access", "features:ai:documents", "features:ai:workflows",
    },
    RoleType.GUEST: {
        "features:ai:documents",  # 읽기 전용
    },
}

# 다중 테넌트를 위한 권한 상속 처리
class PermissionResolver:
    def __init__(self, db: Session):
        self.db = db

    async def get_user_permissions(
        self, user_id: str, tenant_id: str
    ) -> Set[str]:
        """테넌트 내 사용자의 모든 권한을 반환합니다."""
        # 해당 테넌트에서의 사용자 역할 조회
        membership = self.db.query(TenantMembership).filter(
            TenantMembership.user_id == user_id,
            TenantMembership.tenant_id == tenant_id,
        ).first()

        if not membership:
            return set()

        # 직접 역할에 매핑된 권한을 시작점으로 사용
        permissions = ROLE_PERMISSIONS.get(membership.role, set()).copy()

        # 개별적으로 부여한 커스텀 권한 추가
        custom = self.db.query(UserPermission).filter(
            UserPermission.user_id == user_id,
            UserPermission.tenant_id == tenant_id,
        ).all()

        for perm in custom:
            permissions.add(perm.permission_key)

        return permissions

    async def can_perform(
        self, user_id: str, tenant_id: str, permission: str
    ) -> bool:
        """특정 동작을 수행할 수 있는지 확인합니다."""
        permissions = await self.get_user_permissions(user_id, tenant_id)
        return permission in permissions

# 엔드포인트 공개
@app.post("/api/permissions/check")
async def check_permission(
    request: CheckPermissionRequest,  # {permission, tenant_id}
    user_id: str = Depends(get_current_user_id),
    db: Session = Depends(get_db),
) -> dict[str, bool]:
    resolver = PermissionResolver(db)
    allowed = await resolver.can_perform(
        user_id, request.tenant_id, request.permission
    )
    return {"allowed": allowed}

핵심 포인트: 컴포넌트 안에 로직이 없습니다. 모든 규칙은 ROLE_PERMISSIONS와 데이터베이스에 존재합니다. 새로운 기능을 추가하면 ROLE_PERMISSIONS를 한 번만 수정하면 됩니다.

3. React 쪽 구현 – 권한 훅 만들기

// usePermission.ts
import { useAuth } from './useAuth';
import { PermissionKey, PERMISSIONS } from './permissions';

interface UsePermissionOptions {
  cacheSeconds?: number;
}

export function usePermission(
  permissionKey: PermissionKey,
  options: UsePermissionOptions = {}
) {
  const { user, tenantId } = useAuth();
  const [allowed, setAllowed] = React.useState<boolean | null>(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState<Error | null>(null);

  React.useEffect(() => {
    if (!user || !tenantId) {
      setLoading(false);
      setAllowed(false);
      return;
    }

    const checkPerm = async () => {
      try {
        const response = await fetch('/api/permissions/check', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            permission: permissionKey,
            tenant_id: tenantId,
          }),
        });

        if (!response.ok) throw new Error('Permission check failed');
        const data = await response.json();
        setAllowed(data.allowed);
      } catch (err) {
        setError(err as Error);
        setAllowed(false); // 실패 시 닫힌 상태로 처리
      } finally {
        setLoading(false);
      }
    };

    checkPerm();
  }, [user?.id, tenantId, permissionKey]);

  return { allowed, loading, error };
}

컴포넌트에서 사용하기

// AIFeatureCard.tsx
export function AIFeatureCard() {
  const { allowed, loading } = usePermission('features:ai:access');

  if (loading) return <Spinner />;
  if (!allowed) return null;

  return <AIFeature />;
}

깨끗하고, 테스트 가능하고, 타입‑안전합니다.

4. 캐시와 React Query 활용하기

제가 처음 구현했을 때 캐시를 두지 않아서 같은 권한을 요청하는 여러 컴포넌트가 백엔드를 폭격했습니다. 이제는 React Query를 이용해 캐시를 적용합니다.

// usePermission.ts (개선 버전)
import { useQuery } from '@tanstack/react-query';

export function usePermission(
  permissionKey: PermissionKey,
  options: UsePermissionOptions = {}
) {
  const { user, tenantId } = useAuth();
  const cacheSeconds = options.cacheSeconds ?? 300; // 기본 5분

  return useQuery({
    queryKey: ['permission', tenantId, permissionKey],
    queryFn: async () => {
      const resp = await fetch('/api/permissions/check', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          permission: permissionKey,
          tenant_id: tenantId,
        }),
      });

      if (!resp.ok) throw new Error('Permission check failed');
      const data = await resp.json();
0 조회
Back to Blog

관련 글

더 보기 »

내 스킬

프로젝트를 위한 AI 지시문을 만들고, 설치하고, 관리하세요 — 코딩이 필요 없습니다. CREATE 이름을 정하고, 카테고리를 선택하고, 원하는 것을 설명하세요 — 마법사가 자동으로 구성합니다.