feat: 추천 검색어 시스템 구현 (kiwi-nlp 형태소 분석)

- kiwi-nlp 기반 한국어 형태소 분석기 추가
- 추천 검색어 API 구현 (/api/schedules/suggestions)
  - Prefix 매칭, Bi-gram 다음 단어 예측
  - 초성 검색 지원, 영문→한글 자동 변환 (Inko)
- 사용자 사전 추가 (멤버/그룹명, 프로그램명 등)
- DB 테이블: suggestion_queries, suggestion_word_pairs, suggestion_chosung

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-18 13:01:29 +09:00
parent b89255780e
commit c201de203e
11 changed files with 1292 additions and 0 deletions

3
.gitignore vendored
View file

@ -28,3 +28,6 @@ backend/scrape_*.txt
# Backup # Backup
backend-backup/ backend-backup/
# Kiwi 모델 파일 (용량 큼, 별도 다운로드 필요)
backend/models/kiwi/models/

View file

@ -0,0 +1,253 @@
# 추천 검색어 시스템 개선 계획서
## 1. 현재 시스템 분석
### 1.1 기존 동작 방식
```
검색어 입력 → 띄어쓰기 분리 → Unigram/Bi-gram 저장 → prefix 매칭으로 추천
```
### 1.2 기존 코드의 문제점
| 문제 | 설명 | 예시 |
|------|------|------|
| **띄어쓰기 기준 분리** | 조사/어미가 포함된 형태로 저장됨 | "콘서트가", "열립니다" |
| **무의미한 검색어 저장** | 검색 결과 없어도 저장됨 | 오타, 의미없는 문자열 |
| **Redis 캐시 미활용** | prefix 검색은 매번 DB 조회 | 성능 저하 |
| **서브쿼리 반복** | `MAX(count)` 매 쿼리마다 실행 | 성능 저하 |
| **초성 검색 미지원** | "ㅍㅁㅅ"로 검색 불가 | 사용성 저하 |
### 1.3 기존 테이블 구조
```sql
-- Unigram (전체 검색어)
CREATE TABLE search_queries (
query VARCHAR(255) PRIMARY KEY,
count INT DEFAULT 1,
last_searched_at TIMESTAMP
);
-- Bi-gram (단어 쌍)
CREATE TABLE word_pairs (
word1 VARCHAR(100),
word2 VARCHAR(100),
count INT DEFAULT 1,
PRIMARY KEY (word1, word2)
);
```
---
## 2. 개선 목표
1. **형태소 분석기 도입**: 의미있는 단어(명사, 고유명사)만 추출
2. **검색 결과 기반 필터링**: 실제 검색 결과가 있는 검색어만 저장
3. **캐시 성능 개선**: Redis 활용 강화
4. **초성 검색 지원**: "ㅍㅁㅅ" → "프로미스나인"
5. **코드 구조 개선**: Fastify 플러그인 구조에 맞게 재작성
---
## 3. 개선 설계
### 3.1 형태소 분석기 도입 (koalanlp)
**설치 요구사항:**
- Java 8 이상 (Docker에 추가 필요)
- `npm install koalanlp`
**사용할 분석기:**
- **KMR (코모란)**: 속도 빠름, 정확도 양호
**추출 대상 품사:**
| 태그 | 설명 | 예시 |
|------|------|------|
| NNP | 고유명사 | 프로미스나인, 지원 |
| NNG | 일반명사 | 콘서트, 생일 |
| NNB | 의존명사 | (필요시) |
| SL | 외국어 | fromis_9 |
**예시:**
```
입력: "프로미스나인 콘서트가 열립니다"
기존: ["프로미스나인", "콘서트가", "열립니다"]
개선: ["프로미스나인", "콘서트"]
```
### 3.2 검색어 저장 로직 개선
```
검색 실행
Meilisearch 검색 결과 확인
↓ (결과 있음)
형태소 분석으로 명사 추출
Unigram 저장 (전체 검색어)
Bi-gram 저장 (명사 쌍)
Redis 캐시 업데이트
```
### 3.3 테이블 구조 개선
```sql
-- 검색어 테이블 (변경 없음)
CREATE TABLE search_queries (
id INT AUTO_INCREMENT PRIMARY KEY,
query VARCHAR(255) UNIQUE NOT NULL,
count INT DEFAULT 1,
has_results BOOLEAN DEFAULT TRUE, -- 추가: 검색 결과 유무
last_searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_prefix (query(50)),
INDEX idx_count (count DESC)
);
-- 단어 쌍 테이블 (변경 없음)
CREATE TABLE word_pairs (
word1 VARCHAR(100) NOT NULL,
word2 VARCHAR(100) NOT NULL,
count INT DEFAULT 1,
PRIMARY KEY (word1, word2),
INDEX idx_word1 (word1, count DESC)
);
-- 추가: 초성 인덱스 테이블
CREATE TABLE chosung_index (
chosung VARCHAR(50) NOT NULL, -- 초성 (예: "ㅍㅁㅅㄴㅇ")
word VARCHAR(100) NOT NULL, -- 원본 단어 (예: "프로미스나인")
count INT DEFAULT 1,
PRIMARY KEY (chosung, word),
INDEX idx_chosung (chosung)
);
```
### 3.4 Redis 캐시 전략
| 키 패턴 | 용도 | TTL |
|---------|------|-----|
| `suggest:prefix:{query}` | prefix 검색 결과 캐시 | 1시간 |
| `suggest:bigram:{word}` | Bi-gram 다음 단어 | 24시간 |
| `suggest:popular` | 인기 검색어 Top 100 | 10분 |
| `suggest:max_count` | 최대 검색 횟수 (임계값용) | 1시간 |
### 3.5 초성 검색 구현
```javascript
// 한글 → 초성 변환
function getChosung(text) {
const CHOSUNG = ['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
let result = '';
for (const char of text) {
const code = char.charCodeAt(0) - 0xAC00;
if (code >= 0 && code <= 11171) {
result += CHOSUNG[Math.floor(code / 588)];
}
}
return result;
}
// "프로미스나인" → "ㅍㄹㅁㅅㄴㅇ"
```
### 3.6 API 엔드포인트
```
GET /api/schedules/suggestions
?q=검색어
&limit=10
Response:
{
"suggestions": ["프로미스나인", "프로미스나인 콘서트", ...]
}
```
---
## 4. 파일 구조
```
backend/src/
├── services/
│ └── suggestions/
│ ├── index.js # 메인 서비스 (저장/조회)
│ ├── morpheme.js # 형태소 분석 (koalanlp)
│ ├── chosung.js # 초성 변환/검색
│ └── cache.js # Redis 캐시 관리
├── routes/
│ └── schedules/
│ └── suggestions.js # API 라우트
└── plugins/
└── koalanlp.js # koalanlp 초기화 플러그인
```
---
## 5. 구현 순서
### Phase 1: 기본 마이그레이션
1. [ ] Docker에 Java 설치 추가
2. [ ] koalanlp 패키지 설치 및 초기화
3. [ ] 기존 suggestions.js를 Fastify 구조로 마이그레이션
4. [ ] 기본 API 동작 확인
### Phase 2: 형태소 분석 적용
5. [ ] morpheme.js 구현 (명사 추출)
6. [ ] saveSearchQuery에 형태소 분석 적용
7. [ ] 검색 결과 있을 때만 저장하도록 수정
### Phase 3: 캐시 및 성능 개선
8. [ ] Redis 캐시 로직 강화
9. [ ] MAX(count) 캐싱
10. [ ] 인기 검색어 캐시
### Phase 4: 초성 검색
11. [ ] chosung.js 구현
12. [ ] chosung_index 테이블 생성
13. [ ] 초성 검색 API 통합
### Phase 5: 테스트 및 정리
14. [ ] 기존 데이터 마이그레이션 (형태소 재분석)
15. [ ] 성능 테스트
16. [ ] 문서화
---
## 6. Docker 변경사항
```dockerfile
# Dockerfile에 Java 추가
FROM node:20-alpine
# Java 설치 (koalanlp 필요)
RUN apk add --no-cache openjdk11-jre
# JAVA_HOME 설정
ENV JAVA_HOME=/usr/lib/jvm/java-11-openjdk
ENV PATH="$JAVA_HOME/bin:$PATH"
```
---
## 7. 예상 효과
| 항목 | 기존 | 개선 후 |
|------|------|---------|
| 단어 추출 정확도 | 낮음 (띄어쓰기 기준) | 높음 (형태소 기준) |
| 불필요한 데이터 | 많음 | 적음 (결과 있는 것만) |
| 검색 응답 속도 | 보통 | 빠름 (캐시 활용) |
| 초성 검색 | 불가 | 가능 |
| 사용자 경험 | 보통 | 향상 |
---
## 8. 리스크 및 대응
| 리스크 | 영향 | 대응 방안 |
|--------|------|-----------|
| Java 설치로 이미지 크기 증가 | ~100MB | Alpine + JRE만 설치 |
| koalanlp 초기 로딩 시간 | 첫 요청 지연 | 서버 시작 시 미리 로드 |
| 형태소 분석 오류 | 단어 추출 실패 | fallback으로 기존 방식 유지 |

