一覧APIを作っていて、レスポンスに必要なのは id と name の2カラムだけなのに、findAll() でエンティティを丸ごと取ってきていませんか。しかもそのあと手作業でDTOに詰め替えている、というコードはよく見かけます。
Spring Data JPAのプロジェクションを使うと、SELECT段階で必要な列だけを取り出してDTOやインターフェースに直接マッピングできます。今回はインターフェース射影・クラス(コンストラクタ)射影・動的射影の3方式を実際に動かしながら比較して、どれをいつ選ぶかまで整理していきます。
なぜエンティティを丸ごと取得すると無駄なのか
たとえば User エンティティが20カラム持っていて、さらに Department との関連もあるとします。一覧画面で表示したいのが名前と部署名だけだとしても、findAll() は基本的に全列を取ってきます。関連が EAGER ならJOINや追加クエリまで走ります。これが オーバーフェッチ です。
さらに取得したエンティティを UserListDto に詰め替えるコードを書くと、変換ロジックが積み上がっていきます。せっかく取った列の大半を捨てているうえ、詰め替えの手間まで払っているわけですね。
プロジェクションは「取得したあとに絞る」のではなく「SELECTの時点で絞る」アプローチです。発行されるSQLの SELECT 句が宣言したプロパティだけになるので、転送量も詰め替えコードも減らせます。
サンプルのエンティティとリポジトリ
以降で共通に使うエンティティを用意します。
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String firstName;
private String lastName;
private String email;
@ManyToOne(fetch = FetchType.LAZY)
private Department department;
// getter/setter 省略
}
@Entity
public class Department {
@Id @GeneratedValue
private Long id;
private String name;
}
射影が効いているかは発行SQLを見ないと判断できないので、必ずログを出しておきましょう。
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
「SELECT列が本当に絞られているか」をログで確認する。この習慣がプロジェクションを使ううえで一番大事です。クエリメソッドの書き方そのものはSpring Data JPAのクエリメソッド記事も参考にしてください。
インターフェース射影(クローズド射影)
いちばん手軽なのがインターフェース射影です。取得したいプロパティのgetterだけを宣言したインターフェースを作ります。
public interface UserSummary {
Long getId();
String getFirstName();
String getLastName();
}
あとはリポジトリメソッドの戻り型をこのインターフェースにするだけです。
public interface UserRepository extends JpaRepository<User, Long> {
List<UserSummary> findByLastName(String lastName);
}
これで発行されるSQLの SELECT 句は id, first_name, last_name の3列に絞られます。
select u.id, u.first_name, u.last_name from users u where u.last_name=?
宣言したプロパティ以外を参照しない射影を クローズド射影 と呼びます。Spring Dataが内部でプロキシを生成し、読み取り専用のビューとして返してくれます。setterはありませんし、そのままレスポンスに使える手軽さが魅力です。
オープン射影(@Value・SpEL)とその落とし穴
複数のプロパティを組み合わせた導出値が欲しいときは @Value とSpELが使えます。
public interface UserNameView {
@Value("#{target.firstName + ' ' + target.lastName}")
String getFullName();
}
これで getFullName() がフルネームを返します。便利なのですが、落とし穴があります。SpELの式は実行時に評価されるため、Spring Dataはどの列が必要か静的に判断できません。結果として エンティティの全列が取得され 、クローズド射影のSELECT最適化が無効になります。
つまりオープン射影を使った瞬間に「列を絞る」というプロジェクション本来のメリットは消えます。導出はアプリ側で行い、DBからは必要な列だけ取りたいなら、クローズド射影で素の値を取ってサービス層で組み立てるほうが素直です。
クラスベース(コンストラクタ)射影とレコードクラス
インターフェースではなく実体のあるDTOが欲しいなら、クラスベース射影です。コンストラクタの引数名とエンティティのプロパティ名を一致させると、Spring Dataがそこへマッピングしてくれます。Java 16以降なら record が最適です。
public record UserDto(Long id, String firstName, String lastName) {}
public interface UserRepository extends JpaRepository<User, Long> {
List<UserDto> findByEmail(String email);
}
発行SQLは3列に絞られ、返ってくるのはイミュータブルな UserDto です。インターフェース射影と違ってプロキシではなく本物のオブジェクトなので、そのままレスポンスに渡してもよいし、テストでも扱いやすいですね。
ただしクラス射影では後述のネストプロジェクションが使えず、コンストラクタ引数と列の対応が固定される点は覚えておきましょう。
動的射影で1メソッドを複数の戻り型に使い回す
画面ごとに必要な列が違うと、findByDepartment を戻り型別に何個も作りたくなります。これを避けるのが動的射影です。メソッドに Class<T> を渡します。
public interface UserRepository extends JpaRepository<User, Long> {
<T> List<T> findByDepartmentName(String name, Class<T> type);
}
呼び出し側で戻り型を切り替えます。
List<UserSummary> summaries = repo.findByDepartmentName("営業", UserSummary.class);
List<UserDto> dtos = repo.findByDepartmentName("営業", UserDto.class);
List<User> entities = repo.findByDepartmentName("営業", User.class);
渡した型に応じてSELECT列も変わります。同じ検索条件で用途別のDTOが欲しいとき、メソッドの増殖を一気に抑えられます。
ネストプロジェクションと関連エンティティの射影
関連エンティティのプロパティも射影できます。インターフェースの中に、関連用の射影インターフェースを入れ子で返します。
public interface UserWithDept {
String getFirstName();
DeptView getDepartment();
interface DeptView {
String getName();
}
}
これで部署名まで一緒に取れますが、注意点があります。ネストするとJOINや追加クエリが発行され、クローズド射影ほどきれいに列が絞られないことがあります。とくにコレクション関連(@OneToMany)をネストすると、要素ごとにクエリが飛ぶN+1的な挙動になりがちです。
深い階層や集計が必要になってきたら、無理にネスト射影で頑張らず次の @Query コンストラクタ式に寄せるのが安全です。N+1の詳しい話はJPAのパフォーマンス最適化記事も合わせてどうぞ。
@Query・ネイティブクエリと射影を併用する
JPQLでも射影できます。コンストラクタ式(new 演算子)を使い、DTOは 完全修飾名 で書くのがポイントです。
@Query("select new com.example.UserDto(u.id, u.firstName, u.lastName) " +
"from User u where u.department.name = :dept")
List<UserDto> findSummaries(@Param("dept") String dept);
インターフェース射影を @Query と組み合わせる場合は、SELECTのエイリアス名をインターフェースのプロパティ名に一致させる必要があります。
ネイティブクエリはさらにシビアです。SpEL(オープン射影)は効かず、列名とプロパティ名のマッピングが厳密になります。よくあるのが、次のように列別名を付け忘れて値が null になるミスです。
// first_name のままだと firstName に入らずnullになりがち
@Query(value = "select u.id, u.first_name as firstName, u.last_name as lastName " +
"from users u", nativeQuery = true)
List<UserSummary> findNative();
ネイティブでインターフェース射影を使うなら、as firstName のようにプロパティ名へ揃える別名を必ず付けましょう。
3方式の比較と選定基準
最後に整理します。
| 観点 | インターフェース射影 | クラス射影 | 動的射影 |
|---|---|---|---|
| SELECT列の絞り込み | クローズドなら◯(オープンは×) | ◯ | 渡す型に依存 |
| 戻り値の実体 | プロキシ(読み取り専用) | 実体DTO/record | 型次第 |
| @Query併用 | 別名合わせが必要 | コンストラクタ式で相性良 | 併用可 |
| ネスト対応 | ◯ | × | 型次第 |
選び方はこう考えると迷いません。単純な一覧でとにかく列を絞りたいなら クローズドなインターフェース射影 。レスポンスやテストで実体が欲しいなら クラス(record)射影 。画面ごとに列が変わるなら 動的射影 を軸にします。複雑なJOINや集計は @Query のコンストラクタ式へ寄せ、取得後の込み入った変換だけをMapStructなどのマッピングライブラリに任せる、という棲み分けがきれいです。
まとめ
プロジェクションの肝は「取得後に絞る」から「SELECTで絞る」への発想の転換です。まずはクローズドなインターフェース射影から試して、発行SQLの列が本当に減っているかログで確認してみてください。オープン射影やネストで最適化が効かなくなるポイントさえ押さえておけば、一覧・検索APIの無駄なフェッチはかなり減らせますよ。ページング周りはページネーション記事も参考にしてみてください。