コードの背景を考える 第6章 N+1・JOIN・ビュー

正しく動くのに、なぜ書き直す? N+1問題から考えるJavaとDBの役割分担

私がまだ新米の研修講師だったころ、あるJava研修で印象に残る出来事がありました。

受講生向けに用意されていた模範回答のコードを、ベテラン講師が確認していました。

その中に、概念的には次のような処理がありました。

for (CashEntryDTO entry : entries) {
    String categoryName =
            categoryDAO.getCategoryName(
                    entry.getCategoryId());
}

取引データを1件ずつ処理し、その取引が持っている分類IDから分類名を取得するコードです。

当時の私には、特におかしなコードには見えませんでした。

実際に動かしても正しい分類名が表示されます。研修用のサンプルデータは少数だったため、処理も一瞬で終わりました。

ところが、そのコードを見たベテラン講師は問題に気づき、急遽、模範回答を書き直しました。

正しく動いているのに、なぜ書き直す必要があるのだろう?

当時の私には、その理由が分かりませんでした。

今なら、それが典型的なN+1問題につながるコードだったと分かります。

しかしこの記事では、最初から「N+1問題だから駄目」と結論だけを覚えるのではなく、当時の私がベテラン講師から説明を受けて納得した順番で、何が問題だったのかを考えてみたいと思います。


Javaから見ると、ごく自然なコードだった

改めてコードを見ます。

for (CashEntryDTO entry : entries) {
    String categoryName =
            categoryDAO.getCategoryName(
                    entry.getCategoryId());
}

Javaの処理として考えると、とても自然です。

取引を1件取り出す
        ↓
分類IDを取得する
        ↓
分類名を取得する
        ↓
次の取引へ

Javaを学んできた人にとって、for文で1件ずつ処理するのは、むしろ分かりやすい考え方です。

問題はfor文そのものではありません。

ポイントは、

categoryDAO.getCategoryName(...)

の向こう側で何が起きているかです。

例えば、このメソッドの中で次のようなSQLを実行していたとします。

SELECT category_name
FROM categories
WHERE category_id = ?;

すると、categoryDAO.getCategoryName(...)は単なるJavaのメソッド呼び出しではありません。

1回呼び出すたびに、データベースへSELECT文を1回実行する処理です。

ここで、Java側だけを見ていたときとは景色が変わります。


ベテラン講師は何を見抜いたのか

例えば、最初に5件の取引データを取得したとします。

取引1 → category_id = 4
取引2 → category_id = 7
取引3 → category_id = 4
取引4 → category_id = 5
取引5 → category_id = 4

まず、取引一覧を取得するSQLが1回実行されます。

取引一覧を取得    1回

その後、Javaのfor文の中で、各取引の分類名を取得します。

取引1の分類名を取得    1回
取引2の分類名を取得    1回
取引3の分類名を取得    1回
取引4の分類名を取得    1回
取引5の分類名を取得    1回

合計すると、

取引一覧の取得      1回
分類名の取得        5回
------------------------
合計                6回

です。

取引件数をN件とすれば、

一覧取得            1回
各データの追加取得  N回
------------------------
合計              N+1回

となります。

このように、最初の問い合わせでN件を取得し、そのN件それぞれについて追加の問い合わせを行ってしまう構造が、一般にN+1問題と呼ばれます。

当時の私は「N+1問題」という名前を知りませんでした。

しかし、ベテラン講師からSQLの実行回数を説明されると、名前を知らなくても問題の構造は理解できました。

1件ずつ処理する
        ↓
そのたびにSQLを実行する
        ↓
データ件数に比例してSQLも増える

問題の名前を覚えることより、まずこの構造を見抜けることの方が大切です。


なぜ私は問題に気づかなかったのか

理由の一つは単純です。

サンプルデータが少なかったからです。

5件ならSQLは6回です。

10件でも11回です。

ローカルPC上の研修環境でこの程度のSQLを実行しても、人間にはほとんど差が分かりません。

実行ボタンを押せば、結果はすぐに表示されます。

しかし、データが増えると状況は変わります。

100件
   ↓
101回

1,000件
   ↓
1,001回

10,000件
   ↓
10,001回

少量のテストデータでは、問題そのものが隠れていたのです。

ベテラン講師の説明を聞いたとき、私は、

これは、動作テストは合格でも、本番では不合格になり得るコードの典型なのだ

と思いました。

より正確に言えば、少量データを使った機能確認では問題なくても、本番規模のデータでは性能上の問題が表面化する可能性があるコードです。

期待した分類名が正しく表示されたからといって、それだけで十分に良い実装とは限りません。

正しい結果が出る
        ≠
本番で十分に良い実装

ということです。


問題はSQL文の数だけではない

N+1問題を、

SQL文をたくさん実行するから遅い

とだけ覚えると、少し本質を見失います。

Javaからデータベースへ問い合わせるときには、概念的には次のようなやり取りが発生します。

Java
  ↓
JDBC
  ↓
データベースへ問い合わせ
  ↓
SQLを実行
  ↓
結果を返す
  ↓
Java

これをループのたびに繰り返せば、

