設計

公開予約機能のテーブル設計ガイド

記事の公開予約機能を実装する際のデータベース設計パターンを比較し、バッチ処理不要なクエリ時評価型アプローチを解説します。

はじめに

ブログやCMSで「指定した日時に記事を自動公開する」予約投稿機能を実装する際、データベース設計にはいくつかのアプローチがあります。

本記事では、各設計パターンのトレードオフを比較し、バッチ処理不要でシンプルなクエリ時評価型を推奨する理由を解説します。

要件の整理

予約投稿機能では、以下の状態を管理します。

状態説明ユーザーへの表示
下書き編集中非公開
予約中公開日時を設定済み非公開
公開済み公開日時を過ぎた公開

設計パターンの比較

パターン1: バッチ処理型(従来型)

cronやCloud Functionsで定期的にステータスを更新する方式です。

-- テーブル設計
CREATE TABLE articles (
    id UUID PRIMARY KEY,
    status VARCHAR(20) NOT NULL DEFAULT 'draft',  -- draft, scheduled, published
    scheduled_at TIMESTAMPTZ,
    published_at TIMESTAMPTZ
);

-- バッチ処理(1分ごとに実行)
UPDATE articles
SET status = 'published', published_at = NOW()
WHERE status = 'scheduled' AND scheduled_at <= NOW();

メリット

  • ステータスが明示的で直感的
  • クエリがシンプル(WHERE status = 'published'

デメリット

  • バッチ処理のインフラが必要(cron、pg_cron、Cloud Functions等)
  • 公開タイミングがバッチ間隔に依存(1〜5分の遅延)
  • バッチ障害時に公開されない

パターン2: クエリ時評価型(推奨)

バッチ処理を使わず、クエリ実行時に公開判定を行う方式です。

-- テーブル設計
CREATE TABLE articles (
    id UUID PRIMARY KEY,
    is_draft BOOLEAN NOT NULL DEFAULT TRUE,
    scheduled_at TIMESTAMPTZ
);

-- 公開記事の取得(クエリ時に判定)
SELECT * FROM articles
WHERE is_draft = FALSE AND scheduled_at <= NOW();

メリット

  • バッチ処理が不要(インフラがシンプル)
  • 公開タイミングが秒単位で正確
  • 障害ポイントが少ない

デメリット

  • クエリに公開判定ロジックが必要(ビューで解決可能)
  • DBの状態と表示上の状態が異なる
  • ステータス変更イベントが発生しない(後述)

なぜクエリ時評価型を選ぶのか

本質的な問い:「公開済み」は状態か、それとも条件か?

バッチ処理型では「公開済み」をDBに保存された状態として扱います。一方、クエリ時評価型では「公開済み」を scheduled_at <= NOW() という条件として扱います。

予約投稿において「公開済み」は時間経過によって自動的に決まる性質のため、条件として評価する方が自然です。

技術的根拠

この設計パターンは、PostgreSQLの以下の公式機能と同じ思想に基づいています。

機能評価タイミング説明
Viewクエリ時NOW() が参照のたびに評価される
Virtual Generated Columnsクエリ時PostgreSQL 18+で読み取り時に計算
Row Level Securityクエリ時ポリシーが毎クエリ評価される

また、Soft Delete(論理削除)パターンと同じ設計思想です。

// Soft Delete: deletedAt で判定
where: { deletedAt: null }

// 公開予約: scheduled_at で判定
where: { isDraft: false, scheduledAt: { lte: new Date() } }

推奨設計の実装

テーブル定義

CREATE TABLE articles (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    author_id UUID NOT NULL REFERENCES users(id),
    title TEXT NOT NULL,
    slug VARCHAR(255) NOT NULL UNIQUE,
    body TEXT NOT NULL,

    -- 状態管理
    is_draft BOOLEAN NOT NULL DEFAULT TRUE,
    scheduled_at TIMESTAMPTZ,

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- 制約: 公開状態では scheduled_at が必須
    CONSTRAINT chk_published CHECK (
        is_draft = TRUE OR scheduled_at IS NOT NULL
    )
);

-- 公開記事の検索用インデックス
CREATE INDEX idx_articles_published ON articles(scheduled_at DESC)
    WHERE is_draft = FALSE;

ビューで公開判定を隠蔽

CREATE VIEW published_articles AS
SELECT *, scheduled_at AS published_at
FROM articles
WHERE is_draft = FALSE AND scheduled_at <= NOW()
ORDER BY scheduled_at DESC;

アプリケーションは SELECT * FROM published_articles で公開記事を取得できます。ビューにより、公開判定ロジックがアプリケーションコードに散らばることを防ぎます。

基本操作

-- 下書き作成
INSERT INTO articles (author_id, title, slug, body)
VALUES (?, ?, ?, ?);

-- 即時公開
UPDATE articles SET is_draft = FALSE, scheduled_at = NOW()
WHERE id = ?;

-- 予約公開(未来日時を設定)
UPDATE articles SET is_draft = FALSE, scheduled_at = '2024-12-31 10:00:00+09'
WHERE id = ?;

-- 下書きに戻す
UPDATE articles SET is_draft = TRUE, scheduled_at = NULL
WHERE id = ?;

設計パターン比較表

観点バッチ処理型クエリ時評価型
バッチ処理必要不要
公開タイミングバッチ間隔依存秒単位で正確
インフラ複雑度高い低い
障害耐性バッチ障害リスク高い
クエリの複雑さシンプルビューで解決
Webhookトリガー可能不可

アーキテクチャ別の注意点

SSG/ISR を使用する場合

Next.js などで SSG(静的サイト生成)ISR(Incremental Static Regeneration) を使用している場合、クエリ時評価型には注意が必要です。

クエリ時評価型では DB のステータスが変更されないため、Webhook 等で再ビルドをトリガーできません。予約時刻が来ても、次回の再ビルドまたは Revalidation が走るまでサイトに反映されません。

アーキテクチャクエリ時評価型との相性対策
SSR / 動的レンダリング◎ 最適そのまま使用可能
ISR○ 条件付きrevalidate を短く設定(例: 60秒)
SSG△ 要検討定期ビルドの設定、またはバッチ処理型を検討
// Next.js ISR: revalidate を短く設定
export const revalidate = 60; // 60秒ごとに再検証

export default async function ArticlePage() {
  const articles = await getPublishedArticles();
  // ...
}

SSG で厳密な公開タイミングが必要な場合は、バッチ処理型を採用し、ステータス変更時に Webhook で再ビルドをトリガーする方式が適しています。

まとめ

公開予約機能のテーブル設計には、クエリ時評価型を推奨します。

  • 状態管理: is_draft (BOOLEAN) + scheduled_at (TIMESTAMPTZ)
  • 公開判定: is_draft = FALSE AND scheduled_at <= NOW()
  • バッチ処理: 不要
  • ビュー: 公開判定ロジックを隠蔽
  • 適したアーキテクチャ: SSR、ISR(短い revalidate 設定)

「公開済み」を状態ではなく条件として捉えることで、バッチ処理が不要になり、シンプルで堅牢な設計が実現できます。

ただし、SSG で厳密な公開タイミングが求められる場合は、Webhook でビルドをトリガーできるバッチ処理型も検討してください。