Spring Data JPAでは、JPQLでは表現しにくい複雑なクエリやデータベース固有の最適化が必要な場面で、ネイティブクエリ(Native Query)を使用できます。また、Projectionを活用することで、エンティティ全体ではなく必要なカラムだけを取得し、メモリ消費とネットワーク転送量を大幅に削減できます。本記事では、@QueryアノテーションによるネイティブSQLの使い方、位置パラメータと名前付きパラメータの使い分け、Interface-based ProjectionとClass-based Projection(DTO)の実装パターン、@SqlResultSetMappingによる複雑なマッピング、そして大量データ処理でのパフォーマンス最適化テクニックまで、実践的な知識を体系的に解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Data JPA 3.4.x(Spring Boot Starterに含まれる)
Hibernate 6.6.x(Spring Boot 3.4.xのデフォルト)
データベース H2 Database または PostgreSQL
ビルドツール Maven または Gradle

事前に以下の知識があることを前提とします。

  • Spring Data JPAの基本(Entity定義、JpaRepository)
  • SQLの基本(SELECT、JOIN、WHERE句)
  • 命名規則クエリまたは@QueryによるJPQLの基本

ネイティブクエリとProjectionの関係性

ネイティブクエリとProjectionは、Spring Data JPAにおけるパフォーマンス最適化の両輪です。

手法 主な用途 メリット
ネイティブクエリ DB固有機能の活用、複雑なSQLの直接実行 JPQL制約からの解放、DBチューニング済みクエリの使用
Projection 必要なカラムのみ取得 メモリ効率向上、N/W転送量削減
両者の組み合わせ 最適化されたSQLで必要最小限のデータ取得 最大のパフォーマンス効果

これらを適切に組み合わせることで、大量データを扱うアプリケーションでも高いパフォーマンスを維持できます。

@QueryによるネイティブSQLの使い方

基本的なネイティブクエリの定義

ネイティブクエリを使用するには、@QueryアノテーションにnativeQuery = trueを指定します。Spring Data JPA 3.4以降では、より簡潔な@NativeQueryアノテーションも使用できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.example.demo.repository;

import com.example.demo.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.NativeQuery;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // 従来の書き方(nativeQuery = true)
    @Query(value = "SELECT * FROM products WHERE status = 'ACTIVE'", nativeQuery = true)
    List<Product> findActiveProducts();

    // Spring Data JPA 3.4以降の簡潔な書き方
    @NativeQuery("SELECT * FROM products WHERE category = ?1")
    List<Product> findByCategory(String category);

    // PostgreSQL固有の関数を使用する例
    @NativeQuery("SELECT * FROM products WHERE name ILIKE %?1%")
    List<Product> searchByNameCaseInsensitive(String keyword);
}

エンティティクラスの定義

