본문 바로가기
Database/MYSQL

MySQL과 Mroonga로 구현하는 강력한 한글 검색 엔진

by 반화넬 2025. 6. 27.
반응형

MySQL과 Mroonga로 구현하는 강력한 한글 검색 엔진

안녕하세요, 그동안 수많은 프로젝트에서 검색 시스템을 구축하며 쌓아온 경험을 바탕으로, 오늘은 MySQL과 Mroonga를 활용해 효율적이고 강력한 한글 검색 엔진을 구현하는 방법을 공유하려 합니다.

대규모 서비스에서는 Elasticsearch 같은 전문 검색 엔진을 사용하는 경우가 많지만, 중소규모 프로젝트에서는 비용과 운영 복잡도로 인해 부담스러울 수 있습니다.

이런 상황에서 MySQL과 Mroonga 조합은 적은 리소스로도 뛰어난 검색 성능을 제공하며, 특히 한글 검색에 최적화된 솔루션으로 자리 잡고 있습니다.

이 가이드가 유용한 대상

  • Elasticsearch 도입이 부담스러운 스타트업 개발자
  • 기존 MySQL/MariaDB 환경에서 검색 기능을 강화하고 싶은 개발자
  • 한글 검색 기능 구현에 어려움을 겪고 있는 백엔드 개발자
  • Spring Boot와 JPA 환경에서 검색 시스템을 구축하려는 개발자

실무 경험을 바탕으로, 누구나 따라 할 수 있도록 단계별로 자세히 설명하겠습니다.
실전에서 검증된 팁과 함께, 검색 시스템의 설계부터 구현, 최적화까지 모두 다루겠습니다.

프로젝트 개요와 기술 스택 이해하기

프로젝트 목표

이 프로젝트의 목표는 MySQL과 Mroonga를 활용해 한글 검색에 최적화된 검색 엔진을 구현하는 것입니다.
중소규모 서비스에서 검색 기능을 강화하면서도 운영 부담을 최소화하는 것이 핵심입니다.

기술 스택

  • Spring Boot (Kotlin 기반): 코드 생산성을 높이기 위해 Kotlin을 사용합니다.
    Kotlin은 간결한 문법과 Null Safety로 인해 Java보다 개발 효율이 높습니다.
  • JPA + QueryDSL: JPA로 데이터베이스 작업을 간소화하고, QueryDSL을 통해 타입 안전한 쿼리를 작성합니다.
    복잡한 검색 로직을 안정적으로 구현하기에 적합합니다.
  • MariaDB + Mroonga: MariaDB에 Mroonga 스토리지 엔진을 결합해 한글 검색 성능을 극대화합니다.
  • Docker Compose: 개발 환경을 빠르게 구성하기 위해 사용합니다.

Mroonga란?

Mroonga는 MySQL/MariaDB용 전문 검색 스토리지 엔진으로, 일본에서 개발되었습니다.
특히 한글, 일본어, 중국어 같은 아시아 언어 검색에 강력한 성능을 발휘합니다.
다양한 프로젝트에서 테스트한 결과, MySQL 기본 검색보다 한글 검색 정확도와 속도 면에서 월등히 뛰어났습니다.

Mroonga는 래퍼 모드를 지원하는데, 이는 InnoDB의 트랜잭션 안정성을 유지하면서 검색 인덱스만 Mroonga로 관리하는 방식입니다.
이 조합은 안정성과 성능을 모두 잡을 수 있는 실무에서 검증된 방법입니다.

개발 환경 설정하기

초기 설정을 꼼꼼히 하면 이후 구현 과정이 훨씬 수월해집니다. 아래는 개발 환경 설정 방법입니다.

1. Docker로 MariaDB와 Mroonga 설치

Docker를 사용하면 Mroonga 설치가 간단합니다. 아래는 docker-compose.yml 파일 예제입니다.

docker-compose.yml

version: '3'
services:
  mariadb:
    image: groonga/mroonga:latest
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: searchapp
    ports:
      - "3306:3306"
    volumes:
      - ./mysql_data:/var/lib/mysql

실행 방법

터미널에서 docker-compose up -d 명령어를 실행하면 Mroonga가 설치된 MariaDB 서버가 실행됩니다.

2. Spring Boot 프로젝트 설정

Spring Boot 프로젝트를 설정합니다. build.gradle.kts 파일에 필요한 의존성을 추가합니다.

build.gradle.kts

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    
    // QueryDSL
    implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta")
    kapt("com.querydsl:querydsl-apt:5.0.0:jakarta")
    
    runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

Q클래스 생성

IntelliJ에서 프로젝트를 열고 ./gradlew compileKotlin 명령어를 실행해 QueryDSL의 Q클래스를 생성합니다.

