카테고리 없음

[유레카 / 백엔드] TIL - 22 (Elasticsearch)

coding-quokka101 2026. 2. 10. 18:15

Spring Data Elasticsearch 설정 및 검색 기능 구현

📌 들어가며

안녕하세요! 오늘은 Elasticsearch를 공부했습니다.
오늘의 학습 목표: Elasticsearch의 개념을 이해하고, Spring Boot와 연동하여 고성능 검색 기능 구현하기

사실 이걸 배우게 된 계기가 있어요. 쇼핑몰 프로젝트에서 상품 검색 기능을 만들었는데...

SELECT * FROM products 
WHERE name LIKE '%맥북%' 
   OR description LIKE '%맥북%';

상품이 1,000개일 때는 괜찮았어요. 근데 10만 개가 되니까...

검색 결과: 3.2초 소요 🐌

거기다 "맥북"을 검색했는데 "MacBook"은 안 나오고, "노트북"으로 검색하면 "랩탑"은 안 나오고... 😭

"LIKE 검색으로는 한계가 있구나..."

그래서 찾은 게 Elasticsearch! 수억 건의 데이터도 밀리초 단위로 검색하고, 오타 교정, 유사어 검색까지 가능한 검색 엔진이에요.


🎯 Today I Learned

✅ Elasticsearch란 무엇이고 왜 필요한가?
✅ 핵심 개념 (Index, Document, Shard, Node)
✅ Docker로 Elasticsearch 설치
✅ 기본 CRUD 작업 (REST API)
✅ 검색 쿼리 (match, bool, range, wildcard)
✅ 한글 형태소 분석 (Nori Analyzer)
✅ Spring Boot 연동 및 실전 예제
✅ 성능 최적화 팁

🤔 Elasticsearch, 왜 필요할까?

RDBMS 검색의 한계

MySQL LIKE 검색의 문제점:

1️⃣ 느림
   - LIKE '%키워드%'는 인덱스를 못 탐
   - 전체 테이블 스캔 → 데이터 많으면 느려짐

2️⃣ 기능 부족
   - "맥북" 검색 → "MacBook" 못 찾음
   - "삼성 노트북" → "삼성전자 노트북" 못 찾음
   - 오타 교정 불가
   - 연관도 순 정렬 불가

3️⃣ 확장성
   - 단일 서버의 한계
   - 샤딩/복제 직접 구현해야 함

Elasticsearch가 해결해주는 것

Elasticsearch의 강점:

1️⃣ 빠름
   - 역인덱스(Inverted Index) 구조
   - 10억 건도 밀리초 단위 검색

2️⃣ 강력한 검색
   - 풀텍스트 검색 (Full-text Search)
   - 오타 교정 (Fuzzy Search)
   - 유사어 검색 (Synonym)
   - 형태소 분석 (한글 "먹었다" → "먹다")
   - 연관도 점수 (Relevance Score)

3️⃣ 확장성
   - 분산 시스템 기본 내장
   - 샤딩, 복제 자동 지원
   - 수평 확장 용이

성능 비교

100만 건 상품 데이터 검색 테스트:

┌─────────────────────────────────────────┐
│ MySQL LIKE '%맥북%'                      │
│ → 2,847ms (약 2.8초) 🐌                  │
├─────────────────────────────────────────┤
│ Elasticsearch match "맥북"               │
│ → 23ms (0.023초) ⚡                      │
└─────────────────────────────────────────┘

약 124배 빠름!

📚 핵심 개념

https://images.velog.io/images/koo8624/post/8584d80f-950b-46c9-8cd0-78ba2e2c53f4/1.png

RDBMS vs Elasticsearch 용어 비교

RDBMS Elasticsearch 설명

Database Index 데이터 저장 공간
Table Type (7.x부터 deprecated) 문서 유형
Row Document 하나의 데이터 단위
Column Field 문서의 속성
Schema Mapping 필드 타입 정의
SQL Query DSL 검색 쿼리 문법

핵심 구성 요소