本記事で使用するエンティティクラスを定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package com.example.demo.entity;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 200)
    private String name;

    @Column(nullable = false, length = 100)
    private String category;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private ProductStatus status;

    @Column(nullable = false, precision = 12, scale = 2)
    private BigDecimal price;

    @Column(name = "stock_quantity", nullable = false)
    private Integer stockQuantity;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "supplier_id")
    private Supplier supplier;

    // コンストラクタ
    public Product() {}

    public Product(String name, String category, ProductStatus status, 
                   BigDecimal price, Integer stockQuantity) {
        this.name = name;
        this.category = category;
        this.status = status;
        this.price = price;
        this.stockQuantity = stockQuantity;
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    // getter/setter
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public ProductStatus getStatus() { return status; }
    public void setStatus(ProductStatus status) { this.status = status; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public Integer getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
    public Supplier getSupplier() { return supplier; }
    public void setSupplier(Supplier supplier) { this.supplier = supplier; }
}
1
2
3
4
5
6
7
8
package com.example.demo.entity;

public enum ProductStatus {
    DRAFT,       // 下書き
    ACTIVE,      // 販売中
    SUSPENDED,   // 一時停止
    DISCONTINUED // 販売終了
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.example.demo.entity;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "suppliers")
public class Supplier {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 200)
    private String name;

    @Column(length = 500)
    private String contactInfo;

    @OneToMany(mappedBy = "supplier", cascade = CascadeType.ALL)
    private List<Product> products = new ArrayList<>();

    public Supplier() {}

    public Supplier(String name, String contactInfo) {
        this.name = name;
        this.contactInfo = contactInfo;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getContactInfo() { return contactInfo; }
    public void setContactInfo(String contactInfo) { this.contactInfo = contactInfo; }
    public List<Product> getProducts() { return products; }
    public void setProducts(List<Product> products) { this.products = products; }
}

位置パラメータと名前付きパラメータの使い分け

ネイティブクエリでパラメータをバインドする方法は2種類あります。それぞれの特徴と使い分けの指針を理解しておきましょう。

位置パラメータ(Positional Parameters)

位置パラメータは?1?2のように番号で指定します。シンプルなクエリに適しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // 位置パラメータ(?1, ?2でメソッド引数の順序に対応)
    @NativeQuery("""
        SELECT * FROM products 
        WHERE category = ?1 
          AND status = ?2 
          AND price <= ?3
        ORDER BY price ASC
        """)
    List<Product> findByCategoryAndStatusAndMaxPrice(
            String category, 
            String status, 
            BigDecimal maxPrice
    );
}

名前付きパラメータ(Named Parameters)

名前付きパラメータは:paramNameの形式で指定し、@Paramアノテーションで対応付けます。可読性が高く、パラメータが多い場合に推奨されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import org.springframework.data.repository.query.Param;

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // 名前付きパラメータ(:名前でパラメータを特定)
    @NativeQuery("""
        SELECT * FROM products 
        WHERE category = :category 
          AND status = :status 
          AND price BETWEEN :minPrice AND :maxPrice
          AND stock_quantity >= :minStock
        ORDER BY created_at DESC
        """)
    List<Product> searchProducts(
            @Param("category") String category,
            @Param("status") String status,
            @Param("minPrice") BigDecimal minPrice,
            @Param("maxPrice") BigDecimal maxPrice,
            @Param("minStock") Integer minStock
    );
}

使い分けの指針

観点 位置パラメータ 名前付きパラメータ
パラメータ数 1〜2個程度 3個以上
可読性 低い(順序を追う必要あり) 高い(意味が明確)
リファクタリング 順序変更時に注意が必要 パラメータ名で紐付くため安全
同一値の複数使用 同じ位置番号を再利用 同じパラメータ名を再利用

実務では、可読性と保守性を重視して名前付きパラメータを標準とし、非常にシンプルなクエリのみ位置パラメータを使用することを推奨します。

Interface-based Projectionの実装

Interface-based Projectionは、インターフェースを定義してSpring Dataがプロキシを生成する方式です。ボイラープレートコードを削減でき、素早く実装できます。

Closed Projection(閉じたProjection)

Closed Projectionは、インターフェースのメソッドがエンティティのプロパティと完全に対応する形式です。Spring Data JPAがクエリを最適化し、必要なカラムのみをSELECTします。

1
2
3
4
5
6
7
8
9
package com.example.demo.projection;

// Closed Projection: メソッド名がエンティティのプロパティに対応
public interface ProductSummary {
    Long getId();
    String getName();
    String getCategory();
    java.math.BigDecimal getPrice();
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // JPQLでInterface-based Projection
    @Query("SELECT p FROM Product p WHERE p.status = :status")
    List<ProductSummary> findSummaryByStatus(@Param("status") ProductStatus status);

    // ネイティブクエリでInterface-based Projection
    @NativeQuery("""
        SELECT id, name, category, price 
        FROM products 
        WHERE status = :status
        """)
    List<ProductSummary> findSummaryByStatusNative(@Param("status") String status);

    // 命名規則クエリでもProjectionが使える
    List<ProductSummary> findByCategory(String category);
}

Open Projection(開いたProjection)

