도서 검색 응답 시간 개선 (MySQL LIKE → Elasticsearch, 1,200ms → 275ms)

오리구이·2026년 4월 30일
post-thumbnail

도서 쇼핑몰 프로젝트의 개발 단계에서 검색 응답 시간이 평균 1,200ms 수준으로 측정되어, MySQL LIKE 기반 구현을 Elasticsearch로 전환한 과정을 정리한 내용입니다.


1. 들어가며

도서 쇼핑몰 프로젝트를 개발하면서 마주한 검색 성능 문제와 그 해결 과정을 정리한 내용입니다.

해당 프로젝트는 실제 사용자에게 운영되는 서비스가 아니라 개발 및 배포 단계까지만 진행한 프로젝트였습니다. 그러나 개발 단계에서 검색 기능을 테스트하던 중 응답 시간이 평균 1,200ms에 달하는 것을 확인하였고, 이는 정상적인 사용이 어려운 수준이라 판단되어 개선 작업을 진행하게 되었습니다.

본문에서는 다음과 같은 흐름으로 정리하였습니다.

  1. 문제 상황 진단 및 기존 구현 분석
  2. B-Tree 인덱스 추가 시도와 실패
  3. MySQL Full-Text Search 검토와 한계
  4. Elasticsearch 도입 및 설계 의사결정
  5. 측정 결과 및 회고

2. 문제 상황 진단 및 기존 구현 분석

개발 환경에서 키워드 검색 부하 테스트를 진행한 결과, 다수의 SLOW QUERY 로그가 출력되었습니다.

개선 전 운영 로그

WARN  [nio-8080-exec-4]
[SLOW QUERY] SELECT DISTINCT b FROM Book b LEFT JOIN ...
WHERE b.title LIKE '%데이터베이스%' duration=1525ms type=FULL_TABLE_SCAN

30분간 60건 요청 기준으로 다음과 같은 수치가 측정되었습니다.

  • 평균 응답 시간 : 1,187ms
  • Slow Query (≥1,500ms) : 18건 / 60건 (30%)

검색 한 번에 1.5초가 소요되는 빈도가 30%에 달하였고, 이는 사용자 입장에서 정상적인 사용이 불가능한 수준입니다.

기존 구현 코드

기존 검색 기능은 QueryDSL 의 .contains() 메서드를 사용하여 구현되어 있었습니다.

Repository Before — BooleanBuilder + LIKE

BooleanBuilder builder = new BooleanBuilder();
builder.and(
    qBook.title.contains(keyword)            // → LIKE %keyword%
    .or(qBook.description.contains(keyword))
    .or(qContributor.name.contains(keyword))
    .or(qTag.name.contains(keyword))
);

QueryDSL 의 .contains() 는 내부적으로 LIKE %keyword% 로 변환됩니다. 또한 검색 시 4개 테이블에 LEFT JOIN 이 걸리는 구조였습니다.

Repository Before — LEFT JOIN 4개 및 N+1

확인된 문제점을 정리하면 다음과 같습니다.

항목문제
인덱스 사용 불가LIKE %keyword% 앞 와일드카드로 인해 Full Table Scan
4개 JOINcontributor, tag, category 조인 시 결과 행 폭발
N+1각 도서별 기여자·카테고리 조회 추가 쿼리 발생
별도 count 쿼리페이징 전체 건수 집계용 쿼리 별도 실행
한국어 형태소 분석 미지원'자바' 검색 시 '자바스크립트' 누락
관련성 정렬 미지원단순 views 컬럼 기준 정렬만 가능

3. 첫 번째 시도 — B-Tree 인덱스 추가

가장 단순한 접근 방법인 인덱스 추가부터 시도하였습니다.

CREATE INDEX idx_book_title       ON book (title);
CREATE INDEX idx_book_description ON book (description(100));

인덱스 추가 후 EXPLAIN을 통해 실행 계획을 확인하였습니다.

+----+------+------+------+----------+-------------+
| id | type | key  | rows | filtered | Extra       |
+----+------+------+------+----------+-------------+
|  1 | ALL  | NULL | 9821 |    11.11 | Using where |  ← Full Table Scan
+----+------+------+------+----------+-------------+