Elasticsearch 구조:

Cluster (클러스터)
├── Node 1 (Master)
│   ├── Index: products
│   │   ├── Shard 0 (Primary)
│   │   └── Shard 1 (Primary)
│   └── Index: orders
│       └── Shard 0 (Primary)
│
├── Node 2 (Data)
│   ├── Shard 0 (Replica) ← products의 복제본
│   └── Shard 1 (Replica)
│
└── Node 3 (Data)
    └── Shard 0 (Replica) ← orders의 복제본

용어 정리:

용어 설명

Cluster 하나 이상의 노드로 구성된 집합
Node Elasticsearch 실행 인스턴스 (서버 1대)
Index 문서들의 논리적 집합 (DB의 테이블 같은 개념)
Document 저장되는 JSON 데이터 단위
Shard 인덱스를 나눈 조각 (분산 저장)
Replica 샤드의 복제본 (고가용성)

역인덱스 (Inverted Index)

Elasticsearch가 빠른 비결!

일반 인덱스 (Forward Index):
┌──────────┬────────────────────────────────┐
│ Doc ID   │ Content                        │
├──────────┼────────────────────────────────┤
│ 1        │ "삼성 갤럭시 스마트폰"           │
│ 2        │ "애플 아이폰 스마트폰"           │
│ 3        │ "삼성 갤럭시 태블릿"             │
└──────────┴────────────────────────────────┘

"삼성" 검색 → 모든 문서를 순회해야 함 (느림)


역인덱스 (Inverted Index):
┌──────────┬─────────────┐
│ Term     │ Doc IDs     │
├──────────┼─────────────┤
│ 삼성     │ [1, 3]      │
│ 갤럭시   │ [1, 3]      │
│ 스마트폰 │ [1, 2]      │
│ 애플     │ [2]         │
│ 아이폰   │ [2]         │
│ 태블릿   │ [3]         │
└──────────┴─────────────┘

"삼성" 검색 → 바로 [1, 3] 반환! (빠름)

🐳 Docker로 Elasticsearch 설치

docker 설치  - Docker: Accelerated Container Application Development

docker-compose.yml

version: '3.8'

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    container_name: elasticsearch
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false  # 개발용 (운영에선 true)
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ports:
      - "9200:9200"   # REST API
      - "9300:9300"   # 노드 간 통신
    volumes:
      - es-data:/usr/share/elasticsearch/data
    networks:
      - es-network

  kibana:
    image: docker.elastic.co/kibana/kibana:8.11.0
    container_name: kibana
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    ports:
      - "5601:5601"
    depends_on:
      - elasticsearch
    networks:
      - es-network

networks:
  es-network:
    driver: bridge

volumes:
  es-data:
    driver: local

실행 및 확인

# 실행
docker compose up -d

# 상태 확인
docker compose ps

# Elasticsearch 동작 확인
curl http://localhost:9200

# 응답:
{
  "name" : "abc123",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "xyz789",
  "version" : {
    "number" : "8.11.0",
    ...
  },
  "tagline" : "You Know, for Search"
}

Kibana 접속: http://localhost:5601

https://maelfabien.github.io/assets/images/el_4.jpg


💻 기본 CRUD 작업

인덱스 생성

# 인덱스 생성 (Mapping 포함)
PUT /products
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "standard"
      },
      "description": {
        "type": "text"
      },
      "price": {
        "type": "integer"
      },
      "category": {
        "type": "keyword"    # 정확한 값 매칭용
      },
      "created_at": {
        "type": "date"
      },
      "in_stock": {
        "type": "boolean"
      }
    }
  }
}

필드 타입:

타입 설명 예시

text 풀텍스트 검색용 (분석됨) 상품명, 설명
keyword 정확한 값 매칭 (분석 안됨) 카테고리, 상태값
integer/long 숫자 가격, 수량
date 날짜 등록일
boolean 참/거짓 재고 여부

문서 CRUD

# ===== CREATE =====

