TechFeedTechFeed
Open Source

Elasticsearch 실전 튜토리얼 — Docker 설치·한국어 검색·Node.js 연동까지

Elasticsearch 8.x를 Docker Compose로 설치하고 한국어 nori 분석기를 적용한 뒤 Node.js @elastic/elasticsearch 클라이언트로 풀텍스트 검색 API를 직접 구현하는 실습 가이드. 인덱스 매핑 설계, bool 쿼리 조합, multi_match 가중치 설정, Next.js App Router API 라우트 연동까지 실제 동작하는 코드로 단계별로 다룬다. Docker 메모리 설정, nori stoptags 튜닝, 프로덕션 운영 팁도 포함한다.

by

Next.js 앱에 검색 기능을 붙이려다 SQL LIKE 쿼리의 한계를 체감한 적 있다. "에어팟"을 검색해도 "AirPods"는 안 나오고, 띄어쓰기 하나 다르면 결과가 0건이 됐다. PostgreSQL full-text search로 해결하려 했지만 한국어 형태소 분석이 제대로 안 됐다. 결국 Elasticsearch(이하 ES)를 도입했고, 설치부터 한국어 nori 분석기 설정, Node.js 연동까지 직접 겪은 과정을 정리했다.


이 튜토리얼에서는 Docker Compose로 ES 8.x를 설치하고, 한국어 nori 플러그인을 적용한 뒤, Node.js @elastic/elasticsearch 클라이언트로 검색 API를 만드는 전 과정을 단계별로 다룬다. 최종 목표는 Next.js API 라우트에서 한국어 풀텍스트 검색이 실제로 동작하는 것이다. 코드는 모두 실제 돌아가는 것만 넣었다.


Elasticsearch란 무엇이고 언제 써야 하나

Elasticsearch는 루씬(Lucene) 기반의 분산 검색·분석 엔진이다. JSON 문서를 저장하고, 역색인(inverted index)으로 전문 검색을 빠르게 처리한다. 단순 LIKE 쿼리와 다른 점은 세 가지다.


  • 형태소 분석: "달리기"를 검색하면 "달린다"도 찾는다. nori 플러그인으로 한국어를 지원한다.
  • 관련성 점수: BM25 알고리즘으로 가장 연관성 높은 결과를 우선 정렬한다.
  • 분산 확장: 샤드(shard)로 수평 확장되어 수억 건 문서도 처리할 수 있다.

데이터가 이미 데이터베이스에 있고 검색 규모가 작다면(수백만 건 이하, 동시 요청 초당 10건 미만) 데이터베이스 자체 전문 검색 기능으로 시작하는 편이 낫다. 운영할 시스템이 하나 줄어든다. 검색 엔진은 검색 기능이 핵심이거나, 한국어 형태소 분석이 필수이거나, 다중 필드·하이라이팅·집계가 필요한 시점에 도입하는 것이 효율적이다.


실제로 많이 쓰는 곳은 전자상거래 상품 검색, 블로그·뉴스 포털 검색, 로그 분석 시스템이다. 상품명과 설명을 합쳐서 검색하고, 카테고리·가격 필터를 동시에 적용하고, 검색어 하이라이팅까지 필요한 경우라면 관계형 데이터베이스의 기본 검색 기능으로는 한계가 분명하다. 이런 요구사항이 하나라도 있다면 처음부터 검색 엔진을 고려하는 것이 낫다.


Elasticsearch 분산 검색 엔진 아키텍처 개념도
ES는 클러스터-노드-인덱스-샤드 계층으로 구성되며 수평 확장이 가능하다

Docker Compose로 Elasticsearch 8.x 설치하기

ES 8.x부터 보안이 기본 활성화되어 있다. 로컬 개발 환경에서는 TLS와 인증을 비활성화하는 편이 설정이 간단하다. 아래 설정은 단일 노드 개발 환경 기준이며 키바나도 함께 띄운다.


docker-compose.yml
version: '3.8' services: elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4 environment: - discovery.type=single-node - xpack.security.enabled=false # 개발 환경 전용 - ES_JAVA_OPTS=-Xms512m -Xmx512m ports: - "9200:9200" volumes: - es_data:/usr/share/elasticsearch/data kibana: image: docker.elastic.co/kibana/kibana:8.13.4 environment: - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 ports: - "5601:5601" depends_on: - elasticsearch volumes: es_data:

