TechFeedTechFeed
Open Source

Meilisearch 실전 튜토리얼 — Docker 설치부터 Node.js SDK·필터·랭킹·Next.js 연동까지

메이리서치(Meilisearch) 오픈소스 검색 엔진 실전 튜토리얼. Docker 로컬 설치, 인덱스·문서 업로드, Node.js SDK 동기화, 필터·정렬·동의어·랭킹 규칙, Next.js App Router API 프록시, 검색 전용 API 키 분리와 프로덕션 배포 체크리스트까지. 일래스틱서치 대신 가벼운 전문 검색이 필요한 백엔드·프론트엔드 개발자를 위한 단계별 가이드.

by

상품명 검색에서 LIKE '%노트북%' 를 쓰다가 한글 오타·동의어·관련도 순 정렬이 막히면, 그때부터 전문 검색 엔진이 필요해진다. 일래스틱서치(Elasticsearch)는 강력하지만 클러스터 운영 비용이 크고, 사이드 프로젝트나 중소 트래픽 SaaS에는 과한 경우가 많다.


메이리서치(Meilisearch)는 루스트(Rust)로 만든 오픈소스 검색 엔진이다. 도커 한 줄로 띄우고, REST API와 자바스크립트 SDK로 문서를 넣고, 타이핑 즉시 결과(typo-tolerant)를 돌려준다. 이 튜토리얼은 로컬 설치부터 인덱스 설계, 노드 SDK, 필터·랭킹·동의어, 넥스트 앱 라우터 연동까지 단계별로 따라 할 수 있게 정리한다.


  • 도커로 5분 안에 로컬 검색 서버 실행
  • 인덱스 생성·문서 업로드·검색 쿼리 실습
  • 필터·정렬·랭킹 규칙·동의어 설정
  • 넥스트 앱 라우터 API 라우트 연동 패턴
  • 프로덕션 배포 시 키·백업·보안 체크포인트

메이리서치 검색 엔진 아키텍처 개요
클라이언트 → REST/SDK → 메이리서치 인덱스 → 랭킹·필터 파이프라인으로 이어지는 기본 흐름

언제 메이리서치를 쓰는가 — SQL LIKE의 한계

관계형 DB의 LIKE 검색은 구현이 쉽지만 한계가 분명하다. 오타에 약하고, 관련도(relevance) 점수가 없으며, 접두·접미 와일드카드는 인덱스를 거의 못 탄다. 트래픽이 늘면 전체 테이블 스캔에 가까운 비용이 난다.


메이리서치는 이런 문제를 검색 전용 엔진으로 분리한다. 문서를 JSON으로 넣고, 필드를 searchable·filterable·sortable로 선언하면, 타이핑 중에도 오타를 감안해 상위 결과를 돌려준다. 일래스틱서치·알고리아(Algolia)와 비교하면 설치·운영 부담이 훨씬 가볍다.


실무에서 잘 맞는 경우:


  • 상품·문서·헬프센터·블로그 전문 검색
  • 관리자 패널의 빠른 필터 검색 UI
  • 멀티 테넌트 SaaS의 테넌트별 인덱스 또는 필터 격리
  • 일래스틱 클러스터를 유지할 인력이 없는 소규모 팀

맞지 않는 경우도 있다. 초대규모 로그 분석·복잡한 집계 파이프라인이 필요하면 일래스틱·오픈서치 쪽이 낫다. 이 글은 제품 검색·사이트 내 검색에 초점을 둔다.


도커로 로컬 설치하기 — 5분 실행

공식 도커 이미지를 쓰면 별도 바이너리 설치 없이 바로 서버를 띄울 수 있다. 마스터 키는 로컬 개발용으로 길게 잡고, 프로덕션에서는 반드시 강한 랜덤 값으로 교체한다. 데이터는 볼륨에 마운트해 컨테이너를 지워도 인덱스가 남도록 한다.


docker run — 메이리서치 즉시 실행
# 데이터 디렉터리 준비 후 컨테이너 실행 mkdir -p ./meili_data docker run -d \ --name meilisearch \ -p 7700:7700 \ -v "$(pwd)/meili_data:/meili_data" \ -e MEILI_ENV=development \ -e MEILI_MASTER_KEY='dev-master-key-change-me-32chars!!' \ getmeili/meilisearch:v1.11 # 헬스 체크 curl -s http://127.0.0.1:7700/health # 기대 응답: {"status":"available"}