View file

@ -0,0 +1,97 @@
# 사용자 정의 사전
# 형식: 단어\t품사\t점수(선택)
# 품사: NNP(고유명사), NNG(일반명사), SL(외국어)
# 그룹명
프로미스나인 NNP
프미나 NNP
프나 NNP
fromis_9 SL
fromis SL
# 멤버 이름
이새롬 NNP
새롬 NNP
송하영 NNP
하영 NNP
장규리 NNP
규리 NNP
박지원 NNP
지원 NNP
노지선 NNP
지선 NNP
이서연 NNP
서연 NNP
이채영 NNP
채영 NNP
이나경 NNP
나경 NNP
백지헌 NNP
지헌 NNP
# 팬덤
플로버 NNP
flover SL
# 음악 프로그램
뮤직뱅크 NNP
인기가요 NNP
음악중심 NNP
엠카운트다운 NNP
쇼챔피언 NNP
더쇼 NNP
# 앨범/곡명
하얀그리움 NNP
LIKE_YOU_BETTER SL
# 콘텐츠/채널명
스프 NNP
성수기 NNP
이단장 NNP
슈퍼E나경 NNP
FM_1.24 SL
벌거벗은한국사 NNP
꿈친구 NNP
미미미누 NNP
비밀전학생 NNP
하일병 NNP
동네스타K쇼 NNP
밥사효 NNP
워크맨 NNP
영업중 NNP
개그콘서트 NNP
# 방송/행사
워터밤 NNP
서든어택 NNP
NPOP SL
하이록스 NNP
# 관련 용어
팬미팅 NNG
직캠 NNG
시구 NNG
시타 NNG
컴백 NNG
호캉스 NNG
팬캠 NNG
응원법 NNG
브이라이브 NNG
브이앱 NNG
# 다른 아이돌/연예인
류진 NNP
RYUJIN SL
ITZY SL
이즈나 NNP
izna SL
아일릿 NNP
ILLIT SL
키스오브라이프 NNP
하이키 NNP
H1KEY SL
아이들 NNP
미연 NNP
효연 NNP
T1 SL

