feat(Search): Redis 기반 bi-gram 추천 검색어 시스템 구현

- MariaDB 테이블 추가 (search_queries, word_pairs)
- Redis 컨테이너 추가 (Sorted Set 캐싱)
- 백엔드 suggestions 서비스 및 API 구현
- 검색 실행 시 검색어 저장 (bi-gram 학습)
- PC Schedule 프론트엔드 연동 완료
This commit is contained in:
caadiq 2026-01-11 21:33:55 +09:00
parent 3d2a6555f8
commit 727b05f0f5
8 changed files with 389 additions and 31 deletions

19
backend/lib/redis.js Normal file
View file

@ -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;

View file

@ -13,6 +13,7 @@
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"express": "^4.18.2", "express": "^4.18.2",
"inko": "^1.1.1", "inko": "^1.1.1",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"meilisearch": "^0.55.0", "meilisearch": "^0.55.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
@ -1291,6 +1292,12 @@
"url": "https://opencollective.com/libvips" "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": { "node_modules/@smithy/abort-controller": {
"version": "4.2.7", "version": "4.2.7",
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.7.tgz", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.7.tgz",
@ -2184,6 +2191,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@ -2759,6 +2775,53 @@
"node": ">= 0.4.0" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -2835,12 +2898,24 @@
"safe-buffer": "^5.0.1" "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": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT" "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": { "node_modules/lodash.isboolean": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@ -3316,6 +3391,27 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "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": { "node_modules/rss-parser": {
"version": "3.13.0", "version": "3.13.0",
"resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz", "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz",
@ -3555,6 +3651,12 @@
"node": ">= 0.6" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

View file

@ -12,6 +12,7 @@
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"express": "^4.18.2", "express": "^4.18.2",
"inko": "^1.1.1", "inko": "^1.1.1",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"meilisearch": "^0.55.0", "meilisearch": "^0.55.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",

View file

@ -1,9 +1,27 @@
import express from "express"; import express from "express";
import pool from "../lib/db.js"; import pool from "../lib/db.js";
import { searchSchedules } from "../services/meilisearch.js"; import { searchSchedules } from "../services/meilisearch.js";
import { saveSearchQuery, getSuggestions } from "../services/suggestions.js";
const router = express.Router(); 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) => { router.get("/", async (req, res) => {
try { try {
@ -14,6 +32,13 @@ router.get("/", async (req, res) => {
const offset = parseInt(req.query.offset) || 0; const offset = parseInt(req.query.offset) || 0;
const pageLimit = parseInt(req.query.limit) || 100; const pageLimit = parseInt(req.query.limit) || 100;
// 첫 페이지 검색 시에만 검색어 저장 (bi-gram 학습)
if (offset === 0) {
saveSearchQuery(search.trim()).catch((err) =>
console.error("검색어 저장 실패:", err.message)
);
}
// Meilisearch에서 큰 limit으로 검색 (유사도 필터링 후 클라이언트 페이징) // Meilisearch에서 큰 limit으로 검색 (유사도 필터링 후 클라이언트 페이징)
const results = await searchSchedules(search.trim(), { const results = await searchSchedules(search.trim(), {
limit: 1000, // 내부적으로 1000개까지 검색 limit: 1000, // 내부적으로 1000개까지 검색

View file

@ -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);
}
}

View file

@ -43,6 +43,16 @@ services:
- app - app
restart: unless-stopped restart: unless-stopped
# Redis - 추천 검색어 캐시
redis:
image: redis:7-alpine
container_name: fromis9-redis
volumes:
- ./redis_data:/data
networks:
- app
restart: unless-stopped
networks: networks:
app: app:
external: true external: true

View file

@ -158,6 +158,8 @@ function AdminSchedule() {
const [showSuggestions, setShowSuggestions] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
const [originalSearchQuery, setOriginalSearchQuery] = useState(''); // const [originalSearchQuery, setOriginalSearchQuery] = useState(''); //
const [suggestions, setSuggestions] = useState([]); //
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
const SEARCH_LIMIT = 20; // 20 const SEARCH_LIMIT = 20; // 20
const ESTIMATED_ITEM_HEIGHT = 100; // ( ) const ESTIMATED_ITEM_HEIGHT = 100; // ( )

View file

@ -46,6 +46,8 @@ function Schedule() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
const [suggestions, setSuggestions] = useState([]); //
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
const SEARCH_LIMIT = 20; // 20 const SEARCH_LIMIT = 20; // 20
const ESTIMATED_ITEM_HEIGHT = 120; // ( ) const ESTIMATED_ITEM_HEIGHT = 120; // ( )
@ -102,6 +104,34 @@ function Schedule() {
prevInViewRef.current = inView; prevInViewRef.current = inView;
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); }, [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(() => { useEffect(() => {
@ -752,33 +782,28 @@ function Schedule() {
}} }}
onFocus={() => setShowSuggestions(true)} onFocus={() => setShowSuggestions(true)}
onKeyDown={(e) => { onKeyDown={(e) => {
//
const dummySuggestions = ['성수기', '성수기 이채영', '이채영 먹방', 'NOW TOMORROW', '하얀 그리움', '콘서트', '월드투어'].filter(s =>
s.toLowerCase().includes(originalSearchQuery.toLowerCase())
).slice(0, 7);
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
const newIndex = selectedSuggestionIndex < dummySuggestions.length - 1 const newIndex = selectedSuggestionIndex < suggestions.length - 1
? selectedSuggestionIndex + 1 ? selectedSuggestionIndex + 1
: 0; : 0;
setSelectedSuggestionIndex(newIndex); setSelectedSuggestionIndex(newIndex);
if (dummySuggestions[newIndex]) { if (suggestions[newIndex]) {
setSearchInput(dummySuggestions[newIndex]); setSearchInput(suggestions[newIndex]);
} }
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
const newIndex = selectedSuggestionIndex > 0 const newIndex = selectedSuggestionIndex > 0
? selectedSuggestionIndex - 1 ? selectedSuggestionIndex - 1
: dummySuggestions.length - 1; : suggestions.length - 1;
setSelectedSuggestionIndex(newIndex); setSelectedSuggestionIndex(newIndex);
if (dummySuggestions[newIndex]) { if (suggestions[newIndex]) {
setSearchInput(dummySuggestions[newIndex]); setSearchInput(suggestions[newIndex]);
} }
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
if (selectedSuggestionIndex >= 0 && dummySuggestions[selectedSuggestionIndex]) { if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) {
setSearchInput(dummySuggestions[selectedSuggestionIndex]); setSearchInput(suggestions[selectedSuggestionIndex]);
setSearchTerm(dummySuggestions[selectedSuggestionIndex]); setSearchTerm(suggestions[selectedSuggestionIndex]);
} else if (searchInput.trim()) { } else if (searchInput.trim()) {
setSearchTerm(searchInput); setSearchTerm(searchInput);
} }
@ -831,20 +856,16 @@ function Schedule() {
{/* 검색어 추천 드롭다운 */} {/* 검색어 추천 드롭다운 */}
{showSuggestions && originalSearchQuery.length > 0 && ( {showSuggestions && originalSearchQuery.length > 0 && (
<div className="absolute top-full mt-2 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-50 overflow-hidden" style={{ left: '44px', right: '66px' }}> <div className="absolute top-full mt-2 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-50 overflow-hidden" style={{ left: '44px', right: '66px' }}>
{(() => { {isLoadingSuggestions ? (
const dummySuggestions = ['성수기', '성수기 이채영', '이채영 먹방', 'NOW TOMORROW', '하얀 그리움', '콘서트', '월드투어'].filter(s => <div className="px-4 py-3 text-gray-400 text-sm text-center">
s.toLowerCase().includes(originalSearchQuery.toLowerCase()) 검색 ...
).slice(0, 7); </div>
) : suggestions.length === 0 ? (
if (dummySuggestions.length === 0) { <div className="px-4 py-3 text-gray-400 text-sm text-center">
return ( 추천 검색어가 없습니다
<div className="px-4 py-3 text-gray-400 text-sm text-center"> </div>
추천 검색어가 없습니다 ) : (
</div> suggestions.map((suggestion, index) => (
);
}
return dummySuggestions.map((suggestion, index) => (
<button <button
key={index} key={index}
onClick={() => { onClick={() => {
@ -863,8 +884,8 @@ function Schedule() {
<Search size={15} className={selectedSuggestionIndex === index ? 'text-primary' : 'text-gray-400'} /> <Search size={15} className={selectedSuggestionIndex === index ? 'text-primary' : 'text-gray-400'} />
<span className="text-sm">{suggestion}</span> <span className="text-sm">{suggestion}</span>
</button> </button>
)); ))
})()} )}
</div> </div>
)} )}
</div> </div>