Vue 3 역할 기반 접근을 위한 라우팅 가드: 실전 패턴
Vue 3 라우팅 가드를 통한 역할 기반 접근을 실전 패턴으로 설명합니다: 라우트 meta 규칙, 안전한 리다이렉트, 친절한 401/403 폴백, 데이터 유출 방지 방법 포함.

라우트 가드가 실제로 해결하는 것(그리고 해결하지 못하는 것)\n\n라우트 가드는 한 가지 일을 잘합니다: 네비게이션을 제어합니다. 누가 경로에 진입할 수 있는지, 진입할 수 없을 때 어디로 보낼지 결정합니다. 이는 UX를 개선하지만 보안과는 별개입니다.\n\n메뉴 항목을 숨기는 것은 힌트일 뿐입니다. 사람들은 여전히 URL을 입력하거나, 딥 링크에서 새로고침하거나, 즐겨찾기를 열 수 있습니다. 만약 유일한 보호가 "버튼이 보이지 않는다"라면, 보호가 없는 것입니다.\n\n가드는 관리자 영역, 내부 도구, 역할 기반 고객 포털처럼 보여서는 안 되는 페이지를 차단하면서 앱이 일관되게 동작하도록 할 때 빛을 발합니다.\n\n가드를 통해 얻는 이점:\n\n- 페이지가 렌더링되기 전에 차단\n- 로그인이나 안전한 기본 경로로 리다이렉트\n- 깨진 뷰 대신 명확한 401/403 화면 제공\n- 우발적인 네비게이션 루프 회피\n\n가드만으로는 데이터를 보호할 수 없습니다. API가 민감한 데이터를 브라우저로 반환하면, 페이지가 차단되더라도 사용자는 그 엔드포인트를 직접 호출하거나 개발자 도구로 응답을 확인할 수 있습니다. 실제 권한 검사는 서버에서도 이뤄져야 합니다.\n\n좋은 목표는 양쪽을 모두 커버하는 것입니다: 페이지를 차단하고 데이터를 차단하세요. 지원 담당자가 관리자 전용 경로를 열면 가드는 네비게이션을 중단하고 "Access denied"를 보여줘야 합니다. 별도로 백엔드는 관리자 전용 API 호출을 거부해 제한된 데이터가 절대 반환되지 않도록 해야 합니다.\n\n## 간단한 역할 및 권한 모델 선택\n\n역할 목록이 길어지면 접근 제어가 복잡해집니다. 실제로 사람들이 이해하는 작은 집합으로 시작하고, 진짜로 필요할 때만 세부 권한을 추가하세요.\n\n실용적인 분리는 다음과 같습니다:\n\n- 역할(Roles)은 앱에서 누군지를 설명합니다.\n- 권한(Permissions)은 무엇을 할 수 있는지를 설명합니다.\n\n대부분의 내부 도구에서는 세 가지 역할이면 충분한 경우가 많습니다:\n\n- admin: 사용자 및 설정 관리, 모든 데이터 확인\n- support: 고객 기록과 응답 처리, 시스템 설정은 아님\n- viewer: 승인된 화면에 대한 읽기 전용 접근\n\n역할이 어디서 오는지 일찍 결정하세요. 토큰 클레임(JWT)은 가드에 빠르지만 갱신될 때까지 오래된 상태가 될 수 있습니다. 앱 시작 시 사용자 프로필을 가져오면 항상 최신이지만 가드가 그 요청이 끝날 때까지 기다려야 합니다.\n\n또한 라우트 유형을 명확히 분리하세요: 공개 라우트(모두 접근), 인증된 라우트(세션 필요), 제한된 라우트(역할 또는 권한 필요).\n\n## 라우트 meta로 접근 규칙 정의\n\n접근을 표현하는 가장 깔끔한 방법은 라우트 자체에 선언하는 것입니다. Vue Router는 각 라우트 레코드에 meta 객체를 붙일 수 있어 가드가 나중에 읽을 수 있습니다. 이는 규칙을 보호할 페이지 가까이에 두어 관리하기 쉽습니다.\n\n단순한 meta 형태를 선택하고 앱 전체에서 일관되게 사용하세요.\n\njs\nconst routes = [\n {\n path: "/admin",\n component: () => import("@/pages/AdminLayout.vue"),\n meta: { requiresAuth: true, roles: ["admin"] },\n children: [\n {\n path: "users",\n component: () => import("@/pages/AdminUsers.vue"),\n // inherits requiresAuth + roles from parent\n },\n {\n path: "audit",\n component: () => import("@/pages/AdminAudit.vue"),\n meta: { permissions: ["audit:read"] },\n },\n ],\n },\n {\n path: "/tickets",\n component: () => import("@/pages/Tickets.vue"),\n meta: { requiresAuth: true, permissions: ["tickets:read"], readOnly: true },\n },\n]\n\n\n중첩 라우트의 경우 규칙을 어떻게 결합할지 결정하세요. 대부분의 앱에서는 자식이 부모 요구사항을 상속해야 합니다. 가드에서는 매치된 모든 라우트 레코드(단순히 to.meta만이 아니라)를 검사해 부모 규칙이 건너뛰어지지 않도록 하세요.\n\n나중에 시간을 절약해주는 한 가지 세부사항: "조회 가능(can view)"과 "수정 가능(can edit)"을 구분하세요. 어떤 라우트는 support와 admin이 볼 수 있지만, support는 수정을 비활성화해야 할 수 있습니다. meta에 readOnly: true 플래그를 두면 UI 동작(액션 비활성화, 파괴적 버튼 숨기기)을 제어할 수 있고, 이를 보안으로 오해하지 않게 됩니다.\n\n## 가드가 신뢰성 있게 동작하도록 인증 상태 준비\n\n가드 버그의 대부분은 한 가지 문제에서 옵니다: 가드가 앱이 사용자를 알기 전에 실행되는 경우입니다.\n\n인증을 작은 상태 머신처럼 취급하고 그것을 단일 신뢰 소스로 만드세요. 세 가지 명확한 상태가 필요합니다:\n\n- unknown: 앱이 막 시작했고 세션이 아직 확인되지 않음\n- logged out: 세션 확인이 끝났고 유효한 사용자가 없음\n- logged in: 사용자 로드 완료, 역할/권한 이용 가능\n\n규칙: 인증이 unknown일 때 절대 역할을 읽지 마세요. 이것이 보호된 화면의 플래시나 놀라운 로그인 리다이렉트를 만드는 원인입니다.\n\n### 세션 갱신 방식 결정\n\n하나의 갱신 전략을 정하고 예측 가능하게 유지하세요(예: 토큰 읽기, who am I 엔드포인트 호출, 사용자 설정).\n\n안정적인 패턴 예시:\n\n- 앱 로드 시 인증을 unknown으로 설정하고 단일 갱신 요청을 시작\n- 갱신이 끝나거나 타임아웃될 때까지 가드는 해제하지 않음\n- 사용자 정보를 메타 대신 메모리에 캐시\n- 실패 시 인증을 logged out으로 설정\n- 가드가 대기할 수 있는 ready 프라미스(또는 유사)를 노출\n\n이게 마련되면 가드 로직은 단순해집니다: auth가 준비될 때까지 기다렸다가 접근을 결정하세요.\n\n## 단계별: 라우트 수준 권한 구현\n\n깔끔한 접근법은 대부분의 규칙을 하나의 전역 가드에 두고, 라우트별로 진짜로 특별한 로직이 필요할 때만 beforeEnter를 사용하는 것입니다.\n\n### 1) 전역 beforeEach 가드 추가\n\njs\n// router/index.js\nrouter.beforeEach(async (to) => {\n const auth = useAuthStore()\n\n // Step 2: wait for auth initialization when needed\n if (!auth.ready) await auth.init()\n\n // Step 3: check authentication, then roles/permissions\n if (to.meta.requiresAuth && !auth.isAuthenticated) {\n return { name: 'login', query: { redirect: to.fullPath } }\n }\n\n const roles = to.meta.roles\n if (roles && roles.length > 0 && !roles.includes(auth.userRole)) {\n return { name: 'forbidden' } // 403\n }\n\n // Step 4: allow navigation\n return true\n})\n\n\n이 패턴은 컴포넌트 전역에 체크를 흩어놓지 않고 대부분의 경우를 처리합니다.\n\n### beforeEnter가 더 적합한 경우\n\nbeforeEnter는 규칙이 진짜로 라우트별이고 to.params.id에 의존할 때 사용하세요(예: "티켓 소유자만 열 수 있다"). 짧게 유지하고 동일한 인증 스토어를 재사용해 동작을 일관되게 만드세요.\n\n## 홀을 만들지 않는 안전한 리다이렉트\n\n리다이렉트는 신뢰하면 접근 제어를 조용히 무력화할 수 있습니다.\n\n일반 패턴은: 사용자가 로그아웃 상태면 로그인으로 보내고 returnTo 쿼리 파라미터에 원래 경로를 넣습니다. 로그인 후 이를 읽어 되돌아가게 하죠. 위험은 오픈 리다이렉트(의도하지 않은 외부로 보내기)와 루프입니다.\n\n행동을 단순하게 유지하세요:\n\n- 로그아웃 사용자는 현재 경로를 returnTo로 설정해 Login으로 이동\n- 로그인했지만 권한이 없는 사용자는 전용 Forbidden 페이지로 보냄(로그인 페이지 아님)\n- 허용된 내부 returnTo 값만 허용\n- 같은 곳으로 계속 리다이렉트하는 루프 체크 추가\n\njs\nconst allowedReturnTo = (to) => {\n if (!to || typeof to !== 'string') return null\n if (!to.startsWith('/')) return null\n // optional: only allow known prefixes\n if (!['/app', '/admin', '/tickets'].some(p => to.startsWith(p))) return null\n return to\n}\n\nrouter.beforeEach((to) => {\n if (!auth.isReady) return false\n\n if (!auth.isLoggedIn && to.name !== 'Login') {\n return { name: 'Login', query: { returnTo: to.fullPath } }\n }\n\n if (auth.isLoggedIn && !canAccess(to, auth.user) && to.name !== 'Forbidden') {\n return { name: 'Forbidden' }\n }\n})\n\n\n## 네비게이션 중 제한된 데이터 유출 방지\n\n가장 쉬운 유출은 사용자가 볼 수 있는 권한인지 알기 전에 데이터를 로드하는 것입니다.\n\nVue에서는 페이지가 setup()에서 데이터를 가져오고 라우터 가드가 그보다 늦게 실행되는 경우가 자주 있습니다. 사용자가 리다이렉트되더라도 응답이 공유 스토어에 남거나 화면에 잠깐 보일 수 있습니다.\n\n안전한 규칙은: 먼저 권한을 확인하고 그 다음 데이터를 로드하세요.\n\njs\n// router guard: authorize before entering the route\nrouter.beforeEach(async (to) => {\n await auth.ready() // ensure roles are known\n const required = to.meta.requiredRole\n if (required && !auth.hasRole(required)) {\n return { name: 'forbidden' }\n }\n})\n\n\n또한 네비게이션이 빠르게 바뀔 때 들어오는 늦은 요청을 주의하세요. 요청을 AbortController로 취소하거나 요청 아이디를 확인해 늦은 응답을 무시하세요.\n\n캐싱도 흔한 함정입니다. "마지막으로 로드한 고객 레코드"를 전역에 저장하면 관리자 전용 응답이 이후에 일반 사용자에게 표시될 수 있습니다. 캐시는 사용자 id와 역할로 키를 만들거나 로그아웃 시(또는 역할 변경 시) 민감한 모듈을 정리하세요.\n\n몇 가지 습관으로 대부분 유출을 예방할 수 있습니다:\n\n- 권한이 확인되기 전까지 민감한 데이터는 가져오지 마세요.\n- 캐시를 사용자와 역할로 구분하거나 페이지 로컬로 유지하세요.\n- 라우트가 변경되면 진행 중인 요청을 취소하거나 무시하세요.\n\n## 친절한 폴백: 401, 403, 그리고 찾을 수 없음\n\n"거부" 경로도 허용 경로만큼 중요합니다. 좋은 폴백 페이지는 사용자를 방향감 있게 안내하고 지원 요청을 줄여줍니다.\n\n### 401: 로그인 필요(비인증)\n\n사용자가 로그인하지 않았을 때 401을 사용하세요. 메시지는 단순하게 유지하세요: 계속하려면 로그인해야 합니다. 원래 페이지로 돌아가는 기능을 지원하면 리턴 경로를 검증해 앱 외부로 나가지 않도록 하세요.\n\n### 403: 접근 거부(인증됨, 그러나 허용되지 않음)\n\n사용자가 로그인했지만 권한이 없을 때 403을 사용하세요. 중립적이고 민감한 세부사항을 암시하지 않는 문구가 좋습니다.\n\n견고한 403 페이지는 보통 명확한 제목(“Access denied”), 한 문장의 설명, 그리고 안전한 다음 단계(대시보드로 돌아가기, 관리자에게 문의, 계정 전환)가 있습니다.\n\n### 404: 찾을 수 없음\n\n404는 401/403과 별도로 처리하세요. 그렇지 않으면 사용자는 단순히 페이지가 없는데 권한이 없다고 오해할 수 있습니다.\n\n## 접근 제어를 망치는 일반적인 실수\n\n대부분의 접근 제어 버그는 간단한 논리 실수로 나타납니다: 리다이렉트 루프, 잘못된 페이지 플래시, 사용자가 멈춰버리는 현상 등.\n\n주된 원인:\n\n- 숨긴 UI를 "보안"으로 간주 (항상 라우터와 API에서 역할을 강제 적용)\n- 로그아웃/로그인 후 오래된 상태에서 역할을 읽음\n- 권한 없는 사용자를 다른 보호된 라우트로 리다이렉트(즉시 루프 발생)\n- 새로고침 시 "인증 로딩 중" 순간을 무시함\n- 401과 403을 혼동해 사용자 혼란을 초래함\n\n현실적인 예: 지원 담당자가 로그아웃하고 같은 컴퓨터에서 관리자가 로그인할 때, 가드가 새 세션이 확인되기 전에 캐시된 역할을 읽으면 관리자를 잘못 차단하거나, 더 위험하게는 잠깐 허용해 버릴 수 있습니다.\n\n## 배포 전 빠른 체크리스트\n\n느린 네트워크, 만료된 세션, 북마크된 URL 같은 접근 제어가 보통 깨지는 순간에 집중해 짧게 점검하세요.\n\n- 모든 보호된 라우트에 명시적 meta 요구사항 존재\n- 가드는 인증 로딩 상태를 처리해 보호된 UI가 깜빡이지 않음\n- 권한 없는 사용자는 명확한 403 페이지로 이동(혼란스러운 홈으로의 튕김 아님)\n- 모든 "돌아가기" 리다이렉트는 검증되어 루프를 만들지 않음\n- 민감한 API 호출은 권한 확인 후에만 실행됨\n\n그런 다음 한 시나리오를 끝까지 테스트하세요: 로그아웃 상태에서 보호된 URL을 새 탭으로 열고, 기본 사용자로 로그인한 뒤 의도된 페이지로 가야 하는지(허용된다면) 또는 깔끔한 403과 다음 단계를 확인하세요.\n\n## 예시: 작은 웹 앱에서 지원과 관리자 접근\n\n헬프데스크 앱을 가정해 보세요. 두 가지 역할이 있습니다: support와 admin. Support는 티켓을 읽고 응답할 수 있습니다. Admin은 그 외에도 빌링과 회사 설정을 관리할 수 있습니다.\n\n- /tickets/:id는 support와 admin에 허용\n- /settings/billing은 admin만 허용\n\n흔한 상황: support 담당자가 오래된 즐겨찾기에서 /settings/billing로 딥 링크를 엽니다. 가드는 페이지가 로드되기 전에 route meta를 확인해 네비게이션을 차단해야 합니다. 사용자는 로그인은 했지만 역할이 부족하므로 안전한 폴백(403)으로 가야 합니다.\n\n두 가지 메시지가 중요합니다:\n\n- 로그인 필요(401): “계속하려면 로그인하세요.”\n- 접근 거부(403): “청구 설정에 접근할 수 없습니다.”\n\n절대 발생하면 안 되는 것: billing 컴포넌트가 마운트되거나 billing 데이터를 잠깐이라도 가져오는 것.\n\n세션 중 역할 변경도 엣지 케이스입니다. 누군가 승격되거나 강등되면 메뉴만 믿지 마세요. 네비게이션 시 역할을 재확인하고 활성 페이지가 더 이상 허용되지 않으면 새로고침하거나 리다이렉트하세요.\n\n## 다음 단계: 접근 규칙을 유지 가능하게 유지\n\n가드가 동작하면 더 큰 위험은 규칙의 불일치입니다: 새 라우트가 meta 없이 배포되거나 역할이 이름이 바뀌는 등 규칙이 일관성을 잃을 수 있습니다.\n\n라우트를 추가할 때마다 실행할 수 있는 작은 테스트 플랜으로 규칙을 관리하세요:\n\n- 게스트로서 보호된 라우트를 열어 부분 콘텐츠가 보이지 않고 로그인으로 가는지 확인\n- 일반 사용자로 접근 불가한 페이지를 열어 명확한 403이 뜨는지 확인\n- 관리자 권한으로 주소창에서 딥 링크를 시도\n- 각 역할별로 보호된 라우트에서 새로고침해 결과가 안정적인지 확인\n\n추가 안전망으로 개발 전용 뷰나 콘솔 출력을 추가해 라우트와 그 meta 요구사항을 나열하면 누락된 규칙이 즉시 드러납니다.\n\nAppMaster (appmaster.io)로 내부 도구나 포털을 만든다면 동일한 접근법을 적용하세요: Vue3 UI에서는 네비게이션에 가드를 집중시키고, 권한과 데이터는 백엔드 로직에서 강제하세요.\n\n한 가지 개선점을 골라 끝까지 구현하세요: 데이터 페칭 게이팅 강화, 403 페이지 개선, 리다이렉트 처리 잠금 등. 작은 수정이 실제 접근 버그 대부분을 막습니다.
자주 묻는 질문
라우트 가드는 네비게이션을 제어합니다. 페이지 진입을 막고 적절히 리다이렉트하거나 깔끔한 401/403 상태를 보여주는 데 도움을 주지만, API를 직접 호출하는 것을 막지는 못합니다. 제한된 데이터가 브라우저로 반환되지 않도록 서버에서도 동일한 권한 검사를 항상 적용하세요.
UI를 숨기는 것은 사용자가 보는 것만 바꿀 뿐, 요청할 수 있는 것을 막지는 않습니다. 사용자는 URL을 직접 입력하거나 즐겨찾기, 딥 링크로 접근할 수 있습니다. 페이지 차단은 라우터에서 하고, 데이터 차단은 서버에서 하세요.
사람들이 이해할 수 있는 작은 집합으로 시작하고, 실제로 불편함을 느낄 때 세부 권한을 추가하세요. 일반적인 기본 구성은 admin, support, viewer이며, 특정 행동에는 tickets:read나 audit:read 같은 권한을 추가합니다. ‘누구인지’(role)와 ‘무엇을 할 수 있는지’(permission)를 분리하세요.
meta에 접근 규칙을 기록하세요. 라우트 레코드에 requiresAuth, roles, permissions 같은 값을 두면 전역 가드가 예측 가능하게 동작합니다. 중첩 라우트의 경우 부모 요구사항이 누락되지 않도록 모든 매치된 레코드를 검사하세요.
라우트의 to.matched를 읽어 모든 매치된 레코드의 요구사항을 합치세요. 이렇게 하면 자식 라우트가 부모의 requiresAuth나 roles를 우회하지 못합니다. 보통은 부모 요구사항이 자식에게 적용된다고 정하면 됩니다.
가드가 앱이 사용자를 모를 때 실행되면 플래시나 무한 리다이렉트가 생깁니다. 인증 상태를 unknown, logged out, logged in의 세 가지로 다루고, unknown일 때는 역할을 절대 판단하지 마세요. 가드는 초기화(예: who am I)가 끝날 때까지 기다려야 합니다.
일관된 규칙(예: ‘로그인 필요’, ‘권한 필요’)은 전역 beforeEach에서 처리하세요. beforeEnter는 to.params.id처럼 경로별로 정말 특별한 검사(예: 티켓 소유자 확인)가 필요할 때만 쓰세요. 두 경우 모두 동일한 인증 소스를 사용하도록 하세요.
returnTo는 신뢰할 수 없는 입력으로 취급하세요. 내부 경로만 허용하고(예: /로 시작하고 허용된 접두사와 매치되는 값), 동일한 차단된 경로로 다시 리다이렉트하지 않도록 루프 체크를 추가하세요. 로그아웃된 사용자는 로그인으로, 로그인은 했지만 권한이 없는 사용자는 전용 403 페이지로 보내세요.
인증이 확인되기 전에 데이터를 가져오면 유출이 발생할 수 있습니다. 페이지가 setup()에서 데이터를 요청하고 가드가 그 직후 리다이렉트하면 응답이 스토어에 남거나 잠깐 화면에 보일 수 있습니다. 민감한 요청은 권한 확인 뒤에 수행하고, 네비게이션이 바뀔 때는 요청을 취소하거나 늦은 응답을 무시하세요.
401은 로그인 필요(비인증), 403은 인증은 됐지만 권한 없음(접근 거부), 404는 존재하지 않는 페이지로 처리하세요. 404와 403을 섞지 않으면 사용자가 단순히 페이지가 없는데 권한이 없다고 오해하는 일이 줄어듭니다.


