Spring JPA Elasticsearch連携は、リレーショナルデータベースの堅牢なデータ管理と、Elasticsearchの高速な全文検索を組み合わせることで、エンタープライズアプリケーションの検索機能を劇的に強化します。本記事では、Spring BootプロジェクトにおけるJPA EntityとElasticsearchドキュメントの同期パターン、Hibernate Searchの導入による透過的なインデックス管理、イベント駆動の非同期更新アーキテクチャについて詳しく解説します。全文検索機能を実装する際の設計指針から、具体的なコード例、よくあるアンチパターンまで、実践的な知識を提供します。

実行環境と前提条件

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

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Hibernate ORM 6.6.x
Hibernate Search 7.2.x(Hibernate ORM 6.6対応版)
Elasticsearch 8.x
Spring Data Elasticsearch 5.4.x(オプション)
ビルドツール Maven または Gradle

事前に以下の準備を完了してください。

  • JDK 17以上のインストール
  • Elasticsearch 8.xのローカル環境またはクラスター
  • Spring Boot + JPAプロジェクトの基本構成
  • Docker(Elasticsearch実行用、オプション)

アーキテクチャの選択肢

RDBと全文検索エンジンを連携させるアプローチには、主に3つの選択肢があります。それぞれのメリット・デメリットを理解した上で、要件に最適なアーキテクチャを選択することが重要です。

graph TD
    subgraph "アプローチ1: Hibernate Search"
        A1[JPA Entity] -->|透過的同期| B1[Elasticsearch]
        A1 -->|自動管理| C1[Lucene/ES Index]
    end

    subgraph "アプローチ2: Spring Data Elasticsearch"
        A2[JPA Entity] -->|手動同期| B2[ES Repository]
        B2 --> C2[Elasticsearch]
    end

    subgraph "アプローチ3: イベント駆動"
        A3[JPA Entity] -->|イベント発行| B3[Event Handler]
        B3 -->|非同期更新| C3[Elasticsearch]
    end
アプローチ メリット デメリット 適用シーン
Hibernate Search 透過的な同期、JPA統合 学習コスト、設定の複雑さ エンティティ中心の全文検索
Spring Data Elasticsearch 柔軟なインデックス設計 手動同期が必要 独立した検索マイクロサービス
イベント駆動 疎結合、スケーラビリティ 実装コスト、整合性管理 大規模分散システム

Hibernate Searchの導入

Hibernate Searchは、Hibernate ORMと密接に統合された全文検索ソリューションです。JPAエンティティの変更を透過的にElasticsearchへ同期し、アノテーションベースでインデックスマッピングを定義できます。

依存関係の追加

Mavenの場合、pom.xmlに以下を追加します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.hibernate.search</groupId>
            <artifactId>hibernate-search-bom</artifactId>
            <version>7.2.1.Final</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <!-- Hibernate Search ORM Mapper -->
    <dependency>
        <groupId>org.hibernate.search</groupId>
        <artifactId>hibernate-search-mapper-orm</artifactId>
    </dependency>
    <!-- Elasticsearch Backend -->
    <dependency>
        <groupId>org.hibernate.search</groupId>
        <artifactId>hibernate-search-backend-elasticsearch</artifactId>
    </dependency>
</dependencies>

Gradleの場合は以下のように記述します。

1
2
3
4
5
dependencies {
    implementation platform('org.hibernate.search:hibernate-search-bom:7.2.1.Final')
    implementation 'org.hibernate.search:hibernate-search-mapper-orm'
    implementation 'org.hibernate.search:hibernate-search-backend-elasticsearch'
}

Elasticsearch接続設定

application.ymlでElasticsearchへの接続を設定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
spring:
  jpa:
    properties:
      hibernate:
        search:
          backend:
            type: elasticsearch
            hosts: localhost:9200
            protocol: http
            # 認証が必要な場合
            # username: elastic
            # password: changeme
          schema_management:
            strategy: create-or-validate
          # 開発時の自動インデックス作成
          automatic_indexing:
            synchronization:
              strategy: sync

本番環境ではsynchronization.strategyasyncに設定し、パフォーマンスを最適化することを推奨します。

エンティティへのインデックスマッピング

Hibernate Searchでは、@Indexedアノテーションでエンティティをインデックス対象として指定し、@FullTextFieldなどのアノテーションで各フィールドのマッピングを定義します。