Java → DB → Java
Java → DB → Java
Java → DB → Java
Java → DB → Java
...

という往復が、データ件数に応じて増えていきます。

さらに先ほどの例では、

取引1 → category_id = 4
取引3 → category_id = 4
取引5 → category_id = 4

です。

つまり、「食費」のような同じ分類名を取得するために、同じようなSELECTを何度も実行している可能性もあります。

ここまで見えてくると、ベテラン講師が模範回答を書き直した理由が分かってきます。


問題なのはfor文ではない

ここで一つ注意しておきたいことがあります。

この記事を、

for文の中でDAOを呼んではいけない

というルールとして覚えるのは適切ではありません。

for文そのものが悪いわけではありません。

DAOをループから呼び出すことが、どんな場合でも間違いというわけでもありません。

問題なのは、ループの中でデータベースへの問い合わせが繰り返され、その回数がデータ件数に比例して増えていく構造です。

見るべきなのはコードの形ではなく、

このループは、データが増えたときに何を繰り返すのか?

です。


ベテラン講師は「今の5件」ではなく「将来のN件」を見ていた

今振り返ると、当時の私とベテラン講師では、同じコードを見ながら見ていたものが違いました。

当時の私

コードを実行する
    ↓
正しい結果が出る
    ↓
すぐ終了する
    ↓
問題なし

一方、ベテラン講師は、

コードの構造を見る
    ↓
ループ内でDAOを呼んでいる
    ↓
その先でSELECTしている
    ↓
データ件数に比例して
SQL実行回数が増える
    ↓
本番では問題になる可能性

と見ていたのでしょう。

私は「今ある数件のデータ」を見ていました。

ベテラン講師は、このコードに将来何件のデータが流れてくるかを見ていました。

経験者は、コードが「今どう動くか」だけでなく、データ量が増えたときに動き方がどう変化するかまで見ているのだと感じました。

では、この処理はどう直せばよいのでしょうか。


Javaの中で何とかする前に考える

同じ分類を何度も取得しているなら、

一度取得した分類名をJava側で覚えておけばよいのではないか

と考えることもできます。

あるいは、

最初にcategoriesテーブルを全部取得して、Java側で分類IDと分類名を対応付けておけばよい

という方法も考えられます。

これらが常に間違いというわけではありません。

しかし今回、本当に欲しいものを考えてみます。

欲しいのは、

取引日
金額
メモ
分類ID
分類名

が揃った取引一覧です。

それなのに、

まず取引を取得する
        ↓
Javaへ返す
        ↓
分類IDを見る
        ↓
もう一度DBへ問い合わせる

という手順を取っていました。

ここで発想を変えます。

そもそも、なぜ最初のSELECTで分類名まで一緒に取得しなかったのだろう?

という問いです。


データベースにまとめて仕事を任せる

cash_entriesにはcategory_idがあります。

categoriesには、同じcategory_idcategory_nameがあります。

cash_entries.category_id
        │
        │ 同じ値で関連付ける
        ↓
categories.category_id

ならば、データベースへ、

取引と分類をcategory_idで結び付けて、必要なデータをまとめて返してほしい

と依頼できます。

SQLではJOINを使えます。

例えば概念的には、次のようなSQLです。

SELECT
    ce.entry_id,
    ce.entry_date,
    ce.category_id,
    c.category_name,
    ce.income,
    ce.expense,
    ce.note
FROM cash_entries ce
JOIN categories c
    ON ce.category_id = c.category_id;

これなら、

取引一覧を取得
        ↓
各取引について分類を追加取得

ではなく、

取引と分類を結合した結果を
最初からまとめて取得

できます。

問い合わせ回数だけを比較すれば、

修正前

取引一覧取得    1回
分類名取得      N回
-------------------
合計          N+1回

から、

修正後

取引と分類をJOINして取得
        ↓
        1回

へ変えられます。

もちろん、

JOINを使えばどんな場合でも必ず高速になる

という意味ではありません。

今回のポイントは、JavaとDBの間をデータ件数に応じて何度も往復する構造をなくし、必要なデータをまとめて取得できるようにすることです。


直すべきだったのはfor文ではなく、仕事の分け方だった

ここで、私にとって一番大きかった気づきがあります。

最初は、

Javaのfor文が問題
        ↓
Javaコードをどう直すか

と考えたくなります。

しかし今回の本質は、

なぜJavaが1件ずつ
分類名を取得しているのか
        ↓
この仕事をDBに
まとめて任せられないか

でした。

本来データベースが得意な、データ同士を関連付けて、必要な結果をまとめて作るという仕事を、Java側で1件ずつ行おうとしていたのです。

直すべきだったのはfor文そのものではなく、Javaとデータベースの仕事の分け方でした。


Javaは1件ずつ考えやすく、SQLは集合で考えやすい

N+1問題が起きやすい理由の一つは、JavaとSQLでは、処理を考えるときの感覚がかなり違うことだと思います。

Javaでは、

1件取り出す
    ↓
処理する
    ↓
次の1件
    ↓
処理する

という手続き的な考え方が自然です。

一方、SQLでは、

