반응형
Vue 3에서 로그인 상태에 따라 로그인 화면과 메인 화면을 조건부로 보여주는 코드를 만들어드리겠습니다.
방법 1: App.vue에서 전역 조건부 렌더링
App.vue (전역 로그인 체크)
로딩 중...
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import LoginView from '@/views/LoginView.vue'
import NavBar from '@/components/NavBar.vue'
const authStore = useAuthStore()
onMounted(async () => {
// 앱 시작 시 인증 상태 확인
await authStore.initializeAuth()
})
#app {
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.loading-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f8f9fa;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.authenticated-app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
LoginView.vue (전용 로그인 페이지)
로그인
계정에 로그인하세요
이메일
비밀번호
{{ authStore.loading ? '로그인 중...' : '로그인' }}
{{ authStore.error }}
비밀번호를 잊으셨나요?
계정이 없으신가요? 회원가입
import { reactive } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loginForm = reactive({
email: '',
password: ''
})
const handleLogin = async () => {
const result = await authStore.login({
email: loginForm.email,
password: loginForm.password
})
if (result.success) {
// 로그인 성공 시 자동으로 메인 화면으로 전환됨 (App.vue의 조건부 렌더링)
console.log('로그인 성공!')
}
}
const showSignup = () => {
// 회원가입 모달이나 페이지 표시 로직
alert('회원가입 기능을 구현하세요')
}
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.login-container {
width: 100%;
max-width: 400px;
}
.login-card {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h1 {
color: #333;
margin-bottom: 0.5rem;
font-size: 2rem;
}
.login-header p {
color: #666;
margin: 0;
}
.login-form {
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e1e5e9;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.form-group input:disabled {
background-color: #f8f9fa;
cursor: not-allowed;
}
.login-button {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.3s ease;
}
.login-button:hover:not(:disabled) {
opacity: 0.9;
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background-color: #fee;
color: #c53030;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
border: 1px solid #feb2b2;
text-align: center;
font-size: 0.9rem;
}
.login-footer {
text-align: center;
color: #666;
}
.forgot-password {
display: block;
margin-bottom: 1rem;
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
}
.forgot-password:hover {
text-decoration: underline;
}
.signup-link {
font-size: 0.9rem;
}
.signup-link a {
color: #667eea;
text-decoration: none;
font-weight: 600;
}
.signup-link a:hover {
text-decoration: underline;
}
NavBar.vue (로그인 후 네비게이션)
My App
대시보드
프로필
{{ authStore.userName || authStore.userEmail }}
{{ role.replace('ROLE_', '') }}
{{ authStore.loading ? '로그아웃 중...' : '로그아웃' }}
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const handleLogout = async () => {
await authStore.logout()
}
.navbar {
background: white;
border-bottom: 1px solid #e1e5e9;
padding: 0 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.navbar-container {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1200px;
margin: 0 auto;
padding: 1rem 0;
}
.navbar-brand h2 {
margin: 0;
color: #333;
}
.navbar-menu {
display: flex;
gap: 2rem;
}
.nav-link {
text-decoration: none;
color: #666;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: all 0.3s ease;
}
.nav-link:hover,
.nav-link.router-link-active {
color: #667eea;
background-color: #f8f9ff;
}
.navbar-user {
display: flex;
align-items: center;
gap: 1rem;
}
.user-info {
text-align: right;
}
.user-roles {
display: flex;
gap: 0.25rem;
margin-top: 0.25rem;
justify-content: flex-end;
}
.role-badge {
background-color: #e3f2fd;
color: #1976d2;
padding: 0.125rem 0.375rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
.logout-btn {
padding: 0.5rem 1rem;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.3s ease;
}
.logout-btn:hover:not(:disabled) {
background-color: #c82333;
}
.logout-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
방법 2: 라우터 기반 접근 방식
router/index.js (라우터 가드 강화)
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import LoginView from '@/views/LoginView.vue'
import DashboardView from '@/views/DashboardView.vue'
import ProfileView from '@/views/ProfileView.vue'
const routes = [
{
path: '/login',
name: 'Login',
component: LoginView,
meta: {
requiresAuth: false,
hideNavbar: true
}
},
{
path: '/',
redirect: '/dashboard'
},
{
path: '/dashboard',
name: 'Dashboard',
component: DashboardView,
meta: { requiresAuth: true }
},
{
path: '/profile',
name: 'Profile',
component: ProfileView,
meta: { requiresAuth: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
// 인증 상태가 아직 확인되지 않은 경우 초기화
if (!authStore.initialized) {
await authStore.initializeAuth()
}
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
const isAuthenticated = authStore.isAuthenticated
if (requiresAuth && !isAuthenticated) {
// 인증이 필요한데 로그인하지 않은 경우
next('/login')
} else if (to.path === '/login' && isAuthenticated) {
// 이미 로그인한 상태에서 로그인 페이지 접근
next('/dashboard')
} else {
next()
}
})
export default router
stores/auth.js (초기화 상태 추가)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'
export const useAuthStore = defineStore('auth', () => {
// 상태
const user = ref(null)
const token = ref(localStorage.getItem('jwt-token'))
const loading = ref(false)
const error = ref('')
const initialized = ref(false) // 초기화 상태 추가
// 계산된 속성
const isAuthenticated = computed(() => !!token.value && !!user.value)
const userEmail = computed(() => user.value?.email || '')
const userName = computed(() => user.value?.name || '')
const userRoles = computed(() => user.value?.roles || [])
const isAdmin = computed(() => userRoles.value.includes('ROLE_ADMIN'))
// 인증 초기화
const initializeAuth = async () => {
if (initialized.value) return
loading.value = true
try {
const savedToken = localStorage.getItem('jwt-token')
const savedUser = localStorage.getItem('user-info')
if (savedToken && savedUser) {
token.value = savedToken
user.value = JSON.parse(savedUser)
setAuthToken(savedToken)
// 토큰 유효성 검사
try {
await axios.get('/api/user/profile')
// 토큰이 유효하면 사용자 정보 최신화
} catch (error) {
if (error.response?.status === 401) {
// 토큰이 만료되었거나 유효하지 않음
clearAuthData()
}
}
}
} catch (error) {
console.error('인증 초기화 실패:', error)
clearAuthData()
} finally {
loading.value = false
initialized.value = true
}
}
// 로그인
const login = async (credentials) => {
loading.value = true
error.value = ''
try {
const response = await axios.post('/api/authenticate', credentials)
if (response.data.jwt) {
token.value = response.data.jwt
user.value = {
email: response.data.email || credentials.email,
name: response.data.name || '',
roles: response.data.roles || ['ROLE_USER'],
id: response.data.userId || null
}
localStorage.setItem('jwt-token', response.data.jwt)
localStorage.setItem('user-info', JSON.stringify(user.value))
setAuthToken(response.data.jwt)
return { success: true }
}
} catch (err) {
error.value = err.response?.status === 401
? '이메일 또는 비밀번호가 올바르지 않습니다.'
: '로그인 중 오류가 발생했습니다.'
return { success: false, error: error.value }
} finally {
loading.value = false
}
}
// 로그아웃
const logout = async () => {
loading.value = true
try {
await axios.post('/api/logout')
} catch (err) {
console.error('로그아웃 API 호출 실패:', err)
} finally {
clearAuthData()
loading.value = false
}
}
// 인증 데이터 정리
const clearAuthData = () => {
user.value = null
token.value = null
error.value = ''
localStorage.removeItem('jwt-token')
localStorage.removeItem('user-info')
delete axios.defaults.headers.common['Authorization']
}
const setAuthToken = (authToken) => {
if (authToken) {
axios.defaults.headers.common['Authorization'] = `Bearer ${authToken}`
} else {
delete axios.defaults.headers.common['Authorization']
}
}
return {
// 상태
user,
token,
loading,
error,
initialized,
// 계산된 속성
isAuthenticated,
userEmail,
userName,
userRoles,
isAdmin,
// 액션
login,
logout,
initializeAuth,
clearAuthData
}
})
방법 3: 컴포넌트 레벨 조건부 렌더링
AuthGuard.vue (인증 가드 컴포넌트)
인증 확인 중...
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import LoginView from '@/views/LoginView.vue'
const authStore = useAuthStore()
onMounted(async () => {
if (!authStore.initialized) {
await authStore.initializeAuth()
}
})
.auth-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: #f8f9fa;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
사용 예시
import AuthGuard from '@/components/AuthGuard.vue'
import NavBar from '@/components/NavBar.vue'
핵심 특징
전역 인증 체크:
- 앱 시작 시 자동으로 인증 상태 확인
- localStorage의 토큰 유효성 검사
- 조건부 렌더링으로 적절한 화면 표시
사용자 경험 개선:
- 로딩 상태 표시
- 자동 리디렉션
- 에러 메시지 처리
보안 강화:
- 토큰 유효성 실시간 검증
- 만료된 토큰 자동 처리
- 라우터 가드로 추가 보호
이 코드를 사용하면 로그인 상태에 따라 자동으로 적절한 화면이 표시됩니다!
반응형