Full Text Search를 활용한 검색 기능 개발기
검색 기능을 기획하면서
사용자가 글을 쓰고 그 글들을 책으로 엮어 공유하는 서비스를 팀 프로젝트로 개발하면서 검색 기능을 구현할 일이 생겼다.
수 천 개 이상의 책과 글 데이터가 저장될 서비스에서 사용자들이 원하는 데이터를 찾을 수 있는 검색 기능은 필수적인 기능이다. 짧은 프로젝트 기간 동안 적절한 성능을 내는 검색 엔진을 개발한 과정을 정리해보려고 한다.
검색 기능 개발 기획을 하면서 고려한 부분은 다음과 같다.
- 프로젝트 개발 기간이 제한적이다. → 러닝 커브가 있는 새로운 기술 스택을 사용하지 않고 기존 기술 스택인
MySQL을 이용한다. - 대용량 데이터를 처리하는 데 준수한 성능을 보여준다.
LIKE절
데이터베이스에서 문자열을 찾는 쿼리를 날릴 때 가장 먼저 떠오르는 것은 LIKE절이다. 초기에 LIKE절을 사용한 쿼리로 구현하고 추후에는 조회 시에 인덱스를 타게해서 성능을 개선해볼 여지도 있었다.
그러나 LIKE 절은 와일드카드(%)를 사용할 때 항상 인덱스를 타지 않는다.
SELECT * FROM articles WHERE keyword LIKE 'A%';
위와 같이 와일드카드가 키워드의 우측에 붙은 경우에는 인덱스를 탈 수 있어서 Index Range Scan이 일어난다.
SELECT * FROM articles WHERE keyword LIKE '%A';SELECT * FROM articles WHERE keyword LIKE '%A%';
반면에 위와 같이 와일드카드가 키워드의 좌측에 붙은 경우에는 어떤 문자로 시작하는지 알 수 없기 때문에 Full Table Scan이 발생한다.
결국 제한적으로만 인덱스를 탈 수 있어서 좋은 선택지가 아니라고 생각했다.
Full-Text Search
MySQL에서는 문자열 검색을 위해 LIKE절 외에 Full-Text Search를 제공한다.
Full-Text Search는 단어나 구문에 대한 검색을 지원하고자 제공되는 방식이다. 검색하고자 하는 컬럼에 Full-Text Index를 설정해주게 되면, 문자열이 정해진 방법으로 분리되어 인덱스를 생성하고, 이를 빠르게 검색할 수 있다. 그리고 검색 키워드와의 관련성이 높은 순으로 정렬할 수도 있고 추가적인 검색 규칙을 적용할 수도 있다.
우리 팀은 위와 같은 이유들로 LIKE절 대신에 Full-Text Search을 활용해 검색 기능을 제공하기로 결정했다.
Full-Text Search의 인덱스 방식
Full-Text Search에서 인덱스를 생성하는 방법은 여러 가지가 있다. 인덱스는 파서가 문자열을 토크나이징한 후에 생성된다. 이런 역할을 수행하는 파서가 여러 가지 있지만 이번에는 두 가지만 정리해 보려고 한다.
Built-In Parser
빌트인 파서는 stopword(구분자)를 기준으로 키워드를 추출하는 방식이다. 공백이나 문장 기호 혹은 사용자가 지정한 특정 단어를 기준으로 토크나이징하게 된다.
예를 들어서 구분자가 공백이라면 문장이 다음과 같이 쪼개진다.
아버지가 방에 들어가신다-> 아버지가 / 방에 / 들어가신다기상청에서 사용하는 슈퍼컴퓨터-> 기상청에서 / 사용하는 / 슈퍼컴퓨터
위와 같은 방식으로 토크나이징되어있다면 "가신다" 혹은 "컴퓨터"와 같은 검색 키워드로는 위 문장을 검색할 수 없다. Full-Text Search는 토큰과 검색 키워드가 전부 일치하거나 전방(prefix) 일치한 경우에만 결과를 가져오기 때문이다.
N-gram Parser
위와 같은 문제를 해결해주는 파서도 존재한다. N-gram 파서는 MySQL에서 기본적으로 제공하기 때문에 Full-Text Index를 설정해줄 때 옵션으로 지정해주기만 하면 사용할 수 있다.
N-gram 파서는 지정된 토큰 사이즈를 기준으로 키워드를 추출한다.
예를 들어서 토큰 사이즈가 2라면 문장이 다음과 같이 쪼개진다.
아버지가 방에 들어가신다-> 아버 / 버지 / 지가 / 방에 / 들어 / 어가 / 가신 / 신다기상청에서 사용하는 슈퍼컴퓨터-> 기상 / 상청 / 청에 / 에서 / 사용 / 용하 / 하는 / 슈퍼 / 퍼컴 / 컴퓨 / 퓨터
위와 같은 방식으로 토크나이징되어있다면 "가신다"는 "가신" / "신다"로 검색되고 "컴퓨터"는 "컴퓨" / "퓨터"로 검색된다. 각각 두 개씩 일치하기 때문에 빌트인 파서에서는 검색되지 않던 내용들이 검색된다.
MATCH … AGAINST
이제 Full-Text Search에 대한 충분한 설명이 된 것 같으니 실제로 어떻게 사용하는지 알아보자.
Full-Text Index가 걸려있는 컬럼에 한해서 MATCH … AGAINST절을 사용해서 검색을 이용할 수 있다.
SELECT ...FROM ...WHERE MATCH(컬럼) AGAINST('키워드' IN NATURAL LANGUAGE MODE);SELECT ...FROM ...WHERE MATCH(컬럼) AGAINST('키워드' IN BOOLEAN MODE);
그런데 뒤에 붙은 IN NATURAL LANGUAGE MODE와 IN BOOLEAN MODE는 무엇일까?
Full-Text Search에서는 세 가지 종류의 검색 방식(search type)을 지원한다. 여기서는 위의 두 가지의 검색 방식을 알아보자.
IN NATURAL LANGUAGE MODE
해당 모드는 검색 키워드를 토큰 사이즈로 분리한 후, 분리된 단어 중에서 하나라도 포함되는 데이터를 찾는다.
해당 모드는 검색 방식을 생략해서 적었을 때 기본 모드이고 위와 같이 명시적으로 나타낼 수 있다.
IN BOOLEAN MODE
해당 모드는 검색 키워드를 토큰 사이즈로 분리한 후, 추가적인 검색 규칙을 적용해서 단어가 포함되는 데이터를 찾는다.
MATCH(컬럼) AGAINST ('+A -B' IN BOOLEAN MODE);
예를 들어서 위와 같은 검색 규칙은 "A"는 포함하지만 "B"는 포함하지 않는 데이터를 검색한다.
이외에도 여러 가지 검색 규칙이 있는데 원하는 결과를 얻기 위해서 적절하게 조합하면 된다.
| Operator | Description |
|---|---|
| + | 반드시 포함하는 단어 |
| - | 반드시 제외하는 단어 |
| > | 포함하면서 검색 순위를 높일 단어 |
| < | 포함하지만 검색 순위를 낮출 단어 |
| () | 하위 표현식으로 그룹화 |
| ~ | '-' 연산자와 비슷하지만 제외 시키지는 않고 검색 조건을 낮춤 |
| * | 와일드카드 |
| "" | 구문 정의 |
MATCH … AGAINST절은 검색 키워드가 얼마나 많이 포함되어 있는 지에 따라서 관련성(relevance)이 결정된다. 관련성을 정렬해서 사용자에게 더욱 적절한 검색 결과를 보여줄 수도 있다.
SELECT ..., MATCH ... AGAINST ... AS relevanceFROM ...WHERE MATCH ... AGAINST ...ORDER BY relevance DESC;
실제 적용
글(article)과 책(book) 테이블에서 Full-Text Search를 사용했는데, 글에서는 글의 제목(title)과 내용(content)에, 책에서는 책의 제목(title)에 인덱스를 걸어서 활용했다.
우리가 사용했던 Prisma ORM에서는 다음과 같은 방법으로 이용했다.
const articles = await prisma.article.findMany({select: { ... },where: {title: {search: `${query}*`,},content: {search: `${query}*`,},},orderBy: {_relevance: {fields: ['title', 'content'],sort: 'desc',search: `${query}*`,},},});
WHERE절에서 search 키워드를 통해 Full-Text Search를 사용할 수 있고 ORDER BY절에서 _relevance 키워드를 통해 관련성 순으로 정렬할 수 있다.

정리
새로운 검색 엔진을 도입하는 대신 이미 쓰고 있던 MySQL의 기능만으로 검색을 만들었다. 러닝 커브 없이 인덱스 설정과 쿼리 변경만으로 LIKE절의 Full Table Scan을 피할 수 있었고, 관련성 정렬까지 덤으로 얻었다. 기간이 짧은 프로젝트에서는 가진 도구를 얼마나 아는지가 선택지를 넓혀준다고 느꼈다.