Nuxt 3 통합 초보자용 To-Do 완성 가이드 v2

(진짜 처음인 사람도 그대로 따라하면 돌아가게 만든 버전)

이 문서는 설명보다 실행에 집중합니다.
규칙: “한 단계씩 복붙 → 실행 확인 → 다음 단계”


A. 최종 목표

이 문서 끝나면 아래가 됩니다.

  1. 회원가입
  2. 로그인 / 로그아웃
  3. 로그인 상태 유지(새로고침해도 유지)
  4. To-Do 추가 / 완료 체크 / 삭제
  5. 데이터는 SQLite(app.db)에 저장
  6. UI는 Tailwind + Nuxt UI로 깔끔하게 표시

B. 10초 용어

  • Nuxt 3: Vue 기반 풀스택 프레임워크
  • API: 화면이 서버에 요청하는 창구 (/api/...)
  • SQLite: 파일 하나(app.db)로 쓰는 DB
  • 세션: 로그인 상태를 기억하는 방식
  • 쿠키: 브라우저에 저장되는 작은 값(여기선 로그인 토큰)

C. 지금부터 그대로 따라하기

1단계) 프로젝트 생성

npx nuxi@latest init nuxt3-todo-study
cd nuxt3-todo-study
npm install

2단계) 패키지 설치

npm i better-sqlite3 drizzle-orm bcryptjs zod @nuxt/ui
npm i -D drizzle-kit @types/better-sqlite3 tailwindcss @tailwindcss/vite

3단계) 환경변수 파일 생성

프로젝트 루트에 .env 파일 만들고:

SESSION_SECRET="please-change-this-to-a-long-random-secret"

D. 파일을 순서대로 생성

아래 파일들을 이름 그대로 만드시면 됩니다.


1) nuxt.config.ts

export default defineNuxtConfig({
  modules: ['@nuxt/ui'],
  css: ['~/assets/css/main.css'],
})

2) assets/css/main.css

@import "tailwindcss";

3) drizzle.config.ts

import type { Config } from 'drizzle-kit'

export default {
  schema: './server/db/schema.ts',
  out: './drizzle',
  dialect: 'sqlite',
  dbCredentials: {
    url: './app.db',
  },
} satisfies Config

4) server/db/schema.ts

import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').notNull().unique(),
  passwordHash: text('password_hash').notNull(),
  createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
})

export const todos = sqliteTable('todos', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  userId: integer('user_id').notNull(),
  title: text('title').notNull(),
  done: integer('done', { mode: 'boolean' }).notNull().default(false),
  createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
  updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
})

5) server/db/client.ts

import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from './schema'

const sqlite = new Database('./app.db')
export const db = drizzle(sqlite, { schema })

6) server/utils/password.ts

import bcrypt from 'bcryptjs'

export function hashPassword(raw: string) {
  return bcrypt.hash(raw, 10)
}

export function verifyPassword(raw: string, hashed: string) {
  return bcrypt.compare(raw, hashed)
}

7) server/utils/session.ts

import { createHmac, timingSafeEqual } from 'node:crypto'
import { getCookie, setCookie, deleteCookie } from 'h3'

const COOKIE_NAME = 'todo_sid'
const secret = process.env.SESSION_SECRET || 'dev-secret-change-me'

function sign(raw: string) {
  return createHmac('sha256', secret).update(raw).digest('hex')
}

function encode(payload: { userId: number; email: string }) {
  const raw = Buffer.from(JSON.stringify(payload)).toString('base64url')
  return `${raw}.${sign(raw)}`
}

function decode(token: string) {
  const [raw, sig] = token.split('.')
  if (!raw || !sig) return null

  const expected = sign(raw)
  if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null

  try {
    return JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as {
      userId: number
      email: string
    }
  } catch {
    return null
  }
}

