MySQL LIKE 대신 Full-Text Index 쓰는 이유

2024-10-09
#JAVA
4

이번에 데이터베이스 수업을 듣는 중에 LIKE를 배우다가 교수님께서 검색기능에 LIKE를 쓰는건 아주 멍청한 짓이다 라는 말을 듣고 아차! 싶었다.

 

우리가 개발 중인 서비스에서 교수님이 하지말라던 코드와 일치하는 코드를 사용하고 있었다.

 

그래서 우리는 검색 기능을 최적화하기 위해 새로운 방법을 고민하게 된다.

 

우리는 여러 방법을 고민하다가 엄청난 천재 팀원의 의견으로 풀텍스트 인덱스라는 기능을 알게되었다.

 

Full-Text Index

기본적인 like 방식은 우리가 수업시간에 배웠던 것처럼 풀스캔을 하기 때문에 데이터의 양이 많아질수록 시간도 엄청나게 늘어난다.

 

하지만 풀텍스트 인덱스는 우리가 찾고자하는 column을 풀텍스트 인덱스로 설정해준다면, 문자열이 정해진 방법대로 분리되어 인덱스를 생성하여 빠르게 검색할 수 있다.

 

인덱스를 생성하는 방법에는 여러가지 방법이 있다.

그 중 우리는 두가지 방법을 살펴볼 것이다.

 

Built-In Parser

이 방식은 구분자(StopWord)를 기준으로 인덱스를 추출하는 방식이다.

즉, 공백, 문장 기호 혹은 사용자가 지정한 특정 단어를 기준으로 나눈다.

예를 들자면,

대박스프링 공부 진짜 하기 싫다 → 대박스프링 | 공부 | 진짜 | 하기 | 싫다

이렇게 나눠지는 것이다.

이 방법은 효율적으로 보일수도 있지만, 위와 같은 경우에서는 “스프링” 또는 “박스”와 같은 단어로는 검색이 불가능한 것이다.

왜냐하면 풀텍스트 검색은 키워드가 전부 일치하거나, prefix가 일치한 경우에 가져오기 때문이다.

 

또한, 이 기능은 중국어와 같이 구분자를 사용하지 않는 언어에서는 사용할 수가 없다.

N-Gram Parser

이 기능은 n이라는 사이즈를 기준으로 키워드를 추출하는 것이다.

n이 2인 상황을 예로 들자면,

대박스프링 공부 진짜 하기 싫다 → 대박 | 박스 | 스프 | 프링 | 공부 | 진짜 | 하기 | 싫다

와 같이 나눠지는 것이다.

 

따라서 ngram은 built-In에 비해 더 자세한 검색이 가능하다고 할 수 있다.

 

 

검색을 하는 방식에도 여러가지가 존재한다.

Natural Language Mode

해당 방식은 검색할 키워드를 또 작은 사이즈로 분리하여 분리된 단어에서 하나라도 포함되는 데이터를 모두 찾아온다.

이 방식에서는 mysql이 검색어를 분석하여 ‘and’, ‘the’, ‘in’ 등과 같은 단어들은 무시하고 의미있는 단어들을 키워드로 찾는다고 한다.

검색 결과는 관련성 순으로 정렬되고, 일치하는 단어가 많을수록 더 높은 우선순위를 가진다.

이 방식이 풀텍스트 검색의 기본 방식이다.

 

Boolean Mode

이 방식은 추가적인 검색 규칙을 적용하여 검색을 한다.

이런 추가적인 연산자에는 논리 연산자 (And, Or, Not) 과 와일드카드와 같은 연산자가 있다.

이 방식에서는 사용자가 논리적인 검색 표현식을 사용하여 쿼리를 작성할 수 있다.

예를 들자면,

Sql
SELECT * FROM ~~ WHERE MATCH(text) AGAINST('+A -B' IN BOOLEAN MODE);

위의 쿼리문은 A는 포함하지만, B는 포함하지 않는 데이터를 가져오라는 말이다.

+검색 필수 SELECT * FROM newspaper WHERE MATCH(article) AGAINST('영화 +액션' IN BOOLEAN MODE); > 영화를 찾되 반드시 액션이 들어가 있는 열
- 검색 제외 SELECT * FROM newspaper WHERE MATCH(article) AGAINST('영화 -액션' IN BOOLEAN MODE); > 영화를 찾되 액션은 안들어가있는 열
~검색 부정( - 보다 부드러운 방식 ) SELECT * FROM newspaper WHERE MATCH(article) AGAINST('영화 ~액션' IN BOOLEAN MODE); > ‘영화’를 찾되 ‘액션’이 없는 열보다 ‘액션’이 있는 열이 아래 순위
*부분 검색 SELECT * FROM newspaper WHERE MATCH(article) AGAINST('영화*' IN BOOLEAN MODE); > ‘영화를’, ‘영화가’, ‘영화는’ 등
부분 검색 “” 안에 있는 구문과 정확히 동일한 철자의 구문 SELECT * FROM newspaperWHERE MATCH(article) AGAINST("재밌는 영화" IN BOOLEAN MODE); > “재밌는 영화”, “재밌는 영화가” 등 > “재밌는 한국 영화”, “재밌는 할리우드 영화” 불가

