엔지니어링

Elastic에서 구현한 읽기 스키마인 런타임 필드 시작하기

기존에 Elasticsearch는 데이터 검색 속도를 높이기 위해 쓰기 스키마 접근 방식을 사용했습니다. 하지만 이제 Elasticsearch에 읽기 스키마 기능이 추가되어 수집 후에도 사용자가 문서의 스키마를 유연하게 변경하고 검색 쿼리의 일부로만 존재하는 필드를 생성할 수도 있습니다. 읽기 스키마와 쓰기 스키마가 함께 제공되므로 사용자는 필요에 따라 성능과 유연성 사이에서 균형을 유지할 수 있습니다.

Elasticsearch의 읽기 스키마 솔루션은 쿼리 시에만 평가되는 런타임 필드입니다. 이는 인덱스 매핑 또는 쿼리에서 정의되며, 정의된 후에는 검색 요청, 집계, 필터링 및 정렬에 즉시 사용할 수 있습니다. 런타임 필드는 색인되지 않으므로 런타임 필드 추가로 인덱스 크기가 증가하지는 않습니다. 사실상, 스토리지 비용을 절감하고 수집 속도를 높일 수 있습니다.

그러나 단점도 있습니다. 런타임 필드에 대한 쿼리는 비용이 많이 들 수 있으므로 흔히 검색하거나 필터링하는 데이터는 여전히 색인된 필드에 매핑해야 합니다. 런타임 필드는 인덱스 크기가 더 작은 경우에도 검색 속도를 저하시킬 수 있습니다. 런타임 필드를 인덱스 필드와 함께 사용하여 사용 사례에 따라 수집 속도, 인덱스 크기, 유연성 및 검색 성능 간에 적절한 균형을 찾으시는 것이 좋습니다.

간편하게 런타임 필드 추가

런타임 필드를 정의하는 가장 쉬운 방법은 쿼리입니다. 예를 들어 다음과 같은 인덱스가 있고,

 PUT my_index
 {
   "mappings": {
     "properties": {
       "address": {
         "type": "ip"},
       "port": {
         "type": "long"
       }
     }
   } 
 }

여기에 문서 몇 개를 로드하는 경우,

 POST my_index/_bulk
 {"index":{"_id":"1"}}
 {"address":"1.2.3.4","port":"80"}
 {"index":{"_id":"2"}}
 {"address":"1.2.3.4","port":"8080"}
 {"index":{"_id":"3"}}
 {"address":"2.4.8.16","port":"80"}

다음과 같이 정적 스트링으로 두 필드를 연결할 수 있습니다.

 GET my_index/_search
 {
   "runtime_mappings": {
     "socket": {
       "type": "keyword",
       "script": {
         "source": "emit(doc['address'].value + ':' + doc['port'].value)"
       }
     }
   },
   "fields": [
     "socket"
   ],
   "query": {
     "match": {
       "socket": "1.2.3.4:8080"
     }
   }
 }

그러면 다음과 같은 응답이 제공됩니다.

…
     "hits" : [
       {
         "_index" : "my_index",
         "_type" : "_doc",
         "_id" : "2",
         "_score" : 1.0,
         "_source" : {
           "address" : "1.2.3.4",
           "port" : "8080"
         },
         "fields" : {
           "socket" : [
             "1.2.3.4:8080"
           ]
         }
       }
     ]

runtime_mappings 섹션에 필드 소켓을 정의했습니다. 문서별로 소켓 값을 계산하는 방법을 정의하는 짧은 Painless 스크립트를 사용했습니다(+를 사용하여 주소 필드 값과 정적 스트링 ‘:’ 및 포트 필드 값의 연결을 나타냄). 그런 다음 쿼리에 필드 소켓을 사용했습니다. 필드 소켓은 이 쿼리에 대해서만 존재하며 쿼리가 실행될 때 계산되는 임시 런타임 필드입니다. 런타임 필드에 사용할 Painless 스크립트를 정의할 때는 계산된 값을 반환하도록 emit을 포함해야 합니다.

