Nuxt 3 마스터클래스
SQLite + 로그인 세션 인증 + Tailwind/Nuxt UI
(웹 완전 초보도 따라할 수 있는 초상세 교육용 매뉴얼)
이 문서는 “한 번도 웹앱을 끝까지 만들어본 적 없는 사람” 기준으로 작성되었습니다.
목표는 단순 설명이 아니라, 복붙/실행/이해/응용이 모두 가능하도록 만드는 것입니다.
목차
- 왜 Nuxt 3인가?
- 완전 초보 용어 사전
- 오늘 만들 결과물(완성 스펙)
- 개발 환경 준비
- 프로젝트 생성과 기본 실행
- 전체 아키텍처(그림처럼 이해)
- 1단계: SQLite DB 연동 (Drizzle ORM)
- 2단계: 인증(회원가입/로그인/로그아웃) + 세션 유지
- 3단계: 메모(Todo) CRUD를 로그인 사용자 단위로 보호
- 4단계: Tailwind + Nuxt UI로 화면 품질 올리기
- 페이지 보호(미로그인 차단) 안전하게 하기
- 실수하기 쉬운 포인트 20개
- 보안 체크리스트(실무 기준)
- 배포 전 체크리스트
- 부록: 전체 파일 구조 + 명령어 모음
1) 왜 Nuxt 3인가?
Nuxt 3는 Vue 3 기반 풀스택 프레임워크입니다. 초보자가 빠르게 실무형 웹앱을 만들기 좋은 이유는:
- 폴더 규칙 기반 자동화 (
pages,server/api,components) - 템플릿/로직/스타일이 명확히 분리된
.vue파일 구조 - 서버 API를 같은 프로젝트에서 자연스럽게 작성 가능
- SSR/SEO/배포 친화적
즉, “설정 싸움”보다 “기능 구현과 학습”에 시간을 쓸 수 있습니다.
2) 완전 초보 용어 사전
2-1. 프론트엔드 / 백엔드
- 프론트엔드: 사용자 화면(버튼, 입력창, 목록)
- 백엔드: 서버 로직(DB 저장, 인증 검사)
2-2. API
- 프론트가 서버에 요청할 때 쓰는 통로
- 예:
/api/auth/login,/api/memos
2-3. 세션(Session)
- “이 브라우저는 로그인한 사용자다”를 기억하는 서버 측 상태
- 보통 쿠키에 토큰을 저장
2-4. 쿠키(Cookie)
- 브라우저에 저장되는 작은 데이터
- 로그인 상태 유지에 자주 사용
2-5. SQLite
- 파일 기반 DB
- 설치/운영이 간단하고 학습용·소규모 서비스에 강함
2-6. ORM
- SQL을 코드로 다루게 해주는 도구
- 여기서는 Drizzle ORM 사용
2-7. CRUD
- Create(생성), Read(조회), Update(수정), Delete(삭제)
3) 오늘 만들 결과물(완성 스펙)
사용자 기능
- 회원가입
- 로그인
- 로그아웃
- 로그인 유지(새로고침해도 유지)
메모 기능
- 메모 목록 조회
- 메모 추가
- 메모 수정
- 메모 삭제
- 사용자별 데이터 분리(내 계정 메모만 보임)
UI
- Tailwind CSS 기반 스타일
- Nuxt UI 컴포넌트 사용 (
UButton,UInput,UCard,UForm)
4) 개발 환경 준비
4-1. 필수 설치
- Node.js 20+
- npm
확인:
node -v
npm -v
4-2. 프로젝트 생성
npx nuxi@latest init nuxt3-masterclass
cd nuxt3-masterclass
npm install
npm run dev
브라우저: http://localhost:3000
5) 패키지 설치 (DB/인증/UI)
npm i better-sqlite3 drizzle-orm bcryptjs zod
npm i -D drizzle-kit @types/better-sqlite3
npm i @nuxt/ui
npm i -D tailwindcss @tailwindcss/vite
설치 이유:
better-sqlite3: SQLite 연결drizzle-orm: 타입 안전 DB 처리bcryptjs: 비밀번호 해시zod: 요청 검증@nuxt/ui: 고품질 UI 컴포넌트tailwindcss: 유틸리티 기반 스타일
6) 최종 파일 구조
nuxt3-masterclass/
assets/
css/main.css
pages/
index.vue
login.vue
signup.vue
memos.vue
server/
api/
auth/
signup.post.ts
login.post.ts
logout.post.ts
me.get.ts
memos/
index.get.ts
index.post.ts
[id].patch.ts
[id].delete.ts
db/
client.ts
schema.ts
utils/
password.ts
session.ts
auth.ts
drizzle.config.ts
nuxt.config.ts
.env
7) 1단계: SQLite DB 연동 (Drizzle)
7-1. drizzle 설정
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
7-2. 스키마 작성
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 memos = sqliteTable('memos', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull(),
text: text('text').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(),
})
설명:
users.email.unique: 중복 가입 방지memos.userId: 어떤 사용자 메모인지 구분done: 완료 상태
7-3. DB 클라이언트
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 })
7-4. 마이그레이션 실행
package.json에 스크립트 추가:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
}
}
실행:
npm run db:generate
npm run db:migrate
결과:
app.db파일 생성- 테이블 생성 완료
8) 2단계: 인증 + 세션 유지
8-1. 환경변수
.env
SESSION_SECRET="replace-with-very-long-random-secret"
운영환경에서는 반드시 강한 랜덤 문자열 사용
8-2. 비밀번호 유틸
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)
}
8-3. 세션 유틸(서명 쿠키 방식)
server/utils/session.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import { getCookie, setCookie, deleteCookie } from 'h3'
const COOKIE_NAME = 'nuxt_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')
const sig = sign(raw)
return `${raw}.${sig}`
}
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-4. 인증 헬퍼
server/utils/auth.ts
import { getSession } from './session'
export function requireUserSession(event: any) {
const session = getSession(event)
if (!session) {
throw createError({ statusCode: 401, statusMessage: '로그인이 필요합니다.' })
}
return session
}
8-5. 회원가입 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, '비밀번호는 최소 8자 이상이어야 합니다.'),
})
export default defineEventHandler(async (event) => {
const body = Body.parse(await readBody(event))
const exists = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, body.email))
.limit(1)
if (exists.length > 0) {
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, user }
})
8-6. 로그인 API
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, user: { id: user.id, email: user.email } }
})
8-7. 로그아웃 API
server/api/auth/logout.post.ts
import { clearSession } from '~/server/utils/session'
export default defineEventHandler((event) => {
clearSession(event)
return { ok: true }
})
8-8. 현재 로그인 사용자 조회 API
server/api/auth/me.get.ts
import { getSession } from '~/server/utils/session'
export default defineEventHandler((event) => {
const session = getSession(event)
if (!session) return null
return {
userId: session.userId,
email: session.email,
}
})
9) 3단계: 메모 CRUD를 로그인 사용자 단위로 보호
9-1. 메모 목록 조회
server/api/memos/index.get.ts
import { db } from '~/server/db/client'
import { memos } from '~/server/db/schema'
import { eq, desc } from 'drizzle-orm'
import { requireUserSession } from '~/server/utils/auth'
export default defineEventHandler(async (event) => {
const session = requireUserSession(event)
return db
.select()
.from(memos)
.where(eq(memos.userId, session.userId))
.orderBy(desc(memos.id))
})
9-2. 메모 생성
server/api/memos/index.post.ts
import { z } from 'zod'
import { db } from '~/server/db/client'
import { memos } from '~/server/db/schema'
import { requireUserSession } from '~/server/utils/auth'
const Body = z.object({
text: z.string().trim().min(1, '메모를 입력하세요.').max(200),
})
export default defineEventHandler(async (event) => {
const session = requireUserSession(event)
const body = Body.parse(await readBody(event))
const [memo] = await db
.insert(memos)
.values({
userId: session.userId,
text: body.text,
done: false,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
return memo
})
9-3. 메모 수정(완료 토글/텍스트 수정)
server/api/memos/[id].patch.ts
import { z } from 'zod'
import { and, eq } from 'drizzle-orm'
import { db } from '~/server/db/client'
import { memos } from '~/server/db/schema'
import { requireUserSession } from '~/server/utils/auth'
const Body = z.object({
text: z.string().trim().min(1).max(200).optional(),
done: z.boolean().optional(),
})
export default defineEventHandler(async (event) => {
const session = requireUserSession(event)
const id = Number(getRouterParam(event, 'id'))
if (!Number.isInteger(id) || id <= 0) {
throw createError({ statusCode: 400, statusMessage: '잘못된 ID입니다.' })
}
const body = Body.parse(await readBody(event))
await db
.update(memos)
.set({
...(body.text !== undefined ? { text: body.text } : {}),
...(body.done !== undefined ? { done: body.done } : {}),
updatedAt: new Date(),
})
.where(and(eq(memos.id, id), eq(memos.userId, session.userId)))
return { ok: true }
})
9-4. 메모 삭제
server/api/memos/[id].delete.ts
import { and, eq } from 'drizzle-orm'
import { db } from '~/server/db/client'
import { memos } from '~/server/db/schema'
import { requireUserSession } from '~/server/utils/auth'
export default defineEventHandler(async (event) => {
const session = requireUserSession(event)
const id = Number(getRouterParam(event, 'id'))
if (!Number.isInteger(id) || id <= 0) {
throw createError({ statusCode: 400, statusMessage: '잘못된 ID입니다.' })
}
await db
.delete(memos)
.where(and(eq(memos.id, id), eq(memos.userId, session.userId)))
return { ok: true }
})
핵심 보안 포인트: 어떤 수정/삭제든
userId조건으로 반드시 제한
10) 4단계: Tailwind + Nuxt UI 적용
10-1. Nuxt 설정
nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
})
10-2. Tailwind CSS 엔트리
assets/css/main.css
@import "tailwindcss";
11) 페이지 구현 (회원가입/로그인/메모)
11-1. 홈
pages/index.vue
<template>
<main class="mx-auto max-w-3xl p-6">
<h1 class="text-3xl font-bold">Nuxt 3 SQLite 인증 메모앱</h1>
<p class="mt-2 text-gray-600">회원가입 후 로그인하여 메모를 관리하세요.</p>
<div class="mt-6 flex gap-2">
<UButton to="/signup">회원가입</UButton>
<UButton to="/login" color="neutral" variant="outline">로그인</UButton>
<UButton to="/memos" color="primary" variant="soft">메모로 이동</UButton>
</div>
</main>
</template>
11-2. 회원가입
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="pending">가입하기</UButton>
</UForm>
<p class="mt-4 text-sm">
이미 계정이 있으신가요?
<NuxtLink class="text-blue-600" to="/login">로그인</NuxtLink>
</p>
</div>
</main>
</template>
<script setup lang="ts">
const state = reactive({ email: '', password: '' })
const pending = ref(false)
async function onSubmit() {
try {
pending.value = true
await $fetch('/api/auth/signup', { method: 'POST', body: state })
await navigateTo('/memos')
} catch (e: any) {
alert(e?.data?.statusMessage ?? '회원가입 실패')
} finally {
pending.value = false
}
}
</script>
11-3. 로그인
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="pending">로그인</UButton>
</UForm>
<p class="mt-4 text-sm">
아직 계정이 없으신가요?
<NuxtLink class="text-blue-600" to="/signup">회원가입</NuxtLink>
</p>
</div>
</main>
</template>
<script setup lang="ts">
const state = reactive({ email: '', password: '' })
const pending = ref(false)
async function onSubmit() {
try {
pending.value = true
await $fetch('/api/auth/login', { method: 'POST', body: state })
await navigateTo('/memos')
} catch (e: any) {
alert(e?.data?.statusMessage ?? '로그인 실패')
} finally {
pending.value = false
}
}
</script>
11-4. 메모 페이지 (조회/생성/수정/삭제 + 로그아웃)
pages/memos.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">내 메모</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="newText" placeholder="새 메모를 입력하세요" class="flex-1" />
<UButton @click="addMemo">추가</UButton>
</div>
<div class="space-y-2">
<UCard v-for="m in memos" :key="m.id">
<div class="flex items-center gap-2">
<UCheckbox :model-value="m.done" @update:model-value="(v) => toggleDone(m, !!v)" />
<UInput
class="flex-1"
:model-value="m.text"
@update:model-value="(v) => updateText(m, String(v))"
/>
<UButton color="error" variant="soft" size="xs" @click="removeMemo(m.id)">
삭제
</UButton>
</div>
</UCard>
</div>
</main>
</template>
<script setup lang="ts">
type Memo = {
id: number
userId: number
text: string
done: boolean
createdAt: string
updatedAt: string
}
const { data: me } = await useFetch<{ userId: number; email: string } | null>('/api/auth/me')
if (!me.value) {
await navigateTo('/login')
}
const newText = ref('')
const { data: memos, refresh } = await useFetch<Memo[]>('/api/memos', {
default: () => [],
})
async function addMemo() {
if (!newText.value.trim()) return
await $fetch('/api/memos', { method: 'POST', body: { text: newText.value } })
newText.value = ''
await refresh()
}
async function toggleDone(m: Memo, done: boolean) {
await $fetch(`/api/memos/${m.id}`, { method: 'PATCH', body: { done } })
await refresh()
}
let updateTimer: any = null
function updateText(m: Memo, text: string) {
if (updateTimer) clearTimeout(updateTimer)
updateTimer = setTimeout(async () => {
await $fetch(`/api/memos/${m.id}`, { method: 'PATCH', body: { text } })
await refresh()
}, 400)
}
async function removeMemo(id: number) {
await $fetch(`/api/memos/${id}`, { method: 'DELETE' })
await refresh()
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await navigateTo('/login')
}
</script>
12) 페이지 보호를 더 안전하게: 라우트 미들웨어
로그인 체크를 페이지마다 수동으로 넣는 대신, 미들웨어로 통일할 수 있습니다.
middleware/auth.ts
export default defineNuxtRouteMiddleware(async () => {
const me = await $fetch('/api/auth/me')
if (!me) {
return navigateTo('/login')
}
})
pages/memos.vue 상단에 적용
<script setup lang="ts">
definePageMeta({ middleware: ['auth'] })
// ...
</script>
13) 실행 순서 (처음부터 끝까지)
- 패키지 설치
- DB/스키마/마이그레이션 구성
- 인증 API 작성
- 메모 API 작성
- Tailwind + Nuxt UI 설정
- 페이지 작성
- 실행/테스트
명령어:
npm install
npm run db:generate
npm run db:migrate
npm run dev
14) 테스트 시나리오 (꼭 해보기)
/signup에서 계정 생성/memos이동 확인- 메모 3개 추가
- 하나 완료 체크
- 하나 텍스트 수정
- 하나 삭제
- 로그아웃
- 다시 로그인
- 메모 데이터 유지 확인
- 다른 계정 생성 후 데이터 분리 확인
15) 초보자가 자주 하는 실수 20개
.env없이 세션 시크릿 누락- 마이그레이션 안 돌리고 API부터 실행
- API에서 zod 검증 누락
- 로그인 체크를 프론트에서만 수행
- DB 쿼리에서
userId조건 누락 - 비밀번호 원문 저장
- 에러 메시지를 그대로 사용자에게 출력
httpOnly쿠키 미사용- 운영에서
secure: false - 비밀번호 최소 길이 없음
- 중복 이메일 체크 누락
- 로그아웃 시 쿠키 삭제 누락
- PATCH에서 null/undefined 구분 실수
- 라우트 미들웨어 누락
- pending 상태 처리 누락(중복 제출)
- 입력 trim 미적용
- SQL 파일/DB 파일 git 업로드
- 개발/운영 환경변수 혼동
- 배포 시 CORS/도메인 쿠키 정책 미확인
- 백업 전략 없음
16) 보안 체크리스트 (실무 필수)
-
SESSION_SECRET강력 랜덤값 - 쿠키
httpOnly,sameSite=lax, 운영시secure=true - 로그인/회원가입 rate limit
- 비밀번호 정책(길이/복잡도)
- 입력 검증(zod) 전 API 적용
- 사용자별 데이터 접근 제한(userId where)
- 에러 로깅과 사용자 메시지 분리
- DB 백업/복구 리허설
17) 배포 전 점검
-
NODE_ENV=production -
SESSION_SECRET설정 -
app.db저장 위치/권한 확인 - 백업 스케줄링(cron)
- 로그 수집(에러 추적)
- HTTPS 인증서 적용
18) 확장 로드맵
- 사용자 프로필/비밀번호 변경
- 메모 검색/필터/태그
- 팀 공유 메모(권한 모델)
- 첨부파일 업로드
- SQLite -> PostgreSQL 전환
- e2e 테스트(Playwright)
19) 핵심 요약
스카이님 요청하신 1/2/3은 아래로 완결됩니다.
- DB 연동: SQLite + Drizzle로 영구 저장
- 인증: 회원가입/로그인 + 세션 쿠키 유지
- UI: Tailwind + Nuxt UI로 빠르게 실무형 화면 구성
이 구조를 정확히 이해하면, 단순 메모앱을 넘어 대부분의 SaaS 기본 골격(회원/권한/데이터/UI)을 확장 가능하게 됩니다.
부록 A) 명령어 모음
# 초기 설치
npm install
# DB 반영
npm run db:generate
npm run db:migrate
# 개발 서버
npm run dev
부록 B) .gitignore 권장
node_modules/
.nuxt/
.output/
app.db
.env
필요하시면 다음 문서로
**“Nuxt 3 운영 배포 마스터편 (Docker + Nginx + HTTPS + 백업 + 장애복구)”**까지 이어서 작성해드리겠습니다.