본문으로 건너뛰기
  1. Posts/

MySQL 전문 검색의 한계, Elasticsearch 도입으로 검색 API 성능 개선하기

NineKoo9
작성자
NineKoo9
목차

이전 글: [MySQL] Left Join에서 Subquery로 변경 후 쿼리 성능 30배 향상하기

앞선 글에서 전문 검색(FullText Search)을 사용하는 쿼리의 구조를 변경하여 쿼리의 수행시간을 30배 단축했습니다.

하지만 리뷰 개수와 사용자들이 많이 선택한 상위 태그 등 추가 정보를 보여줘야 한다는 요구사항을 반영하기 위한 쿼리를 작성했을 때, 수행시간이 조금 증가하는 단점이 있었습니다.

그리고 무엇보다 이러한 비즈니스적 요구사항이 쿼리 안에 녹아져 있어 DB접근 기술을 변경하거나 비즈니스 로직을 수정할 필요가 생겼을 때, 쿼리를 직접 수정해줘야 하는 만큼 유지보수 관점에서 유연성이 떨어진다는 문제점이 존재합니다.

하지만 그전에 해결해야 할 문제를 명확히 정의할 필요가 있었습니다.


어떤 문제를 해결하고 싶은가?
#

개선해야할 사항은 단순히 쿼리의 수행시간을 단축시키는 부분이 아닙니다. 쿼리의 수행시간 개선은 API의 성능을 향상시키는 도구 중 하나일 뿐입니다.

조금 더 크게 보아서 문제를 정의하면, 기존 API는 쿼리의 수행시간으로 인해 Postman으로 요청 시 응답까지 대략 6.85초가 걸렸지만, 결국 API의 성능이 나오지 않는 것을 문제로 정의할 수 있습니다.

그리고 지금부터 목표는 API의 성능이 나오지 않는 것을 해결하는 것입니다.


먼저 비즈니스 요구사항을 정의합니다
#

  1. /product?search="xxx" 검색을 요청합니다. 또한 page와 size 파라미터로 페이징을 적용합니다.
  2. 각 product에 해당하는 review의 갯수를 표시합니다.
  3. 각 product에 대한 상위 3개 태그를 표시합니다. 사용자들이 리뷰 작성 시 자유롭게 선택한 태그들 중 가장 많이 선택된 3개를 보여줍니다.

성능 목표 설정
#

API의 성능을 개선하기 위해서는 먼저 목표가 필요합니다. 목표 없이 테스트를 진행하면 결과가 좋은 것인지 나쁜 것인지 판단할 기준이 없기 때문입니다.

데이터 규모로부터 서비스 규모 추정
#

현재 테스트 환경에 저장된 데이터는 아래와 같습니다.

  • Product: 150만건
  • Review: 1,000만건
  • ProductDietTag: 2,000만건

Product는 시드 데이터와 사용자 등록이 혼합되어 있다고 가정하면, DAU와 직접 비례하지 않습니다. 반면 Review는 사용자가 직접 작성하는 데이터이므로 역으로 DAU를 추정할 수 있습니다.

  • 운영 기간 1년 가정: 10,000,000건 / 365일 ≈ 일 평균 27,000건 리뷰 생성
  • 음식 기록 앱 특성상 DAU 중 리뷰를 작성하는 비율은 높은 편 (기록 행위 자체가 핵심 기능)
  • 인당 일 평균 리뷰 수: 약 0.6~1.35건 (활성 사용자 기준)
  • DAU ≈ 27,000 / 0.6 ~ 27,000 / 1.35 ≈ 약 20,000~45,000명

보수적으로 DAU 20,000명을 기준으로 설정합니다.

트래픽 추정
#

식사 기록 서비스 특성상 검색 API는 하루 15~25회/인 호출됩니다. (각 식사 기록 시 제품 검색 + 탐색 5 ~ 8회 가정)

  • 일일 총 호출: 20,000명 × 20회 = 400,000 req/day
  • 피크 시간대(점심 11:30~13:30, 저녁 18:00~20:00): 전체의 50% 이상이 4시간에 집중
  • 피크 RPS: 약 15 RPS
  • 순간 버스트(피크의 3~5배): 약 45~75 RPS

응답 시간 목표
#

