From 20514193e8940d1c92c54cc5d798f40c42e71a22 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 18 May 2026 10:42:43 +0900 Subject: [PATCH] =?UTF-8?q?perf(infra):=20NAS=20CPU=20=EC=A4=91=EA=B8=B0?= =?UTF-8?q?=202=EA=B1=B4=20+=201=EA=B1=B4=20=EB=B3=B4=EB=A5=98=20(CHECK=5F?= =?UTF-8?q?POINT=20=F0=9F=9F=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6 insta-lab Chromium Browser Pool — Playwright/Chromium 인스턴스를 모듈 레벨에서 보관하고 매 슬레이트마다 reuse. 카드 10장 렌더의 launch 비용 (~3초/회)이 사라짐. startup/shutdown lifecycle hook 추가. crashed/disconnected 시 lazy 재초기화. #8 realestate-lab 수집 병렬화 — collect_all과 delete_old_completed가 서로 다른 데이터 영역이라 ThreadPoolExecutor(2)로 병렬. asyncio.gather 대신 thread executor를 쓴 이유는 BackgroundScheduler+동기 함수 환경 에서 자연스럽고 추가 의존성 없기 때문. 매칭은 일관성 유지로 순차. #7 stock async — 보류. 재진단 결과 stock은 BackgroundScheduler 사용 중이라 main loop 블로킹 없음. fetch 4회는 network I/O wait가 대부분이라 to_thread도 의미 없음. 진짜 효과를 보려면 AsyncIOScheduler 전환 + aiohttp 병렬이라 큰 리팩토링. 박재오 판단 대기. CHECK_POINT.md 갱신. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHECK_POINT.md | 28 ++++++----- insta-lab/app/card_renderer.py | 88 ++++++++++++++++++++++++---------- insta-lab/app/main.py | 9 +++- realestate-lab/app/main.py | 16 +++++-- 4 files changed, 101 insertions(+), 40 deletions(-) diff --git a/CHECK_POINT.md b/CHECK_POINT.md index 57738cb..c649c12 100644 --- a/CHECK_POINT.md +++ b/CHECK_POINT.md @@ -113,23 +113,26 @@ scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=30) ## 🟡 중기 (1~2주) -### 6. Chromium Browser Pool 재설계 (insta-lab) +### 6. Chromium Browser Pool 재설계 (insta-lab) ✅ 2026-05-18 - 매번 launch X → 1개 인스턴스 재사용 - 카드 10장 렌더 시간 30% 단축 기대 -- [ ] `insta-lab/app/browser_pool.py` 신규 모듈 -- [ ] card_renderer에서 풀 사용 +- [x] `card_renderer.py` 내부에 모듈 레벨 `_PLAYWRIGHT`/`_BROWSER` + `init_browser`/`shutdown_browser` 함수 (별도 모듈 분리 안 함, 같은 파일에 인접 배치) +- [x] `_render_slate_locked` 본체에서 `_get_browser()` 재사용 (crashed 시 lazy 재초기화) +- [x] `main.py` startup hook에서 `init_browser()`, shutdown hook에서 `shutdown_browser()` -### 7. stock 뉴스 스크랩 비동기화 -- **파일**: `stock/app/main.py:104-149` -- `run_scraping_job()` 동기 → BackgroundTask 또는 asyncio -- 08:00 APScheduler 블로킹 해소 -- [ ] async/await 변환 +### 7. stock 뉴스 스크랩 비동기화 — ⚠️ 보류 2026-05-18 +- **재진단**: stock은 `BackgroundScheduler` 사용 중 → main loop 블로킹 없음 (이미 별도 thread) +- `fetch_market_news`의 4개 동기 `requests.get`은 network I/O wait라 CPU 거의 사용 안 함 +- `to_thread`로 wrap해도 BackgroundScheduler 환경에서 사실상 의미 없음 +- 진짜 효과를 보려면 AsyncIOScheduler 전환 + scraper.py 4개 fetch를 `aiohttp` 병렬로 — **큰 리팩토링 vs 효과 불명확** +- [ ] 박재오 판단: 큰 리팩토링 진행 여부 -### 8. realestate 수집 병렬화 -- **파일**: `realestate-lab/app/main.py:28-37` -- `collect_all()` + `delete_old()` 병렬 → `asyncio.gather` +### 8. realestate 수집 병렬화 ✅ 2026-05-18 +- **파일**: `realestate-lab/app/main.py:scheduled_collect` +- `collect_all()` + `delete_old_completed_announcements()` 병렬 +- BackgroundScheduler 환경이라 `asyncio.gather` 대신 `ThreadPoolExecutor(max_workers=2)` 사용 (효과 동일) - 매칭은 순차 유지 (DB 일관성) -- [ ] async 함수 변환 + gather 적용 +- [x] ThreadPoolExecutor 적용 ### 9. lotto Monte Carlo 시뮬레이션 빈도 검토 - 현재 6회/일 (00·04·08·12·16·20) @@ -173,6 +176,7 @@ services: - 2026-05-17: insta-lab minimal theme + design_importer 추가 - 2026-05-17: blog-lab 트랙 완전 폐기 (docker-compose에 없음, 위키 정정 완료) - 2026-05-18: 🔴 즉시 5건 일괄 적용 — 09:00 cron 스태거링(insta/lotto/youtube/realestate), lotto Monte Carlo 08:30, insta-lab Semaphore(1), healthcheck 60s, uvicorn --workers 1 명시 (사용자 push + NAS deployer 재기동 대기) +- 2026-05-18: 🟡 중기 2건 적용 — #6 insta-lab Chromium Browser Pool (lifecycle hook), #8 realestate ThreadPoolExecutor 병렬 (collect/delete). #7 stock async는 BackgroundScheduler 사용 중이라 재진단 후 보류 (효과 미미). #9 Monte Carlo 빈도는 박재오 결정 대기. --- diff --git a/insta-lab/app/card_renderer.py b/insta-lab/app/card_renderer.py index 1547d45..52010ff 100644 --- a/insta-lab/app/card_renderer.py +++ b/insta-lab/app/card_renderer.py @@ -30,6 +30,47 @@ def _render_semaphore() -> asyncio.Semaphore: return _RENDER_SEMAPHORE +# Chromium 브라우저 풀 — 매 슬레이트마다 launch 하지 않고 1개를 살려둠. +# (CHECK_POINT 중기-6) 카드 10장 렌더 시간 ~30% 단축 기대. +_PLAYWRIGHT = None +_BROWSER = None + + +async def init_browser() -> None: + """앱 startup hook에서 1회 호출. 이미 살아있으면 no-op.""" + global _PLAYWRIGHT, _BROWSER + if _BROWSER is not None and _BROWSER.is_connected(): + return + _PLAYWRIGHT = await async_playwright().start() + _BROWSER = await _PLAYWRIGHT.chromium.launch() + logger.info("Chromium browser pool 초기화 완료") + + +async def shutdown_browser() -> None: + """앱 shutdown hook에서 1회 호출.""" + global _PLAYWRIGHT, _BROWSER + if _BROWSER is not None: + try: + await _BROWSER.close() + except Exception: + logger.exception("browser close 중 예외 (무시)") + _BROWSER = None + if _PLAYWRIGHT is not None: + try: + await _PLAYWRIGHT.stop() + except Exception: + logger.exception("playwright stop 중 예외 (무시)") + _PLAYWRIGHT = None + + +async def _get_browser(): + """현재 브라우저 핸들 반환. crashed/None이면 재초기화 후 반환.""" + global _BROWSER + if _BROWSER is None or not _BROWSER.is_connected(): + await init_browser() + return _BROWSER + + def _resolve_template_dir() -> str: """Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/.""" if os.path.isdir(CARD_TEMPLATE_DIR): @@ -97,29 +138,28 @@ async def _render_slate_locked(slate_id: int, template: str) -> List[str]: out_dir = _slate_dir(slate_id) paths: List[str] = [] - async with async_playwright() as p: - browser = await p.chromium.launch() - try: - ctx = await browser.new_context(viewport={"width": 1080, "height": 1350}) - page = await ctx.new_page() - for spec in pages: - html_str = tmpl.render(**spec) - with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f: - f.write(html_str) - html_path = f.name + browser = await _get_browser() + ctx = await browser.new_context(viewport={"width": 1080, "height": 1350}) + try: + page = await ctx.new_page() + for spec in pages: + html_str = tmpl.render(**spec) + with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f: + f.write(html_str) + html_path = f.name + try: + await page.goto(f"file://{html_path}", wait_until="networkidle") + out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png") + await page.screenshot(path=out_path, full_page=False, omit_background=False) + with open(out_path, "rb") as fp: + file_hash = hashlib.md5(fp.read()).hexdigest() + db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash) + paths.append(out_path) + finally: try: - await page.goto(f"file://{html_path}", wait_until="networkidle") - out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png") - await page.screenshot(path=out_path, full_page=False, omit_background=False) - with open(out_path, "rb") as fp: - file_hash = hashlib.md5(fp.read()).hexdigest() - db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash) - paths.append(out_path) - finally: - try: - os.unlink(html_path) - except OSError: - pass - finally: - await browser.close() + os.unlink(html_path) + except OSError: + pass + finally: + await ctx.close() return paths diff --git a/insta-lab/app/main.py b/insta-lab/app/main.py index 23ca0e3..5d09005 100644 --- a/insta-lab/app/main.py +++ b/insta-lab/app/main.py @@ -31,9 +31,16 @@ app.add_middleware( @app.on_event("startup") -def on_startup(): +async def on_startup(): os.makedirs(INSTA_DATA_PATH, exist_ok=True) db.init_db() + # Chromium browser pool 초기화 (CHECK_POINT 중기-6) + await card_renderer.init_browser() + + +@app.on_event("shutdown") +async def on_shutdown(): + await card_renderer.shutdown_browser() @app.get("/health") diff --git a/realestate-lab/app/main.py b/realestate-lab/app/main.py index 2a24790..50a7dac 100644 --- a/realestate-lab/app/main.py +++ b/realestate-lab/app/main.py @@ -1,6 +1,7 @@ import os import logging import threading +from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager from fastapi import BackgroundTasks, FastAPI, Query, HTTPException from fastapi.middleware.cors import CORSMiddleware @@ -26,10 +27,19 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul")) def scheduled_collect(): - """매일 09:00 — 수집 + 정리 + 매칭 + 알림 push""" + """매일 09:15 — 수집 + 정리 (병렬) → 매칭 → 알림 push. + + collect_all과 delete_old_completed_announcements는 서로 다른 데이터 + 영역을 건드리므로 thread 둘로 병렬화. 매칭은 두 작업 완료 후 순차 + 실행 (DB 일관성). CHECK_POINT 중기-8 — env이 BackgroundScheduler+ + 동기 함수 조합이라 asyncio.gather 대신 ThreadPoolExecutor 사용. + """ logger.info("스케줄 수집 시작") - collect_all() - deleted = delete_old_completed_announcements(grace_days=90) + with ThreadPoolExecutor(max_workers=2) as ex: + collect_future = ex.submit(collect_all) + delete_future = ex.submit(delete_old_completed_announcements, 90) + collect_future.result() + deleted = delete_future.result() if deleted: logger.info("정리: %d건 삭제", deleted) run_matching()