Showing posts with label mysql. Show all posts
Showing posts with label mysql. Show all posts

Thursday, September 7, 2023

Spring BootとMySQLによる実践的全文検索の実装

現代のアプリケーションにおいて、ユーザーが必要な情報を迅速かつ的確に見つけ出せる検索機能は、もはや不可欠な要素です。ECサイトの商品検索、ブログの記事検索、社内ドキュメントの横断検索など、その用途は多岐にわたります。このような要求に応える技術が「全文検索」です。単純な `LIKE` 句による文字列マッチングとは異なり、全文検索は言語的な特性を考慮し、より自然で精度の高い検索結果を提供します。本稿では、多くの開発現場で採用されているJavaフレームワークであるSpring Bootと、リレーショナルデータベースのデファクトスタンダードであるMySQLを組み合わせ、堅牢かつ高性能な全文検索機能を実装するプロセスを、基礎から応用まで詳細に解説していきます。

ElasticsearchやOpenSearchのような専用の検索エンジンを導入することも一つの選択肢ですが、プロジェクトの規模や要件によっては、既存のMySQLデータベースの機能を最大限に活用する方が、インフラの複雑化を避け、開発コストを抑制する上で合理的である場合があります。MySQLの全文検索機能は、特にバージョン5.7以降でInnoDBエンジンがN-gramパーサーをサポートしたことにより、日本語のような分かち書きのない言語にも対応可能となり、その実用性が飛躍的に向上しました。この記事を通じて、MySQL全文検索のポテンシャルを最大限に引き出し、Spring Bootアプリケーションにシームレスに統合するための知識とテクニックを習得しましょう。

MySQL全文検索の深層:基礎理論と日本語対応

Spring Bootでの実装に入る前に、土台となるMySQLの全文検索機能そのものについて深く理解することが重要です。その仕組み、特性、そして日本語を扱う上での特有の課題と解決策を掘り下げていきましょう。

FULLTEXTインデックス:検索を支える心臓部

リレーショナルデータベースにおいて、インデックスはデータ検索を高速化するための重要な仕組みです。通常、`B-TREE`インデックスが主キーや検索条件として頻繁に使用されるカラムに設定されますが、これは完全一致や前方一致、範囲検索には強いものの、文章の中から特定の単語を探し出すような「中間一致」や「複数キーワード」での検索には適していません。`LIKE '%keyword%'` のようなクエリがテーブルフルスキャンを引き起こし、パフォーマンスを著しく低下させるのはこのためです。

一方、`FULLTEXT`インデックスは、テキストデータを「単語(トークン)」の集合として扱います。インデックス作成時に、対象となるカラムのテキストデータがパーサーによって単語に分割され、どの単語がどのドキュメント(行)に、どの位置に出現するかの情報(転置インデックス)が構築されます。これにより、検索時にはこのインデックスを利用して、特定の単語を含むドキュメントを極めて高速に特定できるのです。

対象ストレージエンジン: かつて、MySQLの全文検索機能は`MyISAM`ストレージエンジンでしか利用できませんでした。しかし、`MyISAM`はトランザクションをサポートしないなど、現代的なアプリケーション開発においては多くの制約がありました。MySQL 5.6以降、標準のトランザクション対応ストレージエンジンである`InnoDB`が全文検索をサポートしたことで、その実用性は大きく向上しました。本稿でも、`InnoDB`を前提として解説を進めます。

日本語検索の壁と「N-gramパーサー」

MySQLのデフォルトの全文検索パーサーは、スペースや句読点を区切り文字として単語を分割します。これは英語のように単語間がスペースで区切られている言語ではうまく機能しますが、日本語のように単語が連続して記述される言語(分かち書きのない言語)では、文を正しく単語に分割できません。例えば、「東京都の天気」というテキストは、デフォルトパーサーでは一つの単語として扱われてしまい、「東京」や「天気」で検索してもヒットしなくなります。

この課題を解決するのがN-gramパーサーです。N-gramは、テキストを指定した文字数(N)で機械的に分割していく手法です。例えば、N=2(バイグラム)の場合、「東京都の天気」は以下のように分割されます。

  • 東​京
  • 京​都
  • 都​の
  • の​天
  • 天​気

このようにテキストを細かく分割してインデックス化することで、「東京」や「京都」といった部分的な文字列での検索が可能になります。MySQL 5.7.6以降、`InnoDB`でこのN-gramパーサーがビルトインで提供されており、日本語の全文検索を実用的なレベルで実現できるようになりました。

N-gramパーサーを有効にするには、`FULLTEXT`インデックス作成時に `WITH PARSER ngram` を指定します。また、`ngram_token_size`というシステム変数で、分割する文字数(N)をグローバルに設定できます(デフォルトは2)。

3つの検索モードを使いこなす

MySQLの全文検索では、`MATCH(column) AGAINST(keyword [IN ... MODE])` という構文を使用します。この `[IN ... MODE]` の部分で、検索の振る舞いを制御する3つのモードを選択できます。

  1. IN NATURAL LANGUAGE MODE(自然言語モード): デフォルトのモードです。検索キーワードをスペース区切りの単語として解釈し、それらの単語を含むドキュメントを検索します。内部的に関連度(レリバンシー)スコアが計算され、より多くのキーワードを含む、またはキーワードの出現頻度が高いドキュメントが上位に来る傾向があります。
  2. IN BOOLEAN MODE(ブーリアンモード): 最も強力で柔軟なモードです。特殊な演算子を使って、より複雑な検索条件を指定できます。
    • `+`: その単語が必須であることを示す。(例: `+Spring -Java` → Springを含み、Javaを含まない)
    • `-`: その単語を含まないことを示す。
    • `>`: その単語を含むドキュメントの関連度スコアを上げる
    • `<`: その単語を含むドキュメントの関連度スコアを下げる
    • `*`: ワイルドカード。単語の末尾に付けることで前方一致検索を行う。(例: `tech*` → technology, technicalなどにヒット)
    • `"`: フレーズ検索。ダブルクォーテーションで囲まれた語句が、その通りの順番で出現するドキュメントを検索する。(例: `"Spring Boot"`)
  3. WITH QUERY EXPANSION(クエリ拡張モード): 2段階の検索を行います。まず、指定されたキーワードで一度検索を実行します。次に、その結果から関連性の高い単語を自動的に抽出し、それらの単語を元のキーワードに加えて再度検索を実行します。これにより、ユーザーが意図しているであろう関連キーワードまで含めて検索範囲を広げることができます。例えば「データベース」で検索した場合、最初の検索結果に「MySQL」や「PostgreSQL」が多く含まれていれば、それらの単語も検索対象に加える、といった挙動をします。

これらのモードを適切に使い分けることで、アプリケーションの要件に応じた多様な検索機能を提供できます。

Spring Bootプロジェクトの構築

理論を学んだところで、いよいよ実践に移ります。まずは、全文検索機能を実装するためのSpring Bootプロジェクトを準備しましょう。

1. プロジェクトの初期化

Spring Initializr を利用して、プロジェクトの雛形を生成するのが最も簡単です。以下の設定でプロジェクトを作成します。

  • Project: Maven Project
  • Language: Java
  • Spring Boot: 最新の安定バージョン(例: 2.7.x or 3.x.x)
  • Group: com.example (任意)
  • Artifact: mysql-fulltext-search (任意)
  • Packaging: Jar
  • Java: 17 or 11

次に、必要な依存関係を追加します。

  • Spring Web: REST APIを構築するために必要です。
  • Spring Data JPA: データベースアクセスを抽象化し、リポジトリパターンを容易に実装できます。
  • MySQL Driver: MySQLデータベースに接続するためのJDBCドライバです。
  • Lombok (任意): ボイラープレートコード(ゲッター、セッターなど)を削減し、コードを簡潔に保つために便利です。

設定が完了したら、「GENERATE」ボタンをクリックしてプロジェクトをダウンロードし、お好みのIDE(IntelliJ IDEA, Eclipseなど)で開きます。

2. データベース接続設定

次に、`src/main/resources/application.properties`(または `application.yml`)ファイルに、Spring BootがMySQLデータベースに接続するための情報を記述します。

# application.properties

# === DataSource Settings ===
# データベースのURL。your_databaseの部分は実際に使用するデータベース名に置き換えてください。
# useSSL=false: SSL接続を無効化(ローカル開発用)
# serverTimezone=UTC: タイムゾーンをUTCに設定
# allowPublicKeyRetrieval=true: MySQL 8以降で必要な場合がある設定
spring.datasource.url=jdbc:mysql://localhost:3306/your_database?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true

# データベースのユーザー名とパスワード
spring.datasource.username=your_username
spring.datasource.password=your_password

# 使用するJDBCドライバのクラス名
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# === JPA/Hibernate Settings ===
# 起動時にエンティティを基にDDL(Data Definition Language)を自動実行する設定
# create: 毎回テーブルを削除して再作成
# create-drop: 終了時にテーブルを削除
# update: 既存のスキーマとの差分を更新(開発中に便利だが、本番環境での使用は非推奨)
# validate: エンティティとスキーマの整合性を検証
# none: 何も行わない(本番環境での推奨設定)
spring.jpa.hibernate.ddl-auto=update

# 実行されるSQLをコンソールに表示する
spring.jpa.show-sql=true

# コンソールに表示するSQLを整形する
spring.jpa.properties.hibernate.format_sql=true

# 使用するMySQLのバージョンに合わせた方言(Dialect)を指定
# これにより、HibernateがMySQL特有のSQLを正しく生成できるようになる
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect

これらの設定により、Spring Bootアプリケーションは起動時に指定されたMySQLデータベースへの接続を確立します。

データモデルと永続化層の実装

アプリケーションの核となるデータ構造を定義し、データベースと連携させる層を構築します。

1. テーブル定義とFULLTEXTインデックス

まず、検索対象となるデータを格納するテーブルをMySQLで作成します。今回はブログ記事を想定した `articles` テーブルを作成します。重要なのは、全文検索の対象としたいカラム(`title` と `content`)に対して、N-gramパーサーを指定した `FULLTEXT` インデックスを付与することです。

-- MySQL サーバー側で N-gram の文字数を設定 (必要に応じて)
-- SET GLOBAL ngram_token_size=2;

CREATE TABLE articles (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    -- titleとcontentカラムを対象に、N-gramパーサーを使用したFULLTEXTインデックスを作成
    FULLTEXT INDEX ft_index_title_content (title, content) WITH PARSER ngram
) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

このDDLのポイントは `FULLTEXT INDEX ft_index_title_content (title, content) WITH PARSER ngram` の部分です。これにより、`title`と`content`の両方のカラムにまたがる日本語対応の全文検索インデックスが作成されます。

2. JPAエンティティの作成

次に、作成した `articles` テーブルに対応するJPAエンティティクラスをJavaで定義します。

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "articles")
@Getter
@Setter
@NoArgsConstructor // JPA仕様ではデフォルトコンストラクタが必要
public class Article {

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

    @Column(nullable = false)
    private String title;

    @Lob // TEXT型など、大きなデータ型に対応
    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;

    // 作成日時と更新日時のフィールドを追加することも一般的
    // @Column(updatable = false, nullable = false)
    // private LocalDateTime createdAt;
    
    // @Column(nullable = false)
    // private LocalDateTime updatedAt;
}

`@Entity`アノテーションがこのクラスをJPAの管理対象エンティティであることを示し、`@Table(name = "articles")`で対応するテーブル名を指定しています。各フィールドがテーブルのカラムに対応します。

3. Spring Data JPAリポジトリの作成

データベースへのアクセス操作(CRUD操作など)を行うインターフェース、リポジトリを作成します。Spring Data JPAの魔法により、インターフェースを定義するだけで、基本的なデータベース操作のメソッドが自動的に実装されます。

そして、ここに全文検索を実行するためのカスタムメソッドを定義します。MySQLの `MATCH ... AGAINST` 構文は標準のJPQL(Java Persistence Query Language)ではサポートされていないため、`nativeQuery = true` を指定して、データベース固有のSQLを直接記述する必要があります。

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;

@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {

    /**
     * 自然言語モードで全文検索を実行します。
     * @param keyword 検索キーワード
     * @return 検索結果のArticleリスト
     */
    @Query(
        value = "SELECT * FROM articles WHERE MATCH(title, content) AGAINST(:keyword IN NATURAL LANGUAGE MODE)",
        nativeQuery = true
    )
    List<Article> searchByNaturalLanguageMode(@Param("keyword") String keyword);

    /**
     * ブーリアンモードで全文検索を実行します。
     * @param keyword 検索キーワード(ブーリアン演算子を含む)
     * @return 検索結果のArticleリスト
     */
    @Query(
        value = "SELECT * FROM articles WHERE MATCH(title, content) AGAINST(:keyword IN BOOLEAN MODE)",
        nativeQuery = true
    )
    List<Article> searchByBooleanMode(@Param("keyword") String keyword);
}

ここでは、自然言語モードとブーリアンモードの2種類の検索メソッドを定義しました。`@Query`アノテーション内に直接SQLを記述し、`nativeQuery = true` をセットします。メソッドの引数に `@Param("keyword")` を付けることで、SQL文中の `:keyword` プレースホルダに引数の値をバインドできます。これにより、SQLインジェクションのリスクを回避できます。

ビジネスロジックとAPIの構築

永続化層が完成したら、次はそのロジックを呼び出し、外部(Webブラウザや他のサービス)に公開するためのAPIを構築します。ここでは、関心の分離の原則に従い、コントローラー、サービス、DTO(Data Transfer Object)の各層を明確に分けて実装します。