이 소켓 필드를 쿼리마다 정의하지 않고도 여러 쿼리에서 사용할 수 있게 하려면, 다음과 같이 호출하여 간단하게 매핑에 추가하면 됩니다.

 PUT my_index/_mapping
 {
   "runtime": {
     "socket": {
       "type": "keyword",
       "script": {
         "source": "emit(doc['address'].value + ':' + doc['port'].value)"
       }
     } 
   } 
 }

그러면 쿼리에 필드 정의를 포함하지 않아도 됩니다. 예를 들면 다음과 같습니다.

 GET my_index/_search
 {
   "fields": [
     "socket"
  ],
   "query": {
     "match": {
       "socket": "1.2.3.4:8080"
     }
   }
 }

“fields”: [”socket”] 문은 소켓 필드의 값을 표시하려는 경우에만 필요합니다. 이제 필드 소켓을 어느 쿼리에서나 사용할 수 있지만, 인덱스에는 존재하지 않으므로 인덱스 크기가 증가하지 않습니다. 소켓은 쿼리에 필요한 경우에 필요한 문서에 대해서만 계산됩니다.

다른 필드와 마찬가지로 소비됨

런타임 필드는 색인된 필드와 동일한 API를 통해 노출되므로, 쿼리는 필드가 런타임 필드인 일부 인덱스와 필드가 색인된 필드인 다른 인덱스를 참조할 수 있습니다. 색인할 필드와 런타임 필드로 유지할 필드를 유연하게 선택할 수 있습니다. 필드 생성과 필드 소비를 이렇게 분리하면 좀 더 쉽게 생성하고 유지 관리할 수 있는 체계적인 코드를 만들 수 있습니다.

런타임 필드는 인덱스 매핑 또는 검색 요청에 정의합니다. 이러한 고유한 기능을 통해 런타임 필드를 색인된 필드와 함께 유연한 방식으로 사용할 수 있습니다. 

쿼리 시 필드 값 재정의

프로덕션 데이터에 실수가 있음을 너무 늦게 깨닫는 경우가 종종 있습니다.  향후에 수집할 문서의 수집 지침을 수정하기는 쉽지만, 이미 수집되어 색인된 데이터를 수정하기는 훨씬 어렵습니다. 런타임 필드를 사용하면 쿼리 시점에 값을 재정의하여 색인된 데이터의 오류를 수정할 수 있습니다. 런타임 필드는 색인된 데이터의 오류를 수정할 수 있도록 동일한 이름의 색인된 필드를 섀도잉할 수 있습니다.  

다음은 이를 좀 더 구체적으로 보여주는 간단한 예입니다. 메시지 필드와 주소 필드가 있는 인덱스가 있다고 가정해 보겠습니다.

 PUT my_raw_index 
{
  "mappings": {
    "properties": {
      "raw_message": {
        "type": "keyword"
      },
      "address": {
        "type": "ip"
      }
    }
  }
}

여기에 문서를 로드해 보겠습니다.

 POST my_raw_index/_doc/1
{
  "raw_message": "199.72.81.55 - - [01/Jul/1995:00:00:01 -0400] GET /history/apollo/ HTTP/1.0 200 6245",
  "address": "1.2.3.4"
}

이런, 문서의 주소 필드에 잘못된 IP 주소가 포함되어 있네요. 메시지에는 올바른 IP 주소가 있지만, Elasticsearch로 수집하여 색인하도록 전송된 문서에는 잘못된 주소가 구문 분석되어 있습니다. 문서 하나일 경우에는 문제가 되지 않습니다. 그러나 한 달 후에 문서의 10%에 잘못된 주소가 포함되어 있음을 알게 되면 어떻게 해야 할까요? 새로운 문서에서 주소를 수정하는 것은 별문제가 아니지만, 이미 수집된 문서를 다시 색인하는 작업은 운영상 복잡할 때가 많습니다. 런타임 필드를 사용하면 색인된 필드를 런타임 필드로 섀도잉하여 이를 즉시 수정할 수 있습니다. 다음은 쿼리에서 이를 수행하는 방법입니다.

