AWS 기술 블로그

Amazon OpenSearch Service Custom Plugin 적용하기

OpenSearch는 검색, 분석, 보안 모니터링, 통합 가시성 애플리케이션을 위한 확장 가능하고 유연한 오픈 소스 소프트웨어 제품군으로, Apache 2.0 라이선스를 따릅니다. Amazon OpenSearch Service(AOS)는 AWS 클라우드에서 OpenSearch를 간편하게 배포, 확장, 운영할 수 있으며 Amazon OpenSearch Ingestion, Integration 기능을 통해 다양한 AWS 서비스와 연동이 가능한 완전 관리형 서비스 입니다.

검색 서비스에서는 다양한 필터가 필요합니다. 특히, 상품 검색시 프리미엄 광고로 등록된 상품은 상위 노출을 하기 위한 다양한 조건과 가중치 조정이 필요합니다. 이러한 요구사항을 해결하기 위해 사용자 지정 플러그인을 별도로 개발하여 적용할 수 있습니다. 2024년 11월 21일 Amazon OpenSearch Service에서도 사용자 지정 플러그인을 제한적으로 적용할 수 있게 되었습니다. 이제 검색과 분석 영역에서 비즈니스 요구사항에 맞는 다양한 분석 필터, 가중치 조정, 집계 등의 기능을 추가할 수 있습니다.

이번 게시글에서는 Amazon OpenSearch Service에서 사용자 지정 플러그인을 적용하기 위한 방법을 소개하며, 사용시 제약사항에 대해 알아 보고자 합니다.

사용자 지정 플러그인 제약 사항

OpenSearch는 다양한 Plugin을 제공하고 있습니다. 이중 AOS의 사용자 지정 플러그인은 AnalysisPluginSearchPlugin 이 두가지만 지원합니다. 이 두 플러그인은 아래와 같은 기능을 제공합니다.

  • AnalysisPlugin : 텍스트 처리를 위한 사용자 지정 분석기, 캐릭터 토큰화기 또는 필터를 추가하여 분석 기능을 확장합니다.
  • SearchPlugin : 사용자 지정 쿼리 유형, 유사성 알고리즘, 제안 옵션 및 집계를 사용하여 검색 기능을 개선합니다.

사용자 지정 플러그인을 적용하면 AOS의 일부 기능을 사용할 수 없습니다. 자세한 내용은 공식 도큐먼트를 확인하세요.

사용자 지정 플러그인 개발 사전 준비사항

  • OpenSearch Version <= 2.15
  • JDK 21

사용자 지정 플러그인은 AOS Version 2.15 이상에서 사용이 가능합니다. 따라서 JDK버전을 OpenSearch가 지원하는 버전에 맞게 지정해야 합니다. OpenSearch Version별 지원 JDK는 OpenSearch 공식 도큐먼트에서 확인이 가능합니다. AOS의 사용자 지정 플러그인은 JDK 21 Version과 호환되기 때문에 개발 환경은 Amazon Corretto 21을 사용하였습니다.

사용자 지정 플러그인은 Gradle로 빌드하면 opensearch.opensearchplugin을 사용하여 패키징 됩니다. 개발 환경 구성과 관련된 자세한 정보는 opensearch-project/opensearch-plugins Github 레포지토리 에서 확인할 수 있습니다.

빌드 환경 구성

사용자 지정 플러그인은 ZIP-PLUGIN 형태로 등록됩니다. 따라서 빌드된 결과물은 zip으로 압축되어야 하며, 아래의 파일이 포함되어야 합니다.

  • LICENSE.txt
  • NOTICE.txt
  • jar file
  • plugin-descriptor.properties

plugin-descriptor.properties 파일은 OpenSearch Gradle 빌드 시스템을 통해 자동으로 생성되는 JAVA 속성 파일입니다. 이번 게시글에서 사용할 Github 예제 코드를 빌드하면 아래와 같은 파일이 생성됩니다.