1. DTO(Data Transfer Object)の導入

APIのレスポンスとして、JPAエンティティを直接返却するのは避けるべきです。エンティティはデータベースの構造と密接に結合しており、将来的なエンティティの変更がAPI仕様の変更に直結してしまうためです。また、不要な情報や内部的な情報を外部に漏らしてしまうセキュリティリスクも伴います。そこで、APIの入出力専用のデータ構造であるDTOを導入します。

// ArticleSearchResponse.java
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor // 全てのフィールドを引数に持つコンストラクタを生成
public class ArticleSearchResponse {
    private Long id;
    private String title;
    private String highlightedContent; // ハイライト処理などを施した本文

    // EntityからDTOへの変換用ファクトリメソッド
    public static ArticleSearchResponse from(Article article) {
        // ここでは単純なマッピングだが、実際には本文の要約や
        // 検索キーワードのハイライト処理などを加えることができる
        String summary = article.getContent().length() > 100 
            ? article.getContent().substring(0, 100) + "..." 
            : article.getContent();
        
        return new ArticleSearchResponse(article.getId(), article.getTitle(), summary);
    }
}

2. サービス層の設計

サービス層は、アプリケーションのビジネスロジックを担当します。コントローラーから受け取ったリクエストを解釈し、リポジトリを呼び出してデータを操作し、その結果を加工してコントローラーに返却する役割を担います。

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor // finalフィールドに対するコンストラクタを自動生成
@Transactional(readOnly = true) // クラス全体に読み取り専用トランザクションを適用
public class ArticleSearchService {

    private final ArticleRepository articleRepository;

    public List<ArticleSearchResponse> searchArticles(String keyword) {
        if (keyword == null || keyword.isBlank()) {
            return List.of(); // or throw new IllegalArgumentException("キーワードは必須です");
        }

        List<Article> articles = articleRepository.searchByNaturalLanguageMode(keyword);

        return articles.stream()
                .map(ArticleSearchResponse::from)
                .collect(Collectors.toList());
    }
    
    // ブーリアンモード用のメソッドも同様に実装可能
    public List<ArticleSearchResponse> searchArticlesWithBooleanMode(String keyword) {
        if (keyword == null || keyword.isBlank()) {
            return List.of();
        }
        
        // ユーザー入力を安全なブーリアンモードクエリに変換する処理をここに加えるのが望ましい
        // 例: 各単語の前に+を付ける
        String booleanKeyword = formatToBooleanQuery(keyword);

        List<Article> articles = articleRepository.searchByBooleanMode(booleanKeyword);

        return articles.stream()
                .map(ArticleSearchResponse::from)
                .collect(Collectors.toList());
    }

    private String formatToBooleanQuery(String keyword) {
        // スペースで区切られた単語の先頭に `+` を付与し、AND検索とする
        return java.util.Arrays.stream(keyword.split("\\s+"))
                .filter(s -> !s.isEmpty())
                .map(s -> "+" + s)
                .collect(Collectors.joining(" "));
    }
}

`@Transactional(readOnly = true)`をクラスレベルで付与することで、このサービス内のメソッドはデフォルトで読み取り専用トランザクション内で実行され、パフォーマンス上のメリットが得られます。

3. RESTコントローラーの実装

最後に、HTTPリクエストを受け付けるエンドポイントとなるコントローラーを実装します。このコントローラーは、リクエストの検証、サービスメソッドの呼び出し、そしてレスポンスの返却に専念します。

import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;

@RestController
@RequestMapping("/api/articles")
@RequiredArgsConstructor
public class ArticleController {

    private final ArticleSearchService articleSearchService;

    @GetMapping("/search")
    public ResponseEntity<List<ArticleSearchResponse>> search(@RequestParam("keyword") String keyword) {
        List<ArticleSearchResponse> results = articleSearchService.searchArticles(keyword);
        return ResponseEntity.ok(results);
    }
    
    @GetMapping("/search-boolean")
    public ResponseEntity<List<ArticleSearchResponse>> searchBoolean(@RequestParam("keyword") String keyword) {
        List<ArticleSearchResponse> results = articleSearchService.searchArticlesWithBooleanMode(keyword);
        return ResponseEntity.ok(results);
    }
}

これで、`http://localhost:8080/api/articles/search?keyword=検索したい言葉` のようなURLにGETリクエストを送ることで、全文検索機能を実行できるAPIが完成しました。

全文検索機能の拡張と最適化

基本的な実装は完了しましたが、実用的なアプリケーションを構築するためには、さらにいくつかの高度な機能を組み込むことが望ましいです。

関連度スコアによるソート

全文検索の大きな利点の一つは、各ドキュメントが検索キーワードとどれだけ関連しているかを示す「関連度スコア」を算出できる点です。このスコアが高い順に結果を並べることで、ユーザーはより求めている情報を見つけやすくなります。スコアを取得するには、ネイティブクエリを少し変更します。

// ArticleRepository.java
// スコアも取得するためのカスタムDTOインターフェース
public interface ArticleSearchResult {
    Long getId();
    String getTitle();
    String getContent();
    double getScore();
}

@Query(
    value = "SELECT id, title, content, MATCH(title, content) AGAINST(:keyword IN NATURAL LANGUAGE MODE) as score " +
            "FROM articles WHERE MATCH(title, content) AGAINST(:keyword IN NATURAL LANGUAGE MODE) > 0 " + 
            "ORDER BY score DESC",
    nativeQuery = true
)
List<ArticleSearchResult> searchAndSortByRelevance(@Param("keyword") String keyword);

このクエリでは、`MATCH ... AGAINST` の式を `SELECT` 句に含め、`score` というエイリアスを付けています。そして、`ORDER BY score DESC` でスコアの降順にソートしています。`WHERE`句にも同じ`MATCH`式を入れて、スコアが0(ヒットしなかった)のものは除外しています。リポジトリのメソッドの戻り値を、エンティティクラスではなく、必要なカラムとスコアを持つカスタムインターフェース(ここでは`ArticleSearchResult`)にすることで、余分なデータ転送をなくし、結果を柔軟にマッピングできます。

ページネーションの実装

検索結果が大量になる可能性がある場合、すべての結果を一度に返すのは非効率的です。ページネーションを実装し、結果を分割して返すのが一般的です。Spring Data JPAは、`Pageable`インターフェースと`Page`クラスを通じて、ページネーションを強力にサポートしています。

ネイティブクエリでページネーションを実装するには、クエリにカウント用のクエリも追加で定義する必要があります。

// ArticleRepository.java
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

// ...

@Query(
    value = "SELECT * FROM articles WHERE MATCH(title, content) AGAINST(:keyword IN NATURAL LANGUAGE MODE)",
    countQuery = "SELECT count(*) FROM articles WHERE MATCH(title, content) AGAINST(:keyword IN NATURAL LANGUAGE MODE)",
    nativeQuery = true
)
Page<Article> searchWithPagination(@Param("keyword") String keyword, Pageable pageable);

リポジトリメソッドの引数に`Pageable`を追加し、戻り値を`Page<Article>`に変更するだけです。`countQuery`を指定することで、Spring Data JPAが総件数を効率的に取得し、ページネーション情報(総ページ数、現在のページなど)を含む`Page`オブジェクトを生成してくれます。

サービスとコントローラーもこれに合わせて修正します。

// ArticleSearchService.java
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public Page<ArticleSearchResponse> searchArticlesWithPagination(String keyword, Pageable pageable) {
    if (keyword == null || keyword.isBlank()) {
        return Page.empty(pageable);
    }
    Page<Article> articlePage = articleRepository.searchWithPagination(keyword, pageable);
    return articlePage.map(ArticleSearchResponse::from); // Page.mapを使うとページ情報も維持したまま中身を変換できる
}

// ArticleController.java
@GetMapping("/search-paged")
public ResponseEntity<Page<ArticleSearchResponse>> searchPaged(
    @RequestParam("keyword") String keyword, 
    Pageable pageable // Spring Webが自動的に ?page=0&size=20&sort=id,desc のようなパラメータを解釈してくれる
) {
    Page<ArticleSearchResponse> results = articleSearchService.searchArticlesWithPagination(keyword, pageable);
    return ResponseEntity.ok(results);
}

これにより、`?page=1&size=10` のようにリクエストパラメータでページ番号とページサイズを指定できる、柔軟な検索APIが完成します。

パフォーマンスに関する考察と次のステップ

MySQLの全文検索は、多くのユースケースで十分なパフォーマンスと機能を提供しますが、その限界を理解することも重要です。

MySQL全文検索の限界

  • スケーラビリティ: データ量が数千万、数億件と増えてくると、インデックスのサイズが肥大化し、検索パフォーマンスが低下する可能性があります。
  • 高度な機能: 類義語展開、ファセット検索、地理空間検索、より高度なランキングアルゴリズムなど、専用検索エンジンが提供するような高度な機能は限定的です。
  • 更新のオーバーヘッド: `FULLTEXT`インデックスを持つテーブルへの書き込み(INSERT/UPDATE/DELETE)は、インデックスの再構築が伴うため、通常のインデックスよりもオーバーヘッドが大きくなる傾向があります。

Elasticsearchとの比較

より大規模で複雑な検索要件がある場合は、Elasticsearchのような専用検索エンジンの導入を検討すべきです。

項目 MySQL 全文検索 Elasticsearch
導入の容易さ 非常に容易。追加のミドルウェア不要。 別途サーバー構築・運用が必要。学習コストも高い。
パフォーマンス 中小規模のデータセットでは良好。 大規模データセットに対して極めて高速。分散構成によるスケールアウトが得意。
機能性 基本的な検索(自然言語、ブーリアン)とランキング。 集計、ファセット、地理空間検索、類義語、高度な言語解析など多機能。
データ同期 不要。トランザクション内でデータとインデックスが同期される。 データベースとElasticsearch間でデータの同期を取る仕組みが別途必要。

結論として、MySQL全文検索は「手軽に始めて、そこそこの検索機能を実現したい」場合に最適な選択肢です。プロジェクトの初期段階や、検索がアプリケーションのコア機能ではない場合に特に有効です。一方で、検索がビジネスの根幹をなすようなサービスでは、将来的にElasticsearchへの移行を見据えた設計を検討する価値があります。

まとめ

本稿では、Spring BootとMySQLを用いて、日本語対応の全文検索機能を実装する方法を、基本から応用まで網羅的に解説しました。MySQLの`FULLTEXT`インデックスとN-gramパーサーの仕組みを理解し、Spring Data JPAのネイティブクエリ機能を活用することで、比較的少ない労力で強力な検索機能をアプリケーションに組み込むことができます。さらに、関連度スコアでのソートやページネーションといった実用的な機能を加えることで、ユーザー体験を大きく向上させることが可能です。ここで紹介した技術と考え方を土台として、ぜひあなたのアプリケーションに最適な検索機能を実装してみてください。

Tuesday, May 30, 2023

실무 중심의 Spring Boot와 MySQL 전문 검색: 원리부터 최적화까지

현대의 웹 애플리케이션에서 검색 기능은 더 이상 선택이 아닌 필수 요소입니다. 사용자는 방대한 데이터 속에서 원하는 정보를 빠르고 정확하게 찾기를 원하며, 이러한 요구사항을 충족시키기 위해 개발자는 효율적인 검색 솔루션을 구축해야 합니다. 가장 흔히 사용되는 데이터베이스 검색 방식인 SQL의 LIKE 연산자는 간단한 문자열 매칭에는 유용하지만, LIKE '%검색어%'와 같은 패턴은 데이터베이스 인덱스를 전혀 활용하지 못해 테이블 전체를 스캔(Full Table Scan)하게 됩니다. 이는 데이터의 양이 증가함에 따라 성능이 기하급수적으로 저하되는 심각한 문제를 야기합니다.

뿐만 아니라 LIKE 연산자는 단어의 형태소 분석, 동의어 처리, 관련성 기반 정렬 등 사용자가 기대하는 지능적인 검색 기능을 제공하지 못합니다. 이러한 한계를 극복하기 위해 많은 개발자들이 Elasticsearch나 OpenSearch와 같은 전문 검색 엔진 도입을 고려합니다. 이들은 분명 강력하고 확장성 높은 솔루션이지만, 별도의 서버 구축 및 운영, 데이터 동기화, 학습 곡선 등 추가적인 인프라 및 관리 비용을 수반합니다.

만약 프로젝트의 규모가 비교적 작거나, 복잡한 분산 환경이 불필요하며, 기존의 MySQL 데이터베이스 인프라를 최대한 활용하고 싶다면, MySQL이 자체적으로 제공하는 **전문 검색(Full-Text Search)** 기능은 매우 매력적인 대안이 될 수 있습니다. MySQL의 전문 검색은 추가적인 솔루션 도입 없이 간단한 인덱스 설정과 쿼리만으로 자연어 검색, 불린 검색(Boolean Search), 관련성 점수 기반 정렬 등 강력한 기능을 구현할 수 있도록 지원합니다.

