iBetter Books
수정

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 }})

isLoggedIncomputed로 선언했습니다. 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 스토어가 초기화되어 usernull이 됩니다. 세션 쿠키는 남아 있지만 스토어가 비어 있으므로 로그인된 사용자를 로그인하지 않은 것처럼 처리할 수 있습니다.

이를 해결하려면 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를 호출하면 새로고침 후에도 인증 상태가 유지됩니다.