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

Thursday, September 7, 2023

Spring bootとMySQLを使用した全文検索機能の実装

SpringbootとMySQLを使用した全文検索機能の実装

この文書では、SpringbootとMySQLを使用して基本的な全文検索機能を実装する方法を紹介します。

1. MySQL Full-Text 対応テーブルの作成

MySQLで全文検索を使用するには、該当するテーブルにFULLTEXTインデックスを作成する必要があります。以下の例のように必要なカラムにFULLTEXTインデックスを作成します。

CREATE TABLE articles (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200),
    content TEXT,
    FULLTEXT (title, content)
) ENGINE=InnoDB;

2. Spring BootプロジェクトでMySQLに接続

Spring BootからMySQLに接続するには、pom.xmlにMySQL Connector/Jの依存関係を追加する必要があります:

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

次に、application.propertiesファイルに次のようにMySQLの接続情報を設定します:

spring.datasource.url=jdbc:mysql://localhost:3306/your_database?useSSL=false&serverTimezone=UTC
spring.datasource.username=your_username
spring.datasource.password=your_password
spring.jpa.hibernate.ddl-auto=update

3. Articleエンティティとリポジトリの作成

ArticleエンティティとArticleRepositoryを作成して、データベースとの接続を確立します:

import javax.persistence.*;

@Entity
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String content;

    // 省略:ゲッター、セッター、コンストラクタ
}
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

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

4. 全文検索のためのカスタムクエリの作成

ArticleRepositoryにカスタムクエリを作成して、MySQLの全文検索機能を活用できるようにします:

import org.springframework.data.jpa.repository.Query;

@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {
    @Query(value = "SELECT * FROM articles WHERE MATCH (title, content) AGAINST (?1)", nativeQuery = true)
    List<Article> fullTextSearch(String keyword);
}

これで、fullTextSearchメソッドを使用してタイトルとコンテンツにキーワードを含むすべての記事を検索できます。

5. 検索機能を使用するREST APIの実装

検索機能を使用するREST APIを実装するには、Spring Webの依存関係も追加する必要があります:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>

次に、検索コントローラを作成します:

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/articles")
public class ArticleController {
    private final ArticleRepository articleRepository;

    // 依存性注入
    public ArticleController(ArticleRepository articleRepository) {
        this.articleRepository = articleRepository;
    }

    @GetMapping("/search")
    public List<Article> search(@RequestParam String keyword) {
        return articleRepository.fullTextSearch(keyword);
    }
}

これにより、/api/articles/search?keyword=検索語エンドポイントを介して検索機能を使用できます。

必要に応じて、全文検索機能をカスタマイズまたは拡張できます。この文書ではSpringとMySQLを使用して基本的な全文検索機能を実装する方法を紹介しました。必要な検索機能を適用するために、このガイドを参照してください。

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

Monday, February 8, 2021

AWS EC2 서버가 자꾸 죽나요? 프리티어 메모리 부족(OOM) 문제, 스왑(Swap)으로 완벽 해결하기

야심차게 준비한 사이드 프로젝트, 혹은 이제 막 시작하는 스타트업의 서비스를 AWS의 프리티어(Free Tier) EC2 인스턴스에 배포하셨나요? 저렴한 비용, 혹은 무료로 나만의 서버를 운영할 수 있다는 설렘도 잠시, 매일 혹은 불특정한 주기로 서버가 응답하지 않는 현상을 겪고 계실지도 모르겠습니다. SSH 접속은 끊기고, 웹사이트는 '연결할 수 없음' 페이지만 띄우며, 재부팅을 해야만 잠시 정상으로 돌아오는 악순환. 로그를 뒤져봐도 뚜렷한 원인을 찾기 힘든 이 미스터리한 서버 다운의 주범은 바로 '메모리 부족(Out of Memory)'일 가능성이 매우 높습니다.

특히 Spring Boot와 같은 JVM(Java Virtual Machine) 기반의 프레임워크와 MySQL, PostgreSQL 같은 데이터베이스를 1GB 램(RAM)을 가진 t2.microt3.micro 같은 인스턴스에서 함께 운영한다면, 이는 예견된 문제나 다름없습니다. 이 글에서는 AWS EC2 환경에서 발생하는 메모리 부족 문제의 근본적인 원인을 심층적으로 분석하고, '스왑 메모리(Swap Memory)'라는 가장 현실적이고 효과적인 해결책을 A부터 Z까지 상세하게 알려드립니다. 더 이상 원인 모를 서버 다운 때문에 스트레스받지 마세요. 이 가이드를 통해 서버 안정성을 확보하고, 개발에만 집중할 수 있는 환경을 구축해 보세요.