지표목표근거
평균 응답 시간< 200ms사용자가 지연을 느끼지 않는 수준
Error Rate< 0.1%검색 실패 시 사용자 이탈 높음

테스트 부하 설정
#

이번 테스트에서는 VUser 300명을 사용합니다. 이는 현실적 피크(15 RPS)보다 높은 부하로, 시스템의 한계점을 파악하기 위한 스트레스 테스트 성격입니다. 목표 부하에서의 성능이 아닌, 극한 상황에서 시스템이 어디까지 버틸 수 있는지를 확인하는 데 목적이 있습니다.


어떻게 API의 성능을 비교할까?
#

위의 요구사항을 만족하는 API를 만들기 위해 기존에는 DataGrip을 사용하여 쿼리의 수행시간을 분석했습니다. 하지만 프로세스 구현 방식이나 테이블 구조가 변경될 경우 데이터 구조도 함께 달라지기 때문에, 단순히 몇백만 개의 데이터로 쿼리 수행시간을 비교하는 것은 정확한 기준이 되기 어렵습니다.

따라서 더 정확한 성능 측정을 위한 기준이 필요해졌습니다.

API 성능을 제대로 테스트하기 위해서는 Postman으로 단일 응답 시간을 측정하는 것만으로는 부족했고, 더 많은 요청이 필요했습니다. 또한 검색 기능을 테스트할 때 동일한 키워드만으로는 한계가 있었습니다. MySQL InnoDB의 캐시 기능을 고려하면 다양한 데이터에 대한 접근이 필요했기 때문입니다. 이러한 이유로 nGrinder를 사용해서 다양한 데이터에 접근하는 트래픽을 발생시키고, PrometheusGrafana를 활용한 모니터링을 통해 API 성능을 평가하기로 결정했습니다.


테스트 구성
#

1. DB의 스키마 구성
#

앞선 쿼리 구조 변경 글에서 가져온 DDL을 그대로 사용합니다.

-- 150만개의 데이터
-- n-gram 알고리즘을 적용한 전문 검색 인덱스 사용
CREATE TABLE product (
    product_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_name VARCHAR(255) NOT NULL,
    product_corp VARCHAR(255),
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    FULLTEXT INDEX fulltext_idx (product_name, product_corp) WITH PARSER ngram
);
-- 1000만개의 데이터
CREATE TABLE review (
    review_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_id BIGINT NOT NULL,
    content VARCHAR(255),
    rating TINYINT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL
);
CREATE INDEX idx_product_id ON review (product_id);
-- 2000만개의 데이터
CREATE TABLE product_diet_tag (
    product_diet_tag_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_id BIGINT,
    diet_tag_id BIGINT,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL
);
CREATE INDEX idx_product_id ON product_diet_tag (product_id);
-- 10개의 데이터
CREATE TABLE diet_tag (
    diet_tag_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    diet_tag_name VARCHAR(255) NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL
);
DB 스키마 구조

현재 MySQL DB에는:

  • product 테이블 150만 rows
  • review 테이블 1000만 rows
  • product_diet_tag 테이블 2000만 rows
  • diet_tag 테이블 10개의 rows

저장되어 있습니다.

2. 모니터링과 nGrinder을 위한 클라우드 구축
#

클라우드 구성도

최적의 리소스를 할당하기 위해 서버는 각각의 인스턴스에 배치했고, Prometheus와 Grafana만 동일한 인스턴스에 배치했습니다.

인스턴스스펙
Target 애플리케이션 서버vCPU 2EA, Memory 8GB
Target MySQLvCPU 2EA, Memory 8GB
모니터링vCPU 2EA, Memory 8GB
nGrinder ControllervCPU 2EA, Memory 8GB
nGrinder AgentvCPU 2EA, Memory 8GB

3. 테스트 시나리오
#

부하 테스트는 5분동안 진행하였고 가상 유저는 최대 300명으로 아래의 이미지처럼 30초마다 점진적으로 증가시켰습니다.

부하 테스트 시나리오

API 호출 시나리오에서는 검색 키워드, size, page 값을 랜덤한 문자열이나 숫자로 다양하게 적용했습니다.


첫 번째 테스트: 전문 검색(FullText Search)만을 이용한 테스트
#

요구사항을 모두 만족하는 API를 테스트하기 전에, 전문 검색 기능만 단독으로 사용했을 때 어느 정도 API 성능이 나올지 궁금해서 테스트를 진행해 보았습니다.