이 글에서는 단순히 Spring Boot와 MySQL을 연동하여 검색 기능을 구현하는 기초적인 수준을 넘어, MySQL 전문 검색의 핵심 동작 원리를 깊이 있게 파헤치고, 특히 한국어와 같은 CJK(Chinese, Japanese, Korean) 언어 환경에서 필수적인 **N-gram 파서**의 설정 및 활용법을 상세히 다룹니다. 또한, 다양한 검색 모드를 실무에 적용하는 방법, Spring Data JPA를 이용한 효율적인 Repository 설계, 페이지네이션 처리, 그리고 DTO를 활용한 API 설계 패턴까지, 실무에서 마주할 수 있는 다양한 시나리오에 대한 포괄적인 접근법을 제시합니다. 이 글을 통해 여러분은 MySQL 전문 검색의 잠재력을 최대한 활용하여 고성능의 지능형 검색 시스템을 구축할 수 있는 역량을 갖추게 될 것입니다.

1부: MySQL 전문 검색의 내부 동작 원리

효과적인 구현에 앞서, MySQL 전문 검색이 내부적으로 어떻게 동작하는지 이해하는 것은 매우 중요합니다. 이 원리를 알아야만 문제 발생 시 대처가 가능하고, 성능 최적화를 위한 올바른 방향을 설정할 수 있습니다.

1.1. FULLTEXT 인덱스와 스토리지 엔진

MySQL에서 전문 검색을 사용하기 위한 첫 번째 단계는 검색 대상이 될 컬럼에 `FULLTEXT` 인덱스를 생성하는 것입니다. 이 인덱스는 일반적인 `B-Tree` 인덱스와는 다른 방식으로 데이터를 저장하고 관리합니다. `FULLTEXT` 인덱스는 텍스트 데이터를 작은 단위의 단어(token)로 분리하고, 각 단어가 어떤 문서(row)에 나타나는지에 대한 역인덱스(inverted index)를 생성합니다. 이 구조 덕분에 특정 단어를 포함하는 문서를 매우 빠르게 찾아낼 수 있습니다.

과거에는 `MyISAM` 스토리지 엔진만이 `FULLTEXT` 인덱스를 지원했지만, MySQL 5.6 버전부터는 트랜잭션을 지원하고 안정성이 높은 `InnoDB` 스토리지 엔진에서도 완벽하게 지원됩니다. 현대적인 애플리케이션 개발에서는 데이터 무결성과 동시성 제어를 위해 `InnoDB`를 사용하는 것이 표준이므로, 이 글에서도 `InnoDB` 사용을 전제로 설명합니다.

1.2. 한글 검색의 핵심: N-gram 파서

MySQL 전문 검색의 핵심 구성 요소 중 하나는 **파서(Parser)**입니다. 파서는 인덱싱할 텍스트를 어떤 기준으로 단어로 분리할지 결정하는 역할을 합니다. MySQL의 기본 내장 파서는 공백이나 구두점을 기준으로 단어를 분리합니다. 이는 'Hello World'와 같은 영어 텍스트를 'Hello'와 'World'로 분리하는 데는 효과적이지만, 한국어에는 심각한 문제를 야기합니다.

예를 들어, "스프링부트 강좌"라는 문장은 공백이 없기 때문에 기본 파서에 의해 하나의 거대한 단어로 인식됩니다. 따라서 사용자가 "스프링"이나 "부트"로 검색했을 때, 해당 문장을 전혀 찾지 못하는 결과가 발생합니다. 이 문제를 해결하기 위해 MySQL은 **N-gram 파서**를 제공합니다.

N-gram은 텍스트를 정해진 글자 수(N)로 잘라서 토큰을 생성하는 방식입니다. 예를 들어 N을 2로 설정(bigram)하고 "스프링부트"라는 텍스트에 적용하면 다음과 같이 토큰이 생성됩니다.

  • "스프"
  • "프링"
  • "링부"
  • "부트"

이렇게 생성된 토큰들이 `FULLTEXT` 인덱스에 저장되므로, 사용자가 "스프링"이라는 단어로 검색하면 "스프"와 "프링" 토큰을 포함하는 원본 문서를 성공적으로 찾아낼 수 있습니다. N-gram 파서를 사용하기 위해서는 MySQL 서버 설정 파일(`my.cnf` 또는 `my.ini`)에 다음과 같은 설정을 추가하고 서버를 재시작해야 합니다.

[mysqld]
ngram_token_size=2

`ngram_token_size`는 N의 크기를 의미하며, 보통 한국어 환경에서는 2(bigram) 또는 3(trigram)이 많이 사용됩니다. 이 값은 서비스의 데이터 특성과 검색 요구사항에 따라 신중하게 결정해야 합니다. 설정 후에는 `FULLTEXT` 인덱스를 생성할 때 해당 파서를 사용하도록 명시해야 합니다.

CREATE TABLE article (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    content TEXT NOT NULL,
    created_at DATETIME(6),
    updated_at DATETIME(6),
    FULLTEXT INDEX `ft_title_content` (title, content) WITH PARSER ngram
) ENGINE=InnoDB;

이미 생성된 테이블에 인덱스를 추가하는 경우에도 `WITH PARSER ngram` 구문을 동일하게 사용합니다.

1.3. 다양한 검색 모드 심층 분석

MySQL 전문 검색은 `MATCH() ... AGAINST()` 구문을 통해 사용되며, `AGAINST()` 함수 안에 검색어와 함께 검색 모드를 지정할 수 있습니다. 각 모드는 고유한 특징과 사용 사례를 가집니다.

A. `IN NATURAL LANGUAGE MODE` (자연어 모드)

가장 기본적이고 직관적인 모드입니다. 사용자가 입력한 검색어를 별도의 연산자 없이 문장처럼 입력하면, MySQL이 해당 단어들을 포함하는 문서를 찾아 **관련성 점수(Relevance Score)**가 높은 순으로 결과를 반환합니다. 관련성 점수는 TF-IDF와 유사한 알고리즘을 기반으로 계산되며, 문서 내 단어의 빈도, 전체 문서에서 해당 단어의 희귀성 등을 종합적으로 고려합니다.

SELECT id, title, MATCH(title, content) AGAINST('스프링부트 데이터베이스' IN NATURAL LANGUAGE MODE) AS score
FROM article
WHERE MATCH(title, content) AGAINST('스프링부트 데이터베이스' IN NATURAL LANGUAGE MODE)
ORDER BY score DESC;

주의할 점: 자연어 모드에는 '50% 임계값' 규칙이 있습니다. 만약 검색어에 포함된 단어가 전체 문서의 50% 이상에서 나타나면, 그 단어는 너무 흔하다고 간주되어 검색 결과에서 무시됩니다. 이는 매우 큰 테이블에서 예기치 않은 결과를 초래할 수 있으므로, 이러한 현상이 발생할 경우 `IN BOOLEAN MODE` 사용을 고려해야 합니다.

B. `IN BOOLEAN MODE` (불린 모드)

가장 강력하고 유연한 검색 모드입니다. 다양한 연산자를 사용하여 매우 정교하고 복잡한 검색 조건을 만들 수 있습니다. 관련성 점수 기반의 자동 정렬은 수행되지 않지만, 명시적으로 점수를 조회하고 정렬할 수는 있습니다.

  • `+` (필수 포함): `+스프링 +JPA` - '스프링'과 'JPA'를 모두 반드시 포함하는 문서
  • `-` (제외): `+스프링 -MyBatis` - '스프링'은 포함하지만 'MyBatis'는 제외하는 문서
  • ` ` (공백, OR): `스프링 JPA` - '스프링' 또는 'JPA' 중 하나 이상 포함하는 문서 (기본 동작)
  • `*` (와일드카드): `스프*` - '스프'로 시작하는 모든 단어(스프링, 스프링부트 등)를 포함하는 문서
  • `"` (구문 검색): `"스프링 부트"` - '스프링 부트'라는 구문이 정확히 일치하는 문서
  • `> <` (가중치 조절): `+스프링 >+JPA` - '스프링'과 'JPA'를 모두 포함하되, '스프링'에 더 높은 가중치를 부여
  • `()` (그룹화): `+(스프링 자바) +보안` - '스프링' 또는 '자바' 중 하나를 포함하면서, '보안'은 반드시 포함하는 문서
-- '스프링'은 반드시 포함하고, 'JPA' 또는 'QueryDSL'을 포함하며, 'MyBatis'는 제외하는 문서 검색
SELECT id, title FROM article
WHERE MATCH(title, content) AGAINST('+스프링 +(JPA QueryDSL) -MyBatis' IN BOOLEAN MODE);

불린 모드는 사용자에게 고급 검색 옵션을 제공하거나, 내부적으로 검색 로직을 정교하게 제어해야 할 때 매우 유용합니다.

C. `WITH QUERY EXPANSION` (쿼리 확장 모드)

자연어 모드의 확장된 형태입니다. 두 단계로 검색을 수행합니다. 첫 번째 단계에서는 자연어 모드로 검색하여 관련성이 가장 높은 문서들을 찾습니다. 두 번째 단계에서는 첫 번째 단계에서 찾은 문서들에서 중요한 단어들을 추출하여, 원래의 검색어에 그 단어들을 추가하여 다시 검색을 수행합니다. 이를 통해 사용자가 처음 입력한 검색어와 연관성이 높은 다른 문서들까지 검색 범위를 확장할 수 있습니다.

SELECT id, title FROM article
WHERE MATCH(title, content) AGAINST('데이터베이스' WITH QUERY EXPANSION);

예를 들어 '데이터베이스'로 검색했을 때, 이와 관련된 문서에서 'MySQL', 'JPA', '트랜잭션' 등의 단어가 자주 등장했다면, 쿼리 확장 모드는 이 단어들을 포함하는 다른 문서들까지 결과에 포함시켜 줍니다. 이는 사용자가 미처 생각하지 못한 연관 정보를 제공하는 데 도움이 될 수 있습니다.

2부: Spring Boot 프로젝트 연동 및 구현

이제 MySQL 전문 검색의 이론적 배경을 바탕으로, Spring Boot 애플리케이션에서 이를 어떻게 효과적으로 연동하고 구현하는지 단계별로 살펴보겠습니다.

2.1. 프로젝트 설정

먼저 Spring Boot 프로젝트에 필요한 의존성을 `pom.xml`에 추가합니다.

<dependencies>
    <!-- Spring Boot Web Starter for building REST APIs -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Data JPA for database access -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- MySQL Connector/J driver -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Lombok for reducing boilerplate code (optional but recommended) -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <!-- Spring Boot Test Starter for integration tests -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

다음으로 `src/main/resources/application.yml` 파일에 데이터베이스 연결 정보와 JPA 관련 설정을 추가합니다. `properties` 파일보다 구조적으로 명확한 `yml` 형식을 사용하는 것을 권장합니다.

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/your_database?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
    username: your_username
    password: your_password
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      # N-gram 파서를 지원하는 MySQL8Dialect 사용
      ddl-auto: update # 개발 환경에서는 update 또는 create, 운영 환경에서는 validate 또는 none 권장
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL8Dialect
        format_sql: true # SQL 쿼리를 보기 좋게 포맷팅
    show-sql: true # 실행되는 SQL 쿼리를 로그로 출력
    open-in-view: false # OSIV(Open Session In View) 비활성화 권장

여기서 `hibernate.dialect`를 `MySQL8Dialect`로 명시하는 것이 중요합니다. 이는 Hibernate가 MySQL 8.0 이상의 버전에 최적화된 SQL을 생성하도록 보장합니다.

2.2. 엔티티 및 Repository 설계

데이터베이스의 `article` 테이블과 매핑될 `Article` 엔티티 클래스를 작성합니다. JPA의 `@Table` 어노테이션을 사용하여 `FULLTEXT` 인덱스를 명시할 수도 있습니다. 이는 JPA가 DDL을 생성할 때(ddl-auto: create) 해당 인덱스를 함께 생성하도록 합니다.

package com.example.search.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Table(name = "article", indexes = {
    @Index(name = "ft_title_content", columnList = "title, content",
           // Hibernate 6+ 에서는 @Index에 fulltext 옵션을 직접 지정하기 어려움.
           // 따라서 아래와 같이 columnDefinition을 사용하거나, 별도의 DDL 스크립트로 관리하는 것이 좋음.
           // 이 예제에서는 DDL 스크립트로 관리하는 것을 가정.
    )
})
public class Article {

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

    @Column(nullable = false, length = 255)
    private String title;