Open Projectionは、@ValueアノテーションでSpEL式を使い、計算フィールドや結合フィールドを表現できます。ただし、クエリ最適化が効かず全カラムを取得する点に注意が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.example.demo.projection;

import org.springframework.beans.factory.annotation.Value;

// Open Projection: SpEL式で計算フィールドを定義
public interface ProductDisplayInfo {
    
    String getName();
    String getCategory();
    java.math.BigDecimal getPrice();
    Integer getStockQuantity();

    // SpEL式で計算フィールドを定義
    @Value("#{target.name + ' (' + target.category + ')'}")
    String getDisplayName();

    // 在庫状況を文字列で返す
    @Value("#{target.stockQuantity > 10 ? '在庫あり' : (target.stockQuantity > 0 ? '残りわずか' : '在庫切れ')}")
    String getStockStatus();
}

ネストしたProjection

関連エンティティの情報も含めたい場合、ネストしたProjectionを定義できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package com.example.demo.projection;

public interface ProductWithSupplier {
    Long getId();
    String getName();
    java.math.BigDecimal getPrice();
    
    // ネストしたProjection
    SupplierInfo getSupplier();

    interface SupplierInfo {
        Long getId();
        String getName();
        String getContactInfo();
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // JOINを含むクエリでネストProjection
    @Query("""
        SELECT p FROM Product p 
        LEFT JOIN FETCH p.supplier 
        WHERE p.category = :category
        """)
    List<ProductWithSupplier> findWithSupplierByCategory(@Param("category") String category);
}

Class-based Projection(DTO)の実装

Class-based Projection(DTO)は、具象クラスまたはRecordを使ってProjectionを定義する方式です。コンストラクタでマッピングするため、より厳密な型安全性が得られます。

Recordを使ったDTOProjection

Java 16以降で導入されたRecordは、DTO定義に最適です。不変オブジェクトとして自動的にequalshashCodetoStringが生成されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package com.example.demo.dto;

import java.math.BigDecimal;

// Java Record を使ったDTO(推奨)
public record ProductDto(
    Long id,
    String name,
    String category,
    BigDecimal price,
    Integer stockQuantity
) {
    // 追加のメソッドを定義可能
    public boolean isInStock() {
        return stockQuantity != null && stockQuantity > 0;
    }

    public BigDecimal getPriceWithTax() {
        return price.multiply(BigDecimal.valueOf(1.10));
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // JPQLでのDTO Projection(コンストラクタ式)
    @Query("""
        SELECT new com.example.demo.dto.ProductDto(
            p.id, p.name, p.category, p.price, p.stockQuantity
        )
        FROM Product p
        WHERE p.status = :status
        ORDER BY p.createdAt DESC
        """)
    List<ProductDto> findDtoByStatus(@Param("status") ProductStatus status);
}

Spring Data JPA 3.4のDTO自動リライト機能

Spring Data JPA 3.4以降では、JPQLクエリに対してDTO戻り値型を指定すると、自動的にコンストラクタ式にリライトされます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Spring Data JPA 3.4+: 自動リライト対応
    // SELECT p FROM Product p ... がDTOに合わせて自動変換される
    @Query("SELECT p FROM Product p WHERE p.category = :category")
    List<ProductDto> findDtoByCategory(@Param("category") String category);

    // マルチセレクトも自動リライト対応
    @Query("SELECT p.id, p.name, p.category, p.price, p.stockQuantity FROM Product p WHERE p.status = 'ACTIVE'")
    List<ProductDto> findActiveProductDtos();
}

従来のクラスベースDTO

Recordが使用できない環境では、従来のクラスベースDTOを使用します。単一のコンストラクタを持つ必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example.demo.dto;

import java.math.BigDecimal;

public class ProductDetailDto {

    private final Long id;
    private final String name;
    private final String category;
    private final BigDecimal price;
    private final Integer stockQuantity;
    private final String supplierName;

    // コンストラクタ(Spring Data JPAはこれを使ってマッピング)
    public ProductDetailDto(Long id, String name, String category, 
                            BigDecimal price, Integer stockQuantity, 
                            String supplierName) {
        this.id = id;
        this.name = name;
        this.category = category;
        this.price = price;
        this.stockQuantity = stockQuantity;
        this.supplierName = supplierName;
    }

