diff --git a/.gitignore b/.gitignore index 3e6fd5b..dc53f0a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ backend/scrape_*.txt # Backup backend-backup/ + +# Kiwi 모델 파일 (용량 큼, 별도 다운로드 필요) +backend/models/kiwi/models/ diff --git a/backend/docs/suggestion-improvement-plan.md b/backend/docs/suggestion-improvement-plan.md new file mode 100644 index 0000000..e16572c --- /dev/null +++ b/backend/docs/suggestion-improvement-plan.md @@ -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으로 기존 방식 유지 | diff --git a/backend/models/kiwi/user.dict b/backend/models/kiwi/user.dict new file mode 100644 index 0000000..d1c6887 --- /dev/null +++ b/backend/models/kiwi/user.dict @@ -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 diff --git a/backend/package-lock.json b/backend/package-lock.json index 0d267c1..b88218a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,7 +18,9 @@ "dayjs": "^1.11.13", "fastify": "^5.2.1", "fastify-plugin": "^5.0.1", + "inko": "^1.1.1", "ioredis": "^5.4.2", + "kiwi-nlp": "^0.22.1", "mysql2": "^3.12.0", "node-cron": "^3.0.3", "sharp": "^0.34.5" @@ -2685,6 +2687,12 @@ "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": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -2711,6 +2719,22 @@ "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", "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": { "version": "1.1.2", "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==", "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": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2836,6 +2872,15 @@ "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": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2863,6 +2908,15 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "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": { "version": "1.0.1", "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" } }, + "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": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -3115,6 +3175,33 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3151,12 +3238,35 @@ "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": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "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": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", @@ -3277,6 +3387,12 @@ "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": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-4.1.0.tgz", @@ -3401,6 +3517,12 @@ "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": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -3410,6 +3532,19 @@ "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": { "version": "0.40.3", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", @@ -3419,6 +3554,77 @@ "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": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3522,6 +3728,15 @@ "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": { "version": "12.1.3", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", @@ -3534,6 +3749,15 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4021,6 +4245,18 @@ ], "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -4199,6 +4435,12 @@ "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 873dbd1..2effe91 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,9 @@ "dayjs": "^1.11.13", "fastify": "^5.2.1", "fastify-plugin": "^5.0.1", + "inko": "^1.1.1", "ioredis": "^5.4.2", + "kiwi-nlp": "^0.22.1", "mysql2": "^3.12.0", "node-cron": "^3.0.3", "sharp": "^0.34.5" diff --git a/backend/sql/suggestions.sql b/backend/sql/suggestions.sql new file mode 100644 index 0000000..67e7010 --- /dev/null +++ b/backend/sql/suggestions.sql @@ -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; diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index 6e21b5d..0b9add8 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -2,9 +2,14 @@ * 일정 라우트 * GET: 공개, POST/PUT/DELETE: 인증 필요 */ +import suggestionsRoutes from './suggestions.js'; + export default async function schedulesRoutes(fastify) { const { db } = fastify; + // 추천 검색어 라우트 등록 + fastify.register(suggestionsRoutes, { prefix: '/suggestions' }); + /** * GET /api/schedules * 월별 일정 목록 조회 diff --git a/backend/src/routes/schedules/suggestions.js b/backend/src/routes/schedules/suggestions.js new file mode 100644 index 0000000..3fa5859 --- /dev/null +++ b/backend/src/routes/schedules/suggestions.js @@ -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 }; + }); +} diff --git a/backend/src/services/suggestions/chosung.js b/backend/src/services/suggestions/chosung.js new file mode 100644 index 0000000..755cb58 --- /dev/null +++ b/backend/src/services/suggestions/chosung.js @@ -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); +} diff --git a/backend/src/services/suggestions/index.js b/backend/src/services/suggestions/index.js new file mode 100644 index 0000000..e909207 --- /dev/null +++ b/backend/src/services/suggestions/index.js @@ -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; diff --git a/backend/src/services/suggestions/morpheme.js b/backend/src/services/suggestions/morpheme.js new file mode 100644 index 0000000..d3a1b18 --- /dev/null +++ b/backend/src/services/suggestions/morpheme.js @@ -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} - 추출된 명사 배열 + */ +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; +}