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. 전체 구조를 먼저 이해하기

우리는 이렇게 나눕니다.

  1. 화면 컴포넌트: 입력 폼, Todo 리스트
  2. 서버 함수: 회원가입/로그인/Todo CRUD
  3. DB 레이어: SQLite 연결 + 테이블 정의
  4. 인증 레이어: 세션 읽기/쓰기, 현재 로그인 사용자 확인

즉,

  • 브라우저는 화면과 입력 담당
  • 서버 함수는 규칙/검증 담당
  • 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 ORM
  • bcryptjs: 비밀번호 해시(암호화 저장)
  • 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

테스트 순서:

  1. 회원가입
  2. 로그아웃
  3. 로그인
  4. Todo 추가/체크/삭제
  5. 다른 이메일 가입 후 데이터 분리 확인

14. 초보자가 반드시 이해해야 할 “실수 포인트”

14-1) 비밀번호를 원문으로 저장하면 안 됨

  • 항상 passwordHash만 저장

14-2) 로그인 체크를 화면에서만 하면 안 됨

  • 반드시 서버 함수 내부에서 requireUser() 체크

14-3) 사용자 소유권 검증 누락 금지

  • 삭제/수정 SQL에 userId 조건 반드시 포함

14-4) SESSION_SECRET 하드코딩 금지 (운영)

  • .env로 분리

예시:

SESSION_SECRET="아주_길고_랜덤한_문자열"

15. 동작 원리(진짜 이해용)

로그인을 누르면 내부적으로 이런 흐름입니다.

  1. 로그인 폼 제출
  2. loginFn 서버 함수 호출
  3. DB에서 이메일 사용자 조회
  4. 입력 비밀번호와 저장된 hash 비교
  5. 성공 시 세션에 userId 저장
  6. 이후 /todos에서 서버가 세션을 읽어 사용자 식별

즉 핵심은:

  • 세션이 로그인 상태를 증명
  • DB의 userId로 데이터 소유권을 보장

16. 운영 전 체크리스트 (실무 품질)

  • SESSION_SECRET 환경변수 설정
  • 비밀번호 최소 길이/복잡도 정책
  • 로그인 시도 횟수 제한(브루트포스 방지)
  • CSRF/XSS 기본 보안 점검
  • 에러 메시지 민감정보 제거
  • 백업 정책(SQLite 파일 백업)

17. 학습 확장 과제 (다음 단계)

  1. Todo 마감일(dueDate) 추가
  2. Todo 검색/필터(완료/미완료)
  3. 페이지네이션
  4. 사용자 프로필 페이지
  5. 테스트 코드(Vitest/Playwright)
  6. SQLite -> Postgres 마이그레이션

18. 요약

이 매뉴얼의 본질은 3가지입니다.

  1. 인증(Authentication): 세션으로 사용자 식별
  2. 권한(Authorization): 사용자 본인 데이터만 접근
  3. 데이터 관리(CRUD): Todo를 안전하게 생성/조회/수정/삭제

TanStack Start의 장점은, 이 모든 흐름을 타입 안정성 + 서버 함수 패턴으로 깔끔하게 연결해준다는 점입니다.

필요하시면 다음 단계로, 이 코드를 기준으로

  • "관리자 페이지 추가"
  • "팀 협업용 멀티유저 Todo"
  • "배포(도메인/HTTPS/백업)"
    까지 확장 매뉴얼을 이어서 작성해드리겠습니다.