본문으로 건너뛰기
Elasticsearch 매핑과 필드 타입 가이드
검색 엔진 / · PT4M read

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"  // 문제의 원인!
        }
      }
    }
  }
}

pricetext로 잡혀있다. 동적 매핑이 문자열 "50000"을 보고 text로 추론한 것이다.

동적 매핑(Dynamic Mapping)

Elasticsearch는 첫 문서가 색인될 때 자동으로 매핑을 생성한다. 편리하지만 위험하다.

동적 매핑 규칙

JSON 타입Elasticsearch 타입
null필드 추가 안 함
true / falseboolean
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

가장 중요한 구분이다.

구분textkeyword
분석✅ 토큰화됨❌ 분석 안 함
검색전문 검색 (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경큰 정수, 타임스탬프
float32비트 부동소수점일반 실수
double64비트 부동소수점정밀한 실수
scaled_floatlong + 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", 1
  • false, "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개

기타 유용한 타입

타입용도예시
ipIP 주소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.limit1000최대 필드 수
depth.limit20객체 중첩 깊이
nested_fields.limit50nested 필드 수
nested_objects.limit10000nested 객체 수/문서

해결 패턴: 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

차이점

기능ElasticsearchOpenSearch
dense_vector 차원최대 4096최대 16000 (2.9+)
knn_vector별도 타입 없음전용 타입 존재

매핑 설계 체크리스트

설계 전

  • 어떤 검색이 필요한가? (전문 검색 vs 정확 일치)
  • 어떤 정렬/집계가 필요한가?
  • 데이터 타입이 명확한가?
  • 동적 필드가 필요한가?

설계 시

  • text vs keyword 올바르게 선택
  • 숫자는 문자열이 아닌 숫자 타입으로
  • 날짜 형식 명시
  • nested가 정말 필요한지 검토
  • multi-field 활용 검토

설계 후

  • 동적 매핑 제한 설정
  • 매핑 폭발 방지 제한 설정
  • 버전 관리 (인덱스 템플릿)

마무리

이 글에서 다룬 핵심 내용:

  1. 동적 매핑: 편리하지만 위험, strict 모드 권장
  2. text vs keyword: 검색 방식에 따라 선택
  3. nested: 객체 배열의 관계 유지
  4. 매핑 폭발: 필드 제한으로 방지
  5. 재인덱싱: 매핑 변경 시 필수

다음 글에서는 CRUD 작업과 Bulk API를 다룬다. 문서를 효율적으로 색인하고, 대량 데이터를 처리하는 방법을 알아본다.

참고 자료

다음 글: Elasticsearch 설치: Docker와 Kubernetes ...
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


Elasticsearch 검색 엔진 마스터 시리즈
Elasticsearch 검색 엔진 입문 가이드

Elasticsearch 입문: 검색 엔진이 필요한 이유

RDBMS의 LIKE 검색이 왜 프로덕션에서 문제가 되는지, 역인덱스가 무엇인지, 그리고 Elasticsearch가 어떻게 이 문제를 해결하는지 실제 장애 사례와 함께 알아봅니다.

백엔드 데이터베이스 프로덕션 +3
Elasticsearch CRUD와 Bulk API 가이드

Elasticsearch CRUD: 문서 색인과 Bulk API 최적화

Elasticsearch에서 문서를 생성, 조회, 수정, 삭제하는 방법과 대량 데이터 처리를 위한 Bulk API 최적화 전략을 알아봅니다. refresh 동작, 라우팅, 버전 관리까지 실무 팁을 다룹니다.

백엔드 Elasticsearch OpenSearch +3