Spring Elasticsearch 검색 심화

Spring Data Elasticsearch란?

Spring Data Elasticsearch는 Elasticsearch를 Spring 생태계에서 쉽게 사용할 수 있도록 하는 프로젝트입니다. Repository 패턴, ElasticsearchOperations, 그리고 네이티브 쿼리 빌더를 통해 전문 검색(Full-Text Search), 집계(Aggregation), 자동 완성(Autocomplete) 등을 구현할 수 있습니다. Spring Boot 3.x 기준 Elasticsearch 8.x를 지원하며, Java API Client 기반으로 동작합니다.

의존성과 설정

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch")
}
# application.yml
spring:
  elasticsearch:
    uris: http://localhost:9200
    username: elastic
    password: changeme
    connection-timeout: 5s
    socket-timeout: 30s

Document 매핑

Elasticsearch의 인덱스와 매핑을 @Document 어노테이션으로 정의합니다. JPA의 @Entity와 유사하지만, 검색 특화 필드 타입을 지원합니다.

@Document(indexName = "products")
@Setting(settingPath = "/elasticsearch/product-settings.json")
public class ProductDocument {

    @Id
    private String id;

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

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

    @MultiField(
        mainField = @Field(type = FieldType.Text, analyzer = "korean"),
        otherFields = {
            @InnerField(suffix = "keyword", type = FieldType.Keyword),
            @InnerField(suffix = "autocomplete", type = FieldType.Text,
                        analyzer = "autocomplete_analyzer")
        }
    )
    private String category;

    @Field(type = FieldType.Double)
    private double price;

    @Field(type = FieldType.Integer)
    private int stockQuantity;

    @Field(type = FieldType.Keyword)
    private String brand;

    @Field(type = FieldType.Keyword)
    private List<String> tags;

    @Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second)
    private LocalDateTime createdAt;

    @Field(type = FieldType.Nested)
    private List<ProductAttribute> attributes;

    @GeoPointField
    private GeoPoint location;
}

커스텀 분석기: 한국어 + 자동완성

한국어 검색과 자동완성을 위한 인덱스 설정입니다.

// resources/elasticsearch/product-settings.json
{
  "analysis": {
    "analyzer": {
      "korean": {
        "type": "custom",
        "tokenizer": "nori_tokenizer",
        "filter": ["nori_readingform", "lowercase", "nori_part_of_speech"]
      },
      "autocomplete_analyzer": {
        "type": "custom",
        "tokenizer": "autocomplete_tokenizer",
        "filter": ["lowercase"]
      },
      "autocomplete_search": {
        "type": "custom",
        "tokenizer": "standard",
        "filter": ["lowercase"]
      }
    },
    "tokenizer": {
      "nori_tokenizer": {
        "type": "nori_tokenizer",
        "decompound_mode": "mixed"
      },
      "autocomplete_tokenizer": {
        "type": "edge_ngram",
        "min_gram": 1,
        "max_gram": 20,
        "token_chars": ["letter", "digit"]
      }
    },
    "filter": {
      "nori_part_of_speech": {
        "type": "nori_part_of_speech",
        "stoptags": ["E", "IC", "J", "MAG", "MAJ", "MM", "SP", "SSC",
                     "SSO", "SC", "SE", "XPN", "XSA", "XSN", "XSV"]
      }
    }
  }
}

Repository 기본 사용

Spring Data Repository 패턴으로 기본 CRUD와 간단한 검색을 수행할 수 있습니다.

public interface ProductSearchRepository
        extends ElasticsearchRepository<ProductDocument, String> {

    // 메서드 이름으로 쿼리 자동 생성
    List<ProductDocument> findByNameContaining(String keyword);

    List<ProductDocument> findByBrandAndPriceBetween(
        String brand, double minPrice, double maxPrice);

    List<ProductDocument> findByTagsIn(List<String> tags);

    Page<ProductDocument> findByCategoryOrderByPriceAsc(
        String category, Pageable pageable);
}

ElasticsearchOperations: 고급 검색

복잡한 검색 로직은 ElasticsearchOperationsNativeQuery를 사용합니다.

@Service
@RequiredArgsConstructor
public class ProductSearchService {

    private final ElasticsearchOperations operations;

