
📌 들어가며
안녕하세요! 오늘은 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배 빠름!
📚 핵심 개념

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

💻 기본 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
🔍 검색 쿼리

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, 꼭 도입해보세요! 🔍