Stage 3: Backend Essentials

Database · Lesson 04

Node.js + SQLite

A working CRUD API backed by better-sqlite3 and Express.

Database3 min readFree lesson

You have written SQL by hand and an API backed by a JSON file. Now connect the two: a real relational database, embedded in the Node process, with no server to install.

Learning objectives

  • Open a SQLite database from Node with better-sqlite3.
  • Create tables and seed data at startup.
  • Use prepared statements for every query.
  • Build a full CRUD API on top of it.

1. Why better-sqlite3

better-sqlite3 is synchronous. That sounds wrong in Node — but SQLite runs in-process, and a local query typically takes microseconds. The synchronous API removes callback noise and is genuinely faster than the async wrappers for this workload.

npm install better-sqlite3 express
const Database = require("better-sqlite3");

const db = new Database("shop.db");
db.pragma("journal_mode = WAL"); // better concurrency for readers

The database is a file next to your code. Add *.db, *.db-wal and *.db-shm to .gitignore.

2. Create the table

db.exec(`
  CREATE TABLE IF NOT EXISTS books (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT NOT NULL,
    author     TEXT NOT NULL,
    price      INTEGER NOT NULL DEFAULT 0,
    category   TEXT,
    created_at TEXT DEFAULT (datetime('now'))
  )
`);

Run schema setup at startup, or keep it in schema.sql and execute that file. Both are fine; what matters is that a fresh clone can create the database in one step.

3. Prepared statements

Prepare once, run many times. The statement is compiled and cached, and parameters are escaped by the driver.

const getAll = db.prepare("SELECT * FROM books ORDER BY id DESC");
const getById = db.prepare("SELECT * FROM books WHERE id = ?");
const getByCategory = db.prepare("SELECT * FROM books WHERE category = ?");
const insert = db.prepare(
  "INSERT INTO books (title, author, price, category) VALUES (?, ?, ?, ?)",
);
const update = db.prepare(
  "UPDATE books SET title = ?, author = ?, price = ?, category = ? WHERE id = ?",
);
const remove = db.prepare("DELETE FROM books WHERE id = ?");

getAll.all();          // array of rows
getById.get(1);        // one row or undefined
insert.run("Clean Code", "Robert C. Martin", 35000, "Programming");

Three methods: .all() for many rows, .get() for one, .run() for writes. .run() returns { changes, lastInsertRowid }.

Named parameters are easier to read when a query has many columns:

const insertNamed = db.prepare(`
  INSERT INTO books (title, author, price, category)
  VALUES (@title, @author, @price, @category)
`);

insertNamed.run({ title: "Refactoring", author: "Martin Fowler", price: 42000, category: "Programming" });

4. Wiring the API

const express = require("express");
const app = express();
app.use(express.json());

app.get("/books", (req, res) => {
  const { category } = req.query;
  const rows = category ? getByCategory.all(category) : getAll.all();
  res.json({ success: true, count: rows.length, data: rows });
});

app.get("/books/:id", (req, res) => {
  const book = getById.get(Number(req.params.id));
  if (!book) return res.status(404).json({ success: false, error: "Book not found" });
  res.json({ success: true, data: book });
});

app.post("/books", (req, res) => {
  const { title, author, price, category } = req.body;

  if (!title || !author || !Number.isFinite(Number(price))) {
    return res.status(400).json({ success: false, error: "title, author and price are required" });
  }

  const info = insert.run(title.trim(), author.trim(), Number(price), category ?? null);
  const book = getById.get(info.lastInsertRowid);
  res.status(201).json({ success: true, data: book });
});

app.listen(3000);

5. Transactions

better-sqlite3 gives you a transaction wrapper that rolls back on any thrown error:

const createOrder = db.transaction((items) => {
  const order = insertOrder.run(Date.now(), items.length);
  for (const item of items) {
    insertItem.run(order.lastInsertRowid, item.productId, item.quantity);
  }
  return order.lastInsertRowid;
});

createOrder([{ productId: 1, quantity: 2 }]); // all or nothing

