From 727b05f0f5b6e8a2049d7b7e921c673124e5f8e6 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sun, 11 Jan 2026 21:33:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(Search):=20Redis=20=EA=B8=B0=EB=B0=98=20bi?= =?UTF-8?q?-gram=20=EC=B6=94=EC=B2=9C=20=EA=B2=80=EC=83=89=EC=96=B4=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MariaDB 테이블 추가 (search_queries, word_pairs) - Redis 컨테이너 추가 (Sorted Set 캐싱) - 백엔드 suggestions 서비스 및 API 구현 - 검색 실행 시 검색어 저장 (bi-gram 학습) - PC Schedule 프론트엔드 연동 완료 --- backend/lib/redis.js | 19 ++ backend/package-lock.json | 102 ++++++++++ backend/package.json | 3 +- backend/routes/schedules.js | 25 +++ backend/services/suggestions.js | 178 ++++++++++++++++++ docker-compose.dev.yml | 10 + frontend/src/pages/pc/admin/AdminSchedule.jsx | 2 + frontend/src/pages/pc/public/Schedule.jsx | 81 +++++--- 8 files changed, 389 insertions(+), 31 deletions(-) create mode 100644 backend/lib/redis.js create mode 100644 backend/services/suggestions.js diff --git a/backend/lib/redis.js b/backend/lib/redis.js new file mode 100644 index 0000000..195f4c7 --- /dev/null +++ b/backend/lib/redis.js @@ -0,0 +1,19 @@ +import Redis from "ioredis"; + +// Redis 클라이언트 초기화 +const redis = new Redis({ + host: "fromis9-redis", + port: 6379, + retryDelayOnFailover: 100, + maxRetriesPerRequest: 3, +}); + +redis.on("connect", () => { + console.log("[Redis] 연결 성공"); +}); + +redis.on("error", (err) => { + console.error("[Redis] 연결 오류:", err.message); +}); + +export default redis; diff --git a/backend/package-lock.json b/backend/package-lock.json index d8bb465..c1ebba6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "dayjs": "^1.11.19", "express": "^4.18.2", "inko": "^1.1.1", + "ioredis": "^5.4.0", "jsonwebtoken": "^9.0.3", "meilisearch": "^0.55.0", "multer": "^1.4.5-lts.1", @@ -1291,6 +1292,12 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, "node_modules/@smithy/abort-controller": { "version": "4.2.7", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.7.tgz", @@ -2184,6 +2191,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -2759,6 +2775,53 @@ "node": ">= 0.4.0" } }, + "node_modules/ioredis": { + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.1.tgz", + "integrity": "sha512-BXNqFQ66oOsR82g9ajFFsR8ZKrjVvYCLyeML9IvSMAsP56XH2VXBdZjmI11p65nXXJxTEt1hie3J2QeFJVgrtQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2835,12 +2898,24 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -3316,6 +3391,27 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/rss-parser": { "version": "3.13.0", "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz", @@ -3555,6 +3651,12 @@ "node": ">= 0.6" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 2161b9d..97b66ab 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ "dayjs": "^1.11.19", "express": "^4.18.2", "inko": "^1.1.1", + "ioredis": "^5.4.0", "jsonwebtoken": "^9.0.3", "meilisearch": "^0.55.0", "multer": "^1.4.5-lts.1", @@ -20,4 +21,4 @@ "rss-parser": "^3.13.0", "sharp": "^0.33.5" } -} +} \ No newline at end of file diff --git a/backend/routes/schedules.js b/backend/routes/schedules.js index b3e291e..d355bde 100644 --- a/backend/routes/schedules.js +++ b/backend/routes/schedules.js @@ -1,9 +1,27 @@ import express from "express"; import pool from "../lib/db.js"; import { searchSchedules } from "../services/meilisearch.js"; +import { saveSearchQuery, getSuggestions } from "../services/suggestions.js"; const router = express.Router(); +// 검색어 추천 API (Bi-gram 기반) +router.get("/suggestions", async (req, res) => { + try { + const { q, limit } = req.query; + + if (!q || q.trim().length === 0) { + return res.json({ suggestions: [] }); + } + + const suggestions = await getSuggestions(q, parseInt(limit) || 10); + res.json({ suggestions }); + } catch (error) { + console.error("추천 검색어 오류:", error); + res.status(500).json({ error: "추천 검색어 조회 중 오류가 발생했습니다." }); + } +}); + // 공개 일정 목록 조회 (검색 포함) router.get("/", async (req, res) => { try { @@ -14,6 +32,13 @@ router.get("/", async (req, res) => { const offset = parseInt(req.query.offset) || 0; const pageLimit = parseInt(req.query.limit) || 100; + // 첫 페이지 검색 시에만 검색어 저장 (bi-gram 학습) + if (offset === 0) { + saveSearchQuery(search.trim()).catch((err) => + console.error("검색어 저장 실패:", err.message) + ); + } + // Meilisearch에서 큰 limit으로 검색 (유사도 필터링 후 클라이언트 페이징) const results = await searchSchedules(search.trim(), { limit: 1000, // 내부적으로 1000개까지 검색 diff --git a/backend/services/suggestions.js b/backend/services/suggestions.js new file mode 100644 index 0000000..38b393d --- /dev/null +++ b/backend/services/suggestions.js @@ -0,0 +1,178 @@ +import pool from "../lib/db.js"; +import redis from "../lib/redis.js"; +import Inko from "inko"; + +const inko = new Inko(); + +// Redis 키 prefix +const SUGGESTION_PREFIX = "suggestions:"; +const CACHE_TTL = 86400; // 24시간 + +/** + * 영문 자판으로 입력된 검색어인지 확인 + */ +function isEnglishKeyboard(text) { + const englishChars = text.match(/[a-zA-Z]/g) || []; + const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []; + return englishChars.length > 0 && koreanChars.length === 0; +} + +/** + * 검색어 저장 (검색 실행 시 호출) + * - search_queries 테이블에 Unigram 저장 + * - word_pairs 테이블에 Bi-gram 저장 + * - Redis 캐시 업데이트 + */ +export async function saveSearchQuery(query) { + if (!query || query.trim().length === 0) return; + + const normalizedQuery = query.trim().toLowerCase(); + + try { + // 1. Unigram 저장 (인기도) + await pool.query( + `INSERT INTO search_queries (query, count) + VALUES (?, 1) + ON DUPLICATE KEY UPDATE count = count + 1, last_searched_at = CURRENT_TIMESTAMP`, + [normalizedQuery] + ); + + // 2. Bi-gram 저장 (다음 단어 예측) + const words = normalizedQuery.split(/\s+/).filter((w) => w.length > 0); + for (let i = 0; i < words.length - 1; i++) { + const word1 = words[i]; + const word2 = words[i + 1]; + + // DB 저장 + await pool.query( + `INSERT INTO word_pairs (word1, word2, count) + VALUES (?, ?, 1) + ON DUPLICATE KEY UPDATE count = count + 1`, + [word1, word2] + ); + + // Redis 캐시 업데이트 (Sorted Set) + await redis.zincrby(`${SUGGESTION_PREFIX}${word1}`, 1, word2); + } + + console.log(`[SearchSuggestion] 검색어 저장: "${normalizedQuery}"`); + } catch (error) { + console.error("[SearchSuggestion] 검색어 저장 오류:", error.message); + } +} + +/** + * 추천 검색어 조회 + * - 입력이 공백으로 끝나면: 마지막 단어 기반 다음 단어 예측 (Bi-gram) + * - 그 외: prefix 매칭 (인기순) + */ +export async function getSuggestions(query, limit = 10) { + if (!query || query.trim().length === 0) { + return []; + } + + let searchQuery = query.toLowerCase(); + + // 영문 자판 -> 한글 변환 시도 + if (isEnglishKeyboard(searchQuery)) { + const koreanQuery = inko.en2ko(searchQuery); + if (koreanQuery !== searchQuery) { + searchQuery = koreanQuery; + } + } + + try { + const endsWithSpace = query.endsWith(" "); + const words = searchQuery + .trim() + .split(/\s+/) + .filter((w) => w.length > 0); + + if (endsWithSpace && words.length > 0) { + // 다음 단어 예측 (Bi-gram) + const lastWord = words[words.length - 1]; + return await getNextWordSuggestions(lastWord, searchQuery.trim(), limit); + } else { + // prefix 매칭 (인기순) + return await getPrefixSuggestions(searchQuery.trim(), limit); + } + } catch (error) { + console.error("[SearchSuggestion] 추천 조회 오류:", error.message); + return []; + } +} + +/** + * 다음 단어 예측 (Bi-gram 기반) + */ +async function getNextWordSuggestions(lastWord, prefix, limit) { + try { + // 1. Redis 캐시 확인 + const cacheKey = `${SUGGESTION_PREFIX}${lastWord}`; + let nextWords = await redis.zrevrange(cacheKey, 0, limit - 1); + + // 2. 캐시 미스 시 DB 조회 후 Redis 채우기 + if (nextWords.length === 0) { + const [rows] = await pool.query( + `SELECT word2, count FROM word_pairs + WHERE word1 = ? + ORDER BY count DESC + LIMIT ?`, + [lastWord, limit * 2] // 여유있게 가져오기 + ); + + if (rows.length > 0) { + // Redis에 캐싱 + const multi = redis.multi(); + for (const row of rows) { + multi.zadd(cacheKey, row.count, row.word2); + } + multi.expire(cacheKey, CACHE_TTL); + await multi.exec(); + + nextWords = rows.map((r) => r.word2); + } + } + + // 3. prefix + 다음 단어 조합으로 반환 + return nextWords.slice(0, limit).map((word) => `${prefix} ${word}`); + } catch (error) { + console.error("[SearchSuggestion] Bi-gram 조회 오류:", error.message); + return []; + } +} + +/** + * Prefix 매칭 (인기순) + */ +async function getPrefixSuggestions(prefix, limit) { + try { + const [rows] = await pool.query( + `SELECT query FROM search_queries + WHERE query LIKE ? + ORDER BY count DESC, last_searched_at DESC + LIMIT ?`, + [`${prefix}%`, limit] + ); + + return rows.map((r) => r.query); + } catch (error) { + console.error("[SearchSuggestion] Prefix 조회 오류:", error.message); + return []; + } +} + +/** + * Redis 캐시 초기화 (필요시) + */ +export async function clearSuggestionCache() { + try { + const keys = await redis.keys(`${SUGGESTION_PREFIX}*`); + if (keys.length > 0) { + await redis.del(...keys); + console.log(`[SearchSuggestion] ${keys.length}개 캐시 삭제`); + } + } catch (error) { + console.error("[SearchSuggestion] 캐시 초기화 오류:", error.message); + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1e18a82..092d90b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -43,6 +43,16 @@ services: - app restart: unless-stopped + # Redis - 추천 검색어 캐시 + redis: + image: redis:7-alpine + container_name: fromis9-redis + volumes: + - ./redis_data:/data + networks: + - app + restart: unless-stopped + networks: app: external: true diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx index 77c2e14..5e9df68 100644 --- a/frontend/src/pages/pc/admin/AdminSchedule.jsx +++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx @@ -158,6 +158,8 @@ function AdminSchedule() { const [showSuggestions, setShowSuggestions] = useState(false); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const [originalSearchQuery, setOriginalSearchQuery] = useState(''); // 필터링용 원본 쿼리 + const [suggestions, setSuggestions] = useState([]); // 추천 검색어 목록 + const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); const SEARCH_LIMIT = 20; // 페이지당 20개 const ESTIMATED_ITEM_HEIGHT = 100; // 아이템 추정 높이 (동적 측정) diff --git a/frontend/src/pages/pc/public/Schedule.jsx b/frontend/src/pages/pc/public/Schedule.jsx index 7705518..7572644 100644 --- a/frontend/src/pages/pc/public/Schedule.jsx +++ b/frontend/src/pages/pc/public/Schedule.jsx @@ -46,6 +46,8 @@ function Schedule() { const [searchTerm, setSearchTerm] = useState(''); const [showSuggestions, setShowSuggestions] = useState(false); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); + const [suggestions, setSuggestions] = useState([]); // 추천 검색어 목록 + const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); const SEARCH_LIMIT = 20; // 페이지당 20개 const ESTIMATED_ITEM_HEIGHT = 120; // 아이템 추정 높이 (동적 측정) @@ -102,6 +104,34 @@ function Schedule() { prevInViewRef.current = inView; }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); + // 검색어 자동완성 API 호출 (debounce 적용) + useEffect(() => { + // 검색어가 비어있으면 초기화 + if (!originalSearchQuery || originalSearchQuery.trim().length === 0) { + setSuggestions([]); + return; + } + + // debounce: 200ms 후에 API 호출 + const timeoutId = setTimeout(async () => { + setIsLoadingSuggestions(true); + try { + const response = await fetch(`/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10`); + if (response.ok) { + const data = await response.json(); + setSuggestions(data.suggestions || []); + } + } catch (error) { + console.error('추천 검색어 API 오류:', error); + setSuggestions([]); + } finally { + setIsLoadingSuggestions(false); + } + }, 200); + + return () => clearTimeout(timeoutId); + }, [originalSearchQuery]); + // 데이터 로드 // 초기 데이터 로드 (카테고리만) useEffect(() => { @@ -752,33 +782,28 @@ function Schedule() { }} onFocus={() => setShowSuggestions(true)} onKeyDown={(e) => { - // 필터링은 원본 쿼리 기준으로 유지 - const dummySuggestions = ['성수기', '성수기 이채영', '이채영 먹방', 'NOW TOMORROW', '하얀 그리움', '콘서트', '월드투어'].filter(s => - s.toLowerCase().includes(originalSearchQuery.toLowerCase()) - ).slice(0, 7); - if (e.key === 'ArrowDown') { e.preventDefault(); - const newIndex = selectedSuggestionIndex < dummySuggestions.length - 1 + const newIndex = selectedSuggestionIndex < suggestions.length - 1 ? selectedSuggestionIndex + 1 : 0; setSelectedSuggestionIndex(newIndex); - if (dummySuggestions[newIndex]) { - setSearchInput(dummySuggestions[newIndex]); + if (suggestions[newIndex]) { + setSearchInput(suggestions[newIndex]); } } else if (e.key === 'ArrowUp') { e.preventDefault(); const newIndex = selectedSuggestionIndex > 0 ? selectedSuggestionIndex - 1 - : dummySuggestions.length - 1; + : suggestions.length - 1; setSelectedSuggestionIndex(newIndex); - if (dummySuggestions[newIndex]) { - setSearchInput(dummySuggestions[newIndex]); + if (suggestions[newIndex]) { + setSearchInput(suggestions[newIndex]); } } else if (e.key === 'Enter') { - if (selectedSuggestionIndex >= 0 && dummySuggestions[selectedSuggestionIndex]) { - setSearchInput(dummySuggestions[selectedSuggestionIndex]); - setSearchTerm(dummySuggestions[selectedSuggestionIndex]); + if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) { + setSearchInput(suggestions[selectedSuggestionIndex]); + setSearchTerm(suggestions[selectedSuggestionIndex]); } else if (searchInput.trim()) { setSearchTerm(searchInput); } @@ -831,20 +856,16 @@ function Schedule() { {/* 검색어 추천 드롭다운 */} {showSuggestions && originalSearchQuery.length > 0 && (
- {(() => { - const dummySuggestions = ['성수기', '성수기 이채영', '이채영 먹방', 'NOW TOMORROW', '하얀 그리움', '콘서트', '월드투어'].filter(s => - s.toLowerCase().includes(originalSearchQuery.toLowerCase()) - ).slice(0, 7); - - if (dummySuggestions.length === 0) { - return ( -
- 추천 검색어가 없습니다 -
- ); - } - - return dummySuggestions.map((suggestion, index) => ( + {isLoadingSuggestions ? ( +
+ 검색 중... +
+ ) : suggestions.length === 0 ? ( +
+ 추천 검색어가 없습니다 +
+ ) : ( + suggestions.map((suggestion, index) => ( - )); - })()} + )) + )}
)}