DEV_BBAK
← 포스트

프론트엔드에서 권한은 Role이 아니라 Permission으로 다뤄야 한다

·17 min read·로딩중...

한 권한이 정확히 어디까지 열어주는지 모르겠다

얼마 전 권한 설계 관련 회의에서 이런 질문을 받았다.

"그래서 MANAGER 권한은 정확히 어디까지 접근할 수 있나요?"

나는 답을 코드에서 찾아야 했다. 코드베이스를 뒤져 user.role === 'MANAGER'가 어디서 걸러지고, 어디서 ADMIN이 추가로 열리고, 어디서 ENTERPRISE 플랜 조건이 붙는지 하나하나 추적했다. 회의록에는 "매니저는 멤버 조회·초대 가능, 삭제 불가"라고 적었지만, 실제 코드는 그보다 복잡했고, 무엇보다 답이 한 곳에 모여 있지 않았다.

PO는 내 답을 듣고 다시 물었다.

"그럼 이 권한이 갖고 있는 접근 권한이 정확히 뭔가요? 코드 어디서 확인할 수 있나요?"

이 질문이 계속 마음에 걸렸다. PO가 이상하게 묻는 게 아니라, 코드가 대답할 수 없는 질문이었다. Role은 "어떤 이름표를 달고 있는가"는 알려주지만, "그 이름표가 화면의 어느 문을 열어주는가"는 알려주지 않는다. 그 정보가 화면 곳곳에 흩어져 있었기 때문이다.

이 글은 그 회의 이후에 Role에서 Permission으로 시선을 옮긴 과정을 정리한 것이다. 왜 프론트엔드에서 ROLE === 'ADMIN' 대신 Permission 중심으로 코드를 짜야 하는지, 그것이 PO/PD와의 소통을 어떻게 바꾸는지, 그리고 끝에서는 이 모델을 관리하는 별도의 권한 어드민까지 어떻게 이어지는지까지 다룬다.

Role은 이름표, Permission은 열쇠

RBAC는 Role-Based Access Control의 줄임말이다. 역할을 기준으로 접근을 제어하는 방식인데, 어드민 제품을 만들다 보면 보통 이런 Role로 시작한다.

type Role = 'OWNER' | 'ADMIN' | 'MANAGER' | 'MEMBER';

처음엔 이게 충분해 보인다. OWNER는 모든 권한을 가지고, ADMIN은 대부분의 관리 기능을 쓸 수 있고, MANAGER는 일부, MEMBER는 조회만 한다. UI 코드도 단순하다.

{user.role === 'ADMIN' && <DeleteUserButton />}

문제는 같은 MANAGER라도 고객마다 기대가 다를 때 드러난다. A 고객사의 매니저는 멤버 초대가 가능하지만, B 고객사의 매니저는 조회만 할 수 있어야 할 수도 있다. 이 차이를 Role만으로 처리하려다 보면 Role 이름이 점점 늘어난다 — INVITE_MANAGER, READONLY_MANAGER, BILLING_MANAGER 같은 식으로. 어느 순간 Role은 이해하기 쉬운 이름표가 아니라 예외의 목록이 된다.

여기서 Role과 Permission을 분리하면 숨이 트인다. Role은 조직에서 사람을 부르는 이름표이고, Permission은 제품 안의 특정 행동을 허용하는 열쇠다. 멤버를 초대하는 문을 열려면 member:invite 열쇠가 필요하고, 결제 정보를 수정하는 문을 열려면 billing:update 열쇠가 필요하다.

type Permission =
  | 'member:read'
  | 'member:invite'
  | 'member:delete'
  | 'billing:read'
  | 'billing:update'
  | 'report:download';
제품 안의 행동 필요한 열쇠
멤버 초대하기 member:invite
멤버 삭제하기 member:delete
결제 정보 수정하기 billing:update

이 비유의 핵심은 "사람을 분류하는 정보"와 "문을 여는 정보"를 분리하는 데 있다.

flowchart LR
  Role["Role\n이름표"] --> User["사용자"]
  Permission["Permission\n열쇠"] --> Keyring["열쇠꾸러미"]
  Keyring --> User
  User --> UI["프론트엔드 화면"]

회의에서 받았던 그 질문, "이 권한이 갖고 있는 접근 권한이 뭔가요?"는 사실 "이 사람의 열쇠꾸러미 안에 어떤 열쇠가 있는지 한 번에 보여줄 수 있는가"라는 뜻이었다. Role 중심 코드로는 그걸 보여줄 수 없었다. 권한 정책이 컴포넌트 안에 흩어져 있었기 때문이다.

서버가 열쇠꾸러미를 지급하고, 최종 자물쇠도 서버에 있다

프론트엔드가 권한을 다룬다고 해서 권한을 "판결"하는 건 아니다. 열쇠는 서버가 발급하고, 최종적으로 문을 열어줄지 말지도 서버가 판단한다.

