TL;DR
- Mapping: 문서 구조를 정의하는 스키마 (RDB의 테이블 정의와 유사)
- text: 풀텍스트 검색용, Analyzer로 토큰화됨
- keyword: 정확한 값 매칭, 정렬/집계용
- Analyzer: 텍스트를 검색 가능한 토큰으로 변환 (한글은 Nori 사용)
- 비정규화: JOIN 없으므로 관련 데이터를 한 문서에 포함
대상 독자: Elasticsearch 검색 기능을 사용하려는 개발자 선수 지식: 핵심 구성요소, JSON 기본 문법
소요 시간: 약 25-30분
전체 비유: 도서관의 도서 분류 시스템#
데이터 모델링을 도서관의 도서 분류 체계에 비유하면 이해하기 쉽습니다:
| 도서관 비유 | Elasticsearch | 역할 |
|---|---|---|
| 도서 분류 규칙 (DDC, KDC) | Mapping | 책을 어떻게 분류하고 정리할지 정의 |
| 책 내용으로 검색 | text 타입 | “인공지능"을 검색하면 관련 내용 포함 도서 반환 |
| 청구기호로 정확히 찾기 | keyword 타입 | “005.133-P99” 정확히 일치하는 책 찾기 |
| 색인 담당 사서 | Analyzer | 책 내용을 읽고 핵심 키워드 추출하여 색인 카드 작성 |
| 한 책에 관련 정보 모두 기록 | 비정규화 | 저자, 출판사, 분류 정보를 한 카드에 기재 |
| Object (저자 정보) | 단순 중첩 | 저자명, 생년 등 관련 정보 묶음 |
| Nested (시리즈 정보) | 관계 유지 | 1권-2024년, 2권-2025년 각각 독립적 관계 유지 |
이처럼 Mapping은 도서관에서 “어떤 기준으로 책을 분류하고 색인할 것인가"를 정하는 것과 같습니다.
Elasticsearch에서 데이터를 효과적으로 저장하고 검색하기 위한 Mapping, Field Type, Analyzer 설계를 다룹니다.
Mapping이란?#
Mapping 없이 문서를 인덱싱하면 어떻게 될까요? Elasticsearch가 “2024-01-15"를 날짜가 아닌 문자열로 추론하거나, 숫자 ID를 long 타입으로 잡아 불필요한 메모리를 소비합니다. 나중에 타입을 변경하려면 전체 데이터를 재인덱싱해야 합니다. Mapping은 이런 문제를 처음부터 방지하는 스키마 정의입니다.
Mapping은 문서와 필드가 어떻게 저장되고 인덱싱되는지 정의하는 스키마입니다.
RDB vs Elasticsearch#
| RDB | Elasticsearch |
|---|---|
| CREATE TABLE | PUT /index (mapping) |
| 컬럼 타입 | Field Type |
| 스키마 필수 | Dynamic Mapping 가능 |
| ALTER TABLE | 제한적 (재인덱싱 필요) |
Mapping 예시#
PUT /products
{
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "standard"
},
"category": {
"type": "keyword"
},
"price": {
"type": "integer"
},
"created_at": {
"type": "date"
},
"in_stock": {
"type": "boolean"
}
}
}
}핵심 포인트
- Mapping은 인덱스 생성 시 정의하며, 이후 필드 타입 변경이 제한적입니다
- Dynamic Mapping으로 자동 타입 추론이 가능하지만, 프로덕션에서는 명시적 정의 권장
- 스키마 변경이 필요하면 재인덱싱(Reindex)이 필요합니다
Field Types#
문자열 타입#
text vs keyword#
| 특성 | text | keyword |
|---|---|---|
| 용도 | 풀텍스트 검색 | 정확한 값 매칭 |
| 분석 | Analyzer로 토큰화 | 분석 안 함 |
| 검색 | match 쿼리 | term 쿼리 |
| 정렬/집계 | 불가 (기본) | 가능 |
| 예시 | 상품 설명, 게시글 본문 | 카테고리, 상태값, ID |
{
"properties": {
"title": {
"type": "text" // "맥북 프로" → ["맥북", "프로"]
},
"category": {
"type": "keyword" // "노트북" → "노트북" (그대로)
}
}
}Multi-field#
하나의 필드를 여러 방식으로 인덱싱:
{
"properties": {
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword" // name.keyword로 접근
}
}
}
}
}# 풀텍스트 검색
GET /products/_search
{ "query": { "match": { "name": "맥북" } } }
# 정확한 값 집계
GET /products/_search
{
"aggs": {
"names": { "terms": { "field": "name.keyword" } }
}
}숫자 타입#
| 타입 | 범위 | 용도 |
|---|---|---|
byte | -128 ~ 127 | 작은 정수 |
short | -32,768 ~ 32,767 | 작은 정수 |
integer | -2³¹ ~ 2³¹-1 | 일반 정수 |
long | -2⁶³ ~ 2⁶³-1 | 큰 정수, ID |
float | 32비트 부동소수점 | 근사값 |
double | 64비트 부동소수점 | 정밀 계산 |
scaled_float | 스케일 적용 | 가격 (scaling_factor: 100) |
{
"properties": {
"price": {
"type": "scaled_float",
"scaling_factor": 100 // 23900.00 → 2390000으로 저장
},
"quantity": {
"type": "integer"
}
}
}날짜 타입#
{
"properties": {
"created_at": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
}
}
}지원 형식:
2024-01-152024-01-15T10:30:002024-01-15T10:30:00+09:001705300200000(epoch millis)
불리언 타입#
{
"properties": {
"in_stock": {
"type": "boolean" // true, false, "true", "false" 모두 허용
}
}
}복합 타입#
Object#
중첩된 JSON 객체:
{
"properties": {
"seller": {
"properties": {
"name": { "type": "keyword" },
"rating": { "type": "float" }
}
}
}
}// 문서
{
"seller": {
"name": "공식스토어",
"rating": 4.8
}
}
// 검색
GET /products/_search
{
"query": {
"match": { "seller.name": "공식스토어" }
}
}Nested#
Object의 문제점:
// 문서
{
"options": [
{ "color": "black", "size": "M" },
{ "color": "white", "size": "L" }
]
}Object 타입은 배열을 평탄화합니다:
options.color: ["black", "white"]
options.size: ["M", "L"]→ “black AND L” 검색 시 잘못된 매칭 발생!
Nested 타입 사용:
{
"properties": {
"options": {
"type": "nested",
"properties": {
"color": { "type": "keyword" },
"size": { "type": "keyword" }
}
}
}
}// 정확한 nested 쿼리
GET /products/_search
{
"query": {
"nested": {
"path": "options",
"query": {
"bool": {
"must": [
{ "term": { "options.color": "black" } },
{ "term": { "options.size": "M" } }
]
}
}
}
}
}핵심 포인트
- text: 풀텍스트 검색용, match 쿼리 사용
- keyword: 정확한 값, 정렬/집계용, term 쿼리 사용
- Multi-field: 하나의 필드를 text와 keyword로 동시 인덱싱 가능 (name.keyword)
- Nested: 배열 내 객체 간 관계 유지가 필요할 때 사용 (Object는 평탄화됨)
Analyzer#
“삼성전자 갤럭시를 구매했습니다"라는 문서에서 “갤럭시"를 검색하면 결과가 나올까요? Analyzer 없이는 원본 텍스트를 통째로 비교하므로, “갤럭시를"과 “갤럭시"가 다른 문자열로 취급되어 검색에 실패합니다. Analyzer는 텍스트를 의미 단위의 토큰으로 분리하여 이런 불일치 문제를 해결합니다.
Analyzer는 텍스트를 검색 가능한 토큰으로 변환하는 과정입니다.
분석 과정#
flowchart LR
A["입력 텍스트<br>The Quick Brown Fox"]
--> B["Character Filter<br>(HTML 제거 등)"]
--> C["Tokenizer<br>(단어 분리)"]
--> D["Token Filter<br>(소문자 변환 등)"]
--> E["토큰<br>#91;the, quick, brown, fox#93;"]다이어그램: 입력 텍스트가 Character Filter, Tokenizer, Token Filter를 거쳐 최종 토큰으로 변환되는 과정입니다.
기본 Analyzer#
| Analyzer | 동작 | 예시 결과 |
|---|---|---|
standard | 단어 분리 + 소문자 | “Quick Brown” → [quick, brown] |
simple | 문자만 추출 + 소문자 | “Quick-Brown” → [quick, brown] |
whitespace | 공백 기준 분리 | “Quick Brown” → [Quick, Brown] |
keyword | 분석 안 함 | “Quick Brown” → [Quick Brown] |
Analyzer 테스트#
GET /_analyze
{
"analyzer": "standard",
"text": "The Quick Brown Fox"
}{
"tokens": [
{ "token": "the", "position": 0 },
{ "token": "quick", "position": 1 },
{ "token": "brown", "position": 2 },
{ "token": "fox", "position": 3 }
]
}한글 Analyzer (Nori)#
한글은 띄어쓰기만으로 의미 단위 분리가 어렵습니다.
// Standard Analyzer
"삼성전자가 스마트폰을 출시했다"
→ ["삼성전자가", "스마트폰을", "출시했다"]
// Nori Analyzer
"삼성전자가 스마트폰을 출시했다"
→ ["삼성", "전자", "스마트폰", "출시"]Nori 설정#
PUT /products
{
"settings": {
"analysis": {
"analyzer": {
"korean": {
"type": "custom",
"tokenizer": "nori_tokenizer",
"filter": ["nori_part_of_speech"]
}
},
"tokenizer": {
"nori_tokenizer": {
"type": "nori_tokenizer",
"decompound_mode": "mixed"
}
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "korean"
}
}
}
}decompound_mode 옵션#
| 모드 | “삼성전자” 결과 |
|---|---|
none | [삼성전자] |
discard | [삼성, 전자] |
mixed | [삼성전자, 삼성, 전자] |
추천:
mixed- 복합어와 분리된 단어 모두 검색 가능
Custom Analyzer#
PUT /products
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"type": "custom",
"char_filter": ["html_strip"],
"tokenizer": "standard",
"filter": ["lowercase", "my_synonym"]
}
},
"filter": {
"my_synonym": {
"type": "synonym",
"synonyms": [
"노트북, 랩탑",
"핸드폰, 스마트폰, 휴대폰"
]
}
}
}
}
}핵심 포인트
- Analyzer = Character Filter + Tokenizer + Token Filter
- 한글은 Nori Analyzer 사용 권장 (decompound_mode: mixed)
/_analyzeAPI로 분석 결과를 테스트할 수 있습니다- 동의어(Synonym) 처리는 Custom Analyzer로 설정
Dynamic Mapping#
매번 모든 필드의 타입을 수동으로 정의하는 것은 번거롭습니다. 특히 프로토타이핑 단계에서는 스키마가 자주 바뀌기 마련입니다. Dynamic Mapping은 문서를 넣는 것만으로 Elasticsearch가 자동으로 타입을 추론해주어 빠른 개발을 가능하게 합니다. 다만, 프로덕션에서는 잘못된 추론이 치명적일 수 있으므로 주의가 필요합니다.
Mapping을 정의하지 않으면 Elasticsearch가 자동으로 타입을 추론합니다.
자동 타입 추론#
| JSON 값 | 추론 타입 |
|---|---|
"hello" | text + keyword |
123 | long |
12.34 | float |
true | boolean |
"2024-01-15" | date |
{ "a": 1 } | object |
Dynamic Mapping 제어#
PUT /products
{
"mappings": {
"dynamic": "strict", // false: 무시, strict: 에러
"properties": {
"name": { "type": "text" }
}
}
}| 설정 | 동작 |
|---|---|
true | 새 필드 자동 추가 (기본값) |
false | 새 필드 저장은 하지만 인덱싱 안 함 |
strict | 새 필드 발견 시 에러 |
프로덕션 권장:
strict또는 명시적 Mapping 정의
핵심 포인트
- Dynamic Mapping은 개발 시 편리하지만, 프로덕션에서는 예기치 않은 타입 추론 위험
dynamic: strict로 설정하면 정의되지 않은 필드 입력 시 에러 발생dynamic: false는 새 필드를 저장하지만 인덱싱하지 않음 (검색 불가)
모델링 패턴#
패턴 1: 비정규화#
Elasticsearch는 JOIN을 지원하지 않으므로, 관련 데이터를 한 문서에 포함합니다.
// RDB 정규화 (2개 테이블)
// products: id, name, category_id
// categories: id, name
// Elasticsearch 비정규화 (1개 문서)
{
"name": "맥북 프로",
"category": {
"id": 1,
"name": "노트북"
}
}장점: 빠른 검색, 단순한 쿼리 단점: 카테고리 변경 시 모든 문서 업데이트 필요
패턴 2: Application-Side Join#
자주 변경되는 데이터는 별도 인덱스로 관리:
// 1. 상품 검색
List<Product> products = productRepository.search(query);
// 2. 재고 정보 조회 (별도 인덱스)
List<String> productIds = products.stream().map(Product::getId).toList();
Map<String, Stock> stocks = stockRepository.findByIds(productIds);
// 3. 조합
products.forEach(p -> p.setStock(stocks.get(p.getId())));패턴 3: Nested vs Parent-Child#
| 특성 | Nested | Parent-Child (Join) |
|---|---|---|
| 성능 | 빠름 | 느림 |
| 업데이트 | 전체 문서 재인덱싱 | 자식만 업데이트 |
| 쿼리 복잡도 | 낮음 | 높음 |
| 권장 상황 | 변경 적은 관계 | 변경 잦은 1:N |
핵심 포인트
- Elasticsearch는 JOIN을 지원하지 않으므로 비정규화가 기본 전략
- 자주 변경되는 데이터는 Application-Side Join 고려
- Nested는 성능이 좋지만 전체 문서 재인덱싱 필요, Parent-Child는 개별 업데이트 가능
모범 사례#
1. 검색 필드는 text, 필터/집계 필드는 keyword#
{
"name": {
"type": "text",
"fields": { "keyword": { "type": "keyword" } }
},
"status": { "type": "keyword" }
}2. 숫자 ID는 keyword#
{
"user_id": { "type": "keyword" } // long 아님!
}숫자 범위 검색이 없다면 keyword가 더 효율적입니다.
3. 불필요한 필드는 인덱싱 제외#
{
"raw_data": {
"type": "object",
"enabled": false // 저장만, 검색 불가
}
}4. 인덱스 템플릿 활용#
PUT /_index_template/logs
{
"index_patterns": ["logs-*"],
"template": {
"mappings": {
"properties": {
"@timestamp": { "type": "date" },
"message": { "type": "text" }
}
}
}
}핵심 포인트
- 검색용 필드는 text + keyword Multi-field로 설정
- 숫자 ID도 범위 검색이 없으면 keyword가 효율적
- 검색하지 않는 필드는
enabled: false로 인덱싱 제외- 인덱스 템플릿으로 일관된 Mapping 적용
다음 단계#
| 목표 | 추천 문서 |
|---|---|
| 검색 쿼리 작성 | Query DSL |
| 검색 품질 개선 | 검색 관련성 |
| 실습 | 기본 예제 |