export function setSession(event: any, payload: { userId: number; email: string }) {
  const token = encode(payload)
  setCookie(event, COOKIE_NAME, token, {
    httpOnly: true,
    sameSite: 'lax',
    secure: process.env.NODE_ENV === 'production',
    path: '/',
    maxAge: 60 * 60 * 24 * 7,
  })
}

export function getSession(event: any) {
  const token = getCookie(event, COOKIE_NAME)
  if (!token) return null
  return decode(token)
}

export function clearSession(event: any) {
  deleteCookie(event, COOKIE_NAME, { path: '/' })
}

8) server/utils/auth.ts

import { getSession } from './session'

export function requireSession(event: any) {
  const session = getSession(event)
  if (!session) {
    throw createError({ statusCode: 401, statusMessage: '로그인이 필요합니다.' })
  }
  return session
}

9) 인증 API

server/api/auth/signup.post.ts

import { z } from 'zod'
import { db } from '~/server/db/client'
import { users } from '~/server/db/schema'
import { eq } from 'drizzle-orm'
import { hashPassword } from '~/server/utils/password'
import { setSession } from '~/server/utils/session'

const Body = z.object({
  email: z.string().email(),
  password: z.string().min(8),
})

export default defineEventHandler(async (event) => {
  const body = Body.parse(await readBody(event))

  const found = await db
    .select({ id: users.id })
    .from(users)
    .where(eq(users.email, body.email))
    .limit(1)

  if (found.length) {
    throw createError({ statusCode: 409, statusMessage: '이미 가입된 이메일입니다.' })
  }

  const passwordHash = await hashPassword(body.password)

  const [user] = await db
    .insert(users)
    .values({
      email: body.email,
      passwordHash,
      createdAt: new Date(),
    })
    .returning({ id: users.id, email: users.email })

  setSession(event, { userId: user.id, email: user.email })
  return { ok: true }
})

server/api/auth/login.post.ts

import { z } from 'zod'
import { db } from '~/server/db/client'
import { users } from '~/server/db/schema'
import { eq } from 'drizzle-orm'
import { verifyPassword } from '~/server/utils/password'
import { setSession } from '~/server/utils/session'

const Body = z.object({
  email: z.string().email(),
  password: z.string().min(1),
})

export default defineEventHandler(async (event) => {
  const body = Body.parse(await readBody(event))

  const [user] = await db
    .select({ id: users.id, email: users.email, passwordHash: users.passwordHash })
    .from(users)
    .where(eq(users.email, body.email))
    .limit(1)

  if (!user) {
    throw createError({ statusCode: 401, statusMessage: '이메일/비밀번호가 올바르지 않습니다.' })
  }

  const ok = await verifyPassword(body.password, user.passwordHash)
  if (!ok) {
    throw createError({ statusCode: 401, statusMessage: '이메일/비밀번호가 올바르지 않습니다.' })
  }

  setSession(event, { userId: user.id, email: user.email })
  return { ok: true }
})

server/api/auth/logout.post.ts

import { clearSession } from '~/server/utils/session'

export default defineEventHandler((event) => {
  clearSession(event)
  return { ok: true }
})

server/api/auth/me.get.ts

import { getSession } from '~/server/utils/session'

export default defineEventHandler((event) => {
  return getSession(event)
})

10) To-Do API

server/api/todos/index.get.ts

import { db } from '~/server/db/client'
import { todos } from '~/server/db/schema'
import { eq, desc } from 'drizzle-orm'
import { requireSession } from '~/server/utils/auth'

export default defineEventHandler(async (event) => {
  const s = requireSession(event)

  return db
    .select()
    .from(todos)
    .where(eq(todos.userId, s.userId))
    .orderBy(desc(todos.id))
})

server/api/todos/index.post.ts

import { z } from 'zod'
import { db } from '~/server/db/client'
import { todos } from '~/server/db/schema'
import { requireSession } from '~/server/utils/auth'

const Body = z.object({ title: z.string().trim().min(1).max(200) })