# 문서 생성 (ID 자동 생성)
POST /products/_doc
{
  "name": "맥북 프로 14인치",
  "description": "M3 칩셋 탑재 애플 노트북",
  "price": 2390000,
  "category": "laptop",
  "created_at": "2024-01-15",
  "in_stock": true
}

# 문서 생성 (ID 지정)
PUT /products/_doc/1
{
  "name": "갤럭시 S24 울트라",
  "description": "삼성 플래그십 스마트폰",
  "price": 1650000,
  "category": "smartphone",
  "created_at": "2024-01-20",
  "in_stock": true
}


# ===== READ =====

# 단일 문서 조회
GET /products/_doc/1

# 전체 문서 조회
GET /products/_search
{
  "query": {
    "match_all": {}
  }
}


# ===== UPDATE =====

# 부분 업데이트
POST /products/_update/1
{
  "doc": {
    "price": 1550000,
    "in_stock": false
  }
}

# 전체 교체
PUT /products/_doc/1
{
  "name": "갤럭시 S24 울트라",
  "description": "삼성 플래그십 스마트폰 (리퍼)",
  "price": 1400000,
  "category": "smartphone",
  "created_at": "2024-01-20",
  "in_stock": true
}


# ===== DELETE =====

# 문서 삭제
DELETE /products/_doc/1

# 인덱스 삭제 (주의!)
DELETE /products

🔍 검색 쿼리

OpenSearch 및 Elasticsearch 쿼리를 위해 자연어를 쿼리 DSL로 변환 - 권장 가이드

match - 기본 풀텍스트 검색

# 단일 필드 검색
GET /products/_search
{
  "query": {
    "match": {
      "name": "맥북 노트북"
    }
  }
}
# "맥북" OR "노트북" 포함된 문서 검색 (기본: OR 연산)

# AND 조건
GET /products/_search
{
  "query": {
    "match": {
      "name": {
        "query": "삼성 스마트폰",
        "operator": "and"
      }
    }
  }
}
# "삼성" AND "스마트폰" 둘 다 포함된 문서만

multi_match - 여러 필드 검색

GET /products/_search
{
  "query": {
    "multi_match": {
      "query": "애플 노트북",
      "fields": ["name^2", "description"]  # name 필드 가중치 2배
    }
  }
}

bool - 복합 조건

GET /products/_search
{
  "query": {
    "bool": {
      "must": [                    # AND (필수)
        { "match": { "name": "노트북" } }
      ],
      "should": [                  # OR (선택, 점수 높임)
        { "match": { "description": "고성능" } },
        { "match": { "description": "프리미엄" } }
      ],
      "must_not": [                # NOT (제외)
        { "term": { "in_stock": false } }
      ],
      "filter": [                  # AND (필수, 점수 계산 안함)
        { "range": { "price": { "lte": 2000000 } } }
      ]
    }
  }
}

bool 쿼리 정리:

조건 역할 점수 계산

must 반드시 일치 (AND) O
should 일치하면 점수 ↑ (OR) O
must_not 반드시 제외 (NOT) X
filter 반드시 일치 (AND) X (캐싱됨, 빠름)

range - 범위 검색

GET /products/_search
{
  "query": {
    "range": {
      "price": {
        "gte": 1000000,    # 이상
        "lte": 2000000     # 이하
      }
    }
  }
}

# 날짜 범위
GET /products/_search
{
  "query": {
    "range": {
      "created_at": {
        "gte": "2024-01-01",
        "lte": "now"
      }
    }
  }
}

fuzzy - 오타 교정

# "맥뷱" 검색해도 "맥북" 찾아줌!
GET /products/_search
{
  "query": {
    "fuzzy": {
      "name": {
        "value": "맥뷱",
        "fuzziness": "AUTO"   # 자동으로 허용 오차 결정
      }
    }
  }
}

정렬 & 페이징

