20221002_WIL(실전 프로젝트 6주차 회고 및 검색 메서드 리팩터링)
20221002_WIL(실전 프로젝트 6주차 회고 및 검색 메서드 리팩터링)
들어가며
이제 차주 금요일이면 최종 발표로써 그간 열심히 달려왔던 실전 프로젝트 일정도 마무리가 된다. 오늘은 금주 진행하였던 검색 메서드 리팩터링 과정을 정리해보고자 한다.
검색 메서드 리팩터링(Full Text Index를 바탕으로)
ItemRepository - 상품 검색 메서드
/**
* 상품 기본 검색 - 최신순 정렬
*/
@Query("select i from Item i " +
"where i.title like %:keyword% or i.content like %:keyword% or " +
"i.itemCategory like %:keyword% or i.petCategory like %:keyword% " +
"order by i.createdAt desc ")
List<Item> getAllItemListByTitleOrContent(String keyword);
기존에 작성했던 검색 메서드의 코드는 위와 같다. 가장 기초적인 형태로 SQL의 like문과 input 앞 뒤로 % - 와일드카드가 감싸 져 있는 형태이다. 이럴 경우 Full Table Scan이 일어나기에 현재는 괜찮지만 DB 내 데이터가 증가할수록 속도가 상대적으로 저하될 수밖에 없는 방법이었다.
검색 메서드와 관련하여서는 지속적으로 인덱스를 걸어보라는 조언을 들었었고, 중간발표 이후 추가적인 기능 개발보다는 기존의 코드들을 리팩터링해나가며 이번에는 검색 메서드를 인덱스를 바탕으로 리팩터링 해보고자 하였다.
Index
Full Table Scan
순차 접근.
원하는 데이터를 얻기 위하여 데이터가 저장된 목록 중 모든 데이터 요소를 차례로 조사하여 원하는 데이터를 찾아 내는 방식.
(1억 건의 데이터가 있다면 1억 번의 데이터 조회가 발생)
Index란?
Index란 일반적으로 책에서 말하는 목차와 같다. 원하는 데이터(내용)을 얻기 위해 책을 처음부터 끝까지 훑는 것은 상당히 비효율적이다. 목차를 보고 원하는 데이터를 얻는 방식, 그것이 바로 Indexing의 목적이다.
추가적인 쓰기 작업과 저장 공간을 활영하여 DB Table의 검색 속도를 항상 시키기 위한 자료구조를 뜻한다.
인덱스 검색에는 여러 알고리즘 방식이 사용되나 현재 가장 대중적으로 사용되는 방식은 Balaced-Tree(B-Tree)이다.
또 Index의 구조는 Clustered Index와 NonClustered Index로 나뉜다.
B-Tree
기존 이진 트리( Binary Tree) 방식을 개선하기 위해 등장. 하나의 노드가 가질 수 있는 자식 노드의 수가 2보다 큰 트리 구조.
정렬된 데이터를 유지.
Full Text Index
전문 검색. 한 칼럼 안에 많은 양의 데이터가 담겨있어 보다 효율적으로 데이터를 찾기 위해 사용하는 방식.
데이터를 토큰 단위로 쪼개어 검색을 용이하게 함.
Full Text Index에서는 사용자가 보다 효율적으로 검색을 할 수 있도록 2가지 인덱싱 기법을 제공하는데 하나는 공백이나 DB방언으로 지정된 Stop-Word를 바탕으로 데이터를 쪼개는 방식이며 다른 하나는 오늘 다룰 Ngram을 통한 방법이다.
Ngram Parser
n-gram이란 문자열을 N개의 기준으로 절단하는 방식이다. 만약 "가나다라"라는 문자열을 2-gram 방식으로 parser 하게 되면 "가나", "나다", "다라"라는 토큰으로 쪼개어지게 되는 것이다.
(Ngram Parser의 기본 토큰 사이즈는 2이다.)
토큰 사이즈 예시
n = 1 : '가', '나', '다', '라'
n = 2 : '가나', '나다, '다라'
n = 3 : '가나다', '나다라'
n = 4 : '가나다라'
Clustered Index
이번 포스팅의 주 목적은 Index에 대해 파고드는 것이 아니기에 Clustered Index와 NonClustered Index에 대해 깊이 파고들지는 않고 필수적으로 알아야 될 부분만 정의하고 넘어가고자 한다.
Clustered Index는 군집화된 인덱스를 의미하며 특정 컬럼(PK 값)을 바탕으로 정렬된다. 또한 Data Page와 Leaf Level이 동일하다는 클러스터형 인덱스는 검색 속도를 향상시킨다는 장점이 있지만, id 값을 바탕으로 정렬되기에 새로운 데이터를 삽입할 때 많은 비용이 소모된다는 단점 또한 존재한다.

