[Diary] Elastic Search로 100만 데이터 검색 속도 향상시키기
본문 바로가기

Development/Diary

[Diary] Elastic Search로 100만 데이터 검색 속도 향상시키기

이 글에서는 상품 서비스에 CQRS 패턴을 적용하고, Query를 위해 Elasticsearch를 적용한 것을 다룹니다.

문제 상황: RDBMS의 Full Scan 동작으로 인한 상품 검색 성능 저하

기존의 상품 검색의 구현은 위와 같이 QueryDSL로 쿼리가 작성되어 있었습니다.

주목해야 하는 코드는 containsIgnoreCase입니다. containsIgnoreCase 조건은 productName 필드 내에서 키워드를 위치와 상관없이 검색하므로 RDBMS에서는 일반적으로 앞에 와일드카드가 포함된 (% keyword%) 검색을 수행하게 됩니다. 이러한 검색은 인덱스를 사용할 수 없게 만들어, RDBMS가 테이블의 모든 productName을 평가해야 하기 때문에 전체 테이블 스캔을 하게 됩니다.

실행 계획에서 Seq Scan이 표시된 부분을 보면, 해당 쿼리는 순차 스캔(Sequential Scan), 즉 풀 스캔을 수행하고 있음을 의미합니다.

.containsIgnoreCase(keyword)는 필드 값의 모든 위치에 대해 부분 문자열 검색을 수행하기 때문에, 일반적인 인덱스는 사용할 수 없습니다. 인덱스가 작동하려면 검색 문자열이 필드 값의 시작 부분에 있어야 하는데, %keyword%는 문자열의 임의 위치를 대상으로 하기 때문입니다. 따라서 인덱스가 없으면 DBMS는 모든 레코드를 스캔하여 조건을 평가해야 하며, 이는 대규모 데이터베이스에서 성능 저하를 초래할 수 있습니다.

논리적 삭제 및 공개 필드 조건의 경우, isDelete.eq(false) 및 isPublic.eq(true)와 같은 조건은 해당 필드에 인덱스가 있다면 인덱스의 이점을 얻을 수 있지만 containsIgnoreCase 조건 때문에 이점이 상쇄됩니다. productName에 키워드가 포함되는지 확인하기 위해 여전히 모든 레코드를 스캔해야 하기 때문입니다.

성능을 개선하기 위한 방법 1: PostgreSQL에서 전문 검색 인덱스 사용하기?

PostgreSQL에서는 GIN 인덱스와 to_tsvector를 사용하여 전문 검색을 수행할 수 있습니다.

 

전문 인덱스 생성: 이 인덱스는 productName과 description 필드의 텍스트를 결합하여 전문 검색에 최적화된 인덱스를 생성합니다.

CREATE INDEX product_fulltext_idx ON products USING GIN (to_tsvector('english', productName || ' ' || description));

productName과 description 필드에 대해 전문 검색 인덱스를 생성하려면 GIN 인덱스를 사용합니다. 

전문 인덱스를 활용한 검색

SELECT * FROM products WHERE to_tsvector('english', productName || ' ' || description) @@ to_tsquery('phone');

여기서 @@ 연산자는 to_tsvector로 변환된 텍스트가 to_tsquery에 주어진 검색어와 일치하는지 확인하는 연산자입니다.

전문 검색 인덱스를 사용해 키워드를 검색하려면 to_tsquery를 활용할 수 있습니다.

위 SQL문은  "phone" 키워드로 상품을 검색하는 쿼리입니다.

  • to_tsvector: 텍스트를 토큰화하여 검색 가능한 텍스트 형태로 변환합니다. 언어 설정(예: 'english')을 통해 불용어(관사, 전치사 등)를 제거하고, 단어의 기본형을 인덱싱할 수 있습니다.
  • to_tsquery: 검색어를 토큰화하여 전문 검색 쿼리를 작성합니다.

하지만, Postgres의 GIN 인덱스는 한국어 특유의 문법 구조형태소 분석을 완벽히 처리하지 못할 수 있습니다.