팀 공유용으로는 도커 컴포즈가 편하다. 아래 파일을 프로젝트 루트에 두고 docker compose up -d 하면 동일 환경이 재현된다.


docker-compose.yml — 로컬 개발 환경
services: meilisearch: image: getmeili/meilisearch:v1.11 ports: - "7700:7700" environment: MEILI_ENV: development MEILI_MASTER_KEY: dev-master-key-change-me-32chars!! MEILI_NO_ANALYTICS: "true" volumes: - ./meili_data:/meili_data restart: unless-stopped

주의: MEILI_ENV=production 이면 마스터 키 없이 기동이 거부된다. 개발 중에는 development, 배포 시에는 production + 강한 마스터 키를 쓴다. 마스터 키는 클라이언트(브라우저)에 절대 노출하지 말고, 서버 사이드에서만 사용한다.


인덱스 만들고 문서 넣기

인덱스는 검색 단위다. 상품 검색이면 products, 문서 검색이면 docs처럼 목적별로 나눈다. primary key는 문서 고유 ID 필드 이름이다, 생략하면 메이리서치가 id 등을 추론한다. 명확히 지정하는 편이 안전하다.


문서는 JSON 배열로 일괄 업로드한다. 대량이면 배치를 나누고, 비동기 태스크(enqueued → processing → succeeded) 상태를 폴링한다.


curl — 인덱스 생성과 문서 업로드
export MEILI_HOST=http://127.0.0.1:7700 export MEILI_KEY='dev-master-key-change-me-32chars!!' # 1) 인덱스 생성 (primaryKey: id) curl -s -X POST "$MEILI_HOST/indexes" \ -H "Authorization: Bearer $MEILI_KEY" \ -H "Content-Type: application/json" \ --data-binary '{"uid":"products","primaryKey":"id"}' # 2) 문서 업로드 (비동기 task) curl -s -X POST "$MEILI_HOST/indexes/products/documents" \ -H "Authorization: Bearer $MEILI_KEY" \ -H "Content-Type: application/json" \ --data-binary '[ {"id":1,"name":"울트라북 14","brand":"Acme","price":1290000,"category":"laptop","stock":12}, {"id":2,"name":"기계식 키보드","brand":"KeyLab","price":189000,"category":"peripheral","stock":40}, {"id":3,"name":"27인치 모니터","brand":"ViewMax","price":420000,"category":"display","stock":8} ]' # 3) 간단한 검색 curl -s -X POST "$MEILI_HOST/indexes/products/search" \ -H "Authorization: Bearer $MEILI_KEY" \ -H "Content-Type: application/json" \ --data-binary '{"q":"노트북"}'

한글 문서도 기본 토크나이저로 검색된다. "노트북"과 "울트라북"이 동의어로 잡히지 않으면 뒤에서 다루는 동의어(synonyms) 설정으로 연결한다. 업로드 직후 검색이 비면 태스크가 아직 processing 중일 수 있으니 /tasks API로 상태를 확인한다.


검색 인덱스에 문서를 넣고 쿼리하는 흐름
문서 업로드는 비동기 태스크로 처리되고, 완료 후 검색 인덱스에 반영된다

Node.js SDK로 클라이언트 붙이기

공식 패키지 meilisearch 를 쓰면 타입스크립트 친화적으로 인덱스·검색을 다룰 수 있다. 서버 전용 모듈에 클라이언트를 한 번만 만들고, API 라우트·백그라운드 잡에서 재사용한다.


npm 설치 및 클라이언트 초기화
npm install meilisearch # lib/meili.js import { MeiliSearch } from 'meilisearch' const host = process.env.MEILI_HOST || 'http://127.0.0.1:7700' const apiKey = process.env.MEILI_MASTER_KEY if (!apiKey) { throw new Error('MEILI_MASTER_KEY is required') } export const meili = new MeiliSearch({ host, apiKey }) export const productsIndex = meili.index('products')
문서 동기화 헬퍼 — DB 변경 후 인덱스 반영
// lib/sync-product.js import { productsIndex } from './meili.js' /** * 상품 생성·수정 후 호출. 삭제 시 deleteDocument(id). * 실패해도 주문 플로우는 막지 말고, 재시도 큐에 넣는 편이 안전하다. */ export async function upsertProductDocument(product) { const doc = { id: product.id, name: product.name, brand: product.brand, price: product.price, category: product.category, stock: product.stock, description: product.description || '', updatedAt: product.updatedAt || Date.now(), } const task = await productsIndex.addDocuments([doc]) return task // { taskUid, status, ... } } export async function removeProductDocument(id) { return productsIndex.deleteDocument(id) }

