🧑‍💻

ミニSNSアプリの開発備忘録その①【MVP仕様】

に公開

0. はじめに

この記事は、個人的な勉強のために作成したWebアプリの開発備忘録です。
開発中に学んだことをまとめて、自分で後で見返すことを主な目的として作成しました。
もちろん、他の方の参考にもなるようまとめていきますので、温かい目で見守ってください:)
※随時更新していきます

取り組んだこと

SNSの最小限の機能(MVP)を実装したWebアプリを設計からテストまで最短実装した。

目的

  • 設計〜テストの通貫した開発経験
  • 各フレームワーク、モジュールの学習・経験
  • 基礎的なロジックの学習・復習
  • 自走して最後まで開発する経験
  • 実装した内容に対して、できるだけ全て説明できるようになる

GitHub

https://github.com/kt-git-1/mvp-mini-sns-app

1. MVP定義

1.1) ゴールと非ゴール

ゴール(MVPで必ず満たす)

  • E2Eで“縦に1本”動く: SignUp / Login / Create Post / Timeline
  • セキュアな認証: JWT(HS256)、Bearer運用&最小権限
  • 効率的な取得: Keysetページング による無限スクロール
  • 安定運用の初期値:エラーハンドリング、構造化ロギング、マイグレーション

非ゴール(以後取り組む予定)

  • 画像/動画アップロード、通知、DM、検索、ブックマーク
  • 複雑な権限モデル、RBACの細分化
  • フロントのデザイン最適化(MVPは機能優先のUI)

1.2) 技術スタック

  • Backend: Spring Boot 3.5 / Java 21
  • Frontend: Next.js(TypeScript)
  • DB: PostgreSQL 16
  • Migration: Flyway
  • Auth: JWT (JJWT 0.12.x, HS256)
  • Build: Gradle Groovy

1.3) アーキテクチャ

①概要

[ Next.js ] --(HTTPS/JSON)--> [ Spring Boot API ] --(JPA)--> [ PostgreSQL ]
   ↑               ↓                                ↑
JWT保存(Cookie or Memory) SecurityFilterChain(Bearer) Flyway

バックエンド(Spring)

  • Controller: RESTエンドポイント定義(認証必須の線引き)
  • Service: ユースケース実装(ビジネスルール、トランザクション)
  • Repository: JPA + ネイティブSQL(Keyset取得)
  • DTO: Controller⇔Service⇔Entityの境界オブジェクト
  • Security: JWTデコード→ AuthUser 変換 → 認可

フロントエンド(Next.js)

[ ブラウザ ]
--> Next.js{App Router -> Middleware -> /app/lib/api.ts(薄いラッパ) -> BFF}
--(HTTPS/JSON)--> [ REST API(Spring) ]
  • ブラウザ
  • App Router
  • Middleware
  • /app/lib/api.ts (薄いラッパ)
  • BFF
  • REST API(Spring)

②全体像

③ユーザージャーニー(サインアップ→ログイン→投稿→タイムライン)

1.4) データベースモデル

テーブル 主なカラム 備考
users id (PK), username UNIQUE, password_hash, created_at TIMESTAMP WITH TIME ZONE usernameは30文字上限
posts id (PK), user_id (FK -> users), content text, created_at TIMESTAMP WITH TIME ZONE 投稿は280文字上限(MVP)
follows follower_id, followee_id, created_at 複合Unique(follower_id,followee_id)

1.5) API設計

概要

認証不要

  • ヘルスチェック
    • GET /health : Healthチェック
    • GET /health/db : データベース接続確認
  • 認証系
    • POST /auth/signup : ユーザー作成
    • POST /auth/login : ログイン、JWT発行(Cookie設定)

認証必須

  • 投稿/タイムライン
    • POST /posts : 新規投稿(認証必須、280文字)
    • GET /timeline?cursor=...&size=20 : 合成タイムライン(自分+フォロー先)
  • プロフィール
    • GET /mypage : マイページ

1.6) 認証 / 認可 / セキュリティ

