이전 포스팅에서 쿼리 개선을 통해 검색 API를 개선했다.
하지만 여전히 속도가 느렸고 해당 API는 사용자가 회원가입을 하지 않아도 볼 수 있는 유일한 페이지에 사용되기 때문에 이 부분에서 속도가 너무 오래 걸릴 경우 서비스 이탈율이 심각해질 것이라고 생각했다.
기존 구현
기존 검색 쿼리는 동적으로 검색 조건 쿼리를 생성하는데, 그중에서도 키워드 조건이 들어올 경우 Like 절을 통해 4가지의 컬럼에 대해 스캔을 한다.
다른 조건은 모두 id 값을 기준으로 조회하기 때문에, 인덱스를 탈 수 있지만 Like 절은 전방일치만 인덱스를 타기 탈 수 있다.
하지만 우리 요구사항에는 %moly% 처럼 전체 포함된 것을 검색해야 했기에 적절하지 않았다.
실제로 10만건의 템플릿, 30만건의 소스코드의 더미데이터를 넣고 확인해보면 두 컬럼 모두에 대해 풀스캔하고 있는 것을 알 수 있다.
따라서 MySQL의 전문 검색 인덱스를 사용하여 키워드 검색을 개선하고자 했다.
전문 검색(full-text search) 도입하기
전문 검색은 텍스트 검색에서 컴퓨터에 저장된 문서 또는 전문 데이터베이스를 검색하는 기법이다.
전문 검색은 메타데이터 기반 검색이라든지 데이터베이스에 표현된 원문의 일부에 기반한 검색과는 조금 다르다.
전문 검색에서 검색 엔진은 저장된 모든 문서의 내용을 검사하며 예를 들어 사용자가 지정한 텍스트를 검색하는 등 검색 기준의 일치를 시도한다.
MySQL 전문 검색 인덱스
MySQL를 포함한 일부 DBMS는 전문 검색을 위해 인덱스를 제공한다.
전문 검색 인덱스의 장점
전문 검색 인덱스는 일반적인 인덱스와 달리 역 인덱스 방식을 사용한다.
역인덱스 방식이란 쉽게 말하면 문서가 토큰에 대해 인덱스 정보를 가지고 있는 것이 아닌, 토큰이 자신이 포함된 문서 목록을 가지고 있는 것이다.
- 일반 인덱스 구조: 문서 ID -> 문서 내용
- 검색 과정: 모든 문서를 순회하며 검색어 포함 여부 확인
- 역인덱스 구조: 단어(토큰) -> 해당 단어를 포함하는 문서 ID
- 목록 검색 과정: 검색어에 해당하는 단어의 문서 목록을 직접 조회
전문 검색 인덱스 적용하기 전
전문 검색 인덱스를 프로젝트에 적용하려면서 몇 가지의 문제 상황들을 마주했다.
문제 1. JPA는 전문 검색 인덱스를 지원하지 않는다.
우리 서비스는 JPA 기반으로 영속 계층에 데이터를 저장하고 있었다.
그런데 JPA는 전문 검색 인덱스를 지원하지 않는다. 따라서 우리가 선택할 수 있는 방법은 2가지 였다.
- 전문 검색 인덱스를 활용하는 쿼리를 직접 JPQL을 작성
- 전문 검색 인덱스를 활용하는 쿼리를 MySQLDialect을 상속받은 클래스를 통해 커스텀 함수로 등록
문제 2. 기존 검색 API는 JPA의 동적 쿼리 기능인 Specification을 사용하고 있다.
그런데 기존 검색 쿼리가 동적으로 생성되어 JPA의 Specification 을 사용하고 있었다.
Specification과 JPQL은 동시 사용이 불가능하기 때문에 2번 방식인 커스텀 MySQLDialect 방식을 선택하였다.
전문 검색 인덱스 적용하기
1. 인덱스 쿼리 날리기
먼저 검색에 사용할 컬럼에 인덱스를 다음과 같이 넣어줘야 한다.
ALTER TABLE template ADD FULLTEXT INDEX idx_template_fulltext (title, description);
ALTER TABLE source_code ADD FULLTEXT INDEX idx_source_code_fulltext (content, filename);
인덱스를 조회하게 되면 INDEX_TYPE이 FULLTEXT인 것을 확인할 수 있다.
2. Match 함수를 사용할 수 있도록 MySQLDialect 구현체 생성
앞서 말한듯 전문 검색 인덱스를 사용하려면 다음과 같은 MATCH 함수를 사용한 쿼리가 필요하다.
SELECT * FROM articles WHERE MATCH(title, body) AGAINST('database');
하지만 JPA는 검색 인덱스에 관련 메서드를 제공하지 않기 때문에 MySQL의 MATCH 함수를 Hibernate에서 사용할 수 있도록 MySQLDialect를 상속한 객체를 만들어줘야 한다.
그 후 객체 내에서 Match 절을 사용하기 위해 함수를 직접 작성해준다.
public class FullTextSearchMySQLDialect extends MySQLDialect {
@Override
public void initializeFunctionRegistry(FunctionContributions functionContributions) {
super.initializeFunctionRegistry(functionContributions);
SqmFunctionRegistry functionRegistry = functionContributions.getFunctionRegistry();
// MATCH 라는 사용자 정의 메서드를 등록
functionRegistry.register("match", ExactPhraseMatchFunction.INSTANCE);
}
public static class ExactPhraseMatchFunction extends NamedSqmFunctionDescriptor {
public static final ExactPhraseMatchFunction INSTANCE = new ExactPhraseMatchFunction();
public ExactPhraseMatchFunction() {
super("MATCH", false, StandardArgumentsValidators.exactly(3), null);
}
@Override
public void render(
SqlAppender sqlAppender,
List<? extends SqlAstNode> arguments,
ReturnableType<?> returnType,
SqlAstTranslator<?> translator
) {
sqlAppender.appendSql("MATCH("); // match 구문 작성
translator.render(arguments.get(0), SqlAstNodeRenderingMode.DEFAULT); // 검색 조건 등록용
sqlAppender.appendSql(", ");
translator.render(arguments.get(1), SqlAstNodeRenderingMode.DEFAULT); // 검색 컬럼 등록용
sqlAppender.appendSql(") AGAINST (");
translator.render(arguments.get(2), SqlAstNodeRenderingMode.DEFAULT); // 검색 컬럼 등록용
sqlAppender.appendSql(" IN NATURAL LANGUAGE MODE)");
}
}
}
이때 주의할 점은, JPA단에서 제공되는 게 아닌 MySQL의 MATCH AGAINST 기능은 데이터베이스 엔진 레벨에서 동작한다는 것이다. 즉 다시 말해 JPA나 Hibernate의 영속성 컨텍스트와는 독립적으로 작동한다. 따라서 Match 절이 작동하려면 데이터베이스 단까지 가야한다. 영속성 컨텍스트는 이걸 모른다!
(테스트 시에 이 사실을 간과해서 고생했다... 한 테스트 메서드 내에서 같은 트랜잭션으로 묶여 저장하고 바로 조회하니까 저장 쿼리는 영속성 컨텍스트에 있고 검색 쿼리는 못 찾아서 자꾸 에러가 발생했다 ㅠ)
3. Dialect 등록
마지막으로 Hibernate가 사용할 데이터베이스 방언(dialect)에 위에서 작성한 커스텀 객체를 등록해주면 된다.
spring:
jpa:
properties:
hibernate:
dialect: codezap.template.repository.FullTextSearchMySQLDialect
개선 전후 비교
이렇게 등록을 통해 변경한 쿼리를 재실행해보면 인덱스를 타고
최초의 Like 쿼리를 사용했을 때의 총 소요 시간이 533ms이였던 것에 반해 27.2ms로 줄어든 것을 확인할 수 있다.
여전히 개선해야 할 부분이 많기 때문에 3탄에서 이어나가도록 하겠다.
'우아한테크코스 6기 > 4단계' 카테고리의 다른 글
고가용성과 SPOF (0) | 2024.10.01 |
---|---|
JPA에서 Full Text Index 사용하기 (+ 테스트 시 주의점) (0) | 2024.10.01 |
API 성능 개선하기 1탄 (feat. N + 1과 불필요한 쿼리 개선) (0) | 2024.09.25 |
ApplicationContext vs ServletContext (0) | 2024.09.17 |
Tomcat의 Coyote와 Catalina에 대해 알아보자 (1) | 2024.09.08 |