파일을 저장한 뒤 docker compose up -d로 실행한다. 30~60초 후 curl http://localhost:9200을 실행했을 때 JSON 응답이 오면 정상이다. 키바나는 http://localhost:5601에서 접속해 확인할 수 있다.


메모리가 부족하면 힙 크기를 -Xms256m -Xmx256m으로 낮춰라. 한국어 분석을 위한 nori 플러그인 설치는 컨테이너 안에서 실행해야 한다:


docker exec -it $(docker compose ps -q elasticsearch) bash
./bin/elasticsearch-plugin install analysis-nori
exit
docker compose restart elasticsearch

설치 후 재시작이 필요하다. docker compose logs elasticsearch -f로 재시작 완료 여부를 확인하라. 프로덕션에서는 커스텀 이미지 파일로 플러그인을 미리 포함해두는 방식이 일반적이다.


컨테이너가 계속 재시작되는 경우 메모리 부족이 원인인 경우가 많다. 검색 엔진은 기본적으로 자바 가상 머신(JVM) 위에서 돌아가기 때문에 적어도 2GB 이상의 여유 메모리가 있어야 안정적으로 동작한다. 도커 데스크톱을 사용 중이라면 설정에서 도커에 할당된 메모리를 늘려야 할 수도 있다. docker stats 명령으로 현재 메모리 사용량을 확인할 수 있다.


⚠️ 프로덕션 주의: xpack.security.enabled=false는 개발 환경 전용이다. 실서비스에서는 TLS와 패스워드 인증을 반드시 활성화해야 한다. Elastic 공식 문서의 Security minimal setup 가이드를 참고하라.

인덱스 생성과 문서 CRUD — 기본 조작법

검색 엔진에서 인덱스(index)는 관계형 데이터베이스의 테이블과 비슷한 개념이다. 문서(document)는 행(row)에 해당하며 JSON 형식으로 저장된다. 키바나 개발 도구 화면 또는 터미널에서 직접 실행해 볼 수 있다.


처음에는 명령어 하나하나가 낯설게 느껴지지만, 구조를 이해하고 나면 상당히 직관적이다. 문서를 만들고(C), 조회하고(R), 수정하고(U), 삭제하는(D) 기본 작업을 먼저 익혀두면 이후 검색 쿼리 작성이 훨씬 쉬워진다.


인덱스 생성·문서 CRUD (curl)
# 인덱스 생성 curl -X PUT http://localhost:9200/products \ -H 'Content-Type: application/json' \ -d '{ "settings": { "number_of_shards": 1, "number_of_replicas": 0 } }' # 문서 추가 (POST: 자동 ID 생성) curl -X POST http://localhost:9200/products/_doc \ -H 'Content-Type: application/json' \ -d '{ "name": "맥북 프로 M4", "description": "애플 실리콘 M4 칩 탑재 개발자용 노트북", "price": 2490000, "category": "laptop" }' # 특정 ID로 문서 추가 (PUT: 없으면 생성, 있으면 전체 교체) curl -X PUT http://localhost:9200/products/_doc/1 \ -H 'Content-Type: application/json' \ -d '{"name": "맥 미니 M4", "price": 899000}' # 문서 조회 curl http://localhost:9200/products/_doc/1 # 부분 수정 (_update) curl -X POST http://localhost:9200/products/_update/1 \ -H 'Content-Type: application/json' \ -d '{"doc": {"price": 799000}}' # 문서 삭제 curl -X DELETE http://localhost:9200/products/_doc/1

ES는 스키마 없이도 문서를 저장할 수 있다(동적 매핑). 그러나 한국어 검색이나 특정 타입 지정이 필요하다면 인덱스 생성 시 매핑(mapping)을 명시하는 편이 좋다. 인덱스 생성 후에는 기존 필드 타입을 바꿀 수 없으므로, 스키마 설계를 처음부터 하는 것이 중요하다. 특히 text 타입과 keyword 타입의 차이를 이해해야 한다. text는 분석기를 거쳐 검색되고, keyword는 정확한 값 일치와 정렬·집계에 쓰인다.


