BE-commerce 프로젝트
약 6분 분량 검색·추천

검색 엔진을 넣을지 지연으로 먼저 재 봤다: 지연 축은 답했고 품질 축은 남았다

목차

상품 목록 페이지의 지연에서 패싯 집계가 82.5%를 차지했습니다(패싯은 목록 옆에 붙는 “색상별 몇 개, 종류별 몇 개” 같은 개수입니다). 검색 엔진을 넣을 기준을 측정 전에 이슈에 적어 두고 쟀더니 300ms를 넘긴 원인은 집계가 아니라 서버 포화였습니다. 패싯 결과를 60초 캐시하자 패싯 p95가 90.9ms에서 5.7ms로 줄었고 엔진을 검토할 조건이 사라졌습니다. 다만 이것은 지연 축의 답입니다. 이커머스가 검색 엔진을 들이는 큰 이유인 검색 품질(관련도 순위, 오타, 어형 변화, 동의어)은 아직 재지 않았습니다.

검색 엔진 도입을 두 축으로 나누기

이 글은 “검색 엔진을 지금 넣어야 하는가”를 두 축으로 나눠 다룹니다. 지연 축은 기준을 먼저 정하고 재서 답을 냈습니다. 품질 축은 이번 측정의 대상이 아니었고 그래서 이 결정은 “지금 지연 때문에 넣을 이유는 없다”까지만 말합니다. “검색 엔진이 필요 없다”와는 다른 말입니다.

카탈로그는 H&M 공개 데이터에서 만든 상품 105,542행이고 DB는 MySQL 8.4입니다. 로컬 단일 장비에서 쟀습니다.

이후 품질 축을 쟀습니다(2026-09-24). 오타·어형 쿼리 nDCG@10이 지금 검색 0.071, MySQL FULLTEXT 0.324, 앱 안 Lucene 0.808이었고, 측정 전에 정한 기준대로 앱 안 Lucene을 넣었습니다. 과정과 표는 검색 품질을 쟀더니 MySQL 이 따라오지 못했다에 있습니다.

결론부터

  • 패싯 집계가 목록 지연의 82.5%를 차지했지만 60초 캐시로 p95를 90.9ms에서 5.7ms로 줄였습니다.
  • 300ms를 넘긴 원인은 검색 엔진 부재보다 동시성에 따른 서버 포화였습니다.
  • 이후 품질 축에서는 앱 안 Lucene을 선택했지만, 이 글의 측정만으로 “검색 엔진이 필요 없다”고 결론 내리지는 않습니다.

검색 엔진 도입은 지연과 검색 품질을 분리해 판단해야 하는 선택입니다.

검색 엔진이 필요한가는 두 축으로 물어야 한다. 지연 축은 측정 전 기준 두 개로 재서 답을 냈고, 품질 축은 부분 일치 검색이라 관련도 순위·오타·어형·동의어를 다루지 못한 채 아직 재지 않았다

기준을 먼저 적었다

필터 6개와 패싯 2축을 DB의 WHERE와 인덱스로 붙였을 때 “지금 규모에서는 병목이 아니다”라고 판단했습니다. 그 판단은 재지 않고 내린 추정이었고 추정은 규모가 바뀌면 조용히 틀립니다. 그래서 재기 전에 이슈에 판정 기준을 적었습니다.

  1. 목록 p95가 300ms를 넘고 그 원인이 집계라면 검색 엔진을 검토한다.
  2. 패싯 집계가 전체 지연의 50% 이상이면 사전 집계나 엔진을 검토한다.
  3. 둘 다 아니면 도입하지 않는다.

결과를 보고 기준을 만들면 결과에 맞는 기준을 고르게 됩니다. 순서를 지키는 것이 이 측정의 근거입니다.

측정: 패싯이 페이지를 지배했다

필터 조합, 정렬 4종, 페이지 깊이, 검색어, 패싯을 동시성 1, 10, 50에서 30초씩 쟀습니다. 이슈가 “동시성 N”을 축으로 정했으므로 VU 수를 고정하는 닫힌 루프를 썼습니다. 용량을 재는 실험이었다면 도착률을 고정하는 개방 루프가 맞았을 것입니다.

쿼리동시성 1 p95동시성 10 p95동시성 50 p95
필터 없는 목록19.7ms43.6ms587.4ms
검색어48.3ms93.2ms661.8ms
필터 조합49.2ms95.6ms701.6ms
최신순 정렬53.4ms246.1ms986.2ms
400페이지120.4ms174.4ms815.5ms
패싯 2축92.9ms370.9ms1,259.6ms

기준에 대입했습니다.

동시성목록 p95패싯 p95패싯 몫기준 ②
119.7ms92.9ms82.5%발동
1043.6ms370.9ms89.5%발동
50587.4ms1,259.6ms68.2%발동

기준 ②는 발동했습니다. 동시성 1에서도 패싯이 82.5%라서 페이지 하나를 그리는 비용 대부분이 패싯이었습니다.

기준 ①은 발동으로 읽지 않았습니다. 동시성 50에서 목록 p95가 587ms로 300ms를 넘었지만 필터도 패싯도 없는 가장 단순한 목록도 똑같이 587ms였습니다. 가장 단순한 쿼리까지 같이 느려졌다면 원인은 집계가 아니라 서버 포화입니다. 기준이 물은 것은 “300ms를 넘었는가”가 아니라 “넘은 이유가 집계인가”였습니다.

싼 조치부터 했다

