Zenn
📌

Webアプリケーションにおけるデータ保存戦略:フロントエンド、バックエンド、セッションの選択ガイド

2025/03/06に公開
2

はじめに

Webアプリケーション開発において、「データをどこに保存するか」という選択は、アプリケーションの性能、拡張性、ユーザー体験に大きな影響を与えます。特にPythonをバックエンドに使用するMVP開発から本番環境への移行を考えるエンジニアにとって、正しいデータ保存先の選定は重要な課題です。

このような質問を抱えていませんか?

  • SQLiteで始めたけど、本番環境ではどうすべき?
  • セッションとデータベース、どちらに保存すべきデータなの?
  • フロントエンドにデータを保存するメリットは?

この記事では、データ保存の3つの主要な選択肢であるフロントエンドバックエンドセッションのそれぞれの特性と、具体的なユースケースに基づいた選定指針を提供します。MVPから本番環境への段階的な移行を見据えた戦略も紹介します。

データ保存の3つの主要な選択肢

まずは、データを保存できる3つの主要な場所とその特徴を比較してみましょう:

観点 バックエンドDB セッション フロントエンドDB
保存場所 サーバー上のデータベース 主にサーバー側(メモリ/DB) クライアント側(ブラウザ)
識別方法 ユーザーIDやキーで識別 セッションIDで識別(Cookie) JavaScript APIで直接アクセス
永続性 長期(削除するまで) 短期~中期(セッションタイムアウトまで) 技術により異なる(一時的~永続的)
容量 大容量(GB~TB) 中程度(設定による) 小~中容量(数MB~数十MB)
アクセス速度 ネットワーク通信が必要 ネットワーク通信が必要 即時アクセス(ローカル)
セキュリティ 高(適切に設定した場合) 中~高(実装による) 低~中(ユーザー端末に保存)
共有性 複数ユーザー/デバイス間で共有可能 単一ユーザーのセッション内 単一デバイス内のみ
オフライン対応 × ×
ページ間の保持 ○(技術による)
ブラウザ終了後の保持 ×(通常) 技術による
更新と同期 一元管理で更新が容易 サーバー側で制御 デバイス間同期に追加の仕組みが必要

それぞれについて詳しく見ていきましょう。

バックエンドデータベース:サーバー側の堅牢なデータ管理

バックエンドデータベースは、アプリケーションの中核となるデータを長期的に保存するための信頼性の高い選択肢です。

Python言語におけるバックエンドデータベースの種類

データベースアクセス技術 種類 概要 GUI確認ツール
標準ライブラリ SQLite 軽量で設定不要のデータベース DB Browser for SQLite, DBeaver
フレームワーク提供 PyMySQL MySQLに接続するライブラリ MySQL Workbench, DBeaver
フレームワーク提供 psycopg2 PostgreSQLに接続するライブラリ pgAdmin, DBeaver
フレームワーク提供 MongoDB NoSQLデータベース接続 MongoDB Compass
特化ライブラリ SQLAlchemy 多機能ORM、複数DBに対応 DBeaver

ORMの利点

Pythonでのデータベース操作では、SQLAlchemyのようなORMを活用することで開発効率が大幅に向上します。ORMは「Object-Relational Mapping」の略で、オブジェクト指向プログラミング言語とリレーショナルデータベース間のデータ変換技術です。

ORMの主なメリット

  1. オブジェクト指向とデータベースの橋渡し:プログラムのクラスとデータベースのテーブルを対応付け
  2. SQL記述の省略:Pythonコードでデータベース操作が可能
  3. データベース抽象化:異なるデータベース間の切り替えが容易

SQLAlchemyを使ったモデル定義と操作例

from sqlalchemy import create_engine, Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, sessionmaker
from datetime import datetime

# ベースモデルの作成
Base = declarative_base()

