perf(infra): NAS CPU 중기 2건 + 1건 보류 (CHECK_POINT 🟡)

#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) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 10:42:43 +09:00
parent 7a470aad44
commit 20514193e8
4 changed files with 101 additions and 40 deletions

View File

@@ -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 빈도는 박재오 결정 대기.
---

View File

@@ -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

View File

@@ -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")

View File

@@ -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()