Ch 02. 회원 가입과 로그인
PART 05에서 인증 API를 만들었습니다. 이번 챕터에서는 그 API를 Pinia 스토어로 감싸고, 로그인 페이지 UI를 연결합니다.
Pinia 인증 스토어
인증 상태는 여러 컴포넌트에서 공유해야 합니다. 헤더에서 로그인 여부를 확인하고, 글 작성 페이지에서 작성자 정보를 가져오고, 댓글 섹션에서 삭제 버튼 표시 여부를 결정합니다. Pinia 스토어가 이 역할에 딱 맞습니다.
// stores/auth.tsexport const useAuthStore = defineStore('auth', () => { const user = ref<{ id: number; email: string; name: string } | null>(null) const isLoggedIn = computed(() => !!user.value) async function login(email: string, password: string) { const data = await $fetch('/api/auth/login', { method: 'POST', body: { email, password } }) user.value = data.user await navigateTo('/') } async function logout() { await $fetch('/api/auth/logout', { method: 'POST' }) user.value = null await navigateTo('/login') } return { user, isLoggedIn, login, logout }})
isLoggedIn은 computed로 선언했습니다. user.value가 바뀔 때마다 자동으로 재계산되므로 !!user.value를 여기저기 직접 쓸 필요가 없습니다.
회원 가입 API
서버 측에서 이메일 중복 여부를 확인하고 비밀번호를 해시화합니다.
// server/api/auth/register.post.tsimport bcrypt from 'bcryptjs'export default defineEventHandler(async (event) => { const { email, password, name } = await readBody(event) const exists = await usePrisma().user.findUnique({ where: { email } }) if (exists) { throw createError({ statusCode: 409, statusMessage: '이미 사용 중인 이메일입니다.' }) } const hashed = await bcrypt.hash(password, 10) const user = await usePrisma().user.create({ data: { email, password: hashed, name } }) return { id: user.id, email: user.email, name: user.name }})
로그인 페이지
폼 제출 시 스토어의 login()을 호출합니다. 에러가 발생하면 e.data?.statusMessage에서 서버가 보낸 메시지를 꺼내 화면에 표시합니다.
<!-- pages/login.vue -->
<script setup lang="ts">
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
const error = ref('')
async function handleLogin() {
try {
await authStore.login(email.value, password.value)
} catch (e: any) {
error.value = e.data?.statusMessage || '로그인에 실패했습니다.'
}
}
</script>
<template>
<form @submit.prevent="handleLogin">
<input v-model="email" type="email" placeholder="이메일" required />
<input v-model="password" type="password" placeholder="비밀번호" required />
<p v-if="error">{{ error }}</p>
<button type="submit">로그인</button>
</form>
</template>
라우트 미들웨어로 보호하기
로그인하지 않은 사용자가 글 작성 페이지에 접근하면 로그인 페이지로 보내야 합니다. 라우트 미들웨어를 만들어 이 역할을 맡깁니다.
// middleware/auth.tsexport default defineNuxtRouteMiddleware(() => { const authStore = useAuthStore() if (!authStore.isLoggedIn) { return navigateTo('/login') }})
이 미들웨어를 사용할 페이지에서 definePageMeta로 선언하면 됩니다.
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
</script>
페이지 새로고침 시 인증 상태 복원
한 가지 고려할 점이 있습니다. 페이지를 새로고침하면 Pinia 스토어가 초기화되어 user가 null이 됩니다. 세션 쿠키는 남아 있지만 스토어가 비어 있으므로 로그인된 사용자를 로그인하지 않은 것처럼 처리할 수 있습니다.
이를 해결하려면 app.vue 또는 레이아웃 컴포넌트에서 현재 사용자 정보를 가져오는 API를 호출하고 스토어를 채워주어야 합니다.
// server/api/auth/me.get.tsexport default defineEventHandler(async (event) => { const userId = event.context.userId if (!userId) return null return await usePrisma().user.findUnique({ where: { id: userId }, select: { id: true, email: true, name: true } })})
app.vue에서 앱이 마운트될 때 이 API를 호출하면 새로고침 후에도 인증 상태가 유지됩니다.