# ユーザーモデル
class User(Base):
    __tablename__ = 'users'
    
    id = Column(Integer, primary_key=True)
    username = Column(String(50), unique=True, nullable=False)
    email = Column(String(100), unique=True, nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)
    
    # リレーションシップ
    posts = relationship("Post", back_populates="author")
    
    def __repr__(self):
        return f"<User(username='{self.username}', email='{self.email}')>"

# 投稿モデル
class Post(Base):
    __tablename__ = 'posts'
    
    id = Column(Integer, primary_key=True)
    title = Column(String(100), nullable=False)
    content = Column(String(1000))
    created_at = Column(DateTime, default=datetime.utcnow)
    user_id = Column(Integer, ForeignKey('users.id'))
    
    # リレーションシップ
    author = relationship("User", back_populates="posts")
    
    def __repr__(self):
        return f"<Post(title='{self.title}')>"

# データベース接続(SQLite)
engine = create_engine('sqlite:///blog.db')
Base.metadata.create_all(engine)

# セッション作成
Session = sessionmaker(bind=engine)
session = Session()

# データ作成例
def create_user_and_post():
    # 新規ユーザー作成
    new_user = User(username='yamada_taro', email='yamada@example.com')
    session.add(new_user)
    session.commit()
    
    # ユーザーの投稿を作成
    new_post = Post(
        title='はじめての投稿',
        content='SQLAlchemyを使ったORMの例です。',
        user_id=new_user.id
    )
    session.add(new_post)
    session.commit()
    
    return new_user, new_post

# データ検索例
def find_user_posts(username):
    user = session.query(User).filter(User.username == username).first()
    if user:
        print(f"ユーザー: {user.username}")
        for post in user.posts:
            print(f"- {post.title}: {post.content[:30]}...")
    else:
        print(f"ユーザー '{username}' は見つかりませんでした")

このコード例では、ユーザーと投稿のリレーションシップを持ったモデルを定義し、SQLAlchemyを使ってデータベース操作を行っています。SQLの知識がなくてもPythonオブジェクトとして直感的にデータ操作ができる点がORMの大きなメリットです。

データベース接続の切り替え

SQLAlchemyの強力な点は、データベースを変更する場合でも、モデル定義などのコードをほとんど変更せず、接続設定だけを変更するだけで済む点です。

# SQLite(開発環境)
engine = create_engine('sqlite:///app.db')

# PostgreSQL(本番環境)
engine = create_engine('postgresql://username:password@localhost/app')

# MySQL(本番環境の別選択肢)
engine = create_engine('mysql+pymysql://username:password@localhost/app')

セッション:一時的なユーザー状態管理

セッションは、ユーザーの現在の状態や短期的なデータを保持するのに最適です。主にユーザー認証状態や複数ステップにわたるプロセスの一時データの保存に使用されます。

セッション管理の例(Flask)

from flask import Flask, session, redirect, url_for, request, render_template
from datetime import timedelta

app = Flask(__name__)
app.secret_key = 'your_secret_key'  # 実際の運用では環境変数などから安全な値を設定
app.permanent_session_lifetime = timedelta(minutes=30)  # セッションの有効期限

@app.route('/')
def home():
    if 'username' in session:
        return f'こんにちは、{session["username"]}さん! <a href="/logout">ログアウト</a>'
    return 'ログインしていません <a href="/login">ログイン</a>'

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        session.permanent = True  # セッションを永続化
        session['username'] = request.form['username']
        # 実際のアプリでは認証処理を行う
        return redirect(url_for('home'))
    return '''
        <form method="post">
            <p>ユーザー名: <input type="text" name="username"></p>
            <p><input type="submit" value="ログイン"></p>
        </form>
    '''

@app.route('/logout')
def logout():
    session.pop('username', None)  # ユーザー名をセッションから削除
    return redirect(url_for('home'))

