실무 어드민에 RBAC 적용하기 — 메뉴 단위로 시작하는 권한 설계

"주문 관리 메뉴 접근 권한 주세요"처럼 조직은 메뉴 단위로 업무를 인식합니다. RBAC 정석보다 현실적인 메뉴 단위 권한 설계와 구현 방법을 비교합니다.

실무 어드민에 RBAC 적용하기 — 메뉴 단위로 시작하는 권한 설계

어드민 패널에 권한을 적용하려고 할 때, 대부분의 개발자는 RBAC(Role-based Access Control, 역할 기반 접근 제어)를 떠올립니다. 그런데 막상 설계를 시작하면 막막합니다. 리소스를 어떻게 쪼개야 하는지, 역할을 몇 개로 나눠야 하는지, 메뉴 단위로 할지 기능 단위로 할지.

이 글은 RBAC의 두 가지 접근법의 차이를 짚고, 어드민 실무에서 메뉴 단위가 현실적인 이유와 구현 방법을 다룹니다.


RBAC 정석: 리소스와 액션 단위 설계

RBAC는 보통 리소스(Resource)와 액션(Action)의 조합으로 권한을 정의합니다.

orders:read
orders:update
orders:delete
orders:export
settlements:read
settlements:export
campaigns:create
campaigns:delete
users:read
users:ban

역할(Role)은 이 권한들의 묶음입니다.

CS팀    = [orders:read, orders:update, users:read]
마케팅팀 = [campaigns:create, campaigns:delete]
정산팀   = [settlements:read, settlements:export]

이론적으로는 가장 정밀합니다. 어떤 역할이 어떤 리소스에 어떤 액션을 할 수 있는지 명확하게 정의됩니다.


하지만, 어드민 실무에서 현실적인가?

설계 비용이 생각보다 큽니다. 리소스와 액션을 처음부터 빠짐없이 정의해야 하고, 어드민 기능이 추가될 때마다 권한 테이블도 같이 관리해야 합니다. 초기에 제대로 안 잡으면 나중에 orders:update_status, orders:update_memo처럼 계속 쪼개지면서 권한이 수십 개로 늘어납니다.

조직 언어와 맞지 않습니다. 실무에서 권한 요청은 "orders:read 권한 주세요"가 아니라 "주문 관리 메뉴 접근할 수 있게 해주세요"로 옵니다. 사람들은 시스템 리소스가 아니라 메뉴 단위로 자신의 업무를 인식합니다.

모든 시스템이 수천 명을 위한 것은 아닙니다. 리소스·액션 단위 RBAC가 꼭 필요한 시스템은 수천 명이 동시에 쓰고, 감사 요구사항이 엄격하고, 권한 조합이 매우 복잡한 경우입니다. 대부분의 내부 어드민은 그 수준의 복잡도를 갖지 않습니다.


메뉴 단위 RBAC가 현실적인 이유

메뉴 단위 접근은 정석보다 거친 면이 있습니다. 하지만 대부분의 어드민에서 충분히 작동하는 최소 단위입니다.

  • 조직이 업무를 인식하는 단위와 일치합니다.
  • 설계와 유지보수가 단순합니다.
  • "CS팀은 주문·사용자 메뉴, 마케팅팀은 캠페인·쿠폰 메뉴"처럼 소통이 됩니다.

여기에 메뉴 안에서 위험한 액션만 추가로 제어하면, 실무에서 필요한 수준의 정밀도는 충분히 커버됩니다. 처음부터 모든 리소스를 쪼갤 필요가 없습니다.


메뉴 단위 권한의 두 가지 차원

메뉴 단위로 접근할 때 놓치기 쉬운 개념이 있습니다. 메뉴 권한에는 사실 두 가지 차원이 있습니다.

  • 보이는 것 (list): 메뉴가 내비게이션/사이드바에 표시되는가
  • 들어갈 수 있는 것 (view): 메뉴에 실제로 접근할 수 있는가

보통은 "보이냐 안 보이냐"만 생각하게 됩니다. 하지만 보이지만 접근은 못 하는 상태가 유용한 경우가 있습니다.

메뉴가 아예 안 보이면 권한이 없는 팀원은 '이런 기능이 있는지'조차 모릅니다. 비활성화된 채로 보이면 "나는 권한이 없구나, 필요하면 요청해야겠다"를 알 수 있습니다. 작은 차이지만 팀 내 커뮤니케이션 비용을 줄여줍니다.

