MySQL VISUAL EXPLAIN 기반의 쿼리 튜닝 경험기 (2/2)

MySQL VISUAL EXPLAIN 기반의 쿼리 튜닝 경험기 (2/2)

Tags
데이터베이스
index
MySQL
Published
Jan 13, 2022
Property
💡
본 글의 테이블명, 컬럼명, 쿼리 일부는 실제 서비스와는 다르게 임의로 수정, 각색되어 있습니다.

배경

지난 번에 Performance Insights를 통해 확인한 결과 가장 데이터베이스에 부하를 많이 주는 명령은 검색 기능이었다. 이 기능은 가장 많은 빈도로 호출되고 있는 반면, avg latency가 1,300ms 정도로 굉장히 느렸었다.
이걸 어떻게 쓰고 있는건가? 싶었는데 당시 서비스에는 memcached를 통해 쿼리를 캐싱하고 있어서 캐시된 검색의 경우 불편함을 느끼지 못했을 것이다. (성능 개선을 위해서인지 캐싱이 굉장히 철저하게 처리되고 있었다.)

문제점

해당 쿼리의 일부를 MySQL EXPLAIN으로 살펴보자.
80K짜리 검색 테이블을 Full Table Scan하고 있었다.
80K짜리 검색 테이블을 Full Table Scan하고 있었다.
코드를 찾아보니, 검색어를 찾는 부분에서 WHERE LIKE '%?%' 를 사용하고 있었다.
LIKE 를 사용할 때, 패턴에 와일드카드 % 가 앞에 들어가는 경우, 인덱스를 타지 않는다.(range scan 할 수 없다.)
구현이 쉽지만 성능이 좋지 않은 WHERE LIKE '%?%' 대신에 무엇을 사용할 수 있을까?
 

해당 기능을 개선할 수 있는 방법은 무엇이 있을까?

검색 기능을 구현하는 가장 좋은 방법은 별도의 검색엔진을 사용하는 것이다.
검색엔진은 Full Text 검색에 최적화되어 있기 때문에 더 나은 성능을 제공할 수 있다.
또한 DB 부하가 검색 서버로 분산되어서 서비스가 커질수록 꼭 필요하다.
 
검색엔진을 적용하기 위해서 2가지의 후보를 고려할 수 있었다.
  • sphinx
    • 당시 사내 다른 서비스에서 사용 중이어서 알게 되었다.
      라이트하게 사용하기 좋다.
  • Elasticsearch
    • 가장 유명한 검색 엔진으로 문서도 많고, 확장이 용이하게 만들어져 있다.
      큰 서비스에서 활용하기 좋다.
 

그러나.. 검색 엔진을 쓸 수는 없었습니다.

안 쓰고 싶어서 안 쓴게 아니라구요
안 쓰고 싶어서 안 쓴게 아니라구요
 
문제는 기존에 구현된 레거시 코드였다.
기존 로직을 살리면서 검색엔진을 점진적으로 도입하는 것은 사실상 불가능했다.
특히 동의어, 가중치 등 복잡한 검색 옵션들이 하드코딩 되어 있어서 새로운 검색엔진을 도입할 경우 우선순위가 바뀐다는 것이 문제였다.
검색 우선순위가 바뀌어버리면 비즈니스 로직에 큰 문제가 생기기 때문에 쉽사리 진행할 수가 없었다.
결론은 MySQL 내에서 검색 기능이 동작해야 했고, 기능 개선도 MySQL 내에서 이루어져야 했다.
 

그렇다면 Fulltext Index를 걸어보자.

인덱스를 활용하면 검색 성능도 개선할 수 있지 않을까?
MySQL 자체적으로도 전문 검색을 위한 인덱스를 지원하고 있어서 이를 적용해보기로 했다.
5.6 버전부터 InnoDB에서 Fulltext search를, 5.7 버전부터는 N-gram 방식을 기본적으로 지원하고 있다.
N-gram 방식부터는 한글 전문 검색에서도 매우 효율적이라고 한다.
서비스에서는 MySQL 5.7.x 버전을 쓰고 있었기 때문에 적용하기 딱 좋았다.
 

적용 사례