아래의 코드는 API 테스트에 사용된 레포지토리 클래스입니다. 한번의 API 호출에서 페이징을 위해 쿼리는 2가지가 사용되며, 이번 테스트에서 product 테이블을 제외한 다른 테이블은 건드리지 않았습니다.

@Repository
@RequiredArgsConstructor
@Transactional
public class JdbcTemplateProductSearchRepository {
    private final NamedParameterJdbcTemplate namedParameterJdbcTemplate;
    private final DataClassRowMapper<ProductSearchResponse> beanPropertyRowMapper = new DataClassRowMapper<>(ProductSearchResponse.class);

    public Page<ProductSearchResponse> findFullTextSearch(String keyword, Pageable pageable) {
        Integer total = getTotalCount(keyword);

        // OFFSET과 LIMIT을 적용했습니다.
        String sql = "SELECT " +
                "p.product_id, p.product_name, p.product_corp " +
                "FROM product p " +
                "WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)" +
                "LIMIT :offset, :limit";

        MapSqlParameterSource parameters = new MapSqlParameterSource()
                .addValue("keyword", keyword)
                .addValue("offset", pageable.getOffset())
                .addValue("limit", pageable.getPageSize());

        List<ProductSearchResponse> queried = namedParameterJdbcTemplate.query(sql, parameters, beanPropertyRowMapper);
        return new PageImpl<>(queried, pageable, total);
    }

    // 페이징 처리를 위해 전체 갯수를 조회하는 COUNT(*) 쿼리.
    private Integer getTotalCount(String keyword) {
        String sql = "SELECT COUNT(*) " +
                "FROM product p " +
                "WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)";

        MapSqlParameterSource parameters = new MapSqlParameterSource()
                .addValue("keyword", keyword);
        return namedParameterJdbcTemplate.queryForObject(sql, parameters, Integer.class);
    }
}

테스트 결과
#

nGrinder 결과 요약
nGrinder 결과 그래프

테스트의 결과는 매우 심각했습니다.

평균 TPS(Transaction Per Second)가 2.1 나왔고, 응답 시간은 점차 증가하여 30초가 지속되었습니다. 또한 에러율이 51.3% (958개 중에 491개 실패)가 나왔습니다.

Grafana 모니터링

에러의 원인은 애플리케이션 로그를 확인해보니 connection timeout 에러가 발생했습니다. 이는 HikariCP 커넥션 풀의 모든 커넥션이 사용 중인 상황에서, 새로운 커넥션을 얻기 위한 대기 시간이 HikariCP의 기본 설정값 30초를 초과했기 때문입니다.

DB CPU 사용량 1
DB CPU 사용량 2

NCP에서 제공하는 DB 인스턴스 모니터링을 통해 문제를 분석한 결과, CPU 사용량이 최대치에 도달했으며 이것이 성능 병목 지점임을 확인했습니다.

목표 대비 분석: 목표인 평균 응답 시간 < 200ms, Error Rate < 0.1%에 비해 압도적으로 낮은 성능입니다. VUser 300명이라는 높은 부하에서 MySQL의 전문 검색이 CPU를 100% 점유하며 TPS가 2.1밖에 나오지 않았습니다. 현실적 피크(15 RPS)에서도 안정적인 서비스가 어려울 것으로 판단됩니다.

번외 테스트
#

API의 실제 성능을 측정하기 위해 HikariCP의 connection_timeout을 여유롭게 300초로 변경한 후, 커넥션 획득 타임 아웃 에러가 발생하지 않는 테스트를 진행했습니다.

번외 테스트 요약
번외 테스트 그래프

API의 응답은 모두 성공하였지만, 평균 TPS가 1.9, 평균 응답 시간은 대략 1.67분을 유지, 최대 1.79분이 나왔습니다.

목표 대비 분석: 에러는 사라졌지만 평균 응답 시간이 약 1분입니다. 목표 200ms의 약 300배에 달하므로, 타임아웃 완화로는 근본적인 문제를 해결할 수 없습니다.

그리고 다음 번외 테스트로 DB 인스턴스의 스펙을 단계적으로 Scale Up 해보았습니다.