認可ポリシー

  • 許可(認証不要):/health/**, /auth/**(サインアップ・ログイン・トークン検証系)
  • 要認証:上記以外の全API(投稿作成・タイムライン取得など)

認証方式

  • Bearer JWT(HS256)を完全ステートレスで採用
    • Authorization: Bearer <jwt> を基本
    • MVPではローテーション/リフレッシュ無し(要件が固まってから拡張)
  • クレーム最小構成(MVP)
    • sub(=username)、user_id(長整数)、roles(例: ["USER"])
  • Principal:アプリ独自のAuthUser(id, username, roles)をUsernamePasswordAuthenticationTokenに載せる

セキュリティ

  • Session:STATELESS
  • CORS:ローカルのNext.js(3000)のみ許可、Cookieありを許可(allowCredentials=true
  • CSRF:無効化(Cookie+SameSite=Lax/Strictで守る。APIはBearer前提)
  • パスワードハッシュ: BCrypt(強度はデフォルトOK。将来コスト調整)

1.7) エラー応答 / ロギング

エラー応答(JSON形式)

  • 401 未認証:
{"error":"unauthorized","code":"UNAUTHORIZED","status":401,"requestId":"<rid>"}
  • 403 権限不足:
{"error":"forbidden","code":"FORBIDDEN","status":403,"requestId":"<rid>"}

ロギング

このMVP仕様では、以下の2つの自作フィルタを用意してロギングを行なっている。

  • RequestIdFilter(入口)
    • やること
      1. 受信ヘッダX-Request-Idを読む。無ければUUIDを発行
      2. MDCにrequestIdを入れる(ログ相関のため)
      3. レスポンスにもX-Request-Idを付与(クライアントと照合できる)
    • 効用
      • すべてのアプリログに同じ requestId が入る ⇒ 1本のリクエストを追える
  • AccessLogFilter(出口)
    • やること
      1. 開始時刻を記録 → 下流に流す → 最後に経過msを計算
      2. メソッド/パス/ステータス/処理時間 を 1行ログ で出力
      3. MDCのrequestIdを一緒に出す
    • 効用
      • 「どのリクエストが何msで何ステータスを返したか」が一目で分かる
      • 401/403/500 など例外系も漏れずに記録できる

フィルタの並び:RequestId(入口)→ 認証/アプリ処理 → AccessLog(出口)。
これで相関IDつきのアクセスログが必ず1行出る。

関連用語

UUID:
世界でほぼ一意になるIDの規格。"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"形式の128bit識別子。相関ID(X-Request-Id)に使うと、リクエストをログで追いやすい。

  • 形式:8-4-4-4-12 の16進文字列(例:4d275778-9ecf-4bc3-b8c5-49229a26ace7
  • 種類:よく使うのは v4(乱数ベース)。MVPの相関IDならこれで十分。
  • Javaでの生成(相関ID用の最小例):
String rid = java.util.UUID.randomUUID().toString();
// 例: "4d275778-9ecf-4bc3-b8c5-49229a26ace7"

MDC(Mapped Diagnostic Context):
スレッドごとのキー/値メモ。requestIduserIdを入れておくと、ログ出力時に自動で差し込める仕組み(SLF4J/Logbackの機能)。

  • ログ出力時に自動で差し込める“文脈情報”の入れ物。スレッドローカル(スレッドごと)で保持。
  • 使い方(Logback + SLF4J)
    1. 入れる:MDC.put("requestId", rid)
    2. ログパターンに埋める:[%X{requestId}]
    3. 必ず消す:MDC.clear()(または MDC.putCloseable)

1.8) バリデーション / 例外処理

TBA

1.9) フロントエンド(Next.js)

TBA

1.10) テスト設計

概要

  • 対象スコープ
    • API: /auth/signup, /auth/login, /posts(POST), /timeline(GET?cursor,size)
    • DB: FlywayのV1~(ユーザ・投稿・フォローなどMVP分)
    • フロント: サインアップ/ログインフォーム、投稿フォーム、タイムライン画面
  • 合格基準(Exit Criteria)
    • 主要ハッピーパスがE2Eで通る
    • バリデーションエラーとUnauthorizedが想定通りのJSONで返る
    • Keysetページングが 重複・欠落・順序乱れなし
    • 主要セキュリティ(JWT検証、CORS、XSSエスケープ)が担保
  • 実行環境
    • ローカル:Testcontainers(Postgres) で再現性
    • CI:GitHub Actions(バック/フロント/Playwright)

テスト観点

レイヤ 観点
単体(Unit) DTO Validation, Serviceロジック, Cursor Codec, Repositoryクエリ(Keyset)
結合/統合(Integration) API↔DB(Testcontainers, RestAssured/MockMvc), Flyway Migration
コントラクト(API仕様) レスポンス型/エラー型・ステータスコード・CORS
E2E(ブラウザ) Signup→Login→Post→Timelineスクロール(Playwright)
セキュリティ/回帰 JWT署名/期限、XSS/HTMLエスケープ、CORS許可、SQLi不成立
移行/データ Flyway整合、初期データSeed、ロールバック不可シナリオの確認

1.11) デプロイ最小構成

  • Spring: バックエンドアプリ(ローカル)
  • Next.js: フロントエンドアプリ(ローカル)
  • DB: Docker Compose(postgres + pgadmin)

1.12) ロードマップ

  • 画像アップロード(S3/Cloud Storage)
  • 通知(フォロー/いいね/メンション)
  • 検索(全文検索/インデックス戦略)
  • 外部にデプロイ

2. 工夫

2.1) GitHub運用方針

ここではGitHub flowを採用する。

  1. mainブランチを最新化
  2. featureブランチを作成
    • 機能ごとにわかりやすい名前を付ける(例:feature/login-page)
  3. 開発 & コミット
    • 小さめの単位でコミットして履歴をわかりやすくする
  4. リモートへプッシュ
  5. Pull Requestを作成
    • GitHub上でPRを作成
    • 他の人がレビュー
    • CIでテストが通ることを確認
  6. 問題なければmainにマージ
    • mainブランチがデプロイ可能状態のまま維持される
  7. ブランチ削除
    • mainが更新されたら自動
    • 使い終わったfeatureブランチは削除

2.2) 生成AIの活用法

この開発のほぼ全てにおいてChatGPTを活用した。とにかく使い倒しながら自走した。
とにかく聞きまくるべし。

3. バックエンド実装

3.1) ディレクトリ構成(抜粋)

mvp-mini-sns-app/
├── my-api-docs/
   ├── index.html
   └── openapi.yaml
├── backend/  # Spring Boot ベースのバックエンド
   ├── build.gradle
   ├── docker/  # Docker
   ├── .env
   └── docker-compose.yml
   ├── bin/main/  # ビルド生成物(設定ファイルや DB マイグレーション)
   ├── application.yml
   ├── application-local.yml
   └── db/migration/V1__init.sql
   ├── src/
   ├── main/
   ├── java/com/example/backend/
   ├── BackendApplication.java
   ├── entity/  # Post・Follow・User エンティティ
   ├── repository/  # PostRepository 等
   ├── service/  # TimelineService/UserService など
   ├── security/  # JwtService など
   ├── pagination/  # Cursor/CursorCodec
   ├── util/  # CursorUtil:
   ├── health/  # HealthController
   ├── web/  # 各種コントローラ
   ├── AuthController, FollowController
   ├── error/  # GlobalExceptionHandler・ErrorResponder など
   ├── dto/  # AuthDtos・PostDtos・TimelineDtos
   └── log/  # AccessLogFilter・RequestIdFilter
   ├── resources/
   ├── application.yml/application-local.yml
   ├── logback-spring.xml・messages.properties
   └── db/migration/  # DB マイグレーション V1~V3
   └── test/java/com/example/backend/
       └── BackendApplicationTests.java

3.2) 環境構築

Spring

https://start.spring.io/

  • Project
    • Gradle - Groovy
  • Language
    • Java 21
  • Packaging
    • Jar
Dependencies
  • Spring Web
    • Spring Framework が提供する Web 開発向けのモジュール
    • Spring で Web サーバーや REST API を作るための仕組み
    • 主な役割
      1. HTTP リクエスト/レスポンスの処理
        • クライアント(ブラウザやモバイルアプリ)からのリクエストを受け取り、サーバー側で処理してレスポンスを返す。
      2. MVC アーキテクチャの提供
        • Spring Web は Spring MVC を含んでおり、Model-View-Controller の仕組みでアプリケーションを整理できる。
        • Controller がリクエストを処理し、Model にデータを入れて、View に描画を任せる。
      3. REST API 開発
        • @RestController アノテーションを使ってシンプルに RESTful API を作れる。
        • JSON 形式でのやり取りを標準的にサポート。
  • Spring Security
    • 認証(Authentication)と認可(Authorization)を実現するセキュリティフレームワーク
    • 主な機能
      1. 認証 (Authentication)
        • 「この人は誰?」を確認する
        • ユーザー名・パスワード、JWT トークン、OAuth2 (Google, GitHubログインなど) に対応
      2. 認可 (Authorization)
        • 「この人は何をできる?」を決める
        • 例:
          • 管理者は /admin にアクセスできる
          • 一般ユーザーは /user のみ
      3. CSRF対策
        • フォーム送信時の CSRF(クロスサイトリクエストフォージェリ)攻撃を防ぐ
      4. セッション管理
        • ログイン中のユーザーを追跡
        • セッション固定攻撃の防止
      5. 暗号化サポート
        • パスワードのハッシュ化(BCrypt など)
  • Spring Data JPA
    • データベースアクセスを簡単にする仕組み
    • 裏側では JPA (Java Persistence API) を使っており、SQL を直接書かなくても、Java のオブジェクトとしてデータを扱えるようにしてくれる
    • 役割
      1. JPAの簡略化
        • JPA は標準仕様だが、実際に使うときは EntityManager を直接操作しないといけない
        • Spring Data JPA がこれをラップして、もっとシンプルに使えるようにしてくれる
      2. リポジトリパターンの自動生成
        • Repository インターフェースを作るだけで、基本的な CRUD(Create, Read, Update, Delete)処理を自動生成
      3. クエリの自動生成
        • メソッド名から SQL を自動で作ってくれる(findByName, findByEmail など)
    • メリット
      • SQL をほとんど書かずに済む
      • JPA(Hibernate などの実装)を Spring と簡単に統合できる
      • 名前ベースでのクエリ作成が可能
      • 必要なら JPQL / ネイティブSQL も書ける
  • Bean Validation
    • Java の 標準仕様(JSR 380 / Jakarta Bean Validation) で、Javaオブジェクト(=Bean)のフィールド値が妥当かどうかをチェックする仕組み。
    • @NotNull, @Size, @Email などのアノテーションを使って、入力値の検証を宣言的に記述できる。
    • Spring Boot ではこの仕組みが統合されていて、DTO や Entity にアノテーションを書くだけで自動検証してくれる。
  • Hibernate Validator
    • Hibernate ORM で有名な Hibernate プロジェクトが提供している Bean Validation のリファレンス実装
    • つまり、「Bean Validation を実際に動かすエンジン」 のようなもの。
    • Spring Boot で spring-boot-starter-validation を入れると、自動的に Hibernate Validator が有効になる。
  • Flyway Migration
    • データベースのバージョン管理ツールで、データベースのスキーマ変更(テーブル追加、カラム変更、インデックス作成など)を 履歴管理 できる
    • Flywayの目的
      • DBスキーマの変更を自動化・一貫性を確保
      • 開発・テスト・本番で同じ手順を繰り返せる
      • 「誰が、いつ、どんなSQLを適用したか」を追跡可能
    • メリット
      • DBの変更履歴を明確に管理できる
      • 手動でSQLを流す必要がなくなる
      • チーム開発で「DBが人によって違う」問題を防げる
      • 本番リリース時のDB更新を自動化できる
  • Lombok
    • 以下のような定型的なコード(ボイラープレートコード)を自動生成してくれるライブラリ
      • getter / setter
      • コンストラクタ
      • toString(), equals(), hashCode()
      • Builder パターン
    • メリット
      • 冗長なコードを削除できる(クラスがシンプルに)
      • メンテナンス性向上
      • Builder パターンなど便利な仕組みを簡単に導入可能
Docker

TBA

3.3) DBマイグレーション(Flyway)

Spring Bootでの設定

application.yml(抜粋)

spring:
  flyway:
    enabled: true
    locations: classpath:db/migration

マイグレーションファイル

db/migration/V1__init.sql

CREATE TABLE IF NOT EXISTS users (
  id BIGSERIAL PRIMARY KEY,
  username VARCHAR(30) UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS posts (
  id BIGSERIAL PRIMARY KEY,
  user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  content TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
解説

usersテーブル

  • id BIGSERIAL PRIMARY KEY
    • 自動採番の一意キー。
    • BIGSERIAL は内部的に bigint とシーケンスをセットで作成してくれる。
    • アプリでユーザーを識別するときの主キー。
  • username VARCHAR(30) UNIQUE NOT NULL
    • 表示名やログインIDに使う想定。
    • UNIQUEにより重複禁止。
    • 最大30文字。文字数制限をつけることでデータ破壊や検索性能低下を防げる。
  • password_hash TEXT NOT NULL
    • パスワードそのものではなく ハッシュ化した値 を保存する。
    • セキュリティ上、生のパスワードを保存してはいけない。
    • 長さはアルゴリズムに依存するのでTEXT型にしている(bcrypt でも argon2 でも対応可能)。
  • created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    • ユーザー登録日時を自動で記録。
    • TIMESTAMPTZはタイムゾーン付きで、グローバル対応に便利。

postsテーブル

  • id BIGSERIAL PRIMARY KEY
    • 投稿の一意キー(自動採番)。
    • タイムラインや詳細表示で使う。
  • user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE
    • 投稿者のユーザーID。
    • REFERENCES users(id) による外部キーで users と紐づく。
    • ON DELETE CASCADE により、ユーザーが削除されればその人の投稿も自動削除。
      → 「消えたユーザーの投稿が残ってゴミになる」ことを防ぐ。
  • content TEXT NOT NULL
    • 投稿内容そのもの。
    • SNSらしく長文も想定してTEXT型にしている。
    • 実運用では文字数制限(例:280文字)をアプリ側でバリデーションすることが多い。
  • created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    • 投稿日時を自動記録。
    • タイムライン表示やソートに必須。

db/migration/V2__follows.sql

CREATE TABLE IF NOT EXISTS follows (
  follower_id BIGINT NOT NULL,     -- フォローする側(= 自分)
  followed_id BIGINT NOT NULL,     -- フォローされる側(= 相手)
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CONSTRAINT fk_follows_follower FOREIGN KEY (follower_id) REFERENCES users (id) ON DELETE CASCADE,
  CONSTRAINT fk_follows_followed FOREIGN KEY (followed_id) REFERENCES users (id) ON DELETE CASCADE,
  CONSTRAINT uq_follows UNIQUE (follower_id, followed_id),
  CONSTRAINT ck_self_follow CHECK (follower_id <> followed_id)
);
解説

構成

  • 行=「Aさん(follower)がBさん(followed)をフォローする」という 1本のフォロー関係。
  • 同じユーザー同士の重複フォローは禁止、自分自身のフォローも禁止。
  • ユーザーが消えたら、その人に関連するフォロー関係は自動的に消える(参照整合性+カスケード削除)。

各カラムと制約の意味

テーブル作成(CREATE)

CREATE TABLE IF NOT EXISTS follows (
  follower_id BIGINT NOT NULL,     -- フォローする側(= 自分)
  followed_id BIGINT NOT NULL,     -- フォローされる側(= 相手)
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  • follower_id, followed_id
    • users(id)を参照する 外部キー になるID。NOT NULL で「空のフォロー」を禁止。
    • 型がBIGINTなのは、users.idbigserial/bigint前提だから(型を合わせるのが大事)。
  • created_at TIMESTAMPTZ DEFAULT NOW()
    • 追加時刻を自動で入れる。TIMESTAMPTZはタイムゾーン付き。NOW()はPostgresでは timestamptzを返すので型も合う。
    • 監査用途(後で「いつフォローしたか」を出す)に便利。

制約(CONSTRAINT)

  CONSTRAINT fk_follows_follower
    FOREIGN KEY (follower_id) REFERENCES users (id) ON DELETE CASCADE,
  CONSTRAINT fk_follows_followed
    FOREIGN KEY (followed_id) REFERENCES users (id) ON DELETE CASCADE,
  • 外部キー(FK)
    • follower_idfollowed_idは必ず存在するusers.idに紐づくことを保証。
    • ON DELETE CASCADEにより、ユーザーが削除されたら、その人が「フォローした/フォローされた」レコードが自動削除。
      → データのゴミが残らない。SNSでは自然なポリシー。
  CONSTRAINT uq_follows UNIQUE (follower_id, followed_id),
  • 一意制約(複合)
    • 同じペア(A→B)を重複して登録できない。
    • Postgresではこの制約のために (follower_id, followed_id) のユニークインデックスが自動で作成される。
  CONSTRAINT ck_self_follow CHECK (follower_id <> followed_id)
);
  • チェック制約
    • 自分自身のIDを相手に指定することを禁止(自己フォロー不可)。

db/migration/V3__add_timeline_indexes.sql

-- 投稿×タイムライン用
CREATE INDEX IF NOT EXISTS idx_posts_user_created_id
  ON posts (user_id, created_at DESC, id DESC);

-- フォロー集合の取得を高速化
CREATE INDEX IF NOT EXISTS idx_follows_follower_followed
  ON follows (follower_id, followed_id);
解説(投稿×タイムライン用)

投稿 × タイムライン用インデックス

CREATE INDEX IF NOT EXISTS idx_posts_user_created_id
  ON posts (user_id, created_at DESC, id DESC);

意味

  • 対象テーブル: posts
  • 対象カラム: (user_id, created_at DESC, id DESC)の複合インデックス

役割

  • 「あるユーザーの投稿一覧を新しい順に取り出す」 クエリを高速化する。

例)

SELECT *
FROM posts
WHERE user_id = 123
ORDER BY created_at DESC, id DESC
LIMIT 20;
  • user_idで絞り込み
  • created_at DESCid DESCで新しい順に並べる
  • LIMITをつけることでタイムラインのように「直近の投稿だけ」を取る

👉 インデックスが (user_id, created_at DESC, id DESC) になっていることで、WHERE + ORDER BY の両方に効く。

なぜ id DESC も入れるの?

  • created_atが同一の投稿が複数あるときに「安定した順序付け」が必要になる。
  • idは一意かつ増加するのでtie-breaker(順位決め)に最適。
  • keyset pagination(カーソルベースページング)にも対応できる。
インデックスがないとcontentを取得できないの?

結論

インデックスがなくてもcontentを取得することはできる。
インデックスは「検索や並び替えを効率化するための補助」であって、データそのもの(contentカラムなど)の有無を決めるものではない。

インデックスがない場合

SELECT content
FROM posts
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
  • インデックスがないと、Postgresはテーブル全件を走査(Seq Scan)して条件に合う行を探す。
  • そのあとにソート(created_at DESC)をして、先頭20件を返す。
  • 結果として正しい値は取れるが、テーブルが大きくなると遅い。

インデックスがある場合

ON posts (user_id, created_at DESC, id DESC)を貼ってあると:

  • Postgresはインデックスを先にスキャンして条件に合う行を新しい順で拾う。
  • LIMIT 20が効いて「20件見つかったら終わり」になり、ソートも不要。
  • このときcontentはインデックスに含まれていないので、行データ本体(Heap)にアクセスして取得する。

つまり:

  • インデックスは絞り込みや並び替えの効率化に使う
  • 実際のカラム値(contentなど)は テーブル本体から読む

まとめ

  • インデックスがなくてもcontentは取得できる(ただし遅いことがある)
  • インデックスは「どの行を読むか探す」ためのナビゲーション
  • 実際の本文や詳細カラムはテーブル本体から読む
  • contentのような大きなテキストはインデックスに含めないのが基本
解説(フォロー集合の取得を高速化)

フォロー集合の取得を高速化するインデックス

CREATE INDEX IF NOT EXISTS idx_follows_follower_followed
  ON follows (follower_id, followed_id);

意味

  • 対象テーブル: follows
  • 対象カラム: (follower_id, followed_id) の複合インデックス

役割

  • 「自分がフォローしている人の一覧を取る」 クエリを高速化する。

例)

SELECT followed_id
FROM follows
WHERE follower_id = 123;
  • follower_idが先頭カラムなので、この検索に効く。
  • さらにfollowed_idもインデックスに含まれているので、クエリがカバーインデックス化(Index Only Scan)しやすい。
    → 実テーブルアクセスせずに結果を返せるケースがある。

注意点

「自分をフォローしている人の一覧」を取りたい場合:

SELECT follower_id
FROM follows
WHERE followed_id = 123;

こちらはこのインデックスでは効きづらい。
→ その場合は逆順の (followed_id, follower_id) インデックスを作るのが定石。

3.4) ヘルスチェック実装

HealthController.java

@RestController
public class HealthController {

    private final JdbcTemplate jdbc;

    public HealthController(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @GetMapping("/health")
    public Map<String, String> ok() {
        return Map.of("status", "ok");
    }

    @GetMapping("/health/db")
    public ResponseEntity<Map<String, Object>> db() {
        try {
            Integer one = jdbc.queryForObject("SELECT 1", Integer.class);
            return ResponseEntity.ok(Map.of("status", "ok", "db", "up", "check", one));
        } catch (DataAccessException ex) {
            return ResponseEntity.status(503).body(
                    Map.of("status","degraded","db","down","path","/health/db")
            );
        }
    }
}

テスト観点

  • /health: liveness(プロセスが生きているか)
    • ステータスコード:200 OK
    • ボディ:{"status":"ok"}(キー名・値の完全一致)
    • Content-Type:application/json 互換
    • 認可:未認証でもアクセス可
  • /health/db: readiness(依存が使えるか)
    • 正常系
      • DB呼び出しが1回行われる:jdbc.queryForObject("SELECT 1", Integer.class) が1回
      • ステータスコード:200 OK
      • ボディ:{"status":"ok","db":"up","check":1}
        • status=ok / db=up を確認
        • check1(またはnull許容かどうかを仕様化。現状コードはnullでも200を返せる)
      • Content-Type:application/json 互換
      • 認可:未認証でもアクセス可
    • 異常系
      • 例外ハンドリング:DataAccessException系を投げたら503
      • ステータスコード:503 Service Unavailable
      • ボディ:{"status":"degraded","db":"down","path":"/health/db"}
        • status=degraded / db=down / path=/health/db
      • 例外メッセージやスタックトレース等の機微情報が混入しない
      • Content-Type:application/json 互換

3.5) サインアップ/ログイン機能

①User周りの実装

User Entity

backend/entity/User.java

@Entity
@Getter
@NoArgsConstructor
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable=false, unique=true, length=30)
    private String username;

    @Column(name="password_hash", nullable=false)
    private String passwordHash;

    @Column(name="created_at", nullable=false, columnDefinition="TIMESTAMP WITH TIME ZONE")
    private OffsetDateTime createdAt = OffsetDateTime.now();

    public User(String username, String passwordHash) {
        this.username = username;
        this.passwordHash = passwordHash;
    }
}
ポイント

@NoArgsConstructor

  • Lombokの@NoArgsConstructorを使うと、明示的に「引数なしコンストラクタ」を生成してくれる。
    • これはJPAエンティティで必須。
      • HibernateなどのJPA仕様では、エンティティクラスに 引数なしコンストラクタ(public または protected) が必須。
      • そのためエンティティクラスによく @NoArgsConstructor が使われる。
  • シリアライゼーション/デシリアライゼーション
    • JSON → Object の変換時(Jacksonなど)、フレームワークがまず「引数なしコンストラクタ」でインスタンスを作成し、setterでフィールドを埋めていく。
    • そのため「引数なしコンストラクタが必要」になる。

@Table(name = “テーブル名”)

  • jakarta.persistence.Table(JPA のアノテーション)
  • エンティティクラスとDBのテーブル名を対応付ける ためのもの
  • @Entity と一緒に使われる

なぜ必要?

  • デフォルト動作
    • 通常、@Table を書かない場合、クラス名がそのままテーブル名に使われる
    • 例: User クラス → user テーブル
  • 予約語回避
    • user は多くのデータベース(特にPostgreSQLやMySQL)で予約語になっている
    • そのまま使うとエラーになることがあるので、usersapp_user のように変更する

@Getter

  • @Getter は読み取り専用。書き込みたい場合は @Setter も必要。
  • Entity(特にJPA)では setter をあえて付けず、immutable(不変オブジェクト)にしたい場合もある → @Getter だけ使うことが多い。
  • いちいち public String getName() { return name; }と書かなくて済む

User Repository

backend/repository/UserRepository.java

public interface UserRepository extends JpaRepository<User, Long> {

    boolean existsByUsername(String username);

    Optional<User> findByUsername(String username);
}
ポイント

JPA Repository

  • JpaRepositoryCrudRepository を継承した リポジトリインターフェース に、特定の命名規則に従ったメソッド名 を書くだけで、自動的にクエリを作ってくれる仕組み。
    • existsByUsername
      • 戻り値:boolean
      • 「指定したusernameを持つレコードが存在するか?」を判定する
      • 使い方:
        boolean exists = userRepository.existsByUsername("kaito");
        if (exists) {
            System.out.println("そのユーザー名は既に存在します");
        }
        
    • findByUsername
      • 戻り値:通常は Optional<User>
      • 指定したusernameのユーザを取得する
      • 使い方:
        Optional<User> userOpt = userRepository.findByUsername("kaito");
        userOpt.ifPresent(user -> {
            System.out.println("見つかったユーザー: " + user.getUsername());
        });
        
      • もし複数レコードが存在するとエラーになるため、通常はusernameにユニーク制約を付ける。

User Service

backend/service/UserService.java(抜粋)

@Service
public class UserService {

    @Transactional
    public User signup(String username, String rawPassword) {
        if (users.existsByUsername(username)) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "username already taken");
        }
        var u = new User(username, encoder.encode(rawPassword));
        return users.save(u);
    }

    @Transactional(readOnly = true)
    public User authenticate(String username, String rawPassword) {
        var u = users.findByUsername(username)
                     .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials"));
        if (!encoder.matches(rawPassword, u.getPasswordHash())) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials");
        }
        return u;
    }
}
ポイント

@Transactional

  • データベース操作を行うときに、一連の処理をひとまとめにして「全部成功するか、全部失敗して元に戻るか」を保証する仕組み。
  • メソッドやクラスに付けると、その処理がトランザクション内で実行されるようになる。
  • DB整合性を保ちたい処理(ユーザ登録、購入処理など)には必須。
  • 例:
    @Transactional
        public void registerUser(User user) {
            userRepository.save(user);
            // 他のDB操作もここでまとめてトランザクションになる
        }
    
    • この場合registerUser()が呼ばれるときに
      • Spring がトランザクションを開始
      • メソッドが正常終了 → commit
      • メソッド内で例外発生 → rollback(DB変更を取り消す)
  • @Transactional(readOnly = true)
    • 「このメソッドはデータを読み取る専用で、書き込み(更新)は行わない」というヒントを Spring と ORM(Hibernate など)に伝える。
    • 「エンティティの変更がない前提」として動作するので無駄なチェックを省略でき、パフォーマンスが向上する。

JpaRepository saveメソッド

  • JpaRepositorysave メソッドは Spring Data JPA で最もよく使うメソッドのひとつ。
  • エンティティを 永続化(persist)または更新(merge) するメソッド。
  • 引数に渡したオブジェクトをデータベースに反映する。
  • save() = insert か update の両方を担うメソッド
  • new entity(id が null) → insert
  • existing entity(id が存在) → update

②認証系の実装

Auth DTO(認証系のDTO)

backend/web/dto/AuthDtos.java

public class AuthDtos {

    public record SignupRequest(
        @NotBlank @Size(min=3, max=30) String username,
        @NotBlank @Size(min=8, max=128) String password
    ) {}

    public record LoginRequest(
        @NotBlank String username,
        @NotBlank String password
    ) {}

    public record SignupResponse(Long id, String username) {}
}
ポイント

recordを使うメリット

  • イミュータブル(不変)
    • フィールドが final 相当になり、セキュリティ面でも安全
  • ボイラープレート削減
    • getter / constructor / equals / hashCode / toString を自動生成
  • DTOに最適
    • 「ただのデータの入れ物」を定義するのにぴったり

DTOとは

  • 意味: 「データをやりとりするためだけのオブジェクト」
  • 役割:
    • Controller ↔ Service ↔ Client 間のデータ受け渡しに使う
    • DBの Entity と直接やりとりせず、安全に必要な情報だけを渡す
  • 特徴:
    • フィールドとその getter/setter しか持たないことが多い(ロジックは持たない)
    • Java 17 以降では record を使ってよりシンプルに定義するのが主流になりつつある

なぜDTOが必要?

  1. セキュリティのため
    • Entity(DBモデル)をそのまま外部APIに返すと、パスワードハッシュなど不要で危険な情報も出てしまう。
    • DTOなら「公開していい情報だけ」を詰め替えて返せる。
  2. 分離のため
    • EntityはDB構造に依存して変わりやすい。
    • DTOはAPI仕様に依存して変わる。
    • これを分けておけば、DB構造を変えてもAPIを壊さずに済む。
  3. バリデーションのため
    • リクエストDTOに@NotBlank@Sizeなどのアノテーションをつけて入力チェックを自動化できる。

イメージ図

[ クライアント ]
 JSON
[ DTO ] ←→ [ Service ] ←→ [ Entity ] ←→ [ DB ]
  • クライアント(フロントエンドや外部アプリ)は DTOとだけ やりとりする
  • 内部のDBやEntityの構造は隠蔽できる

認証系(サインアップ・ログイン)のController

backend/web/AuthController.java(抜粋)

@RestController
@RequestMapping("/auth")
public class AuthController {

    @PostMapping("/signup")
    public ResponseEntity<AuthDtos.SignupResponse> signup(@RequestBody @Valid AuthDtos.SignupRequest req) {
        var u = users.signup(req.username(), req.password());
        return ResponseEntity.ok(new AuthDtos.SignupResponse(u.getId(), u.getUsername()));
    }

    @PostMapping("/login")
    public Map<String, String> login(@RequestBody @Valid AuthDtos.LoginRequest req) {
        var u = users.authenticate(req.username(), req.password());
        return Map.of("token", jwt.generate(u.getUsername(), u.getId()));
    }
}
ポイント

ResponseEntityのokメソッド

  • Spring Frameworkが提供するHTTPレスポンス全体(ステータスコード+ヘッダー+ボディ)を表すクラス。
  • コントローラの戻り値として使うと、レスポンスを柔軟に制御できる。
  • 例えば引数としてJSONを渡した時、ステータスコード200を設定した上でそのままJSONを返す。

recordのDTOの返り値は?

  • AuthControllerでは、AuthDtos.SignupResponseがコントローラーから拾った値を引数として受け取っている。
    // recordの定義
    public record SignupResponse(Long id, String username) {}
    
    // コントローラーの返り値
    return ResponseEntity.ok(new AuthDtos.SignupResponse(u.getId(), u.getUsername()));
    
  • Javaのrecordクラスのコンストラクタは戻り値を持たない(オブジェクトが生成されるだけ)
  • だが、recordをDTOとして定義した場合、Spring BootのようなWebフレームワークで返せば自動的にJSONになる。
    • Spring BootはデフォルトでJacksonというシリアライザが裏で動いているので、返り値 SignupResponse(record)が自動的にJSONにシリアライズされてクライアントに返るようになる。

まとめ

  • recordはDTOに最適(イミュータブル & シンプル)
  • @RestController@ResponseBodyがついたControllerの戻り値にすると、自動的にJSON化される
  • Jacksonが裏で働いているので、DTOがrecordでもclassでも同じように扱える
  • つまり、recordをDTOとして定義した場合 → Controllerから返せばJSONになる(Jacksonのおかげ)

@Validとは

  • メソッドの引数やフィールドに付けることで、そのオブジェクトに定義されている制約アノテーション(@NotNull, @NotBlank, @Size, @Email など)をチェックしてくれる。
  • 主に Controller 層の @RequestBody DTOに付ける。

JwtService.generate

  • 役割
    • ユーザー情報を受け取る(例: username, userId, roles
    • 秘密鍵 or 署名アルゴリズム(HS256など)を使って署名
    • 有効期限(exp クレーム)をつける
    • 文字列としてJWTトークンを返す
    String token = jwtService.generate(user);
    
  • 戻り値
    • 署名済みのJWT文字列
    eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJrYWl0byIsImlkIjoxLCJpYXQiOjE3MjYzMzYyMDAsImV4cCI6MTcyNjM0MzQwMH0.rqjdfh...
    

③動作確認(サインアップ・ログイン)

# 起動
SPRING_PROFILES_ACTIVE=local ./gradlew bootRun

# サインアップ
curl -s -X POST http://localhost:8080/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"username":"kaito","password":"password1234"}'
  # → {"id":1,"username":"kaito"}

# ログイン(JWTなし版)
curl -s -X POST http://localhost:8080/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"kaito","password":"password1234"}'
# → {"token":"eyJhbGciOiJIUzI1NiJ9.・・・(JWTトークン)"}

現状、返り値がJSONだが、実務ではMapではなく、LoginResponseに変更 + no-storeヘッダ付与などにするべき。

3.6) マイページ機能

①コントローラーの実装

backend/web/MyPageController.java(抜粋)

@RestController
public class MyPageController {

    @GetMapping("/mypage")
    public Map<String, Object> mypage(@AuthenticationPrincipal AuthUser me) {
        return Map.of("username", me.username());
    }
}
ポイント

@AuthenticationPrincipalについて

概要

Spring Securityが提供している便利アノテーション。
コントローラーのメソッド引数に付けると、Authenticationから直接principalを取り出して注入してくれる。

Authenticationをそのまま使う場合

そもそもAuthenticationとは?

Spring Securityにおける認証と認可の仕組みの中心的なインターフェース。

  • Spring Securityが「このリクエストは誰なのか?」を表現するためのオブジェクト
  • 以下の情報を持っている:
    • principal(主体)
      → 「誰か」を表すオブジェクト。通常は UserDetails(ユーザー情報クラス)が入る。
    • credentials(資格情報)
      → パスワードなど認証に使った値。認証後は通常 null にされる。
    • authorities(権限リスト)
      → ユーザーの持つロール(例: ROLE_USER, ROLE_ADMIN)。
    • details(付加情報)
      → リモートIPアドレスやセッションIDなどの追加情報。
    • authenticated(認証済みフラグ)
      → 認証が済んでいるかどうか。

認証の流れでのAuthentication

  1. ユーザーがログインリクエストを送る(例:/loginusernamepassword)。
  2. Spring Securityが未認証状態のAuthenticationを作る。
    • principal: ユーザー名
    • credentials: パスワード
    • authenticated: false
  3. 認証プロバイダ(AuthenticationProvider)がこれをチェックして成功すると、認証済みの Authenticationを作る。
    • principal: UserDetails(認証済みのユーザー情報)
    • credentials: null(パスワードはもう不要なので消す)
    • authorities: 権限リスト
    • authenticated: true
  4. 認証済みAuthenticationがSecurityContextに保存される。
    • Authenticationはグローバルに管理されていて、コントローラー以外のサービス層などでも「今ログインしているユーザー」を参照できる。
@GetMapping("/mypage")
public Map<String, Object> mypage(Authentication auth) {
    return Map.of("username", auth.getName());
}
  • auth.getName()usernameを取得する必要がある。
  • もしUserDetailsprincipalにしているなら、キャストが必要になることも多い。

@AuthenticationPrincipalを使う場合

@GetMapping("/mypage")
public Map<String, Object> mypage(@AuthenticationPrincipal UserDetails user) {
    return Map.of("username", user.getUsername());
}
  • Authenticationを意識せず、直接principal(ユーザー情報)を受け取れる。
  • 余計なキャストやauth.getPrincipal()の呼び出しが不要。

動作イメージ

  • Spring SecurityはリクエストごとにSecurityContextを持っている。
  • その中にAuthenticationがあり、さらにprincipalを保持している。
  • @AuthenticationPrincipalを付けると、このprincipalがそのままメソッド引数に渡される。

②AuthUserの実装

backend/security/AuthUser.java(抜粋)

public record AuthUser(Long id, String username, Set<String> roles) implements Serializable {
    public AuthUser {
        if (id == null) throw new IllegalArgumentException("id is required");
        // usernameは空ならフォールバック(任意)
        if (username == null || username.isBlank()) username = "user-" + id;
        roles = (roles == null) ? Set.of() : Set.copyOf(roles);
    }
}
  • AuthUserはSpring Securityにおける「認証済みユーザー情報」を表現するためのレコードクラス
  • AuthUser は次の3つの情報を持つ:
    • id: DB上のユーザーID(主キーなど)
    • username: ユーザー名
    • roles: 権限(例: ROLE_USER, ROLE_ADMIN)
  • implements Serializable
    → セッション保存や分散環境(キャッシュ、分散トークンなど)で使えるようにしている。
コンストラクタのカスタマイズ
public AuthUser {
    if (id == null) throw new IllegalArgumentException("id is required");
    if (username == null || username.isBlank()) username = "user-" + id;
    roles = (roles == null) ? Set.of() : Set.copyOf(roles);
}

これはコンパクトコンストラクタと呼ばれる書き方で、recordが自動生成するコンストラクタに対して追加のロジックを入れている。

  • if (id == null) throw ...
    → id は必須なので null の場合は例外を投げる。
  • if (username == null || username.isBlank()) username = "user-" + id;
    → username が空なら "user-{id}" というフォールバックを自動的に設定。
  • roles = (roles == null) ? Set.of() : Set.copyOf(roles);
    → null の場合は空セットを使う。
    → さらに Set.copyOf() で イミュータブル化。
    → これにより外部から roles を変更できなくなる(安全性・予測可能性の向上)。

③動作確認

Postmanを使用する場合

  • Auth
    • Type: Bearer Token
    • Token: (取得したJWT token)
  • リクエスト
  • レスポンス(例)
    { "username": "kaito" }
    

3.7) 投稿機能

①Post周りの実装

Post Entity

backend/entity/Post.java

@Entity
@Getter
@NoArgsConstructor
@Table(name = "posts")
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(nullable = false, columnDefinition = "text")
    private String content;

    @Column(name = "created_at", nullable = false, columnDefinition = "TIMESTAMP WITH TIME ZONE")
    private OffsetDateTime createdAt = OffsetDateTime.now();

    public Post(User user, String content) {
        this.user = user;
        this.content = content;
    }
}
各フィールドの説明

idフィールド

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
  • @Id
    → このフィールドが 主キー (Primary Key) であることを示す。
    → JPA がエンティティを一意に識別するために必須。
  • @GeneratedValue(strategy = GenerationType.IDENTITY)
    → 主キーを DB側のオートインクリメント に任せる設定。
    • PostgreSQL なら SERIAL / BIGSERIAL、MySQL なら AUTO_INCREMENT が使われる。
    • 特徴:insertするときにアプリ側は値をセットせず、DBに挿入したタイミングで採番される。

userフィールド

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
  • @ManyToOne
    → 多対一のリレーションを表す。
    • 「複数の Post は 1人の User に属する」という関係。
    • 逆に User 側で @OneToMany(mappedBy = "user") を書けば、ユーザーに紐づく投稿リストが取れる。
    • fetch = FetchType.LAZY
      → 遅延ロード。
      • Post を取得しただけでは User の情報を DB から取らない。
      • post.getUser() を呼んだ瞬間に SQL が実行される。
      • メモリ効率・パフォーマンス向上のための設定。
  • @JoinColumn(name = "user_id", nullable = false)
    → DB の外部キー列名を user_id にする。
    → nullable = false は 必須。必ず投稿にはユーザーが紐づく。

contentフィールド

@Column(nullable = false, columnDefinition = "text")
private String content;
  • @Column
    → このフィールドをテーブルのカラムにマッピングする。
    • nullable = false
      → DB の制約で「必須入力」になる。
      → NOT NULL が付与される。
    • columnDefinition = "text"
      → SQL のカラム型を明示的に text に指定。
      • PostgreSQL の text 型は文字数制限がない(varchar(255) のような制限を超える長文でも保存可能)。
      • 明示しないと JPA のデフォルトでは varchar(255) になることが多い。

createdAtフィールド

@Column(name = "created_at", nullable = false, columnDefinition = "TIMESTAMP WITH TIME ZONE")
private OffsetDateTime createdAt = OffsetDateTime.now();
  • @Column
    → テーブル上の created_at カラムとマッピング。
    • name = "created_at"
      → Java のフィールド名 createdAt と DB の列名 created_at を対応付ける。
      • DB 側はスネークケースにするためにこう指定している。
    • nullable = false
      → この値は必須。投稿が作成された時刻は必ず持つ。
    • columnDefinition = "TIMESTAMP WITH TIME ZONE"
      → DB のカラム型を明示的に指定。
      • PostgreSQL の場合、timestamptz 型になる。
      • 時差やタイムゾーンを考慮した保存が可能。
  • private OffsetDateTime createdAt = OffsetDateTime.now();
    → Java 側でエンティティ生成時に自動で現在時刻をセット。
    • OffsetDateTime はタイムゾーン付きの日時を扱えるので国際化に強い。

Post Repository

public interface PostRepository extends JpaRepository<Post, Long> {
    // Keysetページング
    @Query(value = """
    SELECT p.*
      FROM posts p
     WHERE (p.user_id = :me
            OR p.user_id IN (SELECT followed_id FROM follows WHERE follower_id = :me))
       AND (p.created_at, p.id) < (:cursorAt, :cursorId)
     ORDER BY p.created_at DESC, p.id DESC
     LIMIT :size
    """, nativeQuery = true)
    List<Post> compositeTimeline(
            @Param("me") Long me,
            @Param("cursorAt") OffsetDateTime cursorAt,
            @Param("cursorId") Long cursorId,
            @Param("size") int size
    );
}
ポイント

全体像

public interface PostRepository extends JpaRepository<Post, Long> {
  • JpaRepository<Post, Long>を継承しているので、
    • findById
    • save
    • delete
    • findAll
      など基本的なCRUD操作は自動的に利用できる。

メソッド定義

@Query(value = """ ... """, nativeQuery = true)
List<Post> compositeTimeline(
        @Param("me") Long me,
        @Param("cursorAt") OffsetDateTime cursorAt,
        @Param("cursorId") Long cursorId,
        @Param("size") int size
);
  • @Query
    → 独自のSQLを指定。
    • nativeQuery = true
      → JPQL ではなく 生のSQL を使っている。
    • PostgreSQL の文法がそのまま書ける。
  • @Param("me") など
    → メソッド引数をSQLにバインドする。

SQL文の意味

SELECT p.*
  FROM posts p
 WHERE (p.user_id = :me
        OR p.user_id IN (SELECT followed_id FROM follows WHERE follower_id = :me))
   AND (p.created_at, p.id) < (:cursorAt, :cursorId)
 ORDER BY p.created_at DESC, p.id DESC
 LIMIT :size
  • FROM posts p
    • 投稿テーブルを検索する。
  • WHERE (p.user_id = :me OR p.user_id IN (...))
    • 自分自身の投稿 (p.user_id = :me)
    • 自分がフォローしているユーザーの投稿 (p.user_id IN (...))
      → つまり「自分のタイムラインに表示される投稿」を対象にする。
  • AND (p.created_at, p.id) < (:cursorAt, :cursorId)
    • Keyset ページングのコア部分。
    • 投稿をソートするキーは (created_at, id) の複合キー。
      • 最新順 → ORDER BY created_at DESC, id DESC
    • 「前回のページの最後の位置 (cursorAt, cursorId) より古い投稿だけ取る」という条件。
    • これにより OFFSET を使わずにページングができる(パフォーマンス向上)。
  • ORDER BY p.created_at DESC, p.id DESC
    • 最新投稿から順に並べる。
    • id を追加しているのは、同じ created_at のときに順序が安定するようにするため。
  • LIMIT :size
    • 取得件数を指定。
    • 例えば size=20 なら「次の20件」を返す。

戻り値

List<Post>
  • SQLで取得したレコードを Post エンティティにマッピングして返す。
  • タイムライン表示に使える。

Keysetページングの特徴

  • 通常の OFFSET ... LIMIT ... ページングだと、ページが進むほど遅くなる(DBが先頭からスキャンする必要がある)。
  • Keyset ページングは「カーソル位置以降のデータだけを効率的に取りに行く」ため、パフォーマンスが良い。
  • TwitterやFacebookのような「タイムライン型」のサービスでよく使われる手法。

まとめると

このメソッドは「自分と自分がフォローしているユーザーの投稿」を対象に、created_at + id をソートキーにして Keysetページングで次のページを取得するクエリ。
大量データを扱うSNSタイムラインで、高速かつ安定したスクロール表示を実現できる。

Post Service

backend/service.PostService.java(抜粋)

@Service
public class PostService {
    @Transactional
    public PostDtos.PostResponse create(String username, String content) {
        User me = users.findByUsername(username)
                    .orElseThrow(() -> new ResponseStatusException(UNAUTHORIZED, "user not found"));
        if (content == null || content.isBlank()) {
            throw new ResponseStatusException(BAD_REQUEST, "content is blank");
        }
        var post = posts.save(new Post(me, content.strip()));
        return toDto(post);
    }

    private PostDtos.PostResponse toDto(Post p) {
        return new PostDtos.PostResponse(
                p.getId(),
                p.getUser().getId(),
                p.getUser().getUsername(),
                p.getContent(),
                p.getCreatedAt()
        );
    }
}
ポイント

saveメソッド

var post = posts.save(new Post(me, content.strip()));
  • new Post(me, content.strip())
    • 新しい Post エンティティを生成。
    • strip()は文字列の前後の空白を削除する(Java 11 以降)。
  • posts.save(...)
    • Spring Data JPA が INSERT を発行して DB に保存する。
    • 戻り値は 保存後のPostエンティティ(id など採番済み)。

DTO変換メソッド

return toDto(post);
  • エンティティをそのまま返すのではなく、DTO(PostDtos.PostResponse)に変換して返す。
  • これにより「クライアントに返す情報を制御」できる。
ユーザー認証をSpring Securityに統合する方法(usernameを渡さず Authenticationから取る)
User me = users.findByUsername(username)

この場合、必ずDBにアクセスすることになる。
(Spring Data JPA → SELECT ... FROM users WHERE username = ? を発行)
→ 「すでに認証済みで JWT に uidusername が入っている」場合、再度DBに問い合わせる必要がないのに SELECT が走っている、という点で効率が落ちる。
→ 投稿作成のたびに users.findByUsername(...) すると、毎回 users テーブルに対して検索が行われる。
そこで、以下のようにusernameを渡さずAuthenticationから取る方法にする。

@Transactional
public PostResponse create(AuthUser me, String content) {
    if (content == null || content.isBlank()) throw new ResponseStatusException(BAD_REQUEST, "content is blank");
    User userRef = users.getReferenceById(me.id()); // ← ここでSELECTしない
    var post = posts.save(new Post(userRef, content.strip())); // ← FKでINSERT
    return new PostResponse(post.getId(), me.id(), me.username(), post.getContent(), post.getCreatedAt()); // ← 追加SELECTなし
}
  • Spring Data JPA のgetReferenceByIdは、JPA EntityManager#getReference の薄いラッパー。
  • 即座にSELECTを発行しない。指定IDを持つUserの遅延ロード用プロキシを返す。
  • その参照を@ManyToOneにセットしてPostsaveすると、ユーザーを読み込まずに posts.user_id = me.id()INSERTできる(= 無駄な1回のSELECTを節約)。
User userRef = users.getReferenceById(me.id()); // ← ここではSELECTしない
var post = posts.save(new Post(userRef, content)); // ← INSERT時もUserは未ロードのまま

まとめ

  • findByUsername → 即SELECT。ユーザーが必ず存在する前提なら「無駄なクエリ」になり得る。
  • getReferenceById → SELECTせずに参照を返す。性能最適化できる。
  • ただし「存在チェックを明確にしたい」なら findById/findByUsername が正しい選択。
  • 「存在はすでにJWTで保証されている」なら getReferenceById がベスト。
    今回の設計では、getReferenceByIdを使う方が適している。

Post DTO

backend/web/dto/PostDtos.java

public class PostDtos {

    public record PostRequest(
            @NotBlank
            @Size(max = 280)
            String content
    ) {}

    public record PostResponse(
            Long id,
            Long userId,
            String username,
            String content,
            OffsetDateTime createdAt
    ) {}
}

Post Controller

backend/web/PostController.java

@Validated
@RestController
public class PostController {
    @PostMapping("/posts")
    public ResponseEntity<PostDtos.PostResponse> create(
            @AuthenticationPrincipal AuthUser me,
            @RequestBody @Valid PostDtos.PostRequest req
    ) {
        PostDtos.PostResponse created = posts.create(me, req.content());
        URI location = URI.create("/posts/" + created.id());
        return ResponseEntity.created(location).body(created);
    }
}
ポイント

@Validated

  • Springのバリデーションを有効化。
  • このクラスのメソッド引数に@Valid@Min,@Sizeなどを付けると、Bean Validation (Jakarta Validation) が実行される。
  • @RequestBody @Valid PostDtos.PostRequest req
    • リクエストボディを JSON → DTO に変換する。
    • @Validにより DTO のバリデーションルール(例:@NotBlank)が自動で実行される。
    • 不正な入力なら Spring が 400 Bad Request を返す。

処理の流れ

サービス呼び出し

PostDtos.PostResponse created = posts.create(me, req.content());
  • 投稿サービスに処理を委譲。
  • 認証済みユーザー me とリクエストの content を渡す。
  • 戻り値は PostResponse DTO(投稿の情報)。

Locationヘッダーの生成

URI location = URI.create("/posts/" + created.id());
  • RESTの慣習に従って、新しく作られたリソースのURIを /posts/{id} 形式で作る。

HTTPレスポンスの構築

return ResponseEntity.created(location).body(created);
  • 201 Created を返す。
  • Location ヘッダーに新しいリソースのURIをセット。
  • レスポンスボディには作成された投稿のDTOをJSONで返す。

まとめると

  • POST /posts → 201 Created を返す。
  • Location ヘッダーを返して「作成リソースの場所」をクライアントに通知。
  • 本文には作成されたオブジェクトを返す。

これは RESTのベストプラクティスに忠実な実装。

3.8) Keysetページング

そもそもKetsetページングとは
  • 前のページの最後のデータを基準に、次のデータを取得するページング手法。
  • データベースのクエリにおける カーソル (基準点) を使うので「カーソルページング」とも呼ばれる。

従来のページング (offset/limit)

-- 2ページ目(20件目から20件)
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 20;
  • OFFSET で「先頭から20件スキップ」して21件目から取る。
  • 問題点:
    • 遅い: 大量データがあると、最初の20件を捨てる計算が重い。
    • 不安定: 新しい投稿が入ると「同じページを再取得したときにデータがズレる」ことがある。

Keyset ページング

-- 前ページ最後の投稿が created_at= '2025-09-19 10:00:00', id=123 だった場合
SELECT * FROM posts
 WHERE (created_at < '2025-09-19 10:00:00'
     OR (created_at = '2025-09-19 10:00:00' AND id < 123))
 ORDER BY created_at DESC, id DESC
 LIMIT 20;
  • 「ページ番号」ではなく「前回の最後のレコードの位置」を基準に次を取る。
  • つまり カーソル (created_atid) を持ち回してページングする。
  • 特徴:
    • 速い: インデックスを効かせやすい(OFFSET を使わない)。
    • 安定: データが増えても結果がブレにくい。
    • 柔軟: 時系列フィードやSNSタイムラインに最適。
  • ページングを「offset/limit」でやると、大量データ時に遅くなったり「新規データ追加で順番がズレる」問題が出る。
  • そこで「カーソル (Cursor)」を使う手法がよく採用される。
    例: ?cursor=xxxxxx のように前ページの最後の投稿ID・時刻をエンコードして渡す。

以下のクラスCursorCodecは、その Cursor オブジェクトを JSONに変換して → Base64で文字列化 → APIのクエリパラメータに安全に載せる役割を果たす。
backend/pagination/Cursor.java

public record Cursor(OffsetDateTime at, Long id) {}

backend/pagination/CursorCodec.java

public class CursorCodec {
    private static final ObjectMapper MAPPER = new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

    public static String encode(Cursor c) {
        try {
            var json = MAPPER.writeValueAsString(c);
            return Base64.getUrlEncoder().withoutPadding()
                    .encodeToString(json.getBytes(StandardCharsets.UTF_8));
        } catch (Exception e) {
            throw new IllegalArgumentException("cursor encode error", e);
        }
    }

    public static Cursor decode(String token) {
        try {
            var json = new String(Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_8);
            return MAPPER.readValue(json, Cursor.class);
        } catch (Exception e) {
            throw new IllegalArgumentException("cursor decode error", e);
        }
    }
}
コード解説

ObjectMapper の設定

private static final ObjectMapper MAPPER = new ObjectMapper()
        .registerModule(new JavaTimeModule())
        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
  • JavaTimeModule
    • OffsetDateTime/LocalDateTime など java.time の型を自然なISO-8601文字列で扱える。
  • WRITE_DATES_AS_TIMESTAMPS を無効化
    • {"createdAt":"2025-09-19T10:00:00+09:00"} のような可読な文字列で出力(数値エポックではない)。
  • スレッドセーフ
    • ObjectMapper は構成後はスレッドセーフ(読み書き並列OK)。static final で1個を使い回すのはGoodパターン。
    • 逆に動的にモジュール追加や設定変更をしない(クラス初期化時に完了させる)。

encode:JSON → URL-safe Base64

public static String encode(Cursor c) {
    try {
        var json = MAPPER.writeValueAsString(c);
        return Base64.getUrlEncoder().withoutPadding()
                .encodeToString(json.getBytes(StandardCharsets.UTF_8));
    } catch (Exception e) {
        throw new IllegalArgumentException("cursor encode error", e);
    }
}
  • writeValueAsString で 厳密なJSON に直す(プロパティ順は未定義だが、decodeは順序非依存なので問題なし)。
  • Base64.getUrlEncoder() は URLセーフ(+→-, /→_)になり、クエリにそのまま載せても壊れにくい。
    • .withoutPadding() は末尾の = を削る。
      • 短くなる & URL互換性が上がる(必須ではないが今風)。
  • 文字コードは UTF-8 を明示(プラットフォーム依存を排除)。

例)
{"createdAt":"2025-09-19T10:00:00+09:00","id":123}
eyJjcmVhdGVkQXQiOiIyMDI1LTA5LTE5VDEwOjAwOjAwKzA5OjAwIiwiaWQiOjEyM30

decode:URL-safe Base64 → JSON → Cursor

public static Cursor decode(String token) {
    try {
        var json = new String(Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_8);
        return MAPPER.readValue(json, Cursor.class);
    } catch (Exception e) {
        throw new IllegalArgumentException("cursor decode error", e);
    }
}
  • Base64.getUrlDecoder() でURLセーフ版を逆変換(= が無くても復元できる実装)。
  • UTF-8 文字列に戻し、Cursor.class へデシリアライズ。
  • 例外は IllegalArgumentException に包む
    • コントローラ層で 400 Bad Request にマップしやすい。
設計における注意点
  • カーソルの中身は「ソートキー」だけに

    • Keysetの基準になる 順序保証フィールドの最小集合 を入れる:
      • 典型:createdAt DESC, id DESC なら createdAtid
        → 他ユーザのカーソルを流用した情報漏洩リスクを抑制
  • 日時の型は OffsetDateTime

    • Postgres timestamptz と相性が良く、オフセットを保持。
    • DBとアプリで同じ精度(秒・ミリ秒・マイクロ秒)を扱うこと。
      • DBがマイクロ秒、Javaがナノ秒を持つ場合は丸め/切り捨ての一貫性を決める。

3.9) タイムライン機能

Timeline DTO

backend/web/dto/TimelineDtos.java

public class TimelineDtos {
public record TimelineItem(Long id, Long userId, OffsetDateTime createdAt, String content) {
        public static TimelineItem from(Post p) {
            Long userId = (p.getUser() != null) ? p.getUser().getId() : null;
            OffsetDateTime createdAt = (p.getCreatedAt() != null) ? p.getCreatedAt(): null;
            return new TimelineItem(p.getId(), userId, createdAt, p.getContent());
        }
    }

    /** APIで返す最終形は opaqueな nextCursor 文字列にするのが安定 */
    public record TimelineResponse(List<TimelineItem> items, String nextCursor) {
        public static TimelineResponse of(List<Post> posts, String nextCursor) {
            var list = posts.stream().map(TimelineItem::from).toList();
            return new TimelineResponse(list, nextCursor);
        }
    }
}
解説