nori 한국어 분석기 설정과 인덱스 매핑

기본 분석기(standard analyzer)는 한국어 형태소를 제대로 분리하지 못한다. "검색 엔진 튜토리얼"을 그대로 처리하면 의미 있는 단위로 쪼개지지 않는다. nori 플러그인을 설치하면 "검색", "엔진", "튜토리얼"로 분리해서 각 단어를 독립적으로 검색할 수 있다.


형태소 분석이 왜 중요한지 예를 들어보자. 사용자가 "설치하기"로 검색해도 "설치 방법", "설치 완료", "설치됩니다" 같은 변형이 모두 검색 결과에 나와야 한다. 영어는 단어 경계가 띄어쓰기로 명확하지만, 한국어는 조사와 어미가 붙어서 변형이 훨씬 다양하다. nori는 이 복잡한 한국어 문법 구조를 이해하고 의미 있는 어근 단위로 쪼개준다.


아래 매핑은 한국어 풀텍스트 검색이 필요한 블로그나 상품 검색 인덱스의 일반적인 설정이다:


nori 분석기 적용 인덱스 매핑
curl -X PUT http://localhost:9200/posts \ -H 'Content-Type: application/json' \ -d '{ "settings": { "analysis": { "analyzer": { "korean": { "type": "custom", "tokenizer": "nori_tokenizer", "filter": ["lowercase", "nori_stop"] } }, "filter": { "nori_stop": { "type": "nori_part_of_speech", "stoptags": [ "E", "IC", "J", "MAG", "MAJ", "MM", "SP", "SSC", "SSO", "SC", "SE", "XPN", "XSA", "XSN", "XSV", "UNA", "NA" ] } } }, "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "korean", "fields": { "keyword": { "type": "keyword" } } }, "content": { "type": "text", "analyzer": "korean" }, "category": { "type": "keyword" }, "published_at": { "type": "date" }, "tags": { "type": "keyword" }, "slug": { "type": "keyword" } } } }'

stoptags는 검색에서 제외할 품사 태그다. "은", "는", "이", "가" 같은 조사(J)를 제외하면 검색 정확도가 올라간다. 분석기 동작을 직접 확인하려면 아래 명령을 사용하라:


curl -X POST "http://localhost:9200/posts/_analyze" \
  -H 'Content-Type: application/json' \
  -d '{"analyzer": "korean", "text": "검색 엔진 설치 방법 알아보기"}'

응답의 tokens 배열로 어떤 형태소가 추출되는지 확인할 수 있다. 조사나 어미가 제대로 제거되고 있는지 여기서 검증하고 매핑을 조정하는 것이 튜닝의 핵심이다.


한 가지 자주 발생하는 문제가 있다. 고유명사나 브랜드명이 형태소 단위로 쪼개지는 경우다. 예를 들어 "클로드코드"가 "클로드"+"코드"로 분리되면 "클로드코드"로 붙여 쓴 문서를 찾지 못한다. 이런 경우 사용자 사전(user dictionary)을 추가해서 특정 단어는 분리하지 않도록 설정할 수 있다. 설정 방법은 nori 플러그인 공식 문서의 사용자 사전 섹션을 참고하라.


키바나 Dev Tools 한국어 nori 분석기 테스트 결과
키바나 Dev Tools에서 분석기 동작을 실시간으로 확인하면 매핑 튜닝이 훨씬 빠르다

검색 쿼리 작성법 — match, bool, multi_match

ES 쿼리는 JSON으로 작성한다. 자주 쓰는 유형 세 가지를 이해하면 대부분의 검색 요구사항을 충족할 수 있다.


  • match: 단일 필드 풀텍스트 검색. 가장 기본.
  • multi_match: 여러 필드를 동시에 검색하되 필드별 가중치 설정 가능.
  • bool: must, should, must_not, filter를 조합한 복합 쿼리. 실무에서 가장 많이 쓴다.