type 컬럼이 여전히 ALL 로 출력되는 것을 확인할 수 있었습니다. 즉, 인덱스를 추가하였음에도 Full Table Scan 이 발생하고 있는 상황입니다.

원인 분석
LIKE '%keyword%' 와 같이 검색어 앞에 와일드카드가 위치할 경우, B-Tree 인덱스를 사용할 수 없습니다.
B-Tree 는 데이터를 정렬된 상태로 저장하고 앞 글자부터 순차 탐색하는 구조이기 때문에, 시작점이 정해지지 않으면 전체 스캔으로 처리됩니다.

따라서 인덱스 추가만으로는 본 문제를 해결할 수 없다고 판단하였습니다.


B-Tree 인덱스로 해결이 어렵다는 점을 확인한 후, MySQL 의 Full-Text Search 기능을 검토하였습니다.

ALTER TABLE book ADD FULLTEXT INDEX ft_title_desc (title, description);

SELECT * FROM book
WHERE MATCH(title, description) AGAINST('자바' IN BOOLEAN MODE);

쿼리 자체의 응답 시간은 LIKE 방식보다 빨라졌으나, 두 가지 본질적인 한계가 있었습니다.

한계내용
한국어 형태소 분석 미지원'스프링부트' 를 '스프링' + '부트' 로 분리하지 못함
복합 필드 검색 한계기여자·태그 별도 테이블이라 JOIN + FTS 조합이 어려움

특히 '자바' 키워드로 검색하였을 때 '자바스크립트' 도서는 매칭되지만, '김영한' 저자의 도서는 매칭되지 않는 현상이 발생하였습니다. 저자명은 별도 테이블에 정규화되어 있어 FTS 만으로는 처리할 수 없는 구조였기 때문입니다.

이 시점에서 다음과 같은 의문이 들었습니다.

이 문제가 정말 MySQL 의 영역에서 해결할 수 있는 문제인가?

두 차례의 시도가 모두 만족스러운 결과를 내지 못하였기에, 문제의 본질을 다시 분석하였습니다.

MySQL 은 정형 데이터를 관계형 모델로 저장하는 데 최적화된 데이터베이스입니다.
반면, 키워드 기반 전문 검색은 본질적으로 비정형이며 관련도 기반 정렬이 필요합니다.
두 영역은 요구하는 저장 구조와 처리 방식이 근본적으로 다릅니다.

검색은 비정형(여러 필드 동시 매칭)이고 관련도 순 정렬이 필요하지만, RDB 는 정형(컬럼 단위 조건)에 최적화되어 있고 관련도를 계산할 구조가 없습니다. 또한 한국어 형태소 분석 역시 MySQL 은 플러그인 없이 지원하지 않습니다.

쿼리 튜닝만으로는 위 구조적 불일치가 해소되지 않는다고 판단하였고, 검색 전용 저장소를 분리하는 방향으로 의사결정을 진행하였습니다.


5. Elasticsearch 도입 및 설계

시스템 구성

┌─────────────┐   Logstash   ┌──────────────────┐
│   MySQL DB  │ ───────────▶ │  Elasticsearch    │
│ (원본 데이터)│  10초 주기   │   books index     │
└─────────────┘  JDBC sync   └──────┬───────────┘
                                    │
              ┌────────────┐        │  검색 쿼리
              │   Spring   │◀───────┘
              │    Boot    │
              │            │──▶ Kibana 모니터링
              └────────────┘      :5601

원본 데이터는 그대로 MySQL 에 저장하되, 검색 전용 데이터를 Elasticsearch 에 별도로 색인하는 구조입니다. 두 저장소는 Logstash JDBC 를 통해 주기적으로 동기화됩니다.

동기화 전략 — Logstash JDBC

데이터 동기화 방식을 두 가지 후보 중에서 검토하였습니다.

방법장점단점
Logstash JDBCMySQL → ES 자동 주기 동기화, 기존 코드 변경 불필요별도 인프라 필요
Spring Data ES도메인 이벤트 기반 실시간 동기화모든 도서 수정 로직에 ES 업데이트 코드 추가 필요

도서 등록·수정은 관리자만 수행하는 도메인 특성상 실시간성보다는 안정성과 코드 응집도가 중요하다고 판단하여 Logstash 10초 주기 동기화를 선택하였습니다.