    @Lob // TEXT 타입과 매핑
    @Column(nullable = false)
    private String content;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    @Builder
    public Article(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

참고: Hibernate 6 버전부터는 `@Index` 어노테이션을 통해 `WITH PARSER ngram`과 같은 DBMS 특정 구문을 직접 추가하는 것이 복잡해졌습니다. 따라서 가장 확실하고 권장되는 방법은 Flyway나 Liquibase 같은 데이터베이스 마이그레이션 도구를 사용하거나, `src/main/resources/schema.sql` 파일을 통해 직접 DDL을 관리하는 것입니다.

이제 `Article` 엔티티를 다룰 `ArticleRepository` 인터페이스를 생성합니다. 여기에 전문 검색을 위한 네이티브 쿼리 메소드를 정의합니다.

package com.example.search.repository;

import com.example.search.domain.Article;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ArticleRepository extends JpaRepository<Article, Long> {

    /**
     * 불린 모드를 사용한 전문 검색 (페이지네이션 포함)
     * @param keyword boolean mode 포맷의 검색어 (+word, -word, "exact phrase" 등)
     * @param pageable 페이지네이션 정보
     * @return 검색 결과 페이지
     */
    @Query(value = "SELECT * FROM article WHERE MATCH(title, content) AGAINST(:keyword IN BOOLEAN MODE)",
           countQuery = "SELECT count(*) FROM article WHERE MATCH(title, content) AGAINST(:keyword IN BOOLEAN MODE)",
           nativeQuery = true)
    Page<Article> fullTextSearchByBooleanMode(@Param("keyword") String keyword, Pageable pageable);

    /**
     * 자연어 모드를 사용한 전문 검색 (페이지네이션 포함)
     * @param keyword 일반 검색어
     * @param pageable 페이지네이션 정보
     * @return 검색 결과 페이지
     */
    @Query(value = "SELECT * FROM article WHERE MATCH(title, content) AGAINST(:keyword IN NATURAL LANGUAGE MODE)",
           countQuery = "SELECT count(*) FROM article WHERE MATCH(title, content) AGAINST(:keyword IN NATURAL LANGUAGE MODE)",
           nativeQuery = true)
    Page<Article> fullTextSearchByNaturalLanguageMode(@Param("keyword") String keyword, Pageable pageable);
}

여기서 중요한 점은 `Page<T>` 타입으로 결과를 받기 위해 `countQuery`를 함께 정의한 것입니다. Spring Data JPA는 페이지네이션을 처리하기 위해 전체 결과의 개수를 알아야 하는데, 네이티브 쿼리에서는 이를 자동으로 유추할 수 없으므로 `countQuery`를 명시적으로 제공해야 합니다. 이렇게 함으로써 `Pageable` 객체를 파라미터로 넘겨 손쉽게 페이지네이션을 구현할 수 있습니다.

2.3. 관련성 점수와 함께 결과 반환하기

검색 결과에서 관련성 점수를 함께 표시하고 싶을 때가 많습니다. 하지만 `Article` 엔티티에는 `score` 필드가 없기 때문에 직접 매핑할 수 없습니다. 이 문제를 해결하기 위해 **프로젝션(Projection)**과 **DTO(Data Transfer Object)**를 활용합니다.

먼저 검색 결과를 담을 `ArticleSearchResultDto`를 생성합니다.

package com.example.search.dto;

import lombok.Getter;

@Getter
public class ArticleSearchResultDto {
    private Long id;
    private String title;
    private Double score; // 관련성 점수

    // JPA Constructor Expression을 위한 생성자
    public ArticleSearchResultDto(Long id, String title, Double score) {
        this.id = id;
        this.title = title;
        this.score = score;
    }
}

그 다음, `ArticleRepository`에 DTO를 반환하는 새로운 쿼리 메소드를 추가합니다. 이때 JPA의 **Constructor Expression** (`SELECT new ...`)을 사용합니다.

// ArticleRepository.java에 추가
import com.example.search.dto.ArticleSearchResultDto;

public interface ArticleRepository extends JpaRepository<Article, Long> {

    // ... 기존 메소드 생략 ...

    /**
     * 자연어 모드로 검색하고 관련성 점수를 포함한 DTO로 결과를 반환 (페이지네이션 포함)
     * @param keyword 일반 검색어
     * @param pageable 페이지네이션 정보
     * @return DTO 결과 페이지
     */
    @Query(value = "SELECT new com.example.search.dto.ArticleSearchResultDto(" +
                   "   a.id, " +
                   "   a.title, " +
                   "   MATCH(a.title, a.content) AGAINST(:keyword IN NATURAL LANGUAGE MODE)" +
                   ") " +
                   "FROM Article a " + // 네이티브 쿼리가 아닌 JPQL을 사용
                   "WHERE MATCH(a.title, a.content) AGAINST(:keyword IN NATURAL LANGUAGE MODE) > 0")
    Page<ArticleSearchResultDto> searchByNaturalLanguageModeWithScore(
        @Param("keyword") String keyword, Pageable pageable
    );
}

중요: 위 예제는 JPQL을 사용하고 있습니다. Hibernate는 `MATCH() ... AGAINST()` 구문을 JPQL/HQL 내에서 사용할 수 있도록 지원합니다. 이를 위해서는 `hibernate.dialect` 설정이 `MySQL8Dialect`와 같이 올바르게 되어 있어야 하며, `MATCH` 함수를 Hibernate에 등록해야 할 수도 있습니다. 만약 JPQL에서 `MATCH` 함수가 동작하지 않는다면, 네이티브 쿼리와 `Result-Set-Mapping` 또는 `Interface-based-Projection`을 사용하는 것이 대안이 될 수 있습니다. 하지만 Constructor Expression을 활용한 JPQL 방식이 가장 간결하고 타입-세이프합니다.

네이티브 쿼리를 사용해야 하는 경우(예: 더 복잡한 SQL 구문 필요)는 아래와 같이 작성할 수 있습니다.

// 네이티브 쿼리 + 인터페이스 기반 프로젝션 예시
public interface ArticleSearchResultProjection {
    Long getId();
    String getTitle();
    Double getScore();
}

// ArticleRepository.java에 추가
@Query(value = "SELECT id, title, " +
               "MATCH(title, content) AGAINST(:keyword IN NATURAL LANGUAGE MODE) as score " +
               "FROM article " +
               "WHERE MATCH(title, content) AGAINST(:keyword IN NATURAL LANGUAGE MODE) > 0",
       countQuery = "SELECT count(*) FROM article WHERE MATCH(title, content) AGAINST(:keyword IN NATURAL LANGUAGE MODE) > 0",
       nativeQuery = true)
Page<ArticleSearchResultProjection> searchNativelyWithScore(@Param("keyword") String keyword, Pageable pageable);

이 방식은 순수 네이티브 쿼리를 유지하면서 DTO와 유사한 효과를 낼 수 있어 매우 유용합니다.

3부: API 설계 및 고급 주제

이제 Repository 계층이 준비되었으니, 이를 활용하여 사용자에게 검색 기능을 제공할 서비스와 API 컨트롤러를 설계하고, 성능 최적화와 같은 고급 주제를 다루어 보겠습니다.

3.1. Service 계층의 도입

컨트롤러가 직접 Repository를 호출하기보다는, 중간에 서비스 계층을 두어 비즈니스 로직을 처리하고 트랜잭션을 관리하는 것이 좋습니다. 이는 애플리케이션의 유연성과 확장성을 높여줍니다.

package com.example.search.service;

import com.example.search.dto.ArticleSearchRequestDto;
import com.example.search.repository.ArticleRepository;
import com.example.search.repository.ArticleSearchResultProjection;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Arrays;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // 읽기 전용 트랜잭션으로 성능 최적화
public class ArticleSearchService {

    private final ArticleRepository articleRepository;

    public Page<ArticleSearchResultProjection> search(ArticleSearchRequestDto requestDto, Pageable pageable) {
        String keyword = requestDto.getKeyword();
        String mode = requestDto.getMode();

        if ("boolean".equalsIgnoreCase(mode)) {
            String booleanKeyword = convertToBooleanModeKeyword(keyword);
            // Boolean 모드는 Repository에 별도 메소드 구현 필요 (생략)
            // return articleRepository.fullTextSearchByBooleanMode(booleanKeyword, pageable);
        }

        // 기본은 Natural Language Mode로 점수와 함께 검색
        return articleRepository.searchNativelyWithScore(keyword, pageable);
    }

    /**
     * 일반 검색어를 Boolean Mode 검색을 위한 형식으로 변환하는 헬퍼 메소드.
     * 예: "스프링 JPA" -> "+스프링 +JPA"
     * @param keyword 일반 검색어
     * @return Boolean Mode 포맷의 검색어
     */
    private String convertToBooleanModeKeyword(String keyword) {
        if (keyword == null || keyword.isBlank()) {
            return "";
        }
        // 각 단어 앞에 +를 붙여 AND 조건으로 만듦
        return Arrays.stream(keyword.split("\\s+"))
                     .map(word -> "+" + word)
                     .collect(Collectors.joining(" "));
    }
}

위 서비스 클래스는 검색 요청 DTO를 받아 검색 모드에 따라 적절한 Repository 메소드를 호출합니다. 특히 `convertToBooleanModeKeyword` 메소드처럼 검색어를 가공하는 로직은 서비스 계층에 위치하는 것이 적절합니다.

3.2. Controller와 DTO 패턴

마지막으로, 외부 요청을 받아 처리하는 REST 컨트롤러를 작성합니다. API의 입력과 출력으로는 엔티티 대신 전용 DTO를 사용하는 것이 좋습니다. 이는 API 스펙과 내부 도메인 모델을 분리하여 시스템을 더 유연하게 만듭니다.

먼저 검색 요청을 받을 DTO를 정의합니다.

package com.example.search.dto;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class ArticleSearchRequestDto {
    private String keyword;
    private String mode = "natural"; // 기본 검색 모드는 natural
}

이제 이 DTO를 사용하는 컨트롤러를 작성합니다.

package com.example.search.controller;

import com.example.search.dto.ArticleSearchRequestDto;
import com.example.search.repository.ArticleSearchResultProjection;
import com.example.search.service.ArticleSearchService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/articles")
@RequiredArgsConstructor
public class ArticleController {

    private final ArticleSearchService articleSearchService;

    @GetMapping("/search")
    public ResponseEntity<Page<ArticleSearchResultProjection>> searchArticles(
            @ModelAttribute ArticleSearchRequestDto requestDto,
            @PageableDefault(size = 10, sort = "score", direction = Sort.Direction.DESC) Pageable pageable
    ) {
        // 인터페이스 기반 프로젝션 사용 시, Pageable의 sort 프로퍼티가 엔티티 필드가 아닌 'score'를 참조할 경우
        // Spring Data JPA가 자동으로 변환해주지 못할 수 있음.
        // 이 경우, 서비스 계층에서 PageRequest.of()를 통해 직접 Pageable을 만들거나,
        // 쿼리 내에서 ORDER BY를 명시적으로 처리해야 함.
        // 아래 코드는 개념적인 예시이며 실제로는 정렬 처리에 주의가 필요.

        Page<ArticleSearchResultProjection> results = articleSearchService.search(requestDto, pageable);
        return ResponseEntity.ok(results);
    }
}

위 컨트롤러는 `/api/articles/search?keyword=...&mode=...&page=...&size=...` 와 같은 요청을 처리합니다. `@ModelAttribute`를 사용하여 여러 요청 파라미터를 DTO 객체로 편리하게 바인딩할 수 있습니다. `@PageableDefault` 어노테이션을 사용하여 기본 페이지 크기나 정렬 기준을 지정할 수 있지만, 네이티브 쿼리와 프로젝션을 사용할 때는 정렬 기준(sort 프로퍼티)이 엔티티 필드가 아닐 경우 자동으로 동작하지 않을 수 있으므로, 이 부분은 서비스 계층이나 쿼리 자체에서 명시적으로 처리하는 것이 더 안정적입니다.

3.3. 성능 최적화 및 고려사항

  • 인덱싱 컬럼 선택: `FULLTEXT` 인덱스에 포함되는 컬럼이 많아질수록 인덱스의 크기는 커지고 데이터 CUD(Create, Update, Delete) 작업 시 오버헤드가 발생합니다. 반드시 검색이 필요한 텍스트 컬럼만 인덱스에 포함시켜야 합니다.
  • 불용어(Stopwords) 관리: '은', '는', '이', '가', 'a', 'the'와 같이 검색에 의미가 없는 단어들은 인덱싱에서 제외하는 것이 효율적입니다. MySQL은 내장 불용어 목록을 가지고 있으며, 사용자가 직접 불용어 목록을 담은 테이블을 생성하고 시스템 변수(`innodb_ft_server_stopword_table`)를 통해 지정할 수도 있습니다.
  • 최소 단어 길이: `ft_min_word_len` (MyISAM) 또는 `innodb_ft_min_token_size` (InnoDB) 시스템 변수는 인덱싱할 단어의 최소 길이를 지정합니다. N-gram 파서를 사용할 때는 `ngram_token_size`가 이 역할을 대신하므로 이 값에 맞게 쿼리를 최적화해야 합니다.
  • 쿼리 분석: `EXPLAIN` 명령어를 사용하여 실행하는 전문 검색 쿼리가 실제로 `FULLTEXT` 인덱스를 사용하는지 확인하는 습관이 중요합니다. `EXPLAIN` 결과의 `type` 컬럼에 `fulltext`가 표시되어야 합니다.

결론

이 글을 통해 우리는 Spring Boot와 MySQL을 사용하여 단순한 키워드 매칭을 넘어선, 실무 수준의 고성능 전문 검색 기능을 구축하는 전 과정을 살펴보았습니다. 핵심은 MySQL 전문 검색의 내부 동작 원리, 특히 **N-gram 파서의 중요성**을 이해하고, 이를 Spring Data JPA의 **네이티브 쿼리** 및 **프로젝션** 기능과 결합하여 유연하고 효율적인 코드를 작성하는 것이었습니다.

MySQL 전문 검색은 Elasticsearch와 같은 외부 검색 엔진이 제공하는 모든 고급 기능을 갖추지는 못했지만, 별도의 인프라 구축 없이 기존의 RDBMS 환경 내에서 매우 빠르고 강력한 검색 솔루션을 구현할 수 있다는 점에서 큰 장점을 가집니다. 특히 중소 규모의 프로젝트나, 검색 기능이 핵심 비즈니스는 아니지만 기본적인 자연어 검색 및 관련도 정렬 기능이 필요한 경우, MySQL 전문 검색은 비용과 개발 복잡성 측면에서 매우 합리적이고 효율적인 선택이 될 수 있습니다.

여기서 다룬 개념들—다양한 검색 모드의 활용, 페이지네이션 처리, DTO를 통한 API 계층 분리, 서비스 계층의 역할—은 비단 검색 기능뿐만 아니라 모든 Spring Boot 기반 애플리케이션 개발에 적용될 수 있는 중요한 원칙입니다. 이 글이 여러분의 프로젝트에 강력한 검색 기능을 더하는 데 훌륭한 출발점이 되기를 바랍니다.

Building Robust Search Experiences with Spring Boot and MySQL Full-Text Search

In the digital age, the ability to quickly and accurately find information within an application is no longer a luxury—it's a core user expectation. For developers building data-driven applications, implementing a powerful search feature is a critical task. While a simple `LIKE` query might suffice for the most basic use cases, it quickly falls short in terms of performance and relevance when dealing with large volumes of text data. The `LIKE '%keyword%'` pattern, in particular, is notorious for its inability to use standard B-Tree indexes, leading to full table scans and a significant degradation in application speed as the dataset grows.

This is where Full-Text Search (FTS) comes into play. FTS is a specialized technology designed to efficiently and intelligently search through natural language documents. Instead of performing simple pattern matching, it tokenizes text, removes common "stop words" (like 'the', 'is', 'a'), and builds a sophisticated inverted index. This index maps words back to the documents that contain them, allowing for incredibly fast lookups and complex relevance scoring.

While dedicated search engines like Elasticsearch and Apache Solr offer the gold standard in search capabilities, they also introduce significant operational overhead. They are separate systems that need to be deployed, managed, and kept in sync with the primary database. For many projects, this complexity is overkill. Fortunately, modern relational databases, including MySQL, come equipped with powerful built-in full-text search functionality. Leveraging MySQL's FTS engine within a Spring Boot application provides a pragmatic, performant, and highly effective solution that avoids the need for additional infrastructure, making it an ideal choice for a wide range of applications.

This article provides a comprehensive exploration of how to integrate and master MySQL's Full-Text Search features within a Spring Boot ecosystem. We will go beyond a simple implementation, delving into the underlying mechanics of FTS, exploring its various search modes, and establishing best practices for building a scalable, efficient, and user-friendly search API. We will cover everything from initial database schema design to advanced query techniques, data transfer objects, and performance considerations.


1. Foundational Database Setup: The FULLTEXT Index

The entire mechanism of MySQL's full-text search hinges on a special type of index: the `FULLTEXT` index. Unlike a standard B-Tree index that is optimized for exact value lookups and range scans on structured data (like numbers, dates, or specific strings), a `FULLTEXT` index is designed specifically for indexing and searching blocks of natural language text.

Understanding the Inverted Index

When you create a `FULLTEXT` index, MySQL performs several crucial steps under the hood:

  1. Tokenization: The text from the specified columns is broken down into individual words, or "tokens." This process is governed by a parser, which determines how to split the text based on spaces, punctuation, and other delimiters.
  2. Stop Word Filtering: MySQL maintains a list of common, low-information words (stop words) such as "and," "it," or "for." These words are typically discarded from the index to save space and improve the relevance of search results. This list is configurable.
  3. Stemming/Normalization: Tokens are often normalized to their root form (e.g., 'running' and 'ran' might both be indexed as 'run'). This ensures that searches for one form of a word can find documents containing other forms.
  4. Index Creation: Finally, MySQL builds an inverted index. This data structure is essentially a dictionary that maps each unique, processed token to a list of documents (and their specific locations within those documents) where the token appears.

This inverted index is what makes FTS so fast. When you search for a term, MySQL doesn't have to scan every row of the table. Instead, it looks up the term in the index, instantly retrieves the list of matching document IDs, and then calculates a relevance score for each one.

Creating the Table with a FULLTEXT Index

To enable FTS, you must define a `FULLTEXT` index on one or more `CHAR`, `VARCHAR`, or `TEXT` columns. It's important to use the `InnoDB` storage engine, which has been the default in MySQL since version 5.5 and offers robust support for FTS along with critical features like transactions and row-level locking.

Let's define a table named `articles` for a blog or news application. We want to allow users to search across both the `title` and `content` of the articles.


CREATE TABLE articles (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    content TEXT NOT NULL,
    author VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FULLTEXT KEY ft_index_title_content (title, content)
) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

In this schema definition, we have made a few key decisions:

  • `id BIGINT`: Using `BIGINT` instead of `INT` is a good practice for primary keys to prevent potential overflow as the table grows.
  • `ENGINE=InnoDB`: Explicitly specifies the `InnoDB` storage engine, which is the modern standard.
  • `CHARACTER SET utf8mb4`: This character set fully supports the Unicode standard, including emojis and a wide range of international characters, which is essential for modern web applications.
  • `FULLTEXT KEY ft_index_title_content (title, content)`: This is the core of our setup. We've created a single `FULLTEXT` index named `ft_index_title_content` that covers both the `title` and `content` columns. This allows us to perform a unified search across both fields simultaneously. Searching against this combined index is generally more efficient than maintaining separate indexes and combining results in the application.

2. Configuring the Spring Boot Application

With the database schema ready, the next step is to configure our Spring Boot application to communicate with the MySQL instance and manage our data model using Spring Data JPA.

Essential Dependencies

First, ensure your `pom.xml` (for Maven projects) includes the necessary dependencies. You'll need the Spring Boot Starter for JPA, the web starter for building a REST API, and the MySQL JDBC driver.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
    <!-- ... other project configurations ... -->
    
    <dependencies>
        <!-- Spring Boot Starter for building web, including RESTful, applications using Spring MVC -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring Boot Starter for using Spring Data JPA with Hibernate -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- MySQL JDBC Driver -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        
        <!-- Lombok for reducing boilerplate code (optional but highly recommended) -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        
        <!-- ... other dependencies like spring-boot-starter-test ... -->
    </dependencies>
    
    <!-- ... build configuration ... -->
</project>

Database Connection Properties

Next, configure the database connection in your `src/main/resources/application.properties` file. It's crucial to manage these settings carefully, especially the `ddl-auto` property.


# --- MySQL Datasource Configuration ---
spring.datasource.url=jdbc:mysql://localhost:3306/your_database?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
spring.datasource.username=your_username
spring.datasource.password=your_password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# --- JPA & Hibernate Configuration ---
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect

A Note on `spring.jpa.hibernate.ddl-auto`

This property controls Hibernate's behavior regarding the database schema. It's a powerful but potentially destructive setting. Understanding its values is critical:

  • `create`: Drops the existing schema and creates a new one on startup. Useful for testing but wipes all data.
  • `create-drop`: Creates the schema on startup and drops it on shutdown. Also only for testing.
  • `update`: Attempts to update the schema to match the entity definitions. Can be helpful in early development but is risky as it can fail with complex schema changes and should not be used in production.
  • `validate`: Validates that the existing schema matches the entity mappings. If there's a mismatch, the application will fail to start. This is a safe and recommended setting for production environments.
  • `none`: Disables DDL handling entirely.

For this guide, we use `validate`, assuming you have already created the `articles` table using the SQL script from the previous section.


3. Modeling Data: Entities, Repositories, and DTOs

A well-structured application separates concerns. We will define an Entity to map to our database table, a Repository for data access, a DTO to shape our API response, and a Service layer to orchestrate the logic.

The Article Entity

The `Article` entity is a plain Java object annotated with JPA annotations to map it to the `articles` table in our database. Using Lombok's annotations (`@Entity`, `@Data`, `@NoArgsConstructor`, `@AllArgsConstructor`) significantly reduces boilerplate code.


package com.example.searchapp.domain;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "articles")
@Data // Generates getters, setters, toString(), equals(), hashCode()
@NoArgsConstructor
@AllArgsConstructor
public class Article {

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