# 買い物カートの例
@app.route('/cart/add/<int:product_id>')
def add_to_cart(product_id):
    if 'cart' not in session:
        session['cart'] = []
    
    # カートに商品を追加
    cart = session['cart']
    cart.append(product_id)
    session['cart'] = cart  # セッションを更新
    
    return f'商品 {product_id} をカートに追加しました'

@app.route('/cart')
def view_cart():
    if 'cart' not in session or not session['cart']:
        return 'カートは空です'
    
    cart_items = session['cart']
    # 実際のアプリではDBから商品情報を取得
    return f'カート内の商品: {cart_items}'

if __name__ == '__main__':
    app.run(debug=True)

このFlaskの例では、ユーザーのログイン状態と買い物カートの情報をセッションに保存しています。これらの情報はユーザーのブラウザセッション中のみ保持され、ブラウザを閉じるかセッションの有効期限が切れると消去されます。

Djangoでのセッション管理

# settings.py
SESSION_ENGINE = 'django.contrib.sessions.backends.db'  # データベースバックエンド
SESSION_COOKIE_AGE = 1800  # セッションクッキーの有効期間(秒)

# views.py
def set_session(request):
    request.session['user_preference'] = 'dark_mode'
    request.session['items_per_page'] = 25
    return HttpResponse("セッションに設定を保存しました")

def get_session(request):
    user_pref = request.session.get('user_preference', 'light_mode')
    items = request.session.get('items_per_page', 10)
    return HttpResponse(f"現在の設定: テーマ={user_pref}, 表示件数={items}")

FastAPIとJWTを使用したモダンなセッション管理

最近のWeb開発では、FastAPIのようなモダンなフレームワークとJWT(JSON Web Token)を組み合わせたセッション管理も人気があります。

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from datetime import datetime, timedelta
from pydantic import BaseModel

# JWT設定
SECRET_KEY = "your-secret-key"  # 実際の運用では環境変数から取得
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

# パスワードのハッシュ化
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

app = FastAPI()

# トークン取得のエンドポイント
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

# データモデル
class Token(BaseModel):
    access_token: str
    token_type: str

class User(BaseModel):
    username: str
    email: str = None
    disabled: bool = False

# ユーザーデータ(実際はデータベースから取得)
fake_users_db = {
    "testuser": {
        "username": "testuser",
        "email": "test@example.com",
        "hashed_password": pwd_context.hash("password123"),
        "disabled": False,
    }
}

# JWTトークン生成
def create_access_token(data: dict, expires_delta: timedelta = None):
    to_encode = data.copy()
    expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

# ユーザー認証
def authenticate_user(username: str, password: str):
    user = fake_users_db.get(username)
    if not user:
        return False
    if not pwd_context.verify(password, user["hashed_password"]):
        return False
    return user

# トークンからユーザー取得
async def get_current_user(token: str = Depends(oauth2_scheme)):
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="認証情報が無効です",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception
    
    user = fake_users_db.get(username)
    if user is None:
        raise credentials_exception
    return user

# ログインエンドポイント
@app.post("/token", response_model=Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
    user = authenticate_user(form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="ユーザー名またはパスワードが正しくありません",
            headers={"WWW-Authenticate": "Bearer"},
        )
    
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user["username"]}, expires_delta=access_token_expires
    )
    return {"access_token": access_token, "token_type": "bearer"}

# 保護されたエンドポイント
@app.get("/users/me", response_model=User)
async def read_users_me(current_user = Depends(get_current_user)):
    return current_user

このFastAPIの例では、JWTトークンを使用してユーザーセッションを管理しています。クライアント側(例えばNext.js)では、このトークンをlocalStorageやhttpOnlyクッキーに保存し、APIリクエスト時に認証ヘッダーとして送信します。

フロントエンドデータ保存:クライアント側のローカルデータ管理

フロントエンドでのデータ保存は、オフライン対応やレスポンス向上に役立ちます。JavaScript APIを使用してブラウザ内にデータを保存します。

フロントエンドデータ保存技術の種類

