복잡한 권한 체크 로직을 깔끔하게 관리하는 방법

2025년 10월 14일

서비스가 커지고 기능이 늘어나면, 사용자마다 접근 권한과 수행 가능한 작업이 달라집니다.
초기에는 단순했던 코드도 요구사항이 쌓이면 점점 읽기 어려워지고, 유지보수가 힘들어집니다.
이번 글에서는 관리자 페이지 구현 경험을 바탕으로, 복잡한 조건문을 정리하고 권한 로직을 선언적으로 관리하는 방법을 소개합니다.


복잡해지는 권한 체크 로직

관리자 페이지에는 최고관리자와 일반관리자가 있습니다.
최고관리자는 일반관리자를 관리할 수 있어야 하고, 일반관리자는 일부 권한만 가져야 합니다.
처음에는 간단히 조건문으로 버튼을 숨길 수 있었습니다.

{currentUser.role === 'SUPER_ADMIN' && (
  <button onClick={() => handleDelete(user)}>삭제</button>
)}

그러나 요구사항이 계속 추가될수록, 코드는 복잡해집니다.

 
{currentUser.role === 'ADMIN' &&
  currentUser.organizationId === user.organizationId && // 같은 조직
  user.status === 'ACTIVE' &&             // 활성 상태인 사용자만
  user.permissions.includes('CAN_BE_DELETED') && // 삭제 가능 플래그
  (
    <button onClick={() => handleDelete(user)}>삭제</button>
  )}

이런 코드는 읽기 어렵고, 새로운 요구사항을 추가할 때 실수하기 쉽습니다.


권한 규칙 분리하기

먼저 이런 복잡한 권한 로직을 컴포넌트에서 분리합니다.
권한을 역할별로 정의하고, 중앙에서 관리하면 코드가 훨씬 깔끔해집니다.

권한 인터페이스 정의

먼저 우리 시스템에서 체크할 수 있는 모든 권한을 인터페이스로 정의합니다.

export interface Permissions {
  // User 관련 권한
  updateUser: (user?: User) => boolean;
  deleteUser: (user?: User) => boolean;
 
  // Operator 관련 권한
  updateOperator: (operator?: Operator) => boolean;
  deleteOperator: (operator?: Operator) => boolean;
}

관리자 페이지에서는 User(일반 사용자)와 Operator(운영자)를 관리합니다.
최고관리자는 모든 사용자와 운영자를 자유롭게 관리할 수 있고, 일반관리자는 일부 대상만 다룰 수 있습니다.

역할별 권한 정의

이제 각 역할이 무엇을 할 수 있는지 정의합니다.
permissionsFactoryMap은 각 역할(SUPER_ADMIN, ADMIN)마다 권한 객체를 생성하는 함수를 담고 있습니다.
현재 로그인한 사용자(currentUser)를 받아서, 그 사용자가 대상(targetUser, targetOperator)에게 어떤 작업을 수행할 수 있는지 판단합니다.

const permissionsFactoryMap = {
  SUPER_ADMIN: (currentUser: AuthUser) => ({
    updateUser: (targetUser?: User) => 
      !targetUser || targetUser.id !== currentUser.id,
    deleteUser: (targetUser?: User) => 
      !targetUser || targetUser.id !== currentUser.id,
 
    updateOperator: (targetOperator?: Operator) => 
      !targetOperator || targetOperator.id !== currentUser.id,
    deleteOperator: (targetOperator?: Operator) => 
      !targetOperator || targetOperator.id !== currentUser.id,
  }),
      
  ADMIN: (currentUser: AuthUser) => ({
    updateUser: (targetUser?: User) => 
      !targetUser || targetUser.role === 'USER' || targetUser.id === currentUser.id,
    deleteUser: (targetUser?: User) => 
      !targetUser || targetUser.role === 'USER',
 
    updateOperator: (targetOperator?: Operator) => 
      !targetOperator || targetOperator.id === currentUser.id,
    deleteOperator: () => false,
  }),
};
  • 각 권한 함수는 대상 객체를 받아 boolean을 반환합니다
  • !targetUser는 대상이 없을 때(목록 조회 등) 기본 권한을 체크합니다
  • 역할에 따라 제약 조건이 다르게 적용됩니다

여러 역할과 권한 통합

지금 예시에서는 슈퍼 관리자가 관리자보다 상위 권한을 가지므로, 한 사용자가 두 역할을 동시에 가질 필요가 없습니다.
하지만 한 사용자가 여러 역할을 동시에 가져야 하는 경우도 있습니다.
예를 들어, 어떤 사용자가 Editor(편집자)와 Moderator(중재자) 역할을 모두 가진 사용자라면:

  • Editor 역할로는 게시글을 작성하고 수정할 수 있고
  • Moderator 역할로는 댓글을 삭제하거나 신고를 처리할 수 있습니다

"댓글을 삭제할 수 있는가?"를 체크할 때, Editor 권한만으로는 불가능하지만 Moderator 권한이 있으므로 삭제가 허용되어야 합니다.

{(user.roles.includes('EDITOR') || user.roles.includes('MODERATOR')) && 
  <button>수정</button>
}

조건문으로 이렇게 작성할 수 있지만, 역할이 많아지면 코드가 복잡해집니다.

can 함수로 권한 간단 확인

can 함수를 사용하면, 역할별 권한을 자동으로 합쳐서 단일 함수 호출만으로 권한을 확인할 수 있습니다.

{can(user).editPost(post) && 
  <button>수정</button>
}
export function can(currentUser: AuthUser): Permissions {
  const userRolePermissions = currentUser.roles
    .map(role => permissionsFactoryMap[role]?.(currentUser))
    .filter(Boolean); 
 
  return new Proxy({} as Permissions, {
    get(_target, action: keyof Permissions) {
      return <T extends User | Operator>(data?: T) =>
        userRolePermissions.some(
          permissions => permissions?.[action]?.(data as User & Operator)
        );
    },
  });
}
  • some 메서드를 사용해, 여러 역할 중 하나라도 권한이 있으면 허용합니다.
  • 예를 들어 can(user).deleteComment(comment)는 Editor 역할은 false지만, Moderator 역할이 true이므로 최종적으로 true가 됩니다.

Proxy를 사용하면 반복적으로 some을 적용하는 코드를 일일이 작성하지 않아도 됩니다.

return {
  deleteUser: (data) => userRolePermissions.some(p => p.deleteUser(data)),
  updateUser: (data) => userRolePermissions.some(p => p.updateUser(data)),
  deleteOperator: (data) => userRolePermissions.some(p => p.deleteOperator(data)),
  // ...
};

사용 예시

이제 UI에서는 권한의 상세 조건을 알 필요가 없습니다. "삭제 권한이 있는가?"라고 선언적으로 코드를 작성합니다.

// 이전 코드 (복잡한 조건문)
{currentUser.role === 'ADMIN' && targetUser.role !== 'SUPER_ADMIN' && ... && (
  <button onClick={() => handleDelete(user)}>삭제</button>
)}
 
// 개선된 코드 (선언적인 권한 체크)
{can(currentUser).deleteUser(targetUser) && (
  <button onClick={() => handleDelete(user)}>삭제</button>
)}

이제 UI 컴포넌트는 누가 관리자인지, 조직이 같은지 같은 세부 조건을 신경 쓰지 않아도 됩니다.
단순히 "삭제 권한이 있는가?"만 확인하면 되기 때문에, 코드가 훨씬 깔끔하고 유지보수가 쉬워집니다.
새로운 권한이 생겨도 permissionFactoryMap만 수정하면 컴포넌트 코드는 그대로 사용할 수 있습니다.