문제의 근원: 왜 내 서버는 자꾸만 멈추는가?

문제를 해결하기 위해선 먼저 원인을 정확히 알아야 합니다. '서버가 죽는다'는 현상은 여러 가지 이유로 발생할 수 있지만, 저사양 EC2 인스턴스에서는 그 원인이 메모리와 직결된 경우가 대부분입니다.

프리티어의 함정: 1GB 메모리의 한계

AWS 프리티어는 개발자들에게 클라우드 환경을 맛볼 수 있는 훌륭한 기회를 제공합니다. 대표적으로 t2.micro 인스턴스는 12개월 동안 매월 750시간을 무료로 제공하죠. 하지만 여기에는 명확한 한계가 존재합니다. 바로 물리 메모리(RAM)가 1GB에 불과하다는 점입니다.

1GB는 현대적인 웹 애플리케이션 스택을 구동하기에 결코 넉넉한 용량이 아닙니다. 운영체제(OS) 자체가 기본적인 작동을 위해 일정량의 메모리를 점유하고, 여기에 우리가 배포한 애플리케이션(예: Spring Boot), 데이터베이스(예: MySQL), 그리고 웹 서버(예: Nginx) 등이 추가로 메모리를 할당받아 사용합니다. 사용자가 접속하여 요청을 처리하기 시작하면 메모리 사용량은 더욱 가파르게 증가합니다.

리눅스의 암살자, OOM Killer의 등장

리눅스 커널은 시스템 전체의 안정성을 유지하기 위해 매우 중요한 메커니즘을 가지고 있습니다. 바로 OOM(Out of Memory) Killer입니다. 시스템의 가용 메모리가 거의 소진되어 더 이상 정상적인 운영이 불가능하다고 판단될 때, OOM Killer가 강제로 등판합니다.

OOM Killer는 무작위로 프로세스를 종료시키는 것이 아니라, 정해진 규칙(OOM Score)에 따라 시스템에 가장 큰 부담을 주고 있다고 판단되는, 즉 메모리를 가장 많이 차지하고 있는 프로세스를 찾아내 강제로 종료(kill)시킵니다. 이는 시스템 전체가 다운되는 최악의 상황을 막기 위한 운영체제의 자기방어 수단입니다. 안타깝게도, Spring Boot 애플리케이션(JVM)이나 MySQL 서버는 보통 시스템에서 가장 많은 메모리를 사용하는 주범들이기 때문에 OOM Killer의 첫 번째 타겟이 될 확률이 매우 높습니다. 바로 이것이 우리가 겪는 '원인 모를 서버 다운'의 실체입니다.

메모리를 삼키는 하마: Spring Boot와 MySQL

왜 이 두 조합이 유독 메모리 문제에 취약할까요?

  • Spring Boot (JVM): 자바 애플리케이션은 JVM 위에서 동작합니다. JVM은 시작 시 운영체제로부터 큰 메모리 덩어리(Heap, Metaspace 등)를 할당받습니다. 특히 애플리케이션의 규모가 커지고, 처리하는 데이터나 로직이 복잡해질수록 필요한 힙 메모리(Heap Memory)의 크기는 기하급수적으로 늘어납니다. 1GB 램 환경에서 JVM에 할당된 메모리만으로도 이미 시스템의 절반 가까이를 차지할 수 있습니다.
  • MySQL: 데이터베이스는 빠른 데이터 접근을 위해 '버퍼 풀(Buffer Pool)'이나 '캐시(Cache)'와 같은 다양한 메모리 영역을 사용합니다. 자주 사용되는 데이터나 인덱스를 디스크가 아닌 메모리에 올려두고 사용함으로써 성능을 극대화하는 것이죠. 이 역시 상당한 양의 메모리를 요구하며, 쿼리가 복잡해지거나 동시 접속자가 늘어날수록 사용량은 증가합니다.

