TT Lab
はじめる
学ぶ 学習パス コース

Spring Boot — クエリが何本出るか数える

楽な分だけ見えなくなる

TT Labで続きを見る

一言でいうと

Springの長所と短所は同じものです。多くのことが自動で起こります。だから、間違っていても自動的に間違います。

フィールド注入を使わない理由

@Service
public class ItemService {
    @Autowired private ItemRepository repo;   // ❌
}

便利に見えますが、4つのものを失います。

  1. finalが使えなくなります。生成後に変更できるフィールドになります
  2. テストで差し替えるにはリフレクションが必要になります。コンストラクターなら、そのまま渡すだけで済みます
  3. 依存関係がいくつあるか見えません。コンストラクターの引数が7つあれば「このクラスは多くのことをやりすぎている」と目に見えます。フィールドに散らすと見えません
  4. 循環参照が実行時まで隠れます。コンストラクター注入なら、起動時にすぐ失敗します
@Service
public class ItemService {
    private final ItemRepository repo;
    ItemService(ItemRepository repo) { this.repo = repo; }   // ✅
}

コンストラクターが1つなら、@Autowiredも不要です。Springが自動的に使います。

「起動時に失敗する」か「運用中に失敗する」かを選ぶ問題です。前者のほうがましです。

検証はコントローラーで終わらせる

public record ItemRequest(@NotBlank String name, @Min(1) int price) {}

@PostMapping("/items")
public ResponseEntity<?> create(@Valid @RequestBody ItemRequest req) { ... }

@Validがないと、アノテーションは何もしません。付けてあるのに検証が働かないケースの大半がこれです。

検証に失敗すると、SpringがMethodArgumentNotValidExceptionをスローし、デフォルトで400が返ります。自分でif (name == null)と書いているなら、フレームワークの仕事を奪っています。

例外をステータスコードに変える場所

@RestControllerAdvice
class ApiErrors {
    @ExceptionHandler(NotFoundException.class)
    ResponseEntity<Map<String, String>> notFound(NotFoundException e) {
        return ResponseEntity.status(404).body(Map.of("message", e.getMessage()));
    }
}

1か所にまとめることが要点です。コントローラーごとにtry-catchを散らすと、どの例外がどのコードで返るのか、誰にもわからなくなります。

また、return ResponseEntity.ok(Map.of("error", ...))のように200にエラーを入れてはいけません。ステータスコードは契約の一部です。クライアント・プロキシ・モニタリングがすべてそれを見て動作します。

@Transactionalの境界はどこか

@Transactional
public void bulkCreate(List<ItemRequest> reqs) { ... }

このメソッドが終わるときにコミットされ、実行時例外が外に出るとロールバックされます。知っておくべきことが3つあります。

1. チェック例外は、デフォルトではロールバックされません。IOExceptionをスローするとコミットされます。@Transactional(rollbackFor = Exception.class)を指定する必要があります。知らないと、「例外が発生したのにデータは保存されている」状態になります。

2. 自己呼び出し(self-invocation)は効きません。同じクラスの別のメソッドをthis.method()で呼び出すと、プロキシを経由しないため@Transactionalが無視されます。トランザクションがかからないのにエラーも出ません。最も見つけにくい種類の問題です。

3. privateメソッドには付きません。プロキシがオーバーライドできないためです。

そしてN+1

ここがこのコースの本題です。

List<Shop> shops = shopRepo.findAll();          // 쿼리 1개
for (Shop s : shops) {
    s.getItems().size();                        // 가게마다 1개씩 더
}

遅延ロード(FetchType.LAZY)は、実際に使うときにクエリを発行します。店が3軒なら4本、300軒なら301本のクエリが出ます。

開発時はデータが少なくて見えず、本番で一覧が大きくなると突然遅くなります。しかもアプリケーションログには異常が何もありません。クエリ1本1本はどれも速いからです。

直し方は次のとおりです。

@Query("select distinct s from Shop s join fetch s.items")
List<Shop> findAllWithItems();

1回の結合でまとめて取得します。クエリが1本になります。

数え方を知ることは、直し方を知ることより重要です。

spring.jpa.properties.hibernate.generate_statistics=true

そしてテストでStatistics.getPrepareStatementCount()を読めば、クエリ数をアサートできます。

assertThat(queries).isLessThanOrEqualTo(2);

これが入っていれば、後で誰かがjoin fetchを消してもテストが検出します。パフォーマンスのリグレッションをテストで防げる、数少ない方法です。

テストを層に分ける

アノテーション 起動するもの いつ使うか
@SpringBootTest コンテキスト全体 統合確認、少数だけ
@WebMvcTest Web層だけ コントローラー・検証・ステータスコード
@DataJpaTest JPA層だけ クエリ・マッピング

@WebMvcTestはサービスBeanを起動しないため、@MockitoBeanで差し込みます(Boot 3.4より前の名前は@MockBean)。コンテキスト全体を毎回起動するとテストが遅くなり、遅くなると誰も実行しなくなります。

本番で実際にはまる落とし穴

コネクションプールはスレッド数より小さくなっています。HikariCPのデフォルトは10ですが、Tomcatのスレッドは200です。遅いクエリ1本でプールを使い切ると、残りはすべて待たされます。そのときの症状は「DBが遅い」ではなく「アプリケーションが止まった」です。

open-in-viewがデフォルトで有効になっています。ビューのレンダリングまで永続コンテキストが開いたままなので、コントローラーの外で遅延ロードが起きても静かに動きます。便利ですが、コネクションをリクエストの間ずっと握り続けます。無効にして(spring.jpa.open-in-view=false)、必要なものをサービスの中ですべて読み込んでから返すほうがよいです。無効にすると、これまで隠れていたLazyInitializationExceptionが表に出ますが、それはもともとあった問題です。

Actuatorをそのまま公開してはいけません。/actuator/envと/actuator/heapdumpには機密情報が含まれています。内部ポートに移すか、認証をかけます。

なぜこれが問題なのか

Springで間違いが静かなのは、自動で行われることは、間違うときも自動だからです。フィールド注入はコンパイルも実行もでき、循環参照があっても実行時まで隠れます。@Transactionalは同じクラスの中から呼び出すと何もしませんが、その事実はどこにも表れません。例外が出ても、ロールバックされないだけです。

そのため、上の各節の共通点は1つです。間違ったときにすぐ気づけるようにすることです。コンストラクター注入は循環参照を起動時に失敗させ、検証をコントローラーで終わらせれば不正なリクエストがサービス層まで下りていかず、例外をステータスコードに変換する場所を1か所にまとめれば、その場所を見るだけでAPIの契約がわかります。

現場での姿

本番ではまる落とし穴には、たいてい開発環境で再現しないという共通点があります。N+1は開発DBに店が3軒しかないので見えず、コネクションプールの枯渇は同時ユーザーが1人のときは起きず、テストの分離の問題はテストを1つずつ実行するときには隠れます。

そのため、この3つはコードレビューで見つけるのではなく、数えられるようにして見つけます。クエリ数を数え、コネクションプールの使用率をメトリクスとして出力し、テストをランダムな順序で実行します。見えなかったものを見えるようにすることが、このモジュールのすべてです。