검색 성능 측면에서 중요한 점이 있다. filter는 점수 계산 없이 조건을 걸기 때문에 must보다 빠르고 캐싱도 된다. 카테고리, 날짜 범위처럼 정렬이 필요 없는 조건은 항상 filter를 사용해야 한다.


Elasticsearch 검색 쿼리 예시
// 1. 단순 match 검색 { "query": { "match": { "title": "Elasticsearch 설치 방법" } }, "size": 10, "from": 0 } // 2. multi_match — title(가중치 2배)과 content 동시 검색 { "query": { "multi_match": { "query": "검색 엔진 설치", "fields": ["title^2", "content"], "type": "best_fields" } } } // 3. bool 쿼리 — 카테고리 필터 + 키워드 검색 + 날짜 범위 { "query": { "bool": { "must": [ { "multi_match": { "query": "Docker 설치", "fields": ["title^2", "content"] }} ], "filter": [ { "term": { "category": "tutorial" } }, { "range": { "published_at": { "gte": "2026-01-01" } } } ] } }, "_source": ["title", "category", "published_at", "slug"], "highlight": { "fields": { "title": {}, "content": { "fragment_size": 150, "number_of_fragments": 1 } } } }

highlight는 검색어가 포함된 부분을 <em> 태그로 감싸서 반환한다. 검색 결과 UI에서 검색어 하이라이팅 구현에 바로 활용할 수 있다. 검색어와 정확히 일치하는 문자뿐만 아니라 형태소가 같은 변형도 하이라이팅된다.


검색 결과 순위를 직접 조정하고 싶다면 function_score 쿼리를 활용하면 된다. 예를 들어 최신 문서에 가중치를 주거나, 조회수 필드 값을 점수에 반영할 수 있다. 처음부터 이 기능이 필요한 경우는 드물지만, 검색 품질 개선 단계에서 자주 쓰인다.


Node.js 클라이언트 연동 — 설치와 기본 구조

ES 공식 Node.js 클라이언트는 @elastic/elasticsearch다. ES 버전과 클라이언트 버전을 맞춰야 한다. ES 8.x라면 @elastic/elasticsearch@8을 설치해라. 버전이 다르면 API 응답 구조가 달라서 오류가 난다.


npm install @elastic/elasticsearch@8

클라이언트 초기화 코드는 앱 전체에서 하나만 만들어서 재사용하는 패턴을 권장한다. 매 요청마다 새 클라이언트를 생성하면 연결 오버헤드가 생긴다.