基本的なマッピング例

以下は、商品エンティティに全文検索機能を追加する例です。

 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
package com.example.domain.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;

import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.GenericField;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.Indexed;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.IndexedEmbedded;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.KeywordField;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "products")
@Indexed // Elasticsearchへのインデックス対象として指定
public class Product {

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

    @FullTextField(analyzer = "japanese") // 日本語アナライザーで全文検索
    @KeywordField(name = "name_keyword", normalizer = "lowercase") // ソート・集計用
    private String name;

    @FullTextField(analyzer = "japanese")
    private String description;

    @GenericField // 数値フィールドはGenericFieldを使用
    private BigDecimal price;

    @KeywordField // 完全一致検索用
    private String sku;

    @GenericField
    private LocalDateTime createdAt;

    @ManyToOne
    @IndexedEmbedded // 関連エンティティのフィールドをインデックスに埋め込み
    private Category category;

    // Getters and Setters
    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 getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public String getSku() { return sku; }
    public void setSku(String sku) { this.sku = sku; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
    public Category getCategory() { return category; }
    public void setCategory(Category category) { this.category = category; }
}

関連エンティティのマッピング

@IndexedEmbeddedを使用すると、関連エンティティのフィールドを親エンティティのインデックスに含めることができます。

 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
package com.example.domain.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.KeywordField;

@Entity
@Table(name = "categories")
public class Category {

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

    @FullTextField(analyzer = "japanese")
    @KeywordField(name = "name_keyword")
    private String name;

    @FullTextField(analyzer = "japanese")
    private String description;

    // Getters and Setters
    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 getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
}

この設定により、Productのインデックスにはcategory.namecategory.descriptionフィールドが含まれ、カテゴリ名での検索が可能になります。

日本語アナライザーの設定

日本語テキストを適切にトークン化するには、カスタムアナライザーを定義します。

 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
package com.example.config;

import org.hibernate.search.backend.elasticsearch.analysis.ElasticsearchAnalysisConfigurationContext;
import org.hibernate.search.backend.elasticsearch.analysis.ElasticsearchAnalysisConfigurer;
import org.springframework.stereotype.Component;

@Component("elasticsearchAnalysisConfigurer")
public class CustomElasticsearchAnalysisConfigurer implements ElasticsearchAnalysisConfigurer {

    @Override
    public void configure(ElasticsearchAnalysisConfigurationContext context) {
        // 日本語アナライザー(kuromoji使用)
        context.analyzer("japanese").custom()
                .tokenizer("kuromoji_tokenizer")
                .tokenFilters(
                    "kuromoji_baseform",
                    "kuromoji_part_of_speech",
                    "cjk_width",
                    "lowercase"
                );

        // 英語アナライザー
        context.analyzer("english").custom()
                .tokenizer("standard")
                .tokenFilters("lowercase", "snowball_english", "asciifolding");

        context.tokenFilter("snowball_english")
                .type("snowball")
                .param("language", "English");

        // 正規化用ノーマライザー
        context.normalizer("lowercase").custom()
                .tokenFilters("lowercase", "asciifolding");
    }
}

application.ymlでこのConfigurerを指定します。

1
2
3
4
5
6
7
8
spring:
  jpa:
    properties:
      hibernate:
        search:
          backend:
            analysis:
              configurer: "class:com.example.config.CustomElasticsearchAnalysisConfigurer"

検索機能の実装

Hibernate SearchのSearchSessionを使用して、型安全な検索クエリを構築できます。

検索サービスの実装

 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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package com.example.service;

import com.example.domain.entity.Product;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.search.engine.search.query.SearchResult;
import org.hibernate.search.mapper.orm.Search;
import org.hibernate.search.mapper.orm.session.SearchSession;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.List;

@Service
@Transactional(readOnly = true)
public class ProductSearchService {

    @PersistenceContext
    private EntityManager entityManager;

    /**
     * キーワードによる商品検索
     */
    public SearchResult<Product> searchByKeyword(String keyword, int offset, int limit) {
        SearchSession searchSession = Search.session(entityManager);

        return searchSession.search(Product.class)
                .where(f -> f.match()
                        .fields("name", "description", "category.name")
                        .matching(keyword))
                .fetch(offset, limit);
    }

