승이의 기술블로그
article thumbnail
Published 2023. 7. 20. 21:51
[MySQL] Full Text Search 적용기 SQL

https://github.com/Indipage/SERVER

 

GitHub - Indipage/SERVER

Contribute to Indipage/SERVER development by creating an account on GitHub.

github.com

MySQL Full Text Search 가 필요한 상황

 

검색이 필요한 상황

- 주소에 포함되는 단어를 검색했을 때 해당하는 공간이 떠야 한다.

 

DB 구조

- 지역마다 주소 체계가 다르기 때문에 크롤링 상황에서의 편의성을 위해서 주소를 각 컬럼별로 분리해두었다.

- 따라서 base_government, city, metro_government, road_name, town, detail 6개의 모든 컬럼에 대해서 검색이 이루어져야 한다.

고려한 방법들

1. LIKE 쿼리

SELECT *
FROM space
WHERE base_government LIKE '%검색어%'
   OR city LIKE '%검색어%'
   OR metro_government LIKE '%검색어%'
   OR road_name LIKE '%검색어%'
   OR town LIKE '%검색어%'
   OR detail LIKE '%검색어%';

 

LIKE 를 사용하게 된다면 위와 같이 될 것이다.

이는 쿼리문이 복잡할뿐만 아니라 성능 측면에서도 상당히 좋지 않을 것을 우려했다. 

와일드카드가 앞에 오는 경우에는 전체 탐색이 이루어지고 인덱스를 사용하지 않는다.

현재는 공간에 대한 데이터 수가 적기 때문에 당장은 LIKE 로 처리해도 되지만, 추후에 공간이 늘어날 것을 우려해 LIKE 쿼리는 적절한 선택이 아니라는 판단이 내려졌다. 

 

2. Elastic Search

 

Elastic Search 가 검색 기능을 구현할 때에 가장 먼저 떠오르는 대안책이었다.

Elastic Search 를 하면 Tokenizer 나 Filter 를 자체적으로 제공하기 때문에 편리할 것이라고 생각했다.

그러나 첫번째로, 현재 검색을 하고 있는 대상이 아티클이 아니다. 유의어 검색, 형태소 분석 등을 통해 정확한 검색 결과를 제공하는 것은 아티클과 같은 긴 글에 더 적합한 방법이라는 생각이 들었다.

두번째로는, 러닝 커브가 존재한다. 현재 빠르게 구현을 해야 하는 상황 속에서 세팅을 새로 하고 새로운 기술을 도입하기 보다는 최대한 기존의 상황을 유지하며 구현해내고자 했다.

 

MySQL FUll Text Search 의 원리와 장점

✅ 인덱스 알고리즘

 

1. 구분자 stopwords

- 문장의 내용을 공백, 문장기호, 내장되어 있는 구분자, 사용자가 추가로 정의한 문자열을 기준으로 자른다.

 

2. n-gram

- 정의된 파서의 개수에 따라서 쪼갠다.

- 예를 들어, "구로구" 이고 N 이 1이라면 "구", "로", "구" 로 쪼개진다.

  N 이 2라면 "구로", "로구" 로 쪼개진다.

 

n-gram 방식으로 파싱을 하는 것이 현재 구현해내야 하는 상황에 적합한 방식이라고 생각했다.

지역별로 다양한 체게를 갖고 있는 주소의 특성상, 특정 문자열을 정확하게 입력하지 않아도 올바른 결과가 반환되어야 했다.

예를 들어 "강남" 를 검색하면 서초구의 강남대로에 위치한 공간과 강남구의 공간이 모두 반환되어야 했다. 

 

✅ 토큰 단위 결정 방법

 

- innodb_ft_min_token_size

검색 수행 시에 사용되는 최소 토큰 크기를 설정하는 변수

innodb_ft_min_token_size 가 3이라면, 인덱스에는 무조건 3글자 이상의 인덱스만 포함되기 때문에 3글자 미만인 경우에는 결과에서 무시된다. 따라서 검색 조건과 상황에 맞춰 적절한 값을 선택하는 것이 중요하다.

만약 두글자 이상으로 검색을 제한하고자 한다면, 해당 값을 2로 설정하여 한글자로 검색을 했을 때에는 결과가 나오지 않도록 할 수 있는 것이다. 이는 InnoDB 를 사용할 때에 설정해야 하는 값이며 MyISAM를 사용한다면 ft_min_word_len 에 해당 값을 세팅해주면 된다.

 

- ngram_token_size

인덱스를 생성할 때 잘리는 토큰의 길이의 기준이다.

"안녕하세요" 라는 문장에 해당 값이 1로 세팅되어 있다면 인덱스는 "안", "녕", "하", "세", "요"

2로 세팅되어 있다면 "안녕", "녕하", "하세", "세요" 로 잘리는 것이다.

 

✅ 검색 모드의 종류

 

1. 자연어 검색

 

- 입력된 검색어에서 키워드를 추출하여 이를 포함하고 있는 레코드를 검색한다.

- 해당 키워드가 얼마나 정확하고 많이 포함되어 있는지에 따라 정확도가 결정된다.

  • 이에 대한 기준은 1. 단어가 얼마나 많이 2. 동일한 순서로 배치되어 있는가 이다.
  • 예를 들어, 강남을 검색했을 때 강남 순서가 정확히 동일하면 정확도가 가장 높으며 강남 순서가 정확히 일치하지는 않지만 두 글자를 모두 포함하고 있다면 그 다음으로 높은 정확도를 띄게 된다.

