프론트엔드 유저 권한 정책 유연하게 관리하기

오형근·2023년 11월 10일
7

Frontend

목록 보기
1/10
post-thumbnail

일반적으로 서비스의 규모가 증가하고 깊이(depth)가 깊어지면, 유저의 권한에 따라 접근 가능한 서비스의 범위에도 차이가 생기게 된다. 이는 서비스 자체적으로 유료 플랜을 적용하거나, 특정 권한을 가진 유저만 접근 가능한 정보가 존재하는 등의 이유 때문이다.

그러면 이러한 지점 각각에서 유저 권한을 불러와 확인하고 적용하면 되는 것일까? 각 지점에서 권한을 독립적으로 관리하게 된다면 서비스의 규모가 커질수록 이를 변경하기 어렵고, 각 지점들에 동일한 정책이 적용되어있는지 알기 어려워진다.

물론 프록시 등을 두어 백엔드/인프라 단에서 권한을 확인하는 로직이 반영되어있을 확률이 높지만, 프론트엔드 단에서 1차적인 권한 처리를 적용함으로써 유저에게 올바른 에러 처리를 제공해 고도화된 UX를 제공하거나 불필요한 api 호출을 줄이는 등 다양한 이점을 가져올 수 있다.

또한 권한 정책에서 세부적인 조건이 변경되는 등의 케이스도 존재하기 때문에, 이에 유연하게 반응할 수 있어야 한다. 이러한 조건들을 만족할 수 있는 권한 관리 모듈을 제작해보자.

1. 유저 권한 정의하기

이해를 돕기 위해, 유저를 크게 사장, 직원, 알바 로 나누자. 모든 유저는 반드시 세 권한 중 하나를 가지게 된다.

처음 구현할 때, 유저 권한을 크게 두 가지로 분류하였다.

가능한 유저를 정의하는 경우

A 액션은 사장님만 가능해요~

이 동작은 사장님만 가능해야한다. 다른 권한이 어떤 종류가 있는지는 잘 모르겠지만, 아무튼 사장님만 가능한 동작이다. 나중에 권한 분류가 세분화되거나 분류 기준이 늘어나더라도 사장님만 가능한 것이다.

이를 특정 권한에 대해서 eligble(적격) 이라고 정의한다.

불가능한 유저를 정의하는 경우

B 액션은 알바만 불가능해요~

이 동작의 경우 알바만 빼고 '모두가' 가능하다. 다른 권한이 어떤 종류가 있는지 잘 모르겠지만, 아무튼 알바만 빼고 '모두' 허용되는 동작이다.

이를 특정 권한에 대해 ineligible(부적격) 이라고 정의한다.

위의 내용을 다이어그램으로 나타내면 아래와 같다.

eligible&ineligible

2. 권한 정책을 정의하는 Class 제작하기

이제 유저 권한에 대한 정의를 내렸으니, 정의에 맞게 정책들을 작성해 줄 필요가 있다. 이때 유저 권한 정책을 작성하고 보관할 Class를 제작했다.

Q: 커스텀 훅으로 만들면 안 되나요?

이 부분에 있어 고민이 있었는데, 답변은 다음과 같다.

A:
리액트 커스텀 훅은 해당 로직을 리액트 진영 내부로 들이게 되면서 로직을 리액트 내부에 두고 관리하게 된다. 이는 useState와 같은 리액트 내장 함수를 사용할 수 있게 된다는 장점을 가지는 것과 동시에 해당 로직의 사용을 리액트 진영 내부로 제한한다.

유저 권한에 대한 정책을 정의하고 관리하는 것은 리액트와 무관한 부분이기 때문에, 이를 리액트 진영 외부에서 정의해 자율성을 높이고 관심사를 분리하는 것이 맞다고 생각했다.

정책 분류 메서드 제작

이제 Class를 제작해보자. 먼저 위에서 언급한 것처럼 정책을 특정 권한을 가진 경우 가능특정 권한을 가진 경우 불가능의 두 가지로 분류한다.

PermissionPolicyClass.ts

  type RoleType = '사장' | '직원' | '알바';

  // 이 권한을 가진 경우만 가능합니다.
  private eligible = (roles: RoleType[]) => {
    return roles.includes(this._role);
  };

  // 이 권한을 가진 경우만 불가능합니다.
  private ineligible = (roles: RoleType[]) => {
    return !roles.includes(this._role);
  };

각 정책에 맞는 권한 분기

그리고 각 정책에 맞게 유저 정책을 정의해주자.
먼저, 사장실 출입하기는 사장님만 가능한 액션이다. 이를 eligible 메서드로 정의해주자.

PermissionPolicyClass.ts

  // 사장실 출입하기
  get canAccess사장실() {
    return this.eligible(['사장']);
  }

이에 반해, 퇴사는 사장님에게는 불가한 액션이다. 만일 퇴사를 하고자 한다면 다른 사람에게 사장직을 위임한 뒤에야 가능할 것이다. 따라서 이는 ineligible 메서드로 정의해주자.

PermissionPolicyClass.ts

  // 퇴사하기
  get 퇴사() {
    return this.ineligible(['사장']);
  }

위와 같은 방법으로 각 정책들을 권한에 맞게 작성해주고, 싱글톤 패턴을 적용해 Class의 유일성을 보장해주자.

전체 예시 코드는 아래와 같다.

type RoleType = '사장' | '직원' | '알바';