export default defineEventHandler(async (event) => {
  const s = requireSession(event)
  const body = Body.parse(await readBody(event))

  const [todo] = await db
    .insert(todos)
    .values({
      userId: s.userId,
      title: body.title,
      done: false,
      createdAt: new Date(),
      updatedAt: new Date(),
    })
    .returning()

  return todo
})

server/api/todos/[id].patch.ts

import { z } from 'zod'
import { and, eq } from 'drizzle-orm'
import { db } from '~/server/db/client'
import { todos } from '~/server/db/schema'
import { requireSession } from '~/server/utils/auth'

const Body = z.object({
  title: z.string().trim().min(1).max(200).optional(),
  done: z.boolean().optional(),
})

export default defineEventHandler(async (event) => {
  const s = requireSession(event)
  const id = Number(getRouterParam(event, 'id'))
  if (!Number.isInteger(id) || id <= 0) throw createError({ statusCode: 400, statusMessage: 'invalid id' })

  const body = Body.parse(await readBody(event))

  await db
    .update(todos)
    .set({
      ...(body.title !== undefined ? { title: body.title } : {}),
      ...(body.done !== undefined ? { done: body.done } : {}),
      updatedAt: new Date(),
    })
    .where(and(eq(todos.id, id), eq(todos.userId, s.userId)))

  return { ok: true }
})

server/api/todos/[id].delete.ts

import { and, eq } from 'drizzle-orm'
import { db } from '~/server/db/client'
import { todos } from '~/server/db/schema'
import { requireSession } from '~/server/utils/auth'

export default defineEventHandler(async (event) => {
  const s = requireSession(event)
  const id = Number(getRouterParam(event, 'id'))
  if (!Number.isInteger(id) || id <= 0) throw createError({ statusCode: 400, statusMessage: 'invalid id' })

  await db.delete(todos).where(and(eq(todos.id, id), eq(todos.userId, s.userId)))
  return { ok: true }
})

11) 페이지 3개 만들기

pages/index.vue

<template>
  <main class="mx-auto max-w-2xl p-6">
    <h1 class="text-3xl font-bold">Nuxt3 To-Do 학습앱</h1>
    <p class="mt-2 text-gray-600">회원가입 → 로그인 → To-Do 관리</p>

    <div class="mt-6 flex gap-2">
      <UButton to="/signup">회원가입</UButton>
      <UButton to="/login" color="neutral" variant="outline">로그인</UButton>
      <UButton to="/todo" variant="soft">To-Do</UButton>
    </div>
  </main>
</template>

pages/signup.vue

<template>
  <main class="min-h-screen bg-gray-50 p-6">
    <div class="mx-auto max-w-md rounded-2xl bg-white p-6 shadow">
      <h1 class="mb-4 text-2xl font-bold">회원가입</h1>

      <UForm :state="state" @submit="onSubmit" class="space-y-4">
        <UFormField label="이메일" name="email"><UInput v-model="state.email" type="email" /></UFormField>
        <UFormField label="비밀번호(8자+)" name="password"><UInput v-model="state.password" type="password" /></UFormField>
        <UButton type="submit" block :loading="loading">가입하기</UButton>
      </UForm>
    </div>
  </main>
</template>

<script setup lang="ts">
const state = reactive({ email: '', password: '' })
const loading = ref(false)

async function onSubmit() {
  try {
    loading.value = true
    await $fetch('/api/auth/signup', { method: 'POST', body: state })
    await navigateTo('/todo')
  } catch (e: any) {
    alert(e?.data?.statusMessage ?? '가입 실패')
  } finally {
    loading.value = false
  }
}
</script>

pages/login.vue

<template>
  <main class="min-h-screen bg-gray-50 p-6">
    <div class="mx-auto max-w-md rounded-2xl bg-white p-6 shadow">
      <h1 class="mb-4 text-2xl font-bold">로그인</h1>

      <UForm :state="state" @submit="onSubmit" class="space-y-4">
        <UFormField label="이메일" name="email"><UInput v-model="state.email" type="email" /></UFormField>
        <UFormField label="비밀번호" name="password"><UInput v-model="state.password" type="password" /></UFormField>
        <UButton type="submit" block :loading="loading">로그인</UButton>
      </UForm>
    </div>
  </main>