# SPDX-License-Identifier: Apache-2.0
#
# The OpenSearch Contributors require contributions made to
# this file be licensed under the Apache-2.0 license or a
# compatible open source license.
#
# Modifications Copyright OpenSearch Contributors. See
# GitHub history for details.
#

# OpenSearch plugin descriptor file
# This file must exist as 'plugin-descriptor.properties' inside a plugin.
#
### example plugin for "foo"
#
# foo.zip <-- zip file for the plugin, with this structure:
# |____   <arbitrary name1>.jar <-- classes, resources, dependencies
# |____   <arbitrary nameN>.jar <-- any number of jars
# |____   plugin-descriptor.properties <-- example contents below:
#
# classname=foo.bar.BazPlugin
# description=My cool plugin
# version=6.0
# opensearch.version=6.0
# java.version=1.8
#
### mandatory elements for all plugins:
#
# 'description': simple summary of the plugin
description=Custom OpenSearch custom plugin for educational purposes
#
# 'version': plugin's version
version=1.0.0
#
# 'name': the plugin name
name=opensearch-custom-plugin
#
# 'classname': the name of the class to load, fully-qualified
classname=org.opensearch.plugin.CustomPlugin
#
# 'java.version': version of java the code is built against
# use the system property java.specification.version
# version string must be a sequence of nonnegative decimal integers
# separated by "."'s and may have leading zeros
java.version=21
#
# 'opensearch.version': semantic version of opensearch the plugin is compatible with
# does not include -SNAPSHOT if compiled against a snapshot build
opensearch.version=2.17.0
#
### optional elements for plugins:
#
# 'custom.foldername': the custom name of the folder in which the plugin is installed
custom.foldername=
#
# 'extended.plugins': other plugins this plugin extends through SPI
extended.plugins=
#
# 'has.native.controller': whether or not the plugin has a native controller
has.native.controller=false
Bash

이 설명자 파일의 version, name, java.version등의 속성값이 정상적으로 입력되지 않으면 플러그인 등록이 실패합니다. 이점을 유의해야 합니다. 각 속성의 값은 build.gradle에 설정된 정보에 따라 변경됩니다. build 스크립트의 자세한 내용은 예제 코드를 확인하세요.

opensearchplugin {
    name 'opensearch-custom-plugin'
    description 'Custom OpenSearch custom plugin for educational purposes'
    classname 'org.opensearch.plugin.CustomPlugin'
    licenseFile rootProject.file('LICENSE.txt')
    noticeFile rootProject.file('NOTICE.txt')
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
    targetCompatibility = JavaVersion.VERSION_21
    sourceCompatibility = JavaVersion.VERSION_21
}

version = '1.0.0'
Bash

빌드

사용자 정의 플러그인은 Gradle을 통해 빌드됩니다. Github의 예제 코드를 Clone하고 아래의 명령어를 실행하세요.

./gradlew clean build
Bash

빌드가 정상적으로 수행되면 build/distributions/opensearch-custom-plugin-1.0.0.zip 파일이 생성됩니다. 해당 zip 파일을 압축 해제하면 아래와 같은 파일 목록이 보입니다.

.
├── LICENSE.txt
├── NOTICE.txt
├── opensearch-custom-plugin-1.0.0.jar
└── plugin-descriptor.properties
Bash

사용자 정의 플러그인 등록

AOS에서 사용자 지정 플러그인을 사용하기 위해서는 클러스터에서 아래의 설정이 선행 되어야 합니다.

사전 조건에 대한 보다 상세한 정보를 확인하고 싶으시면 공식 도큐먼트를 참조하세요.

사용자 정의 플러그인 설치

사용자 정의 플러그인은 Amazon S3를 통해 OpenSearch Packages로 등록됩니다. 우선 .zip 파일을 S3에 업로드합니다. S3_BUCKET_NAME은 각자의 환경에 맞게 적절하게 변경해주세요.

aws s3 cp opensearch-custom-plugin-1.0.0.zip s3://S3_BUCKET_NAME/ --acl bucket-owner-full-control --region us-east-1
Bash