DB가 진실 공급원(source of truth)이고, 메이리서치는 파생 인덱스라는 점을 기억하자. 상품 생성·수정·삭제 트랜잭션이 끝난 직후 동기화 함수를 호출하거나, 아웃박스 패턴으로 비동기 반영한다. 동기화가 한두 번 실패해도 주문이 깨지지 않게 설계하는 것이 핵심이다.


검색·필터·정렬 실전 패턴

검색 품질은 "어떤 필드를 검색·필터·정렬 가능하게 열었는가"에서 갈린다. 기본값만 쓰면 필터 쿼리가 거절된다. 인덱스 설정에서 filterableAttributes·sortableAttributes를 먼저 선언한다.


인덱스 설정 + 필터 검색 예시
import { productsIndex } from './meili.js' // 1) 한 번만 설정 (배포 스크립트 또는 마이그레이션) await productsIndex.updateSettings({ searchableAttributes: ['name', 'brand', 'description'], filterableAttributes: ['category', 'brand', 'price', 'stock'], sortableAttributes: ['price', 'stock', 'updatedAt'], displayedAttributes: ['id', 'name', 'brand', 'price', 'category', 'stock'], }) // 2) 검색 API export async function searchProducts({ q, category, maxPrice, page = 1 }) { const limit = 20 const offset = (page - 1) * limit const filters = [] if (category) filters.push(`category = "${category}"`) if (typeof maxPrice === 'number') filters.push(`price <= ${maxPrice}`) const result = await productsIndex.search(q || '', { filter: filters.length ? filters.join(' AND ') : undefined, sort: ['price:asc'], limit, offset, attributesToHighlight: ['name', 'description'], }) return { hits: result.hits, estimatedTotalHits: result.estimatedTotalHits, processingTimeMs: result.processingTimeMs, } }

필터 문법은 SQL과 비슷하다. AND/OR, 비교 연산, 배열 필드 소속을 지원한다. 페이지네이션은 offset+limit 또는 page 파라미터를 쓴다. 하이라이트 필드를 켜면 UI에서 매칭 구간을 강조할 수 있다.


빈 쿼리(q: '') + 필터만으로 "카테고리 전체 목록 정렬"도 가능하다. 카탈로그 브라우징과 검색을 한 엔진으로 묶을 때 유용하다.


랭킹 규칙과 동의어 — 검색 품질 다듬기

기본 랭킹도 쓸 만하지만, 이커머스·콘텐츠 사이트는 동의어와 커스텀 랭킹 규칙을 손보면 체감이 확 달라진다. 예를 들어 "노트북" 검색에 "울트라북·랩탑"을 묶고, 재고 있는 상품·신상품을 살짝 위로 올린다.


