보호된 라우트와 네비게이션 가드
이 챕터를 마치면
- beforeEach 가드로 라우트 접근을 제어할 수 있습니다.
- 로그인이 필요한 라우트에 meta 필드를 설정할 수 있습니다.
- 인증 상태에 따라 리다이렉트를 처리할 수 있습니다.
- 백엔드 API에 인가(Authorization)를 적용할 수 있습니다.
Ch 03에서 Pinia로 로그인 상태를 관리하고, Ch 04에서 헤더에 로그인/로그아웃 버튼까지 만들었습니다. 하지만 아직 한 가지 허점이 있습니다. 로그인하지 않은 사용자도 브라우저 주소창에 /posts/new를 직접 입력하면 게시글 작성 페이지에 들어갈 수 있습니다. 로그인 버튼을 숨기는 것만으로는 충분하지 않습니다. 문을 보이지 않게 가린 것이지, 잠근 것이 아니기 때문입니다.
이번 챕터에서는 Vue Router의 네비게이션 가드로 프론트엔드의 문을 잠그고, FastAPI의 의존성 주입으로 백엔드의 문까지 잠가보겠습니다.
네비게이션 가드란
Vue Router의 네비게이션 가드는 페이지 전환 전에 실행되는 검문소입니다. 공항에 비유하면 이해하기 쉽습니다. 탑승구에 가기 전에 보안검색대를 반드시 통과해야 하는 것처럼, 특정 페이지에 도달하기 전에 조건을 검사할 수 있습니다.
Vue Router는 세 종류의 가드를 제공합니다.
| 가드 | 적용 범위 | 사용 시점 |
|---|---|---|
beforeEach |
모든 라우트 | 전역 인증 검사 |
beforeEnter |
특정 라우트 하나 | 개별 라우트의 특수 조건 |
onBeforeRouteEnter |
컴포넌트 내부 | 컴포넌트 진입 전 데이터 로드 |
이 중에서 beforeEach가 가장 많이 사용됩니다. 모든 라우트 전환에 대해 한 곳에서 일괄적으로 검사할 수 있기 때문입니다. 보안검색대가 각 탑승구마다 따로 있는 것보다 출발층 입구에 하나 있는 것이 효율적인 것과 같습니다.
meta 필드로 보호 대상 지정하기
모든 페이지에 로그인이 필요한 것은 아닙니다. 홈 화면이나 게시글 상세 페이지는 누구나 볼 수 있어야 합니다. 글 작성과 수정만 로그인이 필요합니다. 이런 구분을 위해 Vue Router는 meta라는 필드를 제공합니다.
meta는 라우트에 추가 정보를 붙이는 메모지 같은 것입니다. 라우트 객체에 원하는 데이터를 자유롭게 넣을 수 있습니다. 여기서는 requiresAuth: true라는 플래그를 붙여서 "이 라우트는 인증이 필요하다"고 표시하겠습니다.
// filename: frontend/src/router/index.tsimport { createRouter, createWebHistory } from 'vue-router'import HomeView from '../views/HomeView.vue'import PostDetailView from '../views/PostDetailView.vue'import PostCreateView from '../views/PostCreateView.vue'import PostEditView from '../views/PostEditView.vue'import LoginView from '../views/LoginView.vue'import SignupView from '../views/SignupView.vue'const routes = [ { path: '/', name: 'home', component: HomeView }, { path: '/login', name: 'login', component: LoginView }, { path: '/signup', name: 'signup', component: SignupView }, { path: '/posts/new', name: 'post-create', component: PostCreateView, meta: { requiresAuth: true }, }, { path: '/posts/:id/edit', name: 'post-edit', component: PostEditView, meta: { requiresAuth: true }, }, { path: '/posts/:id', name: 'post-detail', component: PostDetailView },]const router = createRouter({ history: createWebHistory(), routes })export default router
post-create와 post-edit 라우트에만 meta: { requiresAuth: true }를 추가했습니다. 나머지 라우트에는 meta를 설정하지 않았으므로 to.meta.requiresAuth는 undefined, 즉 falsy 값이 됩니다.
이 방식의 장점은 새로운 보호 대상 라우트가 생겨도 meta 한 줄만 추가하면 된다는 것입니다. 가드 로직은 전혀 건드릴 필요가 없습니다.
beforeEach 가드 구현하기
이제 meta.requiresAuth를 검사하는 가드를 작성합니다. router.beforeEach()는 모든 라우트 전환 전에 콜백 함수를 실행합니다.
// filename: frontend/src/router/index.ts (하단에 추가)import { useAuthStore } from '@/stores/auth'router.beforeEach((to, from) => { const auth = useAuthStore() if (to.meta.requiresAuth && !auth.isLoggedIn) { return { name: 'login', query: { redirect: to.fullPath } } }})export default router
콜백 함수가 받는 매개변수를 살펴보겠습니다.
| 매개변수 | 의미 |
|---|---|
to |
이동하려는 대상 라우트 객체 |
from |
현재 라우트 객체 (어디서 왔는지) |
가드의 동작 흐름은 간단합니다. 대상 라우트(to)에 requiresAuth가 설정되어 있고, 사용자가 로그인하지 않았으면 로그인 페이지로 보냅니다. 그 외의 경우에는 아무것도 반환하지 않으므로 정상적으로 이동합니다.
return { name: 'login', query: { redirect: to.fullPath } }가 핵심입니다. 단순히 로그인 페이지로 보내는 것이 아니라, redirect 쿼리 파라미터에 원래 가려던 경로를 담아둡니다. /posts/new에 접근하려다 막혔다면, 로그인 페이지 URL은 /login?redirect=/posts/new가 됩니다. 이 정보를 나중에 로그인 성공 후 되돌아가는 데 사용합니다.
Pinia 스토어를 가드 안에서 초기화하는 이유
useAuthStore()를 beforeEach 콜백 안에서 호출하는 것에 주목하세요. 파일 최상단에서 호출하면 안 됩니다.
// 잘못된 예시const auth = useAuthStore() // Pinia가 아직 등록되지 않은 시점router.beforeEach((to, from) => { if (to.meta.requiresAuth && !auth.isLoggedIn) { ... }})
이렇게 하면 에러가 발생합니다. Pinia는 createApp().use(pinia) 이후에야 사용할 수 있는데, 라우터 파일이 로드되는 시점에는 아직 Pinia가 앱에 등록되지 않았기 때문입니다. 식당에 비유하면, 주방이 준비되기 전에 주문을 넣으려는 것과 같습니다. beforeEach 콜백 안에서 호출하면, 실제로 라우트 전환이 일어나는 시점에 스토어를 가져오므로 그때는 Pinia가 이미 준비된 상태입니다.
전체 라우터 파일
지금까지의 변경사항을 모두 반영한 router/index.ts의 전체 코드입니다.
// filename: frontend/src/router/index.tsimport { createRouter, createWebHistory } from 'vue-router'import { useAuthStore } from '@/stores/auth'import HomeView from '../views/HomeView.vue'import PostDetailView from '../views/PostDetailView.vue'import PostCreateView from '../views/PostCreateView.vue'import PostEditView from '../views/PostEditView.vue'import LoginView from '../views/LoginView.vue'import SignupView from '../views/SignupView.vue'const routes = [ { path: '/', name: 'home', component: HomeView }, { path: '/login', name: 'login', component: LoginView }, { path: '/signup', name: 'signup', component: SignupView }, { path: '/posts/new', name: 'post-create', component: PostCreateView, meta: { requiresAuth: true }, }, { path: '/posts/:id/edit', name: 'post-edit', component: PostEditView, meta: { requiresAuth: true }, }, { path: '/posts/:id', name: 'post-detail', component: PostDetailView },]const router = createRouter({ history: createWebHistory(), routes })router.beforeEach((to, from) => { const auth = useAuthStore() if (to.meta.requiresAuth && !auth.isLoggedIn) { return { name: 'login', query: { redirect: to.fullPath } } }})export default router
로그인 후 원래 페이지로 돌아가기
사용자가 /posts/new에 접근하려다 로그인 페이지로 리다이렉트되었다면, 로그인 성공 후 다시 /posts/new로 돌아가야 자연스럽습니다. 매번 홈 화면으로 보내버리면 사용자는 다시 작성 버튼을 찾아야 합니다.
Ch 04에서 만든 LoginView.vue의 로그인 성공 처리 부분을 수정합니다.
<!-- filename: frontend/src/views/LoginView.vue (script 일부) -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const username = ref('')
const password = ref('')
async function handleLogin() {
try {
await auth.login(username.value, password.value)
const redirect = route.query.redirect || '/'
router.push(redirect)
} catch (error) {
alert('로그인에 실패했습니다.')
}
}
</script>
route.query.redirect는 URL의 쿼리 파라미터에서 redirect 값을 가져옵니다. /login?redirect=/posts/new로 접근했다면 route.query.redirect는 '/posts/new'가 됩니다. 값이 없으면 || 연산자로 기본값 '/'(홈)으로 이동합니다.
전체 흐름을 정리하면 이렇습니다.
1. 사용자가 /posts/new 접근 시도
2. beforeEach 가드가 로그인 여부 확인
3. 비로그인 → /login?redirect=/posts/new 로 리다이렉트
4. 사용자가 로그인 폼에서 ID/PW 입력
5. 로그인 성공 → route.query.redirect 값(/posts/new)으로 이동
6. 게시글 작성 페이지 정상 표시
백엔드에 인가 적용하기
프론트엔드의 네비게이션 가드는 UI 레벨의 보호입니다. 브라우저 개발자 도구나 Postman 같은 도구로 API를 직접 호출하면 가드를 우회할 수 있습니다. 프론트엔드 보호만으로는 집의 현관문만 잠그고 뒷문은 열어둔 것과 같습니다.
진짜 보안은 백엔드에서 해야 합니다. Ch 02에서 만든 get_current_user 의존성 함수를 게시글 API에 적용하겠습니다.
Post 모델에 author_id 추가하기
인가를 적용하려면 "누가 이 글을 썼는가"를 알아야 합니다. Post 모델에 작성자 정보를 추가합니다.
# filename: backend/app/models/post.pyfrom datetime import datetimefrom sqlalchemy import String, Text, DateTime, ForeignKey, funcfrom sqlalchemy.orm import Mapped, mapped_columnfrom app.database import Baseclass Post(Base): __tablename__ = "posts" id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String(100)) content: Mapped[str] = mapped_column(Text) image_url: Mapped[str | None] = mapped_column( String(500), nullable=True ) author_id: Mapped[int | None] = mapped_column( ForeignKey("users.id"), nullable=True ) created_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now() )
ForeignKey("users.id")는 users 테이블의 id 컬럼을 참조하는 외래 키입니다. 게시글과 사용자를 연결하는 고리 역할을 합니다.
nullable=True로 설정한 이유가 중요합니다. 이미 데이터베이스에는 author_id 없이 작성된 게시글이 있습니다. 만약 nullable=False로 설정하면, 기존 게시글의 author_id가 NULL이므로 데이터베이스 제약 조건에 위배됩니다. 기존 데이터와의 호환성을 위해 NULL을 허용하고, 앞으로 새로 생성되는 게시글에만 작성자를 기록하는 방식입니다.
스키마 업데이트
PostResponse에 author_id를 추가합니다. PostCreate에는 추가하지 않습니다. 작성자 정보는 토큰에서 자동으로 추출하므로 클라이언트가 보낼 필요가 없기 때문입니다.
# filename: backend/app/schemas/post.pyfrom pydantic import BaseModel, ConfigDict, Fieldfrom datetime import datetimeclass PostCreate(BaseModel): title: str = Field( min_length=2, max_length=100, description="게시글 제목" ) content: str = Field(min_length=1, description="게시글 내용") image_url: str | None = Field( None, max_length=500, description="이미지 URL" )class PostResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: int title: str content: str image_url: str | None author_id: int | None created_at: datetime
게시글 라우터에 인가 적용하기
이제 핵심입니다. create_post, update_post, delete_post에 get_current_user 의존성을 추가합니다. list_posts와 get_post은 누구나 조회할 수 있어야 하므로 그대로 둡니다.
# filename: backend/app/routers/post.pyfrom fastapi import APIRouter, Depends, HTTPExceptionfrom sqlalchemy.orm import Sessionfrom app.database import get_dbfrom app.deps import get_current_userfrom app.models.post import Postfrom app.models.user import Userfrom app.schemas.post import PostCreate, PostResponserouter = APIRouter(prefix="/posts", tags=["게시글"])
임포트 부분에 get_current_user와 User를 추가했습니다. 이어서 create_post를 수정합니다.
# filename: backend/app/routers/post.py (create_post 함수)@router.post("/", summary="게시글 생성", status_code=201)def create_post( post: PostCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user),) -> PostResponse: new_post = Post( title=post.title, content=post.content, image_url=post.image_url, author_id=current_user.id, ) db.add(new_post) db.commit() db.refresh(new_post) return new_post
Depends(get_current_user)가 하는 일을 정리하겠습니다.
- 요청 헤더에서
Authorization: Bearer <토큰>을 추출합니다. - 토큰을 디코딩하여 사용자 ID를 꺼냅니다.
- 데이터베이스에서 해당 사용자를 조회합니다.
- 토큰이 없거나 유효하지 않으면 401 에러를 반환합니다.
이 모든 과정이 current_user: User = Depends(get_current_user) 한 줄로 처리됩니다. Ch 02에서 만들어둔 의존성 함수가 여기서 빛을 발합니다.
update_post와 delete_post에도 동일하게 적용합니다.
# filename: backend/app/routers/post.py (update_post 함수)@router.put("/{post_id}", summary="게시글 수정")def update_post( post_id: int, post_data: PostCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user),) -> PostResponse: post = db.query(Post).filter(Post.id == post_id).first() if not post: raise HTTPException(404, detail="게시글을 찾을 수 없습니다") if post.author_id and post.author_id != current_user.id: raise HTTPException(403, detail="수정 권한이 없습니다") post.title = post_data.title post.content = post_data.content post.image_url = post_data.image_url db.commit() db.refresh(post) return post
# filename: backend/app/routers/post.py (delete_post 함수)@router.delete("/{post_id}", summary="게시글 삭제", status_code=204)def delete_post( post_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user),): post = db.query(Post).filter(Post.id == post_id).first() if not post: raise HTTPException(404, detail="게시글을 찾을 수 없습니다") if post.author_id and post.author_id != current_user.id: raise HTTPException(403, detail="삭제 권한이 없습니다") db.delete(post) db.commit()
update_post와 delete_post에는 작성자 본인 확인 로직도 추가했습니다. post.author_id and post.author_id != current_user.id 조건에서 앞부분 post.author_id는 기존 게시글(author_id가 NULL인 게시글)에 대한 예외 처리입니다. author_id가 NULL이면 인가 전에 작성된 글이므로, 로그인한 사용자라면 누구나 수정/삭제할 수 있도록 허용합니다.
인증(Authentication)과 인가(Authorization)
지금까지 두 가지 다른 개념을 다루었습니다. 용어가 비슷해서 혼동하기 쉬우니 정리해두겠습니다.
| 구분 | 인증 (Authentication) | 인가 (Authorization) |
|---|---|---|
| 질문 | "당신은 누구입니까?" | "당신은 이 작업을 할 수 있습니까?" |
| 수단 | JWT 토큰 검증 | 작성자 ID 비교 |
| 실패 시 | 401 Unauthorized | 403 Forbidden |
| 비유 | 신분증 확인 | 출입 권한 확인 |
get_current_user는 인증을 담당합니다. 토큰을 검증해서 "이 사람은 홍길동이다"를 확인합니다. post.author_id != current_user.id 비교는 인가를 담당합니다. "홍길동은 이 게시글을 수정할 수 있는가"를 확인합니다.
프론트엔드와 백엔드의 이중 방어
전체 보호 구조를 정리하면 이렇습니다.
사용자가 /posts/new 접근
│
├─ [프론트엔드] beforeEach 가드
│ └─ 비로그인 → /login 으로 리다이렉트
│
└─ [백엔드] Depends(get_current_user)
└─ 토큰 없음/만료 → 401 에러 반환
프론트엔드 가드는 사용자 경험을 위한 것입니다. 로그인 페이지로 안내해주고, 로그인 후 원래 페이지로 돌아가는 편의를 제공합니다. 백엔드 인가는 보안을 위한 것입니다. API를 직접 호출하는 공격을 차단합니다.
PART 06에서 폼 검증을 프론트엔드와 백엔드 양쪽에서 했던 것과 같은 원리입니다. 프론트엔드는 사용자를 위한 보호, 백엔드는 시스템을 위한 보호입니다.
정리
| 항목 | 내용 |
|---|---|
meta.requiresAuth |
라우트에 인증 필요 여부를 표시하는 플래그 |
beforeEach |
모든 라우트 전환 전에 실행되는 전역 가드 |
to.fullPath |
사용자가 원래 가려던 전체 경로. 리다이렉트 후 복귀에 사용 |
route.query.redirect |
로그인 성공 후 되돌아갈 경로를 쿼리 파라미터에서 추출 |
Depends(get_current_user) |
백엔드 API에 인증을 적용하는 FastAPI 의존성 주입 |
ForeignKey("users.id") |
게시글과 사용자를 연결하는 외래 키 |
nullable=True |
기존 데이터와의 호환성을 위해 NULL 허용 |
| 401 vs 403 | 401은 인증 실패(누구인지 모름), 403은 인가 실패(권한 없음) |
PART 08 전체 정리
PART 08에서는 게시판에 인증과 인가 시스템을 완성했습니다. 다섯 개 챕터에서 다룬 내용을 한눈에 정리하겠습니다.
| 챕터 | 주제 | 핵심 개념 |
|---|---|---|
| Ch 01 | JWT 이해하기 | 헤더.페이로드.서명 구조, 토큰 기반 인증 vs 세션 기반 인증 |
| Ch 02 | 로그인/회원가입 API | passlib 해싱, python-jose, OAuth2PasswordBearer, get_current_user |
| Ch 03 | Pinia 스토어 설정 | defineStore, state/getters/actions, localStorage 토큰 저장 |
| Ch 04 | 로그인 상태 관리 | Axios 인터셉터, 헤더 UI 분기, 로그인/회원가입 폼 |
| Ch 05 | 보호된 라우트와 네비게이션 가드 | meta.requiresAuth, beforeEach, 백엔드 인가, author_id |

프론트엔드의 Pinia와 네비게이션 가드, 백엔드의 JWT 검증과 의존성 주입이 하나의 인증 시스템으로 연결되었습니다. 각 챕터에서 독립적으로 만든 조각들이 맞물려 돌아가는 것을 확인할 수 있습니다.
다음 PART 09에서는 FastAPI 미들웨어로 요청과 응답 흐름을 제어하고, CORS 설정을 본격적으로 다룹니다. 또한 Element Plus UI 라이브러리를 도입하여 게시판의 디자인을 개선해보겠습니다.