GET /products/_search
{
  "query": {
    "match": { "category": "laptop" }
  },
  "sort": [
    { "price": "asc" },
    { "_score": "desc" }    # 연관도 점수
  ],
  "from": 0,     # 시작 위치 (offset)
  "size": 10     # 개수 (limit)
}

🇰🇷 한글 형태소 분석 (Nori)

왜 필요한가?

기본 분석기 (Standard):
"삼성전자에서 만든 스마트폰을 샀다"
→ ["삼성전자에서", "만든", "스마트폰을", "샀다"]

문제: "삼성" 검색 → 못 찾음! ("삼성전자에서"만 있음)


Nori 분석기:
"삼성전자에서 만든 스마트폰을 샀다"
→ ["삼성", "전자", "만들다", "스마트폰", "사다"]

"삼성" 검색 → 찾음! ✅
"스마트폰" 검색 → 찾음! ✅

Nori 플러그인 설치

# Elasticsearch 컨테이너 접속
docker exec -it elasticsearch bash

# Nori 플러그인 설치
bin/elasticsearch-plugin install analysis-nori

# 컨테이너 재시작
docker restart elasticsearch

또는 Dockerfile로:

FROM docker.elastic.co/elasticsearch/elasticsearch:8.11.0
RUN bin/elasticsearch-plugin install analysis-nori

Nori 적용 인덱스 생성

PUT /products_korean
{
  "settings": {
    "analysis": {
      "analyzer": {
        "korean": {
          "type": "custom",
          "tokenizer": "nori_tokenizer",
          "filter": ["lowercase", "nori_part_of_speech"]
        }
      },
      "tokenizer": {
        "nori_tokenizer": {
          "type": "nori_tokenizer",
          "decompound_mode": "mixed"  # 복합어 분해
        }
      },
      "filter": {
        "nori_part_of_speech": {
          "type": "nori_part_of_speech",
          "stoptags": ["E", "J", "SC", "SE", "SF"]  # 조사, 어미 등 제외
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "korean"
      },
      "description": {
        "type": "text",
        "analyzer": "korean"
      }
    }
  }
}

분석 결과 확인

# 분석 테스트
GET /products_korean/_analyze
{
  "analyzer": "korean",
  "text": "삼성전자에서 만든 갤럭시 스마트폰을 구매했습니다"
}

# 결과:
{
  "tokens": [
    { "token": "삼성", ... },
    { "token": "전자", ... },
    { "token": "만들", ... },
    { "token": "갤럭시", ... },
    { "token": "스마트폰", ... },
    { "token": "구매", ... }
  ]
}

🌱 Spring Boot 연동

의존성 추가

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
}

application.yml

spring:
  elasticsearch:
    uris: http://localhost:9200
    # username: elastic        # 인증 사용 시
    # password: changeme

Entity 정의

@Document(indexName = "products")
@Setting(settingPath = "elasticsearch/settings.json")  // 선택
public class Product {

    @Id
    private String id;

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

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

    @Field(type = FieldType.Integer)
    private Integer price;

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

    @Field(type = FieldType.Date, format = DateFormat.date)
    private LocalDate createdAt;

    @Field(type = FieldType.Boolean)
    private Boolean inStock;

    // 생성자, getter, setter...
}

Repository

public interface ProductRepository extends ElasticsearchRepository<Product, String> {

    // 메서드 이름으로 쿼리 자동 생성
    List<Product> findByName(String name);
    
    List<Product> findByCategory(String category);
    
    List<Product> findByPriceBetween(Integer min, Integer max);
    
    List<Product> findByNameContainingAndInStockTrue(String keyword);
    
    // 페이징
    Page<Product> findByCategory(String category, Pageable pageable);
}

Service - 커스텀 검색

@Service
@RequiredArgsConstructor
public class ProductSearchService {

    private final ElasticsearchOperations elasticsearchOperations;