    // getter
    public Long getId() { return id; }
    public String getName() { return name; }
    public String getCategory() { return category; }
    public BigDecimal getPrice() { return price; }
    public Integer getStockQuantity() { return stockQuantity; }
    public String getSupplierName() { return supplierName; }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // JOINを含むDTO Projection
    @Query("""
        SELECT new com.example.demo.dto.ProductDetailDto(
            p.id, p.name, p.category, p.price, p.stockQuantity, s.name
        )
        FROM Product p
        LEFT JOIN p.supplier s
        WHERE p.category = :category
        ORDER BY p.name
        """)
    List<ProductDetailDto> findDetailsByCategory(@Param("category") String category);
}

ネイティブクエリでのDTO Projection

ネイティブクエリでClass-based Projectionを使用する場合、カラムの順序と型がコンストラクタ引数と一致している必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // ネイティブクエリでDTO Projection
    // カラム順序がコンストラクタ引数と一致している必要がある
    @NativeQuery("""
        SELECT 
            p.id,
            p.name,
            p.category,
            p.price,
            p.stock_quantity,
            s.name AS supplier_name
        FROM products p
        LEFT JOIN suppliers s ON p.supplier_id = s.id
        WHERE p.category = :category
        ORDER BY p.name
        """)
    List<ProductDetailDto> findDetailsByCategoryNative(@Param("category") String category);
}

@SqlResultSetMappingによる複雑なマッピング

ネイティブクエリの結果を複雑なオブジェクト構造にマッピングする場合、JPAの@SqlResultSetMappingを使用します。

@SqlResultSetMappingの基本

@SqlResultSetMappingはエンティティクラスに定義し、ネイティブクエリの結果とJavaオブジェクトのマッピングルールを宣言します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package com.example.demo.entity;

import com.example.demo.dto.ProductStatisticsDto;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "products")
@SqlResultSetMapping(
    name = "ProductStatisticsMapping",
    classes = @ConstructorResult(
        targetClass = ProductStatisticsDto.class,
        columns = {
            @ColumnResult(name = "category", type = String.class),
            @ColumnResult(name = "product_count", type = Long.class),
            @ColumnResult(name = "avg_price", type = BigDecimal.class),
            @ColumnResult(name = "total_stock", type = Long.class),
            @ColumnResult(name = "min_price", type = BigDecimal.class),
            @ColumnResult(name = "max_price", type = BigDecimal.class)
        }
    )
)
public class Product {
    // ... エンティティ定義は前述の通り
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 200)
    private String name;

    @Column(nullable = false, length = 100)
    private String category;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private ProductStatus status;

    @Column(nullable = false, precision = 12, scale = 2)
    private BigDecimal price;

    @Column(name = "stock_quantity", nullable = false)
    private Integer stockQuantity;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "supplier_id")
    private Supplier supplier;

    public Product() {}

    // getter/setter省略
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package com.example.demo.dto;

import java.math.BigDecimal;

public record ProductStatisticsDto(
    String category,
    Long productCount,
    BigDecimal avgPrice,
    Long totalStock,
    BigDecimal minPrice,
    BigDecimal maxPrice
) {}

リポジトリでの使用

@NativeQueryresultSetMapping属性でマッピング名を指定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // @SqlResultSetMappingを使用
    @NativeQuery(
        value = """
            SELECT 
                category,
                COUNT(*) AS product_count,
                AVG(price) AS avg_price,
                SUM(stock_quantity) AS total_stock,
                MIN(price) AS min_price,
                MAX(price) AS max_price
            FROM products
            WHERE status = 'ACTIVE'
            GROUP BY category
            ORDER BY product_count DESC
            """,
        resultSetMapping = "ProductStatisticsMapping"
    )
    List<ProductStatisticsDto> findProductStatisticsByCategory();
}

複数エンティティのマッピング