결국, 1GB라는 제한된 공간에 운영체제, JVM, MySQL이 함께 살아가다 보니, 순간적인 트래픽 증가나 특정 작업(배치, 복잡한 쿼리 등)으로 인해 메모리 사용량이 임계점을 넘어서게 되고, OOM Killer가 출동하여 우리에게 소중한 애플리케이션이나 데이터베이스 프로세스를 강제 종료시키는 시나리오가 반복되는 것입니다.

원인 분석: 내 서버의 메모리 상태 정밀 진단하기

이제 이론적인 배경을 알았으니, 실제 내 서버에서 어떤 일이 벌어지고 있는지 직접 확인해 볼 차례입니다. 진단은 AWS 콘솔과 SSH 터미널 두 가지 방법으로 진행합니다.

1단계: AWS 콘솔에서 이상 징후 포착하기

가장 먼저, 그리고 가장 쉽게 접근할 수 있는 곳이 바로 AWS Management Console입니다.

  1. AWS 콘솔에 로그인하여 EC2 대시보드로 이동합니다.
  2. 문제가 발생한 인스턴스를 선택합니다.
  3. 하단 탭에서 [모니터링(Monitoring)] 탭을 클릭하여 CPU 사용률, 네트워크 입출력 등의 기본 지표를 확인합니다. 메모리 부족 현상이 발생하기 직전, CPU 사용률이 급증하는 패턴을 보이는 경우도 많습니다.
  4. 더 결정적인 증거를 찾기 위해, 인스턴스를 우클릭하거나 상단의 [작업(Actions)] 메뉴를 클릭한 뒤, [모니터링 및 문제 해결(Monitor and troubleshoot)] > [시스템 로그 가져오기(Get system log)]를 선택합니다.
  5. 방대한 양의 로그가 나타날 것입니다. 여기서 'Out of Memory' 또는 'oom-killer'와 같은 키워드로 검색해 보세요. 아래와 비슷한 로그를 발견했다면, 메모리 부족이 문제의 원인임을 100% 확신할 수 있습니다.
    
    [... EIP is at do_exit+0x80/0x90]
    Out of memory: Kill process 12345 (java) score 850 or sacrifice child
    Killed process 12345 (java) total-vm:2345678kB, anon-rss:876543kB, file-rss:0kB, shmem-rss:0kB
    oom_reaper: reaped process 12345 (java), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB
        
  6. 또 다른 유용한 기능은 [인스턴스 스크린샷 가져오기(Get instance screenshot)] 입니다. 서버가 완전히 멈춘 상태(Kernel Panic 등)일 때, 스크린샷을 통해 마지막 화면 상태를 확인할 수 있습니다.
    AWS Console EC2 인스턴스 모니터링 및 문제 해결 화면
    AWS 콘솔의 '모니터링 및 문제 해결' 메뉴를 통해 시스템 로그와 스크린샷을 확인할 수 있습니다.

2단계: SSH 접속 후, 터미널에서 직접 확인하기

서버가 아직 살아있을 때, 혹은 재부팅 직후에 SSH로 접속하여 메모리 상태를 직접 확인하는 것은 매우 중요합니다. 가장 대표적인 명령어는 free 입니다.


# -h 옵션은 사람이 읽기 쉬운 단위(GB, MB, KB)로 보여줍니다.
$ free -h

이 명령어를 실행하면 다음과 비슷한 결과를 보게 될 것입니다. (스왑 설정 전)


              total        used        free      shared  buff/cache   available
Mem:          981Mi       850Mi        50Mi       0.0Ki        81Mi        45Mi
Swap:           0Bi         0Bi         0Bi

여기서 주목해야 할 부분은 'Swap' 행입니다. total, used, free가 모두 0으로 표시되어 있다면, 현재 시스템에는 스왑 메모리가 전혀 설정되어 있지 않다는 의미입니다. 즉, 981MB의 물리 메모리가 소진되면 시스템은 더 이상 기댈 곳이 없는 상태인 것입니다. 'free'와 'available' 메모리가 매우 낮게 유지되고 있다면 OOM Killer의 등장이 임박했다는 신호입니다.

실시간으로 어떤 프로세스가 메모리를 많이 사용하는지 확인하고 싶다면 top이나 htop 명령어를 사용하세요. htoptop보다 시각적으로 보기 편하며, 설치가 필요할 수 있습니다(sudo apt-get install htop 또는 sudo yum install htop).

