들어가며
회사 프로젝트에서 검색 기능을 붙이면서 Amazon OpenSearch Serverless(이하 AOSS)를 쓰게 됐습니다. 그러려면 collection이라는 단위부터 match와 term의 차이, bool 쿼리 안의 must와 should를 언제 나눠 써야 하는지까지 — OpenSearch 자체의 사용법과 개념을 직접 알아야 했습니다. 그래서 이번 포스팅에서는 하나씩 부딪히면서 정리한 내용을 여기에 남겨보려합니다.
이 글에서는 AOSS가 뭔지, 내부적으로 어떻게 동작하는지, 그리고 실제로 Node.js(TypeScript)에서 어떻게 호출하는지까지 순서대로 다룹니다.
OpenSearch란?
OpenSearch는 오픈소스 검색·분석 엔진입니다. Apache Lucene이라는 검색 라이브러리를 기반으로 만들어졌고, 원래 Elasticsearch였던 프로젝트가 라이선스 문제로 갈라져 나오면서 2021년에 AWS 주도로 시작됐어요.
한마디로, 대량의 데이터를 넣어두고 빠르게 검색하거나 분석할 수 있게 해주는 도구입니다. 상품 검색, 로그 분석, 모니터링, 최근에는 벡터 기반 의미 검색까지 다양한 용도로 씁니다.
이 글에서 다루는 AOSS(Amazon OpenSearch Serverless)는 이 OpenSearch를 AWS가 서버리스 형태로 관리해주는 서비스예요. 그러니 AOSS를 이해하려면 먼저 OpenSearch 자체가 어떤 도구인지 알아야 합니다.
OpenSearch가 다루는 기본 단위: index와 document
쿼리 얘기로 들어가기 전에, 가장 기본이 되는 개념부터 짚고 가겠습니다.
document는 OpenSearch에 저장되는 데이터의 최소 단위예요. JSON 형태로 되어 있고, 우리가 흔히 아는 RDB(관계형 데이터베이스, Relational Database)의 row 한 줄과 비슷한 역할을 합니다. 예를 들어 상품 하나, 게시글 하나가 document 한 개가 되는 식이죠.
index는 이 document들을 모아놓은 묶음입니다. RDB로 치면 테이블에 가깝고요. index에는 mapping이라는 스키마가 붙는데, 여기서 각 필드가 어떤 타입으로 저장되고 색인될지를 정의합니다. 필드 타입에는 여러 가지가 있는데, 그중에서도 text로 둘지 keyword로 둘지가 검색 방식에 가장 직접적인 영향을 줍니다. 실제로 대부분의 mapping에서 가장 먼저 마주치고, 가장 헷갈려하는 지점이기도 해요. 이 차이가 뒤에서 다룰 match와 term 쉷리의 차이로 이어집니다.
자주 쓰는 필드 타입
앞에서 text와 keyword 얘기를 잠깐 했는데, 실제로 mapping에서 쓸 수 있는 필드 타입은 이 둘 말고도 여러 가지가 있습니다. 그중 실무에서 자주 마주칠만한 것들만 짚어보겠습니다.
- text: 색인될 때 analyzer를 거쳐 토큰 단위로 쪼개집니다. 전문 검색(match)에 씁니다. 기본적으로 정렬이나 집계(aggregation)에는 쓸 수 없습니다.
- keyword: analyzer를 거치지 않고 원문 그대로 저장됩니다. 정확값 매칭(term), 정렬, 집계, 필터링에 씁니다. 기본값으로 256자를 넘는 값은 색인에서 제외되는 ignore_above 설정이 걸려 있다는 점도 알아두면 좋습니다.
- 숫자 타입(long, integer, short, double, float 등): 범위 검색이나 정렬에 씁니다.
- date: 날짜/시간 값을 저장합니다. 기간 검색을 할 때 range 쿼리와 함께 씁니다.
- boolean: true/false 값을 저장합니다.
- object: JSON 객체를 중첩해서 저장하지만, 내부적으로는 평탄화(flatten)되어 저장됩니다. 배열 안의 여러 객체를 서로 독립적으로 다루고 싶다면 nested 타입을 따로 써야 합니다.
- knn_vector: 벡터 검색에 쓰는 임베딩 저장용 타입입니다. 뒤에서 k-NN을 다룰 때 자세히 보겠습니다.
같은 필드를 text로도 keyword로도 동시에 쓰고 싶을 때는 multi-field라는 방식을 씁니다. fields 파라미터로 하나의 필드에 여러 타입을 함께 매핑하는 거예요.
{
"mappings": {
"properties": {
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
}
}
이렇게 매핑하면 name 필드로는 match 검색을, name.keyword로는 term 검색이나 정렬을 할 수 있습니다. 실무에서 상품명이나 게시글 제목처럼 전문 검색도 하고 정확 매칭·정렬도 필요한 필드는 이 패턴을 자주 쓰게 됩니다.
여기서 다룬 타입이 전부는 아닙니다. 이 밖에도 geo_point·geo_shape(위치 정보), ip(IP 주소), range 계열(integer_range, date_range 등 범위 값), completion(자동완성), alias, binary 같은 특수 목적 타입들도 있어요. 다만 실무에서 마주치는 검색 로직 대부분은 위에서 다루 타입들만으로도 커버되는 경우가 많다고합니다.
document가 색인되면 OpenSearch는 몇 가지 메타 필드를 자동으로 관리합니다. document를 유일하게 식별하는 _id, 그리고 색인할 때 보낸 문서를 그대로 담고 있는 _source가 대표적이에요. 예를 들어 { "name": "노트북", "category": "전자기기" } 라는 문서를 색인했다면, 이 값 그대로가 _source에 저장됩니다. OpenSearch는 검색을 위해 내부적으로 이 값을 역색인이라는 별도 구조로 가공해서 저장하지만, 검색 결과를 돌려줄 때는 그 가공된 형태가 아니라 처음 넣었던 이 _source를 그대로 보여줍니다.
정리하면 index 안에 여러 document가 있고, index는 mapping이라는 스키마를 갖는다는 것입니다.
AOSS란 무엇인가
매니지드 vs 서버리스
기존 Amazon OpenSearch Service는 도메인(domain)이라는 단위로 클러스터를 직접 프로비저닝합니다. 노드 개수, 인스턴스 타입, 샤드 수를 사람이 정해줘야 하고요. AOSS는 이 과정을 없앤 서버리스 옵션입니다. 사용한 만큼만 비용을 내고, 트래픽에 따라 컴퓨트가 알아서 늘어나고 줄어듭니다.
💡 여기서 프로비저닝(provisioning)이란 서버, 스토리지, 네트워크 같은 인프라 자원을 실제로 쓸 수 있도록 할당하고 준비하는 작업을 말합니다. 도메인 기반 OpenSearch Service에서는 몇 대의 서버를 쓸지, 어떤 사양으로 쓸지를 사람이 미리 정해서 만들어둬야 하는데, 이 과정 자체를 프로비저닝이라고 부릅니다. AOSS는 바로 이 과정을 사용자가 직접 하지 않아도 되게 없애준 거예요.
collection이라는 단위
AOSS에서는 도메인 대신 collection이라는 단위를 씁니다. collection은 같은 보안 설정과 용량 설정을 공유하는 index들의 묶음이에요. 앞서 말한 index-document 관계 위에 collection이 한 층 더 얹힌다고 보면 됩니다.

collection 타입
collection을 만들 때는 어떤 데이터를 어떤 방식으로 다룰 건지에 따라 타입을 하나 골라야 합니다.
- Search: 일반적인 전문 검색
- Time series: 로그, 메트릭 같은 시계열 데이터
- Vector search: 임베딩 기반 벡터 검색
이 선택이 실제로 쓸 수 있는 기능에 영향을 줍니다. 예를 들어 document를 색인할 때 커스텀 _id를 직접 지정하는 게 Search collection에서만 가능하고, Time series나 Vector search collection에서는 지원하지 않습니다. 반대로 k-NN 벡터 index는 Vector search collection에서만 쓸 수 있고요.
참고로 Vector search collection이라고 해서 벡터 검색만 가능한 건 아닙니다. 인덱스 mapping에 knn_vector 필드와 함께 text·keyword 필드를 같이 두면, 같은 인덱스에서 term이나 match 같은 일반 검색도 그대로 쓸 수 있어요. 실제로 뒤에서 다룰 k-NN 예시 코드도 name(text)과 embedding(knn_vector)을 한 인덱스에 같이 매핑해뒀습니다.
Node.js(TypeScript)로 AOSS 연결하기
이제 실제로 코드를 붙여보겠습니다. @opensearch-project/opensearch 클라이언트와 AWS SDK v3의 자격 증명 provider를 함께 씁니다.
// src/opensearch/client.ts
import { Client } from '@opensearch-project/opensearch';
import { AwsSigv4Signer } from '@opensearch-project/opensearch/aws';
import { defaultProvider } from '@aws-sdk/credential-provider-node';
export const client = new Client({
...AwsSigv4Signer({
region: 'ap-northeast-2',
service: 'aoss',
getCredentials: () => defaultProvider()(),
}),
node: process.env.AOSS_COLLECTION_ENDPOINT, // collection 엔드포인트
});
index를 만들고 document를 색인하는 코드는 이렇습니다. Search collection이라 커스텀 id를 직접 지정했어요.
// src/opensearch/indexProduct.ts
import { client } from './client';
const INDEX = 'products';
export async function ensureIndex() {
const { body: exists } = await client.indices.exists({ index: INDEX });
if (!exists) {
await client.indices.create({
index: INDEX,
body: {
mappings: {
properties: {
name: { type: 'text' },
category: { type: 'keyword' },
},
},
},
});
}
}
export async function indexProduct(id: string, name: string, category: string) {
await client.index({
index: INDEX,
id,
body: { name, category },
});
}
검색 쿼리 문법
match vs term
가장 먼저 헷갈렸던 게 match와 term이었습니다. mapping에서 text로 정의한 필드는 색인될 때 analyzer를 거쳐 토큰 단위로 쪼개집니다. match 쿼리는 이 토큰 단위로 비교하고, term 쿼리는 analyzer를 거치지 않고 원문 그대로 완전히 일치하는지만 봅니다.

그래서 text 필드에 term 쿼리를 날리면 원하는 결과가 안 나오는 경우가 흔합니다. term은 보통 keyword 필드(카테고리, 상태값처럼 정확히 일치해야 하는 값)에 씁니다.
// src/opensearch/search.ts
import { client } from './client';
// 전문 검색: name 필드에서 토큰 단위로 매칭
export async function searchByName(keyword: string) {
const { body } = await client.search({
index: 'products',
body: {
query: {
match: { name: keyword },
},
},
});
return body.hits.hits;
}
// 정확값 매칭: category는 keyword 필드
export async function searchByCategory(category: string) {
const { body } = await client.search({
index: 'products',
body: {
query: {
term: { category },
},
},
});
return body.hits.hits;
}
bool 쿼리: must, should, must_not, filter
여러 조건을 조합할 때는 bool 쿼리를 씁니다. 각 절이 하는 역할이 조금씩 다릅니다.
- must: AND 조건이면서 관련도 점수(score) 계산에 반영됨
- should: OR 가중치. must 없이 should만 있으면 OR 조건처럼 동작하고, must와 같이 쓰면 점수를 더해주는 용도가 됨
- must_not: 제외 조건
- filter: must와 마찬가지로 AND 조건이지만 점수 계산을 하지 않고 결과가 캐시됨

filter는 정렬 순위에 영향을 주지 않아도 되는 조건, 예를 들어 카테고리 필터링 같은 데 쓰면 match보다 빠르게 동작합니다. 실무에서 "이 조건은 걸러내기만 하면 되고 점수에는 영향 없어도 된다" 싶은 조건은 must보다 filter로 넣는 게 맞습니다.
💡 어디에 캐싱되는 걸까요? OpenSearch는 filter 결과를 노드 단위의 쿼리 캐시(node query cache)에 저장해둡니다. 같은 filter가 같은 데이터에 다시 쓰이면, 매번 다시 계산하지 않고 캐시된 결과를 그대로 재사용하는 거예요. 그래서 자주 반복되는 filter 조건일수록 캐싱 효과를 크게 볼 수 있습니다. AOSS에서는 이 캐시를 사용자가 직접 설정할 수는 없지만, 내부적으로는 비슷한 방식의 캐싱이 동작합니다.
// src/opensearch/searchWithBool.ts
import { client } from './client';
export async function searchProducts(keyword: string, category: string, excludeCategory: string) {
const { body } = await client.search({
index: 'products',
body: {
query: {
bool: {
must: [{ match: { name: keyword } }],
filter: [{ term: { category } }],
must_not: [{ term: { category: excludeCategory } }],
},
},
},
});
return body.hits.hits;
}
벡터 검색과 k-NN
k-NN(k-Nearest Neighbor)은 벡터 공간에서 쿼리 벡터와 가장 가까운 k개의 벡터를 찾는 검색 방식입니다. 텍스트나 이미지를 임베딩 모델로 벡터화해두면, 단순 키워드 일치가 아니라 의미적으로 가까운 것을 찾을 수 있어요.

앞서 말했듯 이 기능은 Vector search collection에서만 씁니다. 벡터 필드는 mapping에서 knn_vector 타입으로 정의하고, index 설정에서 index.knn을 켜야 합니다.
// src/opensearch/vectorIndex.ts
import { client } from './client';
const INDEX = 'product-embeddings';
export async function ensureVectorIndex() {
await client.indices.create({
index: INDEX,
body: {
settings: {
index: { knn: true },
},
mappings: {
properties: {
name: { type: 'text' },
embedding: {
type: 'knn_vector',
dimension: 1536,
method: {
name: 'hnsw',
engine: 'faiss',
space_type: 'l2',
},
},
},
},
},
});
}
export async function searchByVector(vector: number[], k: number) {
const { body } = await client.search({
index: INDEX,
body: {
size: k,
query: {
knn: {
embedding: { vector, k },
},
},
},
});
return body.hits.hits;
}
reference