</template>

<script setup lang="ts">
const state = reactive({ email: '', password: '' })
const loading = ref(false)

async function onSubmit() {
  try {
    loading.value = true
    await $fetch('/api/auth/login', { method: 'POST', body: state })
    await navigateTo('/todo')
  } catch (e: any) {
    alert(e?.data?.statusMessage ?? '로그인 실패')
  } finally {
    loading.value = false
  }
}
</script>

pages/todo.vue

<template>
  <main class="mx-auto max-w-2xl p-6">
    <div class="mb-6 flex items-center justify-between">
      <div>
        <h1 class="text-2xl font-bold">내 To-Do</h1>
        <p class="text-sm text-gray-600">{{ me?.email }}</p>
      </div>
      <UButton color="neutral" variant="outline" @click="logout">로그아웃</UButton>
    </div>

    <div class="mb-4 flex gap-2">
      <UInput v-model="newTitle" placeholder="할 일을 입력하세요" class="flex-1" />
      <UButton @click="addTodo">추가</UButton>
    </div>

    <div class="space-y-2">
      <UCard v-for="t in todos" :key="t.id">
        <div class="flex items-center gap-2">
          <UCheckbox :model-value="t.done" @update:model-value="(v) => toggleDone(t, !!v)" />
          <span class="flex-1" :class="t.done ? 'line-through text-gray-400' : ''">{{ t.title }}</span>
          <UButton color="error" variant="soft" size="xs" @click="removeTodo(t.id)">삭제</UButton>
        </div>
      </UCard>
    </div>
  </main>
</template>

<script setup lang="ts">
type Me = { userId: number; email: string }
type Todo = { id: number; userId: number; title: string; done: boolean }

const { data: me } = await useFetch<Me | null>('/api/auth/me')
if (!me.value) await navigateTo('/login')

const newTitle = ref('')
const { data: todos, refresh } = await useFetch<Todo[]>('/api/todos', { default: () => [] })

async function addTodo() {
  if (!newTitle.value.trim()) return
  await $fetch('/api/todos', { method: 'POST', body: { title: newTitle.value } })
  newTitle.value = ''
  await refresh()
}

async function toggleDone(t: Todo, done: boolean) {
  await $fetch(`/api/todos/${t.id}`, { method: 'PATCH', body: { done } })
  await refresh()
}

async function removeTodo(id: number) {
  await $fetch(`/api/todos/${id}`, { method: 'DELETE' })
  await refresh()
}

async function logout() {
  await $fetch('/api/auth/logout', { method: 'POST' })
  await navigateTo('/login')
}
</script>

E. DB 생성 명령 (반드시 실행)

package.json에 scripts 추가:

{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate"
  }
}

그리고 실행:

npm run db:generate
npm run db:migrate
npm run dev

F. 동작 확인 체크리스트

  1. /signup 가입
  2. 자동으로 /todo 이동
  3. To-Do 3개 추가
  4. 체크박스 완료 토글
  5. 삭제 버튼 테스트
  6. 로그아웃
  7. 다시 로그인
  8. 기존 데이터 유지 확인

G. 막히면 여기 먼저 확인

  • Cannot find module: 파일 경로/이름 오타
  • 401 오류: 로그인 안 된 상태
  • DB 관련 오류: db:migrate 실행했는지 확인
  • 쿠키 안 먹음: 브라우저 시크릿 모드/도메인/시간 확인

H. 핵심 정리 (한 줄)

이 가이드는 Nuxt3에서 SQLite + 세션 인증 + UI 컴포넌트로 To-Do 앱을 실전처럼 만드는 최소 완성 루트입니다.