GET my_raw_index/_search
{
  "runtime_mappings": {
    "address": {
      "type": "ip",
      "script": "Matcher m = /\\d+\\.\\d+\\.\\d+\\.\\d+/.matcher(doc[\"raw_message\"].value);if (m.find()) emit(m.group());"
    }
  },
  "fields": [ 
    "address"
  ]
}

또한 매핑에서 변경하여 모든 쿼리에서 사용하도록 할 수도 있습니다. 이제 정규식 사용은 Painless 스크립트를 통해 기본적으로 지원됩니다.

성능과 유연성 간의 균형 유지

색인된 필드의 경우, 수집 중에 모든 준비 작업을 수행하고 정교한 데이터 구조를 유지하여 최적의 성능을 제공합니다. 그러나 런타임 필드를 쿼리하는 것은 색인된 필드를 쿼리하는 것보다 느립니다. 런타임 필드를 사용하기 시작한 후 쿼리가 느려지면 어떻게 할까요?

런타임 필드를 검색할 때 비동기식 검색을 사용하는 것이 좋습니다. 쿼리가 지정된 시간 임계값 내에 완료된다면 전체 결과 세트는 동기식 검색과 마찬가지로 반환됩니다. 쿼리가 해당 시간 내에 완료되지 않더라도 부분적인 결과 세트를 받을 수 있으며 Elasticsearch는 전체 결과 세트가 반환될 때까지 폴링을 계속합니다. 이 메커니즘은 인덱스 수명 주기를 관리할 때 특히 유용합니다. 일반적으로 새로운 결과가 먼저 반환되며 사용자에게는 대개 새로운 결과가 더 중요하기 때문입니다.

최적의 성능을 제공하기 위해 쿼리에서 힘들고 복잡한 작업은 색인된 쿼리를 사용하여 수행하므로 런타임 필드의 값은 문서의 하위 세트에 대해서만 계산됩니다.

런타임 필드에서 색인된 필드로 변경

런타임 필드를 사용하면 사용자가 라이브 환경에서 데이터 작업을 하면서 동시에 매핑 및 구문 분석을 유연하게 변경할 수 있습니다. 런타임 필드는 리소스를 소비하지 않으며 이를 정의하는 스크립트는 변경이 가능하므로, 사용자는 최적의 매핑에 도달할 때까지 실험할 수 있습니다. 런타임 필드가 장기적으로 유용한 것으로 확인되면 인덱스 시점에 런타임 필드의 값을 미리 계산할 수 있습니다. 템플릿에 해당 필드를 색인된 필드로 정의하고 수집된 문서에 포함되도록 하기만 하면 됩니다. 이 필드는 다음 인덱스 롤오버부터 색인되어 더 나은 성능을 제공하게 됩니다. 이 필드를 사용하는 쿼리는 변경할 필요가 없습니다. 

이 시나리오는 동적 매핑에서 특히 유용합니다. 한편으로는 새로운 문서가 새로운 필드를 생성하도록 하는 것이 매우 도움이 됩니다. 그러면 해당 문서의 데이터를 즉시 사용할 수 있기 때문입니다(로그를 생성하는 소프트웨어의 변경 사항 등으로 인해 항목 구조가 자주 변경됨). 반면에 동적 매핑은 인덱스에 부담을 주고 매핑 폭주를 유발할 수도 있습니다. 예를 들어 어떤 문서에는 2천 개나 되는 새로운 필드가 포함되어 있을 수도 있기 때문입니다. 런타임 필드는 이러한 사례에 대한 해결책을 제공할 수 있습니다. 런타임 필드는 인덱스에 존재하지 않습니다. 따라서 새로운 필드를 자동으로 런타임 필드로 생성하면 인덱스에 부담을 주지 않으며, 새로운 필드는 index.mapping.total_fields.limit에 포함되지 않습니다. 이렇게 자동으로 생성된 런타임 필드는 성능은 저하되더라도 쿼리가 가능합니다. 따라서 사용자는 이 런타임 필드를 사용할 수 있고, 필요한 경우 다음 롤오버에서 색인된 필드로 변경할 수 있습니다.   