    public SearchPage<ProductDocument> search(ProductSearchRequest request) {
        // Bool 쿼리 조합
        BoolQuery.Builder boolQuery = new BoolQuery.Builder();

        // 키워드 검색 (multi_match)
        if (StringUtils.hasText(request.getKeyword())) {
            boolQuery.must(m -> m.multiMatch(mm -> mm
                .query(request.getKeyword())
                .fields("name^3", "description", "category^2", "tags")
                .type(TextQueryType.BestFields)
                .fuzziness("AUTO")
            ));
        }

        // 브랜드 필터
        if (request.getBrand() != null) {
            boolQuery.filter(f -> f.term(t -> t
                .field("brand")
                .value(request.getBrand())
            ));
        }

        // 가격 범위 필터
        if (request.getMinPrice() != null || request.getMaxPrice() != null) {
            boolQuery.filter(f -> f.range(r -> {
                RangeQuery.Builder range = r.field("price");
                if (request.getMinPrice() != null) range.gte(JsonData.of(request.getMinPrice()));
                if (request.getMaxPrice() != null) range.lte(JsonData.of(request.getMaxPrice()));
                return range;
            }));
        }

        // 재고 있는 상품만
        boolQuery.filter(f -> f.range(r -> r
            .field("stockQuantity")
            .gt(JsonData.of(0))
        ));

        NativeQuery query = NativeQuery.builder()
            .withQuery(q -> q.bool(boolQuery.build()))
            .withSort(Sort.by(Sort.Direction.DESC, "_score"))
            .withPageable(PageRequest.of(request.getPage(), request.getSize()))
            .withHighlightQuery(buildHighlight())
            .build();

        return operations.search(query, ProductDocument.class);
    }

    private HighlightQuery buildHighlight() {
        return new HighlightQuery(
            new Highlight(List.of(
                new HighlightField("name"),
                new HighlightField("description")
            )),
            ProductDocument.class
        );
    }
}

자동완성 구현

Edge NGram 기반 자동완성을 구현합니다. 사용자가 타이핑하는 동안 실시간으로 추천 결과를 반환합니다.

public List<String> autocomplete(String prefix, int size) {
    NativeQuery query = NativeQuery.builder()
        .withQuery(q -> q.bool(b -> b
            .should(s -> s.match(m -> m
                .field("name.autocomplete")
                .query(prefix)
                .analyzer("autocomplete_search")
            ))
            .should(s -> s.match(m -> m
                .field("category.autocomplete")
                .query(prefix)
                .analyzer("autocomplete_search")
            ))
        ))
        .withPageable(PageRequest.of(0, size))
        .withSourceFilter(new FetchSourceFilterBuilder()
            .withIncludes("name", "category")
            .build())
        .build();

    SearchHits<ProductDocument> hits = operations.search(query, ProductDocument.class);
    return hits.getSearchHits().stream()
        .map(hit -> hit.getContent().getName())
        .distinct()
        .toList();
}

Aggregation: 집계 분석

검색 결과에 대한 패싯(facet) 필터를 집계로 구현합니다. 쇼핑몰의 “브랜드별 상품 수”, “가격 범위 분포” 같은 기능입니다.

public SearchResultWithFacets searchWithFacets(String keyword) {
    NativeQuery query = NativeQuery.builder()
        .withQuery(q -> q.multiMatch(m -> m
            .query(keyword)
            .fields("name^3", "description", "category")
        ))
        .withAggregation("brands", Aggregation.of(a -> a
            .terms(t -> t.field("brand").size(20))
        ))
        .withAggregation("price_ranges", Aggregation.of(a -> a
            .range(r -> r.field("price")
                .ranges(
                    new NumberRangeExpression.Builder().to(10000.0).key("~1만원").build(),
                    new NumberRangeExpression.Builder().from(10000.0).to(50000.0).key("1~5만원").build(),
                    new NumberRangeExpression.Builder().from(50000.0).to(100000.0).key("5~10만원").build(),
                    new NumberRangeExpression.Builder().from(100000.0).key("10만원~").build()
                ))
        ))
        .withAggregation("avg_price", Aggregation.of(a -> a
            .avg(avg -> avg.field("price"))
        ))
        .withMaxResults(20)
        .build();

    SearchHits<ProductDocument> hits = operations.search(query, ProductDocument.class);

    // 집계 결과 파싱
    ElasticsearchAggregations aggs =
        (ElasticsearchAggregations) hits.getAggregations();

    Map<String, Long> brandFacets = new LinkedHashMap<>();
    aggs.get("brands").aggregation().getAggregate().sterms()
        .buckets().array().forEach(bucket ->
            brandFacets.put(bucket.key().stringValue(), bucket.docCount()));

    return new SearchResultWithFacets(
        hits.getSearchHits().stream().map(SearchHit::getContent).toList(),
        brandFacets,
        hits.getTotalHits()
    );
}

