서비스가 커지고 기능이 늘어나면, 사용자마다 접근 권한과 수행 가능한 작업이 달라집니다.
초기에는 단순했던 코드도 요구사항이 쌓이면 점점 읽기 어려워지고, 유지보수가 힘들어집니다.
이번 글에서는 관리자 페이지 구현 경험을 바탕으로, 복잡한 조건문을 정리하고 권한 로직을 선언적으로 관리하는 방법을 소개합니다.
복잡해지는 권한 체크 로직
관리자 페이지에는 최고관리자와 일반관리자로 역할이 구분됩니다.
최고관리자는 사용자를 삭제할 수 있고, 일반관리자는 사용자를 생성할 수만 있습니다.
초기에는 간단한 조건문으로 버튼을 보여주거나 숨기는 방식으로 구현했습니다.
{currentUser.role === 'SUPER_ADMIN' && (
<button onClick={() => handleDelete(user)}>삭제</button>
)}
{currentUser.role === 'ADMIN' && (
<button onClick={() => handleCreate()}>생성</button>
)}그러나 요구사항이 계속 추가될수록, 코드는 복잡해집니다.
{currentUser.role === 'SUPER_ADMIN' &&
currentUser.organizationId === user.organizationId && // 같은 조직
user.status === 'ACTIVE' && // 활성 상태인 사용자만
user.permissions.includes('CAN_BE_DELETED') && // 삭제 가능 플래그
(
<button onClick={() => handleDelete(user)}>삭제</button>
)}
{currentUser.role === 'ADMIN' && (
<button onClick={() => handleCreate()}>생성</button>
)}이런 코드는 읽기 어렵고, 새로운 요구사항을 추가할 때 실수하기 쉽습니다.
권한 규칙 분리하기
처음에는 각 컴포넌트에 조건문을 직접 작성했습니다. 그런데 같은 권한 체크가 여러 곳에 반복되다 보니, 권한 규칙이 바뀔 때마다 모든 곳을 찾아 수정해야 했습니다.
이런 문제를 해결하기 위해 권한 로직을 컴포넌트에서 분리하기로 했습니다. 권한을 역할별로 정의하고 한 곳에서 관리하면, UI는 단순히 "가능한가?"만 물어보면 되고, 규칙이 바뀌어도 한 곳만 수정하면 됩니다.
권한 인터페이스 정의
먼저 우리 시스템에서 체크할 수 있는 모든 권한을 인터페이스로 정의합니다.
export interface Permissions {
// 사용자 계정 관련 권한
createUser: () => boolean;
deleteUser: (user?: User) => boolean;
}관리자 페이지에서는 사용자(User)를 관리합니다.
최고관리자는 사용자를 삭제할 수 있고, 일반관리자는 사용자를 생성할 수 있습니다.
역할별 권한 정의
이제 각 역할이 무엇을 할 수 있는지 정의합니다.
permissionsFactoryMap은 각 역할(SUPER_ADMIN, ADMIN)마다 권한 객체를 생성하는 함수를 담고 있습니다.
현재 로그인한 사용자(currentUser)를 받아서, 그 사용자가 대상(targetUser)에게 어떤 작업을 수행할 수 있는지 판단합니다.
const permissionsFactoryMap = {
SUPER_ADMIN: (currentUser: AuthUser) => ({
createUser: () => true,
deleteUser: (targetUser?: User) =>
targetUser?.status === 'ACTIVE' &&
targetUser.organizationId === currentUser.organizationId &&
targetUser.permissions.includes('CAN_BE_DELETED'),
}),
ADMIN: (currentUser: AuthUser) => ({
createUser: () => true,
deleteUser: () => false,
}),
};- 각 권한 함수는 대상 객체를 받아 boolean을 반환합니다
- targetUser가 없을 때는 일반적인 권한 체크(목록 조회 등)에 사용됩니다.
- 역할에 따라 제약 조건이 다르게 적용됩니다
const superAdminPermissions = permissionsFactoryMap.SUPER_ADMIN(currentUser);
// UI에서 사용
{superAdminPermissions.deleteUser(targetUser) && (
<button onClick={() => handleDelete(targetUser)}>삭제</button>
)}이제 복잡했던 조건문이 deleteUser 메서드 안에 깔끔하게 정리되었습니다.
하지만 역할이 하드코딩되어 있고, 사용자가 여러 역할을 가진 경우를 다루기 어렵습니다.
여러 역할과 권한 통합
지금까지는 SUPER_ADMIN과 ADMIN 두 역할만 다뤘습니다.
하지만 팀이 커지면서 역할도 더 세분화되었습니다.
예를 들어 역할을 ContentAdmin(콘텐츠 관리)과 SecurityAdmin(보안 관리)으로 나누고,
한 관리자가 두 역할을 모두 가진다면 어떻게 될까요?
const contentPermissions = permissionsFactoryMap.CONTENT_ADMIN(currentUser);
const securityPermissions = permissionsFactoryMap.SECURITY_ADMIN(currentUser);
{(contentPermissions.deleteUser(user) ||
securityPermissions.deleteUser(user)) && (
<button onClick={() => handleDelete(user)}>삭제</button>
)}역할이 2개, 3개.. 계속 늘어나면 코드가 다시 복잡해집니다.
can 함수로 권한 간단 확인
이 문제를 해결하기 위해 can 함수를 만들었습니다.
이 함수는 사용자가 가진 모든 역할의 권한을 순회하면서, 하나라도 해당 작업을 수행할 수 있으면 true를 반환합니다.
{can(currentUser).deleteUser(user) && (
<button onClick={() => handleDelete(user)}>삭제</button>
)}can 함수는 두 단계로 동작합니다
can 함수 구현
export function can(currentUser: AuthUser): Permissions {
// 1단계: 사용자의 모든 역할에 대한 권한 객체 수집
const userRolePermissions = currentUser.roles
.map(role => permissionsFactoryMap[role]?.(currentUser))
.filter(Boolean);
// 2단계: Proxy로 동적 권한 체크
return new Proxy({} as Permissions, {
get(_target, action: keyof Permissions) {
return <T extends User>(data?: T) =>
userRolePermissions.some(
permissions => permissions?.[action]?.(data)
);
},
});
}1단계: 역할별 권한 객체 수집
const userRolePermissions = currentUser.roles
.map(role => permissionsFactoryMap[role]?.(currentUser))
.filter(Boolean);currentUser.roles: 사용자가 가진 역할 배열 (예:['CONTENT_ADMIN', 'SECURITY_ADMIN']).map(role => ...): 각 역할을 권한 객체로 변환permissionsFactoryMap[role]: 해당 역할의 권한 생성 함수 찾기?.(currentUser): 함수가 존재하면 호출해서 권한 객체 생성
.filter(Boolean): undefined/null 제거
결과적으로 다음과 같은 배열이 만들어집니다:
[
{ createUser: fn, deleteUser: fn }, // CONTENT_ADMIN의 권한
{ createUser: fn, deleteUser: fn } // SECURITY_ADMIN의 권한
]2단계: Proxy로 동적 권한 체크
Proxy는 객체의 속성 접근을 가로채서 커스텀 동작을 실행합니다.
return new Proxy({} as Permissions, {
get(_target, action: keyof Permissions) {
return <T extends User>(data?: T) =>
userRolePermissions.some(
permissions => permissions?.[action]?.(data)
);
},
});실제 호출 과정:
can(currentUser).deleteUser(user)can(currentUser)→ Proxy 객체 반환.deleteUser접근 → Proxy의get트랩 실행action = 'deleteUser'
get트랩이 함수를 반환:
(user) => userRolePermissions.some(
permissions => permissions?.deleteUser?.(user)
)(user)호출 →some으로 모든 역할 체크:
[
contentAdminPermissions.deleteUser(user), // false
securityAdminPermissions.deleteUser(user) // true
].some(result => result) // → true (하나라도 true면 true)some 메서드의 역할
some은 배열의 요소 중 하나라도 조건을 만족하면 true를 반환합니다.
userRolePermissions.some(
permissions => permissions?.[action]?.(data)
)
// 풀어쓰면
ContentAdmin.deleteUser(user) || SecurityAdmin.deleteUser(user)
// false || true → trueProxy를 사용하는 이유
Proxy를 사용하지 않으면 모든 권한 메서드마다 같은 패턴을 반복해야 합니다:
return {
createUser: (data) =>
userRolePermissions.some(p => p.createUser(data)),
deleteUser: (data) =>
userRolePermissions.some(p => p.deleteUser(data)),
updateUser: (data) =>
userRolePermissions.some(p => p.updateUser(data)),
// 권한이 10개, 20개로 늘어나면...
};Proxy를 사용하면 어떤 권한 메서드가 호출되든 자동으로 some 로직이 적용됩니다.
권한이 추가되어도 can 함수를 수정할 필요가 없습니다.
옵셔널 체이닝(?.)을 사용하는 이유
permissions?.[action]?.(data)permissions?.[action]: 권한 객체에 해당 메서드가 없을 수 있음?.(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만 수정하면 컴포넌트 코드는 그대로 사용할 수 있습니다.