    /**
     * 複合条件での検索
     */
    public SearchResult<Product> searchWithFilters(
            String keyword,
            String categoryName,
            BigDecimal minPrice,
            BigDecimal maxPrice,
            int offset,
            int limit) {

        SearchSession searchSession = Search.session(entityManager);

        return searchSession.search(Product.class)
                .where(f -> f.bool(b -> {
                    // キーワード検索(オプショナル)
                    if (keyword != null && !keyword.isBlank()) {
                        b.must(f.match()
                                .fields("name", "description")
                                .matching(keyword));
                    }

                    // カテゴリフィルター
                    if (categoryName != null && !categoryName.isBlank()) {
                        b.must(f.match()
                                .field("category.name_keyword")
                                .matching(categoryName));
                    }

                    // 価格範囲フィルター
                    if (minPrice != null || maxPrice != null) {
                        b.must(f.range()
                                .field("price")
                                .between(minPrice, maxPrice));
                    }
                }))
                .sort(f -> f.field("createdAt").desc())
                .fetch(offset, limit);
    }

    /**
     * ファセット検索(集計)
     */
    public SearchResult<Product> searchWithAggregation(String keyword) {
        SearchSession searchSession = Search.session(entityManager);

        return searchSession.search(Product.class)
                .where(f -> f.match()
                        .fields("name", "description")
                        .matching(keyword))
                .aggregation(key -> key.terms()
                        .field("category.name_keyword", String.class))
                .fetch(20);
    }
}

検索結果とRDBデータの結合

Hibernate Searchの検索結果は、デフォルトでJPAエンティティとして返されます。これにより、検索結果に対して追加のJPA操作(遅延読み込み、関連データの取得など)を行うことができます。

 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
package com.example.service;

import com.example.domain.entity.Product;
import com.example.dto.ProductSearchResultDto;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.search.engine.search.query.SearchResult;
import org.hibernate.search.mapper.orm.Search;
import org.hibernate.search.mapper.orm.session.SearchSession;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional(readOnly = true)
public class ProductSearchWithJoinService {

    @PersistenceContext
    private EntityManager entityManager;

    /**
     * 検索結果をDTOに変換し、追加のRDBデータを結合
     */
    public List<ProductSearchResultDto> searchAndEnrich(String keyword) {
        SearchSession searchSession = Search.session(entityManager);

        // Elasticsearchで検索
        SearchResult<Product> result = searchSession.search(Product.class)
                .where(f -> f.match()
                        .fields("name", "description")
                        .matching(keyword))
                .fetch(20);

        // 検索結果のIDを取得
        List<Long> productIds = result.hits().stream()
                .map(Product::getId)
                .toList();

        // JPAで追加データをフェッチ(N+1問題を回避)
        List<Product> enrichedProducts = entityManager.createQuery(
                "SELECT DISTINCT p FROM Product p " +
                "LEFT JOIN FETCH p.category " +
                "LEFT JOIN FETCH p.reviews " +
                "WHERE p.id IN :ids", Product.class)
                .setParameter("ids", productIds)
                .getResultList();

        // DTOに変換
        return enrichedProducts.stream()
                .map(this::toDto)
                .toList();
    }

