검색 기능을 기획하면서
Knoticle 서비스를 개발하면서 검색 기능을 구현할 일이 생겼습니다.
수 천 개 이상의 책과 글 데이터가 저장될 서비스에서 사용자들이 원하는 데이터를 찾을 수 있는 검색 기능은 필수적인 기능입니다. 제한된 프로젝트 개발 기간 동안 적절한 성능을 내는 검색 엔진을 개발한 과정을 정리해보려고 합니다.
검색 기능 개발 기획을 하면서 고려한 부분은 다음과 같습니다.
- 프로젝트 개발 기간이 제한적이다. → 러닝 커브가 있는 새로운 기술 스택을 사용하지 않고, 기존 기술 스택인
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 라면 문장이 다음과 같이 쪼개집니다.
아버지가 방에 들어가신다-> 아버 / 버지 / 지가 / 방에 / 들어 / 어가 / 가신 / 신다기상청에서 사용하는 슈퍼컴퓨터-> 기상 / 상청 / 청에 / 에서 / 사용 / 용하 / 하는 / 슈퍼 / 퍼컴 / 컴퓨 / 퓨터
위와 같은 방식으로 토크나이징 되어있다면 "가신다" 는 "가신" / "신다" 로 검색되고, "컴퓨터" 는 "컴퓨" / "퓨터" 로 검색됩니다. 각각 두 개 씩 일치하기 때문에 빌트인 파서에서는 검색되지 않던 내용들이 검색되게 됩니다.
Space Handling
N-gram 파서는 공백이 포함된 경우 키워드로 추출하지 않는다.
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;
Knoticle의 검색 기능
저희 서비스에서는 글(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
키워드를 통해 관련성 순으로 정렬할 수 있습니다.