성능을 개선하기 위한 방법 2: 외부 검색 엔진 사용하기 (예: Elasticsearch)

대규모 텍스트 검색이 필요한 경우, 데이터베이스의 전문 인덱스 대신 Elasticsearch와 같은 외부 검색 엔진을 사용하는 것이 좋습니다. Elasticsearch는 대규모 데이터와 복잡한 검색 쿼리에 특화되어 있습니다.

예를 들어, products라는 index를 생성하고 각 상품의 productName과 description 필드를 저장합니다.

Elasticsearch에서는 match 쿼리를 사용하여 상품명을 검색할 수 있습니다. 예를 들어 productName 또는 description 필드에 "phone"이 포함된 상품을 검색하려면 다음과 같이 작성합니다:

GET /products/_search
{
  "query": {
    "multi_match": {
      "query": "phone",
      "fields": ["productName", "description"]
    }
  }
}

상품 도메인의 경우, 대규모 데이터셋에서 쿼리할 가능성이 더 크므로, Elasticsearch를 사용하기로 결정하였습니다.

ELK

ELK Stack은 Elastic Stack의 핵심 구성 요소로, 데이터의 검색, 시각화, 분석을 통해 조직의 문제 해결과 성공을 지원하는 검색 플랫폼입니다. 여기에는 Elasticsearch, Logstash, Kibana 세 가지가 포함되어 있으며, 각 구성 요소는 독립적으로 작동하면서도 상호 보완적으로 결합되어 데이터의 수집과 처리, 시각화를 간편하게 만듭니다. ELK Stack은 특히 실시간 데이터 분석을 통한 운영 인사이트 제공에 강점을 가지고 있습니다.

역색인(Inverted Index) 구조

일반적인 인덱스 구조는 아래와 같이 키를 ID로 하고 데이터를 매핑합니다.

인덱스: ID
----------------------
1 -> "나는 데이터베이스를 좋아합니다."
2 -> "나는 Elasticsearch를 좋아합니다."
3 -> "데이터베이스와 Elasticsearch는 다릅니다."
4 -> "Elasticsearch는 검색을 잘합니다."

하지만 역색인은 단어를 키로 하여 해당 단어가 포함된 문서 ID 목록을 저장합니다. 위 데이터를 기반으로 역색인을 만들어보겠습니다.

단어            | 문서 ID 목록
------------------------------
나는            | [1, 2]
데이터베이스     | [1, 3]
좋아합니다       | [1, 2]
Elasticsearch   | [2, 3, 4]
다릅니다         | [3]
검색            | [4]
잘합니다         | [4]

이 역색인 구조에서는 특정 단어에 대해 어떤 문서들이 포함되어 있는지 빠르게 알 수 있습니다. 예를 들어, "Elasticsearch"라는 단어가 포함된 모든 문서를 찾고 싶다면, 역색인에서 해당 단어를 찾아 [2, 3, 4]라는 문서 목록을 즉시 얻을 수 있습니다.

 

이렇게 Elasticsearch가 검색 속도가 빠른 이유는 데이터를 역색인 방식으로 저장하기 때문인데요, 역색인은 전문(全文) 검색과 텍스트 분석에 탁월한 성능을 발휘하지만 항상 좋은 선택은 아닙니다.

 

역색인의 장점

  1. 빠른 전문 검색: 역색인은 단어와 그것이 포함된 문서 간의 매핑을 가지고 있어, 특정 단어를 포함하는 문서를 빠르게 검색할 수 있습니다.
  2. 복잡한 검색 쿼리 처리: Boolean 검색, 구 검색, 와일드카드 검색 등 복잡한 검색 쿼리를 효율적으로 처리할 수 있습니다.
  3. 랭킹 및 스코어링: 단어의 빈도나 문서의 중요도에 따라 검색 결과를 랭킹 화하여 사용자에게 더 관련성 높은 결과를 제공할 수 있습니다.