input {
  jdbc {
    schedule  => "*/10 * * * * *"
    statement => """
      WITH RECURSIVE category_hierarchy AS ( ... )
      SELECT
        b.book_id, b.title AS book_title,
        (SELECT JSON_ARRAYAGG(JSON_OBJECT('id', c.id, 'name', c.name, 'role', cr.name))
         FROM book_contributor bc JOIN contributor c ...
         WHERE bc.book_id = b.book_id) AS book_contributor,
        ...
      FROM book b LEFT JOIN publisher p ON b.publisher_id = p.publisher_id
      WHERE b.is_active = true
    """
  }
}
output {
  elasticsearch {
    index         => "books"
    document_id   => "%{[@metadata][_id]}"
    doc_as_upsert => true
    action        => "update"
  }
}

JSON_ARRAYAGG 함수를 사용하여 nested 필드(기여자, 태그, 카테고리)를 ES 문서 형식으로 변환한 뒤 색인하는 방식입니다.

매핑 설계 — nested vs object

해당 부분은 개발 과정에서 시행착오를 겪은 부분입니다. 처음에는 object 타입으로 매핑을 설계하였습니다.

"book_contributor": [
  { "name": "김영한", "role": "저자" },
  { "name": "박성철", "role": "역자" }
]

그러나 ES 의 object 타입은 내부적으로 평탄화(flatten)됩니다.

"name": ["김영한", "박성철"]
"role": ["저자", "역자"]

이로 인해 객체 간 쌍(pair) 관계가 보존되지 않아, "김영한 역자" 와 같은 잘못된 매칭이 발생하였습니다. 이를 해결하기 위해 nested 타입으로 변경하였으나, 인덱스 매핑 변경은 전체 재색인을 수반하므로 작지 않은 비용이 발생하였습니다.

참고
Elasticsearch 에서 매핑은 한번 설정되면 일부 속성을 제외하고는 변경이 불가능합니다.
타입 변경은 사실상 인덱스를 새로 만들고 데이터를 다시 색인해야 하므로,
초기 설계 단계에서 검색 케이스를 충분히 정리한 뒤 매핑을 결정하는 것이 좋습니다.

Nori 분석기 decompound_mode 튜닝

기본 Nori 분석기를 그대로 사용할 경우 복합어 처리에 문제가 발생합니다.

"tokenizer": "nori_tokenizer"
// "스프링부트" → ["스프링부트"]   (단일 토큰)

위 설정에서는 사용자가 '스프링' 으로 검색하였을 때 '스프링부트' 도서가 매칭되지 않습니다. decompound_mode 옵션을 mixed 로 설정하면 원형과 분해된 형태소를 모두 보존합니다.

"tokenizer": {
  "type": "nori_tokenizer",
  "decompound_mode": "mixed"
}
// "스프링부트" → ["스프링부트", "스프링", "부트"]

해당 설정 변경 이후 복합어에 대한 검색 정확도가 크게 향상되었습니다.

검색 구현 — 정렬 분기

Repository After 상단 — switch expression

List<Map.Entry<String, String>> sortCriteria = switch (sort) {
    case "popularity"    -> List.of(Map.entry("book_likes", "desc"), Map.entry("book_views", "desc"));
    case "newest"        -> List.of(Map.entry("book_published_at", "desc"));
    case "lowest_price"  -> List.of(Map.entry("book_selling_price", "asc"));
    case "rating"        -> List.of(Map.entry("book_rating_avg", "desc"));
    case "reviews"       -> List.of(Map.entry("book_review_count", "desc"));
    default              -> List.of();  // relevance: ES score 기본 정렬
};

검색 구현 — multi_match 및 nested 쿼리

Repository After 중반 — multi_match + nested

// 제목^10 · 제목.ngram^3 · 설명^3 · 출판사^2 가중치 적용
bq.should(sq -> sq.multiMatch(mm -> mm
    .fields("book_title^10", "book_title.ngram^3",
            "book_title.jaso", "book_title.synonym",
            "book_desc^3", "book_desc.ngram",
            "book_publisher^2", "book_publisher.ngram")
    .query(keyword)));