단계스펙
기존vCPU 2EA, Memory 8GB
첫 번째 Scale UpvCPU 8EA, Memory 32GB
두 번째 Scale UpvCPU 32EA, Memory 128GB
Scale Up 결과 1
Scale Up 결과 2
Scale Up 결과 3

테스트 결과, 두 번째 Scale Up 스펙(vCPU 32EA, Memory 128GB)으로 테스트를 진행해도 평균 TPS 10.5, 평균 응답 시간은 대략 15~20초가 걸렸습니다. 다만 CPU 사용량은 30%까지 감소했습니다.

목표 대비 분석: 16배 Scale Up(vCPU 2→32)으로도 TPS 10.5, 응답 시간 15초입니다. 목표 피크 RPS 15에도 미달하며, 응답 시간은 목표의 75배입니다. 따라서 MySQL 전문 검색만으로는 이 규모의 데이터에서 목표 성능 달성이 어렵다고 판단했습니다. 다만 Elasticsearch로 넘어가기 전에, 실제 서비스 요구사항을 모두 포함한 MySQL 기준선도 한 번 더 측정해보겠습니다.

(Grafana 대시보드에서 중간에 값이 비어있는 현상이 계속 발견되었습니다. 이에 대한 글은 Grafana 대시보드 누락(단절?) 현상에서 다루었습니다.)


두 번째 테스트: 모든 요구사항을 만족한 테스트
#

Elasticsearch로 전환했을 때의 개선 폭을 비교하기 위해, 이전 글에서 사용했던 쿼리를 적용하여 모든 요구사항을 만족하는 MySQL 기준선 테스트를 한 번 더 진행했습니다.

SELECT p.product_id,
       p.product_name,
       p.product_corp,
       (SELECT COUNT(*) FROM review r WHERE r.product_id = p.product_id) AS review_count,
       (SELECT GROUP_CONCAT(diet_tag_name)
        FROM (SELECT diet_tag_name
              FROM product_diet_tag pdt
              WHERE pdt.product_id = p.product_id
              GROUP BY diet_tag_name
              ORDER BY COUNT(diet_tag_name) DESC
              LIMIT 3) AS top3_diet_tag) AS top3_diet_tag_names
FROM product p
WHERE MATCH(p.product_name, p.product_corp) AGAINST(:keyword)
LIMIT :offset, :limit;
테스트 결과 요약
테스트 결과 그래프

예상했던 대로 테스트 결과는 처참했으며, 전문 검색을 단독으로 사용했을 때와 비교해봐도 크게 달라진 점이 없습니다.


왜 이렇게 낮은 성능이 나오는 것일까요?
#

이는 전문 검색 인덱스의 특징을 고려해야 합니다.

전문 검색에는 기존의 MySQL InnoDB 스토리지 엔진에서 제공하는 일반적인 용도의 B-tree 인덱스를 사용할 수 없습니다.

전문 검색(Full Text search) 인덱스는 문서 전체를 분석하고 검색하기 위한 역색인 구조의 인덱싱 알고리즘입니다.

그리고 인덱싱 기법에 따라 크게 “단어의 어근 분석”“n-gram 분석” 알고리즘으로 나눌 수 있습니다.

“단어의 어근 분석” 은 영어와 같이 단어의 변형이 있는 경우 그 단어의 뿌리인 명사 또는 어근을 찾아 인덱싱 합니다.

하지만 한국어, 중국어, 일본어는 이러한 단어 변형이 거의 없으므로, 본문을 일정 길이로 잘라서 토큰으로 인덱싱하는 “n-gram 분석” 을 활용하면 정확성과 효율성을 향상시킬 수 있습니다.

그리고 product 테이블에는 n-gram 분석을 활용한 전문 검색 인덱스가 생성되어 있습니다.

하지만 이러한 방식에도 단점들이 있습니다.

MySQL 서버는 전문 검색 쿼리가 오면 인덱싱할 때와 동일하게 검색어를 토큰 사이즈에 맞게 잘라냅니다. 그리고 잘려진 토큰들에 대해 일치하는 단어의 갯수, 빈도 등을 확인해서 일치율을 계산합니다.

그리고 각 토큰들의 결과에 대해 비교 연산이 수행되며, 이 과정에서 가중치 계산과 정렬이 이루어집니다.