データ保存技術 概要 GUI確認ツール
ブラウザのメモリ (JavaScript変数) アプリケーション実行中のみデータを保持 ブラウザのDevTools Consoleパネル
localStorage/sessionStorage キーと値のペアでデータを保存 DevTools Applicationパネル
IndexedDB ブラウザ内の本格的なNoSQLデータベース DevTools Applicationパネル
Cookies 小さなデータをブラウザに保存 DevTools Applicationパネル
Cache API Service Workerと組み合わせてリソースをキャッシュ DevTools Applicationパネル

フロントエンドデータ保存の例(JavaScript)

localStorage / sessionStorage

// ユーザー設定の保存(localStorage - ブラウザを閉じても保持)
function saveUserPreferences() {
  localStorage.setItem('theme', 'dark');
  localStorage.setItem('fontSize', '16px');
  localStorage.setItem('language', 'ja');
  console.log('設定を保存しました');
}

// 設定の読み込み
function loadUserPreferences() {
  const theme = localStorage.getItem('theme') || 'light';
  const fontSize = localStorage.getItem('fontSize') || '14px';
  const language = localStorage.getItem('language') || 'en';
  
  console.log(`ユーザー設定: テーマ=${theme}, フォント=${fontSize}, 言語=${language}`);
  
  // 設定を適用
  document.body.className = theme;
  document.body.style.fontSize = fontSize;
  
  return { theme, fontSize, language };
}

// sessionStorage - ブラウザのタブを閉じると消去
function saveFormDraft() {
  const title = document.getElementById('title').value;
  const content = document.getElementById('content').value;
  
  sessionStorage.setItem('draftTitle', title);
  sessionStorage.setItem('draftContent', content);
  sessionStorage.setItem('lastSaved', new Date().toISOString());
  
  console.log('下書きを保存しました');
}

// 下書きの復元
function loadFormDraft() {
  const title = sessionStorage.getItem('draftTitle');
  const content = sessionStorage.getItem('draftContent');
  const lastSaved = sessionStorage.getItem('lastSaved');
  
  if (title || content) {
    document.getElementById('title').value = title || '';
    document.getElementById('content').value = content || '';
    console.log(`下書きを復元しました (最終保存: ${lastSaved})`);
  }
}

IndexedDB(より複雑なデータ保存)

// IndexedDBを使った本格的なクライアントサイドデータベース
class NotesDB {
  constructor() {
    this.dbPromise = this.initDB();
  }
  
  // データベース初期化
  initDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open('NotesDatabase', 1);
      
      request.onerror = event => {
        console.error('IndexedDB エラー:', event.target.error);
        reject('データベースを開けませんでした');
      };
      
      request.onsuccess = event => {
        resolve(event.target.result);
      };
      