class PermissionPolicyChecker {
  private static instance: PermissionPolicyChecker;

  // 싱글톤 패턴 적용
  // -> 싱글톤을 적용하였더니 새로운 유저로 로그인하여도 이전의 클래스를 기반으로 권한을 분류하는 문제가 발생했다.
  // -> 각 환경에서 확인 후 싱글톤을 사용하지 않거나 유저 변경 시 다시금 인스턴스를 생성해줄 수 있도록 해야한다.
  static getInstance(role: RoleType) {
    if (!PermissionPolicyChecker.instance) {
      PermissionPolicyChecker.instance = new PermissionPolicyChecker(role);
    }
    return PermissionPolicyChecker.instance;
  }

  /**
   * 권한은 크게 두 종류로 나눌 수 있습니다.
   * 1. 해당 권한인 경우에만 가능한 것
   * 2. 해당 권한인 경우에만 불가능한 것
   *
   * 이를 위해 메서드를 두 가지로 분리했습니다.
   */
  private readonly _role: RoleType;

  constructor(role: RoleType) {
    this._role = role;
  }

  // 이 권한을 가진 경우만 가능합니다.
  private eligible = (roles: RoleType[]) => {
    return roles.includes(this._role);
  };

  // 이 권한을 가진 경우만 불가능합니다.
  private ineligible = (roles: RoleType[]) => {
    return !roles.includes(this._role);
  };

  // 사장실 출입하기
  get canAccess사장실() {
    return this.eligible(['사장']);
  }
  
  // 퇴사하기
  get 퇴사() {
    return this.ineligible(['사장']);
  }
}

export default PermissionPolicyChecker;

실제 정책들은 더욱 많아 아래 코드가 매우 증가하겠지만, 정책의 수에 구애받지 않고 각 정책에 대한 정의를 명시적으로 관리하고 이해할 수 있다.

3. 정책과 유저 권한을 연결하는 Custom Hook 제작하기

이제 단일 출처가 되는 정책 Class를 제작했으니, 이를 서버에서 불러온 유저 권한과 연결해주자.

유저에 대한 정보는 로그인 시 전역 상태에 담겨 관리되고 있으므로, 해당 유저 권한이 존재하는지 여부를 확인하고 이를 위에서 제작한 Class에 담아 정책들을 사용 가능하게 만들어주도록 하자.

import { useRouter } from 'next/navigation';
import { useRecoilValue } from 'recoil';

import userRoleSelector from '@/recoil/selector/userRoleSelector';
import PermissionPolicyChecker from '@/utils/PermissionPolicyClass';

const usePerMissionPolicy = () => {
  // 전역으로 관리되는 유저 권한을 가져옴.
  const userRole = useRecoilValue(userRoleSelector);

  const router = useRouter();

  // 유저 권한이 null이라면 로그인을 요구함.
  if (!userRole) {
    throw new Error('유저가 없습니다. 다시 로그인해주세요!');
  }

  // 가져온 유저 권한을 Class에 주입해줌.
  const UserPermissionPolicyChecker = PermissionPolicyChecker.getInstance(userRole);

  return UserPermissionPolicyChecker;
};

export default usePerMissionPolicy;

위 코드를 살펴보면, 먼저 전역으로 관리되는 유저 권한을 가져온다. 이후 해당 유저 권한의 존재 여부를 따지고, 이를 Class에 주입해 권한을 체크하는 객체를 반환하도록 되어 있다.

이때 만일 유저 권한이 존재하지 않는다면 에러를 반환하는데, 이는 애플리케이션내 유저 권한이 요구되는 영역을 감싸는 Error Boundary에서 캐치하여 에러를 표시해줄 수 있도록 하였다.

4. 실제 코드에 적용하기

제작한 Custom Hook을 실제 코드에 적용해보자.

눌렀을 때 사장실에 출입하는 액션을 가진 버튼이 있다. 이를 현재 접속한 유저의 권한을 확인하여 렌더링 여부를 결정하는 코드를 작성해보자.

AccessPresidentOfficeButton.ts

import usePerMissionPolicy from '@/hooks/usePermissionPolicy';
import 사장실출입버튼 from '@/components/사장실출입버튼';

const AccessPresidentOfficeButton = () => {
  	const { canAccess사장실 } = usePerMissionPolicy();
  
	return 
  		<>
          {canAccess사장실 && <사장실출입버튼 />}	
        </>
}

이러한 방식으로 유저 권한에 따른 렌더링 상태를 선언적으로 관리해줄 수 있다!!


이번 글에서는 다양하게 나뉘고 적용되는 유저 권한과 그에 맞는 정책들을 선언적으로 관리하는 모듈을 제작하고 이를 실제 예시에 적용해보았다.

실제 해당 훅을 제작함으로써 권한 정책이 변경되거나 추가되는 상황에서도 유연하게 대처하고 관리할 수 있게 되었다.

2개의 댓글

comment-user-thumbnail
2023년 11월 20일

안녕하세요. 저도 최근 유저 권한 관련하여 고민을 한 적이 있어 글을 재밌게 읽었습니다. 2번의 전체 예시 코드에서 오타가 있는 것 같아 알려드립니다. type RoleType = 'OWNER' | 'MANAGER' | 'EDITOR'; 로 정의되고 있으나, 아래에서는 '사장'으로 사용되고 있습니다!

1개의 답글