    @Column(nullable = false)
    private String title;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    private String author;

    // Constructors, Getters, and Setters are handled by Lombok
}

The Spring Data JPA Repository

The repository interface is where we will define our custom full-text search query. It extends `JpaRepository`, which provides a rich set of standard CRUD (Create, Read, Update, Delete) methods out of the box.

Here, we will define the method for our full-text search. Crucially, since the `MATCH(...) AGAINST(...)` syntax is specific to MySQL and not part of the standard JPQL (Java Persistence Query Language), we must use a native query.


package com.example.searchapp.repository;

import com.example.searchapp.domain.Article;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {

    /**
     * Performs a full-text search using MySQL's MATCH...AGAINST in natural language mode.
     * @param keyword The search term.
     * @return A list of articles matching the keyword.
     */
    @Query(value = "SELECT * FROM articles WHERE MATCH(title, content) AGAINST (:keyword IN NATURAL LANGUAGE MODE)", nativeQuery = true)
    List<Article> searchByFullTextNaturalMode(@Param("keyword") String keyword);

}

Notice the use of `nativeQuery = true` and the named parameter `:keyword`. This tells Spring Data JPA to execute the provided SQL string directly against the database.


4. Unlocking Advanced Search with MySQL FTS Modes

MySQL's full-text search is not a one-size-fits-all tool. It offers several powerful modes that fundamentally change how search queries are interpreted. Understanding and utilizing these modes is key to building a truly sophisticated search feature.

1. `IN NATURAL LANGUAGE MODE` (The Default)

This is the default mode if none is specified. It interprets the search string as a phrase in natural human language.

  • How it works: It looks for rows that are relevant to the search phrase. Relevance is calculated based on factors like the number of times the word appears in the document, the number of documents containing the word, and the total number of words in the document.
  • Best for: General-purpose searching, similar to how a web search engine works. It's user-friendly and provides results ranked by relevance.
  • Example Query: `AGAINST('spring boot database' IN NATURAL LANGUAGE MODE)` will find documents containing "spring," "boot," or "database," and rank documents containing all three terms higher.

2. `IN BOOLEAN MODE` (The Power User's Choice)

This mode allows for much more precise control over the search logic by using special operators in the search string.

  • How it works: It enables boolean logic. It does not automatically sort results by relevance; you must do that explicitly if needed.
  • Key Operators:
    • `+` (Required): The word must be present. E.g., `+spring +security` finds documents containing both "spring" and "security."
    • `-` (Exclude): The word must not be present. E.g., `+spring -jpa` finds documents with "spring" but not "jpa."
    • `*` (Wildcard): A wildcard that matches any words beginning with the prefix. It must be at the end of the term. E.g., `data*` matches "data," "database," "dataset," etc.
    • `"` (Phrase): The words inside the double quotes must appear together as an exact phrase. E.g., `"spring data jpa"` will only match that exact sequence.
    • `>` and `<` (Relevance): Increase or decrease a word's contribution to the relevance score. E.g., `+spring >security` makes "spring" more important than "security" in the ranking.
  • Best for: Implementing advanced search forms where users can specify required keywords, excluded words, or exact phrases.

3. `WITH QUERY EXPANSION` (The Explorer)

This is an advanced modification of the natural language mode. It performs a two-stage search.

  • How it works:
    1. First, it performs a standard natural language search.
    2. Then, it analyzes the most relevant documents from the first search and identifies other words that frequently co-occur with the original search terms.
    3. Finally, it performs a second search using the original terms plus these newly discovered, highly relevant words.
  • Best for: "Find similar" or "suggested articles" features. It helps users discover content they might not have thought to search for directly. For example, a search for "database" might also return results containing "MySQL," "PostgreSQL," or "transaction," if those terms are highly relevant in the initial result set.

Implementing Different Modes in the Repository

Let's expand our `ArticleRepository` to support these modes.


@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {

    // Natural Language Mode (already defined)
    @Query(value = "SELECT * FROM articles WHERE MATCH(title, content) AGAINST (:keyword IN NATURAL LANGUAGE MODE)", nativeQuery = true)
    List<Article> searchByFullTextNaturalMode(@Param("keyword") String keyword);

    // Boolean Mode
    @Query(value = "SELECT * FROM articles WHERE MATCH(title, content) AGAINST (:searchQuery IN BOOLEAN MODE)", nativeQuery = true)
    List<Article> searchByFullTextBooleanMode(@Param("searchQuery") String searchQuery);

    // Query Expansion Mode
    @Query(value = "SELECT * FROM articles WHERE MATCH(title, content) AGAINST (:keyword WITH QUERY EXPANSION)", nativeQuery = true)
    List<Article> searchByFullTextWithQueryExpansion(@Param("keyword") String keyword);
}

Now our data access layer is equipped to handle a variety of sophisticated search requests.


5. Architecting the Service and Controller Layers

A robust application architecture separates business logic from data access and web-facing concerns. We will now introduce a service layer to orchestrate the search logic and a controller to expose it via a REST API.

Creating a Search Service

The `ArticleSearchService` will act as an intermediary between the controller and the repository. This is where we can add logic, such as transforming a user's simple keyword into a more complex boolean query string.


package com.example.searchapp.service;

import com.example.searchapp.domain.Article;
import com.example.searchapp.repository.ArticleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;
import java.util.Arrays;

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

    private final ArticleRepository articleRepository;

    public ArticleSearchService(ArticleRepository articleRepository) {
        this.articleRepository = articleRepository;
    }

    public List<Article> searchNatural(String keyword) {
        return articleRepository.searchByFullTextNaturalMode(keyword);
    }

    public List<Article> searchWithExpansion(String keyword) {
        return articleRepository.searchByFullTextWithQueryExpansion(keyword);
    }
    
    /**
     * Constructs and executes a boolean mode search.
     * For example, a user query "spring boot -java" would be transformed to "+spring +boot -java".
     * @param query The user's search query.
     * @return A list of articles matching the boolean query.
     */
    public List<Article> searchBoolean(String query) {
        // A simple example of transforming a user query for boolean mode.
        // A more robust implementation would handle quotes for phrases, etc.
        String booleanQuery = Arrays.stream(query.split("\\s+"))
                .map(term -> {
                    if (term.startsWith("-")) {
                        return term; // Already an exclusion term
                    }
                    return "+" + term; // Assume all other terms are required
                })
                .collect(Collectors.joining(" "));

        return articleRepository.searchByFullTextBooleanMode(booleanQuery);
    }
}

Building the REST Controller

The `ArticleController` exposes our search functionality as HTTP endpoints. It's responsible for handling incoming web requests, calling the appropriate service method, and returning the results to the client, typically in JSON format.


package com.example.searchapp.controller;

import com.example.searchapp.domain.Article;
import com.example.searchapp.service.ArticleSearchService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/articles")
public class ArticleController {

