TL;DR
  • Mapping: 문서 구조를 정의하는 스키마 (RDB의 테이블 정의와 유사)
  • text: 풀텍스트 검색용, Analyzer로 토큰화됨
  • keyword: 정확한 값 매칭, 정렬/집계용
  • Analyzer: 텍스트를 검색 가능한 토큰으로 변환 (한글은 Nori 사용)
  • 비정규화: JOIN 없으므로 관련 데이터를 한 문서에 포함

대상 독자: Elasticsearch 검색 기능을 사용하려는 개발자 선수 지식: 핵심 구성요소, JSON 기본 문법

소요 시간: 약 25-30분

전체 비유: 도서관의 도서 분류 시스템#

데이터 모델링을 도서관의 도서 분류 체계에 비유하면 이해하기 쉽습니다:

도서관 비유Elasticsearch역할
도서 분류 규칙 (DDC, KDC)Mapping책을 어떻게 분류하고 정리할지 정의
책 내용으로 검색text 타입“인공지능"을 검색하면 관련 내용 포함 도서 반환
청구기호로 정확히 찾기keyword 타입“005.133-P99” 정확히 일치하는 책 찾기
색인 담당 사서Analyzer책 내용을 읽고 핵심 키워드 추출하여 색인 카드 작성
한 책에 관련 정보 모두 기록비정규화저자, 출판사, 분류 정보를 한 카드에 기재
Object (저자 정보)단순 중첩저자명, 생년 등 관련 정보 묶음
Nested (시리즈 정보)관계 유지1권-2024년, 2권-2025년 각각 독립적 관계 유지

이처럼 Mapping은 도서관에서 “어떤 기준으로 책을 분류하고 색인할 것인가"를 정하는 것과 같습니다.

Elasticsearch에서 데이터를 효과적으로 저장하고 검색하기 위한 Mapping, Field Type, Analyzer 설계를 다룹니다.

Mapping이란?#

Mapping은 문서와 필드가 어떻게 저장되고 인덱싱되는지 정의하는 스키마입니다.

RDB vs Elasticsearch#

RDBElasticsearch
CREATE TABLEPUT /index (mapping)
컬럼 타입Field Type
스키마 필수Dynamic Mapping 가능
ALTER TABLE제한적 (재인덱싱 필요)

Mapping 예시#

PUT /products
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "standard"
      },
      "category": {
        "type": "keyword"
      },
      "price": {
        "type": "integer"
      },
      "created_at": {
        "type": "date"
      },
      "in_stock": {
        "type": "boolean"
      }
    }
  }
}
핵심 포인트
  • Mapping은 인덱스 생성 시 정의하며, 이후 필드 타입 변경이 제한적입니다
  • Dynamic Mapping으로 자동 타입 추론이 가능하지만, 프로덕션에서는 명시적 정의 권장
  • 스키마 변경이 필요하면 재인덱싱(Reindex)이 필요합니다

Field Types#

문자열 타입#

text vs keyword#

특성textkeyword
용도풀텍스트 검색정확한 값 매칭
분석Analyzer로 토큰화분석 안 함
검색match 쿼리term 쿼리
정렬/집계불가 (기본)가능
예시상품 설명, 게시글 본문카테고리, 상태값, ID
{
  "properties": {
    "title": {
      "type": "text"          // "맥북 프로" → ["맥북", "프로"]
    },
    "category": {
      "type": "keyword"       // "노트북" → "노트북" (그대로)
    }
  }
}

Multi-field#

하나의 필드를 여러 방식으로 인덱싱:

{
  "properties": {
    "name": {
      "type": "text",
      "fields": {
        "keyword": {
          "type": "keyword"   // name.keyword로 접근
        }
      }
    }
  }
}
# 풀텍스트 검색
GET /products/_search
{ "query": { "match": { "name": "맥북" } } }

# 정확한 값 집계
GET /products/_search
{
  "aggs": {
    "names": { "terms": { "field": "name.keyword" } }
  }
}

숫자 타입#

타입범위용도
byte-128 ~ 127작은 정수
short-32,768 ~ 32,767작은 정수
integer-2³¹ ~ 2³¹-1일반 정수
long-2⁶³ ~ 2⁶³-1큰 정수, ID
float32비트 부동소수점근사값
double64비트 부동소수점정밀 계산
scaled_float스케일 적용가격 (scaling_factor: 100)
{
  "properties": {
    "price": {
      "type": "scaled_float",
      "scaling_factor": 100    // 23900.00 → 2390000으로 저장
    },
    "quantity": {
      "type": "integer"
    }
  }
}

날짜 타입#