기준 ②의 조치는 “사전 집계 또는 엔진 검토”였습니다. 패싯은 카탈로그가 바뀔 때만 달라지므로 요청마다 세는 것은 낭비입니다. 검색 엔진은 그 자체가 운영 대상이 하나 느는 일입니다. 프로세스, 힙, 색인 동기화, 백업과 복구가 따라옵니다. 그래서 싼 쪽부터 했습니다.

계획은 카탈로그가 바뀔 때 다시 세는 사전 집계였습니다. 그런데 이 저장소의 카탈로그는 앱 밖의 파이프라인이 DB에 적재하므로 앱이 그 변경을 알 수 없습니다. 그래서 패싯 결과를 60초 TTL로 캐시했습니다(상한 200개, 적중·미스·비움 지표 포함). 대가는 TTL 동안 낡은 개수를 보여 줄 수 있다는 것이고 파이프라인이 버전 값을 올려 주면 즉시 무효화로 바꿀 수 있습니다.

같은 하네스로 캐시를 껐다 켰다 하며 쟀습니다. 켠 쪽만 재면 무엇 때문에 좋아졌는지 알 수 없기 때문입니다.

동시성 1동시성 10동시성 50
패싯 p9590.9 → 5.7ms375.5 → 20.7ms1,276.3 → 401.7ms
패싯 몫82.5% → 24.6%89.5% → 31.1%68.2% → 47.2%
다른 쿼리1.0배0.9~1.2배1.2~1.4배

다른 쿼리가 1.0배 근처라서 개선은 패싯에서만 왔습니다. 기준 ②가 모든 동시성에서 50% 밑으로 내려가 엔진을 검토할 조건이 사라졌습니다. 패싯이 DB 커넥션과 CPU를 덜 잡자 다른 쿼리도 여유를 얻어 동시성 50에서 필터 없는 목록이 617ms에서 449ms로 줄었습니다. 다만 동시성 50은 47.2%로 기준에 가깝습니다. 검색 축이 늘거나 카탈로그가 커지면 다시 넘을 수 있습니다.

버린 안이유
지금 Elasticsearch나 OpenSearch를 넣는다지연 기준 ①은 발동하지 않았고 ②에는 더 싼 조치가 있었다. 색인 크기, 재색인 시간, 이중 쓰기 불일치 같은 비용을 재지 않은 채 감수할 근거가 없었다
앱 안에 Lucene을 넣는다인스턴스가 여럿이면 색인을 어떻게 공유할지가 새 문제가 되는데 지금 그 문제를 풀 이유가 없었다
아무것도 안 한다기준 ②가 발동했다. 패싯이 지연의 82~90%인 것을 재고도 두는 것은 측정의 의미를 버리는 일이다

여기까지가 지연 축의 답이다

지금 검색은 상품명과 브랜드에 LIKE '%검색어%'를 거는 부분 일치입니다. 이 방식에는 지연 측정으로 드러나지 않는 한계가 있습니다.

  • 관련도 순위가 없습니다. 검색어가 들어간 상품을 찾을 뿐, 어느 상품이 더 맞는지 점수를 매기지 않습니다.
  • 어형 변화, 오타, 동의어를 다루지 못합니다. 카탈로그 상품명이 영어라서 “dresses”로 찾으면 “dress”가 걸리지 않고 철자가 하나 틀리면 결과가 없습니다.
  • 앞에 %가 붙은 LIKE는 B-Tree 인덱스를 탈 수 없습니다. 지금은 10만 행이라 동시성 1에서 48ms지만 행 수에 비례해 늘어납니다.

MySQL에도 FULLTEXT 인덱스가 있고 자연어 모드에서 관련도 점수를 냅니다. 하지만 어간 추출, 오타 허용, 동의어 사전 같은 분석기 선택지가 좁습니다. 이커머스가 검색 엔진을 들이는 큰 이유가 바로 이 품질이고 이번 결정은 그 축을 재지 않았습니다. ADR에도 “품질 요구가 생기면 비용과 무관하게 검토 대상이 된다”를 다시 볼 조건으로 적어 두었습니다.

품질 축을 재려면

품질 축도 지연 축과 같은 순서로 재야 합니다. 넘어야 할 선을 먼저 정하고 잽니다. 아래 설계대로 잰 결과는 42편에 있습니다.

  • 쿼리셋과 정답: 카탈로그 속성(상품 종류, 색상, 대분류)으로 쿼리를 만들면 정답을 속성 일치로 정할 수 있습니다. 사람이 판정한 정답은 아니라는 한계를 같이 적어야 합니다.
  • 비교 대상: 지금의 LIKE, MySQL FULLTEXT, 검색 엔진의 BM25(오타 허용 포함)를 같은 쿼리셋으로 비교합니다. 지표는 Recall@k와 nDCG@k입니다.
  • 엔진을 넣는다면 같이 잴 것: 가격과 재고가 바뀐 뒤 검색 결과에 반영되기까지의 색인 지연입니다. 이 저장소에는 활동 로그를 CDC로 나르는 경로가 이미 있으므로 같은 방식으로 색인을 갱신할 수 있습니다. 엔진이 죽었을 때 DB 쿼리로 물러서는 폴백도 결정에 들어갑니다.

이 결정이 말하지 않는 것

  • 엔진 비교표가 아닙니다. Elasticsearch, OpenSearch, Lucene을 세워 보지 않았습니다.
  • 동시성 50은 이 장비에서 포화 근처라 다른 장비에서는 기준 ①의 판정이 달라질 수 있습니다.
  • 검색 품질은 재지 않았습니다. 그래서 “검색 엔진이 필요 없다”고 쓰지 않습니다.

참고

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.