179 lines
5 KiB
JavaScript
179 lines
5 KiB
JavaScript
|
|
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);
|
||
|
|
}
|
||
|
|
}
|