C
Spring Boot/JPA/Lesson 09

Spring Data JPA 実践 — Repository · N+1 · DTO 変換

1時間·theory
このチャプター
2/2

Spring Data JPA 実践 — Repository · N+1 · DTO 変換

🎯 このレッスンを読み終えたら

このレッスンをすべて読み終えると、以下の3つを自信を持ってできるようになります。

  • ✅ JpaRepository を継承するだけで 0行のコード で CRUD を実装
  • ✅ N+1 問題 → @EntityGraph または JOIN FETCH で解決
  • ✅ Lazy vs Eager の違い + 実務では必ず Lazy を使う理由

学習目標を チェックリストとして 手元に置き、すべてに答えられるようになったらレッスンを閉じてください。

JpaRepository — *メソッド名を付けるだけで完成*

最大の魔法

java
public interface UserRepository extends JpaRepository<User, Long> {
}

インターフェース1行 を宣言するだけで — Spring が 実行時に実装クラスを自動生成 します。すぐ使えるメソッド:

  • save(entity) — INSERT または UPDATE
  • findById(id) — Optional を返す
  • findAll() — 全件取得
  • count() — 件数取得
  • deleteById(id) — 削除
  • existsById(id) — 存在確認

メソッド名でクエリを生成する

java
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    List<User> findByNameContaining(String keyword);
    long countByCreatedAtAfter(LocalDateTime date);
    boolean existsByEmail(String email);
    List<User> findByAgeGreaterThanOrderByNameAsc(int age);
}

メソッド名の命名規則 に従うだけで、Spring が 自動的に SQL を生成。可読性とパフォーマンスの両方で圧勝です。

キーワード: findBycountByexistsBydeleteBy + フィールド名 + (ContainingGreaterThanInBetweenOrderBy...Asc/Desc ...)

JPQL を直接書く — @Query

複雑なクエリは JPQL (Java Persistence Query Language) で記述します:

java
public interface UserRepository extends JpaRepository<User, Long> {

    @Query("SELECT u FROM User u WHERE u.email = :email AND u.active = true")
    Optional<User> findActiveByEmail(@Param("email") String email);

    @Query("SELECT u FROM User u JOIN u.orders o WHERE o.amount > :min")
    List<User> findBigSpenders(@Param("min") int min);

    @Modifying
    @Query("UPDATE User u SET u.lastLogin = :now WHERE u.id = :id")
    int updateLastLogin(@Param("id") Long id, @Param("now") LocalDateTime now);
}

JPQL は SQL に似ていますが、「エンティティ名」で記述します — テーブル名ではありません。

ページング — Pageable

java
Page<User> page = userRepository.findAll(
    PageRequest.of(0, 20, Sort.by("createdAt").descending())
);

List<User> content = page.getContent();   // 現在のページ
long total = page.getTotalElements();      // 総件数
int totalPages = page.getTotalPages();

無限スクロールページナビゲーション も、これ一つで完結します。

N+1 問題 — 実務で最もよく遭遇する落とし穴

問題の状況

java
@Entity
class User {
    @Id Long id;
    @OneToMany(mappedBy = "user")
    List<Order> orders;
}

@Entity
class Order { @Id Long id; @ManyToOne User user; int amount; }
java
List<User> users = userRepository.findAll();   // (1) SELECT * FROM users  ← 1回
for (User u : users) {
    System.out.println(u.getOrders().size());   // (N) SELECT * FROM orders WHERE user_id = ?  ← ユーザー数分
}

ユーザーが100人 → クエリが101回 発生。データが増えるほど 指数的に遅くなります。これが N+1 問題 です。

解決策1 — @EntityGraph(最もシンプル)

java
public interface UserRepository extends JpaRepository<User, Long> {

    @EntityGraph(attributePaths = {"orders"})
    List<User> findAll();
}

1回の JOIN クエリで完結。SQL にすると:

sql
SELECT u.*, o.* FROM users u
LEFT JOIN orders o ON o.user_id = u.id

解決策2 — JOIN FETCH(JPQL 直接記述)

java
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.orders")
List<User> findAllWithOrders();

DISTINCT が重要 — 付けないと 重複行 が増えます。

解決策3 — Batch Size (@BatchSize)