View file

@ -18,7 +18,9 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
"inko": "^1.1.1",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"kiwi-nlp": "^0.22.1",
"mysql2": "^3.12.0", "mysql2": "^3.12.0",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"sharp": "^0.34.5" "sharp": "^0.34.5"
@ -2685,6 +2687,12 @@
"node": ">= 6.0.0" "node": ">= 6.0.0"
} }
}, },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/bcrypt": { "node_modules/bcrypt": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
@ -2711,6 +2719,22 @@
"integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/browser-stdout": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
"integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
"license": "ISC"
},
"node_modules/cluster-key-slot": { "node_modules/cluster-key-slot": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@ -2738,6 +2762,18 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz",
"integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==",
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -2836,6 +2872,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/diff": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz",
"integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -2863,6 +2908,15 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/fast-decode-uri-component": { "node_modules/fast-decode-uri-component": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
@ -3077,6 +3131,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/generate-function": { "node_modules/generate-function": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
@ -3115,6 +3175,33 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/growl": {
"version": "1.10.5",
"resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz",
"integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==",
"license": "MIT",
"engines": {
"node": ">=4.x"
}
},
"node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/he": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz",
"integrity": "sha512-z/GDPjlRMNOa2XJiB4em8wJpuuBfrFOlYKTZxtpkdr1uPdibHI8rYA3MY0KDObpVyaes0e/aunid/t88ZI2EKA==",
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -3151,12 +3238,35 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/inko": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/inko/-/inko-1.1.1.tgz",
"integrity": "sha512-Lr+HH4xr1eT0OKokYXjr+lRk6ubVEw6iCpOEGsdeokwLsvLXODWZG2XuzvTOlCl5hEVApK8TVWSkWnf55c6RJA==",
"license": "MIT",
"dependencies": {
"mocha": "^5.1.1"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/ioredis": { "node_modules/ioredis": {
"version": "5.9.2", "version": "5.9.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz",
@ -3277,6 +3387,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/kiwi-nlp": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/kiwi-nlp/-/kiwi-nlp-0.22.1.tgz",
"integrity": "sha512-NyYsWmlzw3VNluzfwYPfweT+xmhXc6FcFzHYeFo+IiRH2Lo8KcDHP4RYLcRhSx+fbhfWwQansTxkqKTXIO8YIA==",
"license": "LGPL-2.1-or-later"
},
"node_modules/leven": { "node_modules/leven": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-4.1.0.tgz", "resolved": "https://registry.npmjs.org/leven/-/leven-4.1.0.tgz",
@ -3401,6 +3517,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/minimist": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
"integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==",
"license": "MIT"
},
"node_modules/minipass": { "node_modules/minipass": {
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@ -3410,6 +3532,19 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/mkdirp": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==",
"deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)",
"license": "MIT",
"dependencies": {
"minimist": "0.0.8"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mnemonist": { "node_modules/mnemonist": {
"version": "0.40.3", "version": "0.40.3",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
@ -3419,6 +3554,77 @@
"obliterator": "^2.0.4" "obliterator": "^2.0.4"
} }
}, },
"node_modules/mocha": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz",
"integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==",
"license": "MIT",
"dependencies": {
"browser-stdout": "1.3.1",
"commander": "2.15.1",
"debug": "3.1.0",
"diff": "3.5.0",
"escape-string-regexp": "1.0.5",
"glob": "7.1.2",
"growl": "1.10.5",
"he": "1.1.1",
"minimatch": "3.0.4",
"mkdirp": "0.5.1",
"supports-color": "5.4.0"
},
"bin": {
"_mocha": "bin/_mocha",
"mocha": "bin/mocha"
},
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/mocha/node_modules/debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/mocha/node_modules/glob": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz",
"integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
}
},
"node_modules/mocha/node_modules/minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/mocha/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3522,6 +3728,15 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/openapi-types": { "node_modules/openapi-types": {
"version": "12.1.3", "version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
@ -3534,6 +3749,15 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -4021,6 +4245,18 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/supports-color": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz",
"integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==",
"license": "MIT",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/tagged-tag": { "node_modules/tagged-tag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
@ -4199,6 +4435,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -17,7 +17,9 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
"inko": "^1.1.1",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"kiwi-nlp": "^0.22.1",
"mysql2": "^3.12.0", "mysql2": "^3.12.0",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"sharp": "^0.34.5" "sharp": "^0.34.5"

View file

@ -0,0 +1,34 @@
-- 추천 검색어 테이블
-- 검색어 테이블 (Unigram)
CREATE TABLE IF NOT EXISTS suggestion_queries (
id INT AUTO_INCREMENT PRIMARY KEY,
query VARCHAR(255) NOT NULL UNIQUE,
count INT DEFAULT 1,
last_searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_query_prefix (query(50)),
INDEX idx_count (count DESC),
INDEX idx_last_searched (last_searched_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 단어 쌍 테이블 (Bi-gram)
CREATE TABLE IF NOT EXISTS suggestion_word_pairs (
word1 VARCHAR(100) NOT NULL,
word2 VARCHAR(100) NOT NULL,
count INT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (word1, word2),
INDEX idx_word1_count (word1, count DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 초성 인덱스 테이블
CREATE TABLE IF NOT EXISTS suggestion_chosung (
chosung VARCHAR(50) NOT NULL,
word VARCHAR(100) NOT NULL,
count INT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (chosung, word),
INDEX idx_chosung (chosung),
INDEX idx_count (count DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View file

@ -2,9 +2,14 @@
* 일정 라우트 * 일정 라우트
* GET: 공개, POST/PUT/DELETE: 인증 필요 * GET: 공개, POST/PUT/DELETE: 인증 필요
*/ */
import suggestionsRoutes from './suggestions.js';
export default async function schedulesRoutes(fastify) { export default async function schedulesRoutes(fastify) {
const { db } = fastify; const { db } = fastify;
// 추천 검색어 라우트 등록
fastify.register(suggestionsRoutes, { prefix: '/suggestions' });
/** /**
* GET /api/schedules * GET /api/schedules
* 월별 일정 목록 조회 * 월별 일정 목록 조회

View file

@ -0,0 +1,116 @@
/**
* 추천 검색어 API 라우트
*/
import { SuggestionService } from '../../services/suggestions/index.js';
let suggestionService = null;
export default async function suggestionsRoutes(fastify) {
const { db, redis } = fastify;
// 서비스 초기화 (한 번만)
if (!suggestionService) {
suggestionService = new SuggestionService(db, redis);
// 비동기 초기화 (형태소 분석기 로드)
suggestionService.initialize().catch(err => {
console.error('[Suggestions] 서비스 초기화 실패:', err.message);
});
}
/**
* GET /api/schedules/suggestions
* 추천 검색어 조회
*/
fastify.get('/', {
schema: {
tags: ['suggestions'],
summary: '추천 검색어 조회',
querystring: {
type: 'object',
properties: {
q: { type: 'string', description: '검색어' },
limit: { type: 'integer', default: 10, description: '결과 개수' },
},
},
response: {
200: {
type: 'object',
properties: {
suggestions: {
type: 'array',
items: { type: 'string' },
},
},
},
},
},
}, async (request, reply) => {
const { q, limit = 10 } = request.query;
if (!q || q.trim().length === 0) {
return { suggestions: [] };
}
const suggestions = await suggestionService.getSuggestions(q, limit);
return { suggestions };
});
/**
* GET /api/schedules/suggestions/popular
* 인기 검색어 조회
*/
fastify.get('/popular', {
schema: {
tags: ['suggestions'],
summary: '인기 검색어 조회',
querystring: {
type: 'object',
properties: {
limit: { type: 'integer', default: 10, description: '결과 개수' },
},
},
response: {
200: {
type: 'object',
properties: {
queries: {
type: 'array',
items: { type: 'string' },
},
},
},
},
},
}, async (request, reply) => {
const { limit = 10 } = request.query;
const queries = await suggestionService.getPopularQueries(limit);
return { queries };
});
/**
* POST /api/schedules/suggestions/save
* 검색어 저장 (검색 실행 호출)
*/
fastify.post('/save', {
schema: {
tags: ['suggestions'],
summary: '검색어 저장',
body: {
type: 'object',
required: ['query'],
properties: {
query: { type: 'string', description: '검색어' },
},
},
},
}, async (request, reply) => {
const { query } = request.body;
if (!query || query.trim().length === 0) {
return { success: false };
}
await suggestionService.saveSearchQuery(query);
return { success: true };
});
}

View file

@ -0,0 +1,80 @@
/**
* 초성 변환/검색 모듈
* 한글 텍스트를 초성으로 변환하고 초성 검색 지원
*/
// 초성 목록 (유니코드 순서)
const CHOSUNG = [
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ',
'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
];
// 한글 유니코드 범위
const HANGUL_START = 0xAC00;
const HANGUL_END = 0xD7A3;
/**
* 문자가 한글인지 확인
*/
function isHangul(char) {
const code = char.charCodeAt(0);
return code >= HANGUL_START && code <= HANGUL_END;
}
/**
* 문자가 초성인지 확인
*/
function isChosung(char) {
return CHOSUNG.includes(char);
}
/**
* 한글 텍스트를 초성으로 변환
* @param {string} text - 변환할 텍스트
* @returns {string} - 초성 문자열
* @example "프로미스나인" "ㅍㄹㅁㅅㄴㅇ"
*/
export function getChosung(text) {
if (!text) return '';
let result = '';
for (const char of text) {
if (isHangul(char)) {
const code = char.charCodeAt(0) - HANGUL_START;
const chosungIndex = Math.floor(code / 588);
result += CHOSUNG[chosungIndex];
} else if (isChosung(char)) {
// 이미 초성이면 그대로
result += char;
}
// 한글이 아닌 문자는 무시
}
return result;
}
/**
* 입력이 초성으로만 구성되어 있는지 확인
* @param {string} text - 확인할 텍스트
* @returns {boolean}
*/
export function isChosungOnly(text) {
if (!text) return false;
for (const char of text) {
if (!isChosung(char) && char !== ' ') {
return false;
}
}
return true;
}
/**
* 초성 패턴이 단어와 매칭되는지 확인
* @param {string} chosung - 초성 패턴
* @param {string} word - 비교할 단어
* @returns {boolean}
* @example isChosungMatch("ㅍㄹㅁ", "프로미스") true
*/
export function isChosungMatch(chosung, word) {
const wordChosung = getChosung(word);
return wordChosung.startsWith(chosung);
}

View file

@ -0,0 +1,296 @@
/**
* 추천 검색어 서비스
* - 형태소 분석으로 명사 추출
* - 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,
// 최소 임계값 (데이터 적을 때)
MIN_COUNT_FLOOR: 5,
// Redis 키 prefix
REDIS_PREFIX: 'suggest:',
// 캐시 TTL (초)
CACHE_TTL: {
PREFIX: 3600, // prefix 검색: 1시간
BIGRAM: 86400, // bi-gram: 24시간
POPULAR: 600, // 인기 검색어: 10분
MAX_COUNT: 3600, // 최대 횟수: 1시간
},
};
/**
* 추천 검색어 서비스 클래스
*/
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 매칭
*/
async getPrefixSuggestions(prefix, koreanPrefix, limit) {
try {
let rows;
if (koreanPrefix) {
// 영어 + 한글 변환 둘 다 검색
[rows] = await this.db.query(
`SELECT query, count FROM suggestion_queries
WHERE query LIKE ? OR query LIKE ?
ORDER BY count DESC, last_searched_at DESC
LIMIT ?`,
[`${prefix}%`, `${koreanPrefix}%`, limit]
);
} else {
[rows] = await this.db.query(
`SELECT query, count FROM suggestion_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('[Suggestion] Prefix 조회 오류:', error.message);
return [];
}
}
/**
* 초성 검색
*/
async getChosungSuggestions(chosung, limit) {
try {
const [rows] = await this.db.query(
`SELECT word, count FROM suggestion_chosung
WHERE chosung LIKE ?
ORDER BY count DESC
LIMIT ?`,
[`${chosung}%`, limit]
);
return rows.map(r => r.word);
} catch (error) {
console.error('[Suggestion] 초성 검색 오류:', error.message);
return [];
}
}
/**
* 인기 검색어 조회
*/
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);
}
// DB 조회
const [rows] = await this.db.query(
`SELECT query, count FROM suggestion_queries
ORDER BY count DESC
LIMIT ?`,
[limit]
);
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;

View file

@ -0,0 +1,164 @@
/**
* 형태소 분석 모듈 (kiwi-nlp)
* 검색어에서 명사(NNG, NNP, SL) 추출
*/
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let kiwi = null;
let isInitialized = false;
let initPromise = null;
// 추출 대상 품사 태그 (세종 품사 태그)
const NOUN_TAGS = [
'NNG', // 일반명사
'NNP', // 고유명사
'NNB', // 의존명사
'NR', // 수사
'SL', // 외국어
'SH', // 한자
];
// 모델 파일 목록
const MODEL_FILES = [
'combiningRule.txt',
'default.dict',
'dialect.dict',
'extract.mdl',
'multi.dict',
'sj.morph',
'typo.dict',
'cong.mdl',
];
// 사용자 사전 파일
const USER_DICT = 'user.dict';
/**
* kiwi-nlp 초기화 ( 번만 실행)
*/
export async function initMorpheme() {
if (isInitialized) return;
if (initPromise) return initPromise;
initPromise = (async () => {
try {
console.log('[Morpheme] kiwi-nlp 초기화 시작...');
// kiwi-nlp 동적 import (ESM)
const { KiwiBuilder } = await import('kiwi-nlp');
// wasm 파일 경로
const wasmPath = join(__dirname, '../../../node_modules/kiwi-nlp/dist/kiwi-wasm.wasm');
// 모델 파일 경로
const modelDir = join(__dirname, '../../../models/kiwi/models/cong/base');
const userDictPath = join(__dirname, '../../../models/kiwi', USER_DICT);
// KiwiBuilder 생성
const builder = await KiwiBuilder.create(wasmPath);
// 모델 파일 로드
const modelFiles = {};
for (const filename of MODEL_FILES) {
const filepath = join(modelDir, filename);
try {
modelFiles[filename] = new Uint8Array(readFileSync(filepath));
} catch (err) {
console.warn(`[Morpheme] 모델 파일 로드 실패: ${filename}`);
}
}
// 사용자 사전 로드
let userDicts = [];
try {
modelFiles[USER_DICT] = new Uint8Array(readFileSync(userDictPath));
userDicts = [USER_DICT];
console.log('[Morpheme] 사용자 사전 로드 완료');
} catch (err) {
console.warn('[Morpheme] 사용자 사전 없음, 기본 사전만 사용');
}
// Kiwi 인스턴스 생성
kiwi = await builder.build({ modelFiles, userDicts });
isInitialized = true;
console.log('[Morpheme] kiwi-nlp 초기화 완료');
} catch (error) {
console.error('[Morpheme] 초기화 실패:', error.message);
// 초기화 실패해도 서비스는 계속 동작 (fallback 사용)
}
})();
return initPromise;
}
/**
* 텍스트에서 명사 추출
* @param {string} text - 분석할 텍스트
* @returns {Promise<string[]>} - 추출된 명사 배열
*/
export async function extractNouns(text) {
if (!text || text.trim().length === 0) {
return [];
}
// 초기화 확인
if (!isInitialized) {
await initMorpheme();
}
// kiwi가 초기화되지 않았으면 fallback
if (!kiwi) {
console.warn('[Morpheme] kiwi 미초기화, fallback 사용');
return fallbackExtract(text);
}
try {
// 형태소 분석 실행
const result = kiwi.analyze(text);
const nouns = [];
// 분석 결과에서 명사만 추출
// result 구조: { score: number, tokens: Array<{str, tag, start, len}> }
if (result && result.tokens) {
for (const token of result.tokens) {
const tag = token.tag;
const surface = token.str;
if (NOUN_TAGS.includes(tag) && surface.length > 0) {
const normalized = surface.trim().toLowerCase();
if (!nouns.includes(normalized)) {
nouns.push(normalized);
}
}
}
}
return nouns.length > 0 ? nouns : fallbackExtract(text);
} catch (error) {
console.error('[Morpheme] 형태소 분석 오류:', error.message);
return fallbackExtract(text);
}
}
/**
* Fallback: 공백 기준 분리 (형태소 분석 실패 )
*/
function fallbackExtract(text) {
return text
.toLowerCase()
.split(/\s+/)
.filter(w => w.length > 0);
}
/**
* 초기화 상태 확인
*/
export function isReady() {
return isInitialized && kiwi !== null;
}