동의어·랭킹 규칙 업데이트
import { productsIndex } from './meili.js' await productsIndex.updateSettings({ synonyms: { 노트북: ['울트라북', '랩탑', 'laptop', 'notebook'], 키보드: ['keyboard', '기계식'], }, // 왼쪽일수록 우선순위 높음. 기본 규칙을 복사한 뒤 커스텀 필드를 끼운다. rankingRules: [ 'words', 'typo', 'proximity', 'attribute', 'sort', 'exactness', 'stock:desc', // 재고 많은 상품 우선 (예시) ], typoTolerance: { enabled: true, minWordSizeForTypos: { oneTypo: 4, twoTypos: 8, }, }, stopWords: ['및', '의', '를', '을', 'the', 'a'], })

팁: 랭킹 규칙을 자주 바꾸면 A/B로 검색 클릭률을 비교하는 것이 좋다. 커스텀 규칙(stock:desc 등)을 너무 앞에 두면 관련도가 무너질 수 있다. 보통 words·typo·proximity 뒤에 두는 편이 안전하다.


Next.js App Router 연동 — API 라우트 패턴

브라우저에 마스터 키를 넣으면 안 된다. 검색 요청은 서버 API 라우트가 받아서 메이리서치에 프록시한다. 검색 전용 API 키를 별도로 발급하면 권한을 더 좁힐 수 있다.


app/api/search/route.js — 서버 프록시 검색
import { NextResponse } from 'next/server' import { productsIndex } from '@/lib/meili' export async function GET(request) { const { searchParams } = new URL(request.url) const q = (searchParams.get('q') || '').slice(0, 80) const category = searchParams.get('category') const page = Math.max(1, Number(searchParams.get('page') || 1)) const filters = [] if (category) { // 단순 화이트리스트 검증 예시 const allowed = new Set(['laptop', 'peripheral', 'display']) if (!allowed.has(category)) { return NextResponse.json({ error: 'invalid category' }, { status: 400 }) } filters.push(`category = "${category}"`) } const result = await productsIndex.search(q, { filter: filters.length ? filters.join(' AND ') : undefined, limit: 20, offset: (page - 1) * 20, attributesToHighlight: ['name'], }) return NextResponse.json({ hits: result.hits, estimatedTotalHits: result.estimatedTotalHits, processingTimeMs: result.processingTimeMs, }) }
클라이언트 — 디바운스 검색 호출
// components/ProductSearch.jsx (요약) 'use client' import { useEffect, useState } from 'react' export function ProductSearch() { const [q, setQ] = useState('') const [hits, setHits] = useState([]) useEffect(() => { const t = setTimeout(async () => { const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`) const data = await res.json() setHits(data.hits || []) }, 200) // 200ms 디바운스 return () => clearTimeout(t) }, [q]) return ( <div> <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="상품 검색" aria-label="상품 검색" /> <ul> {hits.map((h) => ( <li key={h.id}>{h.name} — {h.price.toLocaleString()}원</li> ))} </ul> </div> ) }

디바운스 150~300ms면 타이핑 중 요청 폭주를 줄이면서도 즉각 반응하는 느낌을 유지할 수 있다. 결과가 없을 때는 "검색어를 줄여보세요", "카테고리 필터를 해제해보세요" 같은 안내를 붙이면 이탈이 줄어든다.


서버 컴포넌트에서 초기 검색을 넣고 싶다면 같은 productsIndex.search 를 서버에서 직접 호출하면 된다. 다만 마스터 키는 서버 환경 변수에만 둔다.


웹 앱에서 디바운스 검색 UI 연동 개념
브라우저 → Next API 라우트 → 메이리서치. 키는 서버에만 두고 검색 결과 JSON만 내려준다

API 키 분리와 보안 체크리스트

마스터 키는 인덱스 삭제·키 발급까지 가능한 최고 권한이다. 프로덕션에서는 용도별 키를 만든다.


  • 검색 키(search) — 프론트 프록시 API에서 검색만
  • 인덱싱 키 — 백오피스·워커에서 문서 추가·삭제
  • 마스터 키 — 배포·마이그레이션 스크립트 전용, 런타임 앱과 분리

키 생성 예시는 아래와 같다. 응답의 key 값은 한 번만 보이므로 시크릿 저장소에 바로 넣는다.


검색 전용 API 키 생성
curl -s -X POST "http://127.0.0.1:7700/keys" \ -H "Authorization: Bearer $MEILI_KEY" \ -H "Content-Type: application/json" \ --data-binary '{ "name": "search-only", "description": "Next.js API route search proxy", "actions": ["search"], "indexes": ["products"], "expiresAt": null }'

추가 보안 포인트:


  • 메이리서치 포트를 퍼블릭에 직접 열지 말고, 앱 서버 프라이빗 네트워크 또는 리버스 프록시 뒤에서만 접근
  • HTTPS 종단은 프록시(예: 캐디·엔진엑스)에서 처리
  • 검색 쿼리 길이·요청 rate limit을 API 라우트에서 제한
  • 멀티 테넌트면 필터에 tenantId = "..." 를 서버가 강제 삽입 (클라이언트가 넘긴 tenantId를 신뢰하지 말 것)

프로덕션 배포 체크포인트

로컬에서 잘 되던 검색이 배포 후 비는 경우는 대부분 (1) 환경 변수 누락 (2) 인덱스 설정 미적용 (3) 문서 동기화 파이프라인 미실행이다. 체크리스트로 배포 전에 고정한다.


  • MEILI_ENV=production + 32자 이상 랜덤 마스터 키
  • 영구 볼륨에 /meili_data 마운트 (컨테이너 재생성 시 인덱스 유실 방지)
  • 배포 파이프라인에 updateSettings 마이그레이션 단계 포함
  • 기존 DB 스냅샷 → 초기 인덱싱 배치 잡 1회 실행
  • 헬스체크 URL /health 를 오케스트레이터에 연결
  • 스냅샷·덤프 주기 백업 (공식 dump/snapshot API)
  • 메모리: 문서 수·필드 길이에 비례해 증가. 작은 VPS면 스왑·인스턴스 크기 여유 확보

호스팅 선택지는 셀프호스팅(도커·쿠버네티스)과 메이리서치 클라우드가 있다. 트래픽·규정(데이터 위치)에 따라 고른다. 개인정보·내부 문서가 많으면 셀프호스팅 + 프라이빗 네트워크가 기본이다.


DB와 검색 인덱스가 어긋나는 드리프트를 막으려면 주기적 전체 리인덱스 잡(야간)과, 변경 이벤트 기반 증분 업데이트를 함께 쓰는 구성이 실무에서 잘 버틴다.


자주 막히는 케이스 4가지

1) filterable 필드가 아니라고 거절
설정을 안 열었거나, 설정 태스크가 아직 처리 중이다. updateSettings 후 태스크 succeeded를 확인하고 재시도한다.


2) 문서 넣었는데 검색이 비어 있음
primary key 충돌·잘못된 인덱스 uid·태스크 실패를 의심한다. GET /tasks/{uid} 로 에러 메시지를 본다.


3) 한글 검색이 기대와 다름
동의어·stopWords·searchableAttributes 순서를 조정한다. 필드 우선순위는 searchableAttributes 배열 순서가 영향을 준다.


4) 프로덕션에서 401/403
Bearer 토큰 누락, 잘못된 키, 검색 키로 설정 변경 시도 등이 원인이다. 마스터 키와 검색 키를 환경 변수 이름부터 분리한다.