lib/elasticsearch.js — 클라이언트 초기화
import { Client } from '@elastic/elasticsearch' const esClient = new Client({ node: process.env.ELASTICSEARCH_URL || 'http://localhost:9200', // 보안이 활성화된 프로덕션 환경: // auth: { username: 'elastic', password: process.env.ES_PASSWORD } // tls: { rejectUnauthorized: false } // 자체 서명 인증서일 때만 }) export default esClient
lib/search.js — 검색·인덱싱 함수
import esClient from './elasticsearch' export async function searchPosts(keyword, { from = 0, size = 10, category } = {}) { const must = [{ multi_match: { query: keyword, fields: ['title^2', 'content'], type: 'best_fields', }, }] const filter = [] if (category) { filter.push({ term: { category } }) } const response = await esClient.search({ index: 'posts', query: { bool: { must, filter } }, from, size, highlight: { fields: { title: {}, content: { fragment_size: 150, number_of_fragments: 1 }, }, }, _source: ['title', 'category', 'published_at', 'slug'], }) return { total: response.hits.total.value, hits: response.hits.hits.map((hit) => ({ id: hit._id, score: hit._score, ...hit._source, highlight: hit.highlight, })), } } export async function indexPost(post) { return esClient.index({ index: 'posts', id: String(post.id), document: { title: post.title, content: post.content, category: post.category, published_at: post.date, slug: post.slug, tags: post.tags || [], }, }) } export async function bulkIndexPosts(posts) { const operations = posts.flatMap((post) => [ { index: { _index: 'posts', _id: String(post.id) } }, { title: post.title, content: post.content, category: post.category, published_at: post.date, slug: post.slug, tags: post.tags || [], }, ]) return esClient.bulk({ operations }) } export async function deletePost(id) { return esClient.delete({ index: 'posts', id: String(id) }) }

연결 오류가 발생하면 두 가지를 먼저 확인하라. 첫째, 환경변수가 올바른지 확인한다. 도커 내부에서는 서비스 이름으로 연결하고, 로컬 호스트에서는 localhost:9200을 사용한다. 둘째, 도커 컨테이너가 완전히 시작됐는지 확인한다. 검색 엔진은 시작 후 완전히 준비되는 데 20~30초가 걸린다.


대량 문서를 처음 인덱싱할 때는 bulkIndexPosts처럼 일괄 처리 방식을 사용해야 한다. 문서를 하나씩 저장하면 네트워크 왕복 비용이 문서 수만큼 발생해서 수천 건 이상에서는 매우 느려진다. 배치 크기는 한 번에 1000~5000건 단위가 일반적이다.


배치 처리 시 한 가지 주의할 점이 있다. 일괄 처리 응답에는 전체 성공·실패 여부뿐만 아니라 문서별 오류 내역이 담겨 있다. 응답의 errors 필드가 true라면 items 배열을 순회하며 실패한 문서를 따로 처리해야 한다. 이 부분을 놓치면 일부 문서가 인덱싱되지 않았는데 성공으로 착각하는 경우가 생긴다.


Next.js API 라우트에서 검색 엔드포인트 구현

Next.js 앱 라우터 환경에서 검색 API를 구현하는 코드다. GET 요청으로 키워드와 페이지 번호를 받아 ES 검색 결과를 JSON으로 반환한다.


app/api/search/route.js (Next.js App Router)
import { NextResponse } from 'next/server' import { searchPosts } from '@/lib/search' export async function GET(request) { const { searchParams } = new URL(request.url) const q = searchParams.get('q')?.trim() const page = Math.max(1, Number(searchParams.get('page') || '1')) const category = searchParams.get('category') || undefined const size = 10 if (!q || q.length < 2) { return NextResponse.json( { error: '검색어는 2자 이상 입력해주세요.' }, { status: 400 } ) } try { const result = await searchPosts(q, { from: (page - 1) * size, size, category, }) return NextResponse.json(result) } catch (error) { console.error('[ES search error]', error.message) return NextResponse.json( { error: '검색 서비스 일시 오류' }, { status: 500 } ) } }

클라이언트에서는 fetch('/api/search?q=Elasticsearch&page=1')로 호출하면 된다. 검색 결과에 highlight 필드가 포함되어 있으므로, 프론트엔드에서 하이라이팅된 HTML을 그대로 렌더링할 수 있다.


주의할 점: highlight.content처럼 ES가 반환하는 HTML에는 <em> 태그가 들어있다. 이를 dangerouslySetInnerHTML로 렌더링하면 XSS 위험이 생길 수 있으므로, ES 연결 오류나 인젝션 가능성이 없는지 확인 후 사용해야 한다. ES 응답은 직접 저장한 문서 내용을 토대로 하이라이팅하므로 외부 입력이 그대로 반환되지 않지만, 신뢰할 수 없는 콘텐츠를 인덱싱한 경우에는 정제가 필요하다.


Elasticsearch Node.js 검색 API 연동 구조도
클라이언트 → Next.js API 라우트 → ES 클라이언트 → ES 클러스터 구조로 동작한다

프로덕션 운영 팁 — 성능과 안정성

로컬에서 동작을 확인한 뒤 실제 서비스에 투입하기 전에 챙겨야 할 것들이 있다.


  • 메모리 힙 크기: 검색 엔진 프로세스의 메모리 힙은 물리 메모리의 50% 이하, 최대 31기가바이트 이하로 설정한다. 31기가바이트를 넘으면 가상 머신 최적화가 비활성화되어 오히려 느려진다.
  • 샤드 수: 소규모 서비스는 1샤드로 시작해라. 샤드가 많으면 오버헤드가 생긴다. 문서 수가 1억 건을 넘는 시점에 샤드 추가를 고려하라.
  • 인덱스 별칭: 인덱스를 직접 참조하지 말고 별칭을 사용하면, 재인덱싱 시 서비스 중단 없이 전환할 수 있다.
  • 스냅샷 백업: 클라우드 스토리지에 정기 백업을 설정해라. 검색 엔진 데이터는 별도 백업 없으면 복구가 어렵다.
  • 느린 쿼리 로그: 인덱스 설정에서 느린 쿼리 임계값을 설정하면 성능 문제를 조기에 발견할 수 있다.

클라우드 환경이라면 관리형 서비스를 선택하면 운영 부담이 크게 줄어든다. 비용은 직접 운영보다 비싸지만 업그레이드, 백업, 모니터링이 자동화되어 있어 팀 규모가 작을수록 가성비가 좋다.


검색 품질 측면에서 한 가지 더 챙겨야 할 것이 있다. 실제 서비스에서는 사용자 검색어와 검색 결과를 주기적으로 분석해야 한다. 어떤 검색어에서 결과가 0건으로 나오는지, 어떤 검색어 패턴이 가장 많은지 파악해야 분석기 튜닝 방향을 잡을 수 있다. 처음에 완벽하게 만들려 하기보다, 실제 사용 데이터를 보면서 반복적으로 개선하는 방식이 현실적이다.


참고 자료


자주 묻는 질문

ES와 PostgreSQL full-text search 중 무엇을 선택해야 하나?

데이터가 이미 PostgreSQL에 있고 검색 규모가 작다면(수백만 건 이하, 동시 요청 초당 10건 미만) tsvector와 pg_bigm 확장으로 시작하는 편이 낫다. 운영할 시스템이 하나 줄어든다. ES는 한국어 형태소 분석이 필수이거나, 다중 필드 하이라이팅과 집계가 필요하거나, 수억 건 규모로 확장이 필요한 시점에 도입하는 것이 효율적이다.


nori 플러그인을 Docker 이미지에 미리 포함하는 방법은?

커스텀 Dockerfile로 이미지를 만드는 방식이 일반적이다. FROM docker.elastic.co/elasticsearch/elasticsearch:8.13.4를 기반으로 RUN elasticsearch-plugin install --batch analysis-nori를 추가하면 된다. 이렇게 하면 컨테이너를 새로 시작해도 매번 플러그인을 설치하지 않아도 되고, CI/CD 파이프라인에서도 안정적으로 동작한다.


인덱스 매핑을 나중에 변경하고 싶을 때는 어떻게 하나?

기존 필드 타입은 변경할 수 없다. 새 매핑으로 새 인덱스를 만들고, Reindex API(POST _reindex)로 기존 데이터를 복사한 뒤, 인덱스 별칭을 새 인덱스로 전환하는 방식을 사용해라. 애플리케이션이 별칭을 참조하고 있다면 재시작 없이 전환이 된다. 이것이 ES에서 무중단 스키마 변경의 표준 절차다.


@elastic/elasticsearch TypeScript 타입 지원은 어떻게 쓰나?

@elastic/elasticsearch 8.x부터 TypeScript 타입이 내장되어 있다. import { Client, estypes } from '@elastic/elasticsearch'로 사용하며, esClient.search<MyDoc>(...)처럼 제네릭으로 응답 타입을 지정할 수 있다. 복잡한 쿼리 DSL의 타입은 estypes.QueryDslQueryContainer를 참고하면 된다. 다만 중첩된 쿼리는 자동 추론 한계가 있어 부분적으로 타입 단언이 필요할 수 있다.


검색 결과가 예상보다 관련성이 낮을 때 어떻게 튜닝하나?

먼저 _analyze API로 nori 분석기가 의도한 대로 형태소를 추출하는지 확인하라. stoptags 설정이 너무 넓으면 중요한 단어도 제거된다. 그 다음으로 fields의 가중치(boost) 조정, type: "cross_fields" 전환, minimum_should_match 비율 조정 순으로 시도한다. Kibana의 Search Profiler로 각 쿼리 절이 점수에 기여하는 비율을 시각화할 수 있어 튜닝에 도움이 된다.


Elasticsearch검색엔진DockerNode.jsnori한국어검색Next.js풀텍스트검색

관련 도구

함께 보면 좋은 문제 해결

EXPLORE / Open Source

이어서 읽어보기

전체 토픽 둘러보기