{
  "properties": {
    "created_at": {
      "type": "date",
      "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
    }
  }
}

지원 형식:

  • 2024-01-15
  • 2024-01-15T10:30:00
  • 2024-01-15T10:30:00+09:00
  • 1705300200000 (epoch millis)

불리언 타입#

{
  "properties": {
    "in_stock": {
      "type": "boolean"   // true, false, "true", "false" 모두 허용
    }
  }
}

복합 타입#

Object#

중첩된 JSON 객체:

{
  "properties": {
    "seller": {
      "properties": {
        "name": { "type": "keyword" },
        "rating": { "type": "float" }
      }
    }
  }
}
// 문서
{
  "seller": {
    "name": "공식스토어",
    "rating": 4.8
  }
}

// 검색
GET /products/_search
{
  "query": {
    "match": { "seller.name": "공식스토어" }
  }
}

Nested#

Object의 문제점:

// 문서
{
  "options": [
    { "color": "black", "size": "M" },
    { "color": "white", "size": "L" }
  ]
}

Object 타입은 배열을 평탄화합니다:

options.color: ["black", "white"]
options.size: ["M", "L"]

→ “black AND L” 검색 시 잘못된 매칭 발생!

Nested 타입 사용:

{
  "properties": {
    "options": {
      "type": "nested",
      "properties": {
        "color": { "type": "keyword" },
        "size": { "type": "keyword" }
      }
    }
  }
}
// 정확한 nested 쿼리
GET /products/_search
{
  "query": {
    "nested": {
      "path": "options",
      "query": {
        "bool": {
          "must": [
            { "term": { "options.color": "black" } },
            { "term": { "options.size": "M" } }
          ]
        }
      }
    }
  }
}
핵심 포인트
  • text: 풀텍스트 검색용, match 쿼리 사용
  • keyword: 정확한 값, 정렬/집계용, term 쿼리 사용
  • Multi-field: 하나의 필드를 text와 keyword로 동시 인덱싱 가능 (name.keyword)
  • Nested: 배열 내 객체 간 관계 유지가 필요할 때 사용 (Object는 평탄화됨)

Analyzer#

Analyzer는 텍스트를 검색 가능한 토큰으로 변환하는 과정입니다.

분석 과정#

flowchart LR
    A["입력 텍스트<br>The Quick Brown Fox"]
    --> B["Character Filter<br>(HTML 제거 등)"]
    --> C["Tokenizer<br>(단어 분리)"]
    --> D["Token Filter<br>(소문자 변환 등)"]
    --> E["토큰<br>#91;the, quick, brown, fox#93;"]

다이어그램: 입력 텍스트가 Character Filter, Tokenizer, Token Filter를 거쳐 최종 토큰으로 변환되는 과정입니다.

기본 Analyzer#

Analyzer동작예시 결과
standard단어 분리 + 소문자“Quick Brown” → [quick, brown]
simple문자만 추출 + 소문자“Quick-Brown” → [quick, brown]
whitespace공백 기준 분리“Quick Brown” → [Quick, Brown]
keyword분석 안 함“Quick Brown” → [Quick Brown]

Analyzer 테스트#

GET /_analyze
{
  "analyzer": "standard",
  "text": "The Quick Brown Fox"
}
{
  "tokens": [
    { "token": "the", "position": 0 },
    { "token": "quick", "position": 1 },
    { "token": "brown", "position": 2 },
    { "token": "fox", "position": 3 }
  ]
}

한글 Analyzer (Nori)#

한글은 띄어쓰기만으로 의미 단위 분리가 어렵습니다.

// Standard Analyzer
"삼성전자가 스마트폰을 출시했다"
 ["삼성전자가", "스마트폰을", "출시했다"]

// Nori Analyzer
"삼성전자가 스마트폰을 출시했다"
 ["삼성", "전자", "스마트폰", "출시"]

Nori 설정#

PUT /products
{
  "settings": {
    "analysis": {
      "analyzer": {
        "korean": {
          "type": "custom",
          "tokenizer": "nori_tokenizer",
          "filter": ["nori_part_of_speech"]
        }
      },
      "tokenizer": {
        "nori_tokenizer": {
          "type": "nori_tokenizer",
          "decompound_mode": "mixed"
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "korean"
      }
    }
  }
}

decompound_mode 옵션#

모드“삼성전자” 결과
none[삼성전자]
discard[삼성, 전자]
mixed[삼성전자, 삼성, 전자]

추천: mixed - 복합어와 분리된 단어 모두 검색 가능

Custom Analyzer#

PUT /products
{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "type": "custom",
          "char_filter": ["html_strip"],
          "tokenizer": "standard",
          "filter": ["lowercase", "my_synonym"]
        }
      },
      "filter": {
        "my_synonym": {
          "type": "synonym",
          "synonyms": [
            "노트북, 랩탑",
            "핸드폰, 스마트폰, 휴대폰"
          ]
        }
      }
    }
  }
}
핵심 포인트
  • Analyzer = Character Filter + Tokenizer + Token Filter
  • 한글은 Nori Analyzer 사용 권장 (decompound_mode: mixed)
  • /_analyze API로 분석 결과를 테스트할 수 있습니다
  • 동의어(Synonym) 처리는 Custom Analyzer로 설정

