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 기반 애플리케이션 개발에 적용될 수 있는 중요한 원칙입니다. 이 글이 여러분의 프로젝트에 강력한 검색 기능을 더하는 데 훌륭한 출발점이 되기를 바랍니다.


0 개의 댓글:

Post a Comment