역색인의 단점 및 한계

  1. 인덱스 크기 증가: 역색인은 단어별로 문서 목록을 저장하므로, 데이터 양이 많을수록 인덱스 크기가 크게 증가합니다. 이는 저장 공간과 메모리 사용량을 늘리고, 시스템 자원에 부담을 줄 수 있습니다.
  2. 인덱스 업데이트 비용: 데이터가 빈번하게 변경되는 환경에서는 인덱스를 지속적으로 업데이트해야 하며, 이는 성능 저하와 추가적인 자원 소비를 초래합니다.

언제 역색인 구조가 적합하지 않을까요?

  • 단순한 키-값 조회가 주된 경우: 단순히 특정 키로 데이터를 조회하는 경우에는 일반적인 인덱스 구조가 더 효율적입니다.
  • 데이터 변경이 빈번한 경우: 트랜잭션이 많고 데이터의 삽입, 삭제, 업데이트가 빈번하다면 역색인의 유지 비용이 커집니다.
  • 저장 공간이 제한적인 경우: 인덱스 크기가 큰 역색인은 저장 공간을 많이 차지하므로, 저장 공간이 제한적이라면 부적합할 수 있습니다.

ELK Docker compose 및 Elastic Search 설정하기

Docker Compose 파일은 아래 링크 Git Repo를 사용하였습니다.

deviantony/docker-elk: The Elastic stack (ELK) powered by Docker and Compose.

 

GitHub - deviantony/docker-elk: The Elastic stack (ELK) powered by Docker and Compose.

The Elastic stack (ELK) powered by Docker and Compose. - deviantony/docker-elk

github.com

git clone 후 아래 명령어를 수행합니다.

#Mac
docker compose up setup
docker compose up -d

#Window
docker-compose up setup
docker-compose up

 

Docker 컨테이너를 시작하고, http://localhost:5601/ 에 접속해 봅시다.

처음 User 아이디는 elastic,. env를 안 건드렸다면 비밀번호는 changeme입니다.

Elasticseacrh를 사용하기 위해 위 Content로 이동하면 아래와 같이 화면이 나옵니다.

 

Create a new index를 클릭하여 데이터를 저장할 인덱스를 만들어줍니다.

Spring boot에서 ElasticSearch 설정하기

아래 의존성을 추가해 줍니다.

implementation 'org.springframework.data:spring-data-elasticsearch:5.3.5'

ElasticsearchConfiguration을 상속하여 clientConfiguration 메서드를 구현합니다.

아래는 저의 프로젝트에서 사용하는 코드입니다.

@Configuration
public class ElasticSearchConfig extends ElasticsearchConfiguration {
    private final String hostUrl;
    private final String username;
    private final String password;

    public ElasticSearchConfig(@Value("${spring.data.elasticsearch.host}") String hostUrl,
                         @Value("${spring.data.elasticsearch.username}") String username,
                         @Value("${spring.data.elasticsearch.password}") String password) {
        this.hostUrl = hostUrl;
        this.username = username;
        this.password = password;
    }

