Database

Elasticsearch 입문 - Spring Boot로 검색 엔진 구축하기

백엔드 개발자 김승원 2026. 4. 6. 11:22

들어가며

쇼핑몰에서 "무선 블루투스 키보드"를 검색하면, 정확히 일치하는 상품뿐 아니라 "블루투스 무선 기계식 키보드", "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 정책이 중요합니다

검색은 사용자 경험에 직접적인 영향을 미치는 기능입니다. 단순히 동작하는 검색이 아니라, 사용자가 원하는 결과를 정확하고 빠르게 제공하는 검색을 만들어 보시길 바랍니다.