list view 결과
O O 메뉴 보임 + 접근 가능
O X 메뉴는 보이지만 비활성화
X O URL 직접 입력으로만 접근 가능
X X 완전 차단

어떤 기준으로 메뉴를 나누는가

역할 설계에서 실제로 막히는 지점은 "이 기능을 어느 역할에 줘야 하는가"입니다. 두 가지 기준으로 판단하면 대부분의 케이스가 정리됩니다.

기준 1: 이 액션이 얼마나 위험한가

위험도가 높을수록 접근 가능한 역할을 좁힙니다.

위험도 예시 권한 수준
낮음 주문 조회, 통계 열람 해당 부서 운영자 이상
중간 상태 변경, 메모 수정 담당 역할만
높음 환불, 사용자 탈퇴 처리 시니어 운영자 이상
매우 높음 대량 삭제, 민감정보 CSV 대량 다운로드 반드시 필요한 담당자만. 감사 로그 권장

민감정보 CSV 다운로드를 높은 위험도로 분류한 이유가 있습니다. 조회는 화면에서 보는 것으로 끝나지만, 다운로드는 데이터가 어드민 밖으로 나갑니다. 개인정보가 포함된 경우라면 조회 권한과 반드시 분리해서 설계해야 합니다.

기준 2: 역할 간 업무 범위가 겹칠 때

업무 범위가 겹치는 경우는 어떻게 해야 할까요? CS팀이 환불 처리를 담당한다면, 환불 금액 조회는 필요하지만 정산 업무까지 처리할 필요는 없습니다. 이런 경우 정산 메뉴 접근을 허용하는 대신 수정 권한을 제한하거나, 필요한 데이터만 노출하는 읽기 전용 페이지를 별도로 만들 수 있습니다.


구현 방법 4가지 비교

설계 방향이 잡혔다면, 실제로 어떤 도구로 구현할지 선택해야 합니다. 이 글에서 다룬 세 가지 시나리오를 기준으로 각 방식을 비교합니다.

  • 시나리오 A: CS팀은 주문·사용자 메뉴 접근, 마케팅팀은 캠페인 메뉴만
  • 시나리오 B: 주문 취소·CSV 다운로드는 슈퍼어드민만 가능
  • 시나리오 C: 정산 메뉴는 CS팀에게 보이지만, 접근은 정산팀만

1) 미들웨어 직접 구현

역할-메뉴 매핑 테이블을 만들고 미들웨어로 라우터에 적용합니다.

// 시나리오 A: 역할별 접근 가능한 메뉴 정의
const roleMenus = {
  cs_team:        ['orders', 'users'],
  marketing_team: ['campaigns'],
  settlement_team:['settlement'],
  super_admin:    ['orders', 'users', 'campaigns', 'settlement'],
};

// 시나리오 B: 메뉴 안 위험 액션은 별도 제한
const actionRoles = {
  'orders:cancel': ['super_admin'],
  'orders:export': ['super_admin'],
};

function requireMenu(menu) {
  return (req, res, next) => {
    if (!(roleMenus[req.user?.role] || []).includes(menu))
      return res.status(403).json({ error: '접근 권한이 없습니다.' });
    next();
  };
}

function requireAction(action) {
  return (req, res, next) => {
    if (!(actionRoles[action] || []).includes(req.user?.role))
      return res.status(403).json({ error: '접근 권한이 없습니다.' });
    next();
  };
}

router.get('/orders',             requireMenu('orders'),                                 getOrders);
router.post('/orders/:id/cancel', requireMenu('orders'), requireAction('orders:cancel'), cancelOrder);
router.get('/orders/export',      requireMenu('orders'), requireAction('orders:export'), exportOrders);

// 시나리오 C: 서버에서도 settlement 접근을 정산팀만 허용
// roleMenus에 cs_team, marketing_team의 settlement를 포함하지 않으므로
// URL 직접 접근 시에도 403 반환
router.get('/settlement', requireMenu('settlement'), getSettlement);

// 프론트엔드에서 별도로 메뉴 숨김/비활성화 처리 필요
// const canSeeSettlement    = ['cs_team', 'marketing_team', 'settlement_team'].includes(user.role);
// const canAccessSettlement = user.role === 'settlement_team';

