검색이 안 되는 이유가 매핑 때문?
“분명히 데이터는 들어갔는데 검색이 안 돼요.”
개발자가 가장 흔히 겪는 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를 다룬다. 문서를 효율적으로 색인하고, 대량 데이터를 처리하는 방법을 알아본다.