처음에는 런타임 필드를 사용하여 데이터 구조를 실험하는 것이 좋습니다. 데이터를 작업해 본 후 검색 성능 향상을 위해 런타임 필드를 색인하기로 결정할 수 있습니다. 새로운 인덱스를 생성한 다음 인덱스 매핑에 필드 정의를 추가하고, 필드를 _source에 추가하고, 새로운 필드가 수집된 문서에 포함되는지 확인할 수 있습니다. 데이터 스트림을 사용하고 있는 경우, 인덱스 템플릿을 업데이트하면 해당 템플릿에서 인덱스가 생성될 때 Elasticsearch가 이를 알고 해당 필드를 색인합니다. 향후 릴리즈에서는 매핑의 런타임 필드에서 속성 필드로 필드를 이동하는 것만큼이나 간단하게 런타임 필드에서 색인된 필드로 변경할 수 있게 만들 계획입니다. 

다음 요청은 타임스탬프 필드가 있는 간단한 인덱스 매핑을 생성합니다. "dynamic": "runtime" 명령은 이 인덱스에 런타임 필드로 추가 필드를 동적으로 생성하도록 Elasticsearch에 지시합니다. 런타임 필드에 Painless 스크립트가 포함된 경우, 이 필드의 값은 Painless 스크립트에 따라 계산됩니다. 다음 요청과 같이 런타임 필드가 스크립트 없이 생성된 경우, 시스템은 _source에서 런타임 필드와 이름이 동일한 필드를 찾아 그 값을 런타임 필드의 값으로 사용합니다.

PUT my_index-1
{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "timestamp": {
        "type": "date",
        "format": "yyyy-MM-dd"
      }
    }
  }
}

문서를 색인하여 이러한 설정의 이점을 직접 알아보겠습니다.

POST my_index-1/_doc/1
{
  "timestamp": "2021-01-01",
  "message": "my message",
  "voltage": "12"
}

하나의 색인된 타임스탬프 필드와 두 개의 런타임 필드(메시지와 전압)가 있으므로 인덱스 매핑을 확인할 수 있습니다.

GET my_index-1/_mapping

런타임 섹션에는 메시지와 전압이 포함되어 있습니다. 이러한 필드는 색인되지 않았지만, 정확히 색인된 필드처럼 쿼리할 수 있습니다.

{
  "my_index-1" : {
    "mappings" : {
      "dynamic" : "runtime",
      "runtime" : {
        "message" : {
          "type" : "keyword"
        },
        "voltage" : {
          "type" : "keyword"
        }
      },
      "properties" : {
        "timestamp" : {
          "type" : "date",
          "format" : "yyyy-MM-dd"
        }
      }
    }
  }
}

메시지 필드를 쿼리하는 간단한 검색 요청을 생성하겠습니다.

GET my_index-1/_search
{
  "query": {
    "match": {
      "message": "my message"
    }
  }
}

응답에는 다음과 같은 결과가 포함됩니다.

... 
"hits" : [
      {
        "_index" : "my_index-1", 
        "_type" : "_doc", 
        "_id" : "1", 
        "_score" : 1.0, 
        "_source" : { 
          "timestamp" : "2021-01-01", 
          "message" : "my message", 
          "voltage" : "12" 
        } 
      } 
    ]
…

이 응답을 보면 한 가지 문제가 있습니다. 전압이 숫자임을 지정하지 않았습니다. 전압은 런타임 필드이므로 쉽게 수정할 수 있습니다. 매핑의 런타임 섹션에 있는 필드 정의를 업데이트하면 됩니다.

PUT my_index-1/_mapping
{
  "runtime":{
    "voltage":{
      "type": "long"
    }
  }
}

앞의 요청은 전압을 long 유형으로 변경합니다. 그러면 이미 색인된 문서에 즉시 적용됩니다. 이 동작을 테스트하기 위해 전압이 11~13인 모든 문서에 대한 간단한 쿼리를 구성합니다.

GET my_index-1/_search
{
  "query": {
    "range": {
      "voltage": {
        "gt": 11,
        "lt": 13
      }
    }
  }
}