소규모 어드민에서는 이걸로 충분합니다. 다만 시나리오 C처럼 "보이지만 접근은 불가"를 구현하려면 프론트엔드 로직을 별도로 관리해야 하고, 권한 정의가 백엔드와 프론트엔드에 흩어지기 시작합니다.

2) 라이브러리: CASL, ra-rbac

CASL은 선언적으로 권한을 정의하고 프론트·백에서 동일한 정의를 공유할 수 있습니다.

import { AbilityBuilder, createMongoAbility } from '@casl/ability';

function defineAbilityFor(user) {
  const { can, build } = new AbilityBuilder(createMongoAbility);

  // 시나리오 A: 역할별 메뉴(리소스) 접근
  if (user.role === 'cs_team') {
    can('read', 'Order');
    can('read', 'User');
  }
  if (user.role === 'marketing_team') {
    can('read', 'Campaign');
  }
  // 시나리오 B: 위험 액션은 슈퍼어드민만
  if (user.role === 'super_admin') {
    can('manage', 'all');
  }
  // 시나리오 C: list(사이드바 노출)와 view(실제 접근)를 별도 액션으로 정의
  if (['cs_team', 'marketing_team', 'settlement_team'].includes(user.role)) {
    can('list', 'Settlement');   // 사이드바에는 노출
  }
  if (user.role === 'settlement_team') {
    can('view', 'Settlement');   // 실제 페이지 접근은 정산팀만
  }

  return build();
}
// 프론트: list/view를 분리해서 사용
const canSeeSettlement    = ability.can('list', 'Settlement');
const canAccessSettlement = ability.can('view', 'Settlement');

// 사이드바: 보이지만 비활성화
<MenuItemLink
  to="/settlement"
  disabled={!canAccessSettlement}
  style={{ opacity: canSeeSettlement ? 1 : 0 }}
/>

// 라우터: 실제 접근 차단
if (!ability.can('view', 'Settlement')) return <Forbidden />;

listview를 별도 액션으로 정의하면 시나리오 C도 CASL 안에서 처리할 수 있습니다. 다만 이 규칙을 팀 내에서 명시적으로 약속해두지 않으면, 시간이 지나면서 list/view 구분 없이 read 하나로 뭉개지는 경우가 생깁니다. 컨벤션을 문서화해두는 것을 권장합니다.

프론트·백 동일한 정의를 공유할 수 있는 게 장점입니다. 반면 권한 로직이 코드 안에 있기 때문에 역할이나 규칙을 수정할 때마다 재배포가 필요합니다.

react-admin의 ra-rbac는 메뉴 레벨 제어까지 내장된 선택지입니다.

import { IfCanAccess } from '@react-admin/ra-rbac';

// 시나리오 A: 역할에 따라 메뉴 노출
const Menu = () => (
  <>
    <IfCanAccess action="list" resource="orders">
      <MenuItemLink to="/orders" primaryText="주문 관리" />
    </IfCanAccess>
    <IfCanAccess action="list" resource="campaigns">
      <MenuItemLink to="/campaigns" primaryText="캠페인 관리" />
    </IfCanAccess>
    {/* 시나리오 C: 정산 메뉴 — 보임 여부와 접근 여부를 권한으로 분리 정의 */}
    <IfCanAccess action="list" resource="settlement">
      <MenuItemLink to="/settlement" primaryText="정산 관리" />
    </IfCanAccess>
  </>
);

// 시나리오 B: 위험 액션 버튼 제어
const OrderActions = () => (
  <IfCanAccess action="cancel" resource="orders">
    <CancelButton />
  </IfCanAccess>
);

세 시나리오 모두 커버할 수 있지만, react-admin 프레임워크 전체를 도입해야 한다는 전제가 붙습니다. 기존 어드민에 권한만 얹을 수 없고, react-admin으로 어드민을 새로 구축해야 합니다.

3) Policy-as-Code: Cerbos

권한 정책을 YAML 파일로 코드 밖에서 관리하고 API로 조회하는 구조입니다.

# orders_policy.yaml
resourcePolicy:
  resource: order
  rules:
    # 시나리오 A: CS팀은 주문 조회·수정 가능
    - actions: [read, update]
      roles: [cs_team]
      effect: EFFECT_ALLOW
    # 시나리오 B: 취소·다운로드는 슈퍼어드민만
    - actions: [cancel, export]
      roles: [super_admin]
      effect: EFFECT_ALLOW

