검색 기능의 성능 이슈로 Full Text Index를 사용하기로 하면서 적용 방법에 대해 알아보았다.
Full Text Index는 MySQL 등 DBMS에서 제공하는 전문 검색용 인덱스이다.
하지만 이 기능을 도입하면서 몇몇 문제 상황을 마주쳤는데, 어떻게 해결하였는지 작성하고자 한다.
참고
기존 구현은 QueryDSL이 아닌 Specification과 Criteria API를 Like 쿼리로 사용하여 구현이 되어있었다.
if (keyword != null && !keyword.trim().isEmpty()) {
String likeKeyword = "%" + keyword.trim() + "%";
Predicate titlePredicate = criteriaBuilder.like(root.get("title"), likeKeyword);
Predicate descriptionPredicate = criteriaBuilder.like(root.get("description"), likeKeyword);
}
문제발생 🚨 JPA는 전문 검색 인덱스를 지원하지 않는다.
가장 큰 문제는 전문 검색 인덱스를 디비단에 넣어도 JPA에서 사용할 수 없다는 것이었다.
정확히 말하면 MySQL의 전문 검색 인덱스를 사용하려면 MATCH ... AGAINST 구문으로 조회해야 한다.
그런데 이 MATCH ... AGAINST 구문을 지원하지 않는 것이었다.
Hibernate의 MySQLDialect 커스텀하기
따라서 Hibernate의 MySQLDialect를 사용하여 직접 사용할 문법을 함수로 만들어야 했다.
"검색 키워드를 인자로 받아 MATCH ... AGAINST 을 적용하는 것을 "MATCH"라는 SQL 함수로 등록한다" 라고 생각하면 된다.
코드
// MySQLDialect를 확장한 FullText 검색을 위한 Dialect 클래스
public class FullTextSearchMySQLDialect extends MySQLDialect {
@Override
public void initializeFunctionRegistry(FunctionContributions functionContributions) {
super.initializeFunctionRegistry(functionContributions); // 부모 클래스의 함수 등록 실행
SqmFunctionRegistry functionRegistry = functionContributions.getFunctionRegistry();
functionRegistry.register("match", ExactPhraseMatchFunction.INSTANCE);
// 'match' 함수를 ExactPhraseMatchFunction으로 등록
}
// MySQL의 MATCH AGAINST 구문을 구현하는 내부 클래스
public static class ExactPhraseMatchFunction extends NamedSqmFunctionDescriptor {
public static final ExactPhraseMatchFunction INSTANCE = new ExactPhraseMatchFunction();
// 싱글톤 패턴으로 인스턴스 생성
public ExactPhraseMatchFunction() {
super("MATCH", false, StandardArgumentsValidators.exactly(3), null);
// MATCH 함수명 지정, 정확히 3개의 인자를 받도록 설정
}
@Override
public void render(SqlAppender sqlAppender, List<? extends SqlAstNode> arguments,
ReturnableType<?> returnType, SqlAstTranslator<?> translator) {
// MySQL의 전문 검색 구문 생성:
// MATCH(column1, column2) AGAINST (search_term IN NATURAL LANGUAGE MODE)
sqlAppender.appendSql("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)");
// NATURAL LANGUAGE MODE로 전문 검색 수행
}
}
}
Hibernate의 커스텀 방언 등록하기
JPA는 특정 데이터베이스에 종속되지 않고 사용할 수 있도록 설계되어 있다.
이를 위해 Hibernate는 각 데이터베이스별로 SQL 문법의 차이를 추상화한 방언(Dialect)을 제공하는데, 앞서 커스텀한 방언을 Hibernate는 알 방법이 없기 때문에 직접 등록해주어야 한다.
# application.yml
jpa:
properties:
hibernate:
dialect: codezap.template.repository.FullTextSearchMySQLDialect
등록한 함수 호출하여 실행하기
함수를 등록하였으니 이제 사용만 하면된다.
criteria 기준으로는 다음과 같이 함수를 저장하고, 함수에 필요한 변수를 전달할 수 있다.
if (keyword != null && !keyword.trim().isEmpty()) {
String searchKeyword = keyword.trim();
Predicate titleDescriptionPredicate = criteriaBuilder.isTrue(
criteriaBuilder.function("MATCH", Boolean.class, // 등록한 함수 호출
root.get("title"),
root.get("description"),
criteriaBuilder.literal(searchKeyword)));
Subquery<Long> sourceCodeSubquery = query.subquery(Long.class);
Root<SourceCode> sourceCodeRoot = sourceCodeSubquery.from(SourceCode.class);
sourceCodeSubquery.select(sourceCodeRoot.get("template").get("id"));
sourceCodeSubquery.where(criteriaBuilder.isTrue(
criteriaBuilder.function("MATCH", Boolean.class, // 등록한 함수 호출
sourceCodeRoot.get("content"),
sourceCodeRoot.get("filename"),
criteriaBuilder.literal(searchKeyword))));
predicates.add(criteriaBuilder.or(titleDescriptionPredicate, root.get("id").in(sourceCodeSubquery)));
}
테스트 시 주의점
검색에 대해 레포지토리 테스트가 있었는데 키워드 관련 조건이 있는 경우 모두 실패했다.
즉, 전문 검색 인덱스를 사용하면 실패하는 것이었다.
기존 테스트는 테스트 메서드 내에서 데이터를 저장하고 조회하였는데, 이 저장 부분이 문제였다.
@Test
@DisplayName("검색 테스트: 키워드로 템플릿 조회 성공")
void testFindByKeyword() {
// 더미데이터 저장
saveTwoMembers();
saveTwoCategory();
saveTwoTags();
saveThreeTemplates();
saveTemplateTags();
String keyword = "Template";
Specification<Template> spec = new TemplateSpecification(null, keyword, null, null);
Page<Template> result = templateRepository.findAll(spec, PageRequest.of(0, 10));
assertAll(
() -> assertThat(result.getContent())
.allMatch(template -> template.getTitle().contains(keyword)
|| template.getDescription().contains(keyword)),
() -> assertThat(result.getContent()).hasSize(3)
);
}
@DataJpaTest 을 사용한 이 테스트는 내부적으로 @Transactional을 부착하고 있는데, 따라서 영속성 컨텍스트의 변경사항은 트랜잭션이 커밋되는 시점에 flush되어 데이터베이스에 반영된다.
즉,테스트 내에서 저장한 데이터는 실제로 데이터베이스에 반영되지 않았던 것이다.
앞에서 등록한 하이버네이트 방언은 데이터베이스 엔진 단에서 처리를 해주는 것이기 때문에 반드시 데이터베이스에 저장된 컬럼만 인덱스 적용이 가능하다.
결국 테스트 메서드 내부에서 저장하는 방식이 아닌, @Sql 을 통해 미리 DB단에 더미데이터를 저장한 후, 조회하는 방식으로 변경했다!
다들 참고~
'우아한테크코스 6기 > 4단계' 카테고리의 다른 글
Connection Pool과 HikariCP에 대해 알아보자 (0) | 2024.10.09 |
---|---|
고가용성과 SPOF (0) | 2024.10.01 |
API 성능 개선하기 2탄 (feat. 검색 전문 인덱스 적용하기) (1) | 2024.09.28 |
API 성능 개선하기 1탄 (feat. N + 1과 불필요한 쿼리 개선) (0) | 2024.09.25 |
ApplicationContext vs ServletContext (0) | 2024.09.17 |