본문 바로가기
tech epilogue

권한 체계 설계하기(feat. vue, pinia)

by rami_ 2026. 5. 20.

최근 기존 프로젝트에 권한을 추가해야 하는 일이 있었습니다. 처음 설계할 때 레이어를 명확하게 나누지 않으면 나중에 고치기 어렵겠다는 생각이 들었고, 그 결정이 결과적으로 옳았습니다. 기억을 남기기 위해 기록합니다.

 

 

왜 레이어 분리가 중요한가

 

 

권한 체계는 앱 전체에 영향을 줍니다.

컴포넌트 안에서 직접 권한을 계산하면 나중에 서버 응답 구조가 바뀔 때 그 컴포넌트를 전부 찾아서 수정해야 합니다. 라우터 가드에서 권한 계산까지 같이 하면 같은 로직이 컴포넌트에서도 중복으로 나타납니다. 결국 권한 로직이 앱 전체에 흩어지고, 어느 것이 진짜 기준인지 추적하기 어려워집니다.

 

그래서 각 레이어가 하나의 역할만 맡도록 설계했습니다.

[ 서버 역할/권한 데이터 ]
        ↓
[ Pinia Store — 권한 계산 전담 ]
        ↓
[ Router Guard — 페이지 진입 제어 전담 ]
        ↓
[ 컴포넌트 — UI 노출 제어 ]

 

이 구조를 RBAC(Role-Based Access Control)라고 부릅니다. 서버에서 내려온 역할/권한 데이터를 기준으로, 어떤 페이지에 들어갈 수 있는지, 어떤 버튼을 볼 수 있는지를 제어하는 방식입니다.

 

 

서버 응답구조

 

로그인시 서버는 유저가 접근할 수 있는 메뉴 목록을 내려줍니다.

{
 ...
    "userMenuList": [
        {
            "menuId": 1,
            "title": "메뉴_홈",
            "path": "/contents/dashboard",
            "menuPermission": "READ_WRITE_DELETE",
            ...
       }
    ]
  }

 

menuPermission 값은 세 단계로 내려옵니다.

- READ_WRITE_DELETE : 읽기/쓰기/삭제 모두 가능

- READ_WRITE : 읽기, 쓰기만 가능

- READ : 읽기 전용

 


 

 

Store 레이어 - 권한 계산만 담당

 

Store는 서버 응답을 받아서 저장하고, 권한 계산 결과를 제공하는 역할만 합니다. API 호출이나 페이지 이동같은 부수효과는 여기서 하지 않습니다. 가장 중요한 설계 결정은 menuPermission 문자열을 숫자로 매핑한 것입니다.

export const PERMISSION_LEVEL = {
  NONE: 0,
  READ: 1,
  READ_WRITE: 2,
  READ_WRITE_DELETE: 3,
} as const

 

문자열을 직접 비교하면 canRead, canWrite, canDelete를 각 조건문으로 써야 합니다. 숫자로 매핑하면 >= 비교 하나로 세 권한 동시에 결정됩니다.

const level = PERMISSION_LEVEL[menu.menuPermission] // 예: 3
{
  canRead:   level >= 1,  // true
  canWrite:  level >= 2,  // true
  canDelete: level >= 3,  // true
}

 

이 계산 결과를 permissionMap이라는 computed로 만들어 path를 키로 빠르게 조회할 수 있도록 했습니다.

 

const permissionMap = computed(() => {
  const map: Record<string, { level: number; canRead: boolean; canWrite: boolean; canDelete: boolean }> = {}

  const extract = (menuList: UserMenu[], parentPermission?: PermissionType) => {
    menuList.forEach(menu => {
      // 자신 권한이 NONE이면 부모 권한 상속
      const effectivePermission =
        menu.menuPermission === 'NONE' && parentPermission
          ? parentPermission
          : menu.menuPermission
      const level = PERMISSION_LEVEL[effectivePermission] ?? 0

      map[menu.path] = {
        level,
        canRead:   level >= PERMISSION_LEVEL.READ,
        canWrite:  level >= PERMISSION_LEVEL.READ_WRITE,
        canDelete: level >= PERMISSION_LEVEL.READ_WRITE_DELETE,
      }
      if (menu.children?.length) extract(menu.children, effectivePermission)
    })
  }

  extract(frontList.value)
  return map
})

 

