レビューを前提から外す流れ自体は既に語られており、読み手によっては目新しい主張ではないことを予め断ります。
結論(超ざっくり)
生成AIの成果物に対するレビューは、品質の保証ではなく、速度のリミッターとして働いていた。
以降の文章では、結論に至るまでの過程を、プロジェクトを振り返りながら示していく。
作ったもの
Java(Spring)による、マルチテナント対応の認証認可 API。画面は作らず、SwaggerUI から API を叩くのみとした。
構成は DDD レイヤード(presentation → application → domain → infrastructure)。
その他の情報は以下の通り。
| 項目 | 値 |
|---|---|
| 期間 | 約7週 |
| コミット数 | 73 |
| エンドポイント | 5本(POST /auth/login /auth/refresh /auth/logout、GET /users/me /admin/users) |
| テーブル | 3(tenants / users / refresh_tokens) |
| 本体コード | 70ファイル / 2,910行 |
| テストコード | 35ファイル / 4,573行 |
| 分類 | 使用したもの |
|---|---|
| 言語 / FW | Java 21、Spring Boot 3.5.14 |
| フロント | SwaggerUI |
| DB | PostgreSQL、Docker、DBeaver |
| テスト | JUnit、GitHub Actions、JaCoCo、ArchUnit |
| エディタ | IntelliJ IDEA |
| ビルド | Maven |
| AI | ClaudeCode |
目標
当プロジェクトで目標にしたことは、以下のとおりである。
- 個人開発にて認証認可ロジックを実装することで、基盤技術への理解を深める。
- 今まで業務で扱わなかった技術を実践する。
手段
認証は、アクセストークンとリフレッシュトークンを用いて行った。
アクセストークンはJWTにて実装し、リフレッシュトークンはDBにSHA-256 ハッシュの保存にて実装。
認可は、DBのusersテーブルにroleのカラムを持たせて行った。
Java部分はSecurityConfig.javaにて、hasAuthorityを用いて実装。
新規で触れた技術については、以下の通りである。
| 技術 | 新規の度合い |
|---|---|
| SwaggerUI | 完全新規 |
| Docker | 完全新規 |
| DBeaver | 他 DBクライアント 経験あり |
| GitHub Actions | 完全新規、CIのみで利用 |
| JaCoCo | 完全新規 |
| ArchUnit | 完全新規 |
| IntelliJ IDEA | 他 IDE 経験あり |
| ClaudeCode | 他 チャット型/エージェント型 経験あり、指示ファイルによる運用は新規 |
トラブルと対処
tenant_idを持たせたテーブルに発行するSQLを常に注意する必要があった
各テーブルに tenant_id を持たせてマルチテナント機能を実現し、検索キーがuniqueになるSQLは tenant_id を条件に含めていなかった。 (例: findById(userId))
今回の規模では注意すればキーがuniqueなのかを判別できた。
しかし、規模が大きくなると判別が難しくなることは容易に想像がつく。
まずは規則として、検索条件に一律 tenant_id での絞り込みを付けることにして、さらに規模が大きくなればRow Level Securityなどの機能を選択肢とする。
presentation層からrepository層までまとめて作成したコードを、全てrevertした
ある機能についてフロントからバックまでclaude codeで一度に作成した結果、筆者のスキルではレビューしきれず、revertして層ごとに作り直した。
生成AIで作成したコードをレビューするためには、ユーザーが書けるレベルのコードである必要があった。
そこで、ユーザーが書けるレベルのコードに抑制するために、各層ごとの実装になるようにclaude codeに制限を設けた。
結果として、DDDレイヤードと上手くハマり、今回のプロジェクトでは事故は起きなかった(意図せず実装を書き換えられた事象や、そもそも意図と違う実装が行われてrevertすること)。
しかし、理解すべきコードはユーザーが咀嚼する速度よりもはるかに早く生成され、負荷は減らなかった。
init.sql の変更が、docker compose down -vを実行しても反映されなかった
以下のようにバインドマウントしていたため、down -v をしてもプロジェクトルートに postgres_data/ が残り続け、init.sql を再度走らせるためには、sudo rm -rf ./postgres_data を実行する必要があった。
services:
postgres-db:
volumes:
- ./postgres_data:/var/lib/postgresql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
以下のように名前付きボリュームにすることで down -v の実行で init.sql を再度走らせることができるようになる。
services:
postgres-db:
volumes:
- postgres_data:/var/lib/postgresql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
volumes:
postgres_data:
DB のデータのように人間が直接触らないものは名前付きボリュームに置き、
ホストに置くのはソースコードや init.sql のような編集対象に限る、という切り分けを次回以降の基準とする。
/var/lib/postgresql を用いてマウントするのは18以降です。
17以前は /var/lib/postgresql/data を用います。
実装メモ
認証まわりの覚え書き
パスワードのハッシュ値は BCrypt、リフレッシュトークンのハッシュ値はSHA-256を用いて生成した。
パスワードはユーザーの考える文字列であり、特定の値に偏るため、ハッシュ値の導出に計算負荷を強いる必要があった。
リフレッシュトークンはランダムな文字列として生成するため、それをもとに作成するハッシュ値の導出に計算負荷を強いる必要はない。
@Override
public RawRefreshToken generate() {
// TOKEN_BYTE_LENGTH = 32(32 バイト = 256bit)
byte[] bytes = new byte[TOKEN_BYTE_LENGTH];
// 256bit 分のランダムなバイト列を生成する
secureRandom.nextBytes(bytes);
// バイト列を Base64でエンコードし、43 文字の文字列にする
return new RawRefreshToken(
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes));
}
認可まわりの覚え書き
一言に認可ロジックと言っても代表的には以下の3つの層で実装でき、それぞれ役割が異なる。
SecurityConfig.javaでの実装
URLで表現できる範囲(認証要否・領域単位のロール)は SecurityConfigで行う。
.requestMatchers("/admin/**").hasAuthority("ADMIN")
メソッドに対して@PreAuthorizeを付与する実装
同じURL・同じリソースで、操作ごとに権限が違う場合、 @PreAuthorizeで行う。
@PreAuthorize("hasAuthority('ADMIN')")
SQLのWHERE句での実装
レコードの絞り込み(誰のデータが見えるか)は SQLの条件で行う。
所有者やテナント、組織階層や属性の一致、公開状態との組み合わせや期間といったケースが存在する。
WHERE user_id = :userId AND tenant_id = :tenantId
WHERE dept_path LIKE :myDeptPath || '/%' AND region IN (:assignedRegions)
WHERE status = 'PUBLISHED' AND :today BETWEEN valid_from AND valid_to
結果
基盤技術については、選択肢に対する前提条件の違いを理解することができた。
未経験技術の導入については、トラブルシューティングを通して、いわゆるテンプレの書き方の意味を理解することができた。
特に、それぞれ文章だけでは"そうなんだ"で終わりがちな部分を経験できたのが大きい。
結論:全件レビューという前提を捨てる
一方、生成AIの出力をコードレビューしようとすると、以下の問題が構造的に発生する。
- ユーザーの技術力を超えうる — 実力以上のタスクが一見できてしまうため、容易に制御が利かなくなる
- 意思決定が揮発する — その場で調べて判断しても、固有の前提条件や比較検討の内容を覚えて再現することはできない
- およそ正しそうである — ほとんど誤りのないものから誤りを探す作業は、認知負荷が高い
- 出力と読み取りが非対称 — 数千文字を数秒で生成できるAIに対し、人間の読解速度には限界がある
今回、層ごとに実装を制限したことで事故は防げた。
しかしそれは、AIの出力を筆者が読み切れる粒度まで刻むことで成立していた。
レビューを機能させる方法が「AIを人間の速度まで落とす」なのだとしたら、
レビューは品質の保証ではなく、速度のリミッターとして働いていたことになる。
事故が起きず、負荷も減らなかったのは、当然の結果だった。
なので次は、構造そのものを差し替えてみようと思う。
テストコードを手で書いた後に、実装を丸投げするという運用を試す。
この運用は、上記の問題のうち3つに答えを持つ。
テストは筆者が書ける範囲でしか書けないため、仕様が自分の理解を超えない。
テストコードはそれ自体が判断の記録として残るため、意思決定が揮発しない。
そして読む量が生成量に比例しなくなるため、非対称性が問題にならない。
残るのは「およそ正しそうである」だが、細かい実装に対するレビューは既にテストで保証されているものとしてしまうことで解決できないかと考えている。
テストの正しさについては、作成したテストについて、生成AIが"いちゃもんを付けられるか"で底上げする。
なお、実装自体の確認項目は、例えばコードの計算量や、認証認可の切り方に絞り、テストが通る実装であればよいものとしたい。
最後に、これらの内容と並行して、Javaの地力を上げるために手で書く量も増やしていきたい。
以降もコードに向き合うのなら、教科書的な知識だけでなく、基礎体力もそれ以上に重要だと強く実感した。