【Java/JPA】N+1問題の概要と解決方法



### はじめに

N+1問題とは、主にデータベースからのデータ取得時に起こる不要に多くのクエリが発生する問題のことです。たとえば、あるテーブルAからN件のデータを取得し、その各レコードに関連するテーブルBから追加の情報を個別に取得するような場合、合計でN+1回のクエリ（最初の1回 + N回）が実行されてしまうケースを指します。Java（特にJPA/Hibernate）でよく起こるN+1問題の例と、その解決方法を示します。

### 1. 前提：エンティティ定義

```java
import javax.persistence.*;
import java.util.List;

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    // デフォルトでは Lazy Fetch
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Post> posts;

    // getter / setter
    public Long getId() { return id; }
    public String getName() { return name; }
    public List<Post> getPosts() { return posts; }
    public void setId(Long id) { this.id = id; }
    public void setName(String name) { this.name = name; }
    public void setPosts(List<Post> posts) { this.posts = posts; }
}
```

```java
import javax.persistence.*;

@Entity
@Table(name = "posts")
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    // 多対1でUserを参照
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    // getter / setter
    public Long getId() { return id; }
    public String getTitle() { return title; }
    public User getUser() { return user; }
    public void setId(Long id) { this.id = id; }
    public void setTitle(String title) { this.title = title; }
    public void setUser(User user) { this.user = user; }
}
```

- User エンティティは複数の Post を持ち、`@OneToMany(mappedBy = "user")` で関連付けています。
- どちらも `fetch = FetchType.LAZY` になっているため、実際に `getPosts()` を呼び出したタイミングで DB にクエリを投げます。

### 2. N+1問題の例

```java
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.List;

@Service
public class UserService {

    @PersistenceContext
    private EntityManager em;

    public List<User> findAllUsersNaive() {
        // まず全Userを取得 (ここでは1回のクエリ)
        List<User> users = em.createQuery("SELECT u FROM User u", User.class)
                             .getResultList();

        // 全ユーザーのpostsにアクセスしたタイミングで、各ユーザーごとにSQL発行 (N回)
        for (User user : users) {
            List<Post> posts = user.getPosts(); 
            // ここで LAZY ロードにより 「SELECT * FROM posts WHERE user_id=?」 が発行される
            // ユーザー数がN人なら、N回クエリが走る
        }

        return users;
    }
}
```

1. `SELECT u FROM User u` → 1回 のクエリ
2. 取得したUserがN件ある場合、`user.getPosts()` を呼ぶたびに `SELECT * FROM posts WHERE user_id = ?` → N回 のクエリ
3. 合計 N+1回 のクエリが発行される

このようにユーザー数が増えるほどクエリが指数的に増加し、アプリケーション性能が著しく低下します。

### 3. 解決策：Fetch Join を使って一括取得

N+1問題を回避する方法の1つが、JPQLでJOIN FETCHを使う（または Spring Data JPA のメソッド構造を使う）や、`@EntityGraph` を用いて関連テーブルを Eager Loading（事前読み込み）する方法です。

#### 3.1 JPQL の Fetch Join 例

```java
@Service
public class UserService {

    @PersistenceContext
    private EntityManager em;

    public List<User> findAllUsersWithPosts() {
        // Userと紐づくPostをJOIN FETCHでまとめて一括取得
        // N+1問題を回避し、クエリは多くて2回程度 (実際には1回)
        List<User> users = em.createQuery(
            "SELECT u FROM User u JOIN FETCH u.posts",
            User.class
        ).getResultList();

        // forループで user.getPosts() を呼んでも、すでに取得済み
        for (User user : users) {
            List<Post> posts = user.getPosts(); 
            // 追加のSQLクエリは発行されない
        }

        return users;
    }
}
```

- `JOIN FETCH u.posts` により、ユーザーと投稿を 1回のクエリ でまとめて取得します。
- これにより、`getPosts()` を呼び出しても新しいクエリは発行されず、N+1 を防げます。

#### 3.2 Spring Data JPA の @EntityGraph 例

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

    @EntityGraph(attributePaths = "posts")
    @Query("SELECT u FROM User u")
    List<User> findAllWithPosts();
}
```

- `@EntityGraph(attributePaths = "posts")` を使うと、内部的に フェッチジョイン を使って取得してくれます。
- コードをよりシンプルに書きたい場合に有効。

### 4. まとめ

#### 4-1. N+1問題
- ユーザー一覧を取得後、各ユーザーの紐づく投稿を個別に取得するため合計 N+1 回のクエリが走る状態を指す
- パフォーマンスが大きく低下し、スケールしなくなる

#### 4-2. 解決策
- フェッチジョイン（JOIN FETCH）でまとめて取得
- `@EntityGraph` などで Eager Loading
- バッチ読み込み（IN句でまとめて取得）など
- DBスキーマやアプリ設計を見直す

#### 4-3. ポイント
- Java（JPA/Hibernate）ではデフォルト LAZY フェッチが多く、無意識に `getXxx()` を呼ぶとクエリが発行される
- 1対多 のリレーションで N+1 は起こりやすい
- 大量データを扱う場面では特に注意し、SQL発行回数をログなどで確認する習慣を持つ

N+1問題 はアプリケーション開発において非常に一般的なパフォーマンス問題ですが、適切に フェッチジョイン や Eager Loading を使うことで解決できます。Java（JPA/Hibernate）の場合はJPQLの JOIN FETCH や Spring Data JPA の `@EntityGraph` を活用して、一括取得を行いましょう。