      request.onupgradeneeded = event => {
        const db = event.target.result;
        
        // オブジェクトストア(テーブル)の作成
        if (!db.objectStoreNames.contains('notes')) {
          const noteStore = db.createObjectStore('notes', { keyPath: 'id', autoIncrement: true });
          noteStore.createIndex('title', 'title', { unique: false });
          noteStore.createIndex('createdAt', 'createdAt', { unique: false });
          
          console.log('Notes オブジェクトストアを作成しました');
        }
      };
    });
  }
  
  // ノートの追加
  async addNote(note) {
    try {
      const db = await this.dbPromise;
      const tx = db.transaction('notes', 'readwrite');
      const store = tx.objectStore('notes');
      
      // タイムスタンプを追加
      note.createdAt = new Date().toISOString();
      note.updatedAt = note.createdAt;
      
      const result = await new Promise((resolve, reject) => {
        const request = store.add(note);
        request.onsuccess = () => resolve(request.result);
        request.onerror = () => reject(request.error);
      });
      
      await new Promise((resolve) => {
        tx.oncomplete = resolve;
      });
      
      console.log('ノートを追加しました, ID:', result);
      return result;
    } catch (error) {
      console.error('ノート追加エラー:', error);
      throw error;
    }
  }
  
  // すべてのノートを取得
  async getAllNotes() {
    try {
      const db = await this.dbPromise;
      const tx = db.transaction('notes', 'readonly');
      const store = tx.objectStore('notes');
      
      const notes = await new Promise((resolve, reject) => {
        const request = store.getAll();
        request.onsuccess = () => resolve(request.result);
        request.onerror = () => reject(request.error);
      });
      
      return notes;
    } catch (error) {
      console.error('ノート取得エラー:', error);
      throw error;
    }
  }
  
  // ノートの更新
  async updateNote(id, updatedData) {
    try {
      const db = await this.dbPromise;
      const tx = db.transaction('notes', 'readwrite');
      const store = tx.objectStore('notes');
      
      // 既存のノートを取得
      const note = await new Promise((resolve, reject) => {
        const request = store.get(id);
        request.onsuccess = () => resolve(request.result);
        request.onerror = () => reject(request.error);
      });
      
      if (!note) {
        throw new Error(`ID ${id} のノートが見つかりません`);
      }
      
      // データを更新
      const updatedNote = { ...note, ...updatedData, updatedAt: new Date().toISOString() };
      
      await new Promise((resolve, reject) => {
        const request = store.put(updatedNote);
        request.onsuccess = resolve;
        request.onerror = () => reject(request.error);
      });
      
      console.log(`ID ${id} のノートを更新しました`);
      return true;
    } catch (error) {
      console.error('ノート更新エラー:', error);
      throw error;
    }
  }
  
  // ノートの削除
  async deleteNote(id) {
    try {
      const db = await this.dbPromise;
      const tx = db.transaction('notes', 'readwrite');
      const store = tx.objectStore('notes');
      
      await new Promise((resolve, reject) => {
        const request = store.delete(id);
        request.onsuccess = resolve;
        request.onerror = () => reject(request.error);
      });
      
      console.log(`ID ${id} のノートを削除しました`);
      return true;
    } catch (error) {
      console.error('ノート削除エラー:', error);
      throw error;
    }
  }
}

// 使用例
async function notesExample() {
  const notesDB = new NotesDB();
  
  // ノートの追加
  const noteId = await notesDB.addNote({
    title: 'IndexedDBメモ',
    content: 'ブラウザ内のデータベースを使うと、オフラインでもデータを保存できます。'
  });
  
  // すべてのノートを取得
  const allNotes = await notesDB.getAllNotes();
  console.log('保存されたノート:', allNotes);
  
  // ノートの更新
  await notesDB.updateNote(noteId, {
    title: '更新したメモタイトル',
    content: '内容を更新しました。IndexedDBは強力です!'
  });
  
  // 更新後のノートを確認
  const updatedNotes = await notesDB.getAllNotes();
  console.log('更新後のノート:', updatedNotes);
}

Next.jsとTypeScriptでのフロントエンド状態管理

最近のフロントエンド開発では、Next.jsとTypeScriptを使った型安全な実装が主流になっています。以下は、JWTトークンとテーマ設定をlocalStorageで管理する例です。

// types/auth.ts
export interface User {
  id: number;
  username: string;
  email: string;
}

export interface AuthState {
  token: string | null;
  user: User | null;
  isAuthenticated: boolean;
}

// hooks/useAuth.ts
import { useState, useEffect } from 'react';
import { AuthState, User } from '../types/auth';