Bulk 인덱싱 성능 최적화

대량 데이터를 인덱싱할 때는 BulkOperations를 사용하여 배치 처리합니다.

public void bulkIndex(List<ProductDocument> products) {
    List<IndexQuery> queries = products.stream()
        .map(product -> new IndexQueryBuilder()
            .withId(product.getId())
            .withObject(product)
            .build())
        .toList();

    // 1000건씩 배치 처리
    List<List<IndexQuery>> batches = Lists.partition(queries, 1000);
    for (List<IndexQuery> batch : batches) {
        operations.bulkIndex(batch, IndexCoordinates.of("products"));
    }

    // 인덱스 새로고침 (검색 가능 상태로)
    operations.indexOps(ProductDocument.class).refresh();
}

RDB → Elasticsearch 동기화

JPA 엔티티와 Elasticsearch Document를 동기화하는 이벤트 기반 패턴입니다. Spring ApplicationEvent를 활용합니다.

@Component
@RequiredArgsConstructor
public class ProductIndexSyncListener {

    private final ElasticsearchOperations operations;
    private final ProductDocumentMapper mapper;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onProductSaved(ProductSavedEvent event) {
        ProductDocument doc = mapper.toDocument(event.getProduct());
        operations.save(doc);
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onProductDeleted(ProductDeletedEvent event) {
        operations.delete(event.getProductId(),
            IndexCoordinates.of("products"));
    }
}

검색 결과 하이라이트

검색어가 포함된 부분을 하이라이트하여 사용자에게 반환합니다.

public List<ProductSearchResponse> searchWithHighlight(String keyword) {
    NativeQuery query = NativeQuery.builder()
        .withQuery(q -> q.multiMatch(m -> m
            .query(keyword).fields("name^3", "description")))
        .withHighlightQuery(new HighlightQuery(
            new Highlight(List.of(
                new HighlightField("name", Map.of(
                    "pre_tags", List.of("<em class='highlight'>"),
                    "post_tags", List.of("</em>")
                )),
                new HighlightField("description", Map.of(
                    "fragment_size", List.of("150"),
                    "number_of_fragments", List.of("2")
                ))
            )),
            ProductDocument.class
        ))
        .build();

    return operations.search(query, ProductDocument.class)
        .getSearchHits().stream()
        .map(hit -> {
            ProductDocument doc = hit.getContent();
            Map<String, List<String>> highlights = hit.getHighlightFields();
            return ProductSearchResponse.builder()
                .id(doc.getId())
                .name(getHighlight(highlights, "name", doc.getName()))
                .description(getHighlight(highlights, "description", doc.getDescription()))
                .price(doc.getPrice())
                .score(hit.getScore())
                .build();
        })
        .toList();
}

실전 팁

  • 인덱스 별칭(Alias): 무중단 인덱스 재구축을 위해 별칭을 사용합니다. products_v1products 별칭으로 매핑하고, 재구축 시 products_v2에 데이터를 넣은 뒤 별칭만 전환합니다
  • 커넥션 풀 설정: Elasticsearch HTTP 커넥션도 풀링됩니다. 동시 검색이 많으면 max-connections를 늘려야 합니다
  • 검색어 사전: Nori 분석기에 사용자 사전(user_dictionary)을 추가하여 신조어, 브랜드명 등을 올바르게 토크나이징합니다
  • Scroll vs SearchAfter: 대량 결과 처리 시 Scroll은 deprecated되는 추세이므로, search_after 기반의 PIT(Point In Time) 검색을 사용합니다
  • 인덱스 매핑 변경 불가: 기존 필드의 타입은 변경할 수 없습니다. 재인덱싱(reindex)이 필요하므로, 초기 매핑 설계를 신중하게 합니다

마무리

Spring Data Elasticsearch는 Repository 패턴의 편의성과 네이티브 쿼리의 유연성을 모두 제공합니다. 한국어 검색을 위한 Nori 분석기, 자동완성을 위한 Edge NGram, 패싯을 위한 Aggregation, 그리고 RDB 동기화까지 — 실무에서 필요한 검색 기능을 Spring 생태계 안에서 일관되게 구현할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux