React 19에서 동적 RBAC 구축: 권한 문자열에서 컴포넌트 수준 접근 제어까지
출처: 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();