    /**
     * 복합 조건 검색
     */
    public SearchHits<Product> search(String keyword, String category, 
                                       Integer minPrice, Integer maxPrice) {
        
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();

        // 키워드 검색 (name, description에서)
        if (StringUtils.hasText(keyword)) {
            boolQuery.must(QueryBuilders.multiMatchQuery(keyword, "name", "description")
                    .field("name", 2.0f)  // name 필드 가중치 2배
                    .fuzziness(Fuzziness.AUTO));  // 오타 허용
        }

        // 카테고리 필터
        if (StringUtils.hasText(category)) {
            boolQuery.filter(QueryBuilders.termQuery("category", category));
        }

        // 가격 범위 필터
        if (minPrice != null || maxPrice != null) {
            RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("price");
            if (minPrice != null) rangeQuery.gte(minPrice);
            if (maxPrice != null) rangeQuery.lte(maxPrice);
            boolQuery.filter(rangeQuery);
        }

        // 재고 있는 상품만
        boolQuery.filter(QueryBuilders.termQuery("in_stock", true));

        NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                .withQuery(boolQuery)
                .withSort(SortBuilders.scoreSort().order(SortOrder.DESC))  // 연관도순
                .withSort(SortBuilders.fieldSort("price").order(SortOrder.ASC))  // 가격순
                .withPageable(PageRequest.of(0, 20))
                .build();

        return elasticsearchOperations.search(searchQuery, Product.class);
    }

    /**
     * 자동완성 (Prefix 검색)
     */
    public List<String> autocomplete(String prefix) {
        NativeSearchQuery query = new NativeSearchQueryBuilder()
                .withQuery(QueryBuilders.prefixQuery("name", prefix))
                .withPageable(PageRequest.of(0, 10))
                .build();

        SearchHits<Product> hits = elasticsearchOperations.search(query, Product.class);
        
        return hits.stream()
                .map(hit -> hit.getContent().getName())
                .distinct()
                .collect(Collectors.toList());
    }
}

Controller

@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductSearchService searchService;
    private final ProductRepository productRepository;

    @GetMapping("/search")
    public ResponseEntity<?> search(
            @RequestParam(required = false) String keyword,
            @RequestParam(required = false) String category,
            @RequestParam(required = false) Integer minPrice,
            @RequestParam(required = false) Integer maxPrice) {

        SearchHits<Product> results = searchService.search(keyword, category, minPrice, maxPrice);
        
        List<Product> products = results.stream()
                .map(SearchHit::getContent)
                .collect(Collectors.toList());
        
        return ResponseEntity.ok(Map.of(
                "total", results.getTotalHits(),
                "products", products
        ));
    }

    @GetMapping("/autocomplete")
    public ResponseEntity<List<String>> autocomplete(@RequestParam String q) {
        return ResponseEntity.ok(searchService.autocomplete(q));
    }

    @PostMapping
    public ResponseEntity<Product> create(@RequestBody Product product) {
        return ResponseEntity.ok(productRepository.save(product));
    }
}

🔄 MySQL ↔ Elasticsearch 동기화

실무에서는 MySQL(원본)과 Elasticsearch(검색용)를 함께 사용해요.

방법 1: 애플리케이션에서 직접 동기화

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductJpaRepository mysqlRepository;    // MySQL
    private final ProductEsRepository esRepository;        // Elasticsearch

    @Transactional
    public Product create(ProductRequest request) {
        // 1. MySQL에 저장
        ProductEntity entity = mysqlRepository.save(request.toEntity());
        
        // 2. Elasticsearch에 동기화
        esRepository.save(entity.toDocument());
        
        return Product.from(entity);
    }

    @Transactional
    public void update(Long id, ProductRequest request) {
        ProductEntity entity = mysqlRepository.findById(id)
                .orElseThrow(() -> new NotFoundException("상품 없음"));
        
        entity.update(request);
        mysqlRepository.save(entity);
        
        // ES 동기화
        esRepository.save(entity.toDocument());
    }

    @Transactional
    public void delete(Long id) {
        mysqlRepository.deleteById(id);
        esRepository.deleteById(String.valueOf(id));
    }
}

방법 2: 이벤트 기반 동기화 (권장)