JOINクエリで複数のエンティティを同時にマッピングすることも可能です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Entity
@Table(name = "products")
@SqlResultSetMapping(
    name = "ProductWithSupplierEntityMapping",
    entities = {
        @EntityResult(
            entityClass = Product.class,
            fields = {
                @FieldResult(name = "id", column = "product_id"),
                @FieldResult(name = "name", column = "product_name"),
                @FieldResult(name = "category", column = "category"),
                @FieldResult(name = "price", column = "price"),
                @FieldResult(name = "stockQuantity", column = "stock_quantity"),
                @FieldResult(name = "status", column = "status"),
                @FieldResult(name = "createdAt", column = "created_at"),
                @FieldResult(name = "updatedAt", column = "updated_at")
            }
        ),
        @EntityResult(
            entityClass = Supplier.class,
            fields = {
                @FieldResult(name = "id", column = "supplier_id"),
                @FieldResult(name = "name", column = "supplier_name"),
                @FieldResult(name = "contactInfo", column = "contact_info")
            }
        )
    }
)
public class Product {
    // ... 定義省略
}

Projectionによるselect句の最適化とパフォーマンス効果

Projectionを活用することで、データベースから取得するカラム数を削減し、パフォーマンスを大幅に向上させることができます。

エンティティ全体取得 vs Projection

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // アンチパターン: エンティティ全体を取得して一部だけ使用
    @Query("SELECT p FROM Product p WHERE p.category = :category")
    List<Product> findByCategory(@Param("category") String category);

    // 推奨: 必要なカラムのみ取得するProjection
    @Query("SELECT p.id, p.name, p.price FROM Product p WHERE p.category = :category")
    List<ProductSummary> findSummaryByCategory(@Param("category") String category);
}

実際に発行されるSQLを比較すると、その差は明確です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- エンティティ全体取得
SELECT 
    p.id, p.name, p.category, p.status, p.price, 
    p.stock_quantity, p.created_at, p.updated_at, p.supplier_id
FROM products p 
WHERE p.category = 'Electronics';

-- Projection使用
SELECT p.id, p.name, p.price
FROM products p 
WHERE p.category = 'Electronics';

パフォーマンス測定の例

以下は、10万件のデータを取得する際のパフォーマンス比較の目安です。

取得方法 カラム数 取得時間(目安) メモリ使用量(目安)
エンティティ全体 9カラム 500ms 150MB
Interface Projection 3カラム 180ms 45MB
DTO Projection 3カラム 150ms 40MB

実際の数値は環境やデータ構造によって異なりますが、カラム数に応じた削減効果は明確に現れます。

動的Projectionによる柔軟な取得

用途に応じてProjectionタイプを切り替える動的Projectionも活用できます。

1
2
3
4
5
6
7
8
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // 動的Projection: 呼び出し時にProjectionタイプを指定
    <T> List<T> findByCategory(String category, Class<T> type);

    <T> T findById(Long id, Class<T> type);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // 一覧表示用: 軽量なサマリーを取得
    public List<ProductSummary> getProductListForDisplay(String category) {
        return productRepository.findByCategory(category, ProductSummary.class);
    }

    // 詳細表示用: 完全なエンティティを取得
    public Product getProductDetail(Long id) {
        return productRepository.findById(id, Product.class);
    }

    // 編集画面用: 編集に必要なフィールドのみ取得
    public ProductEditDto getProductForEdit(Long id) {
        return productRepository.findById(id, ProductEditDto.class);
    }
}

大量データ処理での活用例

大量データを処理する場合、Projectionとネイティブクエリを組み合わせることで効率的な処理が可能になります。

ページネーションとの組み合わせ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Projectionとページネーションの組み合わせ
    @Query("SELECT p.id, p.name, p.price FROM Product p WHERE p.status = :status")
    Page<ProductSummary> findSummaryByStatus(
            @Param("status") ProductStatus status, 
            Pageable pageable
    );

    // ネイティブクエリでのページネーション(countQueryが必要)
    @NativeQuery(
        value = """
            SELECT id, name, category, price 
            FROM products 
            WHERE status = :status
            ORDER BY created_at DESC
            """,
        countQuery = "SELECT COUNT(*) FROM products WHERE status = :status"
    )
    Page<ProductSummary> findSummaryByStatusNative(
            @Param("status") String status, 
            Pageable pageable
    );
}

