프로젝트를 진행하며 "내가 작성한 편지를 검색하는 효율적인 방법이 없을까?"를 고민하다 MySQL의 전문 검색 인덱스(Full Text Search)를 이용해 간단한 검색 엔진을 구현해 보기로 했습니다.
전문 검색 인덱스 (Full Text Search Index)
1MB가 넘는 컬럼이 있다고 가정했을 때, B-Tree 인덱스는 1MB 전체를 인덱스 키로 잡지 않고 3,072 바이트(InnoDB)까지만 잘라서 인덱스 키로 사용합니다.
그리고 B-Tree 인덱스는 특성상 전체가 같거나, 왼쪽부터 읽으며 일부가 일치하는지만 확인할 수 있습니다. 인덱스 키 값의 왼쪽을 기준(Left-most)으로 오른쪽 값이 정렬되어 있기 때문이죠. "LIKE"문으로 검색할 때 왼쪽 값이 없는 패턴이라면 Full Table Scan이 일어나는 이유이기도 합니다.
-- 왼쪽 값이 없어서 full table scan으로 찾는다.
SELECT name
FROM member
WHERE name LIKE "%lley";
이러한 특성 때문에 특정 키워드로 문서를 검색하고자 할 때는 B-Tree 인덱스를 사용할 수 없습니다. 대신 전문 검색(Full Text Search) 인덱스를 사용할 수 있습니다.
전문 검색 인덱스는 문서 전체에 대한 분석을 위한 인덱싱 알고리즘을 말합니다. 문서의 내용을 빠르게 검색하기 위해 키워드를 분석하고 인덱스를 구축하는 알고리즘들이 있는데, MySQL에서는 어근 분석 알고리즘과 n-gram 알고리즘이 있습니다.
어근 분석 알고리즘
어근 분석
어근 분석(Stemming)은 검색 키워드의 원형을 찾는 작업을 의미합니다. 한국어나 일본어의 경우 단어의 원형이 거의 변하지 않기 때문에 명사와 조사를 구분하는 것이 더 중요한데, MySQL에선 Mecab이라는 플러그인을 설치해서 사용할 수 있습니다. (https://dev.mysql.com/doc/refman/8.4/en/fulltext-search-mecab.html)
플러그인을 설치하는 것은 쉽지만 일본어 분석을 위한 플러그인이다 보니 한글 검색에 특화되도록 만들려면 많은 노력이 필요합니다.
불용어 처리
불용어(Stop Word) 처리는 검색할 때 결과물에서 제외할 단어를 필터링하는 작업을 의미합니다. 자체적으로 불용어 리스트를 만들어 등록할 수 있습니다.
n-gram 알고리즘
n-gram 알고리즘은 문서를 구분자(쉼표나 공백 문자)로 먼저 구분하고, 구분한 문자들을 무조건 n개 단위로 잘라서 인덱싱하는 방법입니다. 이때 n개로 자르는 것을 `토크나이징`이라고 하고, 잘린 단어 조각을 `토큰`이라고 합니다.
예시로 2-gram 알고리즘으로 "my name is yelly"라는 문서를 인덱싱을 한다고 가정하겠습니다.
단어 | 토큰 | |||
my | my | |||
name | na | am | me | |
is | is | |||
yelly | ye | el | ll | ly |
- "my name is yelly"를 먼저 공백으로 구분해 "my", "name", "is", "yelly"로 단어를 나눕니다.
- 단어마다 무조건 2글자의 토큰으로 만듭니다. ⇒ "my", "na", "am", "me", "is", "ye", "el", "ll", "ly"
- 여기서 만약 불용어로 "is"가 등록되어 있다면 해당 토큰은 인덱싱에서 제외됩니다.
- 중복된 토큰은 하나의 인덱스 엔트리로 병합되어 B-Tree 인덱스에 저장됩니다.
전문 검색 인덱스 사용 방법
예시에선 플러그인 설치 방식이 아닌 n-gram 알고리즘을 이용한 방법을 사용합니다.
사용한 Spring Boot 버전은 3.3.4이며, Spring Data JPA를 사용합니다.
MySQL 설정 변경
n-gram 알고리즘을 이용하려면 단어를 몇 개의 토큰으로 나눌 것인지에 대한 설정값이 필요한데, 아래처럼 `fulltext.conf` 파일에 설정합니다. 해당 파일은 `/etc/mysql/conf.d` 디렉토리에 위치하면 됩니다.
# fulltext.conf
ft_stopword_file='' # 서버에 내장된 불용어를 사용하지 않음
ngram_token_size=2 # 단어를 2개씩 잘라 토큰을 생성
이 설정값은 시스템 변수라서 동적으로 변경이 불가능하기 때문에 서버를 재시작해야 적용됩니다.
특정 컬럼에 전문 검색 인덱스 생성
ALTER TABLE letter
ADD FULLTEXT INDEX (message) WITH PARSER ngram;
예시에선 `letter`테이블의 `message` 컬럼에 인덱스를 추가합니다.
주의할 점으로 n-gram 방식으로 인덱스를 생성하기 위해선 인덱스 생성 시 `WITH PARSER ngram` 옵션을 명시해야 합니다.
인덱스를 생성한 이후 테이블에 레코드가 추가될 때 무슨 일이 일어날까요?
MySQL 서버는 프라이머리 키와 별개로 레코드마다 `도큐먼트 ID(Document Id)`를 생성하게 됩니다. 그리고 검색할 때 이를 활용해 문서들을 그루핑(grouping)하죠.
데이터 준비
letter 테이블에 검색 예시에 사용할 데이터들을 만듭니다.
전문 검색 인덱스를 생성한 message 컬럼에 "날씨"와 관련된 주제들로 입력합니다.
각 레코드들엔 "날씨"라는 키워드가 포함되어있거나 포함되어 있지 않습니다.
검색
동작 방식
이제 "날씨"라는 키워드로 검색하면 무슨 일이 일어날까요?
MySQL 서버는 인덱스를 생성한 `message` 컬럼에서 레코드마다 존재하는 토큰들을 가지고 동등 비교 조건으로 검색합니다.
이후 검색 결과를 `도큐먼트 ID`로 그루핑을 하고, 그루핑된 결과에서 키워드를 포함하는지 확인합니다.
검색어의 길이 제한
키워드로 검색할 때는 위에 설정한 토큰 길이(`ngram_token_size`)보다 같거나 큰 단어로 검색해야 합니다.
설정할 때 값을 2로 했기 때문에 최소한 2글자 이상의 키워드로 검색해야 하며, 2글자보다 작은 1글자로는 인덱싱이 되어있지 않아 검색이 불가능합니다.
쿼리
키워드로 검색하려면 `MATCH(검색할 컬럼) AGAINST (검색 키워드);`로 작성하며 WHERE 절에 아래처럼 사용합니다.
WHERE MATCH(message) AGAINST ("날씨");
예시에선 `message` 컬럼에 `날씨`라는 키워드로 검색을 합니다.
전문 검색 쿼리 모드
MySQL은 전문 검색 쿼리로 자연어 검색 모드와 불리언 검색 모드를 지원하는데, 각각에 대해 살펴보겠습니다.
자연어 검색 모드 (Natural Language Mode)
-- 자연어 검색 모드
SELECT letter_id, message, MATCH(message) AGAINST ('날씨가 좋다' IN NATURAL LANGUAGE MODE) as score
FROM letter
WHERE MATCH(message) AGAINST ('날씨가 좋다' IN NATURAL LANGUAGE MODE);
검색 결과는 아래와 같이 출력되는 것을 확인할 수 있습니다.
"날씨"라는 키워드가 2번 들어간 첫 번째 레코드가 가장 높은 일치율(점수)를 받은 것을 확인할 수 있습니다.
자연어 검색 모드는 쉽게 말해 자연어로도 검색할 수 있는 모드입니다. 이 모드는 영문자로 검색 시 대소문자를 구분하지 않습니다. (대소문자를 구분해야 한다면 콜레이션을 변경해야 합니다.)
위의 예시에선 "날씨가 좋다"라는 자연어로 검색을 했는데, 동작 방식은 아래와 같습니다.
- "날씨가 좋다" 문장을 구분자(공백, 줄바꿈 문자)로 단어를 분리합니다.
- 분리한 단어를 다시 n-gram 파서로 토큰을 생성합니다.
- 생성한 토큰에 대해 각 레코드마다 일치하는 개수를 세어 일치율을 계산합니다. (검색어가 단일 문장이나 문장이면 `.`, `,` 같은 문장 기호는 무시)
- 일치율이 높은 순서대로 정렬하고 결과를 출력합니다.
자연어 검색 모드는 일치율을 계산(스코어링)해서 일치율이 높은 순서대로 정렬한 결과를 반환합니다. 따라서 별도로 정렬 조건이 주어진다면 일치율과 상관없는 결과가 될 수 있습니다.
불리언 검색 모드 (Boolean Mode)
-- 불리언 검색 모드
SELECT letter_id, message, MATCH(message) AGAINST ('+날씨 -기분' IN BOOLEAN MODE) as score
FROM letter
WHERE MATCH(message) AGAINST ('+날씨 -기분' IN BOOLEAN MODE);
검색 결과는 아래와 같이 출력됐습니다.
불리언 검색 모드는 검색 키워드가 포함되어 있는지 여부를 이용한 논리적 연산을 지원하는 모드입니다.
위의 예시에선 `+날씨`로 "날씨"라는 키워드가 반드시 포함되면서, `-기분`으로 "기분"이라는 키워드가 반드시 제외해 결과를 만들었습니다.
이외에도 `*`같은 와일드카드 패턴도 지원하지만 n-gram 알고리즘에선 무조건 n개 길이로 된 토큰을 검색하기 때문에 굳이 와일드카드 표현식을 사용하지 않습니다.
검색어 확장 (Query Expansion)
-- 검색어 확장
SELECT letter_id, message
FROM letter
WHERE MATCH(message) AGAINST ('날씨' WITH QUERY EXPANSION);
검색 결과는 아래와 같이 출력됩니다.
이미지의 붉은 박스 안을 보면 "날씨"라는 키워드가 들어있지 않은 레코드도 결과에 포함되었음을 확인할 수 있습니다.
검색어 확장은 "Blind query expansion"이라는 알고리즘을 이용해 입력한 검색어로 검색한 결과에서 공통으로 발견된 단어를 모아 한 번 더 검색을 수행하는 방식입니다.
동작 방식은 아래 처럼 정리할 수 있습니다.
- "날씨"라는 키워드로 전문 검색을 실행
- 실행한 결과에서 "날씨" 키워드와 연관 있어 보이는 단어들을 추출
- 추출한 단어들로 다시 한 번 전문 검색을 수행하고 이전 결과와 취합해 출력
이 방식은 원하지 않는 결과가 많을 수 있고, 전문 검색 쿼리가 많이 발생할 수 있기 때문에 주의해야 합니다.
간단한 검색 엔진 구현
이제 전문 검색을 활용한 간단한 검색 엔진을 만들어 보겠습니다.
검색 결과 프로젝션
public interface FulltextSearchResult {
String getLetterId();
String getMessage();
LocalDateTime getCreatedAt();
}
검색 결과로 받을 컬럼은 `letterId`, `message`, `createdAt` 세 가지 입니다. 이를 위해 프로제션을 작성했습니다.
전문 검색 커스텀 함수 작성과 등록
JPQL에서 사용하기 위해 전문 검색 쿼리 모드 세 가지를 작성합니다.
Hibernate 6 버전부터 `FunctionContributor` 구현체를 만들어서 등록해야 합니다. WHERE 조건에 사용되기 때문에 `BOOLEAN` 타입으로 지정합니다.
자연어 검색 모드
import static org.hibernate.type.StandardBasicTypes.BOOLEAN;
import org.hibernate.boot.model.FunctionContributions;
import org.hibernate.boot.model.FunctionContributor;
public class FulltextMatchNaturalLanguageModeFunctionContributor implements FunctionContributor {
private static final String MATCH_AGAINST_FUNCTION_NAME = "match_against_natural_language_mode";
private static final String PATTERN = "match(?1) against(?2 in natural language mode) ";
@Override
public void contributeFunctions(FunctionContributions functionContributions) {
functionContributions.getFunctionRegistry()
.registerPattern(MATCH_AGAINST_FUNCTION_NAME, PATTERN,
functionContributions.getTypeConfiguration().getBasicTypeRegistry().resolve(BOOLEAN));
}
}
불리언 검색 모드
import static org.hibernate.type.StandardBasicTypes.BOOLEAN;
import org.hibernate.boot.model.FunctionContributions;
import org.hibernate.boot.model.FunctionContributor;
public class FulltextMatchBooleanModeFunctionContributor implements FunctionContributor {
private static final String MATCH_AGAINST_FUNCTION_NAME = "match_against_boolean_mode";
private static final String PATTERN = "match(?1) against(?2 in boolean mode)";
@Override
public void contributeFunctions(FunctionContributions functionContributions) {
functionContributions.getFunctionRegistry()
.registerPattern(MATCH_AGAINST_FUNCTION_NAME, PATTERN,
functionContributions.getTypeConfiguration().getBasicTypeRegistry().resolve(BOOLEAN));
}
}
검색어 확장
import static org.hibernate.type.StandardBasicTypes.BOOLEAN;
import org.hibernate.boot.model.FunctionContributions;
import org.hibernate.boot.model.FunctionContributor;
public class FulltextMatchQueryExpansionFunctionContributor implements FunctionContributor {
private static final String MATCH_AGAINST_FUNCTION_NAME = "match_against_query_expansion";
private static final String PATTERN = "match(?1) against(?2 with query expansion)";
@Override
public void contributeFunctions(FunctionContributions functionContributions) {
functionContributions.getFunctionRegistry()
.registerPattern(MATCH_AGAINST_FUNCTION_NAME, PATTERN,
functionContributions.getTypeConfiguration().getBasicTypeRegistry().resolve(BOOLEAN));
}
}
등록
`resources/META-INF/services` 디렉토리에 `org.hibernate.boot.model.FunctionContributor` 파일에 구현체가 위치한 경로를 작성해 커스텀 함수를 등록합니다.
// resources/META-INF/services 디렉토리에 작성
bsise.server.common.hibernate.FulltextMatchNaturalLanguageModeFunctionContributor
bsise.server.common.hibernate.FulltextMatchBooleanModeFunctionContributor
bsise.server.common.hibernate.FulltextMatchQueryExpansionFunctionContributor
Controller - Service - Repository 작성하기
Repository
전문 검색 쿼리 모드 각각에 대해 아래 함수들을 Repository에 작성합니다.
@Query("""
SELECT l.id AS letterId, l.message AS message, l.createdAt AS createdAt
FROM Letter l
WHERE l.user.id = :userId AND match_against_natural_language_mode(l.message, :searchText)
""")
List<FulltextSearchResult> fulltextSearchWithNaturalLanguageMode(UUID userId, String searchText, Pageable pageable);
@Query("""
SELECT l.id AS letterId, l.message AS message, l.createdAt AS createdAt
FROM Letter l
WHERE l.user.id = :userId AND match_against_boolean_mode(l.message, :searchText)
""")
List<FulltextSearchResult> fulltextSearchWithBooleanMode(UUID userId, String searchText, Pageable pageable);
@Query("""
SELECT l.id AS letterId, l.message AS message, l.createdAt AS createdAt
FROM Letter l
WHERE l.user.id = :userId AND match_against_query_expansion(l.message, :searchText)
""")
List<FulltextSearchResult> fulltextSearchWithQueryExpansion(UUID userId, String searchText, Pageable pageable);
Service
마찬가지로 세 가지 모드를 구별하는 함수를 Service에 작성합니다.
@Transactional(readOnly = true)
public List<FulltextSearchResult> fulltextSearchWithNaturalLanguageMode(UUID userId, String searchText) {
PageRequest page = PageRequest.of(0, 10, Sort.by(Direction.DESC, "createdAt"));
return letterRepository.fulltextSearchWithNaturalLanguageMode(userId, searchText, page);
}
@Transactional(readOnly = true)
public List<FulltextSearchResult> fulltextSearchWithBooleanMode(UUID userId, String searchText) {
PageRequest page = PageRequest.of(0, 10, Sort.by(Direction.DESC, "createdAt"));
return letterRepository.fulltextSearchWithBooleanMode(userId, searchText, page);
}
@Transactional(readOnly = true)
public List<FulltextSearchResult> fulltextSearchWithQueryExpansion(UUID userId, String searchText) {
PageRequest page = PageRequest.of(0, 10, Sort.by(Direction.DESC, "createdAt"));
return letterRepository.fulltextSearchWithQueryExpansion(userId, searchText, page);
}
Controller
검색어는 쿼리 파라미터 `q`로 받습니다.
쿼리 파라미터로 `mode`를 받고, 이 값에 따라 동적으로 쿼리 모드를 선택해 응답을 생성합니다.
@GetMapping(value = "/search/{userId}")
@ResponseStatus(HttpStatus.OK)
public List<FulltextSearchResult> searchLetters(
@PathVariable("userId") String userId,
@RequestParam("q") String query,
@RequestParam(value = "mode", defaultValue = "natural") String mode
) {
return switch (mode) {
case "natural" -> letterService.fulltextSearchWithNaturalLanguageMode(UUID.fromString(userId), query);
case "boolean" -> letterService.fulltextSearchWithBooleanMode(UUID.fromString(userId), query);
case "expansion" -> letterService.fulltextSearchWithQueryExpansion(UUID.fromString(userId), query);
default -> throw new IllegalArgumentException();
};
}
클라이언트 코드
검색어를 입력과 쿼리 모드를 선택하는 라디오 버튼을 작성합니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<label><input type="radio" id="natural" name="mode" value="natural" checked> Natural Language Mode</label>
<label><input type="radio" id="boolean" name="mode" value="boolean"> Boolean Mode</label>
<label><input type="radio" id="expansion" name="mode" value="expansion"> Query Expansion</label>
<div>
<input style="width: 30rem;" id="fulltext" type="text" placeholder="검색어 입력" />
</div>
<script src="search.js"></script>
</body>
</html>
API 요청은 `search.js`에 작성합니다.
`debounceTimeout`을 설정해 서버에 부담을 줄여주도록 합니다.
const inputElement = document.getElementById('fulltext');
const debounceTimeout = 300; // 밀리초 단위로 설정 (0.3초)
let debounceTimer;
// 라디오 버튼 설정
const radioButtons = {
natural: document.getElementById('natural'),
boolean: document.getElementById('boolean'),
expansion: document.getElementById('expansion'),
};
const displayResults = (data) => {
// 기존 결과 삭제
let existingResults = document.getElementById('results');
if (existingResults) {
existingResults.remove();
}
// 새로운 결과 추가
const resultsContainer = document.createElement('div');
resultsContainer.id = 'results';
const table = document.createElement('table');
table.className = 'results-table';
// 테이블 헤더 추가
const headerRow = document.createElement('tr');
['Message', 'Letter ID', 'Created At'].forEach((header) => {
const th = document.createElement('th');
th.textContent = header;
headerRow.appendChild(th);
});
table.appendChild(headerRow);
// 데이터 추가
data.forEach((item) => {
const row = document.createElement('tr');
const messageCell = document.createElement('td');
messageCell.textContent = item.message;
row.appendChild(messageCell);
const idCell = document.createElement('td');
idCell.textContent = item.letterId;
row.appendChild(idCell);
const dateCell = document.createElement('td');
dateCell.textContent = item.createdAt.split('.')[0]; // 날짜 형식 간소화
row.appendChild(dateCell);
table.appendChild(row);
});
resultsContainer.appendChild(table);
document.body.appendChild(resultsContainer);
};
const fetchSearchResults = async (query, mode) => {
try {
console.log(`query: ${query}, mode: ${mode}`)
const response = await fetch(`http://localhost:8080/api/v1/letters/search/6374fec7-65d3-40b0-a9a0-4dbec96eef75?q=${encodeURIComponent(query)}&mode=${mode}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
displayResults(data);
} catch (error) {
console.error('Error fetching search results:', error);
}
};
inputElement.addEventListener('input', (event) => {
clearTimeout(debounceTimer);
const searchText = event.target.value;
const selectedMode = getSelectedMode();
if (searchText.trim() === '') {
// Clear results if input is empty
displayResults([]);
return;
}
debounceTimer = setTimeout(() => {
fetchSearchResults(searchText, selectedMode);
}, debounceTimeout);
});
// 라디오 버튼 중 선택된 모드를 반환하는 함수
const getSelectedMode = () => {
if (radioButtons.natural.checked) return 'natural';
if (radioButtons.boolean.checked) return 'boolean';
if (radioButtons.expansion.checked) return 'expansion';
return 'natural'; // 기본값
};
이렇게 간단하게 작성한 클라이언트 코드를 vscode live server를 이용해 실행해줍니다.
결과
쿼리 모드에 따라 다른 결과들이 나오는 것을 확인할 수 있습니다.
참고
- RealMySQL 8.0 1, 2권
- https://aregall.tech/hibernate-6-custom-functions
'MySQL' 카테고리의 다른 글
[스터디] Real MySQL 8.0 1권 - 5장 트랜잭션과 잠금 정리 (0) | 2024.09.23 |
---|---|
[MySQL] INSERT 할 때, 마지막 PK 값에서 1씩 증가시키는 방법 (0) | 2023.04.27 |
[MySQL] AUTO_INCREMENT 초기화하는 방법 (0) | 2023.04.27 |
[MySQL] 테이블 내 중복 데이터 삭제 (0) | 2023.04.27 |
[MySQL] 프로세스 확인과 프로세스 죽이기 (2) | 2022.09.30 |