객체 업로드가 완료되면 create-package를 통해 새로운 패키지를 생성합니다. 새 패키지를 생성할때 이전에 업로드한 Amazon S3의 .zip 파일을 가르키도록 버킷과 키 위치를 지정해 주어야 합니다. Amazon S3는 OpenSearch 클러스터와 동일한 리전에 있어야 하며 ZIP-PLUGIN만 지원하고 있습니다. 아래의 명령어를 수행하여 새로운 패키지를 생성합니다.

aws opensearch --region us-east-1 create-package --package-name opensearch-custom-plugin --package-type ZIP-PLUGIN --package-source S3BucketName=<bucket>,S3Key=<key> --engine-version OpenSearch_2.17
Bash

패키지 등록 상태는 아래의 명령어를 통해 확인이 가능합니다. 등록에 실패하는 경우, Console에서 상세한 정보를 확인하지 못할 수 있습니다. 등록에 실패했다면 아래의 명령어 결과로 출력되는 ErrorDetails 통해 확인할 수 있습니다.

aws opensearch --region us-east-1 describe-packages --filters '[{"Name": "PackageType","Value": ["ZIP-PLUGIN"]}, {"Name": "PackageName","Value": ["opensearch-custom-plugin"]}]'
{
    "PackageDetailsList": [
        {
            "PackageID": "pkg-************************",
            "PackageName": "opensearch-custom-plugin",
            "PackageType": "ZIP-PLUGIN",
            "PackageDescription": "custom plugin test",
            "PackageStatus": "AVAILABLE",
            "CreatedAt": "2025-01-08T14:28:15.084000+09:00",
            "LastUpdatedAt": "2025-01-08T14:28:15.084000+09:00",
            "AvailablePackageVersion": "v1",
            "EngineVersion": "OpenSearch_2.17",
            "AvailablePluginProperties": {
                "Name": "opensearch-custom-plugin",
                "Description": "Custom OpenSearch custom plugin for educational purposes",
                "Version": "1.0.0",
                "ClassName": "org.opensearch.plugin.CustomPlugin",
                "UncompressedSizeInBytes": 33613
            },
            "AllowListedUserList": []
        }
    ]
}
Bash

패키지 등록이 완료되면 OpenSearch Domain과 연결해 주어야 합니다. assoicate-package를 통해 OpenSearch Domain과 연결합니다. OpenSearch Domain생성과 관련된 내용은 공식 도큐먼트를 확인해주세요. 이 게시글에서는 미리 생성된 OpenSearch Domain을 사용합니다. 아래의 명령어를 통해 OpenSearch Domain과 패키지를 연결합니다. —domain-name—package-id는 각자의 환경에 맞게 변경해주세요.

aws opensearch --region us-east-1  associate-package --domain-name DOMAIN_NAME --package-id pkg-************************
Bash

패키지 연결 상태는 list-packages-for-domain 명령어를 통해 확인이 가능합니다.

aws opensearch --region us-east-1 list-packages-for-domain --domain-name DOMAIN_NAME
{
    "DomainPackageDetailsList": [
        {
            "PackageID": "pkg-************************",
            "PackageName": "opensearch-custom-plugin",
            "PackageType": "ZIP-PLUGIN",
            "LastUpdated": "2025-01-08T14:43:16.472000+09:00",
            "DomainName": "custom-plugin-test",
            "DomainPackageStatus": "ASSOCIATING",
            "PackageVersion": "v1"
        }
    ]
}
Bash

플러그인 등록 확인

사용자 지정 플러그인 패키지가 OpenSearch에 정상적으로 등록되었는지 확인하려면 Amazon OpenSearch Service Console 화면에서 Package를 연결한 Domain을 클릭하여 하단에 Packages 탭을 확인합니다. Active 상태가 되면, 등록된 플러그인을 사용할 수 있습니다.

이 게시글에서 사용된 예제에는 아래의 분석 플러그인이 정의되어 있습니다.

