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 ์ธํ„ฐํŽ˜์ด์Šค

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 ๊ธˆํ†ต์œ„ ์˜๊ฒฐ ๋ณ„๋„ ์นดํ…Œ๊ณ ๋ฆฌ

๊ณตํ†ต ์ฒ˜๋ฆฌ ๋กœ์ง

# ์ค‘๋ณต ๋ฐฉ์ง€: 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)

# 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)

๋ชจ๋ธ ์„ฑ๋Šฅ ๊ธฐ์ค€ ๊ฒ€์ฆ

# ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค (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 ์—”๋“œํฌ์ธํŠธ

# 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"
}

๋ชจ๋ธ ์„œ๋ฒ„ ์„ค๊ณ„ ํฌ์ธํŠธ

# ์•ฑ ์‹œ์ž‘ ์‹œ 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 ์ฟผ๋ฆฌ ์ตœ์ ํ™”

-- ์ž์ฃผ ์‚ฌ์šฉ๋˜๋Š” ํ•„ํ„ฐ ์กฐํ•ฉ์— ์ธ๋ฑ์Šค ์ถ”๊ฐ€
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 (๊ณต์‹œ ์นด๋“œ)

// ํ‘œ์‹œ ์ •๋ณด
// - ๊ธฐ๊ด€ ๋ฐฐ์ง€ (์ƒ‰์ƒ ๊ตฌ๋ถ„: ๊ธˆ์œต์œ„=ํŒŒ๋ž‘, ๊ธˆ๊ฐ์›=์ดˆ๋ก, ...)
// - ์นดํ…Œ๊ณ ๋ฆฌ ํƒœ๊ทธ
// - ์ œ๋ชฉ (2์ค„ ๋ง์ค„์ž„)
// - ๊ฒŒ์‹œ์ผ์‹œ (์ƒ๋Œ€์‹œ๊ฐ„: "3์‹œ๊ฐ„ ์ „")
// - ๋งค์นญ ๋ถ€์„œ + ์‹ ๋ขฐ๋„ ๋ฐ”
// - ํ›„๋ณด ๋ถ€์„œ 2~3๊ฐœ (์ž‘์€ ๊ธ€์”จ)
// - ์ˆ˜๋™ ๊ฒ€ํ†  ํ•„์š” ๊ฒฝ๊ณ  (์‹ ๋ขฐ๋„ < 40%)
// - [์›๋ฌธ ๋ณด๊ธฐ] ๋ฒ„ํŠผ

DeptMatchBadge (๋ถ€์„œ ๋งค์นญ ์‹œ๊ฐํ™”)

// ๋†’์Œ (80%+): ์ดˆ๋ก progress bar
// ๋ณดํ†ต (60~79%): ๋…ธ๋ž‘ progress bar
// ๋‚ฎ์Œ (40~59%): ์ฃผํ™ฉ progress bar
// ๊ฒ€ํ†  ํ•„์š” (<40%): ๋นจ๊ฐ• + "โš ๏ธ ์ˆ˜๋™ ๊ฒ€ํ† " ํ…์ŠคํŠธ

FilterBar (ํ•„ํ„ฐ ์˜์—ญ)

// - ๊ธฐ๊ด€ ํƒญ (์ „์ฒด/๊ธˆ์œต์œ„/๊ธˆ๊ฐ์›/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 โ†’ ์šด์˜ ํ™˜๊ฒฝ)

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์—์„œ ์ž๋™ํ™” ํŒŒ์ดํ”„๋ผ์ธ ๊ตฌ์ถ•์œผ๋กœ ํ•ด์†Œ
๊ธฐ๊ด€ ์‚ฌ์ดํŠธ ๊ตฌ์กฐ ๋ณ€๊ฒฝ ์šด์˜ ์ „๋ฐ˜ ๊ธฐ๊ด€๋ณ„ ํฌ๋กค๋Ÿฌ ๋…๋ฆฝ ๋ชจ๋“ˆ + ์‹คํŒจ ์•Œ๋ฆผ์œผ๋กœ ๋น ๋ฅธ ์ˆ˜์ •