    private final ArticleSearchService articleSearchService;

    public ArticleController(ArticleSearchService articleSearchService) {
        this.articleSearchService = articleSearchService;
    }

    @GetMapping("/search")
    public ResponseEntity<List<Article>> searchArticles(
            @RequestParam String q,
            @RequestParam(defaultValue = "natural") String mode) {
        
        List<Article> results;
        
        switch (mode.toLowerCase()) {
            case "boolean":
                results = articleSearchService.searchBoolean(q);
                break;
            case "expansion":
                results = articleSearchService.searchWithExpansion(q);
                break;
            case "natural":
            default:
                results = articleSearchService.searchNatural(q);
                break;
        }

        if (results.isEmpty()) {
            return ResponseEntity.noContent().build();
        }
        return ResponseEntity.ok(results);
    }
}

With this controller, users can now perform searches via an endpoint like `GET /api/articles/search?q=spring+database&mode=boolean`. This provides a flexible and powerful API for any front-end client to consume.


6. Advanced Techniques: DTOs, Relevance Scoring, and Pagination

For a production-ready application, we need to refine our implementation. Returning raw entity objects can expose sensitive data and be inefficient. We also need to incorporate relevance scores and handle large result sets with pagination.

Using Data Transfer Objects (DTOs) for API Responses

A DTO is a simple object used to transfer data between processes. In our case, it will shape the JSON response of our API. This prevents exposing internal entity structure and allows us to include additional information, like the relevance score.


package com.example.searchapp.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ArticleSearchResponse {
    private Long id;
    private String title;
    private String author;
    private Double relevanceScore;
}

Capturing the Relevance Score

The `MATCH...AGAINST` function not only finds matching rows but also returns a relevance score as a floating-point number. Higher numbers indicate a better match. We can select this score in our native query.

To map this custom result set (which includes columns from the `Article` entity plus the `relevance_score` alias) to our DTO, we can use a JPA Projection Interface. This is often simpler than using `@SqlResultSetMapping` for this purpose.

First, define the projection interface in your repository package:


package com.example.searchapp.repository;

public interface ArticleSearchResultProjection {
    Long getId();
    String getTitle();
    String getAuthor();
    Double getRelevanceScore();
}

Next, modify the repository query to select the score and return this projection:


// In ArticleRepository.java

@Query(
    value = "SELECT id, title, author, MATCH(title, content) AGAINST (:keyword IN NATURAL LANGUAGE MODE) as relevanceScore " +
            "FROM articles WHERE MATCH(title, content) AGAINST (:keyword IN NATURAL LANGUAGE MODE) > 0 " +
            "ORDER BY relevanceScore DESC",
    nativeQuery = true
)
List<ArticleSearchResultProjection> searchAndScoreByFullTextNaturalMode(@Param("keyword") String keyword);

Note two key changes:

  1. We are now explicitly selecting columns and the relevance score aliased as `relevanceScore`.
  2. We added a `WHERE` clause (`> 0`) to filter out non-matching results and an `ORDER BY` clause to sort the results by relevance, which is a critical part of any useful search feature.

Implementing Pagination

Returning thousands of search results in a single response is impractical. Spring Data's `Pageable` abstraction makes pagination straightforward. For native queries, however, it requires a separate `countQuery` to calculate the total number of matching elements.

Let's update our repository and service to support pagination.


// In ArticleRepository.java
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

// ...

@Query(
    value = "SELECT id, title, author, MATCH(title, content) AGAINST (:keyword IN NATURAL LANGUAGE MODE) as relevanceScore " +
            "FROM articles WHERE MATCH(title, content) AGAINST (:keyword IN NATURAL LANGUAGE MODE) > 0",
    countQuery = "SELECT count(*) FROM articles WHERE MATCH(title, content) AGAINST (:keyword IN NATURAL LANGUAGE MODE) > 0",
    nativeQuery = true
)
Page<ArticleSearchResultProjection> searchAndScoreByFullTextNaturalMode(
    @Param("keyword") String keyword, 
    Pageable pageable
);

Now update the service and controller to handle `Pageable` requests and return a `Page` of DTOs. This change transforms the search from a simple list retrieval into a scalable, production-grade feature.

Conclusion and Further Considerations

We have successfully built a robust, multi-faceted search feature using the power of Spring Boot and the built-in capabilities of MySQL Full-Text Search. By starting with a solid database schema and progressing through advanced search modes, service layer architecture, DTOs, relevance scoring, and pagination, we have created a solution that is both powerful and maintainable, without introducing the complexity of external search engines.

MySQL FTS is an excellent tool, but it's important to know its context. For applications with moderate data volumes and standard text search requirements, it is a highly efficient and cost-effective choice. However, as your application scales to handle terabytes of data, requires near real-time indexing, or needs complex aggregations and geospatial search capabilities, it may be time to consider migrating to a dedicated solution like Elasticsearch. The architectural patterns we've established here—such as the use of service layers and DTOs—make such a future migration a much smoother process.

By mastering the techniques outlined in this article, you are well-equipped to deliver the high-quality search experiences that modern users demand, leveraging the familiar and reliable ecosystem of Spring and MySQL.

Spring BootとMySQLによる高度な全文検索システムの構築

現代のウェブアプリケーションにおいて、ユーザーが求める情報を迅速かつ正確に提供する検索機能は、ユーザーエクスペリエンスを決定づける重要な要素です。単純なキーワードマッチングを超え、文脈や関連性を考慮した高度な検索機能は、もはや不可欠な存在と言えるでしょう。多くの開発現場で採用されているSpring BootとMySQLの組み合わせは、このような要求に応えるための強力な基盤を提供します。特にMySQL 5.7以降で大幅に強化されたInnoDBエンジンの全文検索機能は、外部の検索エンジンを導入するほどの複雑さを必要としない多くのケースにおいて、非常に現実的で効率的なソリューションとなります。

この記事では、Spring BootとMySQLを活用して、実用的な全文検索機能を段階的に実装していくプロセスを詳細に解説します。単にコードを提示するだけでなく、なぜその技術を選択するのか、どのような設計思想が背景にあるのかを深く掘り下げます。データベースのテーブル設計からインデックスの最適化、特に日本語のような分かち書きのない言語(CJK言語)を扱う上での特有の課題と解決策、そしてSpring Bootアプリケーション内での適切な階層設計(Controller, Service, Repository)に至るまで、網羅的にカバーします。最終的には、関連度スコアリングやページネーションといった、より洗練された検索システムを構築するための高度なテクニックにも触れていきます。

第1章: MySQL全文検索の基盤設計

全文検索機能の実装は、まず堅牢なデータベース設計から始まります。MySQLで高性能な全文検索を実現するためには、FULLTEXTインデックスを正しく理解し、適切に設定することが不可欠です。この章では、インデックスの作成方法から、ストレージエンジンの選択、そして日本語検索における最重要課題である「N-gramパーサー」の導入までを詳しく見ていきます。

1.1. FULLTEXTインデックスの役割とストレージエンジン

リレーショナルデータベースにおける検索処理として、多くの開発者が最初に思い浮かべるのはLIKE演算子でしょう。しかし、LIKE '%keyword%'のような前方一致・後方一致を含む検索は、テーブルのインデックスを効率的に利用できず、データ量が増加するにつれて著しいパフォーマンスの低下を招きます(フルテーブルスキャン)。

これに対し、FULLTEXTインデックスは、テキストデータを「単語(トークン)」に分割し、転置インデックス(Inverted Index)と呼ばれる特殊なデータ構造を内部的に構築します。これにより、特定の単語を含むドキュメントを極めて高速に特定できるようになります。検索の際には、このインデックスを利用したMATCH() ... AGAINST()構文を使用します。

MySQLでFULLTEXTインデックスを利用できる主要なストレージエンジンはInnoDBMyISAMです。かつてはMyISAMが全文検索の主流でしたが、現在では以下の理由からInnoDBの使用が強く推奨されます。

  • トランザクション対応: InnoDBはACID準拠のトランザクションをサポートしており、データの整合性を保証します。これは現代的なアプリケーションにおいて必須の要件です。
  • 行レベルロック: 高い同時実行性が求められる環境において、MyISAMのテーブルレベルロックはボトルネックになりがちです。InnoDBの行レベルロックは、より多くの並列処理を可能にします。
  • クラッシュリカバリ: InnoDBは堅牢なクラッシュリカバリ機能を備えており、システムの障害耐性を高めます。
  • 継続的な機能強化: MySQLの開発はInnoDBを中心に進められており、全文検索機能においてもN-gramパーサーのサポートなど、InnoDBで先行して多くの機能が強化されています。

したがって、特別な理由がない限り、ストレージエンジンにはInnoDBを選択することが標準的なアプローチとなります。

1.2. 日本語検索の核心:N-gramパーサーの導入

MySQLのデフォルトの全文検索パーサーは、スペースや句読点を区切り文字として単語を分割します。これは英語や多くのヨーロッパ言語ではうまく機能しますが、日本語、中国語、韓国語のように単語間にスペースを入れない言語(分か-ち書きのない言語)では、文全体がひとつの長い単語として扱われてしまい、意図した通りの検索ができません。

この問題を解決するのがN-gramパーサーです。N-gramは、テキストを固定長の文字数(N文字)で連続的に切り出し、それをトークンとしてインデックス化する手法です。例えば、「東京都」という文字列をN=2のN-gram(バイグラム)で解析すると、「東京」と「京都」という2つのトークンが生成されます。これにより、文中に含まれる部分的なキーワードでも検索にヒットさせることが可能になります。

N-gramパーサーを利用してテーブルを作成するDDLは以下のようになります。WITH PARSER ngram句をFULLTEXTインデックス定義に追加することがポイントです。


CREATE TABLE articles (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FULLTEXT INDEX ft_index_title_content (title, content) WITH PARSER ngram
) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

上記の例では、titlecontentの2つのカラムを対象としたFULLTEXTインデックスをft_index_title_contentという名前で作成し、その際にWITH PARSER ngramを指定しています。これにより、このインデックスはN-gramを用いてトークン化されるようになります。

N-gramのトークンサイズ(Nの値)は、システム変数ngram_token_sizeで設定します(デフォルトは2)。一般的に日本語の検索では、2文字(バイグラム)または3文字(トライグラム)が使われることが多いです。検索対象のドメインや専門用語の特性に応じて適切な値を検討する必要がありますが、多くの場合、デフォルトの2で良好な結果が得られます。

1.3. ストップワードと最小トークンサイズ

全文検索のインデックスを作成する際には、検索ノイズとなりうる非常に頻繁に出現する単語("the", "a", "is"など。日本語では「の」「は」「を」など)を除外する「ストップワード」という仕組みがあります。MySQLにはデフォルトのストップワードリストが用意されていますが、information_schema.innodb_ft_default_stopwordテーブルで確認したり、独自のストップワードリストを定義したりすることも可能です。

また、インデックス化される単語の最小長を定義するパラメータも重要です。InnoDBの場合、これはinnodb_ft_min_token_sizeシステム変数で制御されます。N-gramパーサーを使用する場合、この値はngram_token_size以下の値に設定する必要があります。例えば、ngram_token_sizeが2の場合、innodb_ft_min_token_sizeは1または2に設定する必要があります。これらのパラメータはMySQLの設定ファイル(my.cnfまたはmy.ini)で変更し、MySQLサーバーを再起動後にインデックスを再構築することで適用されます。

第2章: Spring Bootプロジェクトの環境設定

データベースの準備が整ったら、次はいよいよSpring Bootアプリケーション側の設定です。ここでは、Mavenプロジェクトに必要な依存関係の追加と、データベース接続情報やJPAの動作を制御するための設定ファイルapplication.ymlの記述方法を詳しく解説します。

2.1. 必要な依存関係の定義 (pom.xml)

Spring BootプロジェクトでMySQLとJPA、そしてWeb APIを構築するために、以下の依存関係をpom.xmlファイルに追加します。Lombokを追加することで、定型的なコード(Getter, Setterなど)を削減し、コードの可読性を高めることができます。


<dependencies>
    <!-- Spring Web: RESTful APIを構築するために必要 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Data JPA: データベースとのやり取りを抽象化 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- MySQL Connector/J: MySQLデータベースへのJDBCドライバ -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Lombok: ボイラープレートコードを削減 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <!-- Spring Boot Test: テストコード作成用 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

2.2. アプリケーション設定 (application.yml)

Spring Bootでは、application.propertiesまたはapplication.ymlファイルでアプリケーションの動作を設定します。階層構造を表現しやすく可読性が高いYAML形式での設定が推奨されます。以下に、データベース接続情報とJPA関連の重要な設定を示します。


spring:
  # ---------------------------------
  # データソース設定
  # ---------------------------------
  datasource:
    # JDBC URL: データベースの場所、名前、および接続オプションを指定
    # useSSL=false: SSL接続を使用しない(ローカル開発用)
    # serverTimezone=UTC: タイムゾーンをUTCに指定(推奨)
    # allowPublicKeyRetrieval=true: MySQL 8以降で必要になる場合がある
    url: jdbc:mysql://localhost:3306/your_database?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
    username: your_username # データベースのユーザー名
    password: your_password # データベースのパスワード
    driver-class-name: com.mysql.cj.jdbc.Driver # MySQL 8用のドライバクラス