해결책: 스왑 메모리(Swap Space) 생성으로 EC2에 인공호흡하기

원인 분석을 통해 우리 서버가 메모리 부족으로 고통받고 있다는 사실을 확인했습니다. 가장 확실한 해결책은 인스턴스 사양을 업그레이드(Scale-up)하는 것이지만, 이는 즉각적인 비용 상승을 의미합니다. 사이드 프로젝트나 초기 서비스 단계에서는 부담스러울 수 있죠. 이때 가장 경제적이면서도 효과적인 대안이 바로 스왑 메모리(Swap Space)를 설정하는 것입니다.

스왑 메모리란 무엇인가? (가상 메모리의 개념)

스왑 메모리는 물리적인 RAM이 부족할 때, 하드 디스크(EC2의 경우 EBS 볼륨)의 일부 공간을 마치 RAM처럼 사용하는 가상 메모리 기술입니다. 운영체제는 당장 사용하지 않는 메모리 페이지(데이터 조각)를 RAM에서 디스크의 스왑 공간으로 옮겨(Swap-out) RAM 공간을 확보합니다. 그리고 나중에 해당 데이터가 다시 필요해지면 디스크의 스왑 공간에서 RAM으로 다시 불러옵니다(Swap-in).

  • 장점: 물리 메모리(RAM) 용량을 초과하는 메모리를 사용할 수 있게 되어 시스템의 안정성이 크게 향상됩니다. OOM Killer에 의해 애플리케이션이 갑자기 종료되는 현상을 방지할 수 있습니다.
  • 단점: 디스크 I/O는 RAM에 비해 수백, 수천 배 느립니다. 따라서 스왑이 빈번하게 발생하면 시스템 전반의 성능 저하를 유발할 수 있습니다.

즉, 스왑은 비상시에 대비한 '보험'과도 같습니다. 평소에는 사용하지 않다가, 갑작스러운 메모리 사용량 급증으로 시스템이 다운되는 최악의 상황을 막아주는 역할을 합니다. 프리티어 인스턴스에서는 이 보험이 필수적입니다.

스왑 파일 생성 실전 가이드 (Step-by-Step)

리눅스에서 스왑 공간을 만드는 방법은 '스왑 파티션'과 '스왑 파일' 두 가지가 있습니다. 이미 시스템이 운영 중인 EC2 환경에서는 파티션을 나누기 번거로우므로, 파일을 이용하는 것이 훨씬 간편하고 유연합니다. 이제부터 2GB 크기의 스왑 파일을 생성하고 적용하는 과정을 단계별로 진행해 보겠습니다. (EC2 인스턴스의 RAM이 1GB이므로, 2배인 2GB를 스왑으로 할당하는 것이 일반적인 권장 사항입니다.)

EC2 인스턴스에 SSH로 접속한 후, 아래 명령어들을 순서대로 실행하세요.

1. 현재 스왑 공간 확인

먼저, 혹시 모를 기존 스왑 설정을 확인합니다. 이미 위에서 free -h로 확인했지만, 다시 한번 swapon 명령어로 확인해 봅니다. 아무것도 출력되지 않으면 스왑이 없는 것이 맞습니다.


sudo swapon --show

2. 스왑 파일 생성

dd 명령어를 사용하여 스왑으로 사용할 파일을 생성합니다. 이 파일은 EBS 볼륨의 루트 디렉토리(/)에 swapfile이라는 이름으로 생성됩니다. dd는 디스크 블록 단위로 데이터를 복사하는 강력한 도구입니다.

  • if=/dev/zero: 0으로 채워진 특수 파일(null-byte stream)을 입력 소스로 사용합니다.
  • of=/swapfile: /swapfile이라는 이름의 파일을 출력 대상으로 지정합니다.
  • bs=1G: 블록 크기(block size)를 1기가바이트(GB)로 설정합니다.
  • count=2: 1GB 크기의 블록을 2개 생성합니다. 결과적으로 2GB 크기의 파일이 만들어집니다.

sudo dd if=/dev/zero of=/swapfile bs=1G count=2

