MySQL8.0 에서 부분 문자열을 포함한 데이터 조회 기능에 성능 저하 문제를 해결한 과정에 대한 글입니다.(온몸을 비틀어서 개선한..)
결과적으로 약 30만 건의 데이터가 있는 테이블에서의 검색 성능이
약 18배 개선
되었습니다.
(870 ms -> 48 ms)
부분 문자열을 포함한 데이터 조회 기능에 성능 저하 문제가 발생해습니다.
SQL의 where절에 ‘%검색할문자%’
와 같이 조건을 사용해 인덱스가 활용되지 않고
있어 발생한 문제였습니다.
WHERE goods_name LIKE '%검색할문자열%'
최근에는 ElasticSearch나 ApachSolr와 같은 검색에 특화된 엔진을 함께 활용해 검색 성능을 확보하는 경우가 많습니다.
하지만 해당 툴을 바로 사용할 수 없는 상황(학습이 필요한 경우 등..) 이었습니다.
N-gram
과 MySQL8.0의 Full-Text Index
를 사용해 문자열 검색 성능을 개선했습니다.
N-gram은 문자열을 일정 크기(N)로 쪼개서 연속된 문자열 조각들을 추출하는 방법을 말합니다.
문자열: "검색할문자열"
3-gram일 때: "검색할", "색할문", "할문자", "문자열"
이처럼 N-gram은 검색어를 작은 단위로 쪼개어 인덱스를 구성하므로,
다음과 같은 장점
이 있습니다:
단점
으로는, 문자열이 길어질수록 조각이 매우 많아지기 때문에 인덱스 크기가 커질 수 있습니다.
예: 문자열 길이가 10인 경우, 2-gram으로 분해하면 9개의 조각이 생성됨
MySQL의 Full-Text Index
는 내부적으로 역색인(Inverted Index) 방식을 사용하여 텍스트 검색을 최적화합니다. 전통적으로 MyISAM 스토리지 엔진에만 지원되던 풀텍스트 인덱스가, MySQL 5.6 이후로 InnoDB에서도 지원되며, MySQL 8.0에서는 성능과 확장성 면에서 다양한 기능이 추가되었습니다.
Full-Text 검색
이란, 테이블에 있는 문자열 열(TEXT, VARCHAR 등)에 대하여 단어 기반으로 빠르게 검색할 수 있게 해주는 기능입니다. 기본적으로는 영어 단어처럼 공백이나 구두점을 분리 기준으로 삼아 토큰화를 하지만, 한글이나 일본어처럼 공백 구분이 명확하지 않은 언어에서는 별도의 파서(parser) 또는 N-gram 방식을 사용해야 합니다.
위의 N-gram과 Full-Text Index를 함께 활용해 검색 성능을 개선했습니다.
N-gram 방식은 단순히 문자열을 연속된 N개의 문자로 쪼개는 방식이라 비교적 쉽게 구현할 수 있고, 이 때 N개의 문자로 쪼개진 문자열에 Full-Text Index를 적용만 하면 되는 방식입니다.
특히 주의할 점은, 자주 Update 되는 값에 적용하면 Update 할 때 마다 N-gram을 새로 생성해야 하기 때문에 Update 성능에 문제가 발생할 수 있습니다.
따라서 자주 바뀌지 않는 값(e.g. 상품명 : 거의 안 바뀜)에 적용했습니다.
아래는 적용을 고민했지만, 결과적으로 적용하지 않은 대안들입니다.
해당 방법은 MySQL 서버에 N-gram Parser Plugin
을 설치해 사용하는 방법입니다.
MySQL이 N-gram을 만들어주는 방식이라 비교적 쉽게 구현 가능합니다.
하지만, 추후에 ElasticSearch 등의 전문 검색 엔진을 도입하게 되면 N-gram 방식은 없앨 예정
이라 MySQL 서버에 Plugin을 설치하는 것에 부담을 느꼈고,
DB가 N-gram을 생성할 때 DB에 추가적인 부하
를 줄 수 있다고 생각해 선택하지 않았습니다.
아래는 N-gram Parser Plugin을 설치해 사용하는 방법입니다.
INSTALL PLUGIN ngram SONAME 'ngramparser.so';
CREATE TABLE goods (
id INT AUTO_INCREMENT PRIMARY KEY,
name TEXT,
FULLTEXT KEY ngram_idx (name) WITH PARSER ngram
) ENGINE=InnoDB;
이렇게 인덱스를 설정하면, N-gram으로 잘린 결과가 인덱스에 저장됩니다.
하지만, N-gram으로 잘린 결과를 확인할 수 없습니다.
-- ngram_token_size: 몇 글자 단위로 자를지 지정(기본값: 2)
SET GLOBAL innodb_ft_aux_table = 'mydb/goods';
SET GLOBAL ngram_token_size=2;
SELECT *
FROM goods
WHERE MATCH(name) AGAINST('검색할상품명' IN NATURAL LANGUAGE MODE);
저는 N-gram을 생성하는 로직을 직접 구현하는 방식
을 선택했습니다.
이유는, 추후에 ElasticSearch와 같은 전문 검색 엔진을 도입했을 때, N-gram과 관련된 로직은 폐기할 예정이고, 이 때 간단히 관련 로직을 제거
할 수 있기 때문입니다.
또한, N-gram을 사용하는 동안 관련 로직을 유지보수
하는데에도 유리할 것이라 판단했습니다.
아래는 구체적인 구현 방법입니다.
N-gram의 길이를 설정하고, 조합을 생성합니다. 저는 3-gram을 선택했습니다.
실제로는, Ngram 생성 로직을 외부 UtilClass에 정의해 TestCode를 작성할 수 있게 했습니다.
여기에선 설명 편의상 Entity 내부에 작성했습니다.
@Entity
@Table(name = "goods")
public class Goods {
@Id
@Column(name = "goods_id")
private String goodsId;
@Column(name = "goods_name")
private String goodsName;
@Lob
@Column(name = "goods_name_ngrams")
private String goodsNameNgrams;
// 기타 필드 생략
// Getter와 Setter
public String getGoodsName() {
return goodsName;
}
// 해당 기능은 Entity의 생성자에 넣어도 됩니다.
public void setGoodsName(String goodsName, maxGramLength) {
this.goodsName = goodsName;
generateNgrams(goodsName, maxGramLength); // 상품명 설정 시 Ngram 생성
}
// Ngram 생성 메소드 -> 실제로는 TestCode 작성을 위해 NgramUtilClass에 구현했습니다.
private void generateNgrams(String input, int maxGramLength) {
StringBuilder result = new StringBuilder();
if (input != null && input.length() >= maxGramLength) {
for (int i = 0; i <= input.length() - maxGramLength; i++) {
result.append(input, i, i + maxGramLength).append(" ");
}
}
this.goodsNmNgrams = result.toString().stripTrailing();
}
}
maxGramLength
는 N
gram의 길이를 설정합니다. 여기서는 예시로 3-gram
을 생성하도록 설정하였습니다.N
보다 짧다면, 상품명의 길이만큼 설정합니다.for
루프를 사용한 Ngram 생성:Ngram
들을 공백으로 구분하여 goodsNameNgrams
에 저장합니다.서비스 클래스에서는 변경된 사항은 없습니다.(아래 예시에선 특별히 조회 기능은 추가함)
Goods 엔터티의 setGoodsName() 메서드에서 자동으로 Ngram이 생성되므로, 동일한 로직으로 저장 및 수정할 때 자동으로 Ngram이 반영됩니다.
@Service
@RequiredArgsConstructor
public class GoodsService {
private final GoodsRepository goodsRepository;
@Transactional
public Goods saveGoods(GoodsSaveDto goodsDto) {
...
// 실제로는 Entity 생성할 때(생성자) 들어감
goods.setGoodsName(goodsDto.getGoodsName());
...
return goodsRepository.save(goods);
}
@Transactional
public Goods updateGoods(Goods goods, String name) {
Goods existingGoods = goodsRepository.findById(goods.getGoodsId())
.orElseThrow(() -> new RuntimeException("Goods not found"));
existingGoods.setGoodsName(name); // 상품명 변경 시 자동으로 Ngram 생성
return goodsRepository.save(existingGoods); // JPA 변경감지 사용하면 없어도 됨
}
@Transactional(readOnly = true)
public List<Goods> searchGoods(String keyword) {
return goodsRepository.searchByGoodsNameNgrams(keyword);
}
}
public interface GoodsRepository extends JpaRepository<Goods, Long> {
@Query(
value = "SELECT * FROM goods " +
"WHERE MATCH(goods_name_ngrams) " +
"AGAINST(CONCAT('+', :keyword, '*') IN BOOLEAN MODE)",
nativeQuery = true
)
List<Goods> searchByGoodsNameNgrams(@Param("keyword") String keyword);
}
ALTER TABLE goods ADD COLUMN goods_name_ngrams TEXT;
ALTER TABLE goods
ADD FULLTEXT INDEX idx_goods_name_ngrams (goods_name_ngrams);
MATCH ... AGAINST
를 사용합니다.SELECT * FROM goods
WHERE MATCH(goods_name_ngrams) AGAINST('이름' IN BOOLEAN MODE);
MATCH ... AGAINST
를 Boolean 모드
로 사용하면, +
, -
, *
와 같은 연산자를 사용하여 검색 조건을 더 구체화할 수 있습니다.
SELECT * FROM goods
WHERE MATCH(goods_name_ngrams) AGAINST('+사과*' IN BOOLEAN MODE);
N-gram을 적용할 Column의 값을 Update할 때, 성능 문제가 발생할 수 있음
인덱스 크기 증가
정확도 vs. 성능
불필요한 토큰 필터링