はじめに
Spring Data JPA を使っていると、
「なんか遅い…?」 という場面に出会うことがあります。
- SQL を書いていないのに大量のクエリが発行される
- 画面表示が遅い
- 本番だけパフォーマンスが落ちる
- ローカルでは気づかない
その原因のひとつが N+1問題 です。
この記事では、N+1問題を
やさしく・実務寄り で整理していきます。
N+1問題とは
一言でいうと、
「1回のつもりが、実は N+1 回クエリが発行されてしまう問題」
です。
例えるなら
- 「1回で全部取ってきてね」と頼んだのに
- 「1回取ってきて、残りは1件ずつ取りに行きますね」
と勝手に分割されるイメージ。
具体例で理解する(最も分かりやすいパターン)
User と Post の関係
- User(1)
- Post(多)
コード
List<User> users = userRepository.findAll();
for (User user : users) {
System.out.println(user.getPosts().size());
}発行される SQL
- User を全部取得するクエリ(1回)
- 各 User の posts を取得するクエリ(N回)
合計 N+1回 のクエリが発行されます。
なぜ N+1 が起きるのか
原因は Lazy ロード(遅延ロード) です。
Lazy の動き
- User を取得したとき → Post はまだ取得しない
- user.getPosts() を呼んだ瞬間に SQL が発行される
Hibernate は
「必要になったら取りに行くね」
という賢い動きをしますが、
ループの中で呼ぶと大量の SQL が発行されます。
N+1 が起きやすいパターン
@OneToMany(多側の取得)
最も多い。
@ManyToOne(親を何度も取りに行く)
意外と起きる。
画面表示でループして関連データを参照
一覧画面でよく発生。
Service 層で複数の関連を参照
複雑なロジックで発生。
N+1問題の何が悪いのか
- クエリが大量に発行される
- DB への負荷が増える
- ネットワークの往復が増える
- 本番環境で顕著に遅くなる
- ローカルでは気づきにくい
静かに性能を奪う“隠れバグ” のような存在です。
N+1問題の解決方法(実務で使う順)
Fetch Join を使う(最も一般的)
@Query("SELECT u FROM User u JOIN FETCH u.posts")
List<User> findAllWithPosts();User と Post を 1回のクエリでまとめて取得 できます。
EntityGraph を使う(アノテーションで解決)
@EntityGraph(attributePaths = "posts")
List<User> findAll();Repository に書くだけで Lazy を上書きできます。
DTO で必要なデータだけ取得する
@Query("SELECT new com.example.UserDto(u.id, u.name, p.title) ...")
List<UserDto> findUserWithPost();画面用のデータは DTO に寄せると効率的。
Lazy のままバッチサイズを設定する(Hibernate の機能)
spring.jpa.properties.hibernate.default_batch_fetch_size: 100Hibernate がまとめて取得してくれるため、
N+1 が 1+1 に近づきます。
実務での判断基準
Fetch Join を使うべき場面
- 一覧画面
- 関連データを必ず使う場面
- N+1 が確実に起きる場面
EntityGraph を使うべき場面
- Repository のメソッド単位で制御したい
- コードをシンプルにしたい
DTO を使うべき場面
- 画面表示用のデータが複雑
- JOIN が多い
- パフォーマンスが重要
バッチサイズを使うべき場面
- 既存コードを大きく変えたくない
- Lazy を維持したい
N+1 を見つける方法
SQL ログを出す
spring.jpa.show-sql: trueHibernate の SQL フォーマットを有効にする
spring.jpa.properties.hibernate.format_sql: trueログを DEBUG にする
logging.level.org.hibernate.SQL: DEBUGSQL が大量に出ていたら N+1 の可能性が高いです。
よくあるつまずきポイント
FetchType.EAGER にすれば解決する?
→ しません。 EAGER は別の問題(予期せぬ JOIN)を生むため非推奨。
@OneToMany に Fetch Join を使うと重複が出る
→ DISTINCT を付けると解決。
DTO にすると Entity のメリットが消える?
→ 画面用は DTO、内部は Entity が基本。
まとめ
N+1問題とは、
「1回のつもりが、実は N+1 回クエリが発行される問題」
です。
- Lazy ロードが原因
- 一覧画面で起きやすい
- 本番で性能劣化を引き起こす
- Fetch Join / EntityGraph / DTO / バッチサイズで解決
難しく聞こえますが、
「関連データをループで参照すると N+1 が起きる」
と理解できれば十分です。

N+1問題は、最初は気づきにくいですが、
“仕組み” を理解すると落ち着いて対処できるようになります。
パフォーマンス改善の第一歩として、
とても価値のある知識です。
あなたの開発が、今日より少しだけ楽になりますように。

1回でいいのに、何回も取りに行っちゃうなんて…
かわいいけど困っちゃうね。


コメント