(참고: 구형 시스템이나 가이드에서는 bs=1M count=2048과 같이 메가바이트 단위를 사용하기도 합니다. 결과는 동일하지만, 블록 크기를 크게 잡는 것이 일반적으로 더 효율적입니다.)

3. 스왑 파일 권한 설정 (⭐ 매우 중요!)

생성된 스왑 파일은 민감한 정보를 담을 수 있으므로, 보안을 위해 root 사용자만 읽고 쓸 수 있도록 권한을 변경해야 합니다. 이 단계를 건너뛰면 시스템이 스왑 파일 사용을 거부할 수 있습니다.


sudo chmod 600 /swapfile

4. 파일을 스왑 영역으로 포맷

이제 일반 파일인 /swapfile을 리눅스가 스왑 공간으로 인식할 수 있도록 포맷해야 합니다. mkswap 명령어가 이 역할을 수행합니다.


sudo mkswap /swapfile

명령어를 실행하면 "Setting up swapspace version 1, size = 2 GiB (2147479552 bytes)"와 같은 메시지와 함께 고유한 UUID가 출력될 것입니다.

5. 스왑 파일 활성화

마지막으로, 생성하고 포맷한 스왑 파일을 시스템이 즉시 사용하도록 활성화합니다.


sudo swapon /swapfile

이제 모든 설정이 끝났습니다. 정말로 스왑 메모리가 적용되었는지 확인해 봅시다.

변경 사항 확인 및 영구 적용

1. 스왑 적용 확인

다시 한번 free -hswapon --show 명령어를 실행해 보세요.


$ free -h
              total        used        free      shared  buff/cache   available
Mem:          981Mi       850Mi        50Mi       0.0Ki        81Mi        45Mi
Swap:         2.0Gi         0Bi       2.0Gi

$ sudo swapon --show
NAME       TYPE SIZE USED PRIO
/swapfile file   2G   0B   -2

free -h 출력 결과의 'Swap' 행에 2.0GiB가 할당된 것을 볼 수 있습니다. swapon --show 결과에서도 /swapfile이 정상적으로 등록된 것을 확인할 수 있습니다. 이제 여러분의 EC2 인스턴스는 1GB의 물리 메모리와 2GB의 가상 메모리, 총 3GB에 가까운 메모리 풀을 가지게 된 것입니다!

2. 재부팅 후에도 유지하기 (영구 적용)

여기서 끝이 아닙니다. sudo swapon /swapfile 명령어는 현재 세션에만 스왑을 활성화합니다. 만약 EC2 인스턴스가 재부팅되면 스왑 설정은 사라지고 다시 원점으로 돌아갑니다. 스왑 설정을 영구적으로 유지하려면, 시스템 부팅 시 파일 시스템 정보를 담고 있는 /etc/fstab 파일에 스왑 정보를 등록해야 합니다.

(주의) /etc/fstab 파일은 시스템 부팅에 매우 중요한 파일입니다. 잘못 수정하면 시스템이 부팅되지 않을 수 있으니, 반드시 백업 후 신중하게 작업하세요.

먼저, 만약을 대비해 원본 파일을 백업합니다.


sudo cp /etc/fstab /etc/fstab.bak

그 다음, echotee 명령어를 사용하여 파일의 맨 끝에 스왑 설정 라인을 안전하게 추가합니다. (직접 편집기를 여는 것보다 실수를 줄일 수 있습니다.)


echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

위 명령어는 '/swapfile none swap sw 0 0' 라는 문자열을 /etc/fstab 파일의 끝에 추가(-a 옵션)합니다. 각 항목의 의미는 다음과 같습니다.

  • /swapfile: 스왑으로 사용할 파일의 경로
  • none: 마운트 포인트 (스왑은 특정 디렉토리에 마운트되지 않으므로 none)
  • swap: 파일 시스템의 종류
  • sw: 스왑 옵션
  • 0: dump 명령어로 덤프할지 여부 (0은 덤프 안 함)
  • 0: 부팅 시 파일 시스템 체크 순서 (0은 체크 안 함)

고급 튜닝: 스왑 사용 최적화하기

스왑 설정을 완료했지만, 여기서 만족하지 않고 한 걸음 더 나아가 시스템을 최적화할 수 있습니다. 바로 커널이 스왑 공간을 얼마나 '공격적으로' 사용할지 조절하는 것입니다.

