Nuxt 3 통합 초보자용 To-Do 완성 가이드 v2
(진짜 처음인 사람도 그대로 따라하면 돌아가게 만든 버전)
이 문서는 설명보다 실행에 집중합니다.
규칙: “한 단계씩 복붙 → 실행 확인 → 다음 단계”
A. 최종 목표
이 문서 끝나면 아래가 됩니다.
- 회원가입
- 로그인 / 로그아웃
- 로그인 상태 유지(새로고침해도 유지)
- To-Do 추가 / 완료 체크 / 삭제
- 데이터는 SQLite(
app.db)에 저장 - 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. 동작 확인 체크리스트
/signup가입- 자동으로
/todo이동 - To-Do 3개 추가
- 체크박스 완료 토글
- 삭제 버튼 테스트
- 로그아웃
- 다시 로그인
- 기존 데이터 유지 확인
G. 막히면 여기 먼저 확인
Cannot find module: 파일 경로/이름 오타- 401 오류: 로그인 안 된 상태
- DB 관련 오류:
db:migrate실행했는지 확인 - 쿠키 안 먹음: 브라우저 시크릿 모드/도메인/시간 확인
H. 핵심 정리 (한 줄)
이 가이드는 Nuxt3에서 SQLite + 세션 인증 + UI 컴포넌트로 To-Do 앱을 실전처럼 만드는 최소 완성 루트입니다.