Common mistakes

  • Building SQL with template strings and user input — that is SQL injection.
  • Calling .get() when the query returns many rows (you silently get the first).
  • Forgetting Number() on route params; "1" is not 1 in SQLite comparison.
  • Committing the .db file to Git.

Practice

Build the books API in the assignment. Every query must be a prepared statement.

Example files

Complete, runnable files used in this lesson. Copy them into your project and experiment.

app.jsJavaScript
// ============================================
// 강의04 예제: Express + better-sqlite3 책 목록 CRUD API
// ============================================
// 실행: node app.js
// 테스트: http://localhost:3000/books
// ============================================

const express = require('express');
const Database = require('better-sqlite3');

const app = express();
const PORT = 3000;

// JSON 요청 바디 파싱
app.use(express.json());

// ─────────────────────────────────────────
// 1. 데이터베이스 초기화
// ─────────────────────────────────────────
// 파일이 없으면 자동으로 생성됨
const db = new Database('library.db');

// 앱 시작 시 테이블이 없으면 자동 생성 (CREATE TABLE IF NOT EXISTS)
db.exec(`
    CREATE TABLE IF NOT EXISTS books (
        id        INTEGER PRIMARY KEY AUTOINCREMENT,
        title     TEXT    NOT NULL,
        author    TEXT    NOT NULL,
        price     INTEGER DEFAULT 0,
        category  TEXT    DEFAULT '미분류',
        created_at TEXT   DEFAULT (datetime('now'))
    )
`);

// 초기 더미 데이터 (책이 없을 때만 삽입)
const count = db.prepare('SELECT COUNT(*) AS cnt FROM books').get();
if (count.cnt === 0) {
    const insert = db.prepare(
        'INSERT INTO books (title, author, price, category) VALUES (?, ?, ?, ?)'
    );
    // 여러 행을 트랜잭션으로 묶어 빠르게 삽입
    const insertMany = db.transaction((books) => {
        for (const book of books) {
            insert.run(book.title, book.author, book.price, book.category);
        }
    });
    insertMany([
        { title: '클린 코드',     author: '로버트 마틴',   price: 30000, category: '프로그래밍' },
        { title: '리팩터링',      author: '마틴 파울러',   price: 35000, category: '프로그래밍' },
        { title: '파이썬 입문',   author: '귀도 반 로썸',  price: 28000, category: '프로그래밍' },
        { title: '사피엔스',      author: '유발 하라리',   price: 22000, category: '역사'       },
    ]);
    console.log('더미 데이터 삽입 완료');
}

// ─────────────────────────────────────────
// 2. Prepared Statements 미리 준비
//    자주 쓰는 쿼리를 한 번만 컴파일하여 성능 향상
// ─────────────────────────────────────────
const stmts = {
    // 전체 조회 (category 필터 옵션)
    getAll: db.prepare('SELECT * FROM books ORDER BY id DESC'),
    getAllByCategory: db.prepare('SELECT * FROM books WHERE category = ? ORDER BY id DESC'),

    // 단일 조회
    getById: db.prepare('SELECT * FROM books WHERE id = ?'),

    // 삽입
    insert: db.prepare(
        'INSERT INTO books (title, author, price, category) VALUES (@title, @author, @price, @category)'
    ),

    // 수정
    update: db.prepare(
        'UPDATE books SET title = @title, author = @author, price = @price, category = @category WHERE id = @id'
    ),

    // 삭제
    delete: db.prepare('DELETE FROM books WHERE id = ?'),
};

// ─────────────────────────────────────────
// 3. API 라우트
// ─────────────────────────────────────────

// GET /books — 전체 목록 (쿼리로 카테고리 필터 가능)
// 예: GET /books?category=프로그래밍
app.get('/books', (req, res) => {
    const { category } = req.query;

    // category 쿼리가 있으면 필터, 없으면 전체 조회
    const books = category
        ? stmts.getAllByCategory.all(category)
        : stmts.getAll.all();

    res.json({ success: true, count: books.length, data: books });
});