현재 서비스에서는 goods_search 라는 별도의 검색용 테이블을 두고 있었다.
테이블상품번호 goods_no, 해당 상품의 키워드들을 보관하는 goods_detail 컬럼이 존재하며, goods_detail은 별도의 DB 프로시저를 통해 자동으로 관리되고 있었다.
임의로 작성한 goods_detail 테이블이다.
임의로 작성한 goods_detail 테이블이다.

1. Fulltext Index 추가

기존에 걸려있던 인덱스들을 지우고, goods_detail에 Fulltext Index를 설정해주었다.

2. 쿼리 변경

인덱스를 타지 않는 WHERE LIKE '%?%' 에서 MATCH AGAINST 로 변경하였다.
매치 스코어 관련 로직은 기존에 이미 짜여져 있어서, 매치율을 비교 로직이 추가된 NATURAL LANGUAGE MODE 대신 BOOLEAN MODE를 사용하기로 했다.
MATCH AGAINST는 FullText Index가 없으면 아예 작동하지 않으니 주의가 필요하다.
... MATCH(`gs`.`goods_detail`) AGAINST ($q IN BOOLEAN MODE)

3. 기타 변경 사항

공식 문서를 참조하여 주요 설정값들을 만져준다.
  • innodb_ft_min_token_size
    • 3이 기본값인데, 한국어 특성 상 2글자 단어도 많기 때문에 2로 바꾸어 주어야 한다.
  • innodb_ft_result_cache_limit
  • innodb_ft_total_cache_size
    • 데이터베이스 크기를 고려하여 캐쉬를 확장시켜준다.
      2GB → 4GB 확장 (t3.2xl의 경우 메모리가 32GB이다)
      특정 케이스에서 timeout이 발생하는 문제가 있었는데, 캐시 확장 후 해결되었다.

4. 배포

개발 서버에서 테스트 결과 최초 테이블에 인덱스를 설정해주는 작업에서 5-10초 정도 검색 테이블에 락이 걸리는 것을 확인했다.
그러나 락은 검색 테이블에만 걸리므로, 검색 기능 외에는 문제가 없을 것으로 판단하였다.
그리고 쿼리 결과는 memcached로 캐싱중이기 때문에, 새로운 검색어만 검색하지 않을 경우 서비스에 문제가 발생하지 않는다.
따라서 사람이 많지 않은 오전 시간에 Fulltext Index를 설정해주는 작업과 함께 실제 배포를 진행하였다.
약간의 시간 동안 락이 걸렸지만, 다운타임 없이 작업이 마무리되었다.
 

결과는..?

눈에 띄는 성능 향상이 있었다!
Full Table Search에서 Fulltext Index Search로 스캔 방식이 바뀌었고, Query cost는 224657에서 5.17로 변경되었다. (Query cost는 검색어마다 달라질 수 있다.)
Performance Insights를 통해 확인한 결과 해당 쿼리는 avg latency 1,300ms에서 150ms~170ms으로 성능이 크게 개선되었다.
이제 FullText Index Search로 스캔한다.
이제 FullText Index Search로 스캔한다.
notion image
효과는 대단했다.
효과는 대단했다.
 

마치며..

새로 시작하는 프로젝트라면 solr나 elasticsearch를 적용하여 사용하겠지만, 레거시 환경에서는 쓰고 싶은 기술을 쓰지 못할 때가 더 많다. 답답해도 밑 빠진 독을 교체하는 대신 접착제를 붙여가며 써야할 때가 있다.
그럼에도 DB 성능 개선을 위해 스스로 여러 해결책을 세워보고, 개발비용을 고려하여 최선의 대응을 하려고 노력했다. 개인적으로는 이슈 해결을 위해 Real MySQL을 많이 들여다 보았고, 데이터베이스에 대해 많이 배울 수 있었다.
또 실무에서 DB 서버 1대로 운영하였을 때의 한계점이 어디쯤인지 감이 생긴 것 같다. 캐시는 부하 분산에 매우 효과적인 기술이지만, 그것만으로 충분하지 못할 때도 있었다.
물론 이제는 검색엔진을 갖다 써보고 싶다..! 🥲
 

참고 자료 및 이미지