로그인 응답이 이렇게 내려온다고 해보자.

{
  "id": 1,
  "name": "Jun",
  "role": "MANAGER",
  "permissions": ["member:read", "member:invite", "report:download"]
}

이 사용자의 이름표는 MANAGER지만, 프론트엔드가 더 관심 갖는 건 열쇠꾸러미 쪽이다. 화면을 그릴 때 Role이 아니라 가진 열쇠를 보기 때문이다.

sequenceDiagram
  participant User as 사용자
  participant Server as 서버
  participant FE as 프론트엔드
  participant Gate as PermissionGate

  User->>Server: 로그인
  Server-->>FE: role + permissions 지급
  FE->>Gate: 이 UI에 필요한 permission 전달
  Gate->>Gate: 열쇠꾸러미에서 필요한 열쇠 확인
  alt 열쇠가 있음
    Gate-->>FE: children 렌더링
  else 열쇠가 없음
    Gate-->>FE: fallback 또는 숨김
  end

여기서 절대 놓치면 안 되는 것이 있다. 프론트엔드에서 버튼을 숨겨도 사용자는 API를 직접 호출할 수 있다. 개발자 도구로 요청을 복사해 보낼 수도 있다. 그래서 최종 자물쇠는 반드시 서버에 있어야 한다.

flowchart LR
  FE["프론트엔드\nPermissionGate"] -->|UX 표현| Button["버튼 숨김/표시"]
  Client["클라이언트 요청"] --> API["서버 API"]
  API --> Authz{"서버 권한 검증"}
  Authz -->|허용| OK["200 OK"]
  Authz -->|거부| Forbidden["403 Forbidden"]

프론트엔드의 권한 처리는 안내판에 가깝다. 열 수 없는 문을 굳이 보여주지 않거나, 왜 열 수 없는지 알려준다. 하지만 문을 진짜로 잠그는 일은 서버가 한다. 역할을 이렇게 나누어야 "화면에 보이지 않으니까 안전하다"는 착각을 피할 수 있다.

ROLE === 'ADMIN'이 흩어지는 순간

처음으로 돌아가서, 그 회의 전의 코드가 왜 그렇게 답하기 어려웠는지를 살펴보면 다음과 같다.

초기에는 Role 중심 코드가 편하다.

{(user.role === 'OWNER' || user.role === 'ADMIN') && (
  <InviteMemberButton />
)}

그런데 어느 날 특정 고객사의 MANAGER도 멤버를 초대할 수 있어야 한다는 요구가 들어온다.

{(user.role === 'OWNER' ||
  user.role === 'ADMIN' ||
  (user.role === 'MANAGER' && user.organization.plan === 'ENTERPRISE')) && (
  <InviteMemberButton />
)}

조건문이 길어진 것 자체는 감당할 수 있다. 진짜 문제는 "멤버 초대"라는 동일한 정책이 멤버 목록 페이지, 온보딩 CTA, 빈 상태 화면, 헤더, 모바일 메뉴 등 여러 화면에 각자 살짝 다른 조건으로 반복된다는 것이다. 정책이 바뀌면 모든 화면을 뒤져야 하고, 각 화면의 조건이 정말 같은 정책을 표현하고 있는지 확신할 수 없게 된다.

flowchart TD
  Policy["멤버 초대 정책 변경"] --> A["MemberList.tsx"]
  Policy --> B["OnboardingCTA.tsx"]
  Policy --> C["EmptyState.tsx"]
  Policy --> D["HeaderAction.tsx"]
  Policy --> E["MobileMenu.tsx"]
  A --> Risk["조건 누락 / 불일치 위험"]
  B --> Risk
  C --> Risk
  D --> Risk
  E --> Risk

회의에서 PO가 "이 권한이 어디까지 열리는지 확인할 수 있는지"를 물었을 때 내가 코드를 뒤져야 했던 이유가 정확히 여기다. 권한 정책이 코드 곳곳에 새어 들어가 있었기 때문이다.

Permission 중심으로 바꾸면 화면 코드는 이렇게 바뀐다.

<PermissionGate permission="member:invite">
  <InviteMemberButton />
</PermissionGate>

이제 화면은 "누가 이 권한을 갖는가"를 직접 설명하지 않는다. 그 결정은 서버가 내려주는 권한 정책에 맡긴다. 화면은 자신이 어떤 열쇠를 요구하는지만 선언한다.

PermissionGate의 단순한 형태는 대략 이렇다.

type PermissionGateProps = {
  permission: Permission;
  children: React.ReactNode;
  fallback?: React.ReactNode;
};

function PermissionGate({
  permission,
  children,
  fallback = null,
}: PermissionGateProps) {
  const { permissions } = useAuth();

  if (!permissions.includes(permission)) {
    return fallback;
  }

  return <>{children}</>;
}