전압은 12였으므로 이 쿼리는 my_index-1에 있는 문서를 반환합니다. 매핑을 다시 살펴보면, 매핑에서 필드 유형을 업데이트하기 전에 Elasticsearch로 수집된 문서에서도 이제 전압이 long 유형의 런타임 필드인 것을 알 수 있습니다.

...
{
  "my_index-1" : {
    "mappings" : {
      "dynamic" : "runtime",
      "runtime" : {
        "message" : {
          "type" : "keyword"
        },
        "voltage" : {
          "type" : "long"
        }
      },
      "properties" : {
        "timestamp" : {
          "type" : "date",
          "format" : "yyyy-MM-dd"
        }
      }
    }
  }
}
…

나중에 전압이 집계에 유용하다고 판단하면 데이터 스트림에 생성되는 다음 인덱스로 이를 색인할 수 있습니다. 데이터 스트림의 인덱스 템플릿과 일치하는 새로운 인덱스(my_index-2)를 생성하고 전압을 정수로 정의하여 런타임 필드를 실험한 후 원하는 데이터 유형을 파악합니다.

인덱스 템플릿 자체를 업데이트하여 변경 사항이 다음 롤오버에 적용되도록 하는 것이 가장 좋습니다. 필드가 한 인덱스의 런타임 필드이고 다른 인덱스의 색인된 필드인 경우에도 my_index* 패턴과 일치하는 인덱스의 전압 필드에 대해 쿼리를 수행할 수 있습니다.

PUT my_index-2
{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "timestamp": {
        "type": "date",
        "format": "yyyy-MM-dd"
      },
      "voltage":
      {
        "type": "integer"
      }
    }
  }
}

따라서 런타임 필드에 새로운 필드 수명 주기 워크플로우를 도입했습니다. 이 워크플로우에서는 필드가 리소스 사용량에 영향이 미치지 않고 매핑 폭주의 위험 없이 런타임 필드로 자동 생성될 수 있으므로, 사용자가 즉시 데이터를 사용할 수 있습니다. 필드의 매핑은 여전히 런타임 필드인 상태에서 실제 데이터에 대해 개선할 수 있으며, 런타임 필드의 유연성 덕분에 변경 사항은 이미 Elasticsearch에 수집된 문서에 즉시 적용됩니다. 이 필드가 유용한 것이 분명한 경우, 다음 롤오버부터는 생성되는 인덱스에서 필드가 색인되어 최적의 성능을 내도록 템플릿을 변경할 수 있습니다.

요약

대부분의 사례에서, 그리고 특히 데이터와 데이터로 수행하려는 작업을 아는 경우, 색인된 필드를 사용하면 성능 이점을 누릴 수 있습니다. 반면에 문서 구문 분석과 스키마 구조에 유연성이 필요한 경우, 이제 런타임 필드가 해답을 제공합니다.

런타임 필드 및 색인된 필드는 서로 보완되는 기능으로 공생 관계를 형성합니다. 런타임 필드는 유연성을 제공하지만, 인덱스의 지원 없이는 대규모 환경에서 성능을 발휘할 수 없습니다. 인덱스의 강력하고 단단한 구조는 런타임 필드의 유연성이 진정한 효과를 발휘할 수 있는 보호 환경을 제공합니다. 해조류가 산호초에서 은신처를 찾는 것과 크게 다르지 않습니다. 모두가 이 공생 관계를 활용할 수 있습니다.

지금 시작하세요

런타임 필드를 시작하려면 Elasticsearch Service의 클러스터를 사용하시거나 Elastic Stack의 최신 버전을 설치하세요. 이미 Elasticsearch를 실행 중이신가요? 그렇다면 클러스터를 7.11로 업그레이드하신 후 다시 사용해 보세요. 런타임 필드 및 관련 이점에 대한 개괄적인 설명이 필요하시다면 Runtime fields: Schema on read for Elastic 블로그 게시물을 참조해 주세요. 또한 런타임 필드를 사용하시는 데 도움이 되도록 다음과 같은 4개의 동영상을 준비했습니다.