DB Study

5장. 매핑과 필드 타입 — 스키마 없는 척하는 스키마

Elasticsearch는 "schemaless"를 표방하지만, 모든 인덱스에는 매핑이라는 스키마가 있습니다. 그리고 한 번 정한 필드 타입은 바꿀 수 없습니다 — 후회하기 전에 배우는 매핑 설계.

중급 ⏱ 약 28분 🎬 애니메이션 3개 선수 지식: 1장 역색인, 3장 doc values
🧭
이 장에서 배우는 것

① 매핑이 결정하는 것 — 저장·색인·검색·집계의 규칙 ② 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 에러)운영 인덱스 권장
runtimeruntime field로 추가 (색인 없이 쿼리 시 평가)스키마 변동이 잦은 로그
⚠️
dynamic=true가 위험한 이유

오타 하나(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 🔑 keyword Analyzer 통과 (토큰 분해) [quick] [brown] [fox] 역색인 — 토큰 3개 각각 등록 quick→doc1 brown→doc1 fox→doc1 (doc values 없음) 분석 없음 — 통짜 그대로 ["Quick Brown Fox"] 역색인 — 단일 토큰 1개 "Quick Brown Fox"→doc1 + doc values (컬럼 저장) ✓ ✅ "fox"만 검색해도 매칭 (풀텍스트) ❌ 정렬·집계 불가, 정확 매칭 어려움 ✅ 정확 매칭·정렬·집계 모두 가능 ❌ "fox"로는 검색 안 됨 (부분 매칭 불가)

이 차이를 모르고 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 / keyword5.3 참고 — 용도가 완전히 다름
정수byte short integer long범위에 맞는 최소 타입 선택
실수float double half_float scaled_float금액은 scaled_float(scaling_factor=100) — 정확한 소수점 + 작은 저장 공간
날짜date / date_nanos내부 저장은 항상 epoch millis(long). format은 파싱·출력용
불리언booleantrue/false/"true"/"false" 허용
네트워크·지리ip geo_point geo_shapeip는 CIDR 쿼리, geo_point는 거리·경계 쿼리
벡터dense_vector sparse_vectorkNN·시맨틱 검색. 9.x엔 semantic_text가 임베딩 자동 생성
구조object nested flattened5.6 참고 — 잘못 고르면 검색 결과가 틀림

5.6 Object 배열의 함정 — 그리고 nested

객체 배열을 기본 object 타입으로 색인하면 검색 결과가 조용히 틀리는 유명한 함정이 있습니다. 에러도 없이, 매칭되면 안 되는 문서가 매칭됩니다.

주문 문서 (items 배열) {"items": [ {"sku":"A1", "qty":5}, {"sku":"B2", "qty":1} ]} object 색인 내부 저장 — 평탄화(flatten) items.sku: ["A1", "B2"] items.qty: [5, 1] ⚠️ "A1과 5가 한 쌍"이라는 정보가 소멸! 검색: sku="A1" AND qty=1 그런 조합의 아이템은 존재하지 않는데... ❌ 매칭됨! (A1은 sku 배열에, 1은 qty 배열에 있으니까) ✅ 해법: "type": "nested" {sku:A1, qty:5} {sku:B2, qty:1} 각 객체가 숨겨진 별도 문서로 색인 → 객체 단위 AND 판정

세 가지 선택지를 정리하면:

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만 사용 alias: products_search 가리키는 곳만 바꾸면 됨 products_v1 status가 text (잘못된 매핑) products_v2 status가 keyword ✓ _reindex alias 원자적 스왑! v1은 확인 후 삭제 ✅ 애플리케이션은 아무것도 몰랐다 — 다운타임 0
# 무중단 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 집계를 하려면 어떤 매핑이 맞을까요?
✅ enum성 식별자는 keyword입니다. text는 analyzer로 토큰 분해되고 doc values가 없어 집계·정렬 시 400 에러가 납니다. status를 자연어로 검색할 일은 없으므로 keyword 단독이면 충분하고, 사람이 검색하는 필드라면 text + fields.keyword multi-field를 씁니다.
{"items": [{"sku":"A1","qty":5}, {"sku":"B2","qty":1}]}를 object 타입으로 색인하고 sku=A1 AND qty=1로 검색하면?
✅ object 타입은 배열을 필드별 배열로 평탄화하므로 "A1과 5가 한 쌍"이라는 정보가 사라지고, 다른 객체의 값끼리 조합되어 잘못 매칭됩니다. 에러 없이 조용히 틀리는 것이 무서운 점입니다. 항목 내 AND 조건이 필요하면 nested 타입 + nested 쿼리를 사용하세요.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch05_mapping_and_field_types.md — runtime fields(schema-on-read), index/component template 전체 예제, 필드 수 폭발 방지 전략이 담겨 있습니다.