검색 결과는 정확하게 일치하는 문서 혹은 조건에 맞는 문서만 반환된다.

 

뭘로 해야할까?

만약 우리가 Built-In Parser를 사용한다고 가정하자.

그때, ‘한국에서 제일’ 이라는 문장을 검색하고 싶어서 우리가 키워드를 ‘한국’으로 해서 검색을 한다면 해당 문장을 검색할 수 없다.

Built-In Parser는 띄어쓰기를 기준으로 나누기 때문에 우리가 저 문장을 얻고 싶다면, ‘한국에서’로 검색해야 하는 것이다.

따라서 한글로 검색을 한다면 Built-In은 별로 좋은 선택지가 아닐 것이다.

 

따라서 대부분의 사람들은 N-Gram Parser를 사용한다.

 

따라서 우리는 n-gram parser 와 natural language mode를 사용하여 개발을 하였다.

 

개발하기 전 mysql로 간단하게 테스트를 해보면 약 40,000개의 데이터를 넣고 테스트를 해본 결과

아래와 같이 대략 3배정도 빨리진 것을 볼 수 있다.

 

 

 

페이지 최적화

페이지를 또 쓰다보니까 페이지를 어떻게 최적화할 방법은 없을까 라는 생각이 들었다.

 

페이지 쿼리는 아래와 같이 두번의 쿼리가 날아가게 되어있다.

Sql
-- 데이터 조회 쿼리 SELECT usermodel0_.user_id AS user_id1_0_, usermodel0_.email AS email2_0_, usermodel0_.password AS password3_0_ FROM users usermodel0_ ORDER BY usermodel0_.user_id ASC LIMIT 2 OFFSET 2; -- 전체 행 수 계산 쿼리 SELECT COUNT(usermodel0_.user_id) AS col_0_0_ FROM users usermodel0_;

 

따라서, 전체 데이터의 수를 계산하는 쿼리가 하나 더 날아가기 때문에 시간이 조금이라도 더 걸릴 수 밖에 없다.

 

예를 들어, 데이터 조회 쿼리가 1000ms 가 걸리고 전체 데이터 계산 쿼리가 500ms 라면 전체 페이지를 반환하는 쿼리는 총 1500ms가 걸리는 것이다.

 

그럼 이것을 어떻게 최적화할 수 있을까?

 

전에 얘기했듯이, Slice를 사용하면 된다.

Slice는 전체 데이터의 수를 파악하지 않기 때문에 조회 쿼리만 나가는 것이다.

 

근데 난 죽어도 Slice는 쓰기 싫어!

편하게 페이지 쓰고싶어!!!

 

라고 한다면 다른 방법이 있다.

 

바로 병렬 처리를 하는 것이다.

아래는 기존에 코틀린으로 작성되어 있던 코드를 자바로 대충 작성해보았다.

실제로 동작할지는 모름

그냥 이런 방식이 있구나 하고 봐주면 좋겠다.

Sql
@Async //데이터 조회 쿼리 public CompletableFuture<List<Order>> findContent(Pageable pageable, String address) { QOrder order = QOrder.order; QUser user = QUser.user; QCoupon coupon = QCoupon.coupon; List<Order> content = queryFactory .select(order) .from(order) .innerJoin(user).on(order.userId.eq(user.id)) .leftJoin(coupon).on(order.couponId.eq(coupon.id)) .where(order.address.eq(address)) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); return CompletableFuture.completedFuture(content); } @Async //데이터 개수 세는 쿼리 public CompletableFuture<Long> findTotalCount(String address) { QOrder order = QOrder.order; Long totalCount = queryFactory .select(order.count()) .from(order) .where(order.address.eq(address)) .fetchOne(); return CompletableFuture.completedFuture(totalCount); } @Override //페이지로 반환 public Page<Order> findPagingBy(Pageable pageable, String address) { try { // 두 쿼리를 비동기적으로 실행하고 결과를 기다림 CompletableFuture<List<Order>> contentFuture = findContent(pageable, address); CompletableFuture<Long> totalCountFuture = findTotalCount(address); // 두 CompletableFuture가 모두 완료될 때까지 기다림 CompletableFuture.allOf(contentFuture, totalCountFuture).join(); List<Order> content = contentFuture.get(); Long totalCount = totalCountFuture.get(); return new PageImpl<>(content, pageable, totalCount); } catch (Exception e) { throw new RuntimeException("Failed to execute queries asynchronously", e); } }

이렇게 처리를 해준다면 기존에 총 1500ms가 걸리던 시간이 1000ms로 끝낼 수 있는 것이다.

 

사실 이것까지 프로젝트에 적용시키는 것은 너무 투머치인 것 같아서 적용하지 않았다.