// 도메인 이벤트 발행
@Service
public class ProductService {

    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public Product create(ProductRequest request) {
        ProductEntity entity = repository.save(request.toEntity());
        
        // 이벤트 발행
        eventPublisher.publishEvent(new ProductCreatedEvent(entity));
        
        return Product.from(entity);
    }
}

// 이벤트 리스너 (비동기)
@Component
@RequiredArgsConstructor
public class ProductEventListener {

    private final ProductEsRepository esRepository;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleProductCreated(ProductCreatedEvent event) {
        esRepository.save(event.getProduct().toDocument());
    }

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleProductUpdated(ProductUpdatedEvent event) {
        esRepository.save(event.getProduct().toDocument());
    }

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleProductDeleted(ProductDeletedEvent event) {
        esRepository.deleteById(event.getProductId());
    }
}

🛠️ 트러블슈팅 & 팁

자주 발생하는 문제

❌ 문제: "mapper_parsing_exception" 에러

원인: 필드 타입 불일치 (text 필드에 숫자 넣기 등)
해결: Mapping을 명확하게 정의하고 타입 확인


❌ 문제: 한글 검색이 제대로 안 됨

원인: Nori analyzer 미적용
해결: 인덱스 생성 시 한글 분석기 설정


❌ 문제: 검색 결과 순서가 이상함

원인: _score 계산 방식
해결: boost로 필드 가중치 조정, function_score 활용


❌ 문제: 메모리 부족 (OOM)

원인: ES_JAVA_OPTS 설정 부족
해결: -Xms512m -Xmx512m → 최소 512MB, 권장 2GB+

성능 최적화 팁

1️⃣ filter 활용
   - 점수 계산 불필요한 조건은 filter로
   - 캐싱되어 빠름

2️⃣ source 필터링
   - 필요한 필드만 반환
   GET /products/_search
   {
     "_source": ["name", "price"],
     "query": { ... }
   }

3️⃣ 적절한 샤드 수
   - 샤드 1개당 10~50GB 권장
   - 너무 많으면 오버헤드 발생

4️⃣ Bulk API 사용
   - 대량 데이터 색인 시 필수
   - 개별 요청 대비 10배+ 빠름

💡 오늘 나는 무엇을 배웠는가

1️⃣ 검색은 DB의 일이 아니다
   LIKE 검색 → 한계 명확
   검색 전용 엔진(ES) → 빠르고 강력함

2️⃣ 역인덱스의 힘
   "단어 → 문서" 매핑으로 O(1)에 가까운 검색

3️⃣ 형태소 분석의 중요성
   한글은 Nori 필수!
   "먹었다" → "먹다"로 분석되어야 검색됨

4️⃣ MySQL + ES 조합
   원본 데이터: MySQL (정합성)
   검색 데이터: Elasticsearch (성능)
   동기화가 핵심!

🚀 다음 학습 목표

📌 단기
   ✓ 프로젝트에 Elasticsearch 적용
   ✓ 상품 검색 기능 고도화

📌 중기
   ✓ 자동완성 + 검색어 추천 구현
   ✓ 검색 로그 기반 인기 검색어

📌 장기
   ✓ ELK Stack (Elasticsearch + Logstash + Kibana)
   ✓ 로그 분석 시스템 구축

🎬 마치며

Elasticsearch를 배우기 전에는 "검색은 그냥 LIKE로 하면 되는 거 아니야?"라고 생각했어요.

하지만 직접 써보니까 완전히 다른 세계더라고요! 밀리초 단위 검색, 오타 교정, 연관도 순 정렬... 네이버나 쿠팡의 검색이 왜 그렇게 잘 되는지 이해가 됐어요.

특히 한글 형태소 분석(Nori)을 적용하니까, "삼성전자에서"를 검색어 "삼성"으로 찾을 수 있게 되었을 때 신기했습니다!

검색 기능이 필요한 프로젝트라면 Elasticsearch, 꼭 도입해보세요! 🔍


📚 참고 자료