始めに
今日は、今やってるプロジェクトでN+1の問題が発生したことをお書きしたいと思います。
今やってるプロジェクトの簡単な紹介
ドラマや映画のレビューを作成するWEBサービスを開発しています。
条件
現在、ドラマレビューのテーブルには4件のレビューがあり、会員は2名として事前にデータを保存しました。
ドラマレビューのテーブルの構成です。簡単に確認できるように、1人のメンバーが2つのドラマに対して合計4件のレビューを作成したと仮定しました。
Entity
- ドラマレビューのEntity
@Getter
@Entity
@ToString(exclude ={"drama", "member"})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class DramaReview {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
private Integer rating;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "drama_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private Drama drama;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "member_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private Member member;
@Builder
public DramaReview(String title, String content, Integer rating, Drama drama, Member member) {
this.title = title;
this.content = content;
this.rating = rating;
this.drama = drama;
this.member = member;
}
}
現在、ドラマと映画はリレーションで結びついており、fetch
オプションがEager
に設定されています。
デフォルトではEager
が適用されているため、即時ローディングが行われ、関連付けられたエンティティを使用しない場合でも、すべてのデータを一緒に取得してしまいます。
これはパフォーマンスに悪影響を与える可能性がありますが、その理由を詳しく調べてみましょう。
ControllerとServiceは次のように作成してみましょう。
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/drama/review")
public class FindDramaReviewController {
private final FindDramaReviewService findDramaReviewService;
@GetMapping
public List<DramaReview> findAllDramaReviews() {
return findDramaReviewService.findAllDramaReview();
}
}
N+1問題だけでなく、Controllerで直接Entityを返していたため、理想的なコードではありませんでした。
分かってはいたものの、実際にコードを書くとついミスをしてしまいましたね。
@RequiredArgsConstructor
@Service
public class FindDramaReviewService {
private final DramaReviewRepository dramaReviewRepository;
public List<DramaReview> findAllDramaReview() {
return dramaReviewRepository.findAll();
}
}
Serviceレイヤーでも、Spring Data JPAのfindAll
をそのまま使用しました。
Postmanを使って
http://localhost:8080/api/drama/review
にAPIリクエストを送ると、コンソールにどれだけのクエリが発行されるかに注目する必要があります。
2025-02-20T09:19:30.782+09:00 DEBUG 24560 --- [nio-8080-exec-1] org.hibernate.SQL
select
dr1_0.id,
dr1_0.content,
dr1_0.drama_id,
dr1_0.member_id,
dr1_0.rating,
dr1_0.title
from
drama_review dr1_0
2025-02-20T09:19:30.786+09:00 DEBUG 24560 --- [nio-8080-exec-1] org.hibernate.SQL
select
d1_0.drama_id,
d1_0.channel_platforms,
d1_0.director,
d1_0.drama_genre,
d1_0.episode_count,
d1_0.ott_platforms,
d1_0.released_date,
d1_0.title
from
drama d1_0
where
d1_0.drama_id=?
2025-02-20T09:19:30.791+09:00 DEBUG 24560 --- [nio-8080-exec-1] org.hibernate.SQL
select
m1_0.id,
m1_0.name
from
member m1_0
where
m1_0.id=?
2025-02-20T09:19:30.793+09:00 DEBUG 24560 --- [nio-8080-exec-1] org.hibernate.SQL
select
d1_0.drama_id,
d1_0.channel_platforms,
d1_0.director,
d1_0.drama_genre,
d1_0.episode_count,
d1_0.ott_platforms,
d1_0.released_date,
d1_0.title
from
drama d1_0
where
d1_0.drama_id=?
たったドラマレビューのテーブル全件を取得するだけなのに、これほど多くのクエリが同時に発行され、サーバーに負担をかけることになります。
その理由は、先ほどのレビューエンティティでEager
を設定していたため、レビューを取得する際に、関連するドラマや会員のエンティティも即時にロードされるからです。
まるでサツマイモを掘ったら、ツルに繋がってどんどん引きずられてくるような状態です。
自分では「ドラマレビューのテーブルを取得するクエリ」を1つ発行したつもりだったのに、実際にはドラマテーブルや会員テーブルにも追加でクエリが発行されてしまいました。
なぜこんなに多くのクエリが発行されるのか?!
私は理解が少し遅い方なので、クエリの数がどう増えるのかがよく分かりませんでした。
そこで、自分なりの理解のプロセスを整理してみます。
select
dr1_0.id,
dr1_0.content,
dr1_0.drama_id,
dr1_0.member_id,
dr1_0.rating,
dr1_0.title
from
drama_review dr1_0
- まず、findAll() を実行したので、ドラマレビューのデータを取得します。
select
d1_0.drama_id,
d1_0.channel_platforms,
d1_0.director,
d1_0.drama_genre,
d1_0.episode_count,
d1_0.ott_platforms,
d1_0.released_date,
d1_0.title
from
drama d1_0
where
d1_0.drama_id=?
- ドラマレビューエンティティのdramaフィールドがEagerになっているため、各レビューに関連するドラマの情報も取得されます。
ここで注意すべき点は、現在ドラマレビューのテーブルにはドラマが2つしかないということです。
つまり、1行目のレビューがドラマ1を初めて取得する際に、JPAの永続化コンテキスト(Persistence Context)にドラマ1が保存されるため、2行目のレビューが同じドラマ1を参照する際には、追加のクエリは発行されません。
したがって、ドラマ1に関するクエリは最初の1回だけ発行され、同様にドラマ2に関するクエリも最初の1回だけ発行されます。
- 会員照会
select
m1_0.id,
m1_0.name
from
member m1_0
where
m1_0.id=?
メンバー1の値を最初に取得し、永続化コンテキストに保存します。したがって、追加でクエリを発行する必要はありません。
- ドラマ2も取得する。
select
d1_0.drama_id,
d1_0.channel_platforms,
d1_0.director,
d1_0.drama_genre,
d1_0.episode_count,
d1_0.ott_platforms,
d1_0.released_date,
d1_0.title
from
drama d1_0
where
d1_0.drama_id=?
ドラマ2に関するドラマエンティティも取得します。これも3行目で取得され、永続化コンテキストに保存されるため、4行目のドラマ2を取得するために追加のクエリを発行する必要はありません。
N+1の理由
重要であり、今後問題が発生すると考えられる点は次の2つです。
-
ドラマレビューのみ取得したい場合、会員やドラマテーブルのクエリを発行する必要がありません。
これにより、サーバーのパフォーマンスが最悪になります。 -
現在も、ドラマレビュー1件の取得に加えて(ドラマのクエリ2回 + 会員のクエリ1回)が発生しており、実質的に「1+N」クエリが発生しているため、N+1問題と呼ばれています。
では、テーブルに会員が4人、レビューも4件、ドラマもそれぞれ4件保存されている場合、何回のクエリが発行されるでしょうか?
例を考えてみます。
例:
- ドラマ: トッケビ、梨泰院クラス、五月の青春、分かっていても
- 会員: キム・ゴウン、キム・ダミ、コ・ミンシ、ハン・ソヒ
- ドラマレビュー: 1,2,3,4(各会員が1つずつ作成)
私はドラマレビューを全件取得するために、1回のSELECT
を実行しました。
→ SELECT * FROM review;
(1回)
次に、各レビューのドラマ情報と会員情報を取得するための追加のクエリが発行されます。
1行目のレビュー(トッケビ)について、ドラマテーブルから情報を取得
→ SELECT * FROM drama WHERE id = 1;
1行目のレビューの投稿者(キム・ゴウン)の情報を取得
→ SELECT * FROM member WHERE id = 1;
2行目のレビュー(梨泰院クラス)のドラマ情報を取得
→ SELECT * FROM drama WHERE id = 2;
2行目のレビューの投稿者(キム・ダミ)の情報を取得
→ SELECT * FROM member WHERE id = 2;
3行目のレビュー(五月の青春)のドラマ情報を取得
→ SELECT * FROM drama WHERE id = 3;
3行目のレビューの投稿者(コ・ミンシ)の情報を取得
→ SELECT * FROM member WHERE id = 3;
4行目のレビュー(わかっていても)のドラマ情報を取得
→ SELECT * FROM drama WHERE id = 4;
4行目のレビューの投稿者(ハン・ソヒ)の情報を取得
→ SELECT * FROM member WHERE id = 4;
合計クエリ数:
- ドラマレビューの取得: 1回
- ドラマテーブルの取得: 4回
- 会員テーブルの取得: 4回
- 合計: 1 + 4 + 4 = 9回
つまり、**「ドラマレビューを4件取得しただけなのに、合計9回のクエリが発行される」**のがN+1問題の本質です。
しかし
しかし、ここで意外な発見がありました。
私がサービス設計の面で誤って考えていた点があります。
そもそも、レビューを取得する際に、会員情報とドラマ情報が必要になるケースがあるため、これらのテーブルも一緒に取得する必要がありました!
→ つまり、Lazy
に変更しても、結局後から追加のクエリが発行されることになるため、単純にLazy
に切り替えることは解決策になりません。
このフィードバックを受けて、fetch join
やSpring Batch
、あるいはQueryDSL
が必要であることが分かりました。
次回は、この3つの技術について学習した記録を残していきます。
感想
もともとN+1問題について何となく知ってはいましたが、それが具体的にどのように問題になるのかは理解できていませんでした。
しかし、今回の問題を通じて、どのように発生するのかを理解でき、とても嬉しく思います。
ただ、よく考えてみると「N+1」よりも「1+N」という表現の方が適切なのではないかとも感じます。