따라서 검색어가 길수록 더 많은 토큰이 생성되고 각 토큰의 결과를 모아 비교 연산을 수행하니 CPU에 가해지는 부하가 더 커지기도 합니다.

또한, 실행계획을 분석해보면 OFFSET이 2000, LIMIT이 10인 경우 2010개의 row를 읽은 것으로 나타납니다.

SELECT p.product_id, p.product_name, p.product_corp
FROM product p
WHERE MATCH (p.product_name, p.product_corp) AGAINST ('닭가슴살')
LIMIT 2000, 10;
실행 계획

하지만 실제 내부적으로 계산되는 행의 수는 단순히 OFFSETLIMIT으로 지정한 개수만큼이 아닙니다.

MySQL은 전문 검색으로 매칭되는 모든 rows에 대해 가중치를 계산하고 정렬한 뒤, 정렬 결과에서 상위 2000번째부터 2010번째까지 반환합니다.

따라서 페이징을 최적화하기 위한 방법들인 No Offset, 커버링 인덱스, Total Count 최적화 같은 기법들을 사용하기에도 어려움이 있었습니다.

실제로 OFFSET을 0으로 설정한 경우와 비교해보았으나 성능상 유의미한 차이는 없었고, 페이징을 위한 COUNT(*) 쿼리를 제거했을 때도 미미한 성능 개선만 있었습니다. (단순히 하나의 트랜잭션에서 2개의 쿼리가 나가던 것을 1개의 쿼리만 나가도록 바꾸니 성능이 대략 2배 정도 좋아지는 매우 당연한 현상…)

  • TPS: 2.1 -> 3.7
  • 에러율: 51.3% -> 28.6% (1372개 중에 393개 실패)

Elasticsearch 엔진을 도입
#

기존에는 Elasticsearch 도입을 비용 부담이 크다는 단점 때문에 선택하지 않았고 MySQL에서 제공하는 전문 검색을 선택했습니다.

하지만 전문 검색을 활용한 경우 100만개가 넘는 데이터에서 성능이 제대로 나오지 않는 치명적인 단점이 발견되었습니다.

또한 애플리케이션에서 사용자가 호출할 API패턴을 고려해 보았을 때, 식품 검색 API는 사용자가 매우 자주 호출하는 API로 서비스에서 핵심적인 역할을 한다는 판단이 들었습니다. 따라서 비용을 투자해서라도 성능을 끌어올릴 필요가 생겼습니다.

이전 테스트에서 DB 엔진을 Scale UP했지만 여전히 TPS는 평균 10 정도, 응답시간은 15초에 가까웠습니다. 제한된 비용 내에서는 Scale UP보다 Elasticsearch 도입이 더 효과적일 것으로 판단했습니다.

초기에는 외부 저장소를 활용한 캐시도 고려했지만, 캐시는 검색 조건이 너무 다양하여 적용이 어려웠고 데이터의 특성과 사용 패턴 분석이 선행되어야 효과적인 부분 캐싱이 가능할 것으로 판단해서 선택하지 않았습니다.

Elasticsearch 엔진은 GCP에서 제공하는 Elastic Cloud 서비스를 이용
#

Elastic Cloud 구성
Elastic Cloud 스펙
  • 인스턴스 스펙: vCPU 5개, Memory 4GB
  • KIBANA를 위한 1GB 메모리 무료 제공
  • 총 비용: 1시간 0.3321$ -> 0.3321 × 24 × 30 = 239$ = 345,594원 (환율 1446원)

현재 프로젝트의 클라우드 서버를 구성한 NCP도 Search Engine Service라는 서비스를 제공하지만, 이상하게 클러스터 접속이 안 되는 문제가 계속 발생했습니다.

NCP는 Search Engine Service 클러스터를 구성하는데 최소 4대의 인스턴스가 필요하며 vCPU 2EA, Memory 8GB 인스턴스 4개를 사용한다면 가격은 GCP의 Elastic Cloud와 비슷하지만 성능은 더 뛰어날 것으로 예상됩니다. (다만 UI와 클러스터 구성의 편의성은 Elastic Cloud가 압도적으로 편합니다. 또한, 새롭게 가입하면 40만원의 크레딧도 지급받을 수 있습니다.)

