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: 고급 검색
복잡한 검색 로직은 ElasticsearchOperations와 NativeQuery를 사용합니다.
@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_v1→products별칭으로 매핑하고, 재구축 시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 생태계 안에서 일관되게 구현할 수 있습니다.