Stage 3: Backend Essentials

Node.js · Lesson 06

File-based Database

Persisting data to JSON files and keeping secrets in .env files.

Node.js3 min readFree lesson

An API that forgets everything on restart is a demo, not a product. Before adding a real database, learn the simplest form of persistence: a JSON file on disk.

Learning objectives

  • Persist an array of records to a JSON file.
  • Read and write safely with fs.
  • Keep configuration and secrets in .env.
  • Add filtering, sorting and pagination to a JSON-backed API.

1. The idea

data/posts.json  ←→  readPosts() / writePosts()  ←→  Express routes

Every request reads the file, changes the array, writes it back. It is not fast and it does not handle concurrent writers, but it is honest, dependency-free persistence and it teaches the shape every database layer has.

2. Read and write helpers

const fs = require("node:fs");
const path = require("node:path");

const DATA_DIR = path.join(__dirname, "data");
const DB_PATH = path.join(DATA_DIR, "posts.json");

function readPosts() {
  if (!fs.existsSync(DB_PATH)) return [];

  try {
    return JSON.parse(fs.readFileSync(DB_PATH, "utf8"));
  } catch (error) {
    console.error("Could not read the database:", error.message);
    return [];
  }
}

function writePosts(posts) {
  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.writeFileSync(DB_PATH, JSON.stringify(posts, null, 2), "utf8");
}

Two details matter:

  • JSON.parse throws on a corrupted file. Catch it and fail gracefully.
  • Create the directory before writing; a missing folder is the most common first-run error.

For production-grade safety, write to a temporary file and rename it over the original — rename is atomic on the same disk, so a crash never leaves half a JSON document.

3. Environment variables

Configuration must not live in code.

npm install dotenv
# .env
PORT=4000
NODE_ENV=development
require("dotenv").config();

const PORT = process.env.PORT || 3000;
const env = process.env.NODE_ENV || "development";

Add .env to .gitignore before the first commit. Commit a .env.example with the variable names and no values.

4. Pagination and filters

app.get("/api/posts", (req, res) => {
  let posts = readPosts();
  const { author, tag, page, limit } = req.query;

  if (author) {
    posts = posts.filter((p) => p.author.toLowerCase().includes(String(author).toLowerCase()));
  }

  if (tag) {
    posts = posts.filter((p) => Array.isArray(p.tags) && p.tags.includes(tag));
  }

  posts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));

  if (page && limit) {
    const pageNum = Math.max(1, Number(page));
    const limitNum = Math.max(1, Number(limit));
    const start = (pageNum - 1) * limitNum;

    return res.json({
      success: true,
      total: posts.length,
      page: pageNum,
      totalPages: Math.ceil(posts.length / limitNum),
      data: posts.slice(start, start + limitNum),
    });
  }

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

Pagination belongs on the server: sending 10,000 records to the browser and hiding most of them wastes bandwidth and memory.

5. Updates that keep history

posts[index] = {
  ...posts[index],
  title: title.trim(),
  content: content.trim(),
  updatedAt: new Date().toISOString(),
};

Spread the old record first so id and createdAt survive. Replace only the fields the client is allowed to change.

Common mistakes

  • Committing data/posts.json with real user data.
  • Forgetting to write after a mutation — the response looks right, then the data disappears on restart.
  • Trusting page and limit without converting and clamping them.
  • Storing secrets in package.json or in source files.

Practice

Build the posts API in the assignment. When it works, the next subject replaces the JSON file with SQLite and you will recognise every layer.

Example files

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

app.jsJavaScript
// ===================================================
// 강의 06 예제 — JSON 파일 기반 연락처 API
// 실행: node app.js
// 테스트: http://localhost:3000/api/contacts
//
// 사전 준비:
//   npm init -y
//   npm install express dotenv
//   .env 파일 생성 후 PORT=3000 작성
// ===================================================

// dotenv 설정 — .env 파일의 내용을 process.env에 로드
// 반드시 파일 맨 위에 작성해야 함
require('dotenv').config();

const express = require('express');
const fs = require('fs');
const path = require('path');

const app = express();
const PORT = process.env.PORT || 3000; // 환경 변수 우선, 없으면 3000

app.use(express.json());

// ① 데이터 파일 경로 설정
const DATA_DIR = path.join(__dirname, 'data');    // 데이터 폴더
const DB_PATH = path.join(DATA_DIR, 'contacts.json'); // JSON 파일 경로

// ② data 폴더가 없으면 자동 생성
if (!fs.existsSync(DATA_DIR)) {
  fs.mkdirSync(DATA_DIR, { recursive: true }); // 중간 폴더도 함께 생성
  console.log('data 폴더 생성 완료');
}

// ③ 헬퍼 함수 — JSON 파일에서 데이터 읽기
function readContacts() {
  // 파일이 없으면 빈 배열 반환 (초기 상태)
  if (!fs.existsSync(DB_PATH)) return [];

  try {
    const raw = fs.readFileSync(DB_PATH, 'utf-8'); // 파일 읽기
    return JSON.parse(raw);                        // JSON 파싱
  } catch (err) {
    console.error('DB 읽기 오류:', err.message);
    return []; // 오류 시 빈 배열 반환
  }
}

// ④ 헬퍼 함수 — JSON 파일에 데이터 쓰기
function writeContacts(contacts) {
  // JSON.stringify(data, replacer, 들여쓰기) — 보기 좋게 저장
  const json = JSON.stringify(contacts, null, 2);
  fs.writeFileSync(DB_PATH, json, 'utf-8');
}

// ============================================================
// 연락처 CRUD API
// ============================================================

// GET /api/contacts — 전체 연락처 조회
app.get('/api/contacts', (req, res) => {
  const contacts = readContacts(); // 파일에서 읽기
  res.json({ success: true, count: contacts.length, data: contacts });
});

