TanStack Start + SQLite로 만드는 로그인/할 일(Todo) 웹앱 완전 초보 매뉴얼
대상 독자: 웹을 한 번도 안 해본 분
목표: 이 문서 한 편으로, 회원가입/로그인 + Todo 관리 웹앱을 스스로 만들고 이해하기
0. 이 문서에서 만드는 결과물
이 매뉴얼을 끝까지 따라오시면 아래 기능이 동작합니다.
- 회원가입
- 로그인 / 로그아웃
- 로그인한 사용자만 Todo 페이지 접근
- Todo 추가 / 완료 토글 / 삭제
- 데이터 저장소: SQLite (파일 하나로 저장되는 DB)
- 프레임워크: TanStack Start (나머지 전부)
1. 초보자를 위한 핵심 용어 사전
1-1) 프론트엔드 / 백엔드
- 프론트엔드: 사용자가 보는 화면(버튼, 입력창 등)
- 백엔드: 서버에서 돌아가는 로직(로그인 검증, DB 저장)
1-2) 라우팅(Route)
- URL 주소별로 어떤 화면을 보여줄지 정하는 규칙
- 예:
/login은 로그인 페이지,/todos는 Todo 페이지
1-3) SSR (Server Side Rendering)
- 서버에서 HTML을 먼저 만들어 브라우저로 보내는 방식
- 첫 화면 표시가 빠르고 SEO에 유리
1-4) 서버 함수(Server Function)
- 버튼 클릭 같은 사용자 행동이 있을 때 브라우저에서 서버 코드를 안전하게 호출하는 방법
- TanStack Start에서
createServerFn으로 작성
1-5) 세션(Session)
- "이 사용자가 로그인 상태인지"를 기억하는 서버 측 상태
- 보통 쿠키(cookie)와 함께 사용
1-6) SQLite
- 설치가 매우 간단하고 가벼운 파일 기반 DB
app.db같은 파일 하나에 테이블/데이터가 저장됨
2. 전체 구조를 먼저 이해하기
우리는 이렇게 나눕니다.
- 화면 컴포넌트: 입력 폼, Todo 리스트
- 서버 함수: 회원가입/로그인/Todo CRUD
- DB 레이어: SQLite 연결 + 테이블 정의
- 인증 레이어: 세션 읽기/쓰기, 현재 로그인 사용자 확인
즉,
- 브라우저는 화면과 입력 담당
- 서버 함수는 규칙/검증 담당
- DB는 데이터 영구 저장 담당
3. 준비물 설치
3-1) Node.js 설치
- Node.js 20 이상 권장
- 확인:
node -v
npm -v
3-2) 프로젝트 생성
npm create @tanstack/start@latest
생성 옵션은 기본으로 시작해도 됩니다.
cd <생성된_프로젝트_폴더>
npm install
3-3) 필요한 패키지 설치
npm install better-sqlite3 drizzle-orm bcryptjs zod
npm install -D drizzle-kit @types/better-sqlite3
왜 설치하나요?
better-sqlite3: SQLite 연결drizzle-orm: 타입 안전 SQL ORMbcryptjs: 비밀번호 해시(암호화 저장)zod: 입력값 검증drizzle-kit: 마이그레이션(테이블 생성/변경 관리)
4. 폴더 구조(완성 기준)
아래 구조를 목표로 합니다.
src/
lib/
db.ts
schema.ts
auth.ts
password.ts
routes/
__root.tsx
index.tsx
login.tsx
signup.tsx
logout.tsx
todos.tsx
drizzle.config.ts
5. DB 만들기 (SQLite + Drizzle)
5-1) drizzle 설정 파일
drizzle.config.ts
import type { Config } from 'drizzle-kit'
export default {
schema: './src/lib/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: './app.db',
},
} satisfies Config
설명:
schema: 테이블 정의 파일 위치out: 마이그레이션 파일 생성 위치url: './app.db': SQLite 파일 경로
5-2) 스키마(테이블) 작성
src/lib/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(),
})
핵심:
users.email은 중복 불가todos.userId로 "어떤 사용자의 Todo인지" 구분
5-3) DB 연결 코드
src/lib/db.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 })
5-4) 마이그레이션 실행
package.json 스크립트 추가:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
}
}
실행:
npm run db:generate
npm run db:migrate
이제 app.db 파일과 테이블이 생성됩니다.
6. 비밀번호/인증 유틸 만들기
6-1) 비밀번호 해시 유틸
src/lib/password.ts
import bcrypt from 'bcryptjs'
const SALT_ROUNDS = 10
export async function hashPassword(rawPassword: string) {
return bcrypt.hash(rawPassword, SALT_ROUNDS)
}
export async function verifyPassword(rawPassword: string, passwordHash: string) {
return bcrypt.compare(rawPassword, passwordHash)
}
설명:
- 절대로 비밀번호 원문을 DB에 저장하면 안 됩니다.
- 해시값만 저장하고, 로그인 시 비교합니다.
6-2) 세션 + 현재 사용자 확인 유틸
src/lib/auth.ts
import { useSession } from '@tanstack/react-start/server'
import { eq } from 'drizzle-orm'
import { db } from './db'
import { users } from './schema'
export type SessionData = {
userId?: number
userEmail?: string
}
export async function getAppSession() {
// 운영 시 반드시 강한 값으로 교체하세요.
return useSession<SessionData>({
password: process.env.SESSION_SECRET ?? 'dev-only-change-this-secret',
})
}
export async function getCurrentUser() {
const session = await getAppSession()
if (!session.data.userId) return null
const [user] = await db
.select({ id: users.id, email: users.email })
.from(users)
.where(eq(users.id, session.data.userId))
.limit(1)
return user ?? null
}
export async function requireUser() {
const user = await getCurrentUser()
if (!user) {
throw new Error('UNAUTHORIZED')
}
return user
}
7. 루트 레이아웃 + 전역 사용자 정보
src/routes/__root.tsx
import {
HeadContent,
Link,
Outlet,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
import { getCurrentUser } from '~/lib/auth'
const fetchMe = createServerFn({ method: 'GET' }).handler(async () => {
return getCurrentUser()
})
export const Route = createRootRoute({
beforeLoad: async () => {
const me = await fetchMe()
return { me }
},
component: RootLayout,
})
function RootLayout() {
const { me } = Route.useRouteContext()
return (
<html lang="ko">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TanStack Start Todo</title>
<HeadContent />
</head>
<body style={{ fontFamily: 'sans-serif', margin: 24 }}>
<nav style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<Link to="/">홈</Link>
<Link to="/todos">할 일</Link>
<span style={{ marginLeft: 'auto' }}>
{me ? (
<>
{me.email} | <Link to="/logout">로그아웃</Link>
</>
) : (
<>
<Link to="/login">로그인</Link> / <Link to="/signup">회원가입</Link>
</>
)}
</span>
</nav>
<Outlet />
<Scripts />
</body>
</html>
)
}
포인트:
beforeLoad에서 현재 로그인 사용자 조회- 모든 페이지에서
me사용 가능
8. 회원가입 구현
src/routes/signup.tsx
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { createServerFn, useServerFn } from '@tanstack/react-start'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '~/lib/db'
import { users } from '~/lib/schema'
import { hashPassword } from '~/lib/password'
import { getAppSession } from '~/lib/auth'
const signupInput = z.object({
email: z.string().email('이메일 형식이 아닙니다'),
password: z.string().min(8, '비밀번호는 최소 8자 이상'),
})
const signupFn = createServerFn({ method: 'POST' })
.inputValidator((input: unknown) => signupInput.parse(input))
.handler(async ({ data }) => {
const exists = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, data.email))
.limit(1)
if (exists.length > 0) {
return { ok: false, message: '이미 가입된 이메일입니다' }
}
const passwordHash = await hashPassword(data.password)
const inserted = await db
.insert(users)
.values({
email: data.email,
passwordHash,
createdAt: new Date(),
})
.returning({ id: users.id, email: users.email })
const user = inserted[0]
const session = await getAppSession()
await session.update({ userId: user.id, userEmail: user.email })
return { ok: true }
})
export const Route = createFileRoute('/signup')({ component: SignupPage })
function SignupPage() {
const router = useRouter()
const callSignup = useServerFn(signupFn)
return (
<main>
<h1>회원가입</h1>
<form
onSubmit={async (e) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
const result = await callSignup({
data: {
email: String(fd.get('email') ?? ''),
password: String(fd.get('password') ?? ''),
},
})
if (!result.ok) {
alert(result.message)
return
}
await router.invalidate()
router.navigate({ to: '/todos' })
}}
>
<div>
<label>이메일 </label>
<input name="email" type="email" required />
</div>
<div>
<label>비밀번호 </label>
<input name="password" type="password" minLength={8} required />
</div>
<button type="submit">가입하기</button>
</form>
</main>
)
}
9. 로그인 구현
src/routes/login.tsx
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { createServerFn, useServerFn } from '@tanstack/react-start'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '~/lib/db'
import { users } from '~/lib/schema'
import { verifyPassword } from '~/lib/password'
import { getAppSession } from '~/lib/auth'
const loginInput = z.object({
email: z.string().email(),
password: z.string().min(1),
})
const loginFn = createServerFn({ method: 'POST' })
.inputValidator((input: unknown) => loginInput.parse(input))
.handler(async ({ data }) => {
const found = await db
.select({ id: users.id, email: users.email, passwordHash: users.passwordHash })
.from(users)
.where(eq(users.email, data.email))
.limit(1)
const user = found[0]
if (!user) {
return { ok: false, message: '이메일 또는 비밀번호가 올바르지 않습니다' }
}
const valid = await verifyPassword(data.password, user.passwordHash)
if (!valid) {
return { ok: false, message: '이메일 또는 비밀번호가 올바르지 않습니다' }
}
const session = await getAppSession()
await session.update({ userId: user.id, userEmail: user.email })
return { ok: true }
})
export const Route = createFileRoute('/login')({ component: LoginPage })
function LoginPage() {
const router = useRouter()
const callLogin = useServerFn(loginFn)
return (
<main>
<h1>로그인</h1>
<form
onSubmit={async (e) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
const result = await callLogin({
data: {
email: String(fd.get('email') ?? ''),
password: String(fd.get('password') ?? ''),
},
})
if (!result.ok) {
alert(result.message)
return
}
await router.invalidate()
router.navigate({ to: '/todos' })
}}
>
<div>
<label>이메일 </label>
<input name="email" type="email" required />
</div>
<div>
<label>비밀번호 </label>
<input name="password" type="password" required />
</div>
<button type="submit">로그인</button>
</form>
</main>
)
}
10. 로그아웃 구현
src/routes/logout.tsx
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { createServerFn, useServerFn } from '@tanstack/react-start'
import { getAppSession } from '~/lib/auth'
const logoutFn = createServerFn({ method: 'POST' }).handler(async () => {
const session = await getAppSession()
await session.clear()
return { ok: true }
})
export const Route = createFileRoute('/logout')({ component: LogoutPage })
function LogoutPage() {
const router = useRouter()
const callLogout = useServerFn(logoutFn)
return (
<main>
<h1>로그아웃</h1>
<button
onClick={async () => {
await callLogout()
await router.invalidate()
router.navigate({ to: '/' })
}}
>
정말 로그아웃
</button>
</main>
)
}
11. Todo 페이지 구현 (핵심)
src/routes/todos.tsx
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { createServerFn, useServerFn } from '@tanstack/react-start'
import { and, eq } from 'drizzle-orm'
import { z } from 'zod'
import { requireUser } from '~/lib/auth'
import { db } from '~/lib/db'
import { todos } from '~/lib/schema'
const listTodosFn = createServerFn({ method: 'GET' }).handler(async () => {
const user = await requireUser()
return db
.select({ id: todos.id, title: todos.title, done: todos.done, createdAt: todos.createdAt })
.from(todos)
.where(eq(todos.userId, user.id))
})
const createTodoInput = z.object({
title: z.string().min(1, '할 일을 입력하세요').max(200),
})
const createTodoFn = createServerFn({ method: 'POST' })
.inputValidator((input: unknown) => createTodoInput.parse(input))
.handler(async ({ data }) => {
const user = await requireUser()
await db.insert(todos).values({
userId: user.id,
title: data.title,
done: false,
createdAt: new Date(),
})
return { ok: true }
})
const toggleTodoInput = z.object({ id: z.number().int().positive() })
const toggleTodoFn = createServerFn({ method: 'POST' })
.inputValidator((input: unknown) => toggleTodoInput.parse(input))
.handler(async ({ data }) => {
const user = await requireUser()
const found = await db
.select({ done: todos.done })
.from(todos)
.where(and(eq(todos.id, data.id), eq(todos.userId, user.id)))
.limit(1)
const row = found[0]
if (!row) return { ok: false }
await db
.update(todos)
.set({ done: !row.done })
.where(and(eq(todos.id, data.id), eq(todos.userId, user.id)))
return { ok: true }
})
const deleteTodoInput = z.object({ id: z.number().int().positive() })
const deleteTodoFn = createServerFn({ method: 'POST' })
.inputValidator((input: unknown) => deleteTodoInput.parse(input))
.handler(async ({ data }) => {
const user = await requireUser()
await db
.delete(todos)
.where(and(eq(todos.id, data.id), eq(todos.userId, user.id)))
return { ok: true }
})
export const Route = createFileRoute('/todos')({
loader: async () => {
const items = await listTodosFn()
return { items }
},
component: TodosPage,
})
function TodosPage() {
const router = useRouter()
const { items } = Route.useLoaderData()
const createTodo = useServerFn(createTodoFn)
const toggleTodo = useServerFn(toggleTodoFn)
const deleteTodo = useServerFn(deleteTodoFn)
return (
<main>
<h1>내 할 일</h1>
<form
onSubmit={async (e) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
const title = String(fd.get('title') ?? '')
await createTodo({ data: { title } })
e.currentTarget.reset()
await router.invalidate()
}}
>
<input name="title" placeholder="할 일을 입력하세요" />
<button type="submit">추가</button>
</form>
<ul style={{ marginTop: 16, paddingLeft: 20 }}>
{items.map((item) => (
<li key={item.id} style={{ marginBottom: 8 }}>
<label style={{ textDecoration: item.done ? 'line-through' : 'none' }}>
<input
type="checkbox"
checked={item.done}
onChange={async () => {
await toggleTodo({ data: { id: item.id } })
await router.invalidate()
}}
/>{' '}
{item.title}
</label>{' '}
<button
onClick={async () => {
await deleteTodo({ data: { id: item.id } })
await router.invalidate()
}}
>
삭제
</button>
</li>
))}
</ul>
</main>
)
}
중요한 보안 포인트:
requireUser()를 서버 함수마다 사용where(and(eq(todos.id, data.id), eq(todos.userId, user.id)))조건으로 본인 데이터만 수정/삭제
12. 홈 페이지 작성
src/routes/index.tsx
import { createFileRoute, Link } from '@tanstack/react-router'
export const Route = createFileRoute('/')({ component: HomePage })
function HomePage() {
return (
<main>
<h1>TanStack Start Todo 앱</h1>
<p>회원가입/로그인 후 Todo를 관리해보세요.</p>
<Link to="/todos">할 일 페이지로 이동</Link>
</main>
)
}
13. 실행 방법
npm run dev
브라우저에서 보통 아래 주소:
http://localhost:3000또는http://localhost:5173
테스트 순서:
- 회원가입
- 로그아웃
- 로그인
- Todo 추가/체크/삭제
- 다른 이메일 가입 후 데이터 분리 확인
14. 초보자가 반드시 이해해야 할 “실수 포인트”
14-1) 비밀번호를 원문으로 저장하면 안 됨
- 항상
passwordHash만 저장
14-2) 로그인 체크를 화면에서만 하면 안 됨
- 반드시 서버 함수 내부에서
requireUser()체크
14-3) 사용자 소유권 검증 누락 금지
- 삭제/수정 SQL에
userId조건 반드시 포함
14-4) SESSION_SECRET 하드코딩 금지 (운영)
.env로 분리
예시:
SESSION_SECRET="아주_길고_랜덤한_문자열"
15. 동작 원리(진짜 이해용)
로그인을 누르면 내부적으로 이런 흐름입니다.
- 로그인 폼 제출
loginFn서버 함수 호출- DB에서 이메일 사용자 조회
- 입력 비밀번호와 저장된 hash 비교
- 성공 시 세션에
userId저장 - 이후
/todos에서 서버가 세션을 읽어 사용자 식별
즉 핵심은:
- 세션이 로그인 상태를 증명
- DB의 userId로 데이터 소유권을 보장
16. 운영 전 체크리스트 (실무 품질)
-
SESSION_SECRET환경변수 설정 - 비밀번호 최소 길이/복잡도 정책
- 로그인 시도 횟수 제한(브루트포스 방지)
- CSRF/XSS 기본 보안 점검
- 에러 메시지 민감정보 제거
- 백업 정책(SQLite 파일 백업)
17. 학습 확장 과제 (다음 단계)
- Todo 마감일(
dueDate) 추가 - Todo 검색/필터(완료/미완료)
- 페이지네이션
- 사용자 프로필 페이지
- 테스트 코드(Vitest/Playwright)
- SQLite -> Postgres 마이그레이션
18. 요약
이 매뉴얼의 본질은 3가지입니다.
- 인증(Authentication): 세션으로 사용자 식별
- 권한(Authorization): 사용자 본인 데이터만 접근
- 데이터 관리(CRUD): Todo를 안전하게 생성/조회/수정/삭제
TanStack Start의 장점은, 이 모든 흐름을 타입 안정성 + 서버 함수 패턴으로 깔끔하게 연결해준다는 점입니다.
필요하시면 다음 단계로, 이 코드를 기준으로
- "관리자 페이지 추가"
- "팀 협업용 멀티유저 Todo"
- "배포(도메인/HTTPS/백업)"
까지 확장 매뉴얼을 이어서 작성해드리겠습니다.