본문 바로가기

React

React 앱에서 사용자 권한(Role-Based Access Control) 처리하기

RBAC는 복잡한 프론트엔드에서 페이지 접근, 버튼 노출, API 호출을 안전하게 통제하는 핵심 패턴입니다. 실무에서 바로 쓸 수 있는 권한 설계, 훅/컴포넌트, 라우팅 가드, 서버 연동까지 단계별로 정리합니다.

1. 왜 RBAC인가

역할(roles)과 권한(permissions)을 분리하면 기능 추가/변경 시 코드 수정 범위를 최소화할 수 있습니다. 또한 UI와 라우팅, API 레벨에서 일관된 통제가 가능합니다.

2. 권한 모델 설계

역할-권한 매핑을 한 곳에 선언합니다. 최소 권한 원칙을 지키고, 비즈니스 용어로 이름을 짓습니다.

// permissions.js
export const Roles = {
  ADMIN: 'admin',
  EDITOR: 'editor',
  VIEWER: 'viewer',
}

export const Permissions = {
  READ_POST: 'read:post',
  EDIT_POST: 'edit:post',
  DELETE_POST: 'delete:post',
  MANAGE_USER: 'manage:user',
}

export const RolePermissions = {
  [Roles.ADMIN]: Object.values(Permissions),
  [Roles.EDITOR]: [Permissions.READ_POST, Permissions.EDIT_POST],
  [Roles.VIEWER]: [Permissions.READ_POST],
}

export const hasPermission = (user, perm) => {
  const roles = user?.roles || []
  return roles.some(r => (RolePermissions[r] || []).includes(perm))
}

3. AuthContext와 useAuth 훅

사용자와 역할을 전역 상태로 관리합니다. 예시는 JWT에서 roles 클레임을 읽어오는 방식입니다.

// auth-context.jsx
import React, { createContext, useContext, useEffect, useState } from 'react'

const AuthContext = createContext(null)

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const token = localStorage.getItem('access_token')
    if (!token) { setLoading(false); return }
    try {
      // 실무에서는 'jwt-decode' 라이브러리 사용 권장
      const payload = JSON.parse(atob(token.split('.')[1]))
      setUser({ id: payload.sub, roles: payload.roles || [], name: payload.name })
    } catch (e) {
      console.error('Invalid token', e)
      localStorage.removeItem('access_token')
    } finally {
      setLoading(false)
    }
  }, [])

  const login = async (email, password) => {
    const res = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })
    if (!res.ok) throw new Error('Login failed')
    const { accessToken } = await res.json()
    localStorage.setItem('access_token', accessToken)
    const payload = JSON.parse(atob(accessToken.split('.')[1]))
    setUser({ id: payload.sub, roles: payload.roles || [], name: payload.name })
  }

  const logout = () => {
    localStorage.removeItem('access_token')
    setUser(null)
  }

  return (
    <AuthContext.Provider value={{ user, loading, login, logout }}>
      {children}
    </AuthContext.Provider>
  )
}

export const useAuth = () => useContext(AuthContext)

4. 권한 체크 훅

권한 판단 로직을 훅으로 캡슐화해 재사용성을 높입니다.

// use-permission.js
import { useAuth } from './auth-context'
import { hasPermission } from './permissions'

export function usePermission(required) {
  const { user, loading } = useAuth()
  const allowed = !loading && hasPermission(user, required)
  return { allowed, isLoading: loading }
}

5. 라우트 가드: 인증과 권한

React Router v6 기준으로 인증 가드와 권한 가드를 분리합니다.

// route-guards.jsx
import React from 'react'
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from './auth-context'
import { usePermission } from './use-permission'

export function ProtectedRoute() {
  const { user, loading } = useAuth()
  if (loading) return <div>Loading...</div>
  return user ? <Outlet /> : <Navigate to='/login' replace />
}

export function RequirePermission({ permission }) {
  const { allowed, isLoading } = usePermission(permission)
  if (isLoading) return <div />
  return allowed ? <Outlet /> : <Navigate to='/403' replace />
}
// App.jsx (라우팅 연결)
import React from 'react'
import { Routes, Route } from 'react-router-dom'
import { ProtectedRoute, RequirePermission } from './route-guards'
import { Permissions } from './permissions'