  # ---------------------------------
  # JPA (Java Persistence API) 設定
  # ---------------------------------
  jpa:
    # Hibernateがデータベースに対して実行するDDL(Data Definition Language)操作のポリシー
    # none: 何もしない(本番環境推奨)
    # validate: Entityとテーブルのスキーマが一致するか検証
    # update: Entityに合わせてスキーマを更新(開発中便利だが、カラム削除は行われない)
    # create: 起動時に常にスキーマを削除して再作成
    # create-drop: 起動時に作成し、終了時に削除
    hibernate:
      ddl-auto: update
    
    # 実行されるSQLをコンソールに出力する
    show-sql: true

    # 出力されるSQLを整形し、可読性を向上させる
    properties:
      hibernate:
        format_sql: true
        
    # ネーミング戦略: Spring Boot 2.x以降のデフォルトはPhysicalNamingStrategyStandardImpl
    # キャメルケース(camelCase)をスネークケース(snake_case)にマッピングする
    database-platform: org.hibernate.dialect.MySQL8Dialect # 使用するMySQLのバージョンに合わせた方言を指定
    open-in-view: false # パフォーマンスとトランザクション管理の観点からfalseを推奨

特にspring.jpa.hibernate.ddl-autoプロパティは、開発サイクルと本番運用で設定を使い分けることが重要です。開発中はupdateに設定するとEntityの変更が自動でDBに反映されて便利ですが、本番環境では意図しないスキーマ変更を防ぐため、validateまたはnoneに設定し、スキーマ変更はFlywayやLiquibaseといったマイグレーションツールで管理するのがベストプラクティスです。

spring.jpa.open-in-viewfalseに設定することも重要です。true(デフォルト)の場合、リクエストが完了するまで永続化コンテキストが開いたままになり、意図しないN+1問題やパフォーマンスの低下、コネクションプールの枯渇を引き起こす可能性があります。Service層でトランザクションを完結させる設計を徹底するためにも、falseに設定することが推奨されます。

第3章: データ永続化層の実装 (EntityとRepository)

アプリケーション設定が完了したら、次はデータベースのテーブルとJavaのオブジェクトをマッピングするEntityクラスと、データベースアクセスを担うRepositoryインターフェースを作成します。

3.1. Articleエンティティの作成

第1章で定義したarticlesテーブルに対応するJPAエンティティArticle.javaを作成します。ここではLombokのアノテーションを活用してコードを簡潔に保ちます。


package com.example.fulltextsearch.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.LocalDateTime;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPAのための保護されたコンストラクタ
@Entity
@Table(name = "articles") // 対応するテーブル名を指定
public class Article {

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

    @Column(nullable = false)
    private String title;

    @Lob // TEXT型などの大きなオブジェクトに対応
    @Column(nullable = false)
    private String content;

    @CreationTimestamp // データ作成時に自動でタイムスタンプを記録
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @UpdateTimestamp // データ更新時に自動でタイムスタンプを記録
    private LocalDateTime updatedAt;

    @Builder
    public Article(String title, String content) {
        this.title = title;
        this.content = content;
    }
}
  • @Entity: このクラスがJPAの管理対象エンティティであることを示します。
  • @Table(name = "articles"): マッピング先のテーブル名を明示的に指定します。
  • @Id, @GeneratedValue: 主キーとその自動生成戦略を指定します。GenerationType.IDENTITYはMySQLのAUTO_INCREMENTに対応します。
  • @Column: カラムの詳細(NOT NULL制約など)を指定します。
  • @Lob: `TEXT`や`CLOB`、`BLOB`といった大きなデータ型にマッピングします。
  • @CreationTimestamp, @UpdateTimestamp: Hibernateの機能で、エンティティの作成・更新日時を自動で管理してくれます。
  • @Getter, @NoArgsConstructor, @Builder: Lombokのアノテーションです。それぞれゲッター、引数なしコンストラクタ、ビルダーパターンのコードを自動生成します。@NoArgsConstructor(access = AccessLevel.PROTECTED)は、JPAが内部的にインスタンスを生成するために必要ですが、アプリケーションコードから直接呼び出すことを防ぐための良い習慣です。

3.2. ArticleRepositoryの作成とカスタムクエリ

次に、Spring Data JPAの機能を利用して、Articleエンティティに対するデータベース操作(CRUDなど)を行うためのリポジトリインターフェースを作成します。

全文検索で用いるMATCH() ... AGAINST()構文はJPQL(Java Persistence Query Language)では標準サポートされていないため、データベース固有のSQLを直接記述するネイティブクエリを使用する必要があります。@Queryアノテーションを用いることで、インターフェースのメソッドに直接ネイティブクエリを紐付けることができます。


package com.example.fulltextsearch.repository;

import com.example.fulltextsearch.entity.Article;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {

    /**
     * 全文検索を実行するネイティブクエリ。
     * MATCH(title, content) は、ft_index_title_contentインデックスの対象カラムと一致させる必要があります。
     * AGAINST (:keyword IN BOOLEAN MODE) で、ブーリアンモードによる検索を行います。
     * :keyword はメソッドの引数で渡された値に置換されます。
     *
     * @param keyword 検索キーワード
     * @return 検索結果のArticleリスト
     */
    @Query(value = "SELECT * FROM articles WHERE MATCH (title, content) AGAINST (:keyword IN BOOLEAN MODE)",
           nativeQuery = true)
    List<Article> fullTextSearch(@Param("keyword") String keyword);

}
  • @Repository: このインターフェースがデータアクセス層のコンポーネントであることを示すステレオタイプアノテーションです。
  • JpaRepository<Article, Long>: これを継承するだけで、save(), findById(), findAll(), delete()といった基本的なCRUD操作メソッドが自動的に利用可能になります。
  • @Query(value = "...", nativeQuery = true): value属性に実行したいSQLを記述し、nativeQuery = trueとすることで、このクエリがネイティブSQLであることをSpring Data JPAに伝えます。
  • MATCH (title, content): 検索対象のカラムを指定します。これはCREATE TABLE時にFULLTEXTインデックスを作成したカラムリストと一致している必要があります。
  • AGAINST (:keyword IN BOOLEAN MODE): 検索キーワードと検索モードを指定します。:keywordはメソッドの引数に@Param("keyword")と付けたものにマッピングされます。検索モードについては次章で詳しく解説します。

第4章: 多様な検索モードを使いこなす

MySQLの全文検索は、単純なキーワードマッチングだけでなく、複数の検索モードを提供することで、より柔軟で強力な検索を実現します。ここでは主要な3つのモード、IN NATURAL LANGUAGE MODEIN BOOLEAN MODEWITH QUERY EXPANSIONについて、その特徴と使い方を解説します。

4.1. IN NATURAL LANGUAGE MODE (自然言語モード)

これはAGAINST()のモードを省略した場合のデフォルトの動作です。ユーザーが入力した検索文字列を、特別な演算子なしで「自然な文章」として解釈します。

  • 特徴:
    • 検索文字列内の単語が、対象カラムに多く含まれるほど、関連性が高いと判断されます。
    • ストップワードは自動的に無視されます。
    • すべての単語が含まれている必要はなく、いずれかの単語が含まれていればヒットします。
    • 関連度スコアに基づいて結果が自動的にソートされます。
  • 使用例: 一般的なブログ記事やニュースサイトの検索など、ユーザーが自然な言葉で検索する場面に適しています。

-- "Spring Boot" というキーワードで検索
SELECT * FROM articles WHERE MATCH (title, content) AGAINST ('Spring Boot' IN NATURAL LANGUAGE MODE);

4.2. IN BOOLEAN MODE (ブーリアンモード)

ブーリアンモードは、開発者が検索ロジックを細かく制御したい場合に非常に強力なモードです。特殊な演算子をキーワードに付与することで、AND/OR/NOT検索やフレーズ検索などを実現できます。

  • 特徴:
    • +: その単語が必須であることを示す (AND)。例: +Spring +JPA (SpringとJPAの両方を含む)
    • -: その単語を含まないことを示す (NOT)。例: +Spring -Java (Springを含むがJavaは含まない)
    • (演算子なし): その単語が含まれていれば尚良い (OR)。例: Spring JPA (SpringかJPAのいずれかを含む)
    • *: ワイルドカード。単語の末尾に付けることで前方一致検索を行う。例: data* (data, database, datasource などにヒット)
    • "...": フレーズ検索。ダブルクォーテーションで囲んだ文字列が、その通りの順番で出現するドキュメントを検索。例: "Spring Data JPA"
    • > <: 含まれる単語の関連度を上げたり下げたりする。例: +Spring >JPA (Springは必須で、JPAが含まれていればさらにスコアを高くする)
    • ( ): 演算の優先順位をグループ化する。例: +Spring +(JPA OR Hibernate)
  • 使用例: 詳細な絞り込み検索機能を持つECサイトや、特定の技術文書を検索するようなシステムで非常に有効です。Service層でユーザーの入力を解析し、動的にブーリアンクエリを組み立てることで、高度な検索UIを実装できます。

例えば、ユーザーが複数のキーワードをスペース区切りで入力した場合、それらをすべて含む(AND)検索を実装するには、Service層でキーワードの先頭に `+` を付与する処理を追加します。


// Service層での処理イメージ
public List<Article> searchArticles(String keywords) {
    // "spring jpa" -> "+spring +jpa"
    String booleanQuery = Arrays.stream(keywords.split("\\s+"))
                                .map(word -> "+" + word)
                                .collect(Collectors.joining(" "));
    return articleRepository.fullTextSearch(booleanQuery);
}

4.3. WITH QUERY EXPANSION (クエリ拡張モード)

このモードは、検索の「おせっかい」機能とも言えるもので、検索の網羅性を高めたい場合に利用します。

  • 特徴:
    • まず、指定されたキーワードで自然言語モードの検索を一度実行します。
    • 次に、その検索でヒットしたドキュメントの中から、関連性の高いドキュメントが含んでいる他の単語を自動的に抽出し、元の検索キーワードに追加して、再度検索を実行します。
    • 例えば、「データベース」で検索した場合、最初の検索でヒットした記事に「MySQL」や「PostgreSQL」という単語が多く含まれていれば、それらの単語も検索対象に加えて再検索してくれます。
  • 使用例: ユーザーが具体的なキーワードを知らない場合でも、関連する情報を見つけやすくするのに役立ちます。ただし、意図しないノイズが増える可能性もあるため、利用シーンを慎重に選ぶ必要があります。

-- 'データベース'で検索し、関連語も検索対象に含める
SELECT * FROM articles WHERE MATCH (title, content) AGAINST ('データベース' WITH QUERY EXPANSION);

これらのモードを使い分けることで、アプリケーションの要件に応じた柔軟な検索機能を提供できます。リポジトリにそれぞれのモードに対応したメソッドを用意しておくことも良いアプローチです。

第5章: ビジネスロジックとAPIの設計

データアクセス層の準備が整ったので、次はビジネスロジックを担うService層と、外部に機能を公開するController層を実装します。ここでは、関心の分離(Separation of Concerns)と、APIの設計において重要なDTO(Data Transfer Object)の導入について解説します。

5.1. なぜService層が必要なのか?

小規模なアプリケーションではControllerから直接Repositoryを呼び出すことも可能ですが、アプリケーションが成長するにつれて、このアプローチは多くの問題を引き起こします。Service層を導入する主な理由は以下の通りです。

  • 責務の分離: ControllerはHTTPリクエストの受付とレスポンスの返却に専念し、Serviceはビジネスロジック(トランザクション管理、複数リポジトリの操作、外部API連携など)に専念します。これにより、各層のコードがシンプルになり、保守性やテスト容易性が向上します。
  • トランザクション管理: 複数のデータベース操作をひとつの作業単位として扱いたい場合(例:記事を保存し、タグも更新する)、Service層のメソッドに@Transactionalアノテーションを付与することで、トランザクションの境界を宣言的に管理できます。
  • 再利用性: あるビジネスロジックが、Web APIだけでなくバッチ処理からも必要になった場合、Service層にロジックが実装されていれば、それを再利用することが容易になります。

5.2. DTO (Data Transfer Object) の導入

ControllerがAPIのレスポンスとしてJPAのエンティティを直接返却することは、いくつかの理由で避けるべきです。

  • 不要な情報の漏洩: エンティティにはパスワードや内部管理用のフラグなど、APIで公開したくない情報が含まれている場合があります。
  • API仕様とDBスキーマの結合: データベースのスキーマ変更が、直接APIのレスポンス形式の変更に繋がってしまい、APIのクライアントに影響を与えてしまいます。
  • 循環参照の問題: 複雑なエンティティリレーション(例:UserとArticleの双方向参照)がある場合、JSONシリアライズ時に無限ループに陥る可能性があります。

これらの問題を解決するために、APIで送受信するデータ専用のクラスであるDTOを導入します。エンティティからDTOへの変換はService層で行います。

まず、検索結果としてクライアントに返したい情報をまとめたDTOクラスを作成します。


package com.example.fulltextsearch.dto;

import com.example.fulltextsearch.entity.Article;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
public class ArticleResponseDto {
    private final Long id;
    private final String title;
    private final String contentSnippet; // 全文ではなくスニペットを返す
    private final LocalDateTime createdAt;