'Swappiness' 값 조절하여 성능 저하 최소화하기

리눅스 커널에는 vm.swappiness 라는 파라미터가 있습니다. 이 값은 0부터 100까지 설정할 수 있으며, 시스템이 스왑을 얼마나 자주 사용할지를 결정합니다.

  • 100: 매우 공격적으로 스왑을 사용합니다. RAM에 여유 공간이 있어도 비활성 메모리 페이지를 스왑으로 옮기려 합니다. (데스크톱 환경에 적합)
  • 0: RAM이 완전히 소진되기 전까지는 절대 스왑을 사용하지 않습니다.
  • 기본값: 대부분의 리눅스 배포판에서 기본값은 60입니다.

서버 환경에서는 디스크 I/O로 인한 성능 저하를 최소화하는 것이 중요합니다. 따라서 물리 RAM을 최대한 활용하고, 정말 필요할 때만 스왑을 사용하도록 swappiness 값을 낮춰주는 것이 좋습니다. 일반적으로 서버에는 10 정도의 낮은 값이 권장됩니다.

먼저 현재 설정된 swappiness 값을 확인합니다.


cat /proc/sys/vm/swappiness

아마 60 또는 30 같은 값이 나올 것입니다. 이 값을 10으로 임시 변경해 보겠습니다.


sudo sysctl vm.swappiness=10

이 설정 또한 재부팅하면 초기화됩니다. 영구적으로 적용하려면 /etc/sysctl.conf 파일을 수정해야 합니다.


# /etc/sysctl.conf 파일 끝에 다음 라인을 추가합니다.
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf

이제 여러분의 서버는 RAM을 최대한 활용하다가, 정말 위급한 순간에만 스왑 공간을 사용하게 되어 안정성과 성능 사이의 균형을 맞출 수 있게 되었습니다.

모니터링의 중요성: 스왑이 만병통치약은 아니다

스왑 메모리는 저사양 인스턴스의 안정성을 비약적으로 높여주는 훌륭한 도구이지만, 만병통치약은 아닙니다. 만약 시스템이 지속적으로 스왑 공간을 사용하고 있다면(이를 '스래싱(thrashing)'이라고 합니다), 이는 애플리케이션이 요구하는 메모리가 인스턴스의 물리적인 한계를 근본적으로 초과했다는 신호입니다.

vmstat 명령어를 사용하면 스왑 인(si)/아웃(so) 활동을 실시간으로 모니터링할 수 있습니다.


# 1초 간격으로 시스템 상태를 출력합니다.
vmstat 1

출력되는 결과에서 si (swap-in, 디스크에서 메모리로)와 so (swap-out, 메모리에서 디스크로) 컬럼의 숫자가 지속적으로 0이 아닌 높은 값을 유지한다면, 심각한 성능 저하가 발생하고 있다는 뜻입니다. 이 경우에는 애플리케이션의 메모리 사용량을 최적화하거나, 더 이상 미루지 말고 EC2 인스턴스 사양을 한 단계 높은 것(예: t3.small)으로 업그레이드하는 것을 심각하게 고려해야 합니다.

결론: 안정적인 서버 운영을 위한 첫걸음

지금까지 AWS EC2 프리티어 인스턴스에서 빈번하게 발생하는 서버 다운 현상의 원인이 '메모리 부족'과 'OOM Killer'에 있음을 파악했습니다. 그리고 이에 대한 가장 현실적인 해결책으로 '스왑 파일'을 생성하고, 활성화하고, 영구적으로 적용하는 모든 과정을 상세하게 살펴보았습니다. 더 나아가 'swappiness' 값 조정을 통해 성능을 최적화하는 방법까지 알아보았습니다.

스왑 메모리를 설정하는 것은 단순히 몇 가지 명령어를 입력하는 행위를 넘어, 내 서버의 한계를 명확히 인지하고 그에 맞는 대비책을 마련하는 과정입니다. 이는 안정적인 서비스를 운영하기 위한 가장 기본적인 첫걸음입니다. 이제 여러분의 소중한 애플리케이션은 예기치 않은 메모리 부족 사태에도 버틸 수 있는 맷집을 가지게 되었습니다. 서버 다운의 공포에서 벗어나, 이제 다시 즐거운 개발의 세계로 돌아갈 시간입니다!

참고 자료