- 50% 이상이 검색된 키워드를 갖고 있다면 이는 의미없는 구분자라고 판단하여 검색 결과에서 제외한다. (MyISAM 에서만!)

- 정확도 순으로 정렬이 이루어진다.

 

2. BOOLEAN 모드 검색

 

- 각 키워드의 포함 및 불포함 비교를 수행한다.

- TRUE / FALSE 를 통해 최종 일치 여부를 판단한다.

- 각 키워드를 잘라서 포함, 불포함 비교를 수행한다.

 

🥳 결정 이유!

1. 정확도 순으로 배열되기 때문에 정렬을 신경 쓸 필요 없이 그대로 결과를 내보내면 된다.

2. 명확한 기준이 없는 주소의 특성 상 토큰으로 잘라서 검색이 이루어지는 것이 적합한 방법

3. 추후에 검색 조건이 바뀌거나 필터가 추가된다고 하더라도 기존의 설정값을 바꿔주기만 한다면 될 것이라고 판단

4. 상황에 따라서 검색 모드를 바꿔가면서 쿼리를 짤 수 있기 때문에

 

고려해야 하는 지점들

인덱스란?

- 추가적인 쓰기 작업과 저장 공간을 활용해서 검색 속도를 높일 수 있는 자료구조

 

인덱스를 활용하게 되면 항상 최신의 정렬된 상태를 유지하게 되기 때문에 조회가 빠르게 이루어진다.

그러나, 이 상태를 유지해야 하기 때문에 INSERT, DELETE, UPDATE 쿼리 과정에서는 성능이 오히려 저하된다.

따라서 삽입이나 수정보다 조회가 자주 이루어지는 경우에만 인덱스를 거는 것이 적합하다. 

-> 현재 주소 관련 쿼리는 어드민 페이지가 따로 없고 DB 에 한꺼번에 필요한 데이터들을 그때그때 삽입하고 있기 때문에 이는 따로 고려할 상황이 아니라고 생각했다.

 

그러나, 현재 주소를 관련해서 여러개로 컬럼을 분리해뒀기 때문에 컬럼 여러개를 묶어서 인덱스로 선언하기에 인덱스의 크기가 커져 성능에 부하를 줄 것을 우려했다.

또한, ngram 은 설정된 토큰의 단위에 따라서 글자를 잘라 인덱싱한다. 이로 인해 인덱스의 크기가 상당히 크다. 뿐만 아니라 프론트엔드 인덱스, 백엔드 인덱스로 나뉘어져 인덱스를 걸기 때문에 크기가 상당하다.

 

잘게 파싱을 하는 대신, 정확도도 일부 상황에서 떨어질 수 있다.

예를 들어 마포구 독막로 2길이 하나의 컬럼에 존재한다면 "마포", "포구", "구독", "독막", "막로", "로2", "2길" 과 같이 파싱이 될 것이다. 포구, 구독, 막로, 로2와 같은 경우에는 의미 없는 글자이며 검색 시에 "구독"을 입력해도 해당 공간이 반환되는 문제가 존재한다. 이는 추후에 더 정교한 설정이 필요함을 시사하는 지점이다.

 

이후 데이터가 많아지면 부가적인 테스트를 통한 리팩토링 및 정교한 세팅이 필요할 것 같다.

MySQL Full Text Search 적용하기

1. 관련 컬럼을 묶어 인덱싱한다.

ALTER TABLE space ADD FULLTEXT INDEX idx_address(base_government, building_number, city, metro_government, road_name, town, detail) with parser `ngram`;

 

 

인덱스가 잘 걸렸는지 확인해보자

 

SHOW INDEX FROM space;

2. 검색 쿼리하기

select * from space where match(base_government, city, metro_government, road_name, town, detail) AGAINST ("검색하고자 하는 단어");

 

기존 세팅값에서 변경을 하기 위해서 RDS 에서 파라미터 그룹을 생성해 ngram_token_size 와 ft_min_word_len, innodb_ft_min_token_size 를 2로 설정해줬다.

 

바꾸고 난 뒤에 재부팅까지 해줘야 적용이 잘 됐다!

설정 및 재부팅 이후 잘 바뀐 것을 확인할 수 있다. 

 

show variables like 'ngram_token_size';

 

@Query(value = "SELECT * FROM space s WHERE MATCH(s.base_government, s.city, s.metro_government, s.road_name, s.town, s.detail) AGAINST(:searchTerm)", nativeQuery = true)
    List<Space> searchByAddress(@Param("searchTerm") String searchTerm);

 

이후 Repository 단에서 쿼리를 직접 작성하여 FullText Search 가 이루어질 수 있도록 했다!

 

👾 트러블 슈팅

처음에는 토큰 사이즈를 1로 세팅하고 진행을 했었다. 예상되는 문제 지점들이 존재했다.

 

"마포" 라고 검색하면 "마"산, "포"항 등 의도하지 않은 검색 결과가 나올 것이라는 것이었다.

이 예상은 적중했고 이후 토큰 사이즈를 2로 바꾸고 한글자 검색 시에는 검색이 이루어지지 않도록 설정했다.

성공적으로 검색 기능 구현을 마칠 수 있었다 ~~! 🥳

검색 태그