5장. 매핑과 필드 타입 — 스키마 없는 척하는 스키마
Elasticsearch는 "schemaless"를 표방하지만, 모든 인덱스에는 매핑이라는 스키마가 있습니다. 그리고 한 번 정한 필드 타입은 바꿀 수 없습니다 — 후회하기 전에 배우는 매핑 설계.
① 매핑이 결정하는 것 — 저장·색인·검색·집계의 규칙 ② dynamic 매핑의 함정과 strict 모드 ③ 같은 문자열이 text와 keyword로 색인될 때의 결정적 차이 ④ object 배열의 평탄화 함정과 nested ⑤ 매핑 변경 불가 → 무중단 reindex 전략
5.1 매핑이란 — 필드의 운명을 정하는 계약서
매핑(mapping)은 문서가 어떤 필드로 구성되고, 각 필드를 어떻게 저장·색인·검색할지 정의하는 메타데이터입니다. RDBMS의 DDL과 비슷하지만 결정적 차이가 있습니다: 스키마 위반 시 에러 대신 자동 추론이 일어나고, 타입 변경이 불가능하다는 점입니다.
# 명시적 매핑으로 인덱스 생성
PUT /products
{
"mappings": {
"properties": {
"name": { "type": "text" }, # 풀텍스트 검색 대상
"sku": { "type": "keyword" }, # 정확 매칭·집계용
"price": { "type": "scaled_float", "scaling_factor": 100 },
"stock": { "type": "integer" },
"created_at": { "type": "date" },
"tags": { "type": "keyword" }
}
}
}
매핑이 결정하는 것들: 역색인에 들어갈지 / doc values(컬럼 스토어)를 만들지 / 어떤 analyzer를 거칠지 / term·range·풀텍스트 쿼리가 가능한지 / 정렬·집계가 가능한지 / 디스크·메모리를 얼마나 쓸지. 필드의 운명 전부입니다.
5.2 Dynamic 매핑 — 편리함의 청구서
매핑에 없는 새 필드가 들어오면? dynamic 파라미터가 처리 방식을 정합니다.
| 값 | 동작 | 어울리는 곳 |
|---|---|---|
true (기본) | 타입을 자동 추론해 매핑에 추가 | 프로토타이핑, 로그 |
false | 무시 — 색인은 되지만 검색·집계 불가(_source에만 보존) | 자유 형식 메타데이터 |
strict | 새 필드 발견 시 문서 거부(400 에러) | 운영 인덱스 권장 |
runtime | runtime field로 추가 (색인 없이 쿼리 시 평가) | 스키마 변동이 잦은 로그 |
오타 하나(uesr_id)가 새 필드를 만들고 영원히 매핑에 남습니다. 필드 수 한도(index.mapping.total_fields.limit, 기본 1000)를 잠식하고, 첫 값이 "42"라서 text로 추론된 필드는 숫자 range 쿼리가 불가능한데 되돌릴 수도 없습니다. 운영 인덱스는 dynamic: "strict"가 정석입니다.
5.3 text vs keyword — 가장 흔한 실수
Elasticsearch에는 "string" 타입이 없습니다. 문자열은 text와 keyword라는 완전히 다른 두 타입으로 색인됩니다. 같은 문자열 "Quick Brown Fox"가 두 타입에서 어떤 운명을 맞는지 봅시다.
같은 문자열 "Quick Brown Fox"가 들어옵니다. 매핑 타입에 따라 두 갈래로 갈라집니다.
text 경로: analyzer를 통과하며 소문자화 + 토큰 분해되어 [quick] [brown] [fox] 세 조각이 됩니다.
세 토큰이 각각 역색인에 등록됩니다. 이제 fox 한 단어로도 이 문서를 찾을 수 있습니다. 단, doc values는 만들어지지 않아 정렬·집계가 안 됩니다.
keyword 경로: 분석 없이 원본 문자열 전체가 단일 토큰으로 색인되고, doc values(컬럼 저장)도 함께 생성됩니다.
결과: text는 풀텍스트 검색 ○ / 집계 ✕, keyword는 정확 매칭·정렬·집계 ○ / 부분 검색 ✕. 용도가 다른 별개의 타입입니다 — 둘 다 필요하면 multi-fields(5.4)로 둘 다 만듭니다.
이 차이를 모르고 status 같은 enum 필드를 text로 만들면, 집계하는 순간 이런 에러를 만납니다.
# status가 text 타입일 때 terms 집계 시도
GET /orders/_search
{
"size": 0,
"aggs": { "by_status": { "terms": { "field": "status" } } }
}
# → 400 에러:
# "Text fields are not optimised for operations that require
# per-document field data like aggregations and sorting...
# Please use a keyword field instead."
사람이 자연어로 검색하는 필드 → text (본문, 제목). 시스템 식별자·enum·태그·정확 매칭·집계 대상 → keyword (status, sku, user_id). 둘 다 필요하면 → multi-fields.
5.4 Multi-fields — 한 데이터, 여러 색인
같은 데이터를 여러 방식으로 동시에 색인할 수 있습니다. 가장 흔한 실전 패턴입니다.
PUT /articles
{
"mappings": {
"properties": {
"title": {
"type": "text", # title → 풀텍스트 검색
"fields": {
"keyword": { # title.keyword → 정렬·집계·정확매칭
"type": "keyword",
"ignore_above": 256 # 256자 초과는 keyword 색인 제외
},
"korean": { # title.korean → 한국어 형태소 검색
"type": "text", "analyzer": "nori"
}
}
}
}
}
}
이 매핑에서 title은 실제로 3개의 색인을 갖습니다. dynamic 매핑이 문자열에 자동으로 만들어주는 것도 바로 text + fields.keyword 조합입니다 — 자동 생성된 인덱스에서 title.keyword로 집계할 수 있는 이유입니다.
5.5 주요 필드 타입 카탈로그
| 분류 | 타입 | 포인트 |
|---|---|---|
| 문자열 | text / keyword | 5.3 참고 — 용도가 완전히 다름 |
| 정수 | byte short integer long | 범위에 맞는 최소 타입 선택 |
| 실수 | float double half_float scaled_float | 금액은 scaled_float(scaling_factor=100) — 정확한 소수점 + 작은 저장 공간 |
| 날짜 | date / date_nanos | 내부 저장은 항상 epoch millis(long). format은 파싱·출력용 |
| 불리언 | boolean | true/false/"true"/"false" 허용 |
| 네트워크·지리 | ip geo_point geo_shape | ip는 CIDR 쿼리, geo_point는 거리·경계 쿼리 |
| 벡터 | dense_vector sparse_vector | kNN·시맨틱 검색. 9.x엔 semantic_text가 임베딩 자동 생성 |
| 구조 | object nested flattened | 5.6 참고 — 잘못 고르면 검색 결과가 틀림 |
5.6 Object 배열의 함정 — 그리고 nested
객체 배열을 기본 object 타입으로 색인하면 검색 결과가 조용히 틀리는 유명한 함정이 있습니다. 에러도 없이, 매칭되면 안 되는 문서가 매칭됩니다.
주문 문서에 아이템이 2개 있습니다: A1 × 5개와 B2 × 1개.
기본 object 타입으로 색인하면 Lucene은 계층 구조를 지원하지 않으므로 필드별 배열로 평탄화합니다: items.sku=["A1","B2"], items.qty=[5,1].
이 순간 "A1과 5가 같은 객체였다"는 정보가 사라집니다. 값들은 남았지만 짝은 잃어버렸습니다.
이제 sku=A1 AND qty=1로 검색하면 — 그런 아이템은 없는데도 — A1이 sku 배열에 있고 1이 qty 배열에 있으니 문서가 매칭됩니다. 에러 없이 조용히 틀리는 최악의 버그입니다.
해법은 nested 타입: 각 객체를 숨겨진 별도 문서로 색인해 객체 경계를 보존합니다. 검색도 nested 쿼리로 해야 하며, 문서 수 증가라는 비용이 있습니다(객체 한도 기본 10,000).
세 가지 선택지를 정리하면:
- object (기본) — 구조화된 단일 객체엔 충분. 배열 + 항목 내 AND 조건에서만 함정 발동.
- nested — 배열 객체의 경계 보존.
nested쿼리 필수, 색인 비용 증가. - flattened — 키가 동적으로 늘어나는 객체(사용자 정의 라벨 등)를 하나의 필드로 취급해 매핑 폭증 방지. 모든 값이 keyword로 취급되는 제약.
5.7 매핑은 변경 불가 — 무중단 reindex 전략
한 번 정한 필드의 타입은 바꿀 수 없습니다. 새 필드 추가나 multi-field 추가는 되지만, text → keyword 같은 타입 변경이나 analyzer 변경은 재색인(reindex)이 유일한 길입니다.
# ❌ 이미 text인 필드를 keyword로 바꾸려 하면
PUT /products/_mapping
{ "properties": { "status": { "type": "keyword" } } }
# → 400: mapper [status] cannot be changed from type [text] to [keyword]
그래서 운영의 정석은 처음부터 인덱스를 alias 뒤에 숨기는 것입니다. 클라이언트는 alias만 바라보게 하고, 매핑을 바꿀 일이 생기면 새 인덱스로 reindex한 뒤 alias를 원자적으로 스왑합니다.
애플리케이션은 인덱스 이름이 아닌 alias(products_search)로만 접근합니다. 현재 alias는 매핑이 잘못된 products_v1을 가리키고 있습니다.
올바른 매핑으로 products_v2를 만들고 _reindex로 데이터를 복사합니다. 그동안 서비스는 v1에서 정상 동작 중입니다.
복사가 끝나면 _aliases API로 alias를 v1에서 v2로 원자적으로 스왑합니다. remove와 add가 한 요청에서 처리되어 빈틈이 없습니다.
애플리케이션은 코드 변경도 재시작도 없이 새 매핑을 쓰게 됩니다. v1은 검증 후 삭제. 처음부터 alias 뒤에서 시작하는 것이 이 모든 것을 가능하게 합니다.
# 무중단 reindex 3단계
# 1) 올바른 매핑으로 새 인덱스 생성
PUT /products_v2 { "mappings": { ... } }
# 2) 데이터 복사 (slices로 병렬화 가능)
POST /_reindex?slices=auto
{
"source": { "index": "products" },
"dest": { "index": "products_v2" }
}
# 3) alias 원자적 스왑 — 다운타임 0
POST /_aliases
{
"actions": [
{ "remove": { "index": "products", "alias": "products_search" } },
{ "add": { "index": "products_v2", "alias": "products_search" } }
]
}
인덱스를 일일이 PUT으로 만들지 않고 index template + component template으로 패턴(logs-*)에 매핑·설정을 자동 적용합니다. dynamic: "strict"도 템플릿에 넣어두면 새 인덱스마다 잊지 않고 적용됩니다.
정렬·집계가 필요 없는 keyword 필드(예: trace_id)는 "doc_values": false로 — 대량 로그에서 30% 이상 절감되기도 합니다. 점수 계산이 필요 없는 필터 전용 text 필드는 "norms": false. 단, 나중에 필요해지면 reindex해야 하니 신중히.
✍️ 이해도 체크
status 필드(값: "shipped", "pending" 등)로 terms 집계를 하려면 어떤 매핑이 맞을까요?{"items": [{"sku":"A1","qty":5}, {"sku":"B2","qty":1}]}를 object 타입으로 색인하고 sku=A1 AND qty=1로 검색하면?이 장의 원문 문서: chapters/ch05_mapping_and_field_types.md — runtime fields(schema-on-read), index/component template 전체 예제, 필드 수 폭발 방지 전략이 담겨 있습니다.