// GET /books/:id — 특정 책 조회
app.get('/books/:id', (req, res) => {
    const { id } = req.params;

    // get()은 단일 행 반환, 없으면 undefined
    const book = stmts.getById.get(id);

    if (!book) {
        // 404: 해당 ID 책이 없음
        return res.status(404).json({ success: false, message: '책을 찾을 수 없습니다.' });
    }

    res.json({ success: true, data: book });
});

// POST /books — 새 책 추가
// Body: { title, author, price, category }
app.post('/books', (req, res) => {
    const { title, author, price = 0, category = '미분류' } = req.body;

    // 필수 필드 검증
    if (!title || !author) {
        return res.status(400).json({ success: false, message: 'title과 author는 필수입니다.' });
    }

    // @name 형식의 파라미터 바인딩 (객체로 전달)
    const result = stmts.insert.run({ title, author, price, category });

    // 방금 삽입한 행을 다시 조회하여 반환
    const newBook = stmts.getById.get(result.lastInsertRowid);

    res.status(201).json({ success: true, data: newBook });
});

// PUT /books/:id — 책 정보 수정
// Body: { title, author, price, category }
app.put('/books/:id', (req, res) => {
    const { id } = req.params;

    // 기존 데이터 조회
    const existing = stmts.getById.get(id);
    if (!existing) {
        return res.status(404).json({ success: false, message: '책을 찾을 수 없습니다.' });
    }

    // 기존 값을 유지하면서 요청된 필드만 업데이트 (부분 업데이트)
    const updated = {
        id,
        title:    req.body.title    ?? existing.title,
        author:   req.body.author   ?? existing.author,
        price:    req.body.price    ?? existing.price,
        category: req.body.category ?? existing.category,
    };

    const result = stmts.update.run(updated);

    if (result.changes === 0) {
        return res.status(400).json({ success: false, message: '수정에 실패했습니다.' });
    }

    // 수정된 데이터 반환
    const updatedBook = stmts.getById.get(id);
    res.json({ success: true, data: updatedBook });
});

// DELETE /books/:id — 책 삭제
app.delete('/books/:id', (req, res) => {
    const { id } = req.params;

    // 삭제 전 존재 확인
    const existing = stmts.getById.get(id);
    if (!existing) {
        return res.status(404).json({ success: false, message: '책을 찾을 수 없습니다.' });
    }

    // run()은 영향받은 행 수를 changes에 담아 반환
    const result = stmts.delete.run(id);

    if (result.changes === 0) {
        return res.status(400).json({ success: false, message: '삭제에 실패했습니다.' });
    }

    res.json({ success: true, message: `"${existing.title}" 삭제 완료` });
});

// ─────────────────────────────────────────
// 4. 서버 시작
// ─────────────────────────────────────────
app.listen(PORT, () => {
    console.log(`서버 실행 중: http://localhost:${PORT}`);
    console.log('');
    console.log('사용 가능한 API:');
    console.log('  GET    /books           - 전체 목록');
    console.log('  GET    /books?category= - 카테고리 필터');
    console.log('  GET    /books/:id       - 단일 조회');
    console.log('  POST   /books           - 책 추가');
    console.log('  PUT    /books/:id       - 책 수정');
    console.log('  DELETE /books/:id       - 책 삭제');
});

Assignment

Assignment 04 — Books API with better-sqlite3

Build a CRUD API that stores books in books.db.

Setup

npm install express better-sqlite3

Add *.db, *.db-wal, *.db-shm and node_modules/ to .gitignore.

Schema