직접 인스턴스에 Elasticsearch을 구축하는 방법도 고려해보았지만 처음 사용해보는 기술에 대해서 아무것도 모르는 채로 파이프라인을 구성하는데 시간과 어려움이 많이 들것 같다는 생각이 들었습니다.

따라서 우선적으로 Elasticsearch 엔진을 사용하면 얼만큼 API 성능이 나오는지 확인하는데 중점을 두어, 빠르게 구성할 수 있는 클라우드 서비스를 선택했습니다.


Elasticsearch 엔진을 이용한 3번째 테스트
#

테스트 결과 요약
테스트 결과 그래프

위의 테스트 결과는 리뷰 개수와 상위 태그 표시와 같은 요구사항 없이 product에 대한 검색(GET 요청)만을 진행한 테스트 결과입니다.

테스트 결과를 보면 MySQL의 전문 검색을 이용한 경우에 비해서 (connection timeout 에러 제거한 경우):

  • 평균 TPS: 1.9 -> 192.5 (약 100배 향상)
  • 응답 시간: 대략 1.67분 -> 800ms (800ms/100200ms = 1/125 줄어듬)

목표 대비 분석: MySQL 대비 TPS 100배, 응답 시간 1/100 수준으로 극적으로 개선되었습니다. VUser 300명 부하에서 평균 926ms이므로, 현실적 피크(15 RPS)에서는 평균 200ms 이내 목표 달성이 충분히 가능한 수준입니다.


쿼리에 있던 요구사항을 비즈니스 레이어로 이동
#

이전 글에서는 쿼리 안에 모든 요구사항이 들어 있었습니다.

요구사항들은 다음과 같습니다.

  1. 각 product에 해당하는 review의 갯수를 표시합니다.
  2. 각 product에 대한 상위 3개 태그를 표시합니다. 사용자들이 리뷰 작성 시 자유롭게 선택한 태그들 중 가장 많이 선택된 3개를 보여줍니다.

기존 테이블 구조의 문제점
#

기존 테이블 구조

product와 diet_tag는 다대다 관계를 가지며, 중간에 product_diet_tag 테이블을 두어 각 product에 연관된 diet_tag를 저장합니다.

사용자는 product에 대한 review를 작성할 때, 원하는 diet_tag를 여러개 선택해서 저장합니다. 따라서 하나의 review가 작성될 때 product_diet_tag의 데이터는 여러개가 저장됩니다.

예를 들어, product가 100만 개가 있고 각 product마다 100개의 review가 있다고 가정해 보겠습니다. 그리고 사용자들이 각 review마다 3개의 diet_tag를 골고루 지정한다면, product_diet_tag의 데이터는 대략 3억 개가 저장됩니다.

이러한 설계는 문제점이 있다고 판단하여 테이블 구조와 저장 프로세스의 개선이 필요하다고 생각했습니다.

개선된 테이블 구조
#

개선된 테이블 구조

사용자들이 review를 작성할때 product_diet_tag에 tag_count 필드를 만들어 해당 값을 증가시키면 데이터 용량을 훨씬 줄일 수 있습니다.

다시 예를 들어, product가 100만 개 있고 diet_tag가 10개라고 가정해보겠습니다. 이 경우 product_diet_tag의 데이터는 최대 1000만 개로 줄어듭니다. product_diet_tag의 수는 product 수와 diet_tag 수에만 의존하며, review 수에는 영향을 받지 않습니다. 사용자가 review를 엄청 많이 등록하더라도 tag_count 값만 증가시키면 됩니다.

기존 코드 (한방 쿼리)
#

기존의 코드는 아래와 같이 JdbcTemplate을 이용하여 Repository에서 모든 로직을 처리하고 있었습니다.

@Repository
@RequiredArgsConstructor
@Transactional
public class JdbcTemplateProductSearchRepository {
    private final NamedParameterJdbcTemplate namedParameterJdbcTemplate;
    private final DataClassRowMapper<ProductSearchResponse> beanPropertyRowMapper = new DataClassRowMapper<>(ProductSearchResponse.class);