public class CustomPlugin extends Plugin implements AnalysisPlugin {

    @Override
    public Map<String, AnalysisProvider<TokenFilterFactory>> getTokenFilters() {
        Map<String, AnalysisProvider<TokenFilterFactory>> extra = new HashMap<>();
        extra.put("custom_chosung", ChosungFilterFactory::new);
        extra.put("custom_jamo", JamoDecomposeFilterFactory::new);
        extra.put("custom_engtohan", EngToHanFilterFactory::new);
        extra.put("custom_hantoeng", HanToEngFilterFactory::new);

        return extra;
    }
}
Java

각 토큰 필터는 아래의 역할을 수행합니다.

  1. custom_chosung(초성 필터)
    1. 한글 토큰 문자열의 초성을 추출합니다.
  2. custom_jamo(자모 분리 필터)
    1. 한글 토큰 문자열을 자음/모음 단위로 분리합니다.
  3. custom_engtohan(영한 변환 필터)
    1. 영어 토큰 문자열을 키보드 배열에 맞는 한글 문자열로 변환합니다.
  4. custom_hantoeng(한영 변환 필터)
    1. 한글 토큰 문자열을 자모 단위로 분해 후 키보드 배열에 맞는 영어 문자열로 변환합니다.

플러그인 테스트

1. 자모 분리 테스트

우선 만들어진 custom_jamo 필터를 사용하도록 분석기를 정의한 인덱스를 생성합니다.

PUT /spell_test
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0,
    "index.max_ngram_diff": 10,
    "analysis": {
      "analyzer": {
        "jamo_analyzer": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "custom_jamo"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "keyword",
        "copy_to": ["name_jamo"]
      },
      "name_jamo": {
        "type": "text",
        "analyzer": "jamo_analyzer"
      }
    }
  }
}
Bash

생성된 인덱스에 더미 데이터를 색인합니다.

POST /_bulk
{ "index" : { "_index" : "spell_test", "_id" : "1" } }
{ "name" : "아마존" }
{ "index" : { "_index" : "spell_test", "_id" : "2" } }
{ "name" : "오픈서치" }
{ "index" : { "_index" : "spell_test", "_id" : "3" } }
{ "name" : "서비스" }
Bash

색인 후 검색을 수행하면, 아래와 같이 자모 분리된 데이터가 검색됩니다.

POST /spell_test/_search
{
  "suggest": {
    "name_suggest": {
      "text": "아마존",
      "term": {
        "field": "name_jamo",
        "max_edits": 2
      }
    }
  }
}
Bash

[검색 결과]

{
  "took": 897,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 0,
      "relation": "eq"
    },
    "max_score": null,
    "hits": []
  },
  "suggest": {
    "name_suggest": [
      {
        "text": "ㅇㅏㅁㅏㅈㅗㄴ",
        "offset": 0,
        "length": 3,
        "options": []
      }
    ]
  }
}
Bash

2. 한/영 변환 테스트

우선 만들어진 custom_engtohan 필터와 custom_hantoeng 필터를 사용하도록 분석기를 정의한 인덱스를 생성합니다.

PUT /haneng_test
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0,
    "index.max_ngram_diff": 10,
    "analysis": {
      "analyzer": {
        "engtohan_analyzer": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "custom_engtohan"
          ]
        },
        "hantoeng_analyzer": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "custom_hantoeng"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "keyword",
        "copy_to": ["name_hantoeng", "name_engtohan"]
      },
      "name_hantoeng": {
        "type": "text",
        "search_analyzer": "hantoeng_analyzer"
      },
      "name_engtohan": {
        "type": "text",
        "search_analyzer": "engtohan_analyzer"
      }
    }
  }
}
Bash

생성된 인덱스에 더미 데이터를 색인합니다.

POST /_bulk
{ "index" : { "_index" : "haneng_test", "_id" : "1" } }
{ "name" : "아마존" }
{ "index" : { "_index" : "haneng_test", "_id" : "2" } }
{ "name" : "open" }
{ "index" : { "_index" : "haneng_test", "_id" : "3" } }
{ "name" : "서비스" }
Bash