    public ArticleResponseDto(Article article) {
        this.id = article.getId();
        this.title = article.getTitle();
        // コンテンツが長い場合は一部を切り出して返す
        this.contentSnippet = article.getContent().length() > 100 ?
                article.getContent().substring(0, 100) + "..." :
                article.getContent();
        this.createdAt = article.getCreatedAt();
    }
}

5.3. Service層とController層の実装

次に、作成したDTOを使ってService層とController層を実装します。

ArticleServiceの実装

Serviceクラスは、Repositoryをインジェクションし、検索処理とEntityからDTOへの変換ロジックをカプセル化します。


package com.example.fulltextsearch.service;

import com.example.fulltextsearch.dto.ArticleResponseDto;
import com.example.fulltextsearch.repository.ArticleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor // finalフィールドに対するコンストラクタを自動生成
@Transactional(readOnly = true) // クラス全体に読み取り専用トランザクションを適用
public class ArticleService {

    private final ArticleRepository articleRepository;

    public List<ArticleResponseDto> searchArticles(String keyword) {
        if (keyword == null || keyword.trim().isEmpty()) {
            return List.of(); // 空のリストを返す
        }

        // 複数の単語がスペースで区切られている場合、それぞれに'+'を付けてAND検索にする
        String booleanQuery = Arrays.stream(keyword.trim().split("\\s+"))
                                    .filter(s -> !s.isEmpty())
                                    .map(word -> "+" + word)
                                    .collect(Collectors.joining(" "));

        return articleRepository.fullTextSearch(booleanQuery).stream()
                .map(ArticleResponseDto::new) // Article -> ArticleResponseDto
                .collect(Collectors.toList());
    }
}
  • @RequiredArgsConstructor: Lombokのアノテーションで、final修飾子が付いたフィールドを引数に取るコンストラクタを自動生成します。これにより、コンストラクタインジェクションを簡潔に記述できます。
  • @Transactional(readOnly = true): 読み取り専用の操作であることを示します。これにより、JPAがダーティチェックなどの不要な処理をスキップするため、パフォーマンスが向上します。

ArticleControllerの実装

ControllerはServiceをインジェクションし、HTTPリクエストを処理してServiceのメソッドを呼び出し、結果を返却します。


package com.example.fulltextsearch.controller;

import com.example.fulltextsearch.dto.ArticleResponseDto;
import com.example.fulltextsearch.service.ArticleService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/articles")
@RequiredArgsConstructor
public class ArticleController {

    private final ArticleService articleService;

    @GetMapping("/search")
    public ResponseEntity<List<ArticleResponseDto>> search(@RequestParam String keyword) {
        List<ArticleResponseDto> results = articleService.searchArticles(keyword);
        return ResponseEntity.ok(results);
    }
}

これで、http://localhost:8080/api/articles/search?keyword=検索したい言葉 のようなURLにGETリクエストを送ることで、全文検索APIを呼び出すことができるようになりました。レスポンスはJSON形式のArticleResponseDtoのリストになります。

第6章: 検索品質を向上させる高度なテクニック

基本的な検索機能が実装できたら、次はユーザーエクスペリエンスをさらに向上させるための高度なテクニックを導入します。ここでは、検索結果の順序付けに不可欠な「関連度スコア」の取得と、大量の検索結果を効率的に表示するための「ページネーション」の実装方法について解説します。

6.1. 関連度スコアの取得と活用

全文検索の大きな利点の一つは、各ドキュメントが検索キーワードに対してどれだけ関連性が高いかを数値化した「関連度スコア」を計算してくれる点です。このスコアを利用することで、検索結果を単純なID順や日付順ではなく、「ユーザーが最も求めているであろう順」に並べ替えることができます。

関連度スコアは、ネイティブクエリ内でMATCH() ... AGAINST()構文自体をSELECT句に含めることで取得できます。

スコア付きDTOの作成

まず、スコアを保持するための新しいDTOを作成するか、既存のDTOを拡張します。


package com.example.fulltextsearch.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
@AllArgsConstructor // 全フィールドを引数に取るコンストラクタ
public class ArticleSearchResponseDto {
    private Long id;
    private String title;
    private String contentSnippet;
    private LocalDateTime createdAt;
    private Double score; // 関連度スコア
}

Repositoryとカスタムマッピング

Repositoryのクエリを修正し、スコアを取得してDTOにマッピングする必要があります。しかし、Spring Data JPAのネイティブクエリで直接DTOにマッピングするには、インターフェースベースのプロジェクションや@SqlResultSetMappingを使うなど、少し複雑な設定が必要になります。ここではより直感的なアプローチとして、List<Object[]>で結果を受け取り、Service層でDTOに変換する方法を紹介します。

ArticleRepositoryの修正:


// ArticleRepository.java

// ...

@Query(value = "SELECT id, title, content, created_at, " +
               "MATCH (title, content) AGAINST (:keyword IN BOOLEAN MODE) AS score " +
               "FROM articles " +
               "WHERE MATCH (title, content) AGAINST (:keyword IN BOOLEAN MODE) " +
               "ORDER BY score DESC", // スコアの高い順にソート
       nativeQuery = true)
List<Object[]> fullTextSearchWithScore(@Param("keyword") String keyword);

このクエリでは、MATCH ... AGAINSTをエイリアスscoreとしてSELECT句に追加し、ORDER BY score DESCで結果をスコアの降順に並べ替えています。

ArticleServiceの修正:


// ArticleService.java

// ...

public List<ArticleSearchResponseDto> searchArticlesWithScore(String keyword) {
    if (keyword == null || keyword.trim().isEmpty()) {
        return List.of();
    }
    String booleanQuery = ...; // 前述のクエリ作成処理

    List<Object[]> results = articleRepository.fullTextSearchWithScore(booleanQuery);
    
    return results.stream()
            .map(this::mapToDto)
            .collect(Collectors.toList());
}

private ArticleSearchResponseDto mapToDto(Object[] row) {
    // クエリのSELECT順に合わせてマッピング
    Long id = ((Number) row[0]).longValue();
    String title = (String) row[1];
    String content = (String) row[2];
    LocalDateTime createdAt = ((java.sql.Timestamp) row[3]).toLocalDateTime();
    Double score = ((Number) row[4]).doubleValue();

    String snippet = content.length() > 100 ? content.substring(0, 100) + "..." : content;

    return new ArticleSearchResponseDto(id, title, snippet, createdAt, score);
}

この実装により、APIは関連性の高い順にソートされた検索結果を返すようになり、検索の品質が大幅に向上します。

6.2. ページネーションの実装

検索結果が数十、数百件になる可能性がある場合、すべての結果を一度に返すのは非効率的です。ページネーションを実装することで、クライアントは必要な分だけデータを要求できるようになります。

Spring Data JPAは、PageableインターフェースとPageクラスを通じて、ページネーションを強力にサポートしています。

Repositoryの変更

Repositoryのメソッドの引数にPageableを追加し、戻り値をPage<Article>(またはプロジェクション)に変更します。Spring Data JPAはPageableオブジェクトからLIMIT句とOFFSET句を自動的にクエリに追加してくれます。


// ArticleRepository.java
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

// ...

@Query(value = "SELECT * FROM articles WHERE MATCH (title, content) AGAINST (:keyword IN BOOLEAN MODE)",
       countQuery = "SELECT count(*) FROM articles WHERE MATCH (title, content) AGAINST (:keyword IN BOOLEAN MODE)",
       nativeQuery = true)
Page<Article> fullTextSearchWithPagination(@Param("keyword") String keyword, Pageable pageable);

ネイティブクエリでページネーションを行う場合、結果リストを取得するvalueクエリと、総件数を取得するためのcountQueryを両方指定する必要があります。

ServiceとControllerの変更

ServiceとControllerもPageablePageを扱うように変更します。

ArticleServiceの変更:


// ArticleService.java
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

// ...

public Page<ArticleResponseDto> searchArticlesWithPagination(String keyword, Pageable pageable) {
    if (keyword == null || keyword.trim().isEmpty()) {
        return Page.empty(pageable);
    }
    String booleanQuery = ...;

    Page<Article> articlePage = articleRepository.fullTextSearchWithPagination(booleanQuery, pageable);
    
    // Pageオブジェクトのmap機能を使って、内容をDTOに変換
    return articlePage.map(ArticleResponseDto::new);
}

ArticleControllerの変更:


// ArticleController.java
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;

// ...

@GetMapping("/search/paginated")
public ResponseEntity<Page<ArticleResponseDto>> searchWithPagination(
        @RequestParam String keyword,
        @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {

    Page<ArticleResponseDto> results = articleService.searchArticlesWithPagination(keyword, pageable);
    return ResponseEntity.ok(results);
}

@PageableDefaultアノテーションを使うと、クライアントからページネーションの指定がない場合のデフォルト値(ページサイズ、ソート順など)を設定できます。クライアントは /api/articles/search/paginated?keyword=...&page=0&size=20&sort=title,asc のようにパラメータを指定して、表示を制御できます。

ただし、全文検索の関連度スコアによるソートとSpring Data JPAのPageableによるソートを組み合わせるには注意が必要です。Pageableのソート機能は、クエリで指定されたカラム名に依存します。スコアでソートしたい場合は、前述のスコア取得クエリとページネーションを組み合わせるカスタム実装が必要になる場合があります。

第7章: パフォーマンスとスケーラビリティの考察

Spring BootとMySQLによる全文検索システムは、多くのユースケースにおいて非常に効果的ですが、その限界と代替案を理解しておくことも重要です。

MySQL全文検索の長所と短所

長所

  • 導入の容易さ: 追加のミドルウェアやインフラを必要とせず、使い慣れたMySQLとSpring Bootの環境だけで完結します。学習コストや運用コストが低いのが最大の魅力です。
  • データの一貫性: アプリケーションのプライマリデータストアと検索インデックスが同一のトランザクション内で管理されるため、データの同期ズレといった問題が発生しません。
  • 十分な機能: ブーリアン検索、N-gramによる日本語対応、関連度スコアリングなど、基本的な全文検索要件を満たす機能を備えています。

短所

  • パフォーマンスの限界: データ量が数千万件、数億件といった規模になると、インデックスの更新や検索クエリのパフォーマンスが低下し始める可能性があります。
  • 高度な機能の不足: より高度なテキスト分析(形態素解析、同義語展開、ゆらぎ吸収)、ファセット検索、リアルタイムに近いインデックス更新、柔軟なランキング制御、高度な集計機能などは提供されていません。
  • 負荷分散: 検索負荷が高まった場合、データベースサーバー自体の負荷が増大します。検索処理と通常のCRUD処理がリソースを奪い合い、システム全体のパフォーマンスに影響を与える可能性があります。

専用検索エンジン (Elasticsearch/OpenSearch) を検討するタイミング

以下の要件が一つでも当てはまる場合、ElasticsearchやOpenSearch(旧Amazon Elasticsearch Service)のような専用の検索エンジンへの移行を検討する価値があります。

  • 大規模データ: テラバイト級のデータを扱う、またはドキュメント数が億単位に達する。
  • 高度な日本語処理: N-gramでは不十分で、文脈を理解する形態素解析(例:Kuromojiアナライザ)による高精度な検索が必要な場合。
  • 複雑な検索要件: ファセット(検索結果の動的な絞り込み)、サジェスト(入力補完)、地理空間検索などが必要な場合。
  • 高いスケーラビリティと可用性: 検索トラフィックが非常に多く、専用のクラスタを組んで負荷分散や冗長化を行いたい場合。
  • リアルタイム性: データが書き込まれてから1秒以内に検索可能になる必要があるような、高いリアルタイム性が求められる場合。

専用検索エンジンは非常に強力ですが、インフラの構築・運用コスト、そしてプライマリDBとのデータ同期という新たな課題も生じます。したがって、プロジェクトの初期段階や中規模のアプリケーションにおいては、まずMySQLの全文検索機能を最大限に活用し、将来的なスケールアウトの選択肢として専用エンジンを視野に入れておくのが賢明なアプローチと言えるでしょう。

結論

本稿では、Spring BootとMySQLを用いて、実用的な全文検索機能を構築するプロセスを、データベース設計からAPI実装、そして高度なテクニックに至るまで詳細に解説しました。MySQLのFULLTEXTインデックス、特に日本語環境で不可欠なN-gramパーサーを正しく設定し、Spring Data JPAのネイティブクエリを介して利用することで、外部の検索エンジンを導入することなく、迅速かつ効率的に検索機能を実装できることを示しました。

また、Service層やDTOの導入による責務の分離、関連度スコアやページネーションによるユーザーエクスペリエンスの向上など、単に機能するだけでなく、保守性や拡張性に優れた堅牢なアプリケーションを設計するためのベストプラクティスも紹介しました。

MySQLの全文検索は、その導入の手軽さとデータ一貫性の利点から、多くのアプリケーションにとって最適な「最初の選択肢」となり得ます。ここで紹介した知識と技術を土台として、あなたのアプリケーションに求められる検索能力を適切に実装し、ユーザーに価値ある体験を提供してください。