들어가며
쇼핑몰에서 "무선 블루투스 키보드"를 검색하면, 정확히 일치하는 상품뿐 아니라 "블루투스 무선 기계식 키보드", "BT 키보드 무선" 같은 유사한 상품도 함께 나옵니다. RDBMS의 LIKE '%키보드%' 쿼리로는 이런 검색을 구현할 수 없습니다. 전문 검색(Full-text Search)에는 전문 검색 엔진이 필요합니다.
Elasticsearch는 Apache Lucene 기반의 분산 검색/분석 엔진으로, 실시간에 가까운 검색 성능과 강력한 텍스트 분석 기능을 제공합니다. 이 글에서는 Elasticsearch의 핵심 개념부터 역인덱스 원리, 매핑 설정, Query DSL, 한국어 분석기(nori), 그리고 Spring Boot와의 연동까지 실제 상품 검색 시스템을 구축하는 과정을 다루겠습니다.
Elasticsearch 핵심 개념
기본 용어 정리
| Elasticsearch | RDBMS 비유 | 설명 |
|---|---|---|
| Index | Database (또는 Table) | 도큐먼트의 논리적 집합 |
| Document | Row | JSON 형태의 단일 데이터 레코드 |
| Field | Column | 도큐먼트 내의 키-값 쌍 |
| Mapping | Schema | 필드의 데이터 타입 및 분석 방법 정의 |
| Shard | Partition | 인덱스를 분산 저장하는 단위 |
| Replica | Replication | 샤드의 복제본 (고가용성 + 검색 성능 향상) |
클러스터 아키텍처
Elasticsearch 클러스터는 여러 노드로 구성되며, 각 노드는 역할에 따라 분류됩니다.
- Master Node: 클러스터 메타데이터 관리, 샤드 할당 결정
- Data Node: 실제 데이터 저장 및 검색/집계 수행
- Coordinating Node: 클라이언트 요청을 받아 Data Node로 분배 후 결과 병합
- Ingest Node: 인덱싱 전 데이터 전처리 파이프라인 수행
하나의 인덱스는 여러 Primary Shard로 분할되고, 각 Primary Shard는 Replica Shard를 가집니다. 예를 들어 Primary 3개, Replica 1개 설정이면 총 6개의 샤드가 노드에 분산됩니다.
역인덱스 (Inverted Index) 원리
역인덱스란?
일반적인 인덱스는 "도큐먼트 ID → 내용"을 매핑하지만, 역인덱스는 "단어(Term) → 도큐먼트 ID 목록"을 매핑합니다. 이것이 Elasticsearch가 빠른 전문 검색을 할 수 있는 핵심 원리입니다.
// 원본 도큐먼트
Doc 1: "스프링 부트 실전 가이드"
Doc 2: "스프링 시큐리티 입문"
Doc 3: "JPA 실전 활용"
// 역인덱스 구조
Term | Document IDs
------------|-------------
스프링 | [1, 2]
부트 | [1]
실전 | [1, 3]
가이드 | [1]
시큐리티 | [2]
입문 | [2]
JPA | [3]
활용 | [3]
"스프링 실전"을 검색하면, "스프링" → [1,2], "실전" → [1,3]을 찾고, 교집합 또는 유니언을 통해 관련성 높은 Doc 1을 최상위로 반환합니다.
텍스트 분석 과정 (Analyzer)
텍스트가 역인덱스에 저장되기 전에 Analyzer를 거칩니다. Analyzer는 3단계로 구성됩니다.
- Character Filter: HTML 태그 제거, 특수문자 치환 등 문자 레벨 전처리
- Tokenizer: 텍스트를 토큰(단어)으로 분리. Standard, Whitespace, Nori(한국어) 등
- Token Filter: 소문자 변환(lowercase), 불용어 제거(stop), 형태소 분석 등 토큰 후처리
매핑 설정
상품 인덱스 매핑 정의
PUT /products
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"korean_analyzer": {
"type": "custom",
"tokenizer": "nori_tokenizer",
"filter": ["nori_readingform", "lowercase", "nori_part_of_speech_filter"]
},
"korean_search_analyzer": {
"type": "custom",
"tokenizer": "nori_mixed_tokenizer",
"filter": ["nori_readingform", "lowercase"]
}
},
"tokenizer": {
"nori_tokenizer": {
"type": "nori_tokenizer",
"decompound_mode": "mixed"
},
"nori_mixed_tokenizer": {
"type": "nori_tokenizer",
"decompound_mode": "mixed",
"user_dictionary": "userdic_ko.txt"
}
},
"filter": {
"nori_part_of_speech_filter": {
"type": "nori_part_of_speech",
"stoptags": [
"E", "IC", "J", "MAG", "MAJ", "MM",
"SP", "SSC", "SSO", "SC", "SE",
"XPN", "XSA", "XSN", "XSV",
"UNA", "NA", "VSV"
]
}
}
}
},
"mappings": {
"properties": {
"productId": { "type": "keyword" },
"name": {
"type": "text",
"analyzer": "korean_analyzer",
"search_analyzer": "korean_search_analyzer",
"fields": {
"keyword": { "type": "keyword" },
"ngram": {
"type": "text",
"analyzer": "standard"
}
}
},
"description": {
"type": "text",
"analyzer": "korean_analyzer"
},
"category": { "type": "keyword" },
"brand": {
"type": "text",
"fields": {
"keyword": { "type": "keyword" }
}
},
"price": { "type": "integer" },
"rating": { "type": "float" },
"reviewCount": { "type": "integer" },
"tags": { "type": "keyword" },
"inStock": { "type": "boolean" },
"createdAt": { "type": "date" },
"updatedAt": { "type": "date" }
}
}
}
한국어 분석기 (Nori) 설치
# Elasticsearch 플러그인 설치
bin/elasticsearch-plugin install analysis-nori
# Docker 환경
FROM docker.elastic.co/elasticsearch/elasticsearch:8.11.0
RUN bin/elasticsearch-plugin install analysis-nori
Nori 분석 결과 확인
POST /products/_analyze
{
"analyzer": "korean_analyzer",
"text": "무선 블루투스 기계식 키보드"
}
// 결과
{
"tokens": [
{ "token": "무선", "start_offset": 0, "end_offset": 2, "type": "word" },
{ "token": "블루투스", "start_offset": 3, "end_offset": 7, "type": "word" },
{ "token": "기계", "start_offset": 8, "end_offset": 10, "type": "word" },
{ "token": "기계식", "start_offset": 8, "end_offset": 11, "type": "word" },
{ "token": "식", "start_offset": 10, "end_offset": 11, "type": "word" },
{ "token": "키보드", "start_offset": 12, "end_offset": 15, "type": "word" }
]
}
decompound_mode: mixed로 설정하면 "기계식"을 "기계" + "식" + "기계식"으로 모두 분리합니다. 이렇게 하면 "기계식"으로 검색해도, "기계"로 검색해도 매칭됩니다.
Query DSL 핵심 쿼리
match 쿼리 - 기본 전문 검색
// 기본 match: 분석기를 통해 토큰화 후 검색
GET /products/_search
{
"query": {
"match": {
"name": {
"query": "무선 기계식 키보드",
"operator": "and" // 모든 토큰이 포함된 결과만 (기본: or)
}
}
}
}
// multi_match: 여러 필드에서 동시 검색
GET /products/_search
{
"query": {
"multi_match": {
"query": "기계식 키보드",
"fields": ["name^3", "description", "brand^2"], // 가중치 부여
"type": "best_fields",
"fuzziness": "AUTO" // 오타 허용
}
}
}
bool 쿼리 - 복합 조건
// bool 쿼리로 복잡한 검색 조건 구성
GET /products/_search
{
"query": {
"bool": {
"must": [
{ "match": { "name": "키보드" } }
],
"filter": [
{ "term": { "category": "peripherals" } },
{ "range": { "price": { "gte": 50000, "lte": 200000 } } },
{ "term": { "inStock": true } }
],
"should": [
{ "match": { "tags": "bestseller" } },
{ "range": { "rating": { "gte": 4.5 } } }
],
"minimum_should_match": 1,
"must_not": [
{ "term": { "brand.keyword": "NoName" } }
]
}
},
"sort": [
{ "_score": "desc" },
{ "reviewCount": "desc" }
],
"from": 0,
"size": 20,
"highlight": {
"fields": {
"name": { "pre_tags": [""], "post_tags": [""] },
"description": { "pre_tags": [""], "post_tags": [""], "fragment_size": 150 }
}
},
"aggs": {
"category_count": {
"terms": { "field": "category", "size": 20 }
},
"brand_count": {
"terms": { "field": "brand.keyword", "size": 20 }
},
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "to": 50000, "key": "~5만원" },
{ "from": 50000, "to": 100000, "key": "5~10만원" },
{ "from": 100000, "to": 200000, "key": "10~20만원" },
{ "from": 200000, "key": "20만원~" }
]
}
},
"avg_price": {
"avg": { "field": "price" }
}
}
}
range 쿼리 - 범위 검색
// 가격 범위 + 날짜 범위
GET /products/_search
{
"query": {
"bool": {
"filter": [
{
"range": {
"price": { "gte": 10000, "lte": 100000 }
}
},
{
"range": {
"createdAt": {
"gte": "now-30d/d",
"lte": "now/d"
}
}
}
]
}
}
}
Spring Data Elasticsearch 연동
의존성 설정
<!-- build.gradle -->
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(indexName = "products")
@Setting(settingPath = "elasticsearch/settings.json")
@Mapping(mappingPath = "elasticsearch/mappings.json")
public class ProductDocument {
@Id
private String id;
@Field(type = FieldType.Keyword)
private String productId;
@Field(type = FieldType.Text, analyzer = "korean_analyzer",
searchAnalyzer = "korean_search_analyzer")
private String name;
@Field(type = FieldType.Text, analyzer = "korean_analyzer")
private String description;
@Field(type = FieldType.Keyword)
private String category;
@Field(type = FieldType.Text)
private String brand;
@Field(type = FieldType.Integer)
private Integer price;
@Field(type = FieldType.Float)
private Float rating;
@Field(type = FieldType.Integer)
private Integer reviewCount;
@Field(type = FieldType.Keyword)
private List<String> tags;
@Field(type = FieldType.Boolean)
private Boolean inStock;
@Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second_millis)
private Instant createdAt;
// Getters, Setters, Builder...
}
Repository 정의
public interface ProductSearchRepository
extends ElasticsearchRepository<ProductDocument, String> {
// 메서드 이름 기반 쿼리
List<ProductDocument> findByNameContaining(String name);
List<ProductDocument> findByCategoryAndPriceBetween(
String category, Integer minPrice, Integer maxPrice);
Page<ProductDocument> findByInStockTrue(Pageable pageable);
}
검색 서비스 구현 - 상품 검색 API
@Service
@RequiredArgsConstructor
public class ProductSearchService {
private final ElasticsearchOperations elasticsearchOperations;
public SearchResultDto searchProducts(ProductSearchRequest request) {
// 1. Bool 쿼리 구성
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 키워드 검색 (must)
if (StringUtils.hasText(request.getKeyword())) {
boolQuery.must(
QueryBuilders.multiMatchQuery(request.getKeyword(),
"name^3", "description", "brand^2")
.type(MultiMatchQueryBuilder.Type.BEST_FIELDS)
.fuzziness(Fuzziness.AUTO)
);
}
// 카테고리 필터
if (StringUtils.hasText(request.getCategory())) {
boolQuery.filter(QueryBuilders.termQuery("category", request.getCategory()));
}
// 가격 범위 필터
if (request.getMinPrice() != null || request.getMaxPrice() != null) {
RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("price");
if (request.getMinPrice() != null) rangeQuery.gte(request.getMinPrice());
if (request.getMaxPrice() != null) rangeQuery.lte(request.getMaxPrice());
boolQuery.filter(rangeQuery);
}
// 재고 있는 상품만
if (Boolean.TRUE.equals(request.getInStockOnly())) {
boolQuery.filter(QueryBuilders.termQuery("inStock", true));
}
// 인기 상품 부스트
boolQuery.should(QueryBuilders.rangeQuery("rating").gte(4.5));
boolQuery.should(QueryBuilders.rangeQuery("reviewCount").gte(100));
// 2. NativeSearchQuery 생성
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder()
.withQuery(boolQuery)
.withPageable(PageRequest.of(request.getPage(), request.getSize()));
// 정렬
switch (request.getSortBy()) {
case "price_asc":
queryBuilder.withSort(SortBuilders.fieldSort("price").order(SortOrder.ASC));
break;
case "price_desc":
queryBuilder.withSort(SortBuilders.fieldSort("price").order(SortOrder.DESC));
break;
case "newest":
queryBuilder.withSort(SortBuilders.fieldSort("createdAt").order(SortOrder.DESC));
break;
default: // relevance
queryBuilder.withSort(SortBuilders.scoreSort().order(SortOrder.DESC));
break;
}
// 하이라이트
queryBuilder.withHighlightBuilder(
new HighlightBuilder()
.field("name")
.field("description", 150, 1)
.preTags("")
.postTags("")
);
// 3. Aggregation 추가 (필터 카운트)
queryBuilder.addAggregation(
AggregationBuilders.terms("categories").field("category").size(20)
);
queryBuilder.addAggregation(
AggregationBuilders.terms("brands").field("brand.keyword").size(20)
);
queryBuilder.addAggregation(
AggregationBuilders.range("price_ranges").field("price")
.addRange("~5만원", 0, 50000)
.addRange("5~10만원", 50000, 100000)
.addRange("10~20만원", 100000, 200000)
.addUnboundedFrom("20만원~", 200000)
);
NativeSearchQuery searchQuery = queryBuilder.build();
// 4. 검색 실행
SearchHits<ProductDocument> searchHits =
elasticsearchOperations.search(searchQuery, ProductDocument.class);
// 5. 결과 변환
List<ProductSearchItem> items = searchHits.getSearchHits().stream()
.map(hit -> {
ProductDocument doc = hit.getContent();
ProductSearchItem item = new ProductSearchItem();
item.setProductId(doc.getProductId());
item.setName(doc.getName());
item.setPrice(doc.getPrice());
item.setRating(doc.getRating());
item.setScore(hit.getScore());
// 하이라이트 적용
if (hit.getHighlightFields().containsKey("name")) {
item.setHighlightedName(
hit.getHighlightFields().get("name").get(0));
}
return item;
})
.collect(Collectors.toList());
return SearchResultDto.builder()
.items(items)
.totalHits(searchHits.getTotalHits())
.page(request.getPage())
.size(request.getSize())
.build();
}
}
검색 API Controller
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductSearchController {
private final ProductSearchService searchService;
private final ProductIndexService indexService;
@GetMapping("/search")
public ResponseEntity<SearchResultDto> search(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String category,
@RequestParam(required = false) Integer minPrice,
@RequestParam(required = false) Integer maxPrice,
@RequestParam(defaultValue = "relevance") String sortBy,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
ProductSearchRequest request = ProductSearchRequest.builder()
.keyword(keyword)
.category(category)
.minPrice(minPrice)
.maxPrice(maxPrice)
.sortBy(sortBy)
.page(page)
.size(size)
.inStockOnly(true)
.build();
return ResponseEntity.ok(searchService.searchProducts(request));
}
// 상품 데이터 인덱싱 (DB → Elasticsearch 동기화)
@PostMapping("/index/{productId}")
public ResponseEntity<Void> indexProduct(@PathVariable String productId) {
indexService.indexProduct(productId);
return ResponseEntity.ok().build();
}
// 전체 재인덱싱
@PostMapping("/reindex")
public ResponseEntity<String> reindexAll() {
long count = indexService.reindexAll();
return ResponseEntity.ok(count + " products indexed");
}
}
인덱싱 서비스 - DB 동기화
@Service
@RequiredArgsConstructor
public class ProductIndexService {
private final ProductRepository productRepository; // JPA Repository
private final ProductSearchRepository searchRepository; // ES Repository
private final ElasticsearchOperations esOperations;
// 단건 인덱싱
public void indexProduct(String productId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new NotFoundException("Product not found: " + productId));
ProductDocument doc = toDocument(product);
searchRepository.save(doc);
}
// 전체 재인덱싱 (Bulk)
@Transactional(readOnly = true)
public long reindexAll() {
// 기존 인덱스 삭제 후 재생성
IndexOperations indexOps = esOperations.indexOps(ProductDocument.class);
if (indexOps.exists()) {
indexOps.delete();
}
indexOps.create();
indexOps.putMapping();
// 배치 단위로 인덱싱
long totalIndexed = 0;
int batchSize = 500;
int page = 0;
Page<Product> productPage;
do {
productPage = productRepository.findAll(
PageRequest.of(page, batchSize));
List<IndexQuery> indexQueries = productPage.getContent().stream()
.map(product -> {
IndexQuery query = new IndexQuery();
query.setId(product.getId());
query.setObject(toDocument(product));
return query;
})
.collect(Collectors.toList());
if (!indexQueries.isEmpty()) {
esOperations.bulkIndex(indexQueries,
esOperations.getIndexCoordinatesFor(ProductDocument.class));
}
totalIndexed += indexQueries.size();
page++;
log.info("Indexed {} / {} products",
totalIndexed, productPage.getTotalElements());
} while (productPage.hasNext());
return totalIndexed;
}
private ProductDocument toDocument(Product product) {
return ProductDocument.builder()
.id(product.getId())
.productId(product.getId())
.name(product.getName())
.description(product.getDescription())
.category(product.getCategory())
.brand(product.getBrand())
.price(product.getPrice())
.rating(product.getAverageRating())
.reviewCount(product.getReviewCount())
.tags(product.getTags())
.inStock(product.getStock() > 0)
.createdAt(product.getCreatedAt())
.build();
}
}
운영 팁
인덱스 설계 시 고려사항
- 샤드 수: 인덱스 생성 후 변경 불가. 데이터 크기 / 30~50GB 기준으로 설정
- Alias 사용: 무중단 재인덱싱을 위해 항상 Alias를 통해 접근
- ILM (Index Lifecycle Management): 로그성 데이터는 자동 롤오버/삭제 설정
- Refresh Interval: 실시간 검색이 필요 없으면 30s~60s로 늘려 인덱싱 성능 향상
Alias를 이용한 무중단 재인덱싱
# 1. 새 인덱스 생성
PUT /products_v2 { ... }
# 2. 데이터 복사
POST /_reindex
{ "source": { "index": "products_v1" }, "dest": { "index": "products_v2" } }
# 3. Alias 전환 (원자적)
POST /_aliases
{
"actions": [
{ "remove": { "index": "products_v1", "alias": "products" } },
{ "add": { "index": "products_v2", "alias": "products" } }
]
}
마치며
Elasticsearch는 단순한 검색 엔진을 넘어 로그 분석, 모니터링, 비즈니스 인텔리전스 등 다양한 분야에서 활용되는 핵심 인프라입니다. 이 글에서 다룬 내용을 정리합니다.
- 역인덱스는 Elasticsearch의 빠른 전문 검색을 가능하게 하는 핵심 자료구조입니다
- 매핑은 인덱스 생성 시 신중하게 설계해야 합니다. text vs keyword 타입 선택이 검색 품질을 결정합니다
- Nori 분석기로 한국어 형태소 분석을 적용하면 검색 품질이 크게 향상됩니다
- Bool 쿼리의 must/filter/should/must_not 조합으로 대부분의 검색 요구사항을 구현할 수 있습니다
- Spring Data Elasticsearch와 ElasticsearchOperations를 활용하면 Java 애플리케이션에서 쉽게 연동할 수 있습니다
- 운영 시 Alias를 통한 무중단 재인덱싱, 적절한 샤드 수 설정, ILM 정책이 중요합니다
검색은 사용자 경험에 직접적인 영향을 미치는 기능입니다. 단순히 동작하는 검색이 아니라, 사용자가 원하는 결과를 정확하고 빠르게 제공하는 검색을 만들어 보시길 바랍니다.
'Database' 카테고리의 다른 글
| Flyway로 DB 마이그레이션 관리 - 팀 개발에서 스키마 충돌 없애기 (0) | 2026.04.14 |
|---|---|
| MySQL 성능 튜닝 실전 - 슬로우 쿼리부터 인덱스 최적화까지 (1) | 2026.04.14 |
| MongoDB 실전 가이드 - 도큐먼트 설계부터 인덱싱 전략까지 (0) | 2026.04.06 |
| PostgreSQL 성능 튜닝 완벽 가이드 - 쿼리 최적화부터 파티셔닝까지 (0) | 2026.04.03 |
| 트랜잭션 격리 수준과 동시성 제어 - 실무에서 겪는 문제들 (0) | 2026.03.31 |