deptmatch-api / DEV_PLAN.md
maninglearchine's picture
feat: ๊ธˆ์œต๊ณต์‹œ ํฌ๋กค๋ง + BGE-M3 ๋ถ€์„œ ์ž๋™ ๋งค์นญ ์„œ๋น„์Šค ์ดˆ๊ธฐ ์ปค๋ฐ‹
12f85b6
|
Raw
History Blame Contribute Delete
24.9 kB
# ๋‹จ๊ณ„๋ณ„ ๊ฐœ๋ฐœ ๊ณ„ํš์„œ
## ๊ธˆ์œต๊ณต์‹œ ํฌ๋กค๋ง + ๋ถ€์„œ ์ž๋™ ๋งค์นญ ์„œ๋น„์Šค
**๊ธฐ์ค€ ๋ฌธ์„œ**: PRD v1.0
**์ž‘์„ฑ์ผ**: 2026-06-17
**์ด ๊ฐœ๋ฐœ ๊ธฐ๊ฐ„**: 13์ฃผ
---
## ์ „์ฒด ์ผ์ • ๊ฐœ์š”
```
Week 1 2 3 4 5 6 7 8 9 10 11 12 13
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Step1 โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
Step2 โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
Step3 โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
Step4 โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
Step5 โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
```
| ๋‹จ๊ณ„ | ์ด๋ฆ„ | ๊ธฐ๊ฐ„ | ์‚ฐ์ถœ๋ฌผ |
|------|------|------|--------|
| **Step 1** | ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ํŒŒ์ดํ”„๋ผ์ธ | 1~3์ฃผ | ํฌ๋กค๋Ÿฌ + DB + ์Šค์ผ€์ค„๋Ÿฌ |
| **Step 2** | ๋ถ€์„œ ๋งค์นญ ๋ชจ๋ธ ์„œ๋น™ | 3~5์ฃผ | ๋ชจ๋ธ API ์„œ๋ฒ„ + ๋งค์นญ ์›Œ์ปค |
| **Step 3** | ๋ฐฑ์—”๋“œ API ์„œ๋ฒ„ | 5~7์ฃผ | REST API + ์ธ์ฆ |
| **Step 4** | ํ”„๋ก ํŠธ์—”๋“œ ๊ฐœ๋ฐœ | 7~10์ฃผ | ์›น UI + Telegram Bot |
| **Step 5** | ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ๋ฐ ์šด์˜ ์•ˆ์ •ํ™” | 10~13์ฃผ | ์ „์ฒด ํ†ตํ•ฉ + ๋ชจ๋‹ˆํ„ฐ๋ง |
---
## Step 1. ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ํŒŒ์ดํ”„๋ผ์ธ ๊ตฌ์ถ• (Week 1~3)
### ๋ชฉํ‘œ
์™ธ๋ถ€ ๊ธฐ๊ด€ ๊ณต์‹œ๋ฅผ DB์— ์‹ ๋ขฐ์„ฑ ์žˆ๊ฒŒ ์ ์žฌํ•˜๋Š” ์ž๋™ํ™” ํŒŒ์ดํ”„๋ผ์ธ ์™„์„ฑ
### 1-1. ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ๋ฐ ์ธํ”„๋ผ ์…‹์—… (Week 1)
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] Docker Compose ๊ธฐ๋ฐ˜ ๋กœ์ปฌ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ๊ตฌ์„ฑ
- PostgreSQL 16
- Redis (๋ฉ”์‹œ์ง€ ํ ์šฉ๋„)
- Adminer (DB ๊ด€๋ฆฌ UI)
[ ] ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ ์ƒ์„ฑ
- announcements ํ…Œ์ด๋ธ”
- dept_matches ํ…Œ์ด๋ธ”
- departments ํ…Œ์ด๋ธ”
- crawl_logs ํ…Œ์ด๋ธ”
- Alembic ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์„ค์ •
[ ] Python ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ์ดˆ๊ธฐํ™”
- poetry ๋˜๋Š” pip-tools ์˜์กด์„ฑ ๊ด€๋ฆฌ
- ๊ธฐ๋ณธ ์„ค์ • ํŒŒ์ผ (config.py, .env)
- ๋กœ๊น… ์„ค์ • (structlog ๋˜๋Š” loguru)
```
#### ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ
```
project/
โ”œโ”€โ”€ crawler/
โ”‚ โ”œโ”€โ”€ base.py # BaseCrawler ์ถ”์ƒ ํด๋ž˜์Šค
โ”‚ โ”œโ”€โ”€ agencies/
โ”‚ โ”‚ โ”œโ”€โ”€ fsc.py # ๊ธˆ์œต์œ„์›ํšŒ
โ”‚ โ”‚ โ”œโ”€โ”€ fss.py # ๊ธˆ์œต๊ฐ๋…์›
โ”‚ โ”‚ โ”œโ”€โ”€ kofiu.py # ๊ธˆ์œต์ •๋ณด๋ถ„์„์›
โ”‚ โ”‚ โ”œโ”€โ”€ moleg.py # ๋ฒ•์ œ์ฒ˜
โ”‚ โ”‚ โ””โ”€โ”€ bok.py # ํ•œ๊ตญ์€ํ–‰
โ”‚ โ””โ”€โ”€ parser.py # HTML โ†’ ์ •์ œ ํ…์ŠคํŠธ ๊ณตํ†ต ์œ ํ‹ธ
โ”œโ”€โ”€ db/
โ”‚ โ”œโ”€โ”€ models.py # SQLAlchemy ORM ๋ชจ๋ธ
โ”‚ โ”œโ”€โ”€ session.py # DB ์„ธ์…˜ ๊ด€๋ฆฌ
โ”‚ โ””โ”€โ”€ migrations/ # Alembic ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜
โ”œโ”€โ”€ scheduler/
โ”‚ โ””โ”€โ”€ tasks.py # APScheduler or Celery beat ์ž‘์—… ์ •์˜
โ”œโ”€โ”€ matching/ # Step 2์—์„œ ์ฑ„์›€
โ”œโ”€โ”€ api/ # Step 3์—์„œ ์ฑ„์›€
โ””โ”€โ”€ docker-compose.yml
```
#### ํ•ต์‹ฌ ์„ค๊ณ„ ์›์น™
- `BaseCrawler` ์ถ”์ƒ ํด๋ž˜์Šค๋กœ ๊ธฐ๊ด€๋ณ„ ํฌ๋กค๋Ÿฌ๋ฅผ ํ”Œ๋Ÿฌ๊ทธ์ธ ๋ฐฉ์‹์œผ๋กœ ํ™•์žฅ
- ๊ธฐ๊ด€ ์ถ”๊ฐ€ ์‹œ `agencies/` ํ•˜์œ„ ํŒŒ์ผ๋งŒ ์ถ”๊ฐ€ํ•˜๋ฉด ์Šค์ผ€์ค„๋Ÿฌ ์ž๋™ ๋“ฑ๋ก
---
### 1-2. ํฌ๋กค๋Ÿฌ ๊ตฌํ˜„ (Week 1~2)
#### BaseCrawler ์ธํ„ฐํŽ˜์ด์Šค
```python
class BaseCrawler(ABC):
agency_name: str # "๊ธˆ์œต์œ„์›ํšŒ"
interval_hours: int # 1 or 2
base_url: str
@abstractmethod
def fetch_list(self) -> list[dict]:
"""๋ชฉ๋ก ํŽ˜์ด์ง€์—์„œ ์ œ๋ชฉ/URL/๋‚ ์งœ ์ˆ˜์ง‘"""
@abstractmethod
def fetch_detail(self, url: str) -> dict:
"""์ƒ์„ธ ํŽ˜์ด์ง€์—์„œ ๋ณธ๋ฌธ/๋‹ด๋‹น๋ถ€์„œ/์—ฐ๋ฝ์ฒ˜ ์ˆ˜์ง‘"""
def run(self) -> int:
"""์‹ ๊ทœ ๊ฑด๋งŒ ์ˆ˜์ง‘ํ•˜์—ฌ DB ์ €์žฅ, ์ˆ˜์ง‘ ๊ฑด์ˆ˜ ๋ฐ˜ํ™˜"""
```
#### ๊ธฐ๊ด€๋ณ„ ๊ตฌํ˜„ ํฌ์ธํŠธ
| ๊ธฐ๊ด€ | ์ˆ˜์ง‘ ๋ฐฉ์‹ | ์ฃผ์š” ํŒŒ์‹ฑ ํฌ์ธํŠธ |
|------|----------|----------------|
| ๊ธˆ์œต์œ„์›ํšŒ (fsc.go.kr) | requests + BeautifulSoup | ๋ณด๋„์ž๋ฃŒ/๊ณ ์‹œ๊ณต๊ณ /์˜๊ฒฐ๊ฒฐ๊ณผ ๊ฒŒ์‹œํŒ ๋ถ„๋ฆฌ |
| ๊ธˆ์œต๊ฐ๋…์› (fss.or.kr) | requests + BeautifulSoup | ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๋ชฉ๋ก URL ํŒจํ„ด ํ™•์ธ ํ•„์š” |
| KoFIU (kofiu.go.kr) | requests + BeautifulSoup | JS ๋ Œ๋”๋ง ์—ฌ๋ถ€ ์‚ฌ์ „ ํ™•์ธ, Playwright fallback |
| ๋ฒ•์ œ์ฒ˜ (moleg.go.kr) | requests + BeautifulSoup | ๋ฒ•๋ นํ•ด์„/์˜๊ฒฌ์„œ 2๊ฐœ ๊ฒŒ์‹œํŒ |
| ํ•œ๊ตญ์€ํ–‰ (bok.or.kr) | requests + BeautifulSoup | ๊ธˆํ†ต์œ„ ์˜๊ฒฐ ๋ณ„๋„ ์นดํ…Œ๊ณ ๋ฆฌ |
#### ๊ณตํ†ต ์ฒ˜๋ฆฌ ๋กœ์ง
```python
# ์ค‘๋ณต ๋ฐฉ์ง€: URL unique constraint + upsert
# ์‹ ๊ทœ ๊ฑด ํŒ๋ณ„: ๋งˆ์ง€๋ง‰ ์ˆ˜์ง‘ published_at ์ดํ›„ ํ•ญ๋ชฉ๋งŒ ์ฒ˜๋ฆฌ
# ๋ณธ๋ฌธ ์ •์ œ: HTML ํƒœ๊ทธ ์ œ๊ฑฐ, ์—ฐ์† ๊ณต๋ฐฑ/์ค„๋ฐ”๊ฟˆ ์ •๊ทœํ™”
# ๋‹ด๋‹น๋ถ€์„œ ์ถ”์ถœ: "๋‹ด๋‹น๋ถ€์„œ[:\s]+([๊ฐ€-ํžฃ]+[ํŒ€๊ณผ์‹ค๋ถ€์ฒ˜])" ํŒจํ„ด ์ •๊ทœ์‹
# ์—ฐ๋ฝ์ฒ˜ ์ถ”์ถœ: "02-\d{4}-\d{4}" ํŒจํ„ด ์ •๊ทœ์‹
# ์š”์ฒญ ๋”œ๋ ˆ์ด: ๊ธฐ๊ด€๋‹น 1~3์ดˆ random sleep
# ์—๋Ÿฌ ์ฒ˜๋ฆฌ: ์ง€์ˆ˜ ๋ฐฑ์˜คํ”„ ์žฌ์‹œ๋„ (1s โ†’ 2s โ†’ 4s, ์ตœ๋Œ€ 3ํšŒ)
```
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] BaseCrawler ์ถ”์ƒ ํด๋ž˜์Šค ๊ตฌํ˜„
[ ] ๊ธˆ์œต์œ„์›ํšŒ ํฌ๋กค๋Ÿฌ (fsc.py) โ€” ์šฐ์„ ์ˆœ์œ„ 1
[ ] ๊ธˆ์œต๊ฐ๋…์› ํฌ๋กค๋Ÿฌ (fss.py) โ€” ์šฐ์„ ์ˆœ์œ„ 2
[ ] HTML ์ •์ œ ์œ ํ‹ธ (parser.py)
[ ] ๋‹ด๋‹น๋ถ€์„œ/์—ฐ๋ฝ์ฒ˜ ์ •๊ทœ์‹ ์ถ”์ถœ๊ธฐ
[ ] ์ค‘๋ณต ๊ฐ์ง€ + DB upsert ๋กœ์ง
[ ] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ: ํฌ๋กค๋Ÿฌ๋ณ„ ํŒŒ์‹ฑ ์ •ํ™•๋„ ๊ฒ€์ฆ
```
---
### 1-3. ์Šค์ผ€์ค„๋Ÿฌ ๋ฐ ๋ฉ”์‹œ์ง€ ํ (Week 2~3)
#### ์Šค์ผ€์ค„๋Ÿฌ ๊ตฌ์„ฑ (APScheduler)
```python
# scheduler/tasks.py
scheduler.add_job(FscCrawler().run, 'interval', hours=1, id='fsc')
scheduler.add_job(FssCrawler().run, 'interval', hours=1, id='fss')
scheduler.add_job(KofiuCrawler().run, 'interval', hours=1, id='kofiu')
scheduler.add_job(MolegCrawler().run, 'interval', hours=2, id='moleg')
scheduler.add_job(BokCrawler().run, 'interval', hours=2, id='bok')
```
#### ๋ฉ”์‹œ์ง€ ํ (Redis Stream)
```
ํฌ๋กค๋Ÿฌ โ†’ XADD announcements_stream {announcement_id}
๋งค์นญ ์›Œ์ปค โ†’ XREAD ์†Œ๋น„ ํ›„ predict_department() ํ˜ธ์ถœ
```
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] APScheduler + Redis ์—ฐ๋™ ์„ค์ •
[ ] ํฌ๋กค๋Ÿฌ ์™„๋ฃŒ ํ›„ Redis Stream์— ์ด๋ฒคํŠธ ๋ฐœํ–‰
[ ] crawl_logs ํ…Œ์ด๋ธ” ์ž๋™ ๊ธฐ๋ก (์‹œ์ž‘/์ข…๋ฃŒ/์ˆ˜์ง‘๊ฑด์ˆ˜/์˜ค๋ฅ˜)
[ ] ํฌ๋กค๋ง ์‹คํŒจ ์‹œ Slack/์ด๋ฉ”์ผ ์˜ค๋ฅ˜ ์•Œ๋ฆผ (์„ ํƒ)
[ ] ๋‚˜๋จธ์ง€ 3๊ฐœ ๊ธฐ๊ด€ ํฌ๋กค๋Ÿฌ (kofiu, moleg, bok)
```
### Step 1 ์™„๋ฃŒ ๊ธฐ์ค€ (Definition of Done)
- [ ] 5๊ฐœ ๊ธฐ๊ด€ ํฌ๋กค๋Ÿฌ ๋ชจ๋‘ ๋™์ž‘, ์‹ ๊ทœ ๊ณต์‹œ ์ž๋™ DB ์ ์žฌ ํ™•์ธ
- [ ] 24์‹œ๊ฐ„ ๋ฌด์ค‘๋‹จ ์Šค์ผ€์ค„ ์‹คํ–‰ ์•ˆ์ •์„ฑ ํ™•์ธ
- [ ] ์ค‘๋ณต ์ˆ˜์ง‘ 0๊ฑด ํ™•์ธ (URL unique ์ œ์•ฝ ๊ฒ€์ฆ)
- [ ] crawl_logs ๊ธฐ๋ก ์ •์ƒ ํ™•์ธ
---
## Step 2. ๋ถ€์„œ ๋งค์นญ ๋ชจ๋ธ ์„œ๋น™ (Week 3~5)
### ๋ชฉํ‘œ
v3.ipynb์˜ Hybrid Retrieval ๋ชจ๋ธ์„ ์šด์˜ ๊ฐ€๋Šฅํ•œ API ์„œ๋น„์Šค๋กœ ์ „ํ™˜ํ•˜๊ณ , ํฌ๋กค๋ง๋œ ๊ณต์‹œ์— ๋ถ€์„œ๋ฅผ ์ž๋™ ๋งค์นญ
### 2-1. ๋ชจ๋ธ ํ•™์Šต ์žฌํ˜„ ๋ฐ ๊ฒ€์ฆ (Week 3)
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] v3.ipynb ๋กœ์ปฌ/Colab ์‹คํ–‰ ํ™˜๊ฒฝ ์žฌํ˜„
- ์˜์กด์„ฑ: sentence-transformers, rank_bm25, python-docx, openpyxl
- ํ•™์Šต ๋ฐ์ดํ„ฐ: ์—…๋ฌด๋‹ด๋‹น๊ทœ์ •.docx, dept.xlsx ๊ฒฝ๋กœ ํ™•์ธ
[ ] Stage 1 ํ•™์Šต ์‹คํ–‰
- ์—…๋ฌด๋‹ด๋‹น๊ทœ์ •.docx โ†’ self-positive ํ•™์Šต์Œ ์ƒ์„ฑ
- jhgan/ko-sroberta-multitask ๋„๋ฉ”์ธ ์ ์‘
- ๊ฒฐ๊ณผ: stage1_domain_retriever/ ์ €์žฅ
[ ] Stage 2 ํ•™์Šต ์‹คํ–‰
- dept.xlsx โ†’ ๋ถ€์„œ๋ณ„ 5ํ–‰ chunk ์ƒ์„ฑ
- OFWRK_NM โ†’ chunk ๋งคํ•‘ ํ•™์Šต
- ๊ฒฐ๊ณผ: stage2_dept_retriever/ ์ €์žฅ
[ ] ๊ธฐ์กด query1~3.txt๋กœ ์ถ”๋ก  ํ…Œ์ŠคํŠธ โ†’ ์˜ˆ์ƒ ๋ถ€์„œ ๊ฒ€์ฆ
[ ] ํ•™์Šต๋œ ๋ชจ๋ธ ํŒŒ์ผ ๋ฒ„์ „ ํƒœ๊น… (์˜ˆ: v3_20260617)
```
#### ๋ชจ๋ธ ์„ฑ๋Šฅ ๊ธฐ์ค€ ๊ฒ€์ฆ
```python
# ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค (query1~3 ๊ธฐ์ค€ ์ˆ˜๋™ ๊ฒ€์ฆ)
query1 โ†’ ์˜ˆ์ƒ: ๋””์ง€ํ„ธ๊ธˆ์œต์ด๊ด„๊ณผ or ํ•€ํ…Œํฌํ˜์‹ ๊ณผ (ํ˜์‹ ๊ธˆ์œต์„œ๋น„์Šค)
query2 โ†’ ์˜ˆ์ƒ: ์„œ๋ฏผ๊ธˆ์œต๊ณผ (์ฑ„๊ถŒ๋งค๊ฐ/์—ฐ์ฒด์ฑ„๋ฌด)
query3 โ†’ ์˜ˆ์ƒ: ๊ธˆ์œต์†Œ๋น„์ž์ •์ฑ…๊ณผ (๊ธˆ์œต๊ต์œก)
```
---
### 2-2. ๋ชจ๋ธ FastAPI ์„œ๋ฒ„ ๊ตฌํ˜„ (Week 4)
#### ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ
```
matching/
โ”œโ”€โ”€ model.py # ๋ชจ๋ธ ๋กœ๋”ฉ + predict_department() ๋ž˜ํผ
โ”œโ”€โ”€ server.py # FastAPI ์•ฑ ์ •์˜
โ”œโ”€โ”€ schemas.py # Pydantic ์ž…์ถœ๋ ฅ ์Šคํ‚ค๋งˆ
โ””โ”€โ”€ worker.py # Redis Stream ์†Œ๋น„ ์›Œ์ปค
```
#### API ์—”๋“œํฌ์ธํŠธ
```python
# POST /match
# Request
{
"text": "๋งˆ์ด๋ฐ์ดํ„ฐ ํ™œ์šฉ ๊ธˆ๋ฆฌ์ธํ•˜์š”๊ตฌ ์„œ๋น„์Šค ํ˜์‹ ๊ธˆ์œต 3๊ฑด ์‹ ๊ทœ ์ง€์ •...",
"top_n": 5
}
# Response
{
"final_department": "๋””์ง€ํ„ธ๊ธˆ์œต์ด๊ด„๊ณผ",
"confidence_score": 0.87,
"top5_candidates": [
{"rank": 1, "dept": "๋””์ง€ํ„ธ๊ธˆ์œต์ด๊ด„๊ณผ", "score": 0.87},
{"rank": 2, "dept": "ํ•€ํ…Œํฌํ˜์‹ ๊ณผ", "score": 0.71},
{"rank": 3, "dept": "์ „์ž๊ธˆ์œต๊ณผ", "score": 0.65},
{"rank": 4, "dept": "IT๊ธˆ์œต์ •๋ณด๋ณดํ˜ธํŒ€", "score": 0.60},
{"rank": 5, "dept": "๊ธˆ์œตํ˜์‹ ์ง€์›์‹ค", "score": 0.54}
],
"processing_time_ms": 118,
"model_version": "v3_20260617"
}
```
#### ๋ชจ๋ธ ์„œ๋ฒ„ ์„ค๊ณ„ ํฌ์ธํŠธ
```python
# ์•ฑ ์‹œ์ž‘ ์‹œ 1ํšŒ ๋กœ๋“œ (startup ์ด๋ฒคํŠธ)
@app.on_event("startup")
async def load_model():
app.state.embedder = SentenceTransformer(STAGE2_MODEL_DIR)
app.state.corpus_embs = ... # pre-computed embeddings
app.state.tfidf = ...
app.state.bm25 = ...
# ์‹ ๋ขฐ๋„ fallback ์ฒ˜๋ฆฌ
if result["confidence_score"] < 0.4:
result["final_department"] = None
result["needs_manual_review"] = True
```
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] model.py: predict_department() ํ•จ์ˆ˜ v3.ipynb์—์„œ ์ถ”์ถœ/์ •๋ฆฌ
[ ] schemas.py: MatchRequest / MatchResponse Pydantic ๋ชจ๋ธ
[ ] server.py: FastAPI ์•ฑ + /match + /health ์—”๋“œํฌ์ธํŠธ
[ ] Docker ์ด๋ฏธ์ง€ ๋นŒ๋“œ (python:3.11-slim ๊ธฐ๋ฐ˜)
[ ] /match ์—”๋“œํฌ์ธํŠธ ๋ถ€ํ•˜ ํ…Œ์ŠคํŠธ (๋ชฉํ‘œ: โ‰ค500ms/๊ฑด)
```
---
### 2-3. ๋งค์นญ ์›Œ์ปค ๊ตฌํ˜„ (Week 4~5)
#### ์›Œ์ปค ๋™์ž‘ ๋ฐฉ์‹
```
Redis Stream 'announcements_stream' ๊ตฌ๋…
โ†“
announcement_id ์ˆ˜์‹ 
โ†“
announcements ํ…Œ์ด๋ธ”์—์„œ body_text ์กฐํšŒ
โ†“
POST /match ํ˜ธ์ถœ (๋˜๋Š” ์ง์ ‘ predict_department ํ˜ธ์ถœ)
โ†“
dept_matches ํ…Œ์ด๋ธ” INSERT
โ†“
announcements.is_matched = TRUE ์—…๋ฐ์ดํŠธ
โ†“
์‹ ๋ขฐ๋„ < 0.4์ด๋ฉด โ†’ manual_review_queue์— ์ถ”๊ฐ€
```
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] worker.py: Redis Stream XREAD ๋ฃจํ”„ ๊ตฌํ˜„
[ ] ๋งค์นญ ๊ฒฐ๊ณผ dept_matches ํ…Œ์ด๋ธ” ์ €์žฅ
[ ] ์‹ ๋ขฐ๋„ ์ž„๊ณ„๊ฐ’(0.4) ๋ฏธ๋งŒ ์ฒ˜๋ฆฌ ๋กœ์ง
[ ] ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ: ๋ฐฑ๋กœ๊ทธ ๋ˆ„์  ์‹œ batch_predict() ํ™œ์šฉ
[ ] ์›Œ์ปค ์žฌ์‹œ์ž‘ ์‹œ ๋ฏธ์ฒ˜๋ฆฌ ๊ฑด ์ž๋™ ์žฌ์ฒ˜๋ฆฌ (XPENDING ํ™œ์šฉ)
[ ] ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ: ํฌ๋กค๋Ÿฌ ์ˆ˜์ง‘ โ†’ ์›Œ์ปค ๋งค์นญ โ†’ DB ์ €์žฅ E2E ํ™•์ธ
```
### Step 2 ์™„๋ฃŒ ๊ธฐ์ค€ (Definition of Done)
- [ ] ๋ชจ๋ธ API /match ์‘๋‹ต ์‹œ๊ฐ„ โ‰ค 500ms (CPU ํ™˜๊ฒฝ)
- [ ] query1~3 ์˜ˆ์ƒ ๋ถ€์„œ ์ •ํ™•ํžˆ ๋ฐ˜ํ™˜ ํ™•์ธ
- [ ] ํฌ๋กค๋Ÿฌ ์ˆ˜์ง‘ ํ›„ 5๋ถ„ ์ด๋‚ด ๋งค์นญ ๊ฒฐ๊ณผ DB ์ ์žฌ ํ™•์ธ
- [ ] ์‹ ๋ขฐ๋„ < 0.4 ๊ฑด manual_review ์ฒ˜๋ฆฌ ํ™•์ธ
- [ ] Docker Compose๋กœ ๋ชจ๋ธ ์„œ๋ฒ„ ๋‹จ๋… ๊ธฐ๋™ ๊ฐ€๋Šฅ
---
## Step 3. ๋ฐฑ์—”๋“œ API ์„œ๋ฒ„ ๊ฐœ๋ฐœ (Week 5~7)
### ๋ชฉํ‘œ
ํ”„๋ก ํŠธ์—”๋“œ์™€ ์™ธ๋ถ€ ์—ฐ๋™์ด ๊ฐ€๋Šฅํ•œ REST API ์„œ๋ฒ„ ์™„์„ฑ
### 3-1. API ์„œ๋ฒ„ ๊ธฐ๋ณธ ๊ตฌ์กฐ (Week 5~6)
#### ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ
```
api/
โ”œโ”€โ”€ main.py # FastAPI ์•ฑ ์—”ํŠธ๋ฆฌํฌ์ธํŠธ
โ”œโ”€โ”€ routers/
โ”‚ โ”œโ”€โ”€ announcements.py
โ”‚ โ”œโ”€โ”€ departments.py
โ”‚ โ””โ”€โ”€ match.py
โ”œโ”€โ”€ schemas/
โ”‚ โ”œโ”€โ”€ announcement.py
โ”‚ โ””โ”€โ”€ department.py
โ”œโ”€โ”€ services/
โ”‚ โ”œโ”€โ”€ announcement_service.py
โ”‚ โ””โ”€โ”€ match_service.py
โ”œโ”€โ”€ auth/
โ”‚ โ””โ”€โ”€ api_key.py # API Key ์ธ์ฆ ๋ฏธ๋“ค์›จ์–ด
โ””โ”€โ”€ dependencies.py # DB ์„ธ์…˜ ๋“ฑ ๊ณตํ†ต ์˜์กด์„ฑ
```
#### ๊ตฌํ˜„ํ•  ์—”๋“œํฌ์ธํŠธ
**๊ณต์‹œ ๋ชฉ๋ก ์กฐํšŒ**
```
GET /api/v1/announcements
Query: source_agency, category, dept, range(D|W|M|ALL),
page, per_page, min_confidence
Response: { total, page, items: [...] }
```
**๊ณต์‹œ ์ƒ์„ธ ์กฐํšŒ**
```
GET /api/v1/announcements/{id}
Response: ์ „์ฒด ํ•„๋“œ + ๋งค์นญ Top-5 + ์›๋ฌธ ๋‹ด๋‹น๋ถ€์„œ/์—ฐ๋ฝ์ฒ˜
```
**๋ถ€์„œ ๋ชฉ๋ก ์กฐํšŒ**
```
GET /api/v1/departments
Response: [{ dept_name, is_active }]
```
**์ˆ˜๋™ ๋งค์นญ ์žฌ์š”์ฒญ**
```
POST /api/v1/announcements/{id}/rematch
Body: { "override_dept": "ํ•€ํ…Œํฌํ˜์‹ ๊ณผ" } (์ƒ๋žต ์‹œ ๋ชจ๋ธ ์žฌ์ถ”๋ก )
Response: ๊ฐฑ์‹ ๋œ ๋งค์นญ ๊ฒฐ๊ณผ
```
**์ˆ˜๋™ ๊ฒ€ํ†  ํ ์กฐํšŒ** *(์šด์˜์ž์šฉ)*
```
GET /api/v1/review-queue
Response: ์‹ ๋ขฐ๋„ < 0.4์ธ ๋ฏธ์ฒ˜๋ฆฌ ๊ณต์‹œ ๋ชฉ๋ก
```
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] FastAPI ์•ฑ ์ดˆ๊ธฐํ™” + CORS ์„ค์ •
[ ] API Key ์ธ์ฆ ๋ฏธ๋“ค์›จ์–ด (X-API-Key ํ—ค๋”)
[ ] GET /announcements ๊ตฌํ˜„ (ํ•„ํ„ฐ + ํŽ˜์ด์ง€๋„ค์ด์…˜)
[ ] GET /announcements/{id} ๊ตฌํ˜„
[ ] GET /departments ๊ตฌํ˜„
[ ] POST /announcements/{id}/rematch ๊ตฌํ˜„
[ ] GET /review-queue ๊ตฌํ˜„
[ ] OpenAPI ๋ฌธ์„œ ์ž๋™ ์ƒ์„ฑ ํ™•์ธ (/docs)
```
---
### 3-2. ์„ฑ๋Šฅ ์ตœ์ ํ™” ๋ฐ ๋ณด์•ˆ (Week 6~7)
#### DB ์ฟผ๋ฆฌ ์ตœ์ ํ™”
```sql
-- ์ž์ฃผ ์‚ฌ์šฉ๋˜๋Š” ํ•„ํ„ฐ ์กฐํ•ฉ์— ์ธ๋ฑ์Šค ์ถ”๊ฐ€
CREATE INDEX idx_ann_agency_published ON announcements(source_agency, published_at DESC);
CREATE INDEX idx_ann_published_range ON announcements(published_at DESC);
CREATE INDEX idx_match_dept ON dept_matches(final_dept);
CREATE INDEX idx_match_confidence ON dept_matches(confidence_score);
-- ๋ชฉ๋ก ์กฐํšŒ์šฉ ๋ทฐ (announcements + dept_matches JOIN)
CREATE VIEW v_announcements_with_match AS
SELECT a.*, dm.final_dept, dm.confidence_score, dm.top5_json
FROM announcements a
LEFT JOIN dept_matches dm ON a.id = dm.announcement_id;
```
#### ๋ณด์•ˆ
```
- API Key ์ธ์ฆ (X-API-Key ํ—ค๋”)
- Rate Limiting: 100 req/min per API Key
- HTTPS ๊ฐ•์ œ (nginx ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ)
- SQL Injection ๋ฐฉ์ง€: SQLAlchemy ORM ์‚ฌ์šฉ (raw query ๊ธˆ์ง€)
- ์ž…๋ ฅ๊ฐ’ ๊ฒ€์ฆ: Pydantic ๋ชจ๋ธ๋กœ ๋ชจ๋“  ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ ํƒ€์ž… ๊ฒ€์ฆ
```
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] ์ฟผ๋ฆฌ ์„ฑ๋Šฅ ๊ฒ€์ฆ (๋ชฉ๋ก ์กฐํšŒ โ‰ค 200ms)
[ ] API Key ๋ฐœ๊ธ‰/๊ด€๋ฆฌ ํ…Œ์ด๋ธ” ์ถ”๊ฐ€
[ ] Rate Limiting ๋ฏธ๋“ค์›จ์–ด (slowapi)
[ ] ์‘๋‹ต ์บ์‹ฑ: ๋ถ€์„œ ๋ชฉ๋ก (5๋ถ„ ์บ์‹œ, Redis)
[ ] ์ „์ฒด API ์—”๋“œํฌ์ธํŠธ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ (pytest)
[ ] Postman Collection ๋˜๋Š” HTTP ํŒŒ์ผ ์ž‘์„ฑ
```
### Step 3 ์™„๋ฃŒ ๊ธฐ์ค€ (Definition of Done)
- [ ] ์ „์ฒด ์—”๋“œํฌ์ธํŠธ ์ •์ƒ ๋™์ž‘ ๋ฐ Postman ๊ฒ€์ฆ ์™„๋ฃŒ
- [ ] ๋ชฉ๋ก ์กฐํšŒ API ์‘๋‹ต โ‰ค 200ms (์ธ๋ฑ์Šค ์ ์šฉ ๊ธฐ์ค€)
- [ ] API Key ์—†๋Š” ์š”์ฒญ 401 ๋ฐ˜ํ™˜ ํ™•์ธ
- [ ] OpenAPI ๋ฌธ์„œ(/docs) ์ž๋™ ์ƒ์„ฑ ํ™•์ธ
- [ ] pytest ๊ธฐ๋ฐ˜ API ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ 80% ์ด์ƒ ์ปค๋ฒ„๋ฆฌ์ง€
---
## Step 4. ํ”„๋ก ํŠธ์—”๋“œ ๊ฐœ๋ฐœ (Week 7~10)
### ๋ชฉํ‘œ
๋‹ด๋‹น์ž๊ฐ€ ๊ณต์‹œ์™€ ๋งค์นญ ๋ถ€์„œ๋ฅผ ํ•œ ํ™”๋ฉด์—์„œ ํ™•์ธํ•˜๊ณ  ํ•„ํ„ฐ๋งํ•  ์ˆ˜ ์žˆ๋Š” ์›น UI ์™„์„ฑ
### 4-1. ๊ธฐ์ˆ  ์Šคํƒ ๋ฐ ์ดˆ๊ธฐ ์„ค์ • (Week 7)
```
ํ”„๋ ˆ์ž„์›Œํฌ: Next.js 14 (App Router)
์Šคํƒ€์ผ๋ง: Tailwind CSS + shadcn/ui
์ƒํƒœ๊ด€๋ฆฌ: Zustand (ํด๋ผ์ด์–ธํŠธ ์ƒํƒœ)
๋ฐ์ดํ„ฐ ํŽ˜์นญ: TanStack Query (์„œ๋ฒ„ ์ƒํƒœ)
๋ฐฐํฌ: Vercel ๋˜๋Š” Nginx + Docker
```
#### ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ
```
frontend/
โ”œโ”€โ”€ app/
โ”‚ โ”œโ”€โ”€ page.tsx # ๋ฉ”์ธ ํ”ผ๋“œ
โ”‚ โ”œโ”€โ”€ announcements/
โ”‚ โ”‚ โ””โ”€โ”€ [id]/page.tsx # ์ƒ์„ธ ํŽ˜์ด์ง€
โ”‚ โ””โ”€โ”€ dashboard/page.tsx # ๋ถ€์„œ๋ณ„ ๋Œ€์‹œ๋ณด๋“œ
โ”œโ”€โ”€ components/
โ”‚ โ”œโ”€โ”€ AnnouncementCard.tsx # ๊ณต์‹œ ์นด๋“œ (ํ•ต์‹ฌ)
โ”‚ โ”œโ”€โ”€ DeptMatchBadge.tsx # ๋ถ€์„œ ๋งค์นญ + ์‹ ๋ขฐ๋„ ๋ฐ”
โ”‚ โ”œโ”€โ”€ FilterBar.tsx # ๊ธฐ๊ด€/๊ธฐ๊ฐ„/๋ถ€์„œ ํ•„ํ„ฐ
โ”‚ โ”œโ”€โ”€ AgencyTabs.tsx # ๊ธฐ๊ด€ ํƒญ
โ”‚ โ””โ”€โ”€ ReviewAlert.tsx # ์ˆ˜๋™ ๊ฒ€ํ†  ๊ฒฝ๊ณ  ๋ฐฐ์ง€
โ”œโ”€โ”€ hooks/
โ”‚ โ”œโ”€โ”€ useAnnouncements.ts
โ”‚ โ””โ”€โ”€ useDepartments.ts
โ””โ”€โ”€ lib/
โ””โ”€โ”€ api.ts # API ํด๋ผ์ด์–ธํŠธ
```
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] Next.js ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐํ™” + Tailwind + shadcn/ui ์„ค์ •
[ ] API ํด๋ผ์ด์–ธํŠธ (lib/api.ts) ํƒ€์ž… ์ •์˜
[ ] TanStack Query ์„ค์ • ๋ฐ ์ปค์Šคํ…€ ํ›… ์ž‘์„ฑ
[ ] ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์„ค์ • (NEXT_PUBLIC_API_URL)
```
---
### 4-2. ํ•ต์‹ฌ ์ปดํฌ๋„ŒํŠธ ๊ตฌํ˜„ (Week 8~9)
#### AnnouncementCard (๊ณต์‹œ ์นด๋“œ)
```tsx
// ํ‘œ์‹œ ์ •๋ณด
// - ๊ธฐ๊ด€ ๋ฐฐ์ง€ (์ƒ‰์ƒ ๊ตฌ๋ถ„: ๊ธˆ์œต์œ„=ํŒŒ๋ž‘, ๊ธˆ๊ฐ์›=์ดˆ๋ก, ...)
// - ์นดํ…Œ๊ณ ๋ฆฌ ํƒœ๊ทธ
// - ์ œ๋ชฉ (2์ค„ ๋ง์ค„์ž„)
// - ๊ฒŒ์‹œ์ผ์‹œ (์ƒ๋Œ€์‹œ๊ฐ„: "3์‹œ๊ฐ„ ์ „")
// - ๋งค์นญ ๋ถ€์„œ + ์‹ ๋ขฐ๋„ ๋ฐ”
// - ํ›„๋ณด ๋ถ€์„œ 2~3๊ฐœ (์ž‘์€ ๊ธ€์”จ)
// - ์ˆ˜๋™ ๊ฒ€ํ†  ํ•„์š” ๊ฒฝ๊ณ  (์‹ ๋ขฐ๋„ < 40%)
// - [์›๋ฌธ ๋ณด๊ธฐ] ๋ฒ„ํŠผ
```
#### DeptMatchBadge (๋ถ€์„œ ๋งค์นญ ์‹œ๊ฐํ™”)
```tsx
// ๋†’์Œ (80%+): ์ดˆ๋ก progress bar
// ๋ณดํ†ต (60~79%): ๋…ธ๋ž‘ progress bar
// ๋‚ฎ์Œ (40~59%): ์ฃผํ™ฉ progress bar
// ๊ฒ€ํ†  ํ•„์š” (<40%): ๋นจ๊ฐ• + "โš ๏ธ ์ˆ˜๋™ ๊ฒ€ํ† " ํ…์ŠคํŠธ
```
#### FilterBar (ํ•„ํ„ฐ ์˜์—ญ)
```tsx
// - ๊ธฐ๊ด€ ํƒญ (์ „์ฒด/๊ธˆ์œต์œ„/๊ธˆ๊ฐ์›/KoFIU/๋ฒ•์ œ์ฒ˜/ํ•œ๊ตญ์€ํ–‰)
// ๊ฐ ํƒญ์— ๋ฏธํ™•์ธ ๊ฑด์ˆ˜ ๋ฑƒ์ง€
// - ๊ธฐ๊ฐ„ ํ•„ํ„ฐ (D/W/M/ALL) โ€” ํ† ๊ธ€ ๋ฒ„ํŠผ ๊ทธ๋ฃน
// - ๋ถ€์„œ ํ•„ํ„ฐ ๋“œ๋กญ๋‹ค์šด (๊ฒ€์ƒ‰ ๊ฐ€๋Šฅ, ๋‚ด ๋ถ€์„œ ์ €์žฅ ๊ธฐ๋Šฅ)
// - ์นดํ…Œ๊ณ ๋ฆฌ ํ•„ํ„ฐ ๋“œ๋กญ๋‹ค์šด
// - ์‹ ๋ขฐ๋„ ์ž„๊ณ„๊ฐ’ ์Šฌ๋ผ์ด๋” (0~100%)
```
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] AnnouncementCard ์ปดํฌ๋„ŒํŠธ ๊ตฌํ˜„
[ ] DeptMatchBadge ์ปดํฌ๋„ŒํŠธ (์‹ ๋ขฐ๋„ ์ƒ‰์ƒ ๋ถ„๊ธฐ)
[ ] FilterBar ์ปดํฌ๋„ŒํŠธ (๊ธฐ๊ด€ํƒญ + ๊ธฐ๊ฐ„ + ๋ถ€์„œ ๋“œ๋กญ๋‹ค์šด)
[ ] ๊ณต์‹œ ๋ชฉ๋ก ํŽ˜์ด์ง€ (๋ฌดํ•œ ์Šคํฌ๋กค or ํŽ˜์ด์ง€๋„ค์ด์…˜)
[ ] ๊ณต์‹œ ์ƒ์„ธ ํŽ˜์ด์ง€ (๋ณธ๋ฌธ + Top-5 ํ›„๋ณด ๋ฐ” ์ฐจํŠธ)
[ ] ๋ถ€์„œ๋ณ„ ๋Œ€์‹œ๋ณด๋“œ ํŽ˜์ด์ง€ (๋‚ด ๋ถ€์„œ ํ•„ํ„ฐ ์ €์žฅ)
[ ] ๋กœ๋”ฉ ์Šค์ผˆ๋ ˆํ†ค UI
[ ] ๋นˆ ์ƒํƒœ ์ฒ˜๋ฆฌ ("์กฐ๊ฑด์— ๋งž๋Š” ๊ณต์‹œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค")
```
---
### 4-3. Telegram Bot ์—ฐ๋™ (Week 9~10)
#### Bot ๋™์ž‘ ๋ฐฉ์‹
```
์‹ ๊ทœ ๊ณต์‹œ DB ์ ์žฌ + ๋งค์นญ ์™„๋ฃŒ
โ†“
Redis Stream 'telegram_stream' ์ด๋ฒคํŠธ ๋ฐœํ–‰
โ†“
Telegram Bot ์›Œ์ปค๊ฐ€ ์†Œ๋น„
โ†“
๊ตฌ๋… ์กฐ๊ฑด(๊ธฐ๊ด€, ๋ถ€์„œ) ์ฒดํฌ
โ†“
์กฐ๊ฑด ๋งŒ์กฑ ์‹œ ๋ฉ”์‹œ์ง€ ๋ฐœ์†ก
๋ฉ”์‹œ์ง€ ํ˜•์‹:
๐Ÿ“ข [๊ธˆ์œต์œ„์›ํšŒ] ๋ณด๋„์ž๋ฃŒ
์ œ๋ชฉ: ๋งˆ์ด๋ฐ์ดํ„ฐ ํ™œ์šฉ ๊ธˆ๋ฆฌ์ธํ•˜์š”๊ตฌ ์„œ๋น„์Šค ํ˜์‹ ๊ธˆ์œต 3๊ฑด ์ง€์ •
๋งค์นญ๋ถ€์„œ: ๋””์ง€ํ„ธ๊ธˆ์œต์ด๊ด„๊ณผ (87%)
๊ฒŒ์‹œ: 2026-06-17 14:30
๐Ÿ”— https://fsc.go.kr/...
```
#### Bot ๋ช…๋ น์–ด
```
/start - ๊ตฌ๋… ์‹œ์ž‘ ์•ˆ๋‚ด
/subscribe - ๊ตฌ๋… ์กฐ๊ฑด ์„ค์ • (๊ธฐ๊ด€, ๋ถ€์„œ)
/unsubscribe - ๊ตฌ๋… ํ•ด์ œ
/status - ํ˜„์žฌ ๊ตฌ๋… ์ƒํƒœ ํ™•์ธ
/latest - ์ตœ์‹  5๊ฑด ์กฐํšŒ
```
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] python-telegram-bot ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์„ค์ •
[ ] Bot ์›Œ์ปค (telegram_worker.py) ๊ตฌํ˜„
[ ] ๊ตฌ๋… ์„ค์ • ํ…Œ์ด๋ธ” (telegram_subscriptions) ์ถ”๊ฐ€
[ ] /subscribe ๋ช…๋ น์–ด + ์กฐ๊ฑด ์ €์žฅ ๋กœ์ง
[ ] ์‹ ๋ขฐ๋„ < 40% ๊ฑด ์•Œ๋ฆผ ์ œ์™ธ ์˜ต์…˜
[ ] ๋ฐœ์†ก ์‹คํŒจ ์‹œ ์žฌ์‹œ๋„ ๋กœ์ง (์ตœ๋Œ€ 3ํšŒ)
[ ] ํ”„๋ก ํŠธ์—”๋“œ์—์„œ Telegram ๊ตฌ๋… ๋งํฌ ๋…ธ์ถœ
```
### Step 4 ์™„๋ฃŒ ๊ธฐ์ค€ (Definition of Done)
- [ ] ๊ณต์‹œ ์นด๋“œ + ๋ถ€์„œ ๋งค์นญ ์‹ ๋ขฐ๋„ ๋ฐ” ์ •์ƒ ๋…ธ์ถœ ํ™•์ธ
- [ ] ๊ธฐ๊ด€/๊ธฐ๊ฐ„/๋ถ€์„œ ํ•„ํ„ฐ ์ •์ƒ ๋™์ž‘ ํ™•์ธ
- [ ] ๊ณต์‹œ ์ƒ์„ธ ํŽ˜์ด์ง€ ๋ณธ๋ฌธ + Top-5 ํ›„๋ณด ํ‘œ์‹œ ํ™•์ธ
- [ ] ๋ชจ๋ฐ”์ผ ๋ฐ˜์‘ํ˜• ๋ ˆ์ด์•„์›ƒ ํ™•์ธ (iPhone 14 ๊ธฐ์ค€)
- [ ] Telegram Bot ์‹ ๊ทœ ๊ณต์‹œ ์•Œ๋ฆผ ๋ฐœ์†ก ํ™•์ธ
- [ ] Lighthouse ์„ฑ๋Šฅ ์ ์ˆ˜ โ‰ฅ 85
---
## Step 5. ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ๋ฐ ์šด์˜ ์•ˆ์ •ํ™” (Week 10~13)
### ๋ชฉํ‘œ
์ „์ฒด ์‹œ์Šคํ…œ E2E ๊ฒ€์ฆ + ๋ชจ๋ธ ์„ฑ๋Šฅ ์ธก์ • + ์šด์˜ ๋ชจ๋‹ˆํ„ฐ๋ง ์ฒด๊ณ„ ๊ตฌ์ถ•
### 5-1. E2E ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ (Week 10~11)
#### ํ…Œ์ŠคํŠธ ์‹œ๋‚˜๋ฆฌ์˜ค
```
์‹œ๋‚˜๋ฆฌ์˜ค 1: ์ •์ƒ ์ˆ˜์ง‘ ํ”Œ๋กœ์šฐ
1. ํฌ๋กค๋Ÿฌ ์ˆ˜๋™ ์‹คํ–‰
2. ์‹ ๊ทœ ๊ณต์‹œ DB ์ ์žฌ ํ™•์ธ
3. ๋งค์นญ ์›Œ์ปค ์ž๋™ ์‹คํ–‰
4. dept_matches ์ ์žฌ ํ™•์ธ
5. API ๋ชฉ๋ก ์กฐํšŒ โ†’ ๊ณต์‹œ + ๋ถ€์„œ ๋…ธ์ถœ ํ™•์ธ
6. Telegram ์•Œ๋ฆผ ์ˆ˜์‹  ํ™•์ธ
์‹œ๋‚˜๋ฆฌ์˜ค 2: ์ค‘๋ณต ์ˆ˜์ง‘ ๋ฐฉ์ง€
1. ๊ฐ™์€ ๊ณต์‹œ 2ํšŒ ํฌ๋กค๋ง
2. DB ์ค‘๋ณต ์ ์žฌ 0๊ฑด ํ™•์ธ
์‹œ๋‚˜๋ฆฌ์˜ค 3: ์‹ ๋ขฐ๋„ ๋‚ฎ์€ ๊ฑด ์ฒ˜๋ฆฌ
1. ๊ด€๋ จ์„ฑ ๋‚ฎ์€ ๋ณธ๋ฌธ ๋งค์นญ ์š”์ฒญ
2. ์‹ ๋ขฐ๋„ < 0.4 โ†’ needs_manual_review: true ํ™•์ธ
3. review-queue API์—์„œ ํ•ด๋‹น ๊ฑด ๋…ธ์ถœ ํ™•์ธ
์‹œ๋‚˜๋ฆฌ์˜ค 4: ํฌ๋กค๋Ÿฌ ์žฅ์•  ๋ณต๊ตฌ
1. ๊ธฐ๊ด€ ์‚ฌ์ดํŠธ ์ ‘๊ทผ ๋ถˆ๊ฐ€ ์‹œ๋ฎฌ๋ ˆ์ด์…˜
2. ์—๋Ÿฌ ๋กœ๊ทธ ๊ธฐ๋ก ํ™•์ธ
3. ๋‹ค์Œ ์Šค์ผ€์ค„ ์ฃผ๊ธฐ์— ์ž๋™ ์žฌ์‹œ๋„ ํ™•์ธ
์‹œ๋‚˜๋ฆฌ์˜ค 5: ์ˆ˜๋™ ๋งค์นญ ์žฌ์š”์ฒญ
1. ์ž˜๋ชป ๋งค์นญ๋œ ๊ณต์‹œ ์„ ํƒ
2. /rematch API ํ˜ธ์ถœ (override_dept ์ง€์ •)
3. dept_matches ๊ฐฑ์‹  ํ™•์ธ
4. ํ”„๋ก ํŠธ์—”๋“œ ๊ฐฑ์‹  ํ‘œ์‹œ ํ™•์ธ
```
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] pytest E2E ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ ์ž‘์„ฑ
[ ] ๊ฐ ์‹œ๋‚˜๋ฆฌ์˜ค ์ž๋™ํ™” ํ…Œ์ŠคํŠธ ๊ตฌํ˜„
[ ] ๋ถ€ํ•˜ ํ…Œ์ŠคํŠธ: API ๋™์‹œ 50 ์š”์ฒญ โ†’ p95 ์‘๋‹ต โ‰ค 500ms
[ ] ํฌ๋กค๋Ÿฌ 72์‹œ๊ฐ„ ์—ฐ์† ์‹คํ–‰ ์•ˆ์ •์„ฑ ํ…Œ์ŠคํŠธ
```
---
### 5-2. ๋ชจ๋ธ ์„ฑ๋Šฅ ํ‰๊ฐ€ ๋ฐ ๊ฐœ์„  (Week 11~12)
#### ํ‰๊ฐ€ ๋ฐ์ดํ„ฐ์…‹ ๊ตฌ์„ฑ
```
- ์ˆ˜๋™ ๋ ˆ์ด๋ธ”๋ง: ์‹ค์ œ ๊ณต์‹œ 50๊ฑด์— ๋Œ€ํ•œ ๋‹ด๋‹น ๋ถ€์„œ ์ •๋‹ต ๋ ˆ์ด๋ธ”
- ํ‰๊ฐ€ ์ง€ํ‘œ: Top-1 ์ •ํ™•๋„, Top-5 ์ •ํ™•๋„, ํ‰๊ท  ์‹ ๋ขฐ๋„ ์ ์ˆ˜
- ๋ชฉํ‘œ: Top-1 โ‰ฅ 80%, Top-5 โ‰ฅ 95%
```
#### ๋ชจ๋ธ ๊ฐœ์„  ํฌ์ธํŠธ (๋ชฉํ‘œ ๋ฏธ๋‹ฌ ์‹œ)
```
A. ๋ณธ๋ฌธ ์ „์ฒ˜๋ฆฌ ๊ฐœ์„ 
- ๊ณต์‹œ ์•ž 512 ํ† ํฐ โ†’ ์ œ๋ชฉ + ์•ž 300ํ† ํฐ ํ˜ผํ•ฉ ๋ฐฉ์‹ ์‹œ๋„
- ์›๋ฌธ ๊ธฐ์žฌ ๋‹ด๋‹น๋ถ€์„œ ํ…์ŠคํŠธ๋ฅผ ์ถ”๊ฐ€ ํžŒํŠธ๋กœ ํ™œ์šฉ
B. ์•™์ƒ๋ธ” ๊ฐ€์ค‘์น˜ ํŠœ๋‹
- dense_weight / tfidf_weight / bm25_weight ๊ทธ๋ฆฌ๋“œ ์„œ์น˜
- ํ˜„์žฌ ๊ธฐ๋ณธ๊ฐ’ โ†’ ์ตœ์ ๊ฐ’ ํƒ์ƒ‰
C. Stage 2 ํ•™์Šต ๋ฐ์ดํ„ฐ ๋ณด๊ฐ•
- ์‹ค์ œ ๊ณต์‹œ-๋ถ€์„œ ๋งคํ•‘ ๋ ˆ์ด๋ธ” ๋ฐ์ดํ„ฐ๋ฅผ ์ถ”๊ฐ€ ํ•™์Šต์Œ์œผ๋กœ ํ™œ์šฉ
- Negative ์ƒ˜ํ”Œ ์ „๋žต ๊ฐœ์„ 
```
#### ๋ชจ๋ธ ์žฌํ•™์Šต ํŒŒ์ดํ”„๋ผ์ธ ์ž๋™ํ™”
```
dept.xlsx ์—…๋ฐ์ดํŠธ ๊ฐ์ง€
โ†“
Stage 2 ์žฌํ•™์Šต ์ž๋™ ํŠธ๋ฆฌ๊ฑฐ (์ฃผ 1ํšŒ ๋ฐฐ์น˜)
โ†“
์‹ ๊ทœ ๋ชจ๋ธ ํ‰๊ฐ€ (์„ฑ๋Šฅ ํšŒ๊ท€ ์—†๋Š”์ง€ ํ™•์ธ)
โ†“
symlink ๊ต์ฒด๋กœ ๋ฌด์ค‘๋‹จ ๋ชจ๋ธ ์—…๋ฐ์ดํŠธ
โ†“
model_version ์—…๋ฐ์ดํŠธ ๊ธฐ๋ก
```
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] ํ‰๊ฐ€ ๋ฐ์ดํ„ฐ์…‹ 50๊ฑด ์ˆ˜๋™ ๋ ˆ์ด๋ธ”๋ง
[ ] ๋ชจ๋ธ ์„ฑ๋Šฅ ํ‰๊ฐ€ ์Šคํฌ๋ฆฝํŠธ ์ž‘์„ฑ
[ ] Top-1 / Top-5 ์ •ํ™•๋„ ์ธก์ • ๋ฐ ๊ฒฐ๊ณผ ๋ฌธ์„œํ™”
[ ] ๋ฏธ๋‹ฌ ์‹œ ๊ฐœ์„ ์•ˆ A/B/C ์ค‘ ์ ์šฉ
[ ] dept.xlsx ๋ณ€๊ฒฝ ๊ฐ์ง€ + ์žฌํ•™์Šต ๋ฐฐ์น˜ ์Šคํฌ๋ฆฝํŠธ
[ ] ๋ชจ๋ธ ๋ฒ„์ „ ๊ด€๋ฆฌ ์ฒด๊ณ„ ์ˆ˜๋ฆฝ
```
---
### 5-3. ์šด์˜ ๋ชจ๋‹ˆํ„ฐ๋ง ์ฒด๊ณ„ ๊ตฌ์ถ• (Week 12~13)
#### ๋ชจ๋‹ˆํ„ฐ๋ง ๋Œ€์‹œ๋ณด๋“œ (Grafana ๋˜๋Š” ๋‚ด์žฅ)
```
ํŒจ๋„ 1: ๊ธฐ๊ด€๋ณ„ ์‹œ๊ฐ„๋‹น ์ˆ˜์ง‘ ๊ฑด์ˆ˜ (์‹œ๊ณ„์—ด ๊ทธ๋ž˜ํ”„)
ํŒจ๋„ 2: ํฌ๋กค๋ง ์„ฑ๊ณต๋ฅ  (๋ชฉํ‘œ: โ‰ฅ 99%)
ํŒจ๋„ 3: ๋งค์นญ ์‹ ๋ขฐ๋„ ๋ถ„ํฌ ํžˆ์Šคํ† ๊ทธ๋žจ
ํŒจ๋„ 4: ์ˆ˜๋™ ๊ฒ€ํ†  ํ ๋ˆ„์  ๊ฑด์ˆ˜
ํŒจ๋„ 5: API ์‘๋‹ต ์‹œ๊ฐ„ p50/p95
ํŒจ๋„ 6: ๋ชจ๋ธ ์„œ๋ฒ„ ๋ฉ”๋ชจ๋ฆฌ/CPU ์‚ฌ์šฉ๋ฅ 
```
#### ์•Œ๋ฆผ ์„ค์ •
```
- ํฌ๋กค๋Ÿฌ ์—ฐ์† 3ํšŒ ์‹คํŒจ โ†’ ์ฆ‰์‹œ ์•Œ๋ฆผ (Slack/์ด๋ฉ”์ผ)
- ๋งค์นญ ์›Œ์ปค 5๋ถ„ ์ด์ƒ ์ค‘๋‹จ โ†’ ์ฆ‰์‹œ ์•Œ๋ฆผ
- ์ˆ˜๋™ ๊ฒ€ํ†  ํ 50๊ฑด ์ดˆ๊ณผ โ†’ ๊ฒฝ๊ณ  ์•Œ๋ฆผ
- API ์˜ค๋ฅ˜์œจ > 1% โ†’ ๊ฒฝ๊ณ  ์•Œ๋ฆผ
```
#### ๋ฐฐํฌ ๊ตฌ์„ฑ (Docker Compose โ†’ ์šด์˜ ํ™˜๊ฒฝ)
```yaml
services:
postgres: # DB
redis: # ๋ฉ”์‹œ์ง€ ํ + ์บ์‹œ
crawler: # ํฌ๋กค๋Ÿฌ + ์Šค์ผ€์ค„๋Ÿฌ
matcher: # ๋ชจ๋ธ ์„œ๋ฒ„ (FastAPI)
worker: # ๋งค์นญ ์›Œ์ปค + Telegram ์›Œ์ปค
api: # REST API ์„œ๋ฒ„ (FastAPI)
frontend: # Next.js (๋˜๋Š” Vercel ๋ฐฐํฌ)
nginx: # ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ + HTTPS
```
#### ์šด์˜ ๋ฌธ์„œ ์ž‘์„ฑ
```
[ ] ์„œ๋น„์Šค ์•„ํ‚คํ…์ฒ˜ ๋‹ค์ด์–ด๊ทธ๋žจ
[ ] ํฌ๋กค๋Ÿฌ ์ถ”๊ฐ€ ๊ฐ€์ด๋“œ (์‹ ๊ทœ ๊ธฐ๊ด€ ์˜จ๋ณด๋”ฉ)
[ ] ๋ชจ๋ธ ์žฌํ•™์Šต ์šด์˜ ์ ˆ์ฐจ์„œ
[ ] ์žฅ์•  ๋Œ€์‘ ๋Ÿฐ๋ถ (Runbook)
[ ] API ์‚ฌ์šฉ ๊ฐ€์ด๋“œ (๋‚ด๋ถ€ ๋ฐฐํฌ์šฉ)
```
#### ์ž‘์—… ํ•ญ๋ชฉ
```
[ ] Prometheus + Grafana ๋˜๋Š” ๋‚ด์žฅ ์ง€ํ‘œ ์ˆ˜์ง‘ ์„ค์ •
[ ] ํฌ๋กค๋Ÿฌ/์›Œ์ปค/API ํ•ต์‹ฌ ์ง€ํ‘œ ๊ณ„์ธก ์ฝ”๋“œ ์ถ”๊ฐ€
[ ] ์•Œ๋ฆผ ์ž„๊ณ„๊ฐ’ ์„ค์ • ๋ฐ ์ˆ˜์‹  ์ฑ„๋„ ์—ฐ๊ฒฐ
[ ] Docker Compose ์šด์˜ ๋ฐฐํฌ ํŒŒ์ผ ์ตœ์ข…ํ™”
[ ] ์ „์ฒด ์šด์˜ ๋ฌธ์„œ ์ž‘์„ฑ
[ ] ํŒŒ์ผ๋Ÿฟ ์šด์˜ (๋‚ด๋ถ€ ์‚ฌ์šฉ์ž 3~5๋ช… ๋Œ€์ƒ, 2์ฃผ)
```
### Step 5 ์™„๋ฃŒ ๊ธฐ์ค€ (Definition of Done)
- [ ] E2E ์‹œ๋‚˜๋ฆฌ์˜ค 5๊ฐœ ์ „๋ถ€ ํ†ต๊ณผ
- [ ] Top-1 ์ •ํ™•๋„ โ‰ฅ 80% ๋‹ฌ์„ฑ ํ™•์ธ
- [ ] ๋ชจ๋‹ˆํ„ฐ๋ง ๋Œ€์‹œ๋ณด๋“œ ํ•ต์‹ฌ ์ง€ํ‘œ 6๊ฐœ ์ •์ƒ ํ‘œ์‹œ
- [ ] ํฌ๋กค๋Ÿฌ 72์‹œ๊ฐ„ ๋ฌด์ค‘๋‹จ ์•ˆ์ • ์šด์˜ ํ™•์ธ
- [ ] ์šด์˜ ๋ฌธ์„œ 4์ข… ์™„์„ฑ
- [ ] ๋‚ด๋ถ€ ํŒŒ์ผ๋Ÿฟ ์‚ฌ์šฉ์ž ํ”ผ๋“œ๋ฐฑ ์ˆ˜๋ ด ๋ฐ ์šฐ์„ ์ˆœ์œ„ ๋ฒ„๊ทธ ์ˆ˜์ •
---
## ๊ธฐ์ˆ  ์Šคํƒ ์š”์•ฝ
| ์˜์—ญ | ๊ธฐ์ˆ  |
|------|------|
| ํฌ๋กค๋Ÿฌ | Python, requests, BeautifulSoup4, Playwright (JS๋ Œ๋”๋ง fallback) |
| ์Šค์ผ€์ค„๋Ÿฌ | APScheduler |
| ๋ฉ”์‹œ์ง€ ํ | Redis Stream |
| DB | PostgreSQL 16 + SQLAlchemy + Alembic |
| ๋ชจ๋ธ | SentenceTransformer (jhgan/ko-sroberta-multitask), TF-IDF, BM25 |
| ๋ชจ๋ธ ์„œ๋ฒ„ | FastAPI + uvicorn |
| API ์„œ๋ฒ„ | FastAPI + uvicorn + slowapi |
| ํ”„๋ก ํŠธ์—”๋“œ | Next.js 14, Tailwind CSS, shadcn/ui, TanStack Query |
| ์•Œ๋ฆผ | python-telegram-bot |
| ์ปจํ…Œ์ด๋„ˆ | Docker + Docker Compose |
| ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ | Nginx |
| ๋ชจ๋‹ˆํ„ฐ๋ง | Prometheus + Grafana (๋˜๋Š” ๋‚ด์žฅ ์ง€ํ‘œ API) |
---
## ๋ฆฌ์Šคํฌ ๋ฐ ์˜์กด์„ฑ
| ๋ฆฌ์Šคํฌ | ๋ฐœ์ƒ ๋‹จ๊ณ„ | ๋Œ€์‘ |
|--------|----------|------|
| ๊ธฐ๊ด€ ์‚ฌ์ดํŠธ JS ๋ Œ๋”๋ง (ํฌ๋กค๋Ÿฌ ํŒŒ์‹ฑ ์‹คํŒจ) | Step 1 | Playwright fallback ๊ตฌํ˜„ |
| GPU ๋ฏธํ™•๋ณด๋กœ ๋ชจ๋ธ ์žฌํ•™์Šต ์ง€์—ฐ | Step 2 | Google Colab ์ฆ‰์‹œ ์‚ฌ์šฉ ๊ฐ€๋Šฅ, ์‚ฌ๋‚ด GPU ๋ณ‘ํ–‰ ํ™•๋ณด |
| Top-1 ์ •ํ™•๋„ 80% ๋ฏธ๋‹ฌ | Step 5 | ํ‰๊ฐ€ ํ›„ ๊ฐœ์„ ์•ˆ A/B/C ์ˆœ์ฐจ ์ ์šฉ, ์ผ์ • 1์ฃผ ์˜ˆ๋น„ |
| dept.xlsx ๋ถ€์„œ ์ •๋ณด ๋ฏธ๊ฐฑ์‹  | ์šด์˜ ์ „๋ฐ˜ | Step 5์—์„œ ์ž๋™ํ™” ํŒŒ์ดํ”„๋ผ์ธ ๊ตฌ์ถ•์œผ๋กœ ํ•ด์†Œ |
| ๊ธฐ๊ด€ ์‚ฌ์ดํŠธ ๊ตฌ์กฐ ๋ณ€๊ฒฝ | ์šด์˜ ์ „๋ฐ˜ | ๊ธฐ๊ด€๋ณ„ ํฌ๋กค๋Ÿฌ ๋…๋ฆฝ ๋ชจ๋“ˆ + ์‹คํŒจ ์•Œ๋ฆผ์œผ๋กœ ๋น ๋ฅธ ์ˆ˜์ • |