import LoginPage from './pages/LoginPage'
import Home from './pages/Home'
import Posts from './pages/Posts'
import EditPost from './pages/EditPost'
import AdminPage from './pages/AdminPage'
import Forbidden from './pages/Forbidden'

export default function App() {
  return (
    <Routes>
      <Route path='/login' element={<LoginPage />} />

      <Route element={<ProtectedRoute />}>
        <Route path='/' element={<Home />} />
        <Route path='/posts' element={<Posts />} />

        <Route element={<RequirePermission permission={Permissions.EDIT_POST} />}>
          <Route path='/posts/:id/edit' element={<EditPost />} />
        </Route>

        <Route element={<RequirePermission permission={Permissions.MANAGE_USER} />}>
          <Route path='/admin' element={<AdminPage />} />
        </Route>
      </Route>

      <Route path='/403' element={<Forbidden />} />
    </Routes>
  )
}

6. UI 단위 권한 제어

버튼, 메뉴 등 컴포넌트 레벨에서도 안전하게 숨기거나 비활성화합니다.

// HasPermission.jsx
import React from 'react'
import { usePermission } from './use-permission'

export default function HasPermission({ permission, fallback = null, children }) {
  const { allowed, isLoading } = usePermission(permission)
  if (isLoading) return null
  return allowed ? children : fallback
}

// 사용 예시
// <HasPermission permission={Permissions.DELETE_POST} fallback={<span>권한 없음</span>}>
//   <button onClick={handleDelete}>삭제</button>
// </HasPermission>

7. 서버와 토큰 연동

프론트 권한 가드는 UX 용도입니다. 실제 보안은 서버에서 반드시 재검증해야 합니다.

// api.js: 토큰을 자동으로 포함하는 fetch 래퍼
export const api = (path, options = {}) => {
  const token = localStorage.getItem('access_token')
  return fetch(path, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...(options.headers || {}),
    },
  }).then(async res => {
    if (res.status === 403) {
      // 전역 알림 또는 403 페이지로 유도
      // window.dispatchEvent(new CustomEvent('forbidden'))
    }
    return res
  })
}

서버는 JWT 서명 검증 후 roles/permissions을 확인하고 403을 반환해야 합니다. 민감한 리소스 보호는 프론트에서 숨기는 것으로 충분하지 않습니다.

8. 로딩과 UX 다듬기

권한/인증 로딩 동안 깜빡임을 줄입니다. 전체 페이지 스피너 또는 스켈레톤을 사용하고, 403 전용 페이지를 제공합니다.

// Forbidden.jsx
export default function Forbidden() {
  return <div style={{ padding: 32 }}>접근 권한이 없습니다.</div>
}

9. 테스트 전략

권한 로직은 회귀가 잦습니다. 단위/통합 테스트를 추가하세요.

  • 단위: hasPermission, usePermission의 다양한 조합 테스트
  • 컴포넌트: HasPermission이 올바르게 노출/비노출 되는지
  • E2E: 보호 라우트 접근 시 로그인/403 동작 확인

10. 흔한 함정과 체크리스트

  • 클라이언트만 믿지 말 것: 서버에서 최종 권한 검증
  • 권한 상수 하드코딩 금지: permissions.js 한 곳에서 관리
  • 비동기 깜빡임: loading 상태 처리
  • 권한 변경 반영: 재로그인 또는 /me 재조회로 동기화
  • 감사 로그: 민감 액션은 서버에서 로깅

11. 확장 아이디어

RBAC에서 한 걸음 더 나아가 리소스 소유권, 속성 기반 정책(ABAC), 피처 플래그와 조합하면 대규모 제품에서도 유연한 접근 제어가 가능합니다.

요약: 역할/권한을 명확히 정의하고, 컨텍스트-훅-가드-UI 컴포넌트 레이어로 일관되게 적용하세요. 프론트는 UX 가드, 서버는 최종 보안이라는 원칙을 지키면 유지보수와 보안성을 모두 확보할 수 있습니다.