TimelineItem

public record TimelineItem(Long id, Long userId, OffsetDateTime createdAt, String content) {
    public static TimelineItem from(Post p) {
        Long userId = (p.getUser() != null) ? p.getUser().getId() : null;
        OffsetDateTime createdAt = (p.getCreatedAt() != null) ? p.getCreatedAt(): null;
        return new TimelineItem(p.getId(), userId, createdAt, p.getContent());
    }
}

フィールド

  • id: 投稿ID
  • userId: 投稿したユーザのID
  • createdAt: 投稿日時(OffsetDateTime
  • content: 投稿本文

from(Post p) ファクトリメソッド

  • Postエンティティから TimelineItem に変換。
  • p.getUser()p.getCreatedAt() が null の可能性に備えてnull安全に変換しているのがポイント。
  • エンティティ直返しを避ける理由:
    • 不要なカラムやリレーションを隠せる
    • APIの仕様変更に柔軟
    • セキュリティ(パスワードや内部カラムを返さない)

TimelineResponse

public record TimelineResponse(List<TimelineItem> items, String nextCursor) {
    public static TimelineResponse of(List<Post> posts, String nextCursor) {
        var list = posts.stream().map(TimelineItem::from).toList();
        return new TimelineResponse(list, nextCursor);
    }
}

フィールド

  • items: 投稿リスト(TimelineItem の一覧)
  • nextCursor: 次のページを取るためのカーソル文字列
    • opaque(中身が見えない)文字列 にするのがベストプラクティス。
    • つまりクライアントは「ただの文字列」として扱い、中身を解釈しない。
    • これによりサーバ側でフィールドを変えても互換性を保ちやすい。

of(List<Post>, String) ファクトリメソッド

  • DBから取得した Post のリストを TimelineItem に変換し、nextCursor をセットして返す。
  • サービス層で毎回 map(...).toList() を書く必要がなくなり、コードがすっきりする。

まとめ

  • TimelineItem
    • 投稿1件をAPI用に変換したDTO。
    • エンティティをそのまま晒さず、必要な情報だけ抽出。
  • TimelineResponse
    • 投稿リスト+次カーソルを返すDTO。
    • 無限スクロールAPIの定番スタイル。
  • 設計上の良い点
    • DTOをエンティティと切り離している(拡張性・安全性◎)
    • static from(...) / of(...) を使い変換ロジックを1箇所に集約
    • nextCursor を opaque文字列 にしている(安定したAPI設計)
ファクトメソッドとは(from/of)

ファクトリメソッド=「オブジェクト生成をカプセル化するメソッド」

from(...)

public static TimelineItem from(Post p) { ... }
  • 目的: 異なる型から変換してインスタンスを作る
  • 入力: Post(エンティティ)
  • 出力: TimelineItem(DTO)
  • 「ある型 → 別の型」の 変換コンストラクタの代替 みたいな役割。
  • よくある命名パターン:
    • from(Entity)
    • fromDto(Dto)
    • fromJson(String) など

of(...)

public static TimelineResponse of(List<Post> posts, String nextCursor) { ... }
  • 目的: 同じ概念をまとめて組み立てる
  • 入力: すでに揃った材料(List<Post> と nextCursor)
  • 出力: TimelineResponse
  • 内部で TimelineItem.from(...) を呼び出して変換処理をまとめている。
  • 「工場として組み立てる」イメージ。
  • よくある命名パターン:
    • of(values...)
    • ofList(...)
    • ofNullable(...)(Java標準の Optional.of(...) も同じ考え方)

まとめると

from型変換(ある型のオブジェクトを受け取って、新しい型のインスタンスを作る)、of組み立て(すでに持っている材料を使ってインスタンスを組み立てる)

Timeline Service

backend/service/TimelineService.java

@Service
@RequiredArgsConstructor
public class TimelineService {
    private final PostRepository postRepository; // compositeTimeline(...) を想定

    private static final int MAX_SIZE = 100;

    public TimelineResponse getCompositeTimeline(Long meId, String cursorToken, int size) {
        // 1〜MAX_SIZE にクリップ
        final int pageSize = Math.max(1, Math.min(size, MAX_SIZE));

        // 1ページ目の既定カーソル
        OffsetDateTime at = OffsetDateTime.now();
        Long cid = Long.MAX_VALUE;

        if (cursorToken != null && !cursorToken.isBlank()) {
            Cursor c = CursorCodec.decode(cursorToken);
            at = c.at();
            cid = c.id();
        }

        var posts = postRepository.compositeTimeline(meId, at, cid, pageSize);

        // 次カーソル作成(createdAt の型が OffsetDateTime の想定)
        String nextCursor = null;
        if (!posts.isEmpty()) {
            var last = posts.get(posts.size() - 1);
            // last.getCreatedAt() が OffsetDateTime の場合:
            nextCursor = CursorCodec.encode(new Cursor(last.getCreatedAt(), last.getId()));
        }

        return TimelineResponse.of(posts, nextCursor);
    }
}

Keysetページングを組み込んだタイムライン取得処理を実装している。

解説

ページサイズの調整

final int pageSize = Math.max(1, Math.min(size, MAX_SIZE));
  • 1未満なら1件に矯正
  • 100件以上要求されたら100件に矯正
    👉 不正な要求を防ぎ、サーバーの安定性を保つ。

カーソルの初期化

OffsetDateTime at = OffsetDateTime.now();
Long cid = Long.MAX_VALUE;
  • 初期値として「今の時刻」と「最大のID」を使うことで、最新の投稿から取得する。
  • Long.MAX_VALUE は Java における long 型の最大値 を表す定数。
    • long 型は 64bit の符号付き整数(範囲は -2^63 ~ 2^63-1)
    • Long.MAX_VALUE はそのうちの最大値 2^63 - 1
    • 値としては 9,223,372,036,854,775,807(約9京)になる
  • 初期状態では 「最新の投稿」から取りたいので、ID(cid)には「最も大きな値」をセットしておく。
  • こうしておくと、実際の投稿ID(1, 2, 3… と小さい数字)が必ず小さくなるので、「最新の投稿から順に取得する」というロジックが自然に動く。

既存のカーソルがある場合

if (cursorToken != null && !cursorToken.isBlank()) {
    Cursor c = CursorCodec.decode(cursorToken);
    at = c.at();
    cid = c.id();
}
  • もし cursorToken が指定されていたら、復号して「その地点以降」を取得するように設定。
    👉 これが Keyset Pagination(カーソル方式ページネーション)の考え方。
    「○ページ目」ではなく「この投稿より前をください」と指定する。

DBアクセス → 対象のPostを取得

var posts = postRepository.compositeTimeline(meId, at, cid, pageSize);

@Query(value = """
    SELECT p.*
      FROM posts p
     WHERE (p.user_id = :me
            OR p.user_id IN (SELECT followed_id FROM follows WHERE follower_id = :me))
       AND (p.created_at, p.id) < (:cursorAt, :cursorId)
     ORDER BY p.created_at DESC, p.id DESC
     LIMIT :size
    """, nativeQuery = true)
List<Post> compositeTimeline(
            @Param("me") Long me,
            @Param("cursorAt") OffsetDateTime cursorAt,
            @Param("cursorId") Long cursorId,
            @Param("size") int size
    );
  • compositeTimeline はリポジトリ側で createdAtid を複合キーにしたクエリを実行する想定。(つまり「ある時刻・IDより前の投稿を取ってきてね」という問い合わせ)

次ページカーソルの生成

String nextCursor = null;
if (!posts.isEmpty()) {
    var last = posts.get(posts.size() - 1);
    nextCursor = CursorCodec.encode(new Cursor(last.getCreatedAt(), last.getId()));
}
  • 取得結果の最後の投稿(=次に繋げる起点)をカーソルに変換。
  • これを次のリクエストに渡すと「さらに古い投稿」が取れる。

まとめると

  1. ページサイズを安全に調整
  2. cursorToken が無ければ「最新」から開始
  3. cursorToken があればそこから続きを取得
  4. DBに問い合わせ(compositeTimeline)
  5. 結果の最後の投稿をもとに nextCursor を生成
  6. 投稿リストと nextCursor を返却
    以上でKeyset Paginationによる無限スクロール対応のタイムラインAPIを実装している。

Timeline Controller

backend/web/TimelineController.java

@RestController
@RequiredArgsConstructor
@RequestMapping("/timeline")
public class TimelineController {
    private final TimelineService timelineService;

    @GetMapping
    public ResponseEntity<TimelineDtos.TimelineResponse> getTimeline(
            @AuthenticationPrincipal AuthUser me,
            @RequestParam(required = false) String cursor,
            @RequestParam(defaultValue = "20") int size
    ){
        TimelineDtos.TimelineResponse resp =
                timelineService.getCompositeTimeline(me.id(), cursor, size);
        return ResponseEntity.ok(resp);
    }
}
解説

Service呼び出し

TimelineDtos.TimelineResponse resp =
        timelineService.getCompositeTimeline(me.id(), cursor, size);
  • timelineService.getCompositeTimeline(...) を呼び出し、
    ユーザー自身のタイムラインを取得。

レスポンス返却

return ResponseEntity.ok(resp);
  • ResponseEntity.ok(...)HTTP 200 OK としてレスポンスを返す。
  • 中身は TimelineResponse(DTO)なので、JSON 変換されてクライアントに返る。
  • ResponseEntityの名前にある「Entity」はJPAのEntityとは関係ない。
    • Spring Web のクラスで「HTTPレスポンスを表す“エンティティ(実体)”」という意味。
    • HttpEntityを継承していて、その上に「HTTPステータス」や「ヘッダー」を持てるようにしたクラス。
    • つまり、ここでの「Entity」は “HTTPメッセージの実体” を指しているだけ。
    • コントローラが返すオブジェクトには
      • エンティティ(DBモデル) をそのまま返すのは避けるべき(セキュリティ・結合度の観点)
      • DTO(Data Transfer Object)を返すのがベストプラクティス
    • なので ResponseEntity<T> の T に DTO を入れるのが正解。
  • @RestControllerでSpring MVCによりJacksonシリアライザを通して、DTO → JSON に変換してクライアントが受け取るのはJSONになる。

その②に続く...

https://zenn.dev/kaichang/articles/e7bb97ba8da100

Discussion