Elasticsearch 매핑: 필드 타입과 스키마 설계
검색이 안 되는 이유가 매핑 때문?
“분명히 데이터는 들어갔는데 검색이 안 돼요.”
개발자가 가장 흔히 겪는 Elasticsearch 문제다. 대부분의 원인은 잘못된 매핑에 있다.
# 상품 색인
curl -X POST "localhost:9200/products/_doc/1" -H "Content-Type: application/json" -d'
{
"name": "블루투스 이어폰",
"price": "50000"
}'
# 가격 범위 검색 - 실패!
curl -X GET "localhost:9200/products/_search" -H "Content-Type: application/json" -d'
{
"query": {
"range": {
"price": { "gte": 40000, "lte": 60000 }
}
}
}'
결과는 0건. price가 문자열 "50000"으로 색인되어 숫자 범위 검색이 불가능하기 때문이다.
이 글에서는 매핑의 개념부터 올바른 필드 타입 선택, 동적 매핑의 함정과 해결책까지 자세히 알아본다.
매핑(Mapping)이란?
매핑은 RDBMS의 스키마와 비슷하다. 문서의 각 필드가 어떤 타입으로 저장되고, 어떻게 인덱싱되는지 정의한다.
매핑 확인하기
curl -X GET "localhost:9200/products/_mapping?pretty"
{
"products": {
"mappings": {
"properties": {
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"price": {
"type": "text" // 문제의 원인!
}
}
}
}
}
price가 text로 잡혀있다. 동적 매핑이 문자열 "50000"을 보고 text로 추론한 것이다.
동적 매핑(Dynamic Mapping)
Elasticsearch는 첫 문서가 색인될 때 자동으로 매핑을 생성한다. 편리하지만 위험하다.
동적 매핑 규칙
| JSON 타입 | Elasticsearch 타입 |
|---|---|
null | 필드 추가 안 함 |
true / false | boolean |
123 (정수) | long |
123.45 (실수) | float |
"2024-01-15" | date (감지 시) |
"hello" | text + keyword 서브필드 |
{"nested": "object"} | object |
[1, 2, 3] | 첫 요소 타입의 배열 |
동적 매핑의 함정
함정 1: 숫자 문자열
{ "price": "50000" } // → text (검색 불가)
{ "price": 50000 } // → long (올바름)
함정 2: 날짜 문자열
{ "date": "2024-01-15" } // → date (감지됨)
{ "date": "15/01/2024" } // → text (감지 안 됨)
{ "date": "2024-01-15T10:30:00" } // → date (감지됨)
함정 3: 첫 문서가 기준
// 첫 번째 문서
{ "count": 100 } // → long
// 두 번째 문서
{ "count": "많음" } // 오류! long에 문자열 삽입 불가
동적 매핑 제어
curl -X PUT "localhost:9200/strict_index" -H "Content-Type: application/json" -d'
{
"mappings": {
"dynamic": "strict",
"properties": {
"name": { "type": "text" },
"price": { "type": "integer" }
}
}
}'
| 설정 | 동작 |
|---|---|
true (기본) | 새 필드 자동 추가 |
false | 새 필드 저장만, 인덱싱 안 함 |
strict | 새 필드 시 오류 발생 |
runtime | 런타임 필드로 추가 |
핵심 필드 타입
text vs keyword
가장 중요한 구분이다.
| 구분 | text | keyword |
|---|---|---|
| 분석 | ✅ 토큰화됨 | ❌ 분석 안 함 |
| 검색 | 전문 검색 (match) | 정확 일치 (term) |
| 정렬 | ❌ 불가 | ✅ 가능 |
| 집계 | ❌ 불가 | ✅ 가능 |
| 용도 | 본문, 설명 | ID, 태그, 상태 |
text 필드 동작:
"블루투스 이어폰 추천"
→ 분석기 → ["블루투스", "이어폰", "추천"]
keyword 필드 동작:
"블루투스 이어폰 추천"
→ 그대로 저장 → "블루투스 이어폰 추천"
multi-field 패턴 (둘 다 필요할 때):
{
"name": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
# 전문 검색
GET /products/_search
{
"query": { "match": { "name": "블루투스" } }
}
# 정확 일치 + 정렬
GET /products/_search
{
"query": { "term": { "name.keyword": "블루투스 이어폰 추천" } },
"sort": ["name.keyword"]
}
숫자 타입
| 타입 | 범위 | 용도 |
|---|---|---|
byte | -128 ~ 127 | 작은 정수 |
short | -32,768 ~ 32,767 | 작은 정수 |
integer | ±2.1억 | 일반 정수 |
long | ±9경 | 큰 정수, 타임스탬프 |
float | 32비트 부동소수점 | 일반 실수 |
double | 64비트 부동소수점 | 정밀한 실수 |
scaled_float | long + scaling_factor | 가격 (소수점 고정) |
가격 필드 설계:
{
"price": {
"type": "scaled_float",
"scaling_factor": 100
}
}
49900.50 → 내부적으로 4990050으로 저장 (정밀도 유지)
날짜 타입
{
"created_at": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
}
}
지원 형식:
- ISO 8601:
2024-01-15T10:30:00Z - 커스텀:
2024-01-15 10:30:00 - Epoch:
1705312200000
불리언 타입
{
"is_active": {
"type": "boolean"
}
}
허용 값:
true,"true","on","yes","1",1false,"false","off","no","0",0
객체와 중첩 타입
object 타입 (기본):
{
"author": {
"first": "John",
"last": "Doe"
}
}
내부적으로 평탄화:
{
"author.first": "John",
"author.last": "Doe"
}
문제: 배열의 객체
{
"comments": [
{ "author": "Alice", "text": "좋아요" },
{ "author": "Bob", "text": "별로예요" }
]
}
평탄화 결과:
{
"comments.author": ["Alice", "Bob"],
"comments.text": ["좋아요", "별로예요"]
}
“Alice가 쓴 ‘별로예요’ 댓글”을 찾으면 잘못 매칭된다!
해결: nested 타입
{
"comments": {
"type": "nested",
"properties": {
"author": { "type": "keyword" },
"text": { "type": "text" }
}
}
}
# nested 쿼리
GET /posts/_search
{
"query": {
"nested": {
"path": "comments",
"query": {
"bool": {
"must": [
{ "term": { "comments.author": "Alice" } },
{ "match": { "comments.text": "좋아요" } }
]
}
}
}
}
}
nested 주의사항:
- 각 nested 객체는 별도 Lucene 문서로 저장
- 검색 성능 오버헤드
- 기본 제한: 인덱스당 50개, 문서당 10,000개
기타 유용한 타입
| 타입 | 용도 | 예시 |
|---|---|---|
ip | IP 주소 | 192.168.1.1, CIDR 범위 검색 |
geo_point | 위경도 좌표 | 거리 검색, 바운딩 박스 |
geo_shape | 지리 도형 | 폴리곤, 다각형 |
completion | 자동완성 | 빠른 prefix 검색 |
dense_vector | 벡터 | ML 임베딩, 유사도 검색 |
alias | 필드 별칭 | 레거시 호환 |
매핑 설계 패턴
이커머스 상품 매핑
{
"mappings": {
"dynamic": "strict",
"properties": {
"product_id": {
"type": "keyword"
},
"name": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": { "type": "keyword" },
"autocomplete": {
"type": "text",
"analyzer": "autocomplete"
}
}
},
"description": {
"type": "text",
"analyzer": "korean"
},
"category": {
"type": "keyword"
},
"tags": {
"type": "keyword"
},
"price": {
"type": "scaled_float",
"scaling_factor": 100
},
"discount_rate": {
"type": "short"
},
"stock": {
"type": "integer"
},
"is_active": {
"type": "boolean"
},
"created_at": {
"type": "date"
},
"updated_at": {
"type": "date"
},
"attributes": {
"type": "nested",
"properties": {
"name": { "type": "keyword" },
"value": { "type": "keyword" }
}
},
"ratings": {
"properties": {
"average": { "type": "float" },
"count": { "type": "integer" }
}
}
}
}
}
로그 데이터 매핑
{
"mappings": {
"dynamic": "runtime",
"properties": {
"@timestamp": {
"type": "date"
},
"level": {
"type": "keyword"
},
"service": {
"type": "keyword"
},
"message": {
"type": "text"
},
"trace_id": {
"type": "keyword"
},
"span_id": {
"type": "keyword"
},
"host": {
"properties": {
"name": { "type": "keyword" },
"ip": { "type": "ip" }
}
},
"http": {
"properties": {
"method": { "type": "keyword" },
"status_code": { "type": "short" },
"url": { "type": "keyword" },
"response_time_ms": { "type": "integer" }
}
}
}
}
}
매핑 폭발(Mapping Explosion) 방지
동적 매핑으로 무한히 필드가 추가되면 클러스터가 불안정해진다.
문제 상황
// 사용자 입력 데이터
{
"user_123_preference": "dark_mode",
"user_456_preference": "light_mode",
"user_789_preference": "auto"
// ... 수만 개의 동적 필드
}
제한 설정
curl -X PUT "localhost:9200/my_index/_settings" -H "Content-Type: application/json" -d'
{
"index.mapping.total_fields.limit": 1000,
"index.mapping.depth.limit": 20,
"index.mapping.nested_fields.limit": 50,
"index.mapping.nested_objects.limit": 10000
}'
| 설정 | 기본값 | 설명 |
|---|---|---|
total_fields.limit | 1000 | 최대 필드 수 |
depth.limit | 20 | 객체 중첩 깊이 |
nested_fields.limit | 50 | nested 필드 수 |
nested_objects.limit | 10000 | nested 객체 수/문서 |
해결 패턴: Flattened 타입
{
"user_preferences": {
"type": "flattened"
}
}
{
"user_preferences": {
"theme": "dark",
"language": "ko",
"notifications": {
"email": true,
"push": false
}
}
}
모든 값을 keyword로 처리하여 매핑 폭발 방지.
매핑 변경과 재인덱싱
매핑은 변경할 수 없다 (대부분의 경우).
변경 가능한 항목
# 새 필드 추가
curl -X PUT "localhost:9200/products/_mapping" -H "Content-Type: application/json" -d'
{
"properties": {
"new_field": { "type": "keyword" }
}
}'
# ignore_above 변경
# 기존 keyword 필드에 새 sub-field 추가
변경 불가능한 항목
- 필드 타입 변경 (text → keyword)
- 분석기 변경
- 필드 삭제
재인덱싱(Reindex) 필요 시
# 1. 새 인덱스 생성 (올바른 매핑)
curl -X PUT "localhost:9200/products_v2" -H "Content-Type: application/json" -d'
{
"mappings": {
"properties": {
"price": { "type": "integer" }
}
}
}'
# 2. 데이터 복사
curl -X POST "localhost:9200/_reindex" -H "Content-Type: application/json" -d'
{
"source": { "index": "products" },
"dest": { "index": "products_v2" }
}'
# 3. Alias 변경 (무중단 전환)
curl -X POST "localhost:9200/_aliases" -H "Content-Type: application/json" -d'
{
"actions": [
{ "remove": { "index": "products", "alias": "products_alias" } },
{ "add": { "index": "products_v2", "alias": "products_alias" } }
]
}'
OpenSearch 차이점
매핑 관련해서 Elasticsearch와 OpenSearch는 거의 동일하다.
주요 동일점
- 동일한 필드 타입
- 동일한 동적 매핑 규칙
- 동일한 매핑 API
차이점
| 기능 | Elasticsearch | OpenSearch |
|---|---|---|
dense_vector 차원 | 최대 4096 | 최대 16000 (2.9+) |
knn_vector | 별도 타입 없음 | 전용 타입 존재 |
매핑 설계 체크리스트
설계 전
- 어떤 검색이 필요한가? (전문 검색 vs 정확 일치)
- 어떤 정렬/집계가 필요한가?
- 데이터 타입이 명확한가?
- 동적 필드가 필요한가?
설계 시
-
textvskeyword올바르게 선택 - 숫자는 문자열이 아닌 숫자 타입으로
- 날짜 형식 명시
- nested가 정말 필요한지 검토
- multi-field 활용 검토
설계 후
- 동적 매핑 제한 설정
- 매핑 폭발 방지 제한 설정
- 버전 관리 (인덱스 템플릿)
마무리
이 글에서 다룬 핵심 내용:
- 동적 매핑: 편리하지만 위험, strict 모드 권장
- text vs keyword: 검색 방식에 따라 선택
- nested: 객체 배열의 관계 유지
- 매핑 폭발: 필드 제한으로 방지
- 재인덱싱: 매핑 변경 시 필수
다음 글에서는 CRUD 작업과 Bulk API를 다룬다. 문서를 효율적으로 색인하고, 대량 데이터를 처리하는 방법을 알아본다.