이 코드는 가장 단순한 형태다. 실무에서는 권한이 아직 로딩 중일 때, 권한이 새로고침되어야 할 때, 권한이 여러 개 필요한 경우(AND/OR), 권한 없음이 버튼 숨김이 아니라 disabled나 403 라우팅으로 표현되어야 할 때 등을 별도로 다뤄야 한다. 다만 핵심은 동일하다. UI 컴포넌트는 Role 이름을 몰라도 되고, 자신이 필요로 하는 열쇠만 선언한다.

예를 들어 권한이 없을 때 버튼을 숨기지 않고 비활성 상태로 보여줘야 한다면 이렇게 쓴다.

<PermissionGate
  permission="billing:update"
  fallback={<DisabledButton reason="결제 정보를 수정할 권한이 없어요" />}
>
  <UpdateBillingButton />
</PermissionGate>

이 선택 자체가 제품 경험의 설계 지점이라는 점이 중요하다. 사용자가 기능의 존재를 몰라도 되면 숨기고, 기능은 알아야 하지만 지금은 못 쓴다면 disabled, 페이지 단위 접근 제한이면 403 페이지로 보낸다. PermissionGate는 단순히 null을 반환하는 컴포넌트가 아니라, 권한 없음을 어떻게 경험으로 보여줄지를 결정하는 경계다.

소통 비용을 줄이려면 권한 정책도 코드 밖으로 꺼내야 한다

다시 회의로 돌아가면, PO가 "이 권한이 어디까지 열리는지 알려달라"고 물었을 때 내가 대답할 수 없었던 진짜 이유는 단순히 코드가 더럽기 때문만은 아니었다. 권한 정책이 코드 안에만 존재했기 때문이다. Permission을 도입하면 코드는 훨씬 읽기 쉬워지지만, 여전히 "어떤 Role이 어떤 Permission을 갖는가"라는 매핑은 서버 코드나 설정 어딘가에 박혀 있다.

여기서 한 발 더 나아가 Role-Permission 매핑을 데이터로 모델링하면 이야기가 바뀐다. Role은 테이블의 한 행이 되고, Permission도 한 행이 되며, 둘 사이의 연결은 또 다른 매핑 테이블이 된다. 모델로서 존재하기 때문에 누군가가 "이 Role은 어떤 Permission을 갖는가"를 물으면, 코드를 뒤지는 게 아니라 데이터를 조회하면 끝이다.

여기에 한 걸음 더 — 이 매핑을 편집하는 권한 관리 어드민을 만들면, PO나 PD가 "이번 고객사 매니저는 멤버 초대가 안 되게 해 주세요"라고 할 때 프론트엔드를 부르지 않아도 된다. 매핑을 바꾸면 그 Role을 가진 사용자가 다음에 받는 열쇠꾸러미가 바뀌고, 화면은 자동으로 따라간다. PR을 올리고 코드를 배포하고 화면을 고칠 필요 없이, 권한 정책 자체가 제품의 설정 항목이 되는 것이다.

이 구조가 주는 가장 큰 변화는 "소통 비용" 쪽이다. 회의에서 "이 권한이 뭘 할 수 있는지"를 물을 때, 더 이상 프론트엔드가 코드를 뒤져서 답하지 않아도 된다. 권한 관리 어드민을 열어보면 그 자리에서 답이 나온다. 개발자는 그저 "이 Permission을 요구하는 문"을 화면에 선언하는 역할에 집중하면 된다.

Role-Permission 모델과 권한 어드민이 갖는 의미는 단순히 "코드가 깔끔해진다"가 아니다. 권한 정책이라는 것이 본래 제품의 의사결정 영역인데, 그것을 개발자가 코드로 번역해야만 바뀌는 구조라면 PO/PD와의 대화가 늘 코드 레벨로 내려올 수밖에 없다. 모델을 도입하고, 매핑을 데이터로 빼고, 그것을 편집할 수 있는 화면을 만들면, 권한에 대한 대화가 비로소 코드가 아니라 제품 언어로 흐른다.

마무리: 권한은 이름표보다 행동에 가깝다

Role은 좋은 출발점이다. 이름도 직관적이고 구현도 빠르다. 하지만 고객이 다양해지고 정책이 세분화되면 Role만으로는 부족해진다. 회의에서 "이 권한이 어디까지 열리는지 알려달라"는 질문에 코드를 뒤져야 했던 순간이 바로 그 신호였다.

Permission은 그 질문에 대답하기 위한 단위다. 서버가 사용자에게 열쇠꾸러미를 지급하고, 프론트엔드는 화면 앞에서 그 열쇠를 확인한다. PermissionGate는 이 과정을 UI 코드에서 표현하는 작은 경계이고, Role-Permission 매핑을 데이터로 모델링하면 그 매핑을 관리하는 어드민까지 자연스럽게 이어진다.

권한은 이름표의 문제가 아니라 행동의 문제다. 그리고 그 행동을 코드에서 꺼내 데이터로 옮길 수 있을 때, 권한 정책은 비로소 제품의 언어가 된다.