    public Page<ProductSearchResponse> findFullTextSearch(String keyword, Pageable pageable) {
        Integer total = getTotalCount(keyword);
        String sql = "SELECT " +
                "p.product_id, p.product_name, p.product_corp, " +
                "(SELECT COUNT(*) FROM review r WHERE r.product_id = p.product_id) AS review_count, " +
                "(SELECT AVG(r.rating) FROM review r WHERE r.product_id = p.product_id) AS review_avg_rating, " +
                "(SELECT GROUP_CONCAT(diet_tag_name) FROM (SELECT dt.diet_tag_name FROM product_diet_tag pdt JOIN diet_tag dt ON pdt.diet_tag_id = dt.diet_tag_id where pdt.product_id = p.product_id ORDER BY pdt.tag_count DESC LIMIT 3) AS top3_diet_tag) AS top3_diet_tag_names " +
                "FROM product p " +
                "WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)" +
                "LIMIT :offset, :limit";

        MapSqlParameterSource parameters = new MapSqlParameterSource()
                .addValue("keyword", keyword)
                .addValue("offset", pageable.getOffset())
                .addValue("limit", pageable.getPageSize());

        List<ProductSearchResponse> queried = namedParameterJdbcTemplate.query(sql, parameters, beanPropertyRowMapper);
        return new PageImpl<>(queried, pageable, total);
    }

    private Integer getTotalCount(String keyword) {
        String sql = "SELECT COUNT(*) " +
                "FROM product p " +
                "WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)";

        MapSqlParameterSource parameters = new MapSqlParameterSource()
                .addValue("keyword", keyword);
        return namedParameterJdbcTemplate.queryForObject(sql, parameters, Integer.class);
    }
}

리팩터링된 코드
#

위의 코드를 리팩터링하여 아래의 코드로 변경했습니다.

// 쿼리에 IN절을 사용해서 각 productId에 해당하는 리뷰들의 갯수를 한번에 조회합니다.
public interface ReviewRepository extends CrudRepository<Review, Long> {
    @Query("SELECT product_id, COUNT(*) AS review_count FROM review WHERE product_id IN (:productIds) GROUP BY product_id")
    List<ProductReviewCount> countReviewsByProductIds(@Param("productIds") List<Long> productIds);
}
// 쿼리에 IN절을 사용해서 각 productId에 해당하는 product_diet_tag를 모두 조회합니다.
// diet_tag 테이블과 조인하여 diet_tag_name을 가져옵니다.
public interface ProductDietTagRepository extends CrudRepository<ProductDietTag, Long> {
    @Query("SELECT pdt.product_id, dt.diet_tag_name, pdt.tag_count " +
            "FROM product_diet_tag pdt " +
            "LEFT JOIN diet_tag dt on pdt.diet_tag_id = dt.diet_tag_id " +
            "WHERE product_id IN (:productIds)"
    )
    List<ProductDietTagDto> findByProductIds(@Param("productIds") List<Long> productIds);
}
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class ProductSearchService {
    // spring data elasticsearch 리포지토리
    private final ProductDocumentRepository productDocumentRepository;
    private final ReviewRepository reviewRepository;
    private final ProductDietTagRepository productDietTagRepository;
    private final ProductSearchResponseMapper productSearchResponseMapper;

    public Page<ProductSearchResponse> search(String keyword, Pageable pageable) {
        // ProductDocument는 Elasticsearch에 저장된 각 데이터를 매핑합니다.
        // Elasticsearch을 이용해서 keyword에 해당하는 ProductDocument를 검색합니다.
        Page<ProductDocument> productDocuments = productDocumentRepository.findByProductName(keyword, pageable);

        // 검색 결과가 없으면 예외를 반환합니다.
        if (productDocuments.isEmpty()) {
            throw new BusinessException(PRODUCT_NOT_FOUND);
        }

        // Product의 id값을 추출합니다.
        List<Long> productIds = productDocuments.stream()
                .map(ProductDocument::getId)
                .toList();

        List<ProductDietTagDto> tags = productDietTagRepository.findByProductIds(productIds);
        List<ProductReviewCount> productReviewCounts = reviewRepository.countReviewsByProductIds(productIds);

        // productSearchResponseMapper클래스가
        // 각 productId에 맞게 tags를 count값 순으로 내림차순으로 정렬한 후 3개를 선택.
        // 또한 각 productId에 맞게 review 갯수도 매핑하여 결과 반환.
        return productSearchResponseMapper.toResponseList(productDocuments, tags, productReviewCounts);
    }
}

