2026-01-18 13:01:29 +09:00
|
|
|
/**
|
|
|
|
|
* 추천 검색어 서비스
|
|
|
|
|
* - 형태소 분석으로 명사 추출
|
|
|
|
|
* - Bi-gram 기반 다음 단어 예측
|
|
|
|
|
* - 초성 검색 지원
|
|
|
|
|
* - 영어 오타 감지 (Inko)
|
|
|
|
|
*/
|
|
|
|
|
import Inko from 'inko';
|
|
|
|
|
import { extractNouns, initMorpheme, isReady } from './morpheme.js';
|
|
|
|
|
import { getChosung, isChosungOnly, isChosungMatch } from './chosung.js';
|
|
|
|
|
|
|
|
|
|
const inko = new Inko();
|
|
|
|
|
|
|
|
|
|
// 설정
|
|
|
|
|
const CONFIG = {
|
|
|
|
|
// 추천 검색어 최소 검색 횟수 비율 (최대 대비)
|
|
|
|
|
MIN_COUNT_RATIO: 0.01,
|
2026-01-19 09:44:02 +09:00
|
|
|
// 최소 임계값 (데이터 적을 때 오타 방지)
|
|
|
|
|
MIN_COUNT_FLOOR: 10,
|
2026-01-18 13:01:29 +09:00
|
|
|
// Redis 키 prefix
|
|
|
|
|
REDIS_PREFIX: 'suggest:',
|
|
|
|
|
// 캐시 TTL (초)
|
|
|
|
|
CACHE_TTL: {
|
|
|
|
|
PREFIX: 3600, // prefix 검색: 1시간
|
|
|
|
|
BIGRAM: 86400, // bi-gram: 24시간
|
|
|
|
|
POPULAR: 600, // 인기 검색어: 10분
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 추천 검색어 서비스 클래스
|
|
|
|
|
*/
|
|
|
|
|
export class SuggestionService {
|
|
|
|
|
constructor(db, redis) {
|
|
|
|
|
this.db = db;
|
|
|
|
|
this.redis = redis;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 서비스 초기화 (형태소 분석기 로드)
|
|
|
|
|
*/
|
|
|
|
|
async initialize() {
|
|
|
|
|
try {
|
|
|
|
|
await initMorpheme();
|
|
|
|
|
console.log('[Suggestion] 서비스 초기화 완료');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[Suggestion] 서비스 초기화 실패:', error.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 영문만 포함된 검색어인지 확인
|
|
|
|
|
*/
|
|
|
|
|
isEnglishOnly(text) {
|
|
|
|
|
const englishChars = text.match(/[a-zA-Z]/g) || [];
|
|
|
|
|
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
|
|
|
|
|
return englishChars.length > 0 && koreanChars.length === 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 영어 입력을 한글로 변환 (오타 감지)
|
|
|
|
|
*/
|
|
|
|
|
convertEnglishToKorean(text) {
|
|
|
|
|
const converted = inko.en2ko(text);
|
|
|
|
|
return converted !== text ? converted : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 검색어 저장 (검색 실행 시 호출)
|
|
|
|
|
* - 형태소 분석으로 명사 추출
|
|
|
|
|
* - Unigram + Bi-gram 저장
|
|
|
|
|
*/
|
|
|
|
|
async saveSearchQuery(query) {
|
|
|
|
|
if (!query || query.trim().length === 0) return;
|
|
|
|
|
|
|
|
|
|
let normalizedQuery = query.trim().toLowerCase();
|
|
|
|
|
|
|
|
|
|
// 영어 입력 → 한글 변환 시도
|
|
|
|
|
if (this.isEnglishOnly(normalizedQuery)) {
|
|
|
|
|
const korean = this.convertEnglishToKorean(normalizedQuery);
|
|
|
|
|
if (korean) {
|
|
|
|
|
console.log(`[Suggestion] 한글 변환: "${normalizedQuery}" → "${korean}"`);
|
|
|
|
|
normalizedQuery = korean;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 1. 전체 검색어 저장 (Unigram)
|
|
|
|
|
await this.db.query(
|
|
|
|
|
`INSERT INTO suggestion_queries (query, count)
|
|
|
|
|
VALUES (?, 1)
|
|
|
|
|
ON DUPLICATE KEY UPDATE count = count + 1, last_searched_at = CURRENT_TIMESTAMP`,
|
|
|
|
|
[normalizedQuery]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 2. 형태소 분석으로 명사 추출
|
|
|
|
|
let nouns;
|
|
|
|
|
if (isReady()) {
|
|
|
|
|
nouns = await extractNouns(normalizedQuery);
|
|
|
|
|
} else {
|
|
|
|
|
// fallback: 공백 분리
|
|
|
|
|
nouns = normalizedQuery.split(/\s+/).filter(w => w.length > 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Bi-gram 저장 (명사 쌍)
|
|
|
|
|
for (let i = 0; i < nouns.length - 1; i++) {
|
|
|
|
|
const word1 = nouns[i].toLowerCase();
|
|
|
|
|
const word2 = nouns[i + 1].toLowerCase();
|
|
|
|
|
|
|
|
|
|
await this.db.query(
|
|
|
|
|
`INSERT INTO suggestion_word_pairs (word1, word2, count)
|
|
|
|
|
VALUES (?, ?, 1)
|
|
|
|
|
ON DUPLICATE KEY UPDATE count = count + 1`,
|
|
|
|
|
[word1, word2]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Redis 캐시 업데이트
|
|
|
|
|
await this.redis.zincrby(`${CONFIG.REDIS_PREFIX}bigram:${word1}`, 1, word2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. 초성 인덱스 저장
|
|
|
|
|
for (const noun of nouns) {
|
|
|
|
|
const chosung = getChosung(noun);
|
|
|
|
|
if (chosung.length >= 2) {
|
|
|
|
|
await this.db.query(
|
|
|
|
|
`INSERT INTO suggestion_chosung (chosung, word, count)
|
|
|
|
|
VALUES (?, ?, 1)
|
|
|
|
|
ON DUPLICATE KEY UPDATE count = count + 1`,
|
|
|
|
|
[chosung, noun.toLowerCase()]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[Suggestion] 저장: "${normalizedQuery}" → 명사: [${nouns.join(', ')}]`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[Suggestion] 저장 오류:', error.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 추천 검색어 조회
|
|
|
|
|
*/
|
|
|
|
|
async getSuggestions(query, limit = 10) {
|
|
|
|
|
if (!query || query.trim().length === 0) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let searchQuery = query.toLowerCase();
|
|
|
|
|
let koreanQuery = null;
|
|
|
|
|
|
|
|
|
|
// 영어 입력 → 한글 변환
|
|
|
|
|
if (this.isEnglishOnly(searchQuery)) {
|
|
|
|
|
koreanQuery = this.convertEnglishToKorean(searchQuery);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 초성 검색 모드
|
|
|
|
|
if (isChosungOnly(searchQuery.replace(/\s/g, ''))) {
|
|
|
|
|
return await this.getChosungSuggestions(searchQuery.replace(/\s/g, ''), limit);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 this.getNextWordSuggestions(lastWord, searchQuery.trim(), limit);
|
|
|
|
|
} else {
|
|
|
|
|
// Prefix 매칭
|
|
|
|
|
return await this.getPrefixSuggestions(searchQuery.trim(), koreanQuery?.trim(), limit);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[Suggestion] 조회 오류:', error.message);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 다음 단어 예측 (Bi-gram)
|
|
|
|
|
*/
|
|
|
|
|
async getNextWordSuggestions(lastWord, prefix, limit) {
|
|
|
|
|
try {
|
|
|
|
|
// Redis 캐시 확인
|
|
|
|
|
const cacheKey = `${CONFIG.REDIS_PREFIX}bigram:${lastWord}`;
|
|
|
|
|
const cached = await this.redis.zrevrange(cacheKey, 0, limit - 1);
|
|
|
|
|
|
|
|
|
|
if (cached && cached.length > 0) {
|
|
|
|
|
return cached.map(word => `${prefix} ${word}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DB 조회
|
|
|
|
|
const [rows] = await this.db.query(
|
|
|
|
|
`SELECT word2, count FROM suggestion_word_pairs
|
|
|
|
|
WHERE word1 = ?
|
|
|
|
|
ORDER BY count DESC
|
|
|
|
|
LIMIT ?`,
|
|
|
|
|
[lastWord, limit]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return rows.map(r => `${prefix} ${r.word2}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[Suggestion] Bi-gram 조회 오류:', error.message);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Prefix 매칭
|
2026-01-19 09:44:02 +09:00
|
|
|
* - GREATEST()로 동적 임계값 적용: MAX(count) * 1% 또는 최소 10 중 더 큰 값
|
2026-01-18 13:01:29 +09:00
|
|
|
*/
|
|
|
|
|
async getPrefixSuggestions(prefix, koreanPrefix, limit) {
|
|
|
|
|
try {
|
|
|
|
|
let rows;
|
|
|
|
|
|
|
|
|
|
if (koreanPrefix) {
|
|
|
|
|
// 영어 + 한글 변환 둘 다 검색
|
|
|
|
|
[rows] = await this.db.query(
|
2026-01-19 09:44:02 +09:00
|
|
|
`SELECT query FROM suggestion_queries
|
|
|
|
|
WHERE (query LIKE ? OR query LIKE ?)
|
|
|
|
|
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
|
2026-01-18 13:01:29 +09:00
|
|
|
ORDER BY count DESC, last_searched_at DESC
|
|
|
|
|
LIMIT ?`,
|
2026-01-19 09:44:02 +09:00
|
|
|
[`${prefix}%`, `${koreanPrefix}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
|
2026-01-18 13:01:29 +09:00
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
[rows] = await this.db.query(
|
2026-01-19 09:44:02 +09:00
|
|
|
`SELECT query FROM suggestion_queries
|
2026-01-18 13:01:29 +09:00
|
|
|
WHERE query LIKE ?
|
2026-01-19 09:44:02 +09:00
|
|
|
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
|
2026-01-18 13:01:29 +09:00
|
|
|
ORDER BY count DESC, last_searched_at DESC
|
|
|
|
|
LIMIT ?`,
|
2026-01-19 09:44:02 +09:00
|
|
|
[`${prefix}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
|
2026-01-18 13:01:29 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return rows.map(r => r.query);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[Suggestion] Prefix 조회 오류:', error.message);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 초성 검색
|
2026-01-19 09:44:02 +09:00
|
|
|
* - GREATEST()로 동적 임계값 적용
|
2026-01-18 13:01:29 +09:00
|
|
|
*/
|
|
|
|
|
async getChosungSuggestions(chosung, limit) {
|
|
|
|
|
try {
|
|
|
|
|
const [rows] = await this.db.query(
|
2026-01-19 09:44:02 +09:00
|
|
|
`SELECT word FROM suggestion_chosung
|
2026-01-18 13:01:29 +09:00
|
|
|
WHERE chosung LIKE ?
|
2026-01-19 09:44:02 +09:00
|
|
|
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_chosung), ?)
|
2026-01-18 13:01:29 +09:00
|
|
|
ORDER BY count DESC
|
|
|
|
|
LIMIT ?`,
|
2026-01-19 09:44:02 +09:00
|
|
|
[`${chosung}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
|
2026-01-18 13:01:29 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return rows.map(r => r.word);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[Suggestion] 초성 검색 오류:', error.message);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 인기 검색어 조회
|
2026-01-19 09:44:02 +09:00
|
|
|
* - GREATEST()로 동적 임계값 적용
|
2026-01-18 13:01:29 +09:00
|
|
|
*/
|
|
|
|
|
async getPopularQueries(limit = 10) {
|
|
|
|
|
try {
|
|
|
|
|
// Redis 캐시 확인
|
|
|
|
|
const cacheKey = `${CONFIG.REDIS_PREFIX}popular`;
|
|
|
|
|
const cached = await this.redis.get(cacheKey);
|
|
|
|
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
return JSON.parse(cached);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 09:44:02 +09:00
|
|
|
// DB 조회 (동적 임계값 이상만)
|
2026-01-18 13:01:29 +09:00
|
|
|
const [rows] = await this.db.query(
|
2026-01-19 09:44:02 +09:00
|
|
|
`SELECT query FROM suggestion_queries
|
|
|
|
|
WHERE count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
|
2026-01-18 13:01:29 +09:00
|
|
|
ORDER BY count DESC
|
|
|
|
|
LIMIT ?`,
|
2026-01-19 09:44:02 +09:00
|
|
|
[CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
|
2026-01-18 13:01:29 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = rows.map(r => r.query);
|
|
|
|
|
|
|
|
|
|
// 캐시 저장
|
|
|
|
|
await this.redis.setex(cacheKey, CONFIG.CACHE_TTL.POPULAR, JSON.stringify(result));
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[Suggestion] 인기 검색어 조회 오류:', error.message);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default SuggestionService;
|