CREATE TABLE IF NOT EXISTS books (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  title      TEXT NOT NULL,
  author     TEXT NOT NULL,
  price      INTEGER NOT NULL DEFAULT 0,
  category   TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

Seed five books only when the table is empty.

Endpoints

Method Path Notes
GET /books?category= newest first, optional category filter
GET /books/:id 404 when missing
POST /books require title, author, numeric price; 201
PUT /books/:id update all fields; 404 when missing
DELETE /books/:id 200 with the deleted row; 404 when missing

Requirements

  1. Every statement is created with db.prepare(...).
  2. No SQL is built by concatenating request data.
  3. Responses use { success, data } / { success, error }.
  4. POST and PUT trim strings and convert price with Number.
  5. Log the database path and row count on startup.

Hints

const getById = db.prepare("SELECT * FROM books WHERE id = ?");
const insert = db.prepare(
  "INSERT INTO books (title, author, price, category) VALUES (?, ?, ?, ?)",
);
const info = insert.run(title, author, price, category);
const created = getById.get(info.lastInsertRowid);

Done when

  • Restarting the server keeps the books.
  • Deleting books.db recreates and reseeds it on the next start.
  • curl -i /books/999 returns 404, not 200 with an empty body.
Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

app.jsJavaScript
// ============================================
// 과제04 정답: 블로그 포스트 CRUD API
// better-sqlite3 + Express
// ============================================
// 실행: node app.js
// ============================================

const express = require('express');
const Database = require('better-sqlite3');

const app = express();
const PORT = 3000;

// JSON 요청 바디 파싱 미들웨어
app.use(express.json());

// ─────────────────────────────────────────
// 1. 데이터베이스 초기화
//    blog.db 파일이 없으면 자동 생성
// ─────────────────────────────────────────
const db = new Database('blog.db');

// WAL 모드: 읽기/쓰기 성능 향상 (SQLite 권장 설정)
db.pragma('journal_mode = WAL');

// posts 테이블 자동 생성 (없을 때만 생성)
db.exec(`
    CREATE TABLE IF NOT EXISTS posts (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        title      TEXT    NOT NULL,                     -- 포스트 제목 (필수)
        content    TEXT    NOT NULL,                     -- 포스트 내용 (필수)
        author     TEXT    NOT NULL,                     -- 작성자 (필수)
        created_at TEXT    DEFAULT (datetime('now')),    -- 작성일시 (자동)
        updated_at TEXT    DEFAULT (datetime('now'))     -- 수정일시 (자동, 수정 시 갱신)
    )
`);

// ─────────────────────────────────────────
// 2. Prepared Statements 준비
//    자주 쓰는 쿼리를 미리 컴파일 → 성능 향상 + SQL 인젝션 방지
// ─────────────────────────────────────────
const stmts = {
    // 전체 포스트 조회 (최신 순)
    getAll: db.prepare('SELECT * FROM posts ORDER BY created_at DESC'),

    // 작성자 필터
    getByAuthor: db.prepare('SELECT * FROM posts WHERE author = ? ORDER BY created_at DESC'),

    // 제목 검색 (LIKE)
    searchByTitle: db.prepare("SELECT * FROM posts WHERE title LIKE '%' || ? || '%' ORDER BY created_at DESC"),

    // 단일 포스트 조회
    getById: db.prepare('SELECT * FROM posts WHERE id = ?'),

    // 포스트 생성
    insert: db.prepare(
        'INSERT INTO posts (title, content, author) VALUES (@title, @content, @author)'
    ),

    // 포스트 수정 (updated_at을 현재 시각으로 갱신)
    update: db.prepare(`
        UPDATE posts
        SET title      = @title,
            content    = @content,
            author     = @author,
            updated_at = datetime('now')   -- 수정 시각 자동 갱신
        WHERE id = @id
    `),

    // 포스트 삭제
    delete: db.prepare('DELETE FROM posts WHERE id = ?'),
};

// ─────────────────────────────────────────
// 3. API 라우트 구현
// ─────────────────────────────────────────

// GET /posts — 전체 목록 조회
// 쿼리 파라미터로 필터 가능:
//   ?author=김철수   → 작성자 필터
//   ?search=키워드   → 제목 검색
app.get('/posts', (req, res) => {
    const { author, search } = req.query;

    let posts;

    if (author) {
        // 작성자 필터 (정확히 일치)
        posts = stmts.getByAuthor.all(author);
    } else if (search) {
        // 제목 키워드 검색
        posts = stmts.searchByTitle.all(search);
    } else {
        // 전체 조회
        posts = stmts.getAll.all();
    }

    res.json({
        success: true,
        count: posts.length,
        data: posts,
    });
});

// GET /posts/:id — 단일 포스트 조회
app.get('/posts/:id', (req, res) => {
    const { id } = req.params;

    // get()은 단일 행 반환, 없으면 undefined
    const post = stmts.getById.get(id);

    if (!post) {
        // 해당 ID의 포스트가 없을 때 404 반환
        return res.status(404).json({
            success: false,
            message: `ID ${id}에 해당하는 포스트가 없습니다.`,
        });
    }

    res.json({ success: true, data: post });
});

// POST /posts — 새 포스트 작성
// Body: { title, content, author }
app.post('/posts', (req, res) => {
    const { title, content, author } = req.body;

    // 필수 필드 검증: title, content, author 모두 필요
    if (!title || !content || !author) {
        return res.status(400).json({
            success: false,
            message: 'title, content, author는 모두 필수 항목입니다.',
        });
    }

    // 빈 문자열도 거부
    if (title.trim() === '' || content.trim() === '' || author.trim() === '') {
        return res.status(400).json({
            success: false,
            message: '값이 비어있으면 안 됩니다.',
        });
    }

    // 포스트 삽입 (run()은 { lastInsertRowid, changes } 반환)
    const result = stmts.insert.run({ title: title.trim(), content: content.trim(), author: author.trim() });

    // 생성된 포스트를 다시 조회하여 응답 (created_at 포함)
    const newPost = stmts.getById.get(result.lastInsertRowid);

    res.status(201).json({
        success: true,
        message: '포스트가 생성되었습니다.',
        data: newPost,
    });
});

// PUT /posts/:id — 포스트 수정
// Body: { title?, content?, author? } — 부분 수정 가능
app.put('/posts/:id', (req, res) => {
    const { id } = req.params;

    // 수정 전 기존 데이터 조회
    const existing = stmts.getById.get(id);
    if (!existing) {
        return res.status(404).json({
            success: false,
            message: `ID ${id}에 해당하는 포스트가 없습니다.`,
        });
    }

    // 요청 바디에 없는 필드는 기존 값 유지 (부분 업데이트)
    const updated = {
        id,
        title:   req.body.title   ?? existing.title,
        content: req.body.content ?? existing.content,
        author:  req.body.author  ?? existing.author,
        // updated_at은 SQL에서 datetime('now')로 자동 갱신
    };

    const result = stmts.update.run(updated);

    if (result.changes === 0) {
        return res.status(400).json({
            success: false,
            message: '수정에 실패했습니다.',
        });
    }

    // 수정된 데이터 반환 (updated_at 확인 가능)
    const updatedPost = stmts.getById.get(id);
    res.json({
        success: true,
        message: '포스트가 수정되었습니다.',
        data: updatedPost,
    });
});

// DELETE /posts/:id — 포스트 삭제
app.delete('/posts/:id', (req, res) => {
    const { id } = req.params;

    // 삭제 전 존재 확인 (삭제 후에는 제목을 알 수 없으므로 미리 저장)
    const existing = stmts.getById.get(id);
    if (!existing) {
        return res.status(404).json({
            success: false,
            message: `ID ${id}에 해당하는 포스트가 없습니다.`,
        });
    }

    // 삭제 실행 (changes가 0이면 실패)
    const result = stmts.delete.run(id);

    if (result.changes === 0) {
        return res.status(400).json({
            success: false,
            message: '삭제에 실패했습니다.',
        });
    }

    res.json({
        success: true,
        message: `"${existing.title}" 포스트가 삭제되었습니다.`,
    });
});

// ─────────────────────────────────────────
// 4. 서버 시작
// ─────────────────────────────────────────
app.listen(PORT, () => {
    console.log(`블로그 API 서버 실행: http://localhost:${PORT}`);
    console.log('');
    console.log('API 목록:');
    console.log('  GET    /posts              - 전체 목록');
    console.log('  GET    /posts?author=이름  - 작성자 필터');
    console.log('  GET    /posts?search=키워드 - 제목 검색');
    console.log('  GET    /posts/:id          - 단일 조회');
    console.log('  POST   /posts              - 포스트 작성');
    console.log('  PUT    /posts/:id          - 포스트 수정');
    console.log('  DELETE /posts/:id          - 포스트 삭제');
});

Learn this lesson with a mentor

Work through the Build Your Homepage series on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now