# settlement_policy.yaml
resourcePolicy:
  resource: settlement
  rules:
    # 시나리오 C: 목록 조회(list)는 CS팀도 허용, 접근(view)은 정산팀만
    - actions: [list]
      roles: [cs_team, marketing_team, settlement_team]
      effect: EFFECT_ALLOW
    - actions: [view]
      roles: [settlement_team]
      effect: EFFECT_ALLOW
// 여러 액션을 한 번에 조회해 프론트 렌더링에 활용
const decision = await cerbos.checkResource({
  principal: { id: user.id, roles: [user.role] },
  resource:  { kind: 'settlement' },
  actions:   ['list', 'view'],
});

const canSee    = decision.isAllowed('list');   // 시나리오 C: 메뉴 노출 여부
const canAccess = decision.isAllowed('view');   // 시나리오 C: 실제 접근 여부

정책 파일만 수정하면 재배포 없이 반영됩니다. 단, Cerbos 서버를 별도로 운영해야 하는 외부 의존성이 생기기 때문에 소규모 어드민에서는 오버엔지니어링이 될 수 있습니다.

4) 어드민 빌더: 셀렉트 어드민

어드민 패널을 새로 구축하는게 나은 상황이라면, 권한 구조가 포함된 도구를 처음부터 쓰는 선택지도 있습니다. 셀렉트 어드민은 YAML로 메뉴 구조·쿼리·권한을 한 파일에서 선언적으로 관리합니다.

menus:
  # 시나리오 A: 역할별 메뉴 접근
  - path: orders
    name: 주문 관리
    roles:
      list: [CS팀, 뷰어]
      view: [CS팀, 뷰어]
  - path: campaigns
    name: 캠페인 관리
    roles:
      list: [마케팅팀]
      view: [마케팅팀]
  # 시나리오 C: 정산 메뉴는 CS팀에게 보이지만 접근은 정산팀만
  - path: settlement
    name: 정산 관리
    roles:
      list: [CS팀, 마케팅팀, 정산팀]  # 사이드바에 노출
      view: [정산팀]                   # 실제 접근

pages:
  - path: orders
    blocks:
      # 시나리오 B: 주문 취소는 슈퍼어드민만
      - type: query
        resource: mysql.qa
        sqlType: update
        sql: UPDATE orders SET status = 'CANCELLED' WHERE id = :id
        roles:
          edit:
            - 슈퍼어드민

      # 시나리오 B: CSV 다운로드는 슈퍼어드민만
      # actions.showDownload로 버튼을 분리하고, roles.edit으로 서버 단 차단
      - type: query
        resource: mysql.qa
        sqlType: select
        sql: SELECT * FROM orders
        showDownload: false
        roles:
          edit:
            - 슈퍼어드민
        actions:
          - label: CSV 다운로드
            showDownload: csv
            single: true

역할 추가 시 코드 배포 없이 YAML 수정만으로 즉시 반영됩니다. 어드민을 새로 만드는 경우에 적합하고, 기존 서비스에 권한 레이어만 얹는 상황에는 맞지 않습니다. 또한 YAML 구조를 벗어나는 복잡한 조건부 권한(예: 특정 데이터 속성에 따른 접근 제어)은 직접 구현하거나 다른 방식과 병행해야 합니다.


미들웨어 직접 구현 CASL ra-rbac Cerbos 셀렉트 어드민
권한 정의 위치 코드에 분산 단일 파일, 선언적 단일 파일, 선언적 외부 YAML 파일 YAML 설정 파일
메뉴 숨김/비활성화 프론트 별도 구현 list/view 액션 분리로 처리 내장 지원 별도 연결 필요 list/view 설정으로 처리
역할 추가 시 코드 수정 + 배포 코드 수정 + 배포 코드 수정 + 배포 정책 파일 수정 YAML 수정, 즉시 반영
어드민 구축 범위 전체 직접 구현 권한만 라이브러리 react-admin 전체 도입 권한만 외부 서버 어드민 + 권한 포함
주요 제약 권한 정의가 흩어짐 재배포 필요, 컨벤션 관리 필요 react-admin 전체 도입 필수 외부 서버 운영 부담 신규 구축 전용, 조건부 권한 한계
적합한 상황 완전 커스텀 필요 기존 앱에 권한 추가 react-admin 기반 구축 대규모, 감사 요구 엄격 어드민 패널 신규 구축 시

실무에서 자주 빠뜨리는 것들

권한 설계를 다 했다고 생각할 때 놓치는 케이스들입니다.