색인 후 검색을 수행하면, 아래와 같이 자모 분리된 데이터가 검색됩니다.

//hantoeng Test
POST /haneng_test/_search
{
  "query": {
    "match": {
      "name_hantoeng": "ㅐㅔ두"
    }
  }
}

//engtohan Test
POST /haneng_test/_search
{
  "query": {
    "match": {
      "name_engtohan": "thsdhrhd"
    }
  }
}
Bash

[검색 결과]

//hantoeng Test Result
{
  "took" : 2,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.9808291,
    "hits" : [
      {
        "_index" : "haneng_test",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 0.9808291,
        "_source" : {
          "name" : "open"
        }
      }
    ]
  }
}

//engtohan Test Result
{
  "took" : 2,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.9808291,
    "hits" : [
      {
        "_index" : "haneng_test",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.9808291,
        "_source" : {
          "name" : "아마존"
        }
      }
    ]
  }
}
Bash

3. 초성 검색 테스트

우선 만들어진 custom_chosung 필터를 사용하도록 분석기를 정의한 인덱스를 생성합니다.

PUT /chosung_test
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0,
    "index.max_ngram_diff": 10,
    "analysis": {
      "analyzer": {
        "chosung_analyzer": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "custom_chosung"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "keyword",
        "copy_to": ["name_chosung"]
      },
      "name_chosung": {
        "type": "text",
        "analyzer": "chosung_analyzer"
      }
    }
  }
}
Bash

생성된 인덱스에 더미 데이터를 색인합니다.

POST /_bulk
{ "index" : { "_index" : "chosung_test", "_id" : "2" } }
{ "name" : "오픈서치" }
{ "index" : { "_index" : "chosung_test", "_id" : "3" } }
{ "name" : "아메리카노" }
Bash

색인후 검색을 수행하면 아래와 같이 초성을 통해 데이터가 검색 됩니다.

POST /chosung_test/_search
{
  "query": {
    "match": {
      "name_chosung": "ㅇㅍㅅㅊ"
    }
  }
}
Bash

[검색 결과]

{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.6931471,
    "hits" : [
      {
        "_index" : "chosung_test",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 0.6931471,
        "_source" : {
          "name" : "오픈서치"
        }
      }
    ]
  }
}
Bash

결론

Amazon OpenSearch Service는 관리형 검색 엔진으로 클러스터를 쉽고 빠르게 프로비저닝 할 수 있지만, 다양한 비즈니스 요구사항에 맞는 사용자 지정 플러그인을 지원하지 않았습니다. 이제 사용자 지정 플러그인을 지원하면서 보다 다채로운 검색 서비스 구현이 가능해 지면서 최종 사용자에게 보다 맞춤형 검색 서비스를 제공할 수 있게 되었으며 교정어, 제안어, 자동완성 등의 사용자 경험을 증대시킬 수 있게 되었습니다. 또한, 자체 플러그인으로 인하여 관리형 서비스를 고려하지 않았던 사용자들에게도 사용자 지정 플러그인 기능은 매우 매력적이라 생각됩니다.

이번 게시글에서 사용한 사용자 지정 플러그인 예제 코드는 aws-samples GitHub 레포지토리에 있습니다. 해당 예제를 통해 보다 쉽게 개발 환경을 구성하고 빠르게 플러그인을 개발할 수 있습니다.

개발에 도움되는 참고자료는 아래의 링크를 확인할 수 있습니다.

Sewoong Kim

Sewoong Kim

김세웅 Solutions Architect는 MFG SA팀의 일원으로서 컨테이너와 서버리스를 중심으로 AWS 기반 서비스를 구성하는 고객들에게 최적화된 아키텍처를 제공하고, GenAI Application Architect를 보다 고도화 하기 위한 여러 기술적인 도움을 드리고 있습니다.