결과적으로 한방 쿼리로 해결하던 것을 3번의 DB 접근으로 나눴지만, 더이상 복잡한 쿼리가 필요 없어졌고 비즈니스 로직과 데이터 접근 로직이 강하게 결합되어 있던 것을 분리하여 코드 가독성이 향상되었습니다.

리팩터링된 코드는 각 역할(제품 조회, 리뷰 조회, 태그 조회)을 개별적으로 처리하고, 서비스 계층에서 결과를 조합하는 방식으로 재사용성 또한 증가했습니다.


마지막 테스트 진행
#

이전 테스트와의 차이점
#

  1. 위의 요구사항들을 모두 반영.
  2. 기존의 GET(검색)요청과 함께 POST(저장)요청을 추가했습니다. 실제 상황을 가정하여 요청 비율은 GET 90%, POST 10%로 설정했습니다. 데이터 저장 시 MySQL과 Elasticsearch의 데이터 동기화는 애플리케이션에서 직접 관리하는 가장 단순한 방식을 선택했습니다.
  3. 테스트 시간을 25분으로 증가. 앞선 테스트에서 요구사항이 없는 Elasticsearch 단독 테스트에서 이미 어느 정도 성능이 나오는 것을 확인했기에 조금 더 긴 시간 동안 테스트를 진행했습니다.
가상 유저 증가 그래프

사용자는 점진적으로 증가하다 4분 정도 지난 후 300명을 유지했습니다.

테스트 결과
#

테스트 결과 요약
테스트 결과 그래프

요구사항이 없는 검색(GET) 요청 테스트와 비교했을 때, TPS가 192.5에서 179.6으로 조금 감소했습니다.

각 API 요청 별로 평균 응답 시간을 살펴보면 GET 요청은 876ms, POST 요청은 GET 요청보다 약 400ms 정도 더 소요된 1.21초(1210ms) 의 결과가 나타났습니다. POST 요청은 Elasticsearch 엔진에 쓰기 작업(읽기에 비해서 상대적으로 느림)과 MySQL에 쓰기 작업을 동기적으로 처리하는데 이 부분에서 더 많은 시간이 소요된 것으로 판단됩니다.

애플리케이션 서버 CPU
MySQL 서버 CPU

애플리케이션 서버의 CPU 사용량은 대략 20~30%정도로 나타났습니다.

그리고 전문 검색만을 이용할 때 가볍게 100%을 찍던 MySQL 서버의 CPU 사용량도 20~25% 정도로 줄어들었습니다.

목표 대비 분석: GET 876ms, POST 1,210ms로 VUser 300명 부하 기준입니다. 응답 시간의 최솟값이 GET 144ms, POST 254ms인 점을 보면 부하가 낮은 상황에서는 목표 근접이 가능합니다. 다만 VUser 300명에서의 평균 응답 시간은 아직 목표를 초과하므로, 추가 개선이 필요합니다.


이번 테스트에서 부족했던 점
#

이번 테스트를 진행하면서 한 가지 아쉬운 점이 있었습니다.

목표를 설정할 때 현실적 피크 RPS를 약 15로 추정했지만, 정작 해당 부하 수준에서의 테스트는 진행하지 않았습니다. 모든 테스트를 VUser 300명이라는 높은 부하로만 진행했기 때문에, 목표 부하에서 실제로 어느 정도 성능이 나오는지 확인하지 못했습니다.

스트레스 테스트를 통해 시스템의 한계점을 파악한 것은 의미가 있었지만, 올바른 성능 테스트 순서는 다음과 같았어야 합니다.

  1. 목표 부하 테스트: 현실적 피크(15 RPS)에서 목표 응답 시간을 달성하는지 확인
  2. 스트레스 테스트: 점진적으로 부하를 높여 시스템이 어디서 한계에 도달하는지 파악
  3. 한계 분석 및 개선: 병목 지점을 분석하고 개선 후 다시 1번부터 반복

이번에는 2번부터 바로 진행한 셈입니다. 앞으로 유사한 성능 테스트를 진행한다면, 목표 부하에서의 검증을 먼저 수행한 뒤 점진적으로 부하를 높이는 순서로 접근하는 편이 더 적절하다고 판단합니다.


다음 글: Spring Boot Tomcat threads.max와 HikariCP maximum-pool-size를 기본으로 설정하면 나타나는 현상