From 346d6529f2d21194c0f27137458bec8c6e11efe1 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 6 Jan 2026 08:22:43 +0900 Subject: [PATCH] =?UTF-8?q?Meilisearch=20=EA=B2=80=EC=83=89=20=EC=97=94?= =?UTF-8?q?=EC=A7=84=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docker Compose에 Meilisearch 서비스 추가 - meilisearch.js 서비스 생성 (초기화, CRUD, 검색) - 공개 일정 API에 Meilisearch 검색 통합 - 일정 생성/삭제 시 Meilisearch 자동 동기화 - YouTube 봇 일정 추가 시 Meilisearch 동기화 - sync-search API 추가 (기존 데이터 일괄 동기화) - 다중 키워드, 오타 허용, 유사어 검색 지원 --- .env | 1 + .gitignore | 1 + backend/package-lock.json | 7 ++ backend/package.json | 1 + backend/routes/admin.js | 39 ++++++++ backend/routes/schedules.js | 46 ++++++++- backend/server.js | 9 ++ backend/services/meilisearch.js | 163 ++++++++++++++++++++++++++++++++ backend/services/youtube-bot.js | 28 ++++++ docker-compose.yml | 11 +++ 10 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 backend/services/meilisearch.js diff --git a/.env b/.env index 1f4d772..a4b8cb0 100644 --- a/.env +++ b/.env @@ -22,3 +22,4 @@ KAKAO_REST_KEY=cf21156ae6cc8f6e95b3a3150926cdf8 # YouTube API YOUTUBE_API_KEY=AIzaSyC6l3nFlcHgLc0d1Q9WPyYQjVKTv21ZqFs +MEILI_MASTER_KEY=xMLNzlGX4xYji494JOb5IMlLHULcYw91 diff --git a/.gitignore b/.gitignore index e0755fe..c641f65 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ Thumbs.db # Build dist/ build/ +meilisearch_data/ diff --git a/backend/package-lock.json b/backend/package-lock.json index 652a0c3..e3780da 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "bcrypt": "^6.0.0", "express": "^4.18.2", "jsonwebtoken": "^9.0.3", + "meilisearch": "^0.55.0", "multer": "^1.4.5-lts.1", "mysql2": "^3.11.0", "node-cron": "^4.2.1", @@ -2781,6 +2782,12 @@ "node": ">= 0.6" } }, + "node_modules/meilisearch": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.55.0.tgz", + "integrity": "sha512-qSMeiezfDgIqciIeYzh5E4pXDZZD7CtHeWDCs43kN3trLgl5FtfmBAIkljL3huFaOx08feYtC8FfIFUpVwq6rg==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index 1b1b676..7d332ab 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,7 @@ "bcrypt": "^6.0.0", "express": "^4.18.2", "jsonwebtoken": "^9.0.3", + "meilisearch": "^0.55.0", "multer": "^1.4.5-lts.1", "mysql2": "^3.11.0", "node-cron": "^4.2.1", diff --git a/backend/routes/admin.js b/backend/routes/admin.js index ca6b513..8249392 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -11,6 +11,10 @@ import { import pool from "../lib/db.js"; import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.js"; import { startBot, stopBot } from "../services/youtube-scheduler.js"; +import { + addOrUpdateSchedule, + deleteSchedule as deleteScheduleFromSearch, +} from "../services/meilisearch.js"; const router = express.Router(); @@ -1321,6 +1325,33 @@ router.post( await connection.commit(); + // Meilisearch에 동기화 + try { + const [categoryInfo] = await pool.query( + "SELECT name, color FROM schedule_categories WHERE id = ?", + [category || null] + ); + const [memberInfo] = await pool.query( + "SELECT id, name FROM members WHERE id IN (?)", + [members?.length ? members : [0]] + ); + await addOrUpdateSchedule({ + id: scheduleId, + title, + description, + date, + time, + category_id: category, + category_name: categoryInfo[0]?.name || "", + category_color: categoryInfo[0]?.color || "", + source_name: sourceName, + source_url: url, + members: memberInfo, + }); + } catch (searchError) { + console.error("Meilisearch 동기화 오류:", searchError.message); + } + res.json({ message: "일정이 생성되었습니다.", scheduleId }); } catch (error) { await connection.rollback(); @@ -1650,6 +1681,14 @@ router.delete("/schedules/:id", authenticateToken, async (req, res) => { await connection.query("DELETE FROM schedules WHERE id = ?", [id]); await connection.commit(); + + // Meilisearch에서도 삭제 + try { + await deleteScheduleFromSearch(id); + } catch (searchError) { + console.error("Meilisearch 삭제 오류:", searchError.message); + } + res.json({ message: "일정이 삭제되었습니다." }); } catch (error) { await connection.rollback(); diff --git a/backend/routes/schedules.js b/backend/routes/schedules.js index efe619c..df9a473 100644 --- a/backend/routes/schedules.js +++ b/backend/routes/schedules.js @@ -1,11 +1,21 @@ import express from "express"; import pool from "../lib/db.js"; +import { searchSchedules } from "../services/meilisearch.js"; const router = express.Router(); -// 공개 일정 목록 조회 +// 공개 일정 목록 조회 (검색 포함) router.get("/", async (req, res) => { try { + const { search } = req.query; + + // 검색어가 있으면 Meilisearch 사용 + if (search && search.trim()) { + const results = await searchSchedules(search.trim()); + return res.json(results); + } + + // 검색어 없으면 DB에서 전체 조회 const [schedules] = await pool.query(` SELECT s.id, @@ -80,4 +90,38 @@ router.get("/:id", async (req, res) => { } }); +// Meilisearch 동기화 API +router.post("/sync-search", async (req, res) => { + try { + const { syncAllSchedules } = await import("../services/meilisearch.js"); + + // DB에서 모든 일정 조회 + const [schedules] = await pool.query(` + SELECT + s.id, + s.title, + s.description, + s.date, + s.time, + s.category_id, + s.source_url, + s.source_name, + c.name as category_name, + c.color as category_color, + GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ' ') as member_names + FROM schedules s + LEFT JOIN schedule_categories c ON s.category_id = c.id + LEFT JOIN schedule_members sm ON s.id = sm.schedule_id + LEFT JOIN members m ON sm.member_id = m.id + GROUP BY s.id + `); + + const count = await syncAllSchedules(schedules); + res.json({ success: true, synced: count }); + } catch (error) { + console.error("Meilisearch 동기화 오류:", error); + res.status(500).json({ error: "동기화 중 오류가 발생했습니다." }); + } +}); + export default router; diff --git a/backend/server.js b/backend/server.js index 49ab759..6742ce1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -7,6 +7,7 @@ import statsRouter from "./routes/stats.js"; import adminRouter from "./routes/admin.js"; import schedulesRouter from "./routes/schedules.js"; import { initScheduler } from "./services/youtube-scheduler.js"; +import { initMeilisearch } from "./services/meilisearch.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -44,6 +45,14 @@ app.get("*", (req, res) => { app.listen(PORT, async () => { console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`); + // Meilisearch 초기화 + try { + await initMeilisearch(); + console.log("🔍 Meilisearch 초기화 완료"); + } catch (error) { + console.error("Meilisearch 초기화 오류:", error); + } + // YouTube 봇 스케줄러 초기화 try { await initScheduler(); diff --git a/backend/services/meilisearch.js b/backend/services/meilisearch.js new file mode 100644 index 0000000..3b6e275 --- /dev/null +++ b/backend/services/meilisearch.js @@ -0,0 +1,163 @@ +import { MeiliSearch } from "meilisearch"; + +// Meilisearch 클라이언트 초기화 +const client = new MeiliSearch({ + host: "http://fromis9-meilisearch:7700", + apiKey: process.env.MEILI_MASTER_KEY, +}); + +const SCHEDULE_INDEX = "schedules"; + +/** + * 인덱스 초기화 및 설정 + */ +export async function initMeilisearch() { + try { + // 인덱스 생성 (이미 존재하면 무시) + await client.createIndex(SCHEDULE_INDEX, { primaryKey: "id" }); + + // 인덱스 설정 + const index = client.index(SCHEDULE_INDEX); + + // 검색 가능한 필드 설정 + await index.updateSearchableAttributes([ + "title", + "description", + "member_names", + "category_name", + "source_name", + ]); + + // 필터링 가능한 필드 설정 + await index.updateFilterableAttributes(["category_id", "date"]); + + // 정렬 가능한 필드 설정 + await index.updateSortableAttributes(["date", "time"]); + + // 오타 허용 설정 (typo tolerance) + await index.updateTypoTolerance({ + enabled: true, + minWordSizeForTypos: { + oneTypo: 2, + twoTypos: 4, + }, + }); + + console.log("[Meilisearch] 인덱스 초기화 완료"); + } catch (error) { + console.error("[Meilisearch] 초기화 오류:", error.message); + } +} + +/** + * 일정 문서 추가/업데이트 + */ +export async function addOrUpdateSchedule(schedule) { + try { + const index = client.index(SCHEDULE_INDEX); + + // 멤버 이름을 문자열로 변환 + const memberNames = schedule.members + ? schedule.members.map((m) => m.name).join(" ") + : ""; + + const document = { + id: schedule.id, + title: schedule.title, + description: schedule.description || "", + date: schedule.date, + time: schedule.time || "", + category_id: schedule.category_id, + category_name: schedule.category_name || "", + category_color: schedule.category_color || "", + source_name: schedule.source_name || "", + source_url: schedule.source_url || "", + member_names: memberNames, + }; + + await index.addDocuments([document]); + console.log(`[Meilisearch] 일정 추가/업데이트: ${schedule.id}`); + } catch (error) { + console.error("[Meilisearch] 문서 추가 오류:", error.message); + } +} + +/** + * 일정 문서 삭제 + */ +export async function deleteSchedule(scheduleId) { + try { + const index = client.index(SCHEDULE_INDEX); + await index.deleteDocument(scheduleId); + console.log(`[Meilisearch] 일정 삭제: ${scheduleId}`); + } catch (error) { + console.error("[Meilisearch] 문서 삭제 오류:", error.message); + } +} + +/** + * 일정 검색 + */ +export async function searchSchedules(query, options = {}) { + try { + const index = client.index(SCHEDULE_INDEX); + + const searchOptions = { + limit: options.limit || 50, + attributesToRetrieve: ["*"], + }; + + // 카테고리 필터 + if (options.categoryId) { + searchOptions.filter = `category_id = ${options.categoryId}`; + } + + // 정렬 (기본: 날짜 내림차순) + searchOptions.sort = options.sort || ["date:desc", "time:desc"]; + + const results = await index.search(query, searchOptions); + + return results.hits; + } catch (error) { + console.error("[Meilisearch] 검색 오류:", error.message); + return []; + } +} + +/** + * 모든 일정 동기화 (초기 데이터 로드용) + */ +export async function syncAllSchedules(schedules) { + try { + const index = client.index(SCHEDULE_INDEX); + + // 기존 문서 모두 삭제 + await index.deleteAllDocuments(); + + // 문서 변환 + const documents = schedules.map((schedule) => ({ + id: schedule.id, + title: schedule.title, + description: schedule.description || "", + date: schedule.date, + time: schedule.time || "", + category_id: schedule.category_id, + category_name: schedule.category_name || "", + category_color: schedule.category_color || "", + source_name: schedule.source_name || "", + source_url: schedule.source_url || "", + member_names: schedule.member_names || "", + })); + + // 일괄 추가 + await index.addDocuments(documents); + console.log(`[Meilisearch] ${documents.length}개 일정 동기화 완료`); + + return documents.length; + } catch (error) { + console.error("[Meilisearch] 동기화 오류:", error.message); + return 0; + } +} + +export { client }; diff --git a/backend/services/youtube-bot.js b/backend/services/youtube-bot.js index dafcdc1..2b33fed 100644 --- a/backend/services/youtube-bot.js +++ b/backend/services/youtube-bot.js @@ -1,5 +1,6 @@ import Parser from "rss-parser"; import pool from "../lib/db.js"; +import { addOrUpdateSchedule } from "./meilisearch.js"; // YouTube API 키 const YOUTUBE_API_KEY = @@ -293,6 +294,33 @@ export async function createScheduleFromVideo( ); } + // Meilisearch에 동기화 + try { + const [categoryInfo] = await pool.query( + "SELECT name, color FROM schedule_categories WHERE id = ?", + [categoryId] + ); + const [memberInfo] = await pool.query( + "SELECT id, name FROM members WHERE id IN (?)", + [memberIds.length > 0 ? [...new Set(memberIds)] : [0]] + ); + await addOrUpdateSchedule({ + id: scheduleId, + title: video.title, + description: "", + date: video.date, + time: video.time, + category_id: categoryId, + category_name: categoryInfo[0]?.name || "", + category_color: categoryInfo[0]?.color || "", + source_name: sourceName, + source_url: video.videoUrl, + members: memberInfo, + }); + } catch (searchError) { + console.error("Meilisearch 동기화 오류:", searchError.message); + } + return scheduleId; } catch (error) { console.error("일정 생성 오류:", error); diff --git a/docker-compose.yml b/docker-compose.yml index d4b7e29..2b8ca96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,17 @@ services: - db restart: unless-stopped + meilisearch: + image: getmeili/meilisearch:v1.6 + container_name: fromis9-meilisearch + environment: + - MEILI_MASTER_KEY=${MEILI_MASTER_KEY} + volumes: + - ./meilisearch_data:/meili_data + networks: + - app + restart: unless-stopped + networks: app: external: true