CSV 다운로드를 조회 권한과 분리하지 않은 경우. 조회는 화면에서 끝나지만 다운로드는 데이터가 조직 밖으로 나갑니다. 개인정보가 포함된 테이블이라면 반드시 분리해야 합니다.

복구가 어려운 액션에 확인 절차가 없는 경우. 대량 삭제, 강제 취소처럼 되돌리기 어려운 액션은 역할 제한 외에 실행 전 사유 입력이나 이중 확인을 추가하는 것이 좋습니다.

권한 설정 자체를 누구나 바꿀 수 있는 경우. 권한을 바꾸는 기능 자체를 슈퍼어드민으로 제한하지 않으면 의미가 없습니다.

퇴사자 처리 프로세스가 없는 경우. 멤버 비활성화 한 번으로 모든 접근이 차단되는지, 역할별로 하나씩 해제해야 하는지에 따라 운영 리스크가 달라집니다.

중요 액션의 실행 이력이 기록되지 않는 경우. "누가 언제 뭘 했는지"가 남지 않으면 문제 발생 시 원인 추적이 어렵습니다. 위험도가 높은 액션부터라도 감사 로그를 남기는 것을 권장합니다.


FAQ

Q. 역할이 여러 개인 사용자는 어떻게 처리하나요?

한 사람이 CS팀이면서 정산 권한도 필요한 경우처럼, 역할을 복수로 부여해야 하는 상황이 생깁니다. 이때는 각 역할의 권한을 합산(union)하는 방식이 일반적입니다. 다만 역할이 늘어날수록 "어떤 역할 때문에 이 권한이 생겼는가"를 추적하기 어려워지므로, 복수 역할보다는 별도 역할을 만드는 편이 장기적으로 관리하기 쉽습니다.

Q. 역할을 몇 개까지 만들어도 되나요?

역할 수 자체에 정해진 상한은 없지만, 역할이 늘어날수록 각 역할의 권한 범위가 명확히 구분되는지 주기적으로 점검하는 게 중요합니다. 역할이 10개를 넘어가기 시작하면, 먼저 합칠 수 있는 역할이 있는지 검토하세요. 역할이 과하게 세분화되면 "어떤 역할을 줘야 하는가"를 결정하는 운영 비용이 오히려 커집니다.

Q. 임시로 권한을 줬다가 회수해야 할 때는 어떻게 하나요?

임시 권한 부여는 놓치기 쉬운 운영 리스크입니다. 별도 만료일 필드를 두거나, 임시 역할을 따로 만들어 주기적으로 정리하는 방식이 현실적입니다. 어떤 방식을 쓰든 "임시로 부여한 권한 목록"을 한눈에 볼 수 있는 관리 화면이 있어야 실수를 줄일 수 있습니다.

Q. UI에서 메뉴를 숨기는 것만으로 충분하지 않나요?

프론트엔드에서 메뉴를 숨기는 건 UX 편의이고, 실제 보안은 서버 단 차단입니다. UI만 숨기면 API를 직접 호출해서 접근할 수 있습니다. 서버에서도 반드시 권한을 확인해야 합니다.

Q. RBAC와 ABAC 중 어드민에는 어떤 게 맞나요?

대부분의 어드민은 RBAC로 충분합니다. ABAC(속성 기반 접근 제어)는 "특정 조건일 때만 허용" 같은 세밀한 제어가 필요할 때 씁니다. 예를 들어 "자신이 담당한 고객 데이터만 볼 수 있다"처럼 데이터 범위가 사용자 속성에 따라 달라지는 경우입니다. RBAC로 시작하고, 이런 요구사항이 생기면 그때 ABAC 방식을 선택적으로 추가하는 순서가 현실적입니다.


참고 자료

Read more

주문 데이터 기반으로 티켓 관리 시스템 만들어보기

주문 데이터 기반으로 티켓 관리 시스템 만들어보기

고객을 응대할때 같은 질문을 반복하게 됩니다. 이 고객이 무엇을 샀는지, 지금 주문 상태는 어떤지, 이전에도 같은 이슈가 있었는지. 문의를 처리하는 기존 방법들부터, 주문 데이터를 기준으로 티켓을 정리하면 무엇이 달라지는지를 다룹니다. 복잡한 자동화가 아니라, 검색과 처리에 집중한 최소한의 시작 방법을 정리했습니다.

By Hakbeom Kim