관계형 DB만으로 운영하다가 검색 기능이 본격적으로 필요해지는 순간이 꼭 온다. 상품명 부분 일치, 오타 허용, 형태소 분석, 한/영 혼용, 가중치 기반 정렬, 집계(aggregation), 파사드 필터. 이런 요구가 쌓이면 LIKE '%...%' + 인덱스는 금세 깨진다. FULLTEXT 인덱스를 동원해도 한국어 형태소와 다국어 처리, 실시간 색인 토폴로지, 분산 집계까지 가면 MySQL 단독으로는 무겁다. CJ OliveYoung Wellness 플랫폼처럼 상품 카탈로그와 검색/추천/로그 분석이 동시에 돌아가는 도메인이라면 OpenSearch(또는 Elasticsearch) 계열은 사실상 표준 선택지다.
OpenSearch는 Elasticsearch 7.10 포크에서 출발한 Apache 2.0 라이선스의 분산 검색/분석 엔진이다. 이 문서는 OpenSearch를 "처음 운영해야 하는 Java 백엔드 엔지니어"가 필요한 최소 분량을 익히는 것을 목표로 한다. 운영 레벨 튜닝이나 ML 플러그인은 다른 문서로 미루고, 이 문서는 색인 설계 → 매핑 → 쿼리 DSL → Java 클라이언트 연동 → 운영 주의점까지의 직선 경로를 다룬다.
관련 개념은 database/ 폴더의 MySQL 인덱스 문서, architecture/의 비동기 이벤트 반영 문서와 겹치는 부분이 있으므로, 아래에서는 중복 설명을 피하고 필요한 지점에 짧게 연결한다.
OpenSearch의 데이터 모델은 RDBMS와 다르게 "문서(document) 중심"이다. 용어를 RDBMS에 대응시키면 대략 이렇다.
여기서 가장 중요한 개념 두 가지는 역색인(inverted index)과 애널라이저다.
RDBMS의 B+Tree 인덱스는 "컬럼 값 → 행 위치"로 정렬되지만, 역색인은 "토큰 → 해당 토큰이 등장한 문서 목록"으로 저장된다. 예를 들어 "아이오페 에어쿠션 N 커버"라는 상품명이 색인될 때, 애널라이저가 [아이오페, 에어쿠션, 커버]로 토큰화하면 각 토큰마다 문서 ID 포스팅 리스트가 만들어진다. 검색어가 "에어쿠션"으로 들어오면 해당 포스팅 리스트만 조회하면 끝이라 빠르다.
이 구조 때문에 OpenSearch는 "텍스트가 어떻게 토큰화됐는가"에 성능과 정확도 둘 다 지배당한다. 즉, 스키마 설계보다 애널라이저 설계가 먼저다.
애널라이저는 세 단계로 구성된다.
한국어 검색은 기본 standard 토크나이저로는 거의 쓸 수 없고, Nori 플러그인(공식 한국어 형태소 분석기) 또는 은전한닢, 맥락에 따라 ngram/edge_ngram을 조합해야 한다.
같은 문자열이라도 두 가지 목적이 섞인다.
text: 애널라이저로 토큰화되어 검색에 사용. 정렬/집계 비효율.keyword: 원문 그대로 한 토큰으로 저장. 정확 일치, 정렬, 집계에 사용.그래서 상품명 같은 필드는 관례적으로 text로 매핑하고, 하위 필드 name.keyword를 같이 둔다. 이걸 multi-field라고 부른다.
Wellness/이커머스 도메인에서 OpenSearch를 얹는 방식은 보통 이렇게 자리 잡는다.
즉, OpenSearch는 **"쓰기는 느슨하고 읽기는 최적"**인 파이프라인의 끝단이다. 여기서 나오는 인터뷰 질문의 90%는 "어떻게 일관성을 맞췄는가", "재색인 전략은 무엇인가", "검색 품질 A/B를 어떻게 잡았는가"다.
첫 인덱스를 만들 때 다음을 명시적으로 결정해야 한다.
예시 매핑:
PUT products-v1
{
"settings": {
"number_of_shards": 2,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"ko_analyzer": {
"type": "custom",
"tokenizer": "nori_tokenizer",
"filter": ["lowercase", "nori_part_of_speech"]
},
"ko_ngram_analyzer": {
"type": "custom",
"tokenizer": "ngram_tokenizer",
"filter": ["lowercase"]
}
},
"tokenizer": {
"ngram_tokenizer": {
"type": "ngram",
"min_gram": 2,
"max_gram": 3,
"token_chars": ["letter", "digit"]
}
}
}
},
"mappings": {
"properties": {
"productId": { "type": "keyword" },
"name": {
"type": "text",
"analyzer": "ko_analyzer",
"fields": {
"keyword": { "type": "keyword", "ignore_above": 256 },
"ngram": { "type": "text", "analyzer": "ko_ngram_analyzer" }
}
},
"brand": { "type": "keyword" },
"categoryPath":{ "type": "keyword" },
"price": { "type": "integer" },
"tags": { "type": "keyword" },
"createdAt": { "type": "date" },
"stockQty": { "type": "integer" },
"isActive": { "type": "boolean" }
}
}
}
POST _aliases
{
"actions": [
{ "add": { "index": "products-v1", "alias": "products" } }
]
}
여기서 name.ngram을 둔 이유는 "크러쉬" 같은 짧은 부분 일치 검색을 보완하기 위해서다. Nori는 형태소 단위라 "크"나 "크러" 검색에서 미끄러질 수 있어 n-gram 서브필드를 병행한다.
GET products/_search
{
"query": {
"match": {
"name": { "query": "에어쿠션 N 커버", "operator": "and" }
}
}
}
operator: and는 토큰 전부가 포함돼야 매칭된다는 뜻. 사용자가 긴 검색어를 넣을 때 상위 정확도를 올리려면 minimum_should_match를 튜닝한다.
GET products/_search
{
"query": {
"bool": {
"filter": [
{ "term": { "brand": "아이오페" } },
{ "terms": { "categoryPath": ["뷰티>베이스메이크업>쿠션"] } },
{ "term": { "isActive": true } }
]
}
}
}
filter 절은 점수 계산을 생략하고 캐시되기 때문에 카테고리/브랜드 파사드처럼 반복되는 조건은 반드시 여기로.
GET products/_search
{
"query": {
"bool": {
"must": [{ "match": { "name": "쿠션" } }],
"should": [{ "match": { "name.ngram": "에어쿠" } }],
"filter": [
{ "term": { "isActive": true } },
{ "range": { "price": { "gte": 10000, "lte": 50000 } } }
],
"must_not": [{ "term": { "brand": "블랙리스트브랜드" } }]
}
}
}
점수에 영향을 주는 건 must와 should, 영향을 주지 않는 건 filter와 must_not. 이 구분은 성능과 랭킹 품질에 직결된다.
GET products/_search
{
"size": 0,
"aggs": {
"by_brand": {
"terms": { "field": "brand", "size": 20 }
},
"price_stats": {
"stats": { "field": "price" }
}
}
}
size: 0으로 본문 검색 결과는 비우고 집계만 받는 패턴이 실무에서 가장 흔하다. 집계 필드는 반드시 keyword나 숫자형이어야 한다는 점을 잊지 말 것.
OpenSearch는 공식적으로 두 개의 Java 클라이언트를 제공한다.
opensearch-java(권장) — 타입 세이프, 빌더 기반opensearch-rest-high-level-client(레거시) — Elasticsearch 7.x에서 포크된 것신규 프로젝트라면 opensearch-java가 정답이다. Spring Data OpenSearch는 CRUD 편의는 좋지만, 매핑/쿼리 제어가 약해지기 쉬워 검색 품질이 중요한 서비스에서는 로우레벨 클라이언트를 쓰는 편이 안전하다.
최소 Bean 설정:
@Configuration
public class OpenSearchConfig {
@Bean
public OpenSearchClient openSearchClient() {
HttpHost host = new HttpHost("localhost", 9200, "http");
RestClient restClient = RestClient.builder(host).build();
OpenSearchTransport transport =
new RestClientTransport(restClient, new JacksonJsonpMapper());
return new OpenSearchClient(transport);
}
}
간단한 색인/검색 서비스:
@Service
@RequiredArgsConstructor
public class ProductSearchService {
private final OpenSearchClient client;
private static final String ALIAS = "products";
public void index(Product product) throws IOException {
client.index(b -> b
.index(ALIAS)
.id(product.getId())
.document(product)
);
}
public List<Product> searchByName(String keyword, int from, int size) throws IOException {
SearchResponse<Product> resp = client.search(s -> s
.index(ALIAS)
.from(from)
.size(size)
.query(q -> q
.bool(b -> b
.must(m -> m.match(mm -> mm.field("name").query(FieldValue.of(keyword))))
.filter(f -> f.term(t -> t.field("isActive").value(FieldValue.of(true))))
)
),
Product.class);
return resp.hits().hits().stream()
.map(Hit::source)
.filter(Objects::nonNull)
.toList();
}
}
실제 서비스 코드에서 주의할 지점은 다음과 같다.
index API는 실시간성은 좋지만 대량 적재에는 부적합. 배치 시 BulkRequest 사용.refresh=wait_for를 남용하면 처리량이 크게 떨어진다. 실시간성이 꼭 필요한 경로만 선택적으로.// BAD — 필드가 들어올 때마다 동적 타입 추론
product.setPrice("10000"); // 문자열로 들어감
product.setStockQty("0"); // 문자열로 들어감
// 결과: price가 text로 매핑되어 range 쿼리 불가,
// 이후 integer로 재매핑하려면 reindex 필요
개선:
// GOOD — 명시적 DTO + 매핑 사전 정의
@Data
public class ProductDoc {
private String productId;
private String name;
private Integer price;
private Integer stockQty;
private boolean isActive;
private Instant createdAt;
}
인덱스 생성 단계에서 숫자형/불리언은 반드시 명시해야 런타임에 예기치 않은 매핑 폭발이 안 생긴다.
// BAD — text는 fielddata가 꺼져 있어 기본적으로 정렬 불가
"sort": [{ "name": "asc" }]
개선:
// GOOD — 하위 keyword 필드를 정렬 대상으로
"sort": [{ "name.keyword": "asc" }]
초기 트래픽이 작은데 샤드를 20개로 잡으면 샤드당 데이터가 너무 적어지고, 각 검색 요청이 모든 샤드에 팬아웃되면서 오히려 레이턴시가 올라간다. 경험칙: 샤드 하나당 10~40GB, 총 문서 수와 쓰기 속도를 기준으로 계산. 애매하면 작게 시작하고 reindex로 늘리는 편이 안전하다.
# BAD
DELETE products
PUT products { ...새 매핑... }
# 서비스 다운타임 발생
개선:
# GOOD
PUT products-v2 { ...새 매핑... }
POST _reindex { "source": {"index":"products-v1"}, "dest": {"index":"products-v2"} }
POST _aliases
{
"actions": [
{ "remove": { "index": "products-v1", "alias": "products" } },
{ "add": { "index": "products-v2", "alias": "products" } }
]
}
alias 스위칭은 원자적으로 수행된다. 무중단 재색인의 표준 패턴.
Docker Compose로 OpenSearch 2.x + 대시보드를 띄운다. 개발 환경에서는 보안 플러그인을 끄는 게 편하다.
# docker-compose.yml
version: "3.8"
services:
opensearch:
image: opensearchproject/opensearch:2.11.1
environment:
- discovery.type=single-node
- DISABLE_SECURITY_PLUGIN=true
- OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
ports:
- "9200:9200"
dashboards:
image: opensearchproject/opensearch-dashboards:2.11.1
environment:
- OPENSEARCH_HOSTS=http://opensearch:9200
- DISABLE_SECURITY_DASHBOARDS_PLUGIN=true
ports:
- "5601:5601"
기동 확인:
curl -s localhost:9200 | jq
curl -s localhost:9200/_cat/nodes?v
POST products/_doc/p-001
{
"productId": "p-001",
"name": "아이오페 에어쿠션 N 커버 21호",
"brand": "아이오페",
"categoryPath": "뷰티>베이스메이크업>쿠션",
"price": 28000,
"tags": ["쿠션", "커버", "21호"],
"createdAt": "2026-04-10T09:00:00Z",
"stockQty": 42,
"isActive": true
}
POST products/_analyze
{
"field": "name",
"text": "아이오페 에어쿠션 N 커버"
}
출력 토큰을 보면서 "이게 내가 기대한 분리인가?"를 검증한다. Nori가 명사만 남기는지, 원하는 수준에서 멈추는지.
GET products/_search
{
"query": {
"multi_match": {
"query": "에어쿠",
"fields": ["name^2", "name.ngram"]
}
}
}
^2는 필드 가중치. 같은 토큰이 매칭되어도 name에서 맞으면 2배 점수.
GET products/_search
{
"size": 0,
"aggs": {
"brand_counts": { "terms": { "field": "brand" } }
}
}
products-v2를 만들고 _reindex → alias 스위칭을 직접 해본다. 이걸 한 번도 안 해보고 면접에서 설명하면 금방 들통난다.dynamic: "strict" 또는 dynamic: "false"로 차단.from + size로 10만 건 이상 넘겨받으려 하면 메모리 폭발. search_after나 scroll/PIT를 사용.-1로 껐다가 끝나고 1s로 복구하는 패턴이 정석.면접관이 "OpenSearch 써보셨어요?"라고 물을 때 원하는 답은 "색인 파이프라인 + 재색인 + 품질 관측"의 3축이다.
답 프레임:
aggs가 네이티브로 있는 엔진이 유리.답 프레임:
답 프레임:
products-v{n} 네이밍 + alias._reindex → alias 스위칭._reindex를 슬라이스 병렬화 + throttling.답 프레임:
답 프레임:
text vs keyword의 용도를 구분하고, multi-field 패턴을 말할 수 있다.bool 쿼리의 must/should/filter/must_not 차이와 캐시 영향을 말할 수 있다.search_after, PIT)을 설명할 수 있다.dynamic: strict의 트레이드오프를 말할 수 있다.