// GET /api/contacts/:id — 특정 연락처 조회
app.get('/api/contacts/:id', (req, res) => {
  const contacts = readContacts();
  const contact = contacts.find((c) => c.id === Number(req.params.id));

  if (!contact) {
    return res.status(404).json({ success: false, error: '연락처를 찾을 수 없습니다.' });
  }

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

// POST /api/contacts — 새 연락처 생성
app.post('/api/contacts', (req, res) => {
  const { name, phone, email } = req.body;

  // 이름과 전화번호는 필수
  if (!name || !phone) {
    return res.status(400).json({
      success: false,
      error: 'name과 phone 필드는 필수입니다.',
    });
  }

  const contacts = readContacts(); // 현재 데이터 읽기

  const newContact = {
    id: Date.now(),              // 유닉스 타임스탬프 ID
    name: name.trim(),
    phone: phone.trim(),
    email: email ? email.trim() : '', // email은 선택
    createdAt: new Date().toISOString(),
  };

  contacts.push(newContact);  // 새 연락처 추가
  writeContacts(contacts);    // 파일에 저장 (영속화!)

  res.status(201).json({
    success: true,
    message: '연락처가 저장되었습니다.',
    data: newContact,
  });
});

// PUT /api/contacts/:id — 연락처 수정
app.put('/api/contacts/:id', (req, res) => {
  const id = Number(req.params.id);
  const contacts = readContacts();
  const index = contacts.findIndex((c) => c.id === id);

  if (index === -1) {
    return res.status(404).json({ success: false, error: '연락처를 찾을 수 없습니다.' });
  }

  const { name, phone, email } = req.body;
  if (!name || !phone) {
    return res.status(400).json({ success: false, error: 'name과 phone은 필수입니다.' });
  }

  // 기존 데이터 업데이트 (id, createdAt은 유지)
  contacts[index] = {
    ...contacts[index], // 기존 데이터 복사
    name: name.trim(),
    phone: phone.trim(),
    email: email ? email.trim() : contacts[index].email,
    updatedAt: new Date().toISOString(), // 수정 시각 추가
  };

  writeContacts(contacts); // 파일에 저장

  res.json({ success: true, message: '연락처가 수정되었습니다.', data: contacts[index] });
});

// DELETE /api/contacts/:id — 연락처 삭제
app.delete('/api/contacts/:id', (req, res) => {
  const id = Number(req.params.id);
  const contacts = readContacts();
  const index = contacts.findIndex((c) => c.id === id);

  if (index === -1) {
    return res.status(404).json({ success: false, error: '연락처를 찾을 수 없습니다.' });
  }

  const [deleted] = contacts.splice(index, 1); // 삭제
  writeContacts(contacts); // 파일에 저장

  res.json({ success: true, message: '연락처가 삭제되었습니다.', data: deleted });
});

// 서버 시작
app.listen(PORT, () => {
  console.log(`연락처 API 서버: http://localhost:${PORT}`);
  console.log(`데이터 저장 경로: ${DB_PATH}`);
  console.log('서버를 재시작해도 데이터가 유지됩니다!');
});

Assignment

Assignment 06 — Posts API with a JSON database

Persist a blog posts API to data/posts.json and configure it with .env.

Setup

npm install express dotenv

Create .env with PORT=3000, add it to .gitignore, and commit a .env.example instead.

Model

{
  "id": 1710000000000,
  "title": "First post",
  "content": "Body text",
  "author": "Aarav",
  "tags": ["node", "api"],
  "createdAt": "2026-10-21T09:00:00.000Z",
  "updatedAt": null
}

Endpoints

Method Path Notes
GET /api/posts?author=&tag=&page=&limit= filter, sort newest-first, paginate
GET /api/posts/:id 404 when missing
POST /api/posts require title, content, author; 201
PUT /api/posts/:id update fields, set updatedAt
DELETE /api/posts/:id remove and persist

Requirements

  1. readPosts() returns [] when the file is missing or invalid.
  2. writePosts() creates the data folder if needed.
  3. Every mutation calls writePosts before responding.
  4. Pagination response includes total, page, limit, totalPages.
  5. tags defaults to [] when the client sends something that is not an array.
  6. The server logs the environment and the database path on start.

Done when

  • Restarting the server keeps the posts.
  • ?page=2&limit=5 returns the correct slice and metadata.
  • Deleting data/posts.json and restarting gives an empty list, not a crash.
  • .env is not tracked by Git.
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
// ===================================================
// 과제 06 정답 — JSON 파일 기반 블로그 API
// 실행: node app.js
// 테스트: http://localhost:3000/api/posts
// ===================================================

// dotenv: .env 파일의 환경 변수를 process.env에 로드
// 파일 최상단에 위치해야 함
require('dotenv').config();

const express = require('express');
const fs = require('fs');
const path = require('path');

const app = express();
const PORT = process.env.PORT || 3000; // .env의 PORT 값 사용, 없으면 3000

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

// ① 파일 경로 상수 정의
const DATA_DIR = path.join(__dirname, 'data');          // 데이터 폴더
const DB_PATH = path.join(DATA_DIR, 'posts.json');      // 블로그 포스트 JSON 파일

// ② 서버 시작 시 data 폴더 자동 생성 (없는 경우)
if (!fs.existsSync(DATA_DIR)) {
  fs.mkdirSync(DATA_DIR, { recursive: true }); // recursive: 중간 폴더도 함께 생성
  console.log(`data 폴더 생성: ${DATA_DIR}`);
}

// ③ 데이터 읽기 헬퍼 함수
function readPosts() {
  if (!fs.existsSync(DB_PATH)) return []; // 파일 없으면 빈 배열

  try {
    const raw = fs.readFileSync(DB_PATH, 'utf-8'); // 파일 읽기
    return JSON.parse(raw);                        // JSON 문자열 → 배열
  } catch (err) {
    console.error('DB 읽기 실패:', err.message);
    return [];
  }
}

// ④ 데이터 쓰기 헬퍼 함수
function writePosts(posts) {
  // JSON.stringify(값, replacer, 들여쓰기공백수)
  // null: replacer 없음, 2: 2칸 들여쓰기로 보기 좋게 저장
  const json = JSON.stringify(posts, null, 2);
  fs.writeFileSync(DB_PATH, json, 'utf-8');
}

// ============================================================
// GET /api/posts — 전체 포스트 조회 (필터링 + 페이지네이션)
// ============================================================
app.get('/api/posts', (req, res) => {
  let posts = readPosts();
  const { author, tag, page, limit } = req.query;

  // 작성자 필터링: ?author=홍길동
  if (author) {
    posts = posts.filter((p) =>
      p.author.toLowerCase().includes(author.toLowerCase())
    );
  }

  // 태그 필터링: ?tag=node.js
  if (tag) {
    posts = posts.filter((p) =>
      Array.isArray(p.tags) && p.tags.includes(tag)
    );
  }

  // 최신순 정렬 (createdAt 내림차순)
  posts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));

  // 페이지네이션: ?page=1&limit=5
  if (page && limit) {
    const pageNum = parseInt(page);   // 현재 페이지 (1부터 시작)
    const limitNum = parseInt(limit); // 페이지당 개수
    const startIdx = (pageNum - 1) * limitNum; // 시작 인덱스
    const endIdx = startIdx + limitNum;         // 끝 인덱스

    const total = posts.length;
    posts = posts.slice(startIdx, endIdx); // 해당 범위만 추출

    return res.json({
      success: true,
      total,
      page: pageNum,
      limit: limitNum,
      totalPages: Math.ceil(total / limitNum),
      data: posts,
    });
  }

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

// ============================================================
// GET /api/posts/:id — 특정 포스트 조회
// ============================================================
app.get('/api/posts/:id', (req, res) => {
  const id = Number(req.params.id);
  const posts = readPosts();
  const post = posts.find((p) => p.id === id);

  if (!post) {
    return res.status(404).json({
      success: false,
      error: `ID ${id}인 포스트를 찾을 수 없습니다.`,
    });
  }

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

// ============================================================
// POST /api/posts — 포스트 생성
// ============================================================
app.post('/api/posts', (req, res) => {
  const { title, content, author, tags } = req.body;

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

  const posts = readPosts(); // 기존 데이터 읽기

  const newPost = {
    id: Date.now(),                       // 유닉스 타임스탬프를 ID로 사용
    title: title.trim(),
    content: content.trim(),
    author: author.trim(),
    tags: Array.isArray(tags) ? tags : [], // tags가 배열이 아니면 빈 배열
    createdAt: new Date().toISOString(),   // 생성 시각 자동 추가 (ISO 형식)
    updatedAt: null,                       // 처음 생성 시 수정 시각은 null
  };

  posts.push(newPost);  // 새 포스트 추가
  writePosts(posts);    // JSON 파일에 저장

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

// ============================================================
// PUT /api/posts/:id — 포스트 수정
// ============================================================
app.put('/api/posts/:id', (req, res) => {
  const id = Number(req.params.id);
  const posts = readPosts();
  const index = posts.findIndex((p) => p.id === id);

  if (index === -1) {
    return res.status(404).json({
      success: false,
      error: `ID ${id}인 포스트를 찾을 수 없습니다.`,
    });
  }

  const { title, content, author, tags } = req.body;

  // 수정 시에도 title, content, author 필수
  if (!title || !content || !author) {
    return res.status(400).json({
      success: false,
      error: 'title, content, author 필드는 필수입니다.',
    });
  }

  // 기존 포스트 업데이트 (스프레드로 기존 데이터 유지 + 새 값 덮어쓰기)
  posts[index] = {
    ...posts[index],                       // id, createdAt 등 기존 값 유지
    title: title.trim(),
    content: content.trim(),
    author: author.trim(),
    tags: Array.isArray(tags) ? tags : posts[index].tags,
    updatedAt: new Date().toISOString(),   // 수정 시각 자동 갱신
  };

  writePosts(posts); // 파일에 저장

  res.json({
    success: true,
    message: '포스트가 수정되었습니다.',
    data: posts[index],
  });
});

// ============================================================
// DELETE /api/posts/:id — 포스트 삭제
// ============================================================
app.delete('/api/posts/:id', (req, res) => {
  const id = Number(req.params.id);
  const posts = readPosts();
  const index = posts.findIndex((p) => p.id === id);

  if (index === -1) {
    return res.status(404).json({
      success: false,
      error: `ID ${id}인 포스트를 찾을 수 없습니다.`,
    });
  }

  const [deleted] = posts.splice(index, 1); // 배열에서 제거
  writePosts(posts); // 파일에 저장

  res.json({
    success: true,
    message: '포스트가 삭제되었습니다.',
    data: deleted,
  });
});

// 서버 시작
app.listen(PORT, () => {
  const env = process.env.NODE_ENV || 'development';
  console.log(`[${env}] 블로그 API 서버: http://localhost:${PORT}`);
  console.log(`데이터 파일: ${DB_PATH}`);
  console.log('서버를 재시작해도 데이터가 유지됩니다!');
});

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