    private ProductSearchResultDto toDto(Product product) {
        return new ProductSearchResultDto(
                product.getId(),
                product.getName(),
                product.getDescription(),
                product.getPrice(),
                product.getCategory() != null ? product.getCategory().getName() : null
        );
    }
}

イベント駆動による非同期インデックス更新

Hibernate Searchのデフォルトの同期方式では、エンティティの変更がトランザクションコミット時にElasticsearchへ即座に反映されます。しかし、高負荷環境では非同期更新によるパフォーマンス最適化が必要になる場合があります。

ApplicationEventPublisherを使用した実装

Spring のApplicationEventPublisherを使用して、エンティティ変更イベントを非同期で処理するパターンを実装します。

 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
package com.example.event;

import com.example.domain.entity.Product;

/**
 * 商品インデックス更新イベント
 */
public record ProductIndexEvent(
    Long productId,
    IndexAction action,
    Product product
) {
    public enum IndexAction {
        CREATE, UPDATE, DELETE
    }

    public static ProductIndexEvent create(Product product) {
        return new ProductIndexEvent(product.getId(), IndexAction.CREATE, product);
    }

    public static ProductIndexEvent update(Product product) {
        return new ProductIndexEvent(product.getId(), IndexAction.UPDATE, product);
    }

    public static ProductIndexEvent delete(Long productId) {
        return new ProductIndexEvent(productId, IndexAction.DELETE, null);
    }
}
 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
package com.example.service;

import com.example.domain.entity.Product;
import com.example.domain.repository.ProductRepository;
import com.example.event.ProductIndexEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductService {

    private final ProductRepository productRepository;
    private final ApplicationEventPublisher eventPublisher;

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

    @Transactional
    public Product createProduct(Product product) {
        Product saved = productRepository.save(product);
        // トランザクションコミット後にイベントを発行
        eventPublisher.publishEvent(ProductIndexEvent.create(saved));
        return saved;
    }

    @Transactional
    public Product updateProduct(Long id, Product productData) {
        Product existing = productRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("Product not found: " + id));

        existing.setName(productData.getName());
        existing.setDescription(productData.getDescription());
        existing.setPrice(productData.getPrice());

        Product updated = productRepository.save(existing);
        eventPublisher.publishEvent(ProductIndexEvent.update(updated));
        return updated;
    }

    @Transactional
    public void deleteProduct(Long id) {
        productRepository.deleteById(id);
        eventPublisher.publishEvent(ProductIndexEvent.delete(id));
    }
}

非同期イベントリスナーの実装

 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
package com.example.event;

import com.example.domain.entity.Product;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.search.mapper.orm.Search;
import org.hibernate.search.mapper.orm.session.SearchSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
public class ProductIndexEventListener {

    private static final Logger log = LoggerFactory.getLogger(ProductIndexEventListener.class);

    @PersistenceContext
    private EntityManager entityManager;

    /**
     * トランザクションコミット後に非同期でインデックスを更新
     */
    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handleProductIndexEvent(ProductIndexEvent event) {
        try {
            SearchSession searchSession = Search.session(entityManager);

            switch (event.action()) {
                case CREATE, UPDATE -> {
                    // 最新のエンティティを再取得してインデックス
                    Product product = entityManager.find(Product.class, event.productId());
                    if (product != null) {
                        searchSession.indexingPlan().addOrUpdate(product);
                    }
                }
                case DELETE -> {
                    searchSession.indexingPlan().purge(Product.class, event.productId(), null);
                }
            }

            log.info("Successfully processed index event: {} for product {}",
                    event.action(), event.productId());

        } catch (Exception e) {
            log.error("Failed to process index event for product {}: {}",
                    event.productId(), e.getMessage(), e);
            // リトライロジックやDead Letter Queue への送信を実装
        }
    }
}

@Asyncを有効化するために、設定クラスでアノテーションを追加します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package com.example.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync
public class AsyncConfig {
    // カスタムExecutorの設定も可能
}

Spring Data Elasticsearchとの併用

Hibernate Searchとは別に、Spring Data Elasticsearchを使用して、より柔軟なインデックス設計を行うことも可能です。この場合、JPA Entityとは独立したElasticsearchドキュメントを定義します。

依存関係の追加

1
2
3
4
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

Elasticsearchドキュメントの定義

 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
package com.example.elasticsearch.document;

import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import org.springframework.data.elasticsearch.annotations.Setting;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Document(indexName = "products")
@Setting(settingPath = "/elasticsearch/settings/product-settings.json")
public class ProductDocument {

    @Id
    private String id;

    @Field(type = FieldType.Text, analyzer = "kuromoji")
    private String name;

    @Field(type = FieldType.Text, analyzer = "kuromoji")
    private String description;

    @Field(type = FieldType.Double)
    private BigDecimal price;

    @Field(type = FieldType.Keyword)
    private String categoryName;

    @Field(type = FieldType.Date)
    private LocalDateTime createdAt;