SELECT ...
FROM ...
JOIN ...
WHERE ...;

のように、どのようなデータの集合が欲しいかを指定し、データベースにまとめて処理してもらうことができます。

Java

1件
 ↓
処理
 ↓
次の1件
 ↓
処理


SQL

必要なデータの集合を指定
          ↓
DBがまとめて結果を作る

Javaで1件ずつ処理する考え方を、そのままデータベースアクセスにも持ち込むと、N+1のような構造が生まれることがあります。

だからこそ、

これは本当にJavaが1件ずつ行うべき仕事なのか?

と考えることが重要になります。


N+1を見つけたら、何でもJOINすればよいのか

もちろん、そう単純ではありません。

状況によっては、

  • JOIN
  • 一括取得
  • IN
  • キャッシュ
  • 事前ロード
  • ORMのフェッチ方法の変更

など、さまざまな解決方法があります。

今回のケースでは、

取引一覧と、その分類名を同時に表示したい

という目的なので、関連する2つのテーブルをJOINして一度に取得するのが素直です。

大切なのは、

N+1を見つけたらJOIN

という公式を暗記することではありません。

データ件数に比例して問い合わせが増えていることに気づいたら、

まとめて取得できないか?

と考えることです。


JOINした結果も、JavaではDTOとして受け取れる

JOINすると、検索結果には、

entry_id
entry_date
category_id
category_name
income
expense
note

のように、複数のテーブルから取得した列が含まれます。

Java側では、その1行分をDTOへ変換できます。

JOINしたResultSetの1行
        ↓
表示に必要なデータを持つDTO

DTOは、必ずしも「1テーブルの1行をそのまま写すもの」である必要はありません。

今回のように、複数テーブルをJOINした検索結果に合わせたデータの入れ物として使うこともできます。

この点は、以前扱った、

ResultSetの1行
        ↓
DTO 1個

という考え方の延長線上にあります。


良いデータベースプログラムを書くには、Javaの向こう側を見る

当時、ベテラン講師は模範回答を急遽書き直しました。

今振り返ると、単に「N+1問題という有名な言葉を知っていたから」直したのではないと思います。

コードを見た瞬間、

ループ
  ↓
DAO呼び出し
  ↓
SQL

という構造から、

データ件数が増える
        ↓
SQL実行回数も増える

ことを想像していたのでしょう。

良いデータベースプログラムを書くには、Javaコードを読むときにも、その向こう側でデータベースが何をしているか想像する必要があります。

categoryDAO.getCategoryName(
        entry.getCategoryId());

は、Javaから見ればただのメソッド呼び出しです。

しかし、その向こうには、

Java
 ↓
DAO
 ↓
JDBC
 ↓
SQL
 ↓
PostgreSQL
 ↓
検索
 ↓
結果をJavaへ返す

という処理があります。

Javaコードの1行が、DB側でどのような仕事を発生させるのか。

そこまで想像することが、データベースプログラミングでは重要なのだと思います。


「正しく動く」から、もう一歩先を見る

あのとき、研修用のサンプルデータでプログラムを実行すると、結果はすぐに表示されました。

表示内容も正しかった。

当時の私には、模範回答を書き直す理由が分かりませんでした。

しかし今なら、ベテラン講師が見ていたものが少し分かります。

私は「目の前の実行結果」を見ていました。

ベテラン講師は、そのコードがデータ件数の増えた本番環境でどう動くかを見ていました。

N+1問題を知ったことで、

forの中でSQLを発行しない

というルールを一つ覚えることもできます。

しかし、それだけではまたコードの「形」を暗記することになってしまいます。

覚えたいのは、

このループは、データ件数が増えたときに何を繰り返すのか?

と考える習慣です。

そしてもう一つ、

この仕事は、本当にJavaがするべきなのか? データベースにまとめて任せるべきではないか?

と考える習慣です。


ベテラン講師の言葉で、今も覚えていること

このとき、ベテラン講師が言っていた言葉で、今も印象に残っているものがあります。

「受講生が、データベースの設計に関与することは当分ないだろうから、今、しっかりと教えておきたい」

当時の受講生が、研修を終えてすぐにデータベース設計を任される可能性は高くなかったのでしょう。

それでも、ベテラン講師はこの問題を見逃さず、模範回答を書き直しました。

今振り返ると、彼が教えたかったのは、JOINの書き方だけではなかったのだと思います。

Javaのコードを書く側であっても、データベースがどのように動くのかを意識する。

自分がDB設計を担当していなくても、その設計や特性を理解したうえでコードを書く。

その姿勢を、受講生が現場に出る前に身につけさせたかったのではないか。

私は、あの言葉を今ではそのように受け取っています。

N+1問題は、単なるSQLの性能問題ではありません。

Javaだけを見てコードを書くのではなく、そのコードがデータベースへどのような仕事を依頼しているのかを考えるための、よい教材でもあります。

現場で設計を担当するようになってから初めて学ぶのではなく、実装担当の段階から、Javaの向こう側にあるデータベースまで想像してコードを書く。

それが、N+1問題から私が学んだもう一つの教訓でした。