🐷

日付管理はバックエンドで行うべき理由

に公開

はじめに

この記事では、created_atupdated_at のような日時カラムをどこで生成すべきかを整理します。

結論から言うと、日時はバックエンド(またはDB)で生成するのが基本です。
フロントエンドから日時を受け取る設計は、一見シンプルに見えますが、実務でいくつかの障害を引き起こしやすくなります。

フロントから日時を受け取ると何が起きるか

タイムゾーンのズレが起きやすい

フロントエンドはブラウザやアプリが動作する端末の時計を使います。
日本国内のユーザーだけを想定している場合でも、端末の時計設定が正しいとは限りません。
タイムゾーンが異なる環境から利用されると、意図しない時刻がDBに入ります。

具体例として、次のような状況が起きます。

  • 日本(JST: UTC+9)のユーザーが 2026-03-20 10:00:00 をタイムゾーン情報なしで送信する
  • バックエンドがタイムゾーンを指定せずにDBに保存する
  • 別のタイムゾーン(UTC)のサーバーから参照すると 2026-03-20 01:00:00 として扱われる

「日付を送ってもらっているのに記録がずれる」という問題は、フロント起点の日時とタイムゾーン情報なしの受け渡しが組み合わさることで起きやすくなります。

日時の改ざんが可能になる

フロントから受け取る値は、開発者ツールやプロキシで書き換えられます。
created_at をフロントに委ねると、ユーザーが任意の日時でレコードを作成できる状態になります。

ログや監査に使う日時は特に危険です。
「いつ操作が行われたか」という事実を、操作した当人が決められる設計になってしまいます。

並行リクエストで順序が保証されない

2つのリクエストがほぼ同時に届いたとき、フロントが生成した日時が同一になることがあります。
サーバー側で生成すればマイクロ秒単位でも差が出ますが、フロント側の Date.now() は精度に限界があります。
また、ネットワーク遅延でリクエストの到着順とフロントが付けた日時の順序が逆転することもあります。

バックエンドで生成する方法

DBに任せる(最も確実)

created_atupdated_at はDBのデフォルト値に任せるのが最もシンプルで確実です。

CREATE TABLE orders (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    user_id    BIGINT UNSIGNED NOT NULL,
    status     VARCHAR(20) NOT NULL DEFAULT 'pending',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

DEFAULT CURRENT_TIMESTAMP を使うと、INSERTのたびにDBサーバーの時刻が自動で入ります。
ON UPDATE CURRENT_TIMESTAMP を使うと、UPDATEのたびに更新日時が自動で書き換わります。
アプリ側のコードに NOW()time.Now() を書く必要がなく、漏れが発生しません。

アプリ側で生成する場合

DBのデフォルト値が使えない事情がある場合や、アプリ側でより細かい制御が必要な場合は、バックエンドのコードで生成します。

// バックエンドで現在時刻を生成してDBに渡す
now := time.Now().UTC()
order := &Order{
    UserID:    userID,
    Status:    "pending",
    CreatedAt: now,
    UpdatedAt: now,
}
repo.Save(ctx, order)

このとき、time.Now().UTC() でUTCに統一するのが重要です。
ローカルタイムのまま保存すると、サーバーのタイムゾーン設定に依存したデータになります。

タイムゾーンの扱い方

DBに保存する日時は原則UTCで統一します。

表示はアプリケーション側で行うため、DBには「事実として何時に起きたか」をUTCで記録しておき、必要なタイムゾーンへの変換はフロントまたはAPIレスポンス時に行います。

// DBから取得した UTC 時刻を JST に変換して返す例
jst, _ := time.LoadLocation("Asia/Tokyo")
displayTime := record.CreatedAt.In(jst)

MySQLでは DATETIMETIMESTAMP でタイムゾーンの扱いが異なります。

ここでいう time_zone は、MySQL の接続セッションが「どのタイムゾーンで日時を解釈・表示するか」を表す設定です。
同じDBでも、接続ごとに +09:00+00:00 など異なる値を持てます。

タイムゾーンの扱い
DATETIME タイムゾーン情報を持たない。保存した値をそのまま返す
TIMESTAMP UTCで保存し、参照時にセッションのタイムゾーンに変換する

TIMESTAMP は保存・取得のたびに接続セッションの time_zone 設定に従って変換されます。
セッションのタイムゾーンが環境によって異なると、同じレコードでも参照値が変わります。
例えば、セッションの time_zone+09:00 の接続で 2026-03-20 10:00:00TIMESTAMP に保存すると、内部的にはUTC相当の値に変換されます。
その後、time_zone = '+00:00' の接続で同じレコードを読むと 2026-03-20 01:00:00 として返ります。
逆に、保存時と取得時で同じ time_zone にそろえていれば、アプリケーションからは同じ日時に見えます。
便利な仕組みですが、接続ごとの設定がぶれる環境では挙動を追いにくくなります。
そのため、接続ごとのタイムゾーンを意識したくない場合は DATETIME を使ってアプリ側でUTC統一する方が扱いやすいです。
一方で、TIMESTAMP も挙動を理解してセッションの time_zone を統一できるなら選択肢になります。

フロントから日時を受け取ってよいケース

すべての日時をバックエンドで生成するわけではありません。
フロントから受け取ることが適切なケースもあります。

  • ユーザーが明示的に指定する日時(予約開始時刻、締切日時など)
  • 過去の日時を入力するフォーム(イベント開催日時など)

これらは「サーバーが今何時か」ではなく「ユーザーが何時と指定したか」が重要な値です。
時刻を含む値を受け取る場合は、RFC3339形式(2026-03-20T10:00:00Z)で統一するとタイムゾーンの曖昧さを防げます。

なお、生年月日のように時刻を含まない日付は YYYY-MM-DDDATE 型)で扱います。
日付のみの値まで無理に日時として扱うと、変換時に日付がずれる原因になります。
日付だけの値と日時の値は、APIでも明確に分けて扱う方が安全です。

まとめ

created_atupdated_at のような「システムが記録する日時」は、バックエンドまたはDBで生成するのが基本です。

  • フロント起点の日時はタイムゾーンのズレ・改ざん・順序逆転のリスクがある
  • DBの DEFAULT CURRENT_TIMESTAMP に任せるのが最もシンプルで確実
  • アプリで生成する場合は UTC で統一する
  • MySQLでは DATETIMETIMESTAMP の違いを理解し、方針をそろえる
  • ユーザーが指定する日時(予約日時など)はフロントから受け取ってよい。時刻を含む場合はRFC3339形式で統一する
  • 生年月日など時刻を含まない日付は YYYY-MM-DDDATE 型)で扱い、日時とは分ける

Discussion