// nested: 카테고리 검색
bq.should(sq -> sq.nested(n -> n.path("book_category")
    .query(cq -> cq.multiMatch(mm -> mm
        .fields("book_category.name^3", "book_category.name.ngram")
        .query(keyword)))));

^10, ^3 와 같은 표기는 가중치(boost)를 의미하며, 제목에서 매칭되었을 때 설명에서 매칭되었을 때보다 점수가 높게 산정됩니다.

검색 구현 — ES 호출 및 결과 매핑

Repository After 하단 — search 호출 및 결과 매핑

ES 응답에 전체 건수(total)가 포함되어 있으므로, MySQL 구현 시 필요했던 별도 count 쿼리가 불필요합니다.


6. 개선 결과

참고
본 프로젝트는 실제 사용자 트래픽을 받지 않은 개발·배포 단계의 프로젝트였기에, 개발 진행 당시 측정값을 Kibana 대시보드로 그대로 재현하는 것은 어려웠습니다.
따라서 아래 Kibana 대시보드 이미지는 당시 로그 기반 측정값을 시각화 형태로 재구성한 자료이며, 실제 수치는 응용 서버 로그(duration, took) 기반으로 검증한 값입니다.

Kibana 대시보드 (재구성)

Kibana 성능 대시보드

  • 평균 took : 275ms (개선 전 ~1,200ms)
  • P90 : 371ms / P99 : 453ms
  • 약 4.4배 개선
  • 일별 요청 수가 증가하여도 응답 시간이 안정적으로 유지됨

Dev Tools 응답 확인

Dev Tools 응답

"took": 243 은 ES 내부 처리 시간을 의미하며, Spring Boot 레이어를 포함한 전체 응답 시간은 약 275ms 수준입니다.

Before vs After 비교

키워드별 응답 시간 및 비교표

항목Before (MySQL LIKE)After (Elasticsearch)개선
평균 응답 시간~1,200 ms~275 ms약 4.4배
P90~1,580 ms~371 ms4.3배
P99~2,230 ms~453 ms4.9배
Slow Query (≥1,500ms)30% (18/60건)0건완전 해소
인덱스 사용Full Table Scan역색인 (Inverted Index)--
한국어 분석미지원Nori + ngram + jaso검색 품질 향상

개선 후 응용 서버 로그

개선 후 운영 로그

INFO  [nio-8080-exec-1]
[ES] searchByKeyword keyword='디자인패턴' duration=397ms took=367ms

INFO  [nio-8080-exec-2]
[ES] searchByKeyword keyword='디자인패턴' sort=lowest_price duration=262ms took=223ms

WARN 레벨의 SLOW QUERY 로그가 출력되지 않으며, slow_queries 는 0건입니다.


7. 회고 및 정리

LIKE '%keyword%' 는 인덱스로 풀 수 없습니다.
B-Tree 인덱스 전략과 무관하게 Full Table Scan 이 강제됩니다. EXPLAIN type: ALL 한 줄이 그 사실을 확인해 줬고, 이후 모든 의사결정의 출발점이 되었습니다.

ES 도입 비용은 띄우는 것이 아니라 매핑·튜닝에 있습니다.
nested vs object 오결정 시 전체 재색인, Nori decompound_mode 파라미터 튜닝, 동기화 전략 선택 — 시간이 걸리는 부분은 모두 여기에 집중됐습니다.

본질은 성능 개선이 아니라 저장 구조 분리였습니다.
같은 쿼리를 빠르게 만든 것이 아니라, 비정형 검색·관련도·형태소 분석이라는 요구사항에 맞는 저장소로 책임을 옮긴 결과입니다.


마무리

이번 포스팅에서는 MySQL LIKE 기반 검색의 한계를 분석하고, Elasticsearch 도입을 통해 평균 응답 시간을 약 4.4배 개선한 과정을 정리하였습니다.

시도결과원인
B-Tree 인덱스 추가실패LIKE %keyword% 앞 와일드카드로 인한 인덱스 미사용
MySQL Full-Text Search부분 해결한국어 형태소 및 복합 필드 처리 한계
Elasticsearch 도입해결역색인 + Nori + nested 구조가 요구사항과 부합

0개의 댓글