export const useAuth = () => {
  const [authState, setAuthState] = useState<AuthState>({
    token: null,
    user: null,
    isAuthenticated: false
  });

  // 初期化時にlocalStorageからトークンを読み込む
  useEffect(() => {
    const token = localStorage.getItem('auth_token');
    const userJson = localStorage.getItem('auth_user');
    
    if (token && userJson) {
      try {
        const user = JSON.parse(userJson) as User;
        setAuthState({
          token,
          user,
          isAuthenticated: true
        });
      } catch (error) {
        console.error('ユーザー情報の解析に失敗しました:', error);
        // 無効なデータを削除
        localStorage.removeItem('auth_token');
        localStorage.removeItem('auth_user');
      }
    }
  }, []);

  // ログイン処理
  const login = async (username: string, password: string) => {
    try {
      // FastAPIのエンドポイントにリクエスト
      const response = await fetch('http://localhost:8000/token', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: new URLSearchParams({
          username,
          password,
        }),
      });

      if (!response.ok) {
        throw new Error('認証に失敗しました');
      }

      const data = await response.json();
      
      // ユーザー情報を取得
      const userResponse = await fetch('http://localhost:8000/users/me', {
        headers: {
          'Authorization': `Bearer ${data.access_token}`
        }
      });
      
      if (!userResponse.ok) {
        throw new Error('ユーザー情報の取得に失敗しました');
      }
      
      const userData = await userResponse.json();
      
      // localStorageに保存
      localStorage.setItem('auth_token', data.access_token);
      localStorage.setItem('auth_user', JSON.stringify(userData));
      
      // 状態を更新
      setAuthState({
        token: data.access_token,
        user: userData,
        isAuthenticated: true
      });
      
      return true;
    } catch (error) {
      console.error('ログインエラー:', error);
      return false;
    }
  };

  // ログアウト処理
  const logout = () => {
    localStorage.removeItem('auth_token');
    localStorage.removeItem('auth_user');
    
    setAuthState({
      token: null,
      user: null,
      isAuthenticated: false
    });
  };

  return {
    ...authState,
    login,
    logout

メモアプリを使用したデータベースアクセス技術の理解

シンプルメモアプリ:概要

このアプリは、ユーザー認証機能付きのオンラインメモ帳です。Next.js(TypeScript)とFastAPIを組み合わせた現代的なWebアプリケーションで、データの保存方法としてサーバーサイドデータベース、セッション管理、クライアントサイドストレージの3つの技術を実際に体験できます。

主な機能

  • ユーザー登録・ログイン: 個人アカウントの作成とログイン
  • メモの作成・閲覧・削除: シンプルなテキストメモの管理
  • ダークモード/ライトモード: 好みの表示モードを選択(自動保存)

アプリを触って得られる経験

  1. 現代的なWeb認証の仕組み体験

    • JWTトークンによるセッション管理を実際に体験
    • ログインすることでAPIリクエストがどう変わるかを理解
  2. データ保存技術の使い分け

    • 重要なデータ(メモの内容)はサーバーのデータベースに
    • ユーザー設定(ダークモード)はブラウザのlocalStorageに
    • これらが連携する仕組みを体験
  3. シングルページアプリケーションの操作感

    • ページ遷移がスムーズで、アプリのような使用感
    • ページ更新なしでのデータ更新の仕組み
  4. TypeScriptによる型安全なコードの利点

    • 開発者として、コードを眺めることで型システムのメリットを理解

このアプリは比較的シンプルでありながら、Webアプリケーションの基本的な要素のほとんどを網羅しています。実際に触りながらコードを見ることで、現代的なWebアプリ開発の基礎を効率よく学ぶことができるでしょう。

アカウント登録画面

ここではセッション管理する前のJWTトークンの発行を体験してもらいます。

実際に発行後はハッシュがバックエンドのuserデータベースに保存されていることがわかります。

またログイン後の画面では

  • メモの保存はSQLite
  • ダークモード、ライトモードの切り替えはlocalstorage

でそれぞれ保存されていることを確認できます。

こちらから実際に触れますので、ぜひ体験していただいて感想を教えていただければ幸いです!
https://github.com/fumifumi0831/simple-memo-app

最後に

最後までご覧いただきましてありがとうございました!

2

Discussion

ログインするとコメントできます