    // Getters and Setters
    public String getId() { return id; }
    public void setId(String id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public String getCategoryName() { return categoryName; }
    public void setCategoryName(String categoryName) { this.categoryName = categoryName; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

Elasticsearchリポジトリ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package com.example.elasticsearch.repository;

import com.example.elasticsearch.document.ProductDocument;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

import java.util.List;

public interface ProductDocumentRepository extends ElasticsearchRepository<ProductDocument, String> {

    List<ProductDocument> findByNameContaining(String name);

    List<ProductDocument> findByCategoryName(String categoryName);
}

JPA Entityからドキュメントへの同期

 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
package com.example.sync;

import com.example.domain.entity.Product;
import com.example.elasticsearch.document.ProductDocument;
import com.example.elasticsearch.repository.ProductDocumentRepository;
import org.springframework.stereotype.Component;

@Component
public class ProductDocumentSync {

    private final ProductDocumentRepository documentRepository;

    public ProductDocumentSync(ProductDocumentRepository documentRepository) {
        this.documentRepository = documentRepository;
    }

    public void syncToElasticsearch(Product product) {
        ProductDocument document = toDocument(product);
        documentRepository.save(document);
    }

    public void deleteFromElasticsearch(Long productId) {
        documentRepository.deleteById(String.valueOf(productId));
    }

    private ProductDocument toDocument(Product product) {
        ProductDocument doc = new ProductDocument();
        doc.setId(String.valueOf(product.getId()));
        doc.setName(product.getName());
        doc.setDescription(product.getDescription());
        doc.setPrice(product.getPrice());
        doc.setCategoryName(
            product.getCategory() != null ? product.getCategory().getName() : null
        );
        doc.setCreatedAt(product.getCreatedAt());
        return doc;
    }
}

初期データのマスインデックス

既存のRDBデータをElasticsearchに一括インデックスするには、Hibernate SearchのMassIndexerを使用します。

 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
package com.example.service;

import com.example.domain.entity.Product;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.search.mapper.orm.Search;
import org.hibernate.search.mapper.orm.massindexing.MassIndexer;
import org.hibernate.search.mapper.orm.session.SearchSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class IndexMaintenanceService {

    private static final Logger log = LoggerFactory.getLogger(IndexMaintenanceService.class);

    @PersistenceContext
    private EntityManager entityManager;

    /**
     * 全商品データの再インデックス
     */
    @Transactional
    public void reindexAllProducts() throws InterruptedException {
        SearchSession searchSession = Search.session(entityManager);

        MassIndexer indexer = searchSession.massIndexer(Product.class)
                .threadsToLoadObjects(4)        // データベース読み込みスレッド数
                .batchSizeToLoadObjects(100)    // バッチサイズ
                .cacheMode(org.hibernate.CacheMode.IGNORE);

        log.info("Starting mass indexing for Product entities...");

        indexer.startAndWait();

        log.info("Mass indexing completed.");
    }

    /**
     * インデックスの再作成(スキーマ変更時)
     */
    @Transactional
    public void recreateIndex() throws InterruptedException {
        SearchSession searchSession = Search.session(entityManager);

        // 既存インデックスを削除して再作成
        searchSession.schemaManager(Product.class).dropAndCreate();

        // データを再インデックス
        reindexAllProducts();
    }
}

アプリケーション起動時に自動実行する場合は、ApplicationRunnerを使用します。

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

import com.example.service.IndexMaintenanceService;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

@Component
@Profile("!test") // テスト環境では実行しない
public class InitialIndexRunner implements ApplicationRunner {

    private final IndexMaintenanceService indexMaintenanceService;

    public InitialIndexRunner(IndexMaintenanceService indexMaintenanceService) {
        this.indexMaintenanceService = indexMaintenanceService;
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        indexMaintenanceService.reindexAllProducts();
    }
}

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

誤解1: ElasticsearchをRDBの代替として使う

Elasticsearchは全文検索に最適化されていますが、トランザクション処理やACID特性を持ちません。マスターデータは必ずRDBに保持し、Elasticsearchは検索用のセカンダリインデックスとして使用してください。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// アンチパターン: Elasticsearchのみにデータを保存
@PostMapping("/products")
public void createProduct(@RequestBody ProductDocument doc) {
    // RDBへの保存なしでElasticsearchに直接保存
    productDocumentRepository.save(doc); // NG
}

// 正しいパターン: RDBをマスターとして使用
@PostMapping("/products")
public Product createProduct(@RequestBody ProductRequest request) {
    Product product = productRepository.save(toEntity(request)); // RDBに保存
    // Hibernate Searchが自動的にインデックス同期
    return product;
}

誤解2: リアルタイム同期を過信する

Elasticsearchにはデフォルトで1秒のリフレッシュ間隔があり、書き込み直後のデータが検索結果に反映されない場合があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// アンチパターン: 保存直後に検索結果を期待
@Transactional
public void saveAndSearch() {
    productRepository.save(newProduct);
    // この時点ではまだインデックスに反映されていない可能性がある
    SearchResult<Product> result = searchService.searchByKeyword("新商品");
}

// 正しいパターン: 整合性が必要な場合は待機またはrefresh
@Transactional
public void saveAndSearchWithRefresh() {
    Product saved = productRepository.save(newProduct);
    entityManager.flush();

    // 明示的にインデックスをリフレッシュ(パフォーマンスに影響あり)
    Search.session(entityManager).indexingPlan().execute();
}

誤解3: N+1問題の軽視

検索結果のエンティティに対して遅延読み込みを行うと、N+1問題が発生します。

 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
// アンチパターン: 検索結果に対するN+1クエリ
public List<ProductDto> searchProducts(String keyword) {
    SearchResult<Product> result = searchSession.search(Product.class)
            .where(f -> f.match().field("name").matching(keyword))
            .fetch(100);

    return result.hits().stream()
            .map(p -> new ProductDto(
                p.getName(),
                p.getCategory().getName())) // カテゴリ取得でN+1発生
            .toList();
}

// 正しいパターン: 検索後にJPAで一括フェッチ
public List<ProductDto> searchProductsOptimized(String keyword) {
    List<Long> ids = /* 検索で取得したID */;

    List<Product> products = entityManager.createQuery(
            "SELECT p FROM Product p JOIN FETCH p.category WHERE p.id IN :ids",
            Product.class)
            .setParameter("ids", ids)
            .getResultList();

    return products.stream()
            .map(p -> new ProductDto(p.getName(), p.getCategory().getName()))
            .toList();
}

誤解4: 全フィールドをインデックス対象にする

必要のないフィールドまでインデックスに含めると、インデックスサイズが膨大になり、パフォーマンスが低下します。

 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
// アンチパターン: 全フィールドに@FullTextFieldを付与
@Entity
@Indexed
public class Product {
    @FullTextField
    private String name;          // OK: 検索対象

    @FullTextField
    private String internalNote;  // NG: 内部メモは検索不要

    @GenericField
    private byte[] imageData;     // NG: バイナリデータはインデックス不可
}

// 正しいパターン: 検索に必要なフィールドのみ
@Entity
@Indexed
public class Product {
    @FullTextField
    private String name;

    private String internalNote;  // アノテーションなし = インデックス対象外

    private byte[] imageData;     // インデックス対象外
}

まとめと実践Tips

Spring JPA Elasticsearch連携を実装する際の重要なポイントを整理します。

アーキテクチャ選択のガイドライン

  • シンプルな全文検索が必要で、既存のJPAエンティティを活用したい場合は「Hibernate Search」を選択
  • 検索マイクロサービスとして独立させたい場合や、複雑なインデックス設計が必要な場合は「Spring Data Elasticsearch」を選択
  • 高負荷環境でスケーラビリティを重視する場合は「イベント駆動アーキテクチャ」を採用

本番運用のためのTips

  1. Elasticsearchのヘルスチェックを組み込み、障害時にもRDBからのフォールバック検索を可能にする
  2. インデックス更新の失敗時はリトライキューやDead Letter Queueを実装し、データ欠損を防ぐ
  3. 定期的なフルリインデックスのジョブを設定し、RDBとElasticsearchの整合性を担保する
  4. 検索クエリのスロークエリログを監視し、パフォーマンス劣化を早期に検知する

テスト戦略

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@SpringBootTest
@Testcontainers
class ProductSearchIntegrationTest {

    @Container
    static ElasticsearchContainer elasticsearch =
            new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:8.15.0")
                    .withEnv("xpack.security.enabled", "false");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.jpa.properties.hibernate.search.backend.hosts",
                elasticsearch::getHttpHostAddress);
    }

    @Test
    void testProductSearch() {
        // テスト実装
    }
}

Spring JPA Elasticsearch連携は、正しく設計・実装することで、RDBの堅牢性とElasticsearchの検索パフォーマンスを両立できる強力なアーキテクチャです。本記事で解説したパターンとアンチパターンを参考に、要件に最適なソリューションを構築してください。

参考リンク