Dynamic Mapping#

Mapping을 정의하지 않으면 Elasticsearch가 자동으로 타입을 추론합니다.

자동 타입 추론#

JSON 값추론 타입
"hello"text + keyword
123long
12.34float
trueboolean
"2024-01-15"date
{ "a": 1 }object

Dynamic Mapping 제어#

PUT /products
{
  "mappings": {
    "dynamic": "strict",    // false: 무시, strict: 에러
    "properties": {
      "name": { "type": "text" }
    }
  }
}
설정동작
true새 필드 자동 추가 (기본값)
false새 필드 저장은 하지만 인덱싱 안 함
strict새 필드 발견 시 에러

프로덕션 권장: strict 또는 명시적 Mapping 정의

핵심 포인트
  • Dynamic Mapping은 개발 시 편리하지만, 프로덕션에서는 예기치 않은 타입 추론 위험
  • dynamic: strict로 설정하면 정의되지 않은 필드 입력 시 에러 발생
  • dynamic: false는 새 필드를 저장하지만 인덱싱하지 않음 (검색 불가)

모델링 패턴#

패턴 1: 비정규화#

Elasticsearch는 JOIN을 지원하지 않으므로, 관련 데이터를 한 문서에 포함합니다.

// RDB 정규화 (2개 테이블)
// products: id, name, category_id
// categories: id, name

// Elasticsearch 비정규화 (1개 문서)
{
  "name": "맥북 프로",
  "category": {
    "id": 1,
    "name": "노트북"
  }
}

장점: 빠른 검색, 단순한 쿼리 단점: 카테고리 변경 시 모든 문서 업데이트 필요

패턴 2: Application-Side Join#

자주 변경되는 데이터는 별도 인덱스로 관리:

// 1. 상품 검색
List<Product> products = productRepository.search(query);

// 2. 재고 정보 조회 (별도 인덱스)
List<String> productIds = products.stream().map(Product::getId).toList();
Map<String, Stock> stocks = stockRepository.findByIds(productIds);

// 3. 조합
products.forEach(p -> p.setStock(stocks.get(p.getId())));

패턴 3: Nested vs Parent-Child#

특성NestedParent-Child (Join)
성능빠름느림
업데이트전체 문서 재인덱싱자식만 업데이트
쿼리 복잡도낮음높음
권장 상황변경 적은 관계변경 잦은 1:N
핵심 포인트
  • Elasticsearch는 JOIN을 지원하지 않으므로 비정규화가 기본 전략
  • 자주 변경되는 데이터는 Application-Side Join 고려
  • Nested는 성능이 좋지만 전체 문서 재인덱싱 필요, Parent-Child는 개별 업데이트 가능

모범 사례#

1. 검색 필드는 text, 필터/집계 필드는 keyword#

{
  "name": {
    "type": "text",
    "fields": { "keyword": { "type": "keyword" } }
  },
  "status": { "type": "keyword" }
}

2. 숫자 ID는 keyword#

{
  "user_id": { "type": "keyword" }  // long 아님!
}

숫자 범위 검색이 없다면 keyword가 더 효율적입니다.

3. 불필요한 필드는 인덱싱 제외#

{
  "raw_data": {
    "type": "object",
    "enabled": false    // 저장만, 검색 불가
  }
}

4. 인덱스 템플릿 활용#

PUT /_index_template/logs
{
  "index_patterns": ["logs-*"],
  "template": {
    "mappings": {
      "properties": {
        "@timestamp": { "type": "date" },
        "message": { "type": "text" }
      }
    }
  }
}
핵심 포인트
  • 검색용 필드는 text + keyword Multi-field로 설정
  • 숫자 ID도 범위 검색이 없으면 keyword가 효율적
  • 검색하지 않는 필드는 enabled: false로 인덱싱 제외
  • 인덱스 템플릿으로 일관된 Mapping 적용

다음 단계#

목표추천 문서
검색 쿼리 작성Query DSL
검색 품질 개선검색 관련성
실습기본 예제