참고 자료


버전은 배포 시점에 공식 도커 태그를 확인한 뒤 고정하는 것을 권장한다. 이 글의 예시는 v1.11 계열을 기준으로 했다.


자주 묻는 질문

메이리서치와 일래스틱서치 중 무엇을 고를까?

제품·사이트 내 검색, 운영 인력 1~2명, 빠른 설치가 목표면 메이리서치가 적합하다. 대규모 로그 분석, 복잡한 집계·파이프라인, 기존 ELK 스택이 있으면 일래스틱서치·오픈서치가 맞다. 둘을 혼용하는 팀도 있다(앱 검색 vs 관측).


알고리아 대신 셀프호스팅하는 이유는?

알고리아는 관리형 품질·속도가 뛰어나지만 레코드·검색 수 과금이 부담될 수 있다. 데이터 주권·비용 예측·온프레 요구가 있으면 메이리서치 셀프호스팅이 유리하다. 반대로 인프라를 전혀 만지기 싫으면 알고리아·메이리 클라우드 같은 관리형이 낫다.


PostgreSQL full-text search만으로 부족한가?

단순 키워드·언어 설정이 고정된 내부 도구는 PG FTS로 충분한 경우가 많다. 오타 허용, 타이핑 즉시 검색, 동의어·랭킹 튜닝, 프론트 하이라이트가 필요해지면 전용 엔진이 생산성이 높다. 트래픽이 커질수록 LIKE/FTS 부하를 검색 엔진으로 분리하는 편이 안전하다.


인덱스와 DB를 어떻게 동기화하나?

DB 커밋 후 증분 업서트, 실패 시 재시도 큐, 야간 전체 리인덱스를 기본 세트로 둔다. 이벤트 소싱·CDC가 있으면 그 스트림을 소비해 문서를 갱신한다. 검색 결과가 "조금 늦은" 것은 허용하고, 주문·결제 같은 정합성이 중요한 흐름은 DB를 직접 보게 한다.


멀티 테넌트 SaaS에서 데이터 격리 방법은?

테넌트마다 인덱스를 나누거나, 단일 인덱스에 tenantId 필터를 서버가 강제하는 방식이 일반적이다. 클라이언트가 보낸 tenantId를 그대로 믿지 말고, 세션·JWT의 테넌트 클레임으로 필터를 합성한다. 테넌트 수·문서 규모에 따라 인덱스 분리 비용을 저울질한다.


한글 형태소 분석기가 별도로 필요한가?

많은 제품 검색 시나리오는 기본 설정 + 동의어 + searchable 필드 설계로 충분히 시작한다. 법률·의료처럼 전문 용어 분해가 중요하면 토크나이징·정규화 전략을 문서화하고 테스트 쿼리 세트를 만든다. 무조건 외부 형태소 분석기를 붙이기보다, 실제 실패 쿼리 로그를 보고 보강하는 편이 효율적이다.


Meilisearch메이리서치검색 엔진DockerNode.jsNext.js오픈소스APISDK풀텍스트 검색

관련 도구

함께 보면 좋은 문제 해결

EXPLORE / Open Source

이어서 읽어보기

전체 토픽 둘러보기