StreamによるメモリEfficient処理

大量データをStreamで処理することで、メモリ消費を抑えられます。

1
2
3
4
5
6
7
8
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Streamで大量データを効率的に処理
    @Query("SELECT p FROM Product p WHERE p.category = :category")
    @QueryHints(@QueryHint(name = org.hibernate.jpa.HibernateHints.HINT_FETCH_SIZE, value = "100"))
    Stream<Product> streamByCategory(@Param("category") String category);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
@Transactional(readOnly = true)
public class ProductExportService {

    private final ProductRepository productRepository;

    public ProductExportService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    public void exportProductsToCsv(String category, OutputStream outputStream) {
        // Streamを使ってメモリ効率的に処理
        try (Stream<Product> productStream = productRepository.streamByCategory(category)) {
            productStream.forEach(product -> {
                // CSVに1行ずつ書き出し
                writeToCsv(outputStream, product);
            });
        }
    }

    private void writeToCsv(OutputStream os, Product product) {
        // CSV書き出し処理
    }
}

バッチ処理での活用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // バッチ更新用に最小限のデータを取得
    @NativeQuery("""
        SELECT id, price, stock_quantity 
        FROM products 
        WHERE status = 'ACTIVE' 
          AND stock_quantity > 0
        ORDER BY id
        LIMIT :batchSize OFFSET :offset
        """)
    List<ProductUpdateDto> findForBatchUpdate(
            @Param("batchSize") int batchSize,
            @Param("offset") int offset
    );
}
1
2
3
package com.example.demo.dto;

public record ProductUpdateDto(Long id, java.math.BigDecimal price, Integer stockQuantity) {}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Service
public class ProductBatchService {

    private static final int BATCH_SIZE = 1000;
    
    private final ProductRepository productRepository;
    private final EntityManager entityManager;

    public ProductBatchService(ProductRepository productRepository, EntityManager entityManager) {
        this.productRepository = productRepository;
        this.entityManager = entityManager;
    }

    @Transactional
    public void updatePricesInBatch(BigDecimal priceIncreaseRate) {
        int offset = 0;
        List<ProductUpdateDto> batch;

        do {
            batch = productRepository.findForBatchUpdate(BATCH_SIZE, offset);
            
            for (ProductUpdateDto dto : batch) {
                // 最小限のUpdateクエリを発行
                entityManager.createNativeQuery(
                    "UPDATE products SET price = :newPrice, updated_at = :now WHERE id = :id")
                    .setParameter("newPrice", dto.price().multiply(priceIncreaseRate))
                    .setParameter("now", LocalDateTime.now())
                    .setParameter("id", dto.id())
                    .executeUpdate();
            }

            entityManager.flush();
            entityManager.clear(); // メモリ解放
            
            offset += BATCH_SIZE;
        } while (batch.size() == BATCH_SIZE);
    }
}

よくある誤解とアンチパターン

アンチパターン1: ネイティブクエリの過剰使用

JPQLで表現可能なクエリをネイティブクエリで書くと、データベース依存が発生します。

1
2
3
4
5
6
// アンチパターン: 単純なクエリをネイティブで書く
@NativeQuery("SELECT * FROM products WHERE category = ?1")
List<Product> findByCategory(String category);

// 推奨: 命名規則クエリまたはJPQLを使用
List<Product> findByCategory(String category);

アンチパターン2: Projection未使用でのN+1問題

Projectionを使わずエンティティを取得すると、関連エンティティのLAZYロードでN+1問題が発生しがちです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// アンチパターン: 関連エンティティを個別にロード
@Query("SELECT p FROM Product p WHERE p.category = :category")
List<Product> findByCategory(@Param("category") String category);

