검색어와 필터를 함께 걸고 앱 안 Lucene이 몇 건까지 버티는지 쟀다
목차
검색어와 필터를 함께 걸 때는 필터를 검색 엔진 안에서 걸어야 했습니다. 엔진이 준 상위 500개를 DB에서 다시 거르는 방식은 결과 수를 95.5% 잃었습니다. 그리고 앱 프로세스 안에 둔 Lucene 검색은 복제 카탈로그 300만 건에서 동시성 10 p95 232ms로, 미리 정한 지연 예산(100ms)을 넘었습니다. 100만 건까지는 예산 안이었습니다.
결론부터
- 검색 엔진의 상위 500개만 받아 DB에서 필터링하면 첫 페이지가 맞아 보여도 전체 결과 수와 패싯이 틀렸고, 정답 결과의 95.5%를 잃었습니다.
- 필터와 패싯은 검색 엔진 안에서 함께 처리해야 했습니다.
- 앱 안 Lucene은 100만 건까지 p95 100ms 예산 안이었지만 300만 건에서는 232ms로 넘어섰습니다. 규모가 운영 경계를 결정합니다.
검색어와 필터를 함께 처리하는 위치
이 쇼핑몰의 상품 검색은 앱 프로세스 안의 Lucene 색인으로 답합니다. MySQL LIKE와 FULLTEXT, Elasticsearch, OpenSearch를 같은 쿼리셋으로 비교했을 때 오타·어형 쿼리 품질이 엔진 셋에서 비슷했고 추가로 띄울 프로세스가 없는 Lucene이 운영 비용이 가장 낮았기 때문입니다.
그 비교에는 빠진 것이 둘 있었습니다. 하나는 필터입니다. 검색어가 있으면 카테고리, 가격, 재고, 색상 필터가 전부 무시되고 있었습니다. 이커머스 검색은 “원피스, 여성복, 3만 원 이하, 재고 있음”을 한 번에 걸고 그 조건의 색상별 개수(패싯)를 옆에 보여 줍니다. 다른 하나는 규모입니다. “상품이 수백만 개가 되면 옮긴다”고만 적고 숫자로 재지 않았습니다.
필터를 어디서 거는가
세 방식을 같은 API로 비교했습니다.
| 방식 | 필터와 패싯을 어디서 | 설정 |
|---|---|---|
| Lucene 안 | 점수에 끼지 않는 필터 절과 문서값으로 셉니다 | 기본 |
| 후보 자르기 | Lucene 상위 500개를 받아 DB에서 거르고 셉니다 | filters-in-engine=false |
| Elasticsearch 안 | filter 절과 집계로 셉니다 | 엔진 교체 |
패싯은 DB 패싯과 같은 뜻으로 셉니다. 색상 패싯은 색상 필터를 뺀 나머지 조건으로 셉니다. 한 색을 고른 뒤에도 다른 색의 개수가 보여야 하기 때문입니다.
쿼리는 이전 품질 실험의 종류 쿼리 171개에 필터 조합 넷(가격 중앙값 이하, 색상, 대분류, 재고 있음)을 번갈아 붙였습니다. 정답은 엔진이 필터 없이 찾은 전체 일치 집합에 필터를 파이썬에서 직접 적용해 만들었습니다. 비교 대상인 엔진 필터 코드와 정답 코드가 겹치지 않게 하려는 것입니다.
| 방식 | 결과 수·패싯이 정답과 같은 쿼리 | 결과 수 합(응답/정답) | API p95(동시성 10) |
|---|---|---|---|
| Lucene 안 | 171/171 | 839,809/839,809 | 37ms |
| 후보 자르기 | 19/171 | 37,503/839,809 | 48ms |
| Elasticsearch 안 | 171/171 | 839,809/839,809 | 39ms |
후보 자르기가 틀리는 자리는 예상과 달랐습니다. 첫 페이지 20개는 171개 쿼리 중 153개가 엔진 필터와 같았습니다. 관련도 순서가 같고 필터를 통과한 상품이 상위 500개 안에 20개 넘게 있으면 첫 페이지는 같아집니다. 틀린 것은 “총 N건”과 패싯 개수였습니다. 쿼리의 정답 결과 수 중앙값이 1,246건인데 후보는 500개라서 개수가 틀린 쿼리가 152개였습니다. 첫 페이지만 보면 맞아 보이니 알아차리기 어렵습니다.
측정 하네스가 결함도 하나 잡았습니다. 검색 패싯 응답의 키가 code 대신 getCode로 나가고 있었습니다. 자바 레코드의 필드 이름을 getCode로 지어서 JSON 키가 그대로 따라 나갔고 서비스 단위 테스트는 객체만 봐서 통과했습니다. API로 재는 하네스가 처음 잡았고 머지 전에 고쳤습니다.
앱 안 Lucene은 몇 건까지 버티나
규모는 실제 상품 10.6만 행을 1배, 10배, 30배로 복제한 합성 카탈로그로 쟀습니다. 앱이 쓰는 Lucene 클래스를 그대로 만들고 같은 요청 셋(검색, 검색과 필터, 패싯)을 동시성 10으로 보냈습니다. 예산은 측정 전에 셋으로 정했습니다. 색인 다시 만들기 60초 이하, 색인 크기 256MB 이하, 검색 p95 100ms 이하입니다.
| 규모 | Lucene 재색인 | 색인 크기 | Lucene p95 | ES p95 |
|---|---|---|---|---|
| 10.6만 | 1.9초 | 6MB | 19ms | 18ms |
| 106만 | 12.6초 | 58MB | 86ms | 60ms |
| 317만 | 36.4초 | 175MB | 232ms | 148ms |
300만 건에서 먼저 넘은 것은 지연이었습니다. 색인 크기와 재색인 시간은 300만 건에서도 예산 안이었습니다. 단일 노드 Elasticsearch(힙 1GB, 샤드 하나)도 같은 규모에서 148ms로 예산을 넘었습니다. Elasticsearch로 옮기기만 해서는 지연이 풀리지 않는다는 뜻입니다. Elasticsearch가 가진 해법은 샤드를 여러 노드로 나누는 것이고 그 구성은 이번에 재지 않았습니다.
복제 카탈로그라는 조건도 결과에 들어 있습니다. 같은 행을 30번 복제하면 쿼리 하나의 일치 집합도 30배로 커집니다. 실제 카탈로그는 커지면서 상품이 다양해지니 일치 집합이 이보다 덜 자랄 가능성이 큽니다. 그래서 이 전환점은 작게 나온 쪽입니다.
정리
- 필터와 패싯은 엔진 안에서 겁니다. 엔진이 실패하면 후보 자르기로 물러서서 개수는 모자랄 수 있어도 검색은 살아 있게 했습니다
- 앱 안 Lucene은 100만 건까지 씁니다. 100만 건의 p95 86ms는 예산까지 14ms가 남습니다. 카탈로그가 100만에 가까워지면 실제 데이터로 이 벤치마크를 다시 돌립니다
- 옮길 곳이 단일 노드 Elasticsearch는 아닙니다. 옮긴다면 샤드를 나눈 클러스터이고 그 구성은 옮길 때 잽니다
검색 색인이 상품 변경을 얼마나 늦게 반영하는지는 이 글에서 재지 않았습니다. 그 지연 때문에 품절 상품이 “재고 있음” 결과에 남는 문제는 다음 글에서 다룹니다.
이어서 읽기
이 글에서 이어진 작업은 따로 적었습니다.
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.