NonClustered Index
NonClustered Index는 클러스터형 인덱스와 다르게 데이터 정렬이 되어있지 않으며 Leaf level과 Data Page가 구분되어 있다. 따라서 별도의 저장공간(약 10%)이 필요하다. 검색 속도 또한 클러스터형 인덱스보다 한 단계 더 요구되기에 상대적으로 조금 느리다. 하지만 추가적인 데이터 삽입에 있어서 과도한 데이터 정렬이 요구되지 않기에 클러스트형 인덱스에 비해서는 상대적으로 적은 비용 - 성능 하락이 덜 하다.

Reference
위 내용을 바탕으로 검색 기능 향상을 위해 Full Text Index를 생성해주었다. 우리의 멍냥 마켓에서의 검색 기능은 사용자 편의를 고려하여 title, content, itemCategory(상품 카테고리), petCategory(펫 카테고리)의 내용을 바탕으로 검색을 실시하기에 FT_INDEX라는 이름은 인덱스를 생성하며 해당 칼럼들에 대한 인덱스도 모두 생성해주었다.
CREATE FULLTEXT INDEX FT_INDEX ON item(TITLE, CONTENT, ITEM_CATEGORY, PET_CATEGORY) WITH PARSER ngram;
MySQL MATCH() AGAINST()
Full Text Search를 하는 방식은 MATCH() AGAINST() 함수를 이용하는 방법이 있다.
select * from match table_name where match('column_name') against ('input' in natural language mode);
Query 문은 위와 같이 작성한다. 기본적으로 'input'의 내용과 일치하는 데이터를 가져온다고 보면 된다.
추가로 눈여겨 봐야할 부분이 쿼리 메서드 마지막에 오는 mode이다.
natural language mode(natural search)
자연어 검색에서는 검색 문자열을 단어 단위로 분리한 후, 해당 단어 중 하나라도 포함되는 행을 찾는다.
별도의 옵션을 지칭하지 않으면 기본적으로 자연어 검색 모드로 검색하게 된다.
boolean mode(boolean search)
불린 모드 검색은 검색 문자열을 단어 단위로 분리한 후, 해당 단어가 포함되는 행을 찾는 규칙을 추가적으로 적용하여 해당 규칙에 매칭 되는 행을 찾는다.
Operator | Description |
+ | AND, 반드시 포함하는 단어 |
– | NOT, 반드시 제외하는 단어 |
> | 포함하며, 검색 순위를 높일 단어+mysql >tutorial : mysql과 tutorial가 포함하는 행을 찾을 때, tutorial이 포함되면 검색 랭킹이 높아짐 |
< | 포함하되,검색 순위를 낮출 단어+mysql <training : mysql과 training가 포함하는 행을 찾지만, training이 포함되면 검색 랭킹이 낮아짐 |
() | 하위 표현식으로 그룹화 (포함, 제외, 순위 지정 등)+mysql +(>tutorial <training) : mysql AND tutorial, mysql AND training 이지만, tutorial의 우선순위가 더욱 높게 지정 |
~ | Negate. '-' 연산자와 비슷하지만 제외 시키지는 않고 검색 조건을 낮춤 |
* | Wildcard. 와일드카드 my* : mysql, mybatis 등 my 뒤의 와일드 카드로 붙음 |
“” | 구문 정의 |
추가적으로 자연어 검색을 확장한 with Query Expansion(쿼리 확장 검색) 방식도 존재하지만 본 포스팅에서는 boolean mode을 적용할 것이기에 일단은 넘어가려 한다.
위의 Full Text Index 생성하는 방법으로 MySQL나 DBeaver와 같은 DBeaver와 같은 데이터 관리 도구를 이용하여 Full Text Index를 생성해주고 Native Query 문을 작성하는 것으로 기존 진행하고 있던 프로젝트에 바로 Full Text Search를 도입하는 것도 가능하지만 JPA의 장점을 최대한 헤치지 않으며 개발하고자 기존 적용해주었던 QueryDSL를 통해서 검색 메서드를 개선시켜 보고자 하였다.
MATCH() AGAINST() 함수 QueryDSL를 활용하여 프로젝트에 적용하기
JPQL에서는 MATCH() 함수을 지원하지 않기에 MATCH() 함수를 사용하기 위한 추가 작업이 필요하였다. 그것은 바로 커스텀 Dialect 클래스를 생성하여 MATCH() 함수에 대한 SQL function template를 추가하는 것이다.
dialect
dialect는 방언을 의미한다. hibernate에서는 기본적으로 DB 별 맞춤 dialect를 제공하고 있다. 하지만 앞서 언급했던 바와 같이 JPQL에서는 MySQL에서의 MATCH() 함수를 제공하고 있지 않았기에 기존의 dialect class를 상속받아 커스텀 Dialect 클래스를 생성해주어야 한다.
MysqlDialect(커스텀 dialect clas)
public class MysqlDialect extends MySQL8Dialect { /* 기존 MySqlDialect 클래스를 상속받아 Custom 하는 클래스 */
public MysqlDialect() { /* MATCH 함수 사용 위함 */
super(); /* 권한 부여 */
registerFunction( /* registerFunction - MySQL에 등록된 메서드를 사용할 수 있게끔 해주는 메서드 */
"match", /* match 함수 return type -> double, match(4개의 args) against(input 값 in boolean mode)로 사용핟도록 세팅 */
new SQLFunctionTemplate(StandardBasicTypes.DOUBLE, "match(?1, ?2, ?3, ?4) against (?5 in boolean mode)")
);
}
}
그리고 프로퍼티 파일에도 해당 커스텀 dialect class를 적용시켜주어야 한다.
application.propertiry
spring.jpa.properties.hibernate.dialect=com.hanghae.mungnayng.util.MysqlDialect
위와 같은 설정이 마무리가 되었다면 QueryDsl 클래스에서 MATCH() 함수를 사용하는 검색 메서드를 작성해줄 수가 있다.
ItemQueryRepository
/**
* 검색 쿼리 메서드
*/
public List<Item> getItemListParseredByNgram(String keyword, String target) {
/* Dialect Class에서 만든 registerFunction() 메서드 사용하여 디테일한 부분 설정 */
NumberTemplate<Double> booleanTemplate = Expressions.numberTemplate(Double.class,
"function('match',{0},{1},{2},{3},{4})", item.title, item.content, item.itemCategory, item.petCategory, "+" + keyword + "*");
/* 동적 정렬 위한 OrderSpecifier 가구현 - TODO :: 추후 FE와 논의 후 리팩터링 필요 */
OrderSpecifier<?> orderSpecifier = item.viewCnt.desc();
if (target.equals("item.id")) {
orderSpecifier = item.id.desc();
}
return jpaQueryFactory.select(item)
.from(item)
.where(booleanTemplate.gt(0))
.orderBy(orderSpecifier)
.fetch();
}
이를 토대로 기존의 검색 메서드를 리팩터링 할 수 있었다. 하지만 이마저도 아직 절대 완벽한 것이 아니다. 동적 정렬을 위한 OrderSpecifier도 제대로 사용한 것이 아닌 임시방편으로 사용한 것이기에 굉장히 불완전한 코드이기에 추가적인 학습이 필요한 부분이다. 또 최종적으로는 검색 기능의 확실한 향상을 위하여 Elastic Search를 도입해볼 필요가 있다고 판단된다. 다만 금번 프로젝트의 경우 금주가 최종 발표이기에 당장은 어려운 부분이기는 하지만.
위와 같은 리팩터링을 바탕으로 현재 멍냥마켓 수준의 DB에서는 검색 기능이 향상된 것이 확인되지 않는다. 하지만 DB 데이터가 커지면 커질수록 Full Table Scan의 비효율성은 급격하게 증가하기에 추후 더미 데이터를 통한 Test DB를 생성하여 DB 데이터가 증가하였을 때, 기존의 검색 메서드와 비교하여 얼마나 속도가 증가하였는지 확인해볼 예정이다.
Reference
RyanGomdoriPooh - MySQL Full Text Search Index 사용하기
intrepidgeeks - MySQL Full Text Search
ENFJ dev - MySQL Full Text Search, 제대로 이해하기
논리적 코딩 - [QueryDSL] JPA에서 MySQL 비트연산하는 방법
이제 정말 실전 프로젝트도 마무리 되어가고 있다. 중간발표 때 사전 준비가 잘 되지 않아 아쉬움이 많이 남았던 만큼 이번에는 최종 발표에 대한 준비를 확실히 하여 아쉬움이 남지 않도록 최선을 다해보자.
조금만 더 파이팅해보자!