Day26 後半のゴール
「“証拠としてのログ”と“業務で使う履歴”をテーブル設計まで落とせるようになる」
前半で、「監査ログ」と「履歴管理」の目的は整理できました。
後半では、それを実際のテーブル設計・実装パターンに落としていきます。
ここでのゴールは次のイメージです。
監査ログ用のテーブルを、自分で設計できる
履歴テーブル(有効期間・バージョン管理)の典型パターンを理解する
「何でもログる」の危険性と、個人情報をログに書くときの注意点を意識できる
コードを書く前に、「どう設計するか」をしっかり固めます。
監査ログテーブルの設計例
「“誰が・いつ・何をしたか”を一行で表現する」
まずは監査ログ。
目的は「誰が・いつ・どの対象に・何をしたか」を後から追えるようにすることでした。
シンプルな監査ログテーブルの例を考えてみます。
CREATE TABLE audit_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
occurred_at DATETIME NOT NULL,
actor_type VARCHAR(20) NOT NULL, -- 'user' / 'system' / 'admin' など
actor_id BIGINT NULL, -- ユーザーIDなど(匿名操作ならNULL)
ip_address VARCHAR(45) NULL,
action VARCHAR(50) NOT NULL, -- 'login_success' / 'update_user' など
target_type VARCHAR(50) NULL, -- 'user' / 'order' など
target_id BIGINT NULL,
detail JSON NULL -- 追加情報(変更前後など)をJSONで
) ENGINE=InnoDB;
SQLここでのポイントを一つずつかみ砕きます。
occurred_at は「いつ」を表すので必須です。
actor_type と actor_id で「誰が」を表します。
IPアドレスも、特にログイン系では重要な手がかりになります。
action は「何をしたか」を表すラベルです。
target_type と target_id で「どの対象に対して」を表します。
detail は、必要に応じて JSON で追加情報を入れる“拡張枠”です。
この設計にしておくと、例えば次のような行が入ります。
2025-05-10 10:00、actor_type=user, actor_id=123, action=login_success
2025-05-10 10:05、actor_type=admin, actor_id=1, action=update_user, target_type=user, target_id=123
detail に「変更前後の値」を入れるかどうかは、監査ポリシー次第です。
個人情報をそのまま detail に書きすぎると、ログ自体が“危険なデータの塊”になるので、ここは慎重に決めます。
監査ログをいつ・どう書くか
「“DBトリガー任せ”ではなく“アプリの意図として書く”」
監査ログをどこで書くか、という設計も重要です。
大きく分けると、二つのパターンがあります。
アプリケーション側で INSERT する
DBのトリガーで自動的に INSERT する
初心者が「楽そう」と感じるのはトリガーですが、
実務的には「アプリ側で書く」方がコントロールしやすいことが多いです。
理由はこうです。
誰が(ログインユーザーID)はアプリ側が一番よく知っている
どの画面・どのAPIからの操作かもアプリ側で分かる
ビジネスルールに応じて「これは監査対象」「これは対象外」を分けやすい
例えば、ユーザー情報更新APIの中で、
更新が成功したタイミングで監査ログを1行書く、というイメージです。
INSERT INTO audit_logs (
occurred_at, actor_type, actor_id, ip_address,
action, target_type, target_id, detail
) VALUES (
NOW(), 'admin', 1, '203.0.113.10',
'update_user', 'user', 123,
JSON_OBJECT('changed_fields', JSON_ARRAY('email', 'name'))
);
SQLここでは、detail に「どの項目が変わったか」だけを入れています。
実際の値(メールアドレスの具体的な文字列など)を入れるかどうかは、
セキュリティポリシーと相談です。
履歴テーブルの典型パターン(有効期間方式)
「“いつからいつまでこの値だったか”をテーブルで表す」
次に、履歴管理の代表的なパターンを見ます。
ここでは「有効期間方式」を扱います。
例として、「ユーザーのメールアドレスの履歴」を管理したいとします。
CREATE TABLE user_email_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
email VARCHAR(255) NOT NULL,
valid_from DATETIME NOT NULL,
valid_to DATETIME NULL,
CONSTRAINT fk_user_email_history_user
FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB;
SQLこのテーブルの意味はこうです。
1行が「ある期間に有効だったメールアドレス」を表す
valid_from 〜 valid_to の間、その email が有効だった
valid_to が NULL の行が「現在有効なメールアドレス」
例えば、ユーザーID=123のメールアドレスがこう変わったとします。
2024-01-01〜2025-05-10: old@example.com
2025-05-10〜現在: new@example.com
このとき、テーブルには次のような2行が入ります。
user_id=123, email=old@example.com, valid_from=2024-01-01, valid_to=2025-05-10
user_id=123, email=new@example.com, valid_from=2025-05-10, valid_to=NULL
これで、
「2024年12月31日時点のメールアドレスは?」
→ WHERE user_id=123 AND '2024-12-31' BETWEEN valid_from AND COALESCE(valid_to, '9999-12-31')
のようなクエリで取れるようになります。
履歴テーブルをどう更新するか
「“古い行を閉じて、新しい行を開く”という操作をセットで行う」
有効期間方式の履歴テーブルでは、
更新処理が少しだけ手順を踏みます。
ユーザーのメールアドレスを変更するときの流れを考えます。
まず、「今有効な行」を特定します。
SELECT *
FROM user_email_history
WHERE user_id = 123 AND valid_to IS NULL;
SQLその行の valid_to を「今の時刻」に更新します。
UPDATE user_email_history
SET valid_to = NOW()
WHERE user_id = 123 AND valid_to IS NULL;
SQL次に、新しいメールアドレスの行を INSERT します。
INSERT INTO user_email_history (user_id, email, valid_from, valid_to)
VALUES (123, 'new@example.com', NOW(), NULL);
SQLこの二つの操作は、必ずトランザクションでまとめます。
START TRANSACTION;
UPDATE user_email_history
SET valid_to = NOW()
WHERE user_id = 123 AND valid_to IS NULL;
INSERT INTO user_email_history (user_id, email, valid_from, valid_to)
VALUES (123, 'new@example.com', NOW(), NULL);
COMMIT;
SQLこれにより、
「有効期間が重なる行が存在しない」
「常に1つだけ“現在有効な行”がある」
という状態を保てます。
アプリ側から見ると、
「メールアドレスを更新する」という1つの操作の裏で、
履歴テーブルでは2つのSQLが動いている
というイメージです。
バージョン番号方式という考え方
「“何番目のバージョンか”で履歴を追う」
有効期間方式の代わりに、
「バージョン番号」で履歴を管理するパターンもあります。
例えば、ユーザーのプロフィール全体をバージョン管理したい場合。
CREATE TABLE user_profile_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
version INT NOT NULL,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL,
CONSTRAINT fk_user_profile_history_user
FOREIGN KEY (user_id) REFERENCES users(id)
);
SQLここでは、
version=1 が最初の状態
version=2 が2回目の変更後
version=3 が3回目の変更後
というように、「何番目の状態か」を表します。
最新の状態を取りたいときは、
SELECT *
FROM user_profile_history
WHERE user_id = 123
ORDER BY version DESC
LIMIT 1;
SQLのように書きます。
有効期間方式との違いは、
「いつ有効だったか」よりも「何回目の変更か」を重視する
という点です。
どちらを採用するかは、
業務要件(時間軸で見たいか、バージョンとして見たいか)で決めます。
個人情報をログ・履歴に書くときの注意点
「“残すこと自体がリスク”になることを忘れない」
監査ログ・履歴管理は、「残す」ことが目的ですが、
個人情報を残しすぎると、それ自体がリスクになります。
意識しておきたいポイントは次のようなものです。
パスワードの平文を絶対にログ・履歴に書かない
クレジットカード番号などは、原則としてDBにもログにも持たない(トークン化などを使う)
メールアドレス・住所などを監査ログの detail に丸ごと書かない
例えば、監査ログの detail に、
変更前メールアドレス: old@example.com
変更後メールアドレス: new@example.com
といった情報をそのまま書くと、
audit_logs テーブルが「個人情報の塊」になります。
代わりに、
「どの項目が変わったか」だけを記録する
「マスクした値」(例:a***e@example.com)だけを残す
といった工夫も考えられます。
ログ設計は、「何を残すか」と同じくらい
「何をあえて残さないか」も重要です。
Day26 後半のまとめ
監査ログをDBで設計する場合は、audit_logs のようなテーブルに occurred_at(いつ)、actor_type / actor_id(誰が)、action(何をしたか)、target_type / target_id(どの対象に) を1行で表現し、必要に応じて detail をJSONで持たせることで、「ログイン成功/失敗」「重要データの更新」「削除操作」などを後から追える“証拠”を残し、これはDBトリガー任せではなく、アプリ側の意図として「どの操作を監査対象にするか」を決めて INSERT するのが現実的なやり方になる。
一方、履歴管理では、有効期間方式(valid_from〜valid_to で「いつその値が有効だったか」を表す)やバージョン番号方式(version で「何番目の状態か」を表す)といったパターンを使って、「過去の状態を業務で参照・集計できるテーブル」を設計しつつ、パスワードやクレジットカード番号のようなセンシティブ情報はログ・履歴に残さない、メールアドレスや住所も必要最小限にとどめるなど、「残すこと自体がリスクになる」視点を持って、“証拠として十分・でも過剰ではない”バランスを取ることが、実務的なログ設計の肝になっていく。