// 使用側でN+1発生
products.forEach(p -> System.out.println(p.getSupplier().getName())); // 各行でクエリ発行

// 推奨: 必要な情報をProjectionで事前に取得
@Query("""
    SELECT new com.example.demo.dto.ProductWithSupplierName(p.id, p.name, s.name)
    FROM Product p
    LEFT JOIN p.supplier s
    WHERE p.category = :category
    """)
List<ProductWithSupplierName> findWithSupplierNameByCategory(@Param("category") String category);

アンチパターン3: Interface ProjectionでのOpen Projection過剰使用

Open Projectionは便利ですが、クエリ最適化が効かないため注意が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// アンチパターン: 全フィールドで@Valueを使用(全カラムがSELECTされる)
public interface ProductComputed {
    @Value("#{target.name}")
    String getName();
    
    @Value("#{target.price}")
    BigDecimal getPrice();
}

// 推奨: Closed Projectionを基本とし、計算フィールドのみOpen化
public interface ProductWithTotal {
    String getName();
    BigDecimal getPrice();
    Integer getQuantity();
    
    // 計算フィールドのみ@Valueを使用
    @Value("#{target.price.multiply(target.quantity)}")
    BigDecimal getTotalAmount();
}

アンチパターン4: DTOのコンストラクタ引数順序ミス

ネイティブクエリでDTOを使用する場合、SELECTのカラム順序とコンストラクタ引数の順序が一致しないとマッピングに失敗します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// DTO定義
public record ProductDto(Long id, String name, BigDecimal price) {}

// アンチパターン: カラム順序がコンストラクタと不一致
@NativeQuery("SELECT name, id, price FROM products WHERE id = :id")
ProductDto findDtoById(@Param("id") Long id);  // 実行時エラー

// 推奨: カラム順序をコンストラクタに合わせる
@NativeQuery("SELECT id, name, price FROM products WHERE id = :id")
ProductDto findDtoById(@Param("id") Long id);  // 正常動作

まとめと実践Tips

本記事では、Spring Data JPAにおけるネイティブクエリとProjectionによるパフォーマンス最適化テクニックを解説しました。

選択指針

graph TD
    A[クエリ実装の検討] --> B{JPQLで表現可能?}
    B -->|Yes| C{動的条件が必要?}
    B -->|No| D[ネイティブクエリ]
    C -->|Yes| E[Specification/Querydsl]
    C -->|No| F{複雑なJOIN?}
    F -->|Yes| G[JPQL @Query]
    F -->|No| H[命名規則クエリ]
    
    D --> I{結果のマッピング}
    G --> I
    H --> I
    E --> I
    
    I --> J{必要なカラムは少数?}
    J -->|Yes| K[Projection使用]
    J -->|No| L[エンティティ取得]
    
    K --> M{計算フィールド必要?}
    M -->|Yes| N[Class-based DTO]
    M -->|No| O{型安全性重視?}
    O -->|Yes| N
    O -->|No| P[Interface Projection]

実践Tips

  1. ネイティブクエリは最後の手段: JPQLやQuerydslで表現できる場合はそちらを優先し、DB固有機能が必要な場合のみネイティブクエリを使用します。

  2. 名前付きパラメータを標準とする: パラメータが2つ以上の場合は、可読性と保守性のために名前付きパラメータを使用します。

  3. 一覧画面はProjection必須: 一覧表示で必要なのは限られたカラムのみです。Projectionを使って取得データを最小化します。

  4. DTOにはRecordを使用: Java 16以降であれば、Record型でDTOを定義することでボイラープレートを削減できます。

  5. 大量データはStream + @QueryHintsで処理: 全データをメモリに載せず、Streamとフェッチサイズ指定で効率的に処理します。

  6. @SqlResultSetMappingは複雑なケースのみ: 単純なマッピングはInterface/Class-based Projectionで十分です。集計クエリなど複雑なケースで使用します。

これらのテクニックを適切に組み合わせることで、Spring Data JPAアプリケーションのデータアクセス層を大幅に最適化できます。

参考リンク