한 가지 주의할 점이 있습니다. permissionMap은 computed라 pinia-plugin-persistedstate로 직접 persist할 수 없습니다.

그래서 원본 데이터인 frontList를 persist하고, 새로고침 후 frontList가 복원되면 permissionMap이 자동으로 재계산 되도록 했습니다.

 

setNavsStore를 호출하면 두 가지로 나뉘어 저장됩니다. frontList는 전체 메뉴 원본을 그대로 담아 persist 대상이 되고, navs는 isMenu: true이면서 READ 이상인 것만 걸러내 사이드바 렌더링에 씁니다.

 

 

Router 레이어 - 페이지 진입 제어 전담

 

Router는 Store가 계산해준 PermissionMap을 소비해서 '이 페이지에 들어가도 되는가'만 판단합니다.

 

권한 레벨은 라우트 선언부의 meta에 명시했습니다. 이렇게 하면 라우트 파일을 보는 것만으로도 어떤 권한이 필요한 페이지인지 바로 있습니다.

// 기본값: canRead — 별도 명시 없으면 읽기 권한만 있어도 진입 가능
{ path: 'stations', meta: { tokenRequired: true } }

// canWrite 명시 — 쓰기 권한 없으면 진입 불가
{ path: 'stations/register', meta: { tokenRequired: true, requiredPermission: 'canWrite' } }

 

beforeEach의 판단 흐름은 다음 순서로 진행됩니다.

router.beforeEach((to, from, next) => {
  // 1. 쿠키에서 토큰 확인
  const isTokenValid = cookies.get('authorization') && ...

  // 2. tokenRequired 페이지 진입 시도
  if (to.meta.tokenRequired) {
    if (!isTokenValid) return next({ name: 'Login' })

    // 3. store 미초기화 방어 (새 탭 직접 URL 입력 등)
    if (navStore.frontList.length === 0) return next(ROUTE.NO_PERMISSION)

    // 4. permissionMap에서 현재 경로 권한 조회
    const permission = navStore.permissionMap[to.path]
    const requiredPermission = to.meta.requiredPermission ?? 'canRead'

    // 5. 직접 매칭 없으면 상위 경로 폴백
    const hasAccess = permission
      ? permission[requiredPermission]
      : Object.keys(navStore.permissionMap)
          .some(p => to.path.startsWith(p + '/') && navStore.permissionMap[p][requiredPermission])

    // 6. 권한 없으면 차단
    if (!hasAccess) {
      if (from.name) return next(false)
      return next(navStore.navs[0]?.path ?? ROUTE.NO_PERMISSION)
    }

    // 7. 통과 시 쿠키 만료 2h 연장 (Rolling Session)
    cookies.set('authorization', token.authorization, '2h')
    return next()
  }
})

 

토큰은 쿠키에 저장하고, 페이지 이동할 때마다 만료 시간을 2시간으로 갱신하는 Rolling Session 방식을 택했습니다. 탭을 열어두고 사용하는 동안에는 세션을 유지되고, 탭을 닫고 일정 시간이 지나면 자동 만료됩니다.

 

 

컴포넌트 레이어 - UI 제어

 

Router Guard가 페이지 진입을 막는다면, 컴포넌트는 페이지 안에서 버튼이나 액션의 노출 여부를 제어합니다. getPermission을 호출해서 현재 경로의 권한 객체를 씁니다.

<script>
  const { canWrite, canDelete } = navStore.getPermission(route.path)
</script>

<template>
  <EditButton v-if="canWrite" />
  <DeleteButton v-if="canDelete" />
</template>

 

중요한 원칙이 하나 있다. 프론트엔드 권한 체계는 UX를 위한 것입니다. 버튼을 숨기고 페이지 진입을 막는것은 사용자 경험 차원의 제어고, 실제 데이터 보호는 서버 API 매 요청마다 독립적으로 권한을 재검증해야합니다. 프론트 권한만으로는 보안이 완성되지 않습니다.

 


전체적인 플로우

 

 

 

 

기존 프로젝트에 권한을 추가해야 한다고 했을 때는 막막했습니다. 레이어를 나누고 각 레이어에 역할을 부여하고 나서야 방향이 보였고, 생각보다 리소스도 절감됐습니다. 앞으로 권한에 따른 UI와 액션은 컴포넌트 레이어에만 코드를 추가하며 진행할 수 있을 것 같습니다.

개발 초기 설계가 얼마나 중요한지 다시 한번 실감했습니다.