完全な解決ではありませんが、N+1 を N/バッチサイズ+1 に削減。複数の関連 + ページングの組み合わせで有効です。

java
@Entity
class User {
    @OneToMany(mappedBy = "user")
    @BatchSize(size = 100)        // 100件単位で IN クエリ
    List<Order> orders;
}

Lazy vs Eager — いつどちらを使うか

java
@ManyToOne(fetch = FetchType.LAZY)   // デフォルト。*必要なときだけ* ロード
@OneToMany(fetch = FetchType.LAZY)   // デフォルト。
@ManyToOne(fetch = FetchType.EAGER)  // 即時ロード — *使わないでください*

実務ルール: 必ず LAZY。EAGER は 予測不可能なクエリ爆発 の原因になります。必要なときは都度 @EntityGraph または JOIN FETCH を使いましょう。

N+1 のデバッグ — show-sql をオンにする

yaml
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true

開発中 はコンソールに実際に発行された SQL が表示されます。同じクエリが 何十回も繰り返される 場合は N+1 の疑いがあります。

Entity → DTO 変換 — *なぜ分離すべきか*

Entity をそのまま返してはいけない理由

java
// ❌ Entity をそのままレスポンスに返す
@GetMapping("/users/{id}")
public User get(@PathVariable Long id) {
    return userRepository.findById(id).orElseThrow();
}

4つの問題:

1. 循環参照: UserOrder の双方向マッピングがあると JSON の無限シリアライズ → StackOverflow。
2. 機密情報の露出: passwordinternalNote などのフィールドが 自動的に公開される
3. API スキーマと DB スキーマの結合: DB のカラム名を変えると API も壊れる
4. 不要なクエリ: Jackson が Lazy フィールドをシリアライズしようとして 追加クエリが発生する

DTO パターン

java
@Getter @Builder
public class UserResponse {
    private Long id;
    private String email;
    private String name;
    private int orderCount;

    public static UserResponse from(User u) {
        return UserResponse.builder()
            .id(u.getId())
            .email(u.getEmail())
            .name(u.getName())
            .orderCount(u.getOrders().size())
            .build();
    }
}

@GetMapping("/{id}")
public UserResponse get(@PathVariable Long id) {
    User u = userRepository.findById(id).orElseThrow();
    return UserResponse.from(u);
}

スタティックファクトリメソッド (from) が変換の責務を持ち、Entity と DTO が 互いを知らない関係 を保ちます。

入力用 DTO も分離する

java
@Getter @Setter
public class UserCreateRequest {
    @Email
    @NotBlank
    private String email;

    @Size(min = 2, max = 50)
    private String name;
}

@PostMapping
public UserResponse create(@Valid @RequestBody UserCreateRequest req) {
    User u = User.builder().email(req.getEmail()).name(req.getName()).build();
    return UserResponse.from(userRepository.save(u));
}

@ValidBean Validation (@Email@NotBlank@Size) を自動実行。バリデーション失敗時は 400 Bad Request が自動返却されます。

MapStruct — 変換コードを自動生成する

DTO が増えると from() メソッドも増えていきます。MapStruct ライブラリは コンパイル時に変換コードを生成 し、ボイラープレートを排除します。

java
@Mapper(componentModel = "spring")
public interface UserMapper {
    UserResponse toResponse(User u);
    User toEntity(UserCreateRequest req);
}

中・大規模プロジェクトの標準手法 です。最初は手で書き、慣れてきたら導入しましょう。

🤖 AI にこう依頼してみましょう

このレッスンの概念を理解すれば、AI に 具体的に 指示できるようになります。漠然とした「直して」ではなく、語彙を持ったリクエスト — それがトークン節約の出発点です。

  • 「この findAll + ループが N+1 になっているので @EntityGraph で解決して」
  • 「この Entity を UserResponse DTO に変換する static factory メソッドを追加して」
  • 「JpaRepository に findByEmailAndActive メソッドのシグネチャを追加して」

なぜこれがトークンを削減するのか

概念を知らないままだと、AI の回答を受け取っても 「それって何ですか?」 とまた聞き直すことになります。その「聞き直し」がトークンを消費します。概念を一度しっかり理解しておけば、会話が一度で完結 します。

Spring Data JPA 実践 — Repository · N+1 · DTO 変換 - Spring Boot