데이터베이스와 테이블 설계하기

검색 엔진의 성능은 테이블 설계와 인덱스 설정에 크게 좌우됩니다.
경험으로 터득한 핵심은 "검색에 필요한 최소한의 데이터만 인덱싱하라"입니다.

1. 테이블 스키마 구성

아래는 schema.sql 파일로, 검색 기능을 위한 테이블을 정의합니다.

schema.sql

CREATE TABLE IF NOT EXISTS member (
    id BIGINT NOT NULL AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS post (
    id BIGINT NOT NULL AUTO_INCREMENT,
    title VARCHAR(255) NOT NULL,
    content LONGTEXT NOT NULL,
    member_id BIGINT NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    FULLTEXT INDEX ft_post (title, content) WITH PARSER ngram COMMENT 'engine "mroonga"'
) ENGINE=InnoDB COMMENT='engine "mroonga", default_tokenizer "TokenBigram"';

중요 포인트

  • FULLTEXT INDEX를 사용해 title과 content에 전문 검색 인덱스를 설정했습니다.
  • WITH PARSER ngram으로 한글 검색에 최적화된 분석기를 지정했습니다.
  • ENGINE=InnoDB와 COMMENT='engine "mroonga"'로 래퍼 모드를 활성화했습니다.
    이는 InnoDB의 트랜잭션 안정성과 Mroonga의 검색 성능을 동시에 활용하는 방식입니다.

2. JPA 엔티티 정의

JPA를 사용해 엔티티 클래스를 정의합니다.

EntityClasses.kt

import jakarta.persistence.*
import java.time.LocalDateTime

@Entity
class Member(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    
    val name: String
)

@Entity
class Post(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    
    val title: String,
    
    @Column(columnDefinition = "LONGTEXT")
    val content: String,
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    val member: Member,
    
    @Column(name = "created_at")
    val createdAt: LocalDateTime = LocalDateTime.now()
)

3. 데이터베이스 연결 설정

application.properties 파일에 데이터베이스 연결 정보를 설정합니다.

application.properties

spring.datasource.url=jdbc:mariadb://localhost:3306/searchapp
spring.datasource.username=root
spring.datasource.password=rootpassword
spring.jpa.hibernate.ddl-auto=validate
spring.sql.init.mode=always
spring.sql.init.schema-locations=classpath:schema.sql

검색 로직 구현하기

이제 검색 기능을 구현하겠습니다. QueryDSL을 활용해 타입 안전한 검색 로직을 작성하며, Mroonga의 전문 검색 기능을 최대한 활용합니다.

1. QueryDSL 설정

QueryDSL을 사용하기 위해 JPAQueryFactory를 설정합니다.

QuerydslConfig.kt

import com.querydsl.jpa.impl.JPAQueryFactory
import jakarta.persistence.EntityManager
import jakarta.persistence.PersistenceContext
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class QuerydslConfig {
    @PersistenceContext
    private lateinit var entityManager: EntityManager
    
    @Bean
    fun jpaQueryFactory() = JPAQueryFactory(entityManager)
}

2. MATCH AGAINST 함수 등록

Hibernate에서 Mroonga의 MATCH AGAINST 구문을 사용하려면 사용자 정의 함수를 등록해야 합니다.

MariaDBFunctionContributor.kt

package com.example.search.infrastructure

import org.hibernate.boot.model.FunctionContributor
import org.hibernate.boot.model.FunctionContributions
import org.hibernate.dialect.function.StandardSQLFunction
import org.hibernate.type.StandardBasicTypes

class MariaDBFunctionContributor : FunctionContributor {
    override fun contributeFunctions(functionContributions: FunctionContributions) {
        functionContributions.functionRegistry.register(
            "match_against",
            StandardSQLFunction("MATCH_AGAINST", StandardBasicTypes.DOUBLE)
        )
    }
}

설정 파일 추가

src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor
파일에 다음 내용을 추가합니다

com.example.search.infrastructure.MariaDBFunctionContributor

3. 검색 레포지토리 구현

검색 로직을 구현합니다.
MATCH AGAINST를 활용해 전문 검색을 수행하고, 검색 점수에 따라 결과를 정렬합니다.

PostRepositoryImpl.kt

import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Repository
import com.example.search.domain.QPost.post

@Repository
class PostRepositoryImpl(
    private val queryFactory: JPAQueryFactory
) : PostRepositoryCustom {
    
    override fun search(keyword: String?, pageable: Pageable): Page<Post> {
        val query = queryFactory.selectFrom(post)
        
        if (!keyword.isNullOrBlank()) {
            val matchQuery = Expressions.booleanTemplate(
                "MATCH({0}, {1}) AGAINST({2} IN BOOLEAN MODE)",
                post.title, post.content, keyword
            )
            
            query.where(matchQuery)
                .orderBy(
                    Expressions.numberTemplate(
                        Double::class.java,
                        "MATCH({0}, {1}) AGAINST({2} IN BOOLEAN MODE)",
                        post.title, post.content, keyword
                    ).desc(),
                    post.id.desc()
                )
        } else {
            query.orderBy(post.id.desc())
        }
        
        val totalCount = query.fetchCount()
        val results = query
            .offset(pageable.offset)
            .limit(pageable.pageSize.toLong())
            .fetch()
            
        return PageImpl(results, pageable, totalCount)
    }
}

interface PostRepositoryCustom {
    fun search(keyword: String?, pageable: Pageable): Page<Post>
}

핵심 포인트

  • MATCH AGAINST 구문을 사용해 전문 검색을 구현했습니다.
  • IN BOOLEAN MODE 옵션으로 고급 검색 기능(예: +맛집 +서울, 맛집 -강남)을 지원합니다.
    검색 점수에 따라 결과를 정렬해 사용자 경험을 향상시켰습니다.

API 컨트롤러와 테스트 데이터 생성

검색 기능을 API로 제공하고, 테스트 데이터를 생성해 기능을 검증합니다.

1. API 컨트롤러 구현

검색 요청을 처리하는 API를 구현합니다.

PostController.kt

import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.web.PageableDefault
import org.springframework.data.domain.Sort
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/api/v1/posts")
class PostController(
    private val postRepository: PostRepository
) {
    @GetMapping
    fun search(
        @RequestParam keyword: String?,
        @PageableDefault(size = 10, sort = ["id"], direction = Sort.Direction.DESC) pageable: Pageable
    ): Page<PostResponse> {
        return postRepository.search(keyword, pageable)
            .map { post -> PostResponse(post) }
    }
}

data class PostResponse(
    val id: Long,
    val title: String,
    val content: String,
    val author: String,
    val createdAt: LocalDateTime
) {
    constructor(post: Post) : this(
        id = post.id!!,
        title = post.title,
        content = post.content,
        author = post.member.name,
        createdAt = post.createdAt
    )
}

2. 테스트 데이터 생성

개발 환경에서 테스트 데이터를 생성합니다.

TestDataInitializer.kt

import org.springframework.context.annotation.Profile
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import org.springframework.stereotype.Component

@Component
@Profile("!prod")
class TestDataInitializer(
    private val memberRepository: MemberRepository,
    private val postRepository: PostRepository
) : ApplicationRunner {
    
    override fun run(args: ApplicationArguments) {
        val member = memberRepository.save(Member(name = "테스트사용자"))
        
        val posts = listOf(
            Post(
                title = "맛집 추천합니다",
                content = "서울 강남에 있는 맛집을 소개합니다. 정말 맛있어요!",
                member = member
            ),
            Post(
                title = "홈트레이닝 루틴",
                content = "집에서 할 수 있는 효과적인 홈트레이닝 루틴을 공유합니다.",
                member = member
            ),
            Post(
                title = "스터디 모집",
                content = "코틀린과 스프링부트 스터디원을 모집합니다. 함께 공부해요!",
                member = member
            )
        )
        
        postRepository.saveAll(posts)
    }
}

검색 기능 활용 및 최적화 팁

검색 기능 활용

이제 다음과 같은 검색 요청을 테스트할 수 있습니다

  • 기본 검색: /api/v1/posts?keyword=맛집
  • 복합 검색: /api/v1/posts?keyword=맛집 서울 (맛집 OR 서울)
  • 제외 검색: /api/v1/posts?keyword=맛집 -강남 (강남 제외)
  • 정확한 구문 검색: /api/v1/posts?keyword="홈 트레이닝" (정확히 이 구문 포함)

실전 최적화 팁

검색 시스템을 운영하며 얻은 핵심 팁을 공유합니다

  • 인덱스 최적화: 검색에 필요한 칼럼만 인덱싱하세요. 불필요한 칼럼은 인덱스 크기를 키워 성능을 저하시킵니다.
  • 텍스트 길이 관리: content 필드가 너무 길면 인덱스 크기가 커집니다. 필요한 경우 앞부분만 인덱싱하는 방식을 고려하세요.
  • 스톱워드 설정: 한글에서 자주 등장하는 불필요한 단어(예: '이', '그', '저')를 제외하면 검색 성능이 향상됩니다.
  • 래퍼 모드 활용: InnoDB와 Mroonga의 장점을 동시에 누리세요. 트랜잭션 안정성과 검색 성능을 모두 확보할 수 있습니다.

 

 

원본 : https://velog.io/@sleekydevzero86/mroonga-korean-search-engine

반응형