    @Override
    @SneakyThrows
    public ClientConfiguration clientConfiguration() {
        return ClientConfiguration.builder()
                .connectedTo(hostUrl)
                .withConnectTimeout(Duration.ofSeconds(5))
                .withSocketTimeout(Duration.ofSeconds(3))
                .withBasicAuth(username, password)
                .withHeaders(() -> {
                    HttpHeaders headers = new HttpHeaders();
                    headers.add("currentTime", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
                    return headers;
                })
            .build();
    }
}
  • @SneakyThrows: 예외 처리를 강제하지 않는 Lombok 애너테이션으로, CheckedException를 코드에서 명시적으로 처리하지 않고 던지게 해 줍니다. 예를 들어, try-catch나 throws 키워드를 명시하지 않아도 Exception을 일으킵니다.
  • ClientConfiguration.builder(): 이 메서드는 ClientConfiguration 객체의 빌더 패턴을 사용하여 구성 요소를 설정합니다.
  • . connectedTo(hostUrl): 서버에 연결하기 위해 필요한 URL을 지정합니다. hostUrl은 서버의 호스트 주소를 담고 있으며, 서버와의 연결을 설정하는 데 사용됩니다.
  • 타임아웃 설정:
    • . withConnectTimeout(Duration.ofSeconds(5)): 연결 타임아웃을 설정하여, 연결을 시도할 때 서버가 응답하지 않으면 5초 후에 연결을 포기하게 합니다.
    • . withSocketTimeout(Duration.ofSeconds(3)): 데이터 송수신 타임아웃을 설정하여, 연결 후 데이터를 주고받을 때 응답이 없으면 3초 후에 타임아웃 처리합니다.
  • 기본 인증 설정:
    • . withBasicAuth(username, password): 서버와의 연결에서 기본 인증을 설정합니다. username과 password는 HTTP 기본 인증 헤더를 통해 전송되며, 서버에서 사용자를 인증하는 데 사용됩니다.
  • 헤더 설정:
    • . withHeaders(() -> {... }): 요청마다 동적으로 HTTP 헤더를 추가합니다.
      • HttpHeaders headers = new HttpHeaders();: HttpHeaders 객체를 생성합니다.
      • headers.add("currentTime", LocalDateTime.now(). format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));: 현재 시간을 ISO-8601 형식으로 문자열로 변환하여 "currentTime"이라는 헤더로 추가합니다. 이 동적 헤더는 요청을 보낼 때마다 갱신됩니다.

자세한 설정은 아래 링크에서 확인할 수 있습니다.

https://docs.spring.io/spring-data/elasticsearch/reference/elasticsearch/clients.html#elasticsearch.clients.configuration

 

Elasticsearch Clients :: Spring Data Elasticsearch

Client behaviour can be changed via the ClientConfiguration that allows to set options for SSL, connect and socket timeouts, headers and other parameters. Example 1. Client Configuration import org.springframework.data.elasticsearch.client.ClientConfigurat

docs.spring.io

Repository는 아래와 같이, 쿼리 메서드를 지원합니다. ElasticsearchRepository를 상속하여 사용 가능합니다.

public interface ProductSearchRepository extends ElasticsearchRepository<ProductDocument, UUID> {
    // Retrieve all products where isDelete is false and isPublic is true
    Page<ProductDocument> findAllByIsDeleteIsFalseAndIsPublicIsTrue(Pageable pageable);

    // Retrieve a product by productId where isDelete is false and isPublic is true
    Optional<ProductDocument> findByProductIdAndIsDeleteIsFalseAndIsPublicIsTrue(UUID productId);

    // Retrieve a product by productId where isDelete is false
    Optional<ProductDocument> findByProductIdAndIsDeleteIsFalse(UUID productId);

    Page<ProductDocument> findByProductNameContainingAndIsDeleteIsFalseAndIsPublicIsTrue(String keyword, Pageable pageable);

}

물론 위처럼 조건이 많아지면 메서드 명이 길어져서 가독성이 좋지 않습니다. 

ElasticsearchOperations를 사용하여 직접 쿼리를 구현하는 방법도 있으니, 아래 공식 문서를 참조하시면 되겠습니다.

https://docs.spring.io/spring-data/elasticsearch/reference/elasticsearch/template.html

 

Elasticsearch Operations :: Spring Data Elasticsearch

When a document is retrieved with the methods of the DocumentOperations interface, just the found entity will be returned. When searching with the methods of the SearchOperations interface, additional information is available for each entity, for example t

docs.spring.io

이번엔 Index에 저장할 Document를 구현합니다.

아래는 저의 프로젝트에서 상품 문서로 사용할 코드입니다.

@Document(indexName = "products")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductDocument {

    @Id
    @Field(name = "product_id", type = FieldType.Keyword)
    private UUID productId;

    @Field(name = "product_name", type = FieldType.Text)
    private String productName;

    @Field(name = "description", type = FieldType.Text)
    private String description;

    @Field(name = "product_price", type = FieldType.Double)
    private BigDecimal productPrice;

    @Field(name = "product_image", type = FieldType.Text)
    private String productImage;

    @Field(name = "is_public", type = FieldType.Boolean)
    private Boolean isPublic;

    @Field(name = "is_delete", type = FieldType.Boolean)
    private Boolean isDelete;
    
}

@Document 어노테이션으로 인덱스 이름을 명시해 주고, @Field 어노테이션으로 실제로 저장할 필드 이름과 타입을 명시합니다.

성능 평가 해보기: RDBMS vs Elastic Search

테스트 데이터 셋은 100만 개입니다.

(DB에 저장되어 있던 데이터를 Elasticsearch index에 옮기다가 문제가 생겨서 80개가 더 늘어났습니다.)

Jmeter 설정을 위와 같이 설정했습니다. 단순히 성능을 평가하기 위함이라 큰 부하는 주지 않았습니다.

  • Number of Threads (Thread Count): 10
    • 적당한 동시 요청 수를 유지하면서 서버의 검색 성능을 평가할 수 있습니다.
  • Ramp-Up Period (초): 10초
    • 10초 동안 10개의 스레드가 순차적으로 시작되도록 하여, 초당 1개씩 요청이 추가되도록 설정합니다.
  • Loop Count: 10
    • 각 스레드가 10회 반복하도록 설정하여, 총 100회 검색 요청을 보내 성능을 측정합니다.

100개의 데이터가 포함된 1 페이지를 요청하는 엔드포인트로 성능을 평가해 보겠습니다.

기존의 RDBMS를 사용한 결과
Elasticsearch를 사용했을 때 결과

Average (평균 응답 시간)

  • QueryDSL: 평균 응답 시간이 100 ms
  • Elasticsearch: 평균 응답 시간이 26 ms

Elasticsearch를 사용했을 때 평균 응답 시간이 현저히 낮아졌습니다. 이뿐만 아니라, Min, Max 도 Elasticsearch를 사용했을 때 더 좋은 성능을 보였습니다.

추가: 한국어 검색 Analyzer nori

한글은 형태의 변형이 매우 복잡한 언어입니다. 특히 복합어, 합성어 등이 많아 하나의 단어도 여러 어간으로 분리해야 하는 경우가 많아 한글을 형태소 분석을 하려면 반드시 한글 형태소 사전이 필요합니다.

 

예를 들어, 한국어는 어절 단위로 검색되기 때문에, analyzer 없이 검색하면 문장에서 특정 단어를 찾기 어려워질 수 있습니다.

  • 검색어: "대한민국"
  • 문서: "대한민국은 아름다운 나라입니다."

analyzer 없이 검색하면 "대한민국은" 전체가 하나의 토큰으로 처리될 수 있어, 단순히 "대한민국"을 검색했을 때 매칭되지 않을 가능성이 높습니다.

Elasticsearch 6.6 버전 부터 공식적으로 Nori(노리) 라고 하는 한글 형태소 분석기를 Elastic사에서 공식적으로 개발해서 지원을 하기 시작했습니다.

 

자세한 내용은 아래 링크에서 참조 가능합니다.

https://esbook.kimjmin.net/06-text-analysis/6.7-stemming/6.7.2-nori

 

6.7.2 노리 (nori) 한글 형태소 분석기 | Elastic 가이드북

이 문서의 허가되지 않은 무단 복제나 배포 및 출판을 금지합니다. 본 문서의 내용 및 도표 등을 인용하고자 하는 경우 출처를 명시하고 김종민(kimjmin@gmail.com)에게 사용 내용을 알려주시기 바랍

esbook.kimjmin.net

마치며

CQRS 패턴을 통해 PostgreSQL과 Elasticsearch의 강점을 결합함으로써 시스템의 성능과 유연성을 향상할 수 있었습니다. PostgreSQL은 안정적인 데이터 저장과 트랜잭션 관리를 담당하고, Elasticsearch는 실시간 검색 및 분석 기능을 담당하여 각자의 역할을 최적화하였습니다. 하지만 데이터 일관성 문제와 데이터 동기화의 복잡성 등을 고려해야 하며, 추가적인 인프라 관리와 유지보수 계획도 함께 수립해야 합니다.

참조