- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes) - 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에 안내용 README.md 추가 - CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가 - docs/README.md 문서 지도 갱신
110 KiB
DMF Crawler 아키텍처 확정안
이 문서의 역할: 후보 3안과 3개 렌즈 심사를 종합해 구현이 그대로 시작될 수 있는 단일 설계 정본을 확정한다. 이 문서의 디렉터리 트리를 그대로 만들고 모듈 계약대로 채우면 프로젝트가 완성된다.
상위 문서:
docs/00-REQUIREMENTS.md(요구 SSOT),docs/design/00-DATA-SOURCE-DECISION.md(데이터 소스 SSOT),docs/research/05a-agy-cli-ssot.md(agy SSOT),docs/research/06-xlsx-linking-and-formatting.md(xlsx SSOT),docs/research/07-pharma-excel-dashboard-design.md(대시보드 SSOT). 충돌 시 이 다섯 문서가 우선한다.
확정일: 2026-09-02 대상 환경: Windows 11 Pro 10.0.26220, Python 3.14.6 (실측), PowerShell 7 상태: ✅ 확정 — M0 착수 가능
2026-09-03 정정: 사용자 요구에 따라 공식 API 단일 소스 전제를 수정한다. 수집 경로는 ① 인증키가 있으면 공식 Open API 우선 ② 인증키가 없거나 source.mode=browser 이면 AGY headful/visible Chrome 으로 의약품안전나라 CCBAC03 xlsx 다운로드다. 수동 smoke run_20260903_130332 에서 visible Chrome + https://nedrug.mfds.go.kr/pbp/CCBAC03/getExcel 다운로드, 9,816건 정규화·저장·리포트 생성까지 성공했다. AGY 권한은 mcp(chrome-devtools/*), execute_url(nedrug.mfds.go.kr)만 좁게 허용한다. AI 요약용 headless agy 호출과 수집용 headful agy 호출은 별개다. 아래 ADR 중 ADR-01/02/06/15 의 원문은 이 정정이 우선한다.
0. 확정 요약
- 뼈대는 후보 B(SQLite 이벤트 소싱 + 계층 분리) 다. 3개 렌즈 중 2개(실패 내성 7.5, 진화 7.0)에서 1위이며, 운영 렌즈가 A를 뽑으면서도 "A 채택의 조건"으로 요구한 다섯 개 이식 항목이 전부 B의 핵심이었다.
- A(단일 파이프라인)에서 이식: idempotency 가드, 서브커맨드 체계(
run/backfill/report-only/doctor), 설정 파일 1개(TOML, stdlibtomllib), 의존성 최소주의,agy조건부 호출(변화 없으면 미호출). - C(플러그인+오케스트레이터)에서 이식:
Retry-After절대 우선 + full jitter 백오프 + 조건부 요청, 3상태 서킷 브레이커(CLOSED/OPEN/HALF_OPEN), 스테이지 체크포인트 재개, Event Log 병행 기록, AI 제안 자동 적용 금지 원칙. - C에서 이식하지 않은 것: DAG 위상정렬, 문자열 동적 import 레지스트리, 소스 플러그인 3종 스텁, 설정 5분할. 요구사항이 단일 공식 API 소스를 확정했고 해외 소스는 명시적 비목표이므로 소스 추상화는 근거를 잃었다.
- 세 후보 모두가 뚫려 있던 구멍을 신규로 메웠다: HTTP 200 + 빈/오염 응답 판정, diff 안전장치 5종(요구 R2.2), xlsx 원자적 교체, 자동 백업(
VACUUM INTO), 디스크 여유 점검, 마이그레이션 롤백 경로. - 세 후보 모두가 몰랐던 요구를 반영했다: 요구 SSOT의 R6(비개발자용 GUI 온보딩 마법사)·R7(강제 모달 복구 창). 배치는 UI를 절대 띄우지 않고 알림 의도만 기록하며, 대화형 세션에서 도는 별도 작업이 그것을 화면에 띄운다 — 이것이 S4U 세션에 데스크톱이 없다는 Windows 제약(3개 렌즈가 모두 지적)의 유일한 구조적 해법이다.
- 온보딩과 복구는 같은 컴포넌트다(요구 4절).
checks.py의 진단 엔진 하나를 CLI(doctor)와 GUI(onboard)가 각각 렌더링할 뿐이다. - 런타임 의존성은 3개(
httpx,XlsxWriter,jsonschema).pandas·openpyxl·LibreOffice 재계산·PyYAML은 전부 기각했다(부록 참조). agy는 두 용도다. 수집용agy는source.mode=browser또는 auto+키 없음에서 headful Chrome 으로 xlsx 를 받는다. 요약용agy는 diff 이후 부가 계층이며 죽어도 xlsx는 나온다. 스키마 레벨에서enrichment테이블을events와 분리해 이를 강제한다.- 과잉 설계 억제: 소스 추상화·DAG 엔진·웹 대시보드·해외 소스는 "향후 확장 지점"으로만 표시하고 코드를 만들지 않는다.
1. 설계 결정 기록(ADR)
각 행은 하나의 되돌리기 어려운 결정이다. "대안"은 실제로 검토한 것만 적었다.
| # | 결정 | 대안 | 선택 이유 | 트레이드오프 |
|---|---|---|---|---|
| ADR-01 | 데이터 소스는 공식 API 우선 + AGY headful browser 폴백/강제 모드 | 공식 API 단일, nedrug HTML 파싱, 다중 플러그인 | 인증키가 있으면 공식 API가 가장 단순하고 안정적이다. 단, 사용자가 여러 차례 AGY가 headful Chrome 으로 사람처럼 수집해야 한다고 확정했으므로, source.mode=auto+키 없음 또는 source.mode=browser 에서는 AGY가 visible Chrome 으로 CCBAC03/getExcel xlsx 를 다운로드한다 |
공식 API 7필드와 nedrug xlsx 필드가 다르므로 sheet XML 검증·헤더 alias 매핑이 필요하다. CCBAC03 xlsx 의 파일명은 generic 하게 의약품등심사결과공개.xlsx 일 수 있어 파일명만으로 wrong export 라고 판단하지 않는다. 봇 차단 가능성은 0으로 보장하지 않으며 스텔스/우회 기법은 쓰지 않는다 |
| ADR-02 | 무거운 플러그인 registry 는 만들지 않지만 수집 분기는 둔다. source_mfds.py(API) + agy/browser_collect.py(headful xlsx) |
후보 B의 SourceAdapter, 후보 C의 sources.yaml + 문자열 동적 import |
소스는 2개가 됐지만 동적 import registry 는 여전히 과하다. 파이프라인 stage_fetch 가 source.mode 와 키 존재 여부로 명시 분기한다 |
세 번째 소스가 생기면 그때 fetch_all 프로토콜 승격을 검토한다 |
| ADR-03 | 저장소는 SQLite 단일 파일 + 이벤트 소싱. 일자별 전량 스냅샷과 이벤트를 append-only로 영구 축적 | CSV/Parquet 파일, 최신 상태만 덮어쓰는 단순 테이블, PostgreSQL | diff의 정확성은 "직전 상태와의 완전한 비교"를 요구하고, RA 실무는 몇 달 전 상태 소급 조회를 요구한다. 트랜잭션·인덱스·부분쓰기 방어가 파일 하나로 해결된다 | 파일 하나가 단일 장애점이다 → ADR-22(자동 백업)로 상쇄. DB가 연 단위로 커진다 → 원문 아카이브에 보존 기간을 둔다 |
| ADR-04 | 스키마 진화는 번호순 SQL 마이그레이션 러너 + schema_version 테이블. 적용 직전 VACUUM INTO로 백업본을 뜬다 |
alembic, 수작업 ALTER(후보 A·C), 마이그레이션 없음 |
앞으로 올 변경 다수가 스키마 변경이다(진화 렌즈). alembic은 ORM 없는 프로젝트에 과하다. 백업본이 곧 롤백 경로여서 세 후보가 공통으로 빠뜨린 "롤백 없음" 결함이 수십 줄로 해소된다 | 다운 마이그레이션 SQL은 쓰지 않는다. 되돌리기 = 백업본 복원 + 코드 되돌리기. 1인 운영에는 충분하다 |
| ADR-05 | HTTP 클라이언트는 httpx (동기 Client) |
requests, stdlib urllib.request, aiohttp, Scrapy |
데이터 소스 SSOT §9가 params= 자동 인코딩을 전제로 Decoding 키 사용을 확정했다(인코딩 키 혼동은 이 API의 1위 실패 원인). httpx는 타임아웃을 연결/읽기로 분리해 지정할 수 있어 배치에 유리하다 |
의존성 1개 추가. requests와 실질 차이는 작지만 SSOT가 이미 이 전제로 쓰여 있어 일관성을 택했다 |
| ADR-06 | Playwright/Selenium 은 쓰지 않고, 브라우저가 필요할 때는 AGY + chrome-devtools MCP 로 headful Chrome 만 쓴다. | Playwright 스텔스, undetected-chromedriver, headless 브라우저 | 사용자가 headful Chrome 수집을 요구했다. 파이썬 의존성은 늘리지 않고 AGY가 이미 가진 chrome-devtools MCP 를 이용한다. curl/headless/스텔스 우회는 금지한다 | AGY/Chrome 상태와 권한(mcp(chrome-devtools/*), execute_url(nedrug.mfds.go.kr))에 의존하므로 실패 시 마지막 성공 자료로 리포트를 만든다 |
| ADR-07 | xlsx 쓰기는 XlsxWriter 100%, 매일 새 파일 통째 생성 |
openpyxl 갱신, pandas.ExcelWriter(mode='a'), 템플릿 편집 |
xlsx SSOT §3.2 결론 그대로다. 스파크라인 API는 XlsxWriter에만 있고(요구 R3.4), openpyxl은 load_workbook→save에서 차트·이미지·도형을 잃는다. 매일 새 파일이면 손실 문제 자체가 소멸하고 날짜별 증빙이 남는다 |
기존 파일 편집 불가. report-only가 항상 전체 재생성이지만, DB가 정본이므로 비용이 아니라 이점이다 |
| ADR-08 | 리포트의 모든 숫자는 파이썬이 계산해 값으로 쓴다. LibreOffice headless 재계산 단계를 두지 않는다 | LibreOffice 재계산 파이프라인(후보 A), 수식 자유 사용 | xlsx SSOT §0·§6: XlsxWriter는 수식 결과에 0을 쓰고 재계산 플래그만 세운다. 재계산을 옵션으로 두면 미설치 시 조용히 스킵되어 수식 캐시가 빈 리포트가 몇 주 나간다(운영 렌즈가 A의 치명 결함으로 지목) |
사용자가 필터를 바꿔도 살아 움직여야 하는 셀은 write_formula(..., value=사전계산값)으로 캐시값을 박아 넣는다. 동적 배열 함수(FILTER/UNIQUE/XLOOKUP)는 쓰지 않는다 |
| ADR-09 | 집계는 SQLite SQL로 한다. pandas를 쓰지 않는다 |
pandas 기반 집계(후보 B·C가 전제) |
데이터가 이미 SQLite에 있고 필요한 집계는 GROUP BY 수준이다. 요구 N5(의존성 최소)와 부팅 시간·설치 실패 표면을 고려하면 정당화되지 않는다 |
복잡한 피벗 형태 집계는 SQL이 장황하다. 시트별 조회는 report/data.py에 SQL을 모아 상수로 관리한다 |
| ADR-10 | 스케줄러는 Windows Task Scheduler, 작업 3개. 배치는 S4U(비대화형), UI를 띄우는 에이전트는 Interactive(로그온 세션) 로 분리 | 단일 작업(후보 A·C), Windows Service, WSL cron | agy OAuth 토큰이 사용자 프로필 평문 파일이므로 SYSTEM 금지·동일 사용자 계정 필수다(agy SSOT §4.2). 그런데 S4U 세션에는 데스크톱이 없어 토스트·모달이 뜨지 않는다. 알림 발생과 알림 표시를 분리하는 것이 요구 R7.2(강제 창)를 만족하는 유일한 구조다 |
사용자가 로그오프 상태로 방치하면 화면 알림이 지연된다. Event Log·state/alerts.json에 흔적을 남기고 다음 로그온 시 표시한다(요구 4절 각주와 동일한 결론) |
| ADR-11 | 배치 프로세스는 어떤 UI도 띄우지 않는다. alerts 테이블 + state/alerts.json에 알림 의도만 기록한다 |
배치가 직접 BurntToast/모달 호출(후보 A) | ADR-10의 귀결. 후보 A는 delivered=True를 기록하면서 화면에는 아무것도 뜨지 않는 조용한 실패를 만든다(실패 내성 렌즈 최대 감점 항목) |
알림 지연이 최대 15분(에이전트 반복 주기)이다. 요구 N3(24시간 내 인지) 대비 충분한 여유다 |
| ADR-12 | GUI 툴킷은 stdlib tkinter. 온보딩·복구·토스트를 전부 여기서 그린다 |
PySide6/PyQt, WinForms(pythonnet), 웹 UI(FastAPI+브라우저), BurntToast(PowerShell 모듈) | 요구 N5(의존성 최소)와 R6.2(콘솔 창이 보이면 안 됨)를 동시에 만족한다. pythonw.exe로 실행하면 콘솔이 없다. BurntToast는 외부 PowerShell 모듈 설치가 전제여서 "설치가 안 되면 알림도 안 됨"이라는 순환 실패를 만든다 |
진짜 Windows 토스트(액션 센터 잔류)가 아니라 우상단 자동 소멸 창이다. 액션 센터 이력이 필요하면 win11toast를 선택 의존성으로 켤 수 있는 자리만 남긴다(구현하지 않음) |
| ADR-13 | API 키는 Windows DPAPI(CryptProtectData, 사용자 범위)로 암호화해 %LOCALAPPDATA%에 저장 |
환경변수(후보 A), 평문 파일 secrets/*.txt(후보 B), .env |
요구 R6.3의 사용자는 환경변수가 뭔지 모른다. GUI에서 입력받아 즉시 암호화 저장해야 한다. DPAPI는 ctypes만으로 호출 가능해 의존성이 0이고, 같은 사용자 계정에서만 복호화된다 — S4U 배치가 정확히 그 계정이다 |
다른 PC로 폴더를 복사하면 키가 복호화되지 않는다. 요구 N6(이식성)은 "폴더 복사 + 온보딩 재실행"으로 정의돼 있으므로 오히려 요구와 일치한다 |
| ADR-14 | 설정은 config/config.toml 단 하나. stdlib tomllib로 읽는다 |
YAML 다중 파일(후보 B 4개, 후보 C 5개), JSON, INI | 운영 렌즈가 C를 5.5로 깎은 첫 번째 이유가 설정 6분할이다. 06:05에 "어느 파일을 열지"부터 정해야 하는 시스템은 5분 안에 안 고쳐진다. PyYAML 의존성도 사라진다 | 파일 하나가 길어진다(약 90줄). 섹션 주석으로 가독성을 보완한다 |
| ADR-15 | agy는 수집용(headful)과 요약용(headless)을 분리한다. 요약용 agy 는 diff·저장 뒤 부가 계층이고, 수집용 agy 는 stage_fetch 의 browser 경로다 |
agy를 전부 부가 계층으로만 둠, agy로 셀렉터 자가복구 | 사용자 요구상 headful 수집 경로가 필요하다. 다만 요구 R4.5("AGY/AI가 실패해도 리포트는 생성")는 유지해 browser 수집 실패도 스테일 폴백으로 처리한다. enrichment 테이블 분리는 요약용 산출물에만 적용된다 |
agy/Chrome 문제로 최신 수집이 실패할 수 있다. 이 경우 마지막 성공 자료로 리포트를 만들고 WARN 알림을 남긴다 |
| ADR-16 | agy 호출은 하루 최대 1회, diff가 비어 있지 않을 때만. 모든 질의(브리핑+이상해석+정규화 후보)를 한 프롬프트에 묶는다 |
역할별 다중 호출(A1~A7 각각), 매 실행 헬스체크 | agy SSOT §0·§4.3 실측: 첫 호출 오버헤드가 input 28,317 토큰 / 33.7초다. 변화 없는 날 이 비용을 태우면 쿼터 소진 시점만 앞당긴다. 인증 상태는 별도 헬스체크가 아니라 실제 작업 호출의 결과로 판정한다 | 프롬프트가 길어지고 한 번 실패하면 모든 AI 산출물이 함께 빠진다. graceful degradation이 이미 전제이므로 수용한다 |
| ADR-17 | --json-schema의 structured_output을 신뢰하지 않는다. 자체 균형괄호 추출 + jsonschema 검증 + 1회 강화 재시도 |
--json-schema 단독 신뢰 |
agy SSOT §7.2 실측: structured_output이 아예 없었고 response에 JSON 조각이 4회 반복 + 한국어 산문과 혼입됐으며 토큰이 3배로 뛰었다 |
코드 약 60줄과 테스트 픽스처가 늘어난다. SSOT가 실측으로 증명한 함정을 프로덕션에 노출시키는 것보다 싸다 |
| ADR-18 | 오케스트레이션은 pipeline.py의 순차 STAGES 리스트 + 스테이지 체크포인트. DAG 위상정렬을 쓰지 않는다 |
TaskGraph 위상정렬(후보 C), Airflow/Prefect, 오케스트레이션 없음(후보 A) | 7단계 선형 흐름에 병렬성도 동적 브랜칭도 없다. 그러나 "스테이지 추가 = 모듈 하나 + 리스트 한 줄"이라는 확장 문법과, RestartCount 재시도 시 성공한 스테이지를 건너뛰는 체크포인트는 실제 가치가 있다(진화·실패 내성 렌즈) |
스테이지 순서가 코드에 있어 배포 없이 못 바꾼다. 1인 운영에서 순서 변경은 코드 변경과 같은 사건이므로 문제가 아니다 |
| ADR-19 | 로깅은 stdlib logging. 실행 1회당 logs/run_<run_id>/ 디렉터리에 pipeline.log(사람용) + events.jsonl(기계용) + agy.stdout.json / agy.stderr.log 분리 |
structlog, 단일 롤링 파일, print |
agy SSOT §5.3 규약이 stdout/stderr 혼합을 금지한다. 실행 단위 디렉터리는 "가장 최근 디렉터리를 열면 끝"이라는 조사 시작점을 만든다(운영 렌즈가 A의 강점으로 꼽은 항목) | 디렉터리 수가 늘어난다. logging.retain_days(기본 90)로 정리한다 |
| ADR-20 | 패키지 관리는 pyproject.toml + venv + pip. 프로젝트 로컬 .venv |
poetry, uv, conda, 시스템 파이썬 직접 사용 | 비개발자 PC에 추가 도구를 설치시키지 않는다. bootstrap.cmd가 python -m venv .venv 후 pip install -e . 한 줄로 끝난다 |
잠금 파일(lock)이 없다. pyproject.toml에 상한 버전을 명시해 대체한다 |
| ADR-21 | 테스트는 pytest. 실제 API 응답을 고정 픽스처로 박아 넣는 골든 테스트가 중심 |
테스트 없음, tox/nox 매트릭스, 라이브 API 통합 테스트 | diff 오탐(요구 R2.2)과 agy 응답 오염 파싱(ADR-17)이 이 프로젝트의 두 급소다. 둘 다 픽스처만 있으면 오프라인에서 결정론적으로 검증된다 | 커버리지 목표를 세우지 않는다. 급소 4모듈(normalize/integrity/diff/agy.extract)만 촘촘히 덮는다 |
| ADR-22 | 파이프라인 성공 직후 VACUUM INTO로 날짜별 백업본을 다른 경로에 생성. 보존 개수 제한 |
README 안내에만 의존(후보 A), 설계 밖으로 미룸(후보 B) | 세 후보가 공통으로 빠뜨렸고 세 렌즈가 모두 지적했다. 1인 운영에서 문서로 미룬 절차는 6개월 안에 잊힌다. VACUUM INTO는 SQLite 내장이고 일관된 스냅샷을 보장한다 |
백업 경로가 같은 디스크면 디스크 장애를 못 막는다. config의 backup.dir을 다른 드라이브/OneDrive 경로로 지정하도록 온보딩에서 유도한다 |
| ADR-23 | 중복 실행 방어는 3중: ① 코드의 idempotency 가드(오늘 SUCCESS면 즉시 종료 0) ② 파일 락(msvcrt.locking) ③ Task Scheduler MultipleInstances=IgnoreNew |
스케줄러 설정에만 의존(후보 B·C) | IgnoreNew는 동시 실행만 막지 06:00 실행 후 재부팅 캐치업 같은 순차 재실행을 못 막는다. append-only 스키마는 하필 여기에 가장 취약하다(실패 내성 렌즈가 B의 최대 구멍으로 지목) |
의도적 재실행에는 --force가 필요하다. report-only는 가드 대상이 아니다 |
| ADR-24 | 온보딩(R6)과 오류 복구(R7)는 checks.py 하나를 공유하는 같은 컴포넌트. CLI doctor와 GUI onboard는 렌더러일 뿐 |
온보딩 마법사와 복구 창을 별도 구현 | 요구 4절이 명시한 원칙이다. 진단 로직이 한 곳에 있어야 온보딩과 복구가 어긋나지 않고, 새 전제조건이 생겨도 한 곳만 고친다 | GUI와 CLI가 같은 체크 목록에 묶여 유연성이 준다. 그것이 의도다 |
| ADR-25 | AI가 제안한 어떤 것도 자동 적용하지 않는다. 스키마 변경 대응(A7)·정규화 후보(A2)는 state/proposals/에 저장하고 사람이 승인 |
자동 반영 | 후보 C의 유일한 성숙한 안전 원칙이자 데이터 소스 SSOT §6(A7)의 명시 규칙이다. 틀린 제안이 조용히 잘못된 데이터를 수집하는 2차 사고를 원천 차단한다 | 사람이 개입할 때까지 대응이 지연된다. 그 지연이 오탐 데이터가 영구 기록되는 것보다 싸다 |
2. 전체 디렉터리 구조
이 트리를 그대로 만들면 프로젝트가 시작된다. 런타임 생성 경로는 (런타임)으로 표시한다.
D:\workspace\DMF_Crawler\
├── bootstrap.cmd # 최초 설치 진입점. venv 생성→pip install→바탕화면 바로가기 생성→온보딩 GUI 기동
├── pyproject.toml # 패키지 메타·의존성 3개 상한 고정·엔트리포인트·pytest 설정
├── README.md # 설치·최초 인증·수동 실행·장애 대응 5분 가이드
├── .gitignore # .venv/ data/ logs/ reports/ state/ backup/ config.local.toml *oauth-token*
│
├── config\
│ ├── config.toml # 유일한 설정 정본 (6절 전체 키 스펙)
│ └── config.local.toml.example # PC별 오버라이드 예시(경로·백업 드라이브). 실제 파일은 gitignore
│
├── prompts\
│ ├── daily_briefing.md # agy 단일 호출 프롬프트 템플릿. "JSON 객체 하나만 출력" 강제 문구 포함
│ └── daily_briefing.schema.json # 자체 jsonschema 검증 스키마(--json-schema는 보조 수단)
│
├── scripts\
│ ├── install_tasks.ps1 # Task Scheduler 작업 3종 idempotent 등록(Daily/Agent/AgyUpdate)
│ ├── uninstall_tasks.ps1 # 작업 3종 제거
│ ├── make_shortcuts.ps1 # "DMF 설정.lnk" / "지금 실행.lnk" 생성(pythonw 대상, 콘솔 창 없음)
│ └── bootstrap_agy.ps1 # agy 존재 확인 → 미설치 시 install.ps1 무인 실행 → 버전 출력
│
├── docs\
│ ├── 00-REQUIREMENTS.md # 요구사항 SSOT (기존)
│ ├── README.md # 문서 지도 (기존)
│ ├── design\
│ │ ├── 00-DATA-SOURCE-DECISION.md # 데이터 소스 SSOT (기존)
│ │ ├── 01-architecture.md # 이 문서 — 아키텍처 확정안
│ │ ├── 02-data-model.md # 스키마 DDL·정규화 규칙·비교 필드 확정 (M1에서 작성)
│ │ ├── 03-xlsx-report-spec.md # 시트 8종 셀 단위 명세 (M3에서 작성)
│ │ └── 04-onboarding-wizard.md # GUI 화면 전이·문구 명세 (M4에서 작성)
│ ├── ops\
│ │ ├── 01-scheduling-and-resilience.md # 작업 등록 파라미터·재부팅 시나리오 검증 절차 (M2)
│ │ ├── 02-failure-alerting.md # 알림 등급·문구 4요소 규격·쿨다운 (M4)
│ │ └── 03-api-usage-policy.md # 호출 빈도·출처 표시·이용약관 준수 기록 (M1)
│ └── research\ # 리서치 정본 (기존, 01/03/04/05/05a/06/07)
│
├── src\
│ └── dmf_crawler\
│ ├── __init__.py # __version__ 상수만
│ ├── __main__.py # `python -m dmf_crawler` 진입. cli.main(sys.argv[1:]) 위임
│ ├── cli.py # argparse 서브커맨드 정의 → 각 커맨드 함수 매핑. 로직 없음
│ ├── paths.py # 프로젝트 루트·data·logs·reports·state·backup 경로 상수 계산
│ ├── config.py # config.toml + config.local.toml 병합 → 불변 Config 데이터클래스, 검증
│ ├── errors.py # 예외 계층: DmfError → ConfigError/FetchError/IntegrityError/StorageError/ReportError/AgyError
│ ├── logging_setup.py # run_id별 로그 디렉터리·핸들러(pipeline.log + events.jsonl) 구성
│ ├── runlock.py # msvcrt 기반 배타 파일 락. 컨텍스트 매니저
│ ├── secrets_dpapi.py # ctypes로 CryptProtectData/CryptUnprotectData 호출. 키 저장·읽기·삭제
│ ├── pipeline.py # STAGES 순차 실행기. 체크포인트 조회/기록, 스테이지 예외 흡수
│ ├── http.py # httpx 얇은 래퍼. Retry-After 우선·full jitter 백오프·조건부 요청·UA 고정
│ ├── source_mfds.py # 공식 API 전량 수집. totalCount 확인·페이지네이션·원문 아카이브 저장
│ ├── normalize.py # 원본 7필드 → DmfRecord 표준화. dmf_key·content_hash 산출
│ ├── integrity.py # 요구 R2.2 안전장치 5종 판정. 통과 못하면 diff 차단
│ ├── diff.py # 순수 함수. 전일 스냅샷 vs 금일 → NEW/CHANGED/WITHDRAWN
│ ├── health.py # 3상태 서킷 브레이커(CLOSED/OPEN/HALF_OPEN). 소스·agy 공용
│ ├── checks.py # 진단 엔진. Check 12종 정의·실행. doctor CLI와 GUI가 공유
│ ├── backup.py # VACUUM INTO 날짜별 백업·보존 개수 정리·복원 안내 생성
│ ├── watchdog.py # heartbeat 신선도 판정. 미갱신 시 alerts에 CRITICAL 기록
│ ├── alerts.py # 알림 의도 기록/조회/중복 억제(dedup_key + 쿨다운)/해소 표시
│ │
│ ├── storage\
│ │ ├── __init__.py
│ │ ├── db.py # 연결 생성(WAL·foreign_keys=ON·busy_timeout)·마이그레이션 러너
│ │ ├── repo.py # 유일한 읽기/쓰기 경로. run/snapshot/event/enrichment/agy_call/health/alert
│ │ └── migrations\
│ │ ├── 0001_init.sql # runs/stage_status/fetch_stats/snapshots/records/events 최초 스키마
│ │ ├── 0002_enrichment.sql # enrichment/enrichment_run/agy_calls (AI 계층 분리)
│ │ └── 0003_ops.sql # component_health/alerts/watchlist/schema_version 인덱스
│ │
│ ├── agy\
│ │ ├── __init__.py
│ │ ├── client.py # agy.exe subprocess 실행. 예외 대신 AgyEnvelope 값으로 반환
│ │ ├── prompt.py # diff+지표 → daily_briefing.md 템플릿 렌더링. 데이터 구분자 감싸기
│ │ ├── extract.py # response에서 균형괄호 JSON 추출 + jsonschema 검증 (SSOT §7.3 포팅)
│ │ └── budget.py # 일일 토큰 상한 강제. agy_calls 누적 조회·초과 판정
│ │
│ ├── report\
│ │ ├── __init__.py # build_report(run_id) 단일 공개 함수
│ │ ├── data.py # DB → ReportData 조회 전담. 시트별 SQL 상수 모음
│ │ ├── build.py # 워크북 조립 오케스트레이션. 시트 생성 순서·저장 호출
│ │ ├── theme.py # Okabe-Ito 팔레트·맑은 고딕·상태 매핑·Format 객체 캐시
│ │ ├── widgets.py # KPI 타일·델타 화살표 서식·목차 링크·한글 열너비 계산 유틸
│ │ ├── atomic.py # 임시파일 → os.replace 원자 교체. 잠김 시 재시도·폴백 파일명
│ │ └── sheets\
│ │ ├── __init__.py # SHEET_BUILDERS 순서 리스트
│ │ ├── s00_dashboard.py # 대시보드: KPI 6타일·차트 2종·스파크라인·각 시트 링크·상태 배너
│ │ ├── s01_changes.py # 오늘 변경분: 신규/변경/취하 통합 표. 상태 3중 코딩·행 강조
│ │ ├── s02_ledger.py # 전체 누적 원장. Excel 표·자동필터·틀고정·nedrug 검색 링크
│ │ ├── s03_ingredient.py # 성분별 집계 Top N + 추이
│ │ ├── s04_company.py # 업체·제조국별 집계 Top N
│ │ ├── s05_watchlist.py # 워치리스트 히트. 목록이 비면 시트 자체를 만들지 않음
│ │ ├── s06_trend.py # 일자별 건수 시계열(스파크라인·차트 원본)
│ │ └── s99_meta.py # 수집 시각·소스 URL·건수·해시·실행 로그 경로·AI 상태
│ │
│ ├── notify\
│ │ ├── __init__.py
│ │ ├── pump.py # 대화형 에이전트 본체. 워치독 판정 → 알림 표시 → 복구 GUI 기동
│ │ ├── toast.py # tkinter 우상단 자동소멸 알림 창(WARN/INFO용)
│ │ ├── eventlog.py # eventcreate.exe로 Windows 이벤트 로그 병행 기록
│ │ └── messages.py # 알림 문구 4요소(무엇/왜/어떻게/다음행동) 템플릿 렌더러
│ │
│ └── gui\
│ ├── __init__.py
│ ├── app.py # 온보딩=복구 단일 창. checks.py 결과를 체크리스트로 렌더
│ ├── steps.py # 단계별 액션: 키 입력·발급페이지 열기·agy 설치·재로그인·작업 등록
│ └── widgets.py # 공통 위젯: 상태 뱃지·진행률 바·스크롤 프레임·복사 가능 로그 영역
│
├── tests\
│ ├── conftest.py # 임시 DB·설정 픽스처. 네트워크 차단 픽스처
│ ├── fixtures\
│ │ ├── api_page_ok.json # 정상 API 응답(실제 응답 저장본)
│ │ ├── api_page_empty.json # resultCode 00 + items 0건 (200-빈응답 케이스)
│ │ ├── api_error_invalid_key.xml # 인증키 무효 응답 원문
│ │ ├── api_html_block.html # 200인데 본문이 HTML 안내 페이지인 케이스
│ │ ├── agy_envelope_clean.json # agy 정상 봉투
│ │ ├── agy_envelope_polluted.json # SSOT §7.2 실측 오염 사례(JSON 4회 반복+산문 혼입)
│ │ └── snapshot_prev.json # diff 비교용 전일 스냅샷
│ ├── test_normalize.py # dmf_key 안정성·중복 등록번호 처리·국가 다중값 분해
│ ├── test_integrity.py # 안전장치 5종 각각의 통과/차단 경계
│ ├── test_diff.py # NEW/CHANGED/WITHDRAWN 판정·필드별 변경 추출
│ ├── test_agy_extract.py # 오염 응답에서의 JSON 추출·스키마 검증 실패 경로
│ ├── test_http_retry.py # Retry-After 우선·지터 범위·조건부 요청 304 처리
│ ├── test_report_atomic.py # 잠긴 파일 시 폴백 파일명 생성·원자 교체
│ ├── test_repo_idempotency.py # 같은 run_id 재삽입 시 append-only 무결성
│ └── test_checks.py # 진단 12종의 통과/실패 판정
│
├── data\ # (런타임) git 미추적
│ ├── dmf.sqlite3 # 정본 저장소
│ └── raw\YYYY-MM-DD\ # API 원문 응답 페이지별 보존(회귀 픽스처·재파싱 원자료)
│
├── state\ # (런타임) 잠금 없이 읽는 가벼운 상태 파일
│ ├── heartbeat.json # 마지막 성공 실행 시각·run_id·리포트 경로 (워치독 전용)
│ ├── alerts.json # 미해소 알림 미러 (에이전트가 SQLite 잠금 없이 읽음)
│ ├── run.lock # 배타 실행 락 파일
│ └── proposals\ # AI 제안 보관소(자동 적용 금지, 사람 승인 대기)
│
├── reports\ # (런타임)
│ ├── DMF_리포트_YYYY-MM-DD.xlsx # 일자별 산출물
│ └── DMF_리포트_최신.xlsx # 최신본 고정 링크(원자적 복사)
│
├── backup\ # (런타임, 다른 드라이브 권장)
│ └── dmf_YYYY-MM-DD.sqlite3 # VACUUM INTO 백업본
│
└── logs\ # (런타임)
└── run_YYYYMMDD_HHMMSS\
├── pipeline.log # 사람이 읽는 전체 로그
├── events.jsonl # 기계가 읽는 구조화 이벤트
├── agy.stdout.json # agy JSON 봉투 원문(감사용)
├── agy.stderr.log # agy 진단 출력(stdout과 절대 섞지 않음)
└── agy_cli.log # --log-file로 지정한 agy 내부 로그
트리에 없는 것과 그 이유
| 없는 것 | 이유 |
|---|---|
sources/ 디렉터리와 플러그인 스텁 |
ADR-02. 소스가 1개이고 확장은 비목표 |
config/*.yaml 5분할, permissions.agy.json |
ADR-14. agy 권한은 ~/.gemini/antigravity-cli/settings.json에 온보딩이 병합 배포한다(별도 파일 두면 병합 스텝이 조용히 실패한다는 운영 렌즈 지적) |
orchestrator/graph.py, checkpoint.py 별도 모듈 |
ADR-18. pipeline.py 하나에 스테이지 실행과 체크포인트가 함께 있다 |
normalize/dictionaries/company_aliases.csv |
수동 큐레이션은 영구 사람 부채다(운영 렌즈). 규칙 기반 정규화 + agy 제안(승인제, ADR-25)으로 대체 |
report/recalc.py |
ADR-08 |
| 웹 대시보드, FastAPI | 요구 3절 비목표 |
3. 모듈별 계약
모든 시그니처는 파이썬 3.11+ 타입 힌트 기준이다. 데이터클래스는 전부 @dataclass(frozen=True, slots=True)다.
3.1 config.py
- 책임:
config.toml+ (있으면)config.local.toml을 병합해 불변Config를 만들고, 실행 전에 필수 키·경로·타입을 검증한다. 비밀 값은 담지 않는다(경로와 키 이름만). - 의존:
paths,errors - 공개 API
def load_config(path: Path | None = None) -> Config: ...
def validate(cfg: Config) -> list[str]: # 사람이 읽는 문제 목록. 빈 리스트면 정상
...
@dataclass(frozen=True, slots=True)
class Config:
general: GeneralCfg
schedule: ScheduleCfg
source: SourceCfg
integrity: IntegrityCfg
storage: StorageCfg
backup: BackupCfg
report: ReportCfg
agy: AgyCfg
notify: NotifyCfg
logging: LoggingCfg
- 입력: TOML 파일 경로. 출력:
Config - 실패 시: 파일 없음·파싱 실패·타입 불일치 →
ConfigError. 파이프라인은 시작하지 않고 종료 코드 2 + CRITICAL 알림 기록.
3.2 secrets_dpapi.py
- 책임: 공공데이터포털 serviceKey를 사용자 범위 DPAPI로 암·복호화한다. 평문은 메모리에만 존재하고 로그·예외 메시지에 절대 넣지 않는다(요구 N7).
- 의존:
ctypes(stdlib),paths - 공개 API
def save_service_key(plaintext: str) -> Path: ... # %LOCALAPPDATA%\DMF_Crawler\service_key.bin
def load_service_key() -> str | None: ... # 없거나 복호화 실패 시 None
def delete_service_key() -> None: ...
def key_fingerprint() -> str | None: ... # sha256 앞 8자. 로그·GUI 표시용(원문 아님)
- 실패 시: 복호화 실패는 예외 없이
None. 호출부(checks.py)가 "키 미설정"과 동일하게 취급해 GUI 복구 경로로 보낸다.
3.3 http.py
- 책임: 이 프로젝트의 유일한 네트워크 창구. 타임아웃·재시도·정중함 정책을 강제한다.
- 의존:
httpx,config,errors - 공개 API
@dataclass(frozen=True, slots=True)
class HttpResult:
status_code: int
text: str
headers: dict[str, str]
elapsed_seconds: float
from_cache: bool # 304 Not Modified 였는가
class HttpClient:
def __init__(self, cfg: SourceCfg, logger: logging.Logger) -> None: ...
def get(self, url: str, params: dict[str, str | int],
etag: str | None = None,
last_modified: str | None = None) -> HttpResult: ...
def close(self) -> None: ...
- 정책(고정): 최대 4회 시도, base 5s · factor 2 · cap 300s · full jitter.
429/503의Retry-After가 있으면 절대 우선(계산된 백오프를 무시).If-None-Match/If-Modified-Since조건부 요청. UA는DMF-Crawler/<version> (+contact: <config의 연락처>)고정. 요청 간 최소 간격min_interval_seconds(기본 0.7s) + 0~0.3s 지터. - 실패 시: 재시도 소진 →
FetchError(reason=...). 예외에 URL은 담되serviceKey는 마스킹한다.
3.4 source_mfds.py
- 책임: 공식 API에서 DMF 현황 전량을 받아오고, 원문을 그대로 아카이브하며, 수집 통계를 남긴다.
- 의존:
http,secrets_dpapi,config,paths,errors - 공개 API
@dataclass(frozen=True, slots=True)
class RawRecord:
fields: dict[str, str] # 원본 필드명 그대로 (DMF_PERMIT_NO 등 7개)
@dataclass(frozen=True, slots=True)
class FetchResult:
records: list[RawRecord]
total_count_reported: int # 응답의 totalCount
pages_fetched: int
http_calls: int
elapsed_seconds: float
archive_dir: Path
body_signature_ok: bool # 200인데 빈/차단 본문이 아닌가
def fetch_all(cfg: SourceCfg, client: HttpClient, run_id: str) -> FetchResult: ...
def parse_body(text: str, fmt: str) -> tuple[list[RawRecord], int, str]:
"""(records, totalCount, resultCode) 반환. JSON/XML 양쪽 지원."""
- 동작: ①
numOfRows=1로totalCount취득 → ②ceil(totalCount/page_size)페이지 순회 → ③ 각 페이지 원문을data/raw/<date>/page_%04d.json에 그대로 저장 → ④ 페이지 간 정중 지연. - 본문 시그니처 검사(신규): HTTP 200이어도 다음이면 실패로 간주한다 — 본문 길이 <
min_body_bytes(기본 200),resultCode != "00",Content-Type이 예상과 다름, 본문에<html·location.href·차단 키워드 존재. 세 후보 모두가 뚫려 있던 구멍이다. - 실패 시:
FetchError. 원문은 이미 아카이브돼 있으므로 사후 재현이 가능하다.
3.5 normalize.py
- 책임: 원본 7필드를 표준
DmfRecord로 변환하고, diff의 키와 비교 해시를 계산한다. - 의존: 없음(순수 함수,
unicodedata만) - 공개 API
@dataclass(frozen=True, slots=True)
class DmfRecord:
dmf_key: str # 정규화된 DMF_PERMIT_NO (+중복 시 결정론적 접미사)
permit_no: str
ingredient_name: str
applicant: str # ENTP_NAME
manufacturer: str # MNFCTR_NAME
manufacture_place: str
countries: tuple[str, ...] # MANUF_COUNTRY_CODE_NM 콤마 분해 후 정렬
permit_date: str # ISO YYYY-MM-DD 정규화
accepted_date: str | None # 등록번호 앞 8자리에서 파생
raw: dict[str, str]
content_hash: str # 비교 대상 6필드의 sha256 앞 16자
COMPARE_FIELDS: tuple[str, ...] = (
"ingredient_name", "applicant", "manufacturer",
"manufacture_place", "countries", "permit_date",
)
def normalize_all(raws: list[RawRecord]) -> tuple[list[DmfRecord], NormalizeStats]: ...
def normalize_one(raw: RawRecord, dup_index: int = 0) -> DmfRecord: ...
def make_dmf_key(permit_no: str, dup_index: int) -> str: ...
def compute_content_hash(rec: DmfRecord) -> str: ...
- 정규화 규칙: 유니코드 NFKC, 연속 공백 1개로 축약, 앞뒤 공백 제거, 전각→반각,
(주)/㈜통일, 국가 다중값은 콤마 분해 후 공백 제거·정렬(순서 흔들림으로 인한 오탐 방지 — 요구 R2.3). - 중복 등록번호: 한 스냅샷에 같은
DMF_PERMIT_NO가 둘 이상이면제조소명사전순 정렬 후#2,#3접미사를 붙인다(결정론적).NormalizeStats.duplicate_permit_no에 건수를 남겨integrity가 임계값 판정에 쓴다.⚠️ 미검증: API가 실제로 중복 등록번호를 반환하는지는 데이터 소스 SSOT 부록 B의 미해결 항목이다. serviceKey 발급 직후 실측하고, 중복이 없으면 이 접미사 로직은 무해한 no-op으로 남는다.
- 실패 시: 개별 레코드 파싱 실패는 예외를 던지지 않고
NormalizeStats.rejected에 적재한다. 널 비율 판정은integrity가 한다.
3.6 integrity.py
- 책임: 요구 R2.2의 안전장치 5종을 판정한다. 하나라도 실패하면 그 실행은 diff를 수행하지 않는다. 이 프로젝트에서 가장 위험한 실패 모드(전건 취하 오탐)를 막는 단일 관문이다.
- 의존:
storage.repo(전일 통계 조회),config - 공개 API
@dataclass(frozen=True, slots=True)
class Gate:
name: str
passed: bool
detail: str
@dataclass(frozen=True, slots=True)
class IntegrityVerdict:
ok: bool
gates: tuple[Gate, ...]
blocking_reason: str | None
def evaluate(fetch: FetchResult, records: list[DmfRecord],
stats: NormalizeStats, prev: PrevSnapshotStats | None,
cfg: IntegrityCfg) -> IntegrityVerdict: ...
- 게이트 5종(요구 R2.2와 1:1)
- 모든 페이지가 HTTP 200 이고
resultCode == "00"이며 본문 시그니처 통과 len(records) == total_count_reportedtotal_count_reported가 직전 성공 스냅샷 대비max_drop_ratio(기본 0.05) 이상 급감하지 않음- 필수 필드(
permit_no,ingredient_name,applicant) 널 비율 ≤max_null_ratio(기본 0.01) - 중복
DMF_PERMIT_NO비율 ≤max_duplicate_ratio(기본 0.02)
- 모든 페이지가 HTTP 200 이고
- 실패 시:
ok=False. 파이프라인은 스냅샷 INSERT도 하지 않는다(기준선 오염 방지). 리포트는 마지막 성공 스냅샷으로 생성하되 대시보드에 "오늘 수집 실패 — 마지막 성공: YYYY-MM-DD" 배너를 넣고 WARN 알림을 기록한다. 실행 상태는PARTIAL.
3.7 diff.py
- 책임: 전일 스냅샷과 금일 레코드를 비교해 이벤트를 만든다. DB에 접근하지 않는 순수 함수(테스트 용이성의 핵심).
- 의존:
normalize - 공개 API
@dataclass(frozen=True, slots=True)
class FieldChange:
field: str
before: str
after: str
@dataclass(frozen=True, slots=True)
class DiffEvent:
dmf_key: str
event_type: Literal["NEW", "CHANGED", "WITHDRAWN"]
changes: tuple[FieldChange, ...]
before: dict[str, str] | None
after: dict[str, str] | None
@dataclass(frozen=True, slots=True)
class DiffResult:
new: tuple[DiffEvent, ...]
changed: tuple[DiffEvent, ...]
withdrawn: tuple[DiffEvent, ...]
unchanged_count: int
@property
def is_empty(self) -> bool: ...
def compute_diff(previous: dict[str, DmfRecord],
current: dict[str, DmfRecord]) -> DiffResult: ...
def diff_fields(before: DmfRecord, after: DmfRecord) -> tuple[FieldChange, ...]: ...
- 판정 규칙: 키는
dmf_key. NEW = 오늘만 존재, WITHDRAWN = 어제만 존재, CHANGED = 양쪽 존재 +content_hash상이(그 후COMPARE_FIELDS로 필드별 차이 추출). - 첫 실행:
previous가 비어 있으면 모든 레코드를 NEW로 만들지 않는다.DiffResult를 빈 결과로 반환하고unchanged_count에 전량을 넣는다(기준선 수립일). 요구 부록의 "첫 며칠은 diff가 비어 있다"와 일치한다. - 실패 시: 순수 함수라 실패 경로가 없다. 입력이 비정상이면
integrity가 이미 막았다.
3.8 storage/db.py
- 책임: 연결 생성과 마이그레이션 적용. 이 모듈 밖에서는
sqlite3.connect를 직접 호출하지 않는다. - 의존:
sqlite3,paths,backup,errors - 공개 API
def connect(db_path: Path, *, read_only: bool = False) -> sqlite3.Connection: ...
# PRAGMA journal_mode=WAL; foreign_keys=ON; busy_timeout=15000; synchronous=NORMAL
def current_version(conn: sqlite3.Connection) -> int: ...
def apply_migrations(conn: sqlite3.Connection, migrations_dir: Path,
backup_dir: Path) -> list[int]:
"""적용 전 VACUUM INTO 백업 → 번호순 적용 → schema_version 기록. 적용된 버전 목록 반환."""
def integrity_check(conn: sqlite3.Connection) -> tuple[bool, str]: ...
- 실패 시: 마이그레이션 도중 예외 → 롤백 +
StorageError. 안내 문구에 백업본 절대경로를 포함한다(ADR-04의 롤백 경로).
3.9 storage/repo.py
- 책임: 정본 테이블에 대한 유일한 읽기·쓰기 경로. 하나의 실행이 만드는 쓰기를 트랜잭션으로 묶는다.
- 의존:
storage.db,normalize,diff - 공개 API(발췌)
def start_run(conn, run_id: str, run_date: str, trigger: str) -> None: ...
def finish_run(conn, run_id: str, status: str, exit_code: int, notes: str) -> None: ...
def last_success_run_on(conn, run_date: str) -> RunRow | None: ... # idempotency 가드
def latest_success_run(conn) -> RunRow | None: ...
def save_checkpoint(conn, run_id: str, stage: str, status: str,
artifact_path: str | None, error: str | None) -> None: ...
def is_stage_done(conn, run_id: str, stage: str) -> bool: ...
def insert_snapshot(conn, run_id: str, records: list[DmfRecord]) -> int: ...
def load_snapshot(conn, run_id: str) -> dict[str, DmfRecord]: ...
def insert_events(conn, run_id: str, diff: DiffResult) -> int: ...
def upsert_current_records(conn, run_id: str, records: list[DmfRecord]) -> None: ...
def record_fetch_stats(conn, run_id: str, fetch: FetchResult) -> None: ...
def prev_snapshot_stats(conn) -> PrevSnapshotStats | None: ...
def insert_agy_call(conn, run_id: str, call: AgyCallRow) -> None: ...
def tokens_used_today(conn, day: str) -> int: ...
def insert_enrichment(conn, run_id: str, run_level: dict, per_event: list[dict]) -> None: ...
def get_health(conn, component: str) -> HealthRow: ...
def set_health(conn, row: HealthRow) -> None: ...
- 불변식:
snapshots·events·agy_calls·alerts는 절대 UPDATE/DELETE 하지 않는다(보존 정책에 따른 오래된 파티션 삭제만 예외).records만 현재 상태를 UPDATE한다. - 실패 시:
StorageError로 승격하고 트랜잭션 롤백.database is locked는busy_timeout15초로 흡수하고, 그래도 실패하면 파이프라인 FAILED + CRITICAL 알림.
3.10 health.py
- 책임: 3상태 서킷 브레이커. 소스(
source_mfds)와 AI(agy) 두 컴포넌트에 같은 코드를 재사용한다. - 의존:
storage.repo,config - 공개 API
class CircuitState(StrEnum):
CLOSED = "CLOSED"; OPEN = "OPEN"; HALF_OPEN = "HALF_OPEN"
def allow(conn, component: str, cfg: HealthCfg, now: datetime) -> tuple[bool, CircuitState, str]: ...
def record_success(conn, component: str, run_id: str) -> CircuitState: ...
def record_failure(conn, component: str, reason: str, cfg: HealthCfg,
now: datetime) -> tuple[CircuitState, bool]:
"""(새 상태, 상태가 방금 전환됐는가) — 전환 시에만 알림을 쏘기 위한 플래그."""
- 전이 규칙: 연속 실패가
failure_threshold(소스 3, agy 3)에 도달하면 OPEN +cooldown_until = now + cooldown(소스 1일, agy 24시간). 쿨다운 경과 후 첫 호출은 HALF_OPEN으로 1회만 시도, 성공하면 CLOSED 복귀, 실패하면 OPEN 유지 + 쿨다운 갱신. - 알림 정책: 상태 전환 시 1회만 알림을 기록한다. OPEN 상태가 유지되는 동안에는 매일 반복 알림을 내지 않는다(운영 렌즈의 "학습된 무시" 방지).
3.11 agy/client.py
- 책임:
agy.exe를 SSOT §16 체크리스트대로 호출한다. 어떤 실패도 예외로 던지지 않고 값으로 반환한다. - 의존:
subprocess,config,paths - 공개 API
@dataclass(frozen=True, slots=True)
class AgyEnvelope:
status: str # SUCCESS/ERROR/CANCELED/INTERRUPTED/INVALID/WAITING/RUNNING/LAUNCH_FAILED
response: str
error: str | None
exit_code: int
duration_seconds: float
usage: dict[str, int]
conversation_id: str | None
stdout_path: Path
stderr_path: Path
class ErrorKind(StrEnum):
NONE="NONE"; AUTH="AUTH"; QUOTA="QUOTA"; TIMEOUT="TIMEOUT"; NOT_FOUND="NOT_FOUND"; OTHER="OTHER"
def build_args(cfg: AgyCfg, prompt_path: Path, log_file: Path) -> list[str]: ...
def run_agy(cfg: AgyCfg, prompt: str, run_id: str, log_dir: Path) -> AgyEnvelope: ...
def classify_error(env: AgyEnvelope) -> ErrorKind: ...
- 고정 호출 규약: 절대경로
%LOCALAPPDATA%\agy\bin\agy.exe,--output-format json,--model/--effort설정값,--print-timeout 10m,--disable-slash-commands,--log-file <run별 경로>. 환경변수AGY_CLI_DISABLE_AUTO_UPDATE=true를 자식 프로세스 환경에 주입한다.--continue·--dangerously-skip-permissions는 쓰지 않는다. - 스트림 분리: stdout은
agy.stdout.json, stderr는agy.stderr.log로 각각 리다이렉트하고 절대 합치지 않는다(SSOT §5.3). - 프롬프트 전달: 프롬프트가 길어 명령줄 길이 제한(약 32KB)에 걸릴 수 있으므로
-p에 넘기기 전 크기를 검사하고, 초과하면 diff 항목 수를 상위 N건으로 잘라 재구성한다. - 실패 시: 실행 자체가 안 되면
status="LAUNCH_FAILED". 호출부는 이것을 정상 입력의 하나로 취급한다.
3.12 agy/extract.py
- 책임: agy
response에서 JSON을 견고하게 추출하고 자체 스키마로 검증한다(ADR-17). - 의존:
jsonschema - 공개 API
def extract_json_object(text: str) -> dict | None: ... # SSOT §7.3 균형괄호 스캐너
def validate(obj: dict | None, schema: dict) -> tuple[bool, str | None]: ...
def get_briefing(env: AgyEnvelope, schema: dict) -> BriefingResult: ...
@dataclass(frozen=True, slots=True)
class BriefingResult:
ok: bool
headline: str | None
summary_md: str | None
anomalies: tuple[str, ...]
per_event: tuple[dict, ...] # {dmf_key, comment, importance}
failure_reason: str | None
- 실패 시:
ok=False. 리포트는 그대로 진행하고 대시보드에 "AI 요약 없음: <사유>" 배지를 표시한다.
3.13 report/build.py · report/atomic.py
- 책임: DB만 읽어 xlsx를 조립하고, 사용자가 파일을 열어둔 상태에서도 실패하지 않게 저장한다(요구 R3.5).
- 의존:
xlsxwriter,report.data,report.theme,report.sheets - 공개 API
def build_report(conn, run_id: str, cfg: ReportCfg) -> ReportOutcome: ...
@dataclass(frozen=True, slots=True)
class ReportOutcome:
path: Path
latest_link_path: Path | None
used_fallback_name: bool
sheets: tuple[str, ...]
warnings: tuple[str, ...]
# atomic.py
def atomic_write(build_fn: Callable[[Path], None], target: Path,
retries: int = 3, backoff_seconds: float = 2.0) -> tuple[Path, bool]:
"""임시 파일에 생성 → os.replace 로 원자 교체.
PermissionError(파일 잠김) 시 재시도, 끝내 실패하면
DMF_리포트_YYYY-MM-DD_HHMMSS.xlsx 폴백 파일명으로 저장하고 (경로, True) 반환."""
- 불변식:
build_report는 AI 산출물 유무와 무관하게 항상 완주한다.enrichment조인 결과가 없으면 해당 셀을 "(AI 요약 없음)"으로 렌더링할 뿐이다. - 시트 구성(xlsx SSOT §4 + 대시보드 SSOT §0 통합 확정, 8시트):
00_대시보드/01_오늘변경분/02_전체현황/03_성분별/04_업체별/05_워치리스트(비면 생략) /06_추이/99_메타. - 실패 시:
ReportError. DB는 이미 커밋됐으므로 알림 문구에 "report-only --run-id <id>로 재생성 가능"을 넣는다.
3.14 alerts.py
- 책임: 알림 의도를 기록하고, 중복을 억제하고, 표시 여부를 추적한다. 표시는 하지 않는다(ADR-11).
- 의존:
storage.repo,paths,notify.messages - 공개 API
class Severity(StrEnum):
INFO="INFO"; WARN="WARN"; CRITICAL="CRITICAL"
def raise_alert(conn, *, run_id: str | None, severity: Severity, code: str,
what: str, why: str, how: str, action: str,
log_dir: Path | None, cooldown_minutes: int) -> bool:
"""dedup_key = code(+run_date). 쿨다운 안이면 기록만 갱신하고 False 반환."""
def pending(conn) -> list[AlertRow]: ...
def mark_shown(conn, alert_id: int) -> None: ...
def resolve(conn, code: str, note: str) -> int: ...
def mirror_to_file(conn, path: Path) -> None: ... # state/alerts.json 갱신
- 문구 규격(요구 R7.3): 모든 알림은 무엇이 / 왜 / 어떻게 고치는지 / 다음 행동 4요소를 필수로 갖는다.
raise_alert가 하나라도 비면ValueError를 던진다 — 규격을 문자열 생성기가 아니라 계약으로 승격시킨다. - 실패 시: 알림 기록 실패는 파이프라인을 막지 않는다. 최후 수단으로
state/alerts.json에 직접 추가 기록하고 Event Log에 남긴다.
3.15 checks.py
- 책임: 온보딩·복구·진단이 공유하는 단일 진단 엔진(ADR-24).
- 의존: 거의 모든 모듈(읽기 전용)
- 공개 API
@dataclass(frozen=True, slots=True)
class CheckResult:
key: str
title: str # "공공데이터포털 API 키"
ok: bool
detail: str # 사람이 읽는 현재 상태
fix_hint: str # 무엇을 하면 되는지
fix_action: str | None # GUI 버튼이 호출할 액션 키 ("enter_api_key" 등)
severity: Severity
def run_all(cfg: Config | None) -> list[CheckResult]: ...
def run_one(key: str, cfg: Config | None) -> CheckResult: ...
- 체크 12종: ① Python·venv ② 의존성 3종 import ③
config.toml파싱·검증 ④ API 키 존재(DPAPI 복호화) ⑤ API 키 실호출 검증(numOfRows=1) ⑥agy.exe존재 ⑦agy인증 상태(최근agy_calls기반 판정, 헬스체크 호출 안 함) ⑧ DB 존재·integrity_check·스키마 버전 ⑨ 작업 스케줄러 3종 등록 상태 ⑩ 디스크 여유 공간 ⑪ 리포트 출력 디렉터리 쓰기 권한 + 오늘 파일 잠김 여부 ⑫ 최근 실행 상태(heartbeat 신선도·연속 실패 횟수). - 실패 시: 체크 자체의 예외는 잡아
ok=False, detail="<예외 요약>"으로 변환한다. 진단기가 죽어서 진단이 안 되는 일이 없어야 한다.
3.16 notify/pump.py
- 책임: 로그온 세션에서 15분마다 도는 대화형 에이전트. UI를 띄울 권한을 가진 유일한 프로세스다.
- 의존:
alerts,watchdog,notify.toast,notify.eventlog,gui.app,checks - 공개 API
def pump_once(cfg: Config) -> int:
"""1) 워치독 판정 → 필요 시 alerts 기록
2) state/alerts.json + DB에서 미표시 알림 조회
3) WARN/INFO → 자동소멸 토스트 창
4) CRITICAL → 복구 GUI를 해당 체크에 포커스해 기동(모달)
5) mark_shown 기록. 종료 코드 반환."""
- 동시성: 자체 락 파일로 중복 pump를 막는다. GUI가 이미 떠 있으면 새로 띄우지 않고 기존 창을 전면으로 올린다.
- 실패 시: 어떤 예외도 사용자에게 보이지 않게 삼키되 Event Log에 남긴다. 알리미가 죽어서 조용해지는 것이 최악이므로,
pump_once가 예외로 끝나면 다음 주기에 다시 시도한다.
3.17 gui/app.py
- 책임: 요구 R6·R7의 단일 창.
checks.run_all()결과를 체크리스트로 그리고, 각 항목의fix_action을 버튼으로 노출한다. - 의존:
tkinter,checks,gui.steps - 공개 API
def launch(mode: Literal["setup", "recover", "inspect"] = "setup",
focus_key: str | None = None) -> int: ...
- 진입 모드(요구 4절 표와 1:1):
setup(전체 체크리스트) /recover(실패 항목 강조 + 원인 설명) /inspect(진단 결과만). - 막다른 골목 금지(R7.7): 모든 실패 항목은 반드시 액션 버튼을 가진다. 액션이 정의되지 않은 체크는
checks.py단계에서 등록을 거부한다(계약으로 강제). - 실패 시: GUI가 뜨지 못하면(예: tkinter 손상)
cli doctor의 텍스트 출력을 콘솔로 폴백하고 Event Log에 기록한다.
3.18 pipeline.py
- 책임: 스테이지 순차 실행, 체크포인트 조회·기록, 스테이지 경계에서의 예외 흡수.
- 의존: 위 모든 것
- 공개 API
@dataclass(frozen=True, slots=True)
class Stage:
name: str
fn: Callable[[RunContext], None]
required: bool # False면 실패해도 파이프라인 계속(best-effort)
timeout_seconds: int
STAGES: tuple[Stage, ...] = (
Stage("preflight", stage_preflight, required=True, timeout_seconds=60),
Stage("fetch", stage_fetch, required=True, timeout_seconds=900),
Stage("normalize", stage_normalize, required=True, timeout_seconds=120),
Stage("integrity", stage_integrity, required=True, timeout_seconds=60),
Stage("diff", stage_diff, required=True, timeout_seconds=120),
Stage("persist", stage_persist, required=True, timeout_seconds=180),
Stage("enrich", stage_enrich, required=False, timeout_seconds=720),
Stage("report", stage_report, required=True, timeout_seconds=300),
Stage("backup", stage_backup, required=False, timeout_seconds=300),
Stage("finalize", stage_finalize, required=True, timeout_seconds=60),
)
def run_pipeline(ctx: RunContext) -> RunResult: ...
required=False스테이지의 실패는 상태를 FAILED로 끌어내리지 않는다.enrich(AI)와backup이 여기 해당한다.- 체크포인트: 각 스테이지 시작·종료를
stage_status에 기록한다. 같은run_id로 재실행하면status="SUCCESS"인 스테이지를 건너뛴다 — Task SchedulerRestartCount재시도가 소스를 다시 두드리지 않게 한다. - 실패 시: 미처리 예외는
run_pipeline이 최상위에서 잡아finish_run(status="FAILED")+ CRITICAL 알림을 기록한다.
4. 데이터 흐름
4.1 정상 경로
[06:00 ± 0~240초 지터] DMF_Crawler_Daily (S4U, 사용자 계정)
│ 또는 [부팅 후 5분] 같은 작업의 AtStartup 트리거
▼
python -m dmf_crawler run --trigger scheduled
│
├─ runlock.acquire() ── 실패 시 종료 0 (다른 인스턴스 실행 중)
├─ config.load_config() + validate() ── 실패 시 종료 2 + CRITICAL 알림
├─ repo.last_success_run_on(오늘) ── 있으면 "SKIPPED" 기록 후 종료 0 ★ idempotency 가드
└─ logging_setup: logs/run_<run_id>/ 생성
│
▼
┌─ STAGE preflight ─────────────────────────────────────────────────┐
│ db.connect(WAL) → apply_migrations(백업 후) → integrity_check │
│ secrets_dpapi.load_service_key() → 없으면 API는 건너뛰고 headful/폴백 │
│ 디스크 여유 공간 확인 → 임계 미만이면 WARN 알림 │
└──────────────────────────────────────────────────────────────────┘
▼
┌─ STAGE fetch ─────────────────────────────────────────────────────┐
│ source.mode=auto + API 키 있음 또는 source.mode=api │
│ · health.allow("source_mfds") 확인 (OPEN이면 스테일 폴백) │
│ · HttpClient.get(numOfRows=1) → totalCount 취득 │
│ · ceil(totalCount/page_size) 페이지 순회 │
│ · 각 페이지 원문 → data/raw/<date>/page_NNNN.json │
│ source.mode=auto + API 키 없음 또는 source.mode=browser │
│ · AGY + chrome-devtools MCP + visible Chrome │
│ · https://nedrug.mfds.go.kr/pbp/CCBAC03/getExcel 다운로드 │
│ 공통: 본문/파일 시그니처, total/zip/XML/행 수 검증 → FetchResult │
└──────────────────────────────────────────────────────────────────┘
▼
┌─ STAGE normalize ─────────────────────────────────────────────────┐
│ RawRecord[] → DmfRecord[] (NFKC·공백·전각·법인격·국가 다중값 정렬) │
│ dmf_key 부여(중복 시 결정론적 접미사), content_hash 산출 │
└──────────────────────────────────────────────────────────────────┘
▼
┌─ STAGE integrity ─── ★ 요구 R2.2 관문 ─────────────────────────────┐
│ 게이트 5종 판정. 하나라도 실패하면 → 4.2절 [실패 경로 C]로 분기 │
└──────────────────────────────────────────────────────────────────┘
▼
┌─ STAGE diff ─── (순수 함수, DB 미접근) ────────────────────────────┐
│ repo.load_snapshot(가장 최근 성공 run) 을 인자로 받아 비교 │
│ → DiffResult{new, changed, withdrawn} │
│ (첫 실행이면 빈 결과 + 기준선 수립 표시) │
└──────────────────────────────────────────────────────────────────┘
▼
┌─ STAGE persist ─── 단일 트랜잭션 ──────────────────────────────────┐
│ insert_snapshot(오늘 전량, append-only) │
│ insert_events(diff, append-only) │
│ upsert_current_records(records 현재 상태) │
│ record_fetch_stats │
│ ★ 이 시점에서 리포트는 이미 완결 가능하다. AI는 순수 후행 단계다. │
└──────────────────────────────────────────────────────────────────┘
▼
┌─ STAGE enrich (required=False) ───────────────────────────────────┐
│ 조건: diff.is_empty == False AND agy.enabled │
│ AND health.allow("agy") == True │
│ AND budget.tokens_used_today < daily_token_cap │
│ prompt.render(diff + 지표) → 단일 프롬프트 │
│ client.run_agy(...) → AgyEnvelope (예외 없음) │
│ extract.get_briefing → 실패 시 강화 프롬프트로 1회 재시도 │
│ repo.insert_agy_call(성공/실패 무관 usage·duration 기록) │
│ 성공 시 repo.insert_enrichment │
│ classify_error == AUTH → CRITICAL 알림 기록(창은 안 띄움) │
│ health.record_success/failure("agy") │
└──────────────────────────────────────────────────────────────────┘
▼
┌─ STAGE report ─── (DB 읽기 전용, AI 유무와 무관하게 항상 완주) ─────┐
│ data.fetch_report_data(run_id) → ReportData │
│ XlsxWriter로 8시트 조립(모든 숫자는 파이썬 사전 계산값) │
│ atomic.atomic_write: 임시파일 → os.replace │
│ · PermissionError 시 3회 재시도 → 폴백 파일명 + WARN 알림 │
│ DMF_리포트_최신.xlsx 원자적 갱신 │
└──────────────────────────────────────────────────────────────────┘
▼
┌─ STAGE backup (required=False) ───────────────────────────────────┐
│ VACUUM INTO backup/dmf_<date>.sqlite3 → 보존 개수 초과분 삭제 │
└──────────────────────────────────────────────────────────────────┘
▼
┌─ STAGE finalize ──────────────────────────────────────────────────┐
│ finish_run(status=SUCCESS|PARTIAL) │
│ state/heartbeat.json 갱신 ← ★ 성공/부분성공일 때만 갱신 │
│ alerts.mirror_to_file(state/alerts.json) │
│ eventlog.write(요약 1줄) │
│ 로그 보존 기간 초과 디렉터리 정리 │
└──────────────────────────────────────────────────────────────────┘
▼
종료 코드 0
┊
┊ (독립 트리거) [로그온 시 + 15분마다] DMF_Crawler_Agent (Interactive)
▼
pythonw -m dmf_crawler notify-pump
1) watchdog: heartbeat 나이 > stale_after_minutes → CRITICAL 알림 기록
2) alerts.pending() 조회
3) WARN/INFO → tkinter 자동소멸 토스트
4) CRITICAL → gui.launch(mode="recover", focus_key=...) 모달 창 ★ 요구 R7.2
5) mark_shown
4.2 실패 경로
[A] 네트워크·API 실패 (fetch 단계)
HTTP 오류/타임아웃 → http.py 재시도 4회(Retry-After 우선)
├─ 회복 → 정상 흐름 복귀
└─ 소진 → FetchError
→ health.record_failure("source_mfds")
├─ CLOSED 유지(1~2회차): WARN 알림, 오늘은 스테일 폴백
└─ OPEN 전환(3회차): CRITICAL 알림 1회(전환 시에만)
→ integrity 게이트 1 실패로 이어짐 → [C]로 합류
[B] agy 실패 (enrich 단계)
LAUNCH_FAILED / status=ERROR / 타임아웃 / JSON 검증 실패
→ 예외 없이 값으로 반환 → agy_calls에 실패 기록
→ health.record_failure("agy") (3회 연속이면 24h OPEN)
→ classify_error:
AUTH → CRITICAL 알림(code=AGY_AUTH). 에이전트가 재로그인 창을 띄운다
QUOTA → WARN 알림. 그날 AI 스킵
기타 → INFO 로그만
→ report 단계는 그대로 실행. 대시보드에 "AI 요약 없음: <사유>" 배지
→ 실행 상태는 FAILED가 아니라 SUCCESS(요구 R4.4)
[C] 무결성 게이트 차단 (integrity 단계)
게이트 5종 중 하나라도 실패
→ 스냅샷 INSERT 하지 않음 (기준선 오염 방지)
→ diff 수행하지 않음 (전건 취하 오탐 원천 차단)
→ report는 "마지막 성공 스냅샷"으로 생성 + 대시보드 최상단 경고 배너
→ WARN 알림(code=INTEGRITY_BLOCKED, 4요소 문구 포함)
→ 실행 상태 PARTIAL, 종료 코드 0 (리포트가 나왔으므로)
→ heartbeat는 갱신하되 status=PARTIAL을 함께 기록
[D] 리포트 파일 잠김 (report 단계)
os.replace → PermissionError (사용자가 어제/오늘 파일을 Excel로 열어둠)
→ 2초 백오프로 3회 재시도
→ 끝내 실패 → DMF_리포트_<date>_<HHMMSS>.xlsx 폴백 파일명으로 저장
→ WARN 알림: "리포트를 다른 이름으로 저장했습니다 / 원본이 열려 있습니다 /
Excel을 닫고 report-only 를 실행하세요 / [지금 실행] 버튼"
→ 실행 상태 SUCCESS (파일은 나왔다)
[E] 프로세스가 아예 시작되지 못함 / 강제 종료
ExecutionTimeLimit 30분 초과 → Task Scheduler가 강제 종료 → finally도 못 돔
→ heartbeat 미갱신
→ Task Scheduler RestartCount 3회 재시도(10분 간격)
→ 체크포인트 덕에 성공한 스테이지는 건너뛰고 재개
→ 그래도 실패하면 heartbeat가 계속 오래됨
→ 다음 pump 주기(≤15분)에서 워치독이 감지 → CRITICAL 모달
문구: "06:00 배치가 실행되지 않았습니다 / 마지막 성공 <시각> /
[지금 실행] [작업 상태 확인] [로그 폴더 열기]"
[F] 알림을 표시할 세션이 없음
사용자가 로그오프 상태
→ 배치는 alerts 테이블 + state/alerts.json + Windows Event Log에 기록
→ 다음 로그온 시 DMF_Crawler_Agent의 AtLogOn 트리거가 즉시 실행
→ 미표시 알림을 그때 표시 (요구 4절 각주의 "다음 로그온 시 재표시")
5. 실행 진입점
단일 진입점 python -m dmf_crawler <서브커맨드>. 배포된 콘솔 스크립트 dmf 도 등록한다(pyproject.toml).
| 커맨드 | 인자 | 동작 | 주 사용자 |
|---|---|---|---|
run |
--trigger {scheduled,startup,manual} (기본 manual), --force(idempotency 가드 무시), --dry-run(DB 쓰기·리포트 저장 없이 로그만), --skip-agy |
전체 파이프라인 1회 실행 | Task Scheduler, GUI "지금 실행" |
report-only |
--run-id <id> 또는 --date YYYY-MM-DD(기본: 마지막 성공 run) |
수집·diff 없이 DB에서 리포트만 재생성 | 사람(리포트 깨짐 복구) |
backfill |
--from YYYY-MM-DD --to YYYY-MM-DD, --reparse(원문 아카이브에서 재파싱), --rediff, --rereport |
과거 구간 재처리. 원문 아카이브가 있는 날짜만 대상 | 사람(파서 수정 후) |
doctor |
--json(기계 판독 출력), --fix-tasks(스케줄 작업만 재등록) |
checks.run_all()을 텍스트 표로 출력. 종료 코드로 정상/이상 구분 |
사람, GUI 내부 |
onboard |
--mode {setup,recover,inspect}, --focus <check_key> |
GUI 창 기동(요구 R6·R7). pythonw로 호출 |
바로가기 더블클릭 |
notify-pump |
--once(기본), --interval <초>(디버깅용 루프) |
워치독 판정 + 알림 표시 + 필요 시 복구 GUI 기동 | Task Scheduler(Interactive) |
install-task |
--time 06:00, --user <계정> |
scripts/install_tasks.ps1 호출로 작업 3종 등록 |
GUI 버튼, 최초 설치 |
uninstall-task |
— | 작업 3종 제거 | 사람 |
backup |
--now, --prune |
즉시 백업 / 보존 정책 적용 | 사람, 파이프라인 내부 |
db |
migrate | vacuum | check | version |
마이그레이션 적용 / VACUUM / integrity_check / 스키마 버전 | 사람 |
version |
— | 패키지·Python·의존성·agy 버전 출력 | 사람 |
종료 코드 규약(전 커맨드 공통)
| 코드 | 의미 | Task Scheduler 재시작 유발 |
|---|---|---|
0 |
SUCCESS / PARTIAL / SKIPPED — 리포트 파일이 존재한다 | 아니오 |
1 |
FAILED — 리포트 미생성. 일시적 원인일 수 있어 재시도 가치 있음 | 예(최대 3회) |
2 |
BLOCKED — DB 손상, 설정 오류처럼 재시도해도 같은 전제조건 미충족. API 키 없음은 선택 기능이라 BLOCKED가 아니다 | 예(무해, idempotent + 알림 dedup으로 흡수) |
130 |
사용자 중단(Ctrl+C) | 아니오 |
doctor만 예외적으로 0(전부 통과) / 1(WARN 존재) / 2(CRITICAL 존재)를 쓴다.
바로가기(요구 R6.1·R6.2): bootstrap.cmd가 scripts/make_shortcuts.ps1을 호출해 프로젝트 루트와 바탕화면에 두 개의 .lnk를 만든다.
| 바로가기 | 대상 |
|---|---|
DMF 설정.lnk |
.venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode setup |
지금 실행.lnk |
.venv\Scripts\pythonw.exe -m dmf_crawler onboard --mode inspect --run-now |
pythonw.exe는 콘솔을 만들지 않는다. .bat/.cmd는 반드시 콘솔 창을 띄우므로 최초 설치용 bootstrap.cmd 외에는 배치 파일을 사용자에게 노출하지 않는다.
6. 설정 파일
형식 확정: TOML 단일 파일 config/config.toml. stdlib tomllib로 읽는다(ADR-14). PC별 차이는 config/config.local.toml(gitignore)이 같은 키를 덮어쓴다. 비밀 값은 절대 넣지 않는다 — API 키는 DPAPI, agy 토큰은 agy가 관리한다.
6.1 전체 키 스펙
| 키 | 타입 | 기본값 | 설명 |
|---|---|---|---|
general.timezone |
str | "Asia/Seoul" |
실행일자 판정 기준 시간대 |
general.contact_email |
str | "" |
User-Agent에 넣을 연락처. 요구 N1(출처 표시·정중한 접근) |
general.language |
str | "ko" |
리포트·알림 언어 |
schedule.daily_time |
str | "06:00" |
일일 실행 시각(요구 R5.1) |
schedule.jitter_seconds |
int | 240 |
0~이 값 사이 랜덤 지연. R5.1의 ±5분 안에 들도록 상한 300 |
schedule.startup_delay_minutes |
int | 5 |
AtStartup 트리거 지연(네트워크·프로필 준비 대기) |
schedule.execution_time_limit_minutes |
int | 30 |
작업 강제 종료 한계 |
schedule.restart_count |
int | 3 |
실패 시 재시작 횟수 |
schedule.restart_interval_minutes |
int | 10 |
재시작 간격 |
schedule.agent_repeat_minutes |
int | 15 |
알림 에이전트 반복 주기 |
schedule.agy_update_weekday |
str | "Sunday" |
주간 agy update 요일 |
schedule.agy_update_time |
str | "14:00" |
주간 업데이트 시각(사람이 깨어 있는 시간대) |
source.base_url |
str | "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01" |
정본 엔드포인트 |
source.response_type |
str | "json" |
json | xml. json 실패 시 xml 폴백 |
source.page_size |
int | 100 |
numOfRows. 명세 크기가 3자리라 상한 999 추정(⚠️ 미검증) |
source.connect_timeout_seconds |
float | 10.0 |
연결 타임아웃 |
source.read_timeout_seconds |
float | 30.0 |
읽기 타임아웃 |
source.max_attempts |
int | 4 |
재시도 최대 횟수 |
source.backoff_base_seconds |
float | 5.0 |
지수 백오프 기준 |
source.backoff_cap_seconds |
float | 300.0 |
백오프 상한 |
source.min_interval_seconds |
float | 0.7 |
페이지 간 최소 간격(정중함) |
source.min_body_bytes |
int | 200 |
이보다 짧은 200 응답은 실패로 간주 |
source.archive_retain_days |
int | 180 |
data/raw/ 보존 기간 |
integrity.max_drop_ratio |
float | 0.05 |
totalCount 급감 허용 한계(요구 R2.2 게이트 3) |
integrity.max_null_ratio |
float | 0.01 |
필수 필드 널 비율 상한(게이트 4) |
integrity.max_duplicate_ratio |
float | 0.02 |
중복 등록번호 비율 상한(게이트 5) |
storage.sqlite_path |
str | "data/dmf.sqlite3" |
정본 DB 경로 |
storage.busy_timeout_ms |
int | 15000 |
SQLite 잠금 대기 |
storage.snapshot_retain_days |
int | 0 |
0 = 영구 보존(이벤트 소싱 원칙) |
backup.enabled |
bool | true |
성공 후 자동 백업 |
backup.dir |
str | "backup" |
다른 드라이브 권장. 온보딩이 유도 |
backup.keep_count |
int | 30 |
보존 개수 |
backup.min_free_gb |
float | 2.0 |
이보다 여유가 적으면 백업 스킵 + WARN |
report.output_dir |
str | "reports" |
산출물 디렉터리 |
report.filename_pattern |
str | "DMF_리포트_{date}.xlsx" |
파일명 |
report.latest_link_name |
str | "DMF_리포트_최신.xlsx" |
고정 링크 파일명. 빈 문자열이면 생성 안 함 |
report.font |
str | "맑은 고딕" |
Windows Vista+ 기본 탑재 |
report.palette |
str | "okabe_ito" |
색각이상 안전 팔레트 고정 |
report.top_n |
int | 10 |
성분별·업체별 시트 상위 N |
report.trend_days |
int | 90 |
추이 시트 표시 기간 |
report.retain_days |
int | 365 |
리포트 보존 기간 |
report.lock_retries |
int | 3 |
파일 잠김 재시도 횟수 |
agy.enabled |
bool | true |
AI 계층 사용 여부 |
agy.binary_path |
str | "" |
빈 값이면 %LOCALAPPDATA%\agy\bin\agy.exe 자동 |
agy.model |
str | "gemini-3.7-flash-medium" |
agy SSOT §8.2 권장 |
agy.effort |
str | "medium" |
low | medium | high |
agy.print_timeout |
str | "10m" |
SSOT §16 권장. 기본 5분은 부족 |
agy.skip_if_diff_empty |
bool | true |
변화 없는 날 28k 토큰을 태우지 않는다 |
agy.max_json_retries |
int | 1 |
스키마 검증 실패 시 강화 프롬프트 재시도 |
agy.daily_token_cap |
int | 300000 |
일일 누적 토큰 상한 |
agy.max_events_in_prompt |
int | 200 |
명령줄 길이·토큰 절약을 위한 상위 N건 절단 |
agy.prompt_path |
str | "prompts/daily_briefing.md" |
템플릿 경로 |
agy.schema_path |
str | "prompts/daily_briefing.schema.json" |
자체 검증 스키마 |
health.source_failure_threshold |
int | 3 |
소스 서킷 OPEN 임계(연속 실행 횟수) |
health.source_cooldown_hours |
int | 24 |
소스 쿨다운 |
health.agy_failure_threshold |
int | 3 |
agy 서킷 OPEN 임계 |
health.agy_cooldown_hours |
int | 24 |
agy 쿨다운 |
notify.watchdog_stale_minutes |
int | 120 |
heartbeat 신선도 한계(06:00 + 2시간) |
notify.toast_seconds |
int | 12 |
자동 소멸 알림 표시 시간 |
notify.cooldown_minutes |
int | 240 |
동일 코드 알림 억제 시간(요구 R7.8) |
notify.consecutive_failure_critical |
int | 3 |
연속 실패 이 횟수부터 CRITICAL 승격 |
notify.eventlog_source |
str | "DMF Crawler" |
Windows 이벤트 로그 원본 이름 |
logging.dir |
str | "logs" |
로그 루트 |
logging.level |
str | "INFO" |
DEBUG | INFO | WARNING | ERROR |
logging.retain_days |
int | 90 |
실행 로그 디렉터리 보존 |
logging.mask_patterns |
list[str] | ["serviceKey", "access_token"] |
로그 기록 전 마스킹 대상 키 |
6.2 완전한 예시 파일
# config/config.toml — DMF Crawler 설정 정본
# 비밀 값(API 키, OAuth 토큰)은 이 파일에 절대 넣지 않는다.
# API 키는 온보딩 GUI에서 입력하면 Windows DPAPI 로 암호화 저장된다.
[general]
timezone = "Asia/Seoul"
contact_email = "" # User-Agent 에 들어갈 연락처. 온보딩에서 입력받는다
language = "ko"
[schedule]
daily_time = "06:00"
jitter_seconds = 240
startup_delay_minutes = 5
execution_time_limit_minutes = 30
restart_count = 3
restart_interval_minutes = 10
agent_repeat_minutes = 15
agy_update_weekday = "Sunday"
agy_update_time = "14:00"
[source]
base_url = "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01"
response_type = "json"
page_size = 100
connect_timeout_seconds = 10.0
read_timeout_seconds = 30.0
max_attempts = 4
backoff_base_seconds = 5.0
backoff_cap_seconds = 300.0
min_interval_seconds = 0.7
min_body_bytes = 200
archive_retain_days = 180
[integrity]
max_drop_ratio = 0.05
max_null_ratio = 0.01
max_duplicate_ratio = 0.02
[storage]
sqlite_path = "data/dmf.sqlite3"
busy_timeout_ms = 15000
snapshot_retain_days = 0 # 0 = 영구 보존
[backup]
enabled = true
dir = "backup" # 다른 드라이브 경로를 권장한다 (예: "E:/DMF_Backup")
keep_count = 30
min_free_gb = 2.0
[report]
output_dir = "reports"
filename_pattern = "DMF_리포트_{date}.xlsx"
latest_link_name = "DMF_리포트_최신.xlsx"
font = "맑은 고딕"
palette = "okabe_ito"
top_n = 10
trend_days = 90
retain_days = 365
lock_retries = 3
[agy]
enabled = true
binary_path = "" # 빈 값이면 %LOCALAPPDATA%\agy\bin\agy.exe
model = "gemini-3.7-flash-medium"
effort = "medium"
print_timeout = "10m"
skip_if_diff_empty = true
max_json_retries = 1
daily_token_cap = 300000
max_events_in_prompt = 200
prompt_path = "prompts/daily_briefing.md"
schema_path = "prompts/daily_briefing.schema.json"
[health]
source_failure_threshold = 3
source_cooldown_hours = 24
agy_failure_threshold = 3
agy_cooldown_hours = 24
[notify]
watchdog_stale_minutes = 120
toast_seconds = 12
cooldown_minutes = 240
consecutive_failure_critical = 3
eventlog_source = "DMF Crawler"
[logging]
dir = "logs"
level = "INFO"
retain_days = 90
mask_patterns = ["serviceKey", "access_token"]
7. 실패 시나리오 대응표
심사에서 제기된 모든 시나리오 + 요구 SSOT 4절의 복구 표를 하나로 합쳤다.
| # | 실패 | 감지 방법 | 자동 대응 | 사용자 알림 | 복구 절차 |
|---|---|---|---|---|---|
| 1 | API 키 미설정 | secrets_dpapi.load_service_key() is None |
파이프라인 시작 안 함, 종료 2 | CRITICAL, 강제 창 | GUI에서 키 입력 → 즉시 실호출 검증 → 저장. 발급 페이지 열기 버튼 제공 |
| 2 | API 키 무효·만료 | resultCode != "00" 또는 HTTP 401/403 |
fetch 중단, 스테일 폴백 리포트 | CRITICAL, 강제 창 | GUI에서 키 재입력. 기존 키 지문(앞 8자)만 표시해 혼동 방지 |
| 3 | 일일 트래픽 10,000 초과 | API 오류 코드 LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR |
그날 fetch 포기, 스테일 폴백 | WARN 토스트 | 내일 재시도 안내. 반복되면 운영계정 전환 안내 |
| 4 | 네트워크 단절 | 연결 예외 | 재시도 4회 → 서킷 카운터 증가 | WARN 토스트(1~2회차) | 자동 회복. 3회 연속이면 #5로 승격 |
| 5 | 소스 서킷 OPEN 전환 | health.record_failure 3회 연속 |
24시간 소스 호출 자체를 스킵 | CRITICAL 1회(전환 시에만) | GUI "지금 실행"으로 HALF_OPEN 강제 시도. 원인 조사 후 자동 복귀 |
| 6 | HTTP 429/503 + Retry-After |
응답 헤더 | 헤더 값을 절대 우선해 대기 후 재시도 | 없음(로그만) | 자동 |
| 7 | HTTP 200인데 빈/차단 본문 | min_body_bytes 미달, <html 포함, resultCode 이상 |
실패로 분류 → 게이트 1 차단 | WARN 토스트 | 원문이 data/raw/에 남아 있으므로 즉시 열람 가능 |
| 8 | 수집 건수 ≠ totalCount |
게이트 2 | diff 미수행, 스냅샷 저장 안 함 | WARN 토스트 | 다음 실행에서 자동 재시도. run --force로 즉시 재시도 가능 |
| 9 | totalCount 5% 이상 급감 |
게이트 3 | diff 미수행(전건 취하 오탐 차단) | CRITICAL | 사람이 원문 확인 후 정상이면 임계값 조정 또는 run --force |
| 10 | 필수 필드 널 비율 초과 | 게이트 4 | diff 미수행 | WARN | 원문 확인. API 스키마 변경 의심 시 #12로 |
| 11 | 중복 등록번호 비율 초과 | 게이트 5 | diff 미수행 | WARN | 정상 데이터일 수 있음 — 실측 후 임계값 조정 |
| 12 | API 스키마 변경 | 예상 필드 부재 → 널 비율 급증 | diff 미수행, 원문 아카이브 보존 | CRITICAL | agy에 A7(변경점 진단) 요청 → state/proposals/에 저장 → 사람 승인 후 매핑 수정(ADR-25) |
| 13 | agy.exe 미설치 |
checks ⑥ 경로 확인 실패 |
AI 스테이지 스킵, 리포트는 정상 | CRITICAL, 강제 창 | GUI "설치" 버튼 → bootstrap_agy.ps1 무인 실행(진행률 표시) |
| 14 | agy OAuth 만료 |
classify_error(env) == AUTH |
AI 스킵. 리포트 정상 | CRITICAL, 강제 창 | GUI "로그인 창 열기" → 대화형 agy 콘솔 기동 → 완료 후 재검사 |
| 15 | agy 쿼터 소진 |
classify_error == QUOTA |
그날 AI 스킵, 서킷 카운터 증가 | WARN 토스트 | "AI 없이 리포트 생성됨" 안내. 익일 자동 재시도 |
| 16 | agy 타임아웃 |
--print-timeout 초과, 종료 코드 비0 |
AI 스킵 | INFO 로그만 | 자동. 반복되면 서킷 OPEN |
| 17 | agy JSON 추출/검증 실패 |
extract.validate 실패 |
강화 프롬프트 1회 재시도 → 실패 시 스킵 | INFO 로그만 | 오염 응답을 agy.stdout.json에 보존. 픽스처로 승격해 회귀 테스트 추가 |
| 18 | agy 일일 토큰 상한 초과 |
budget.tokens_used_today >= cap |
호출 자체를 하지 않음 | INFO 로그만 | 상한 조정 또는 다음 날 대기 |
| 19 | agy 자동 업데이트 충돌 |
배치 중 바이너리 교체 | AGY_CLI_DISABLE_AUTO_UPDATE=true를 자식 환경에 주입해 예방 |
없음 | 주간 별도 작업(일요일 14:00)에서만 agy update |
| 20 | xlsx 파일 잠김 | os.replace → PermissionError |
3회 재시도 → 폴백 파일명 저장 | WARN 토스트 | Excel 닫고 report-only 실행. 알림 문구에 명령이 포함됨 |
| 21 | 디스크 여유 부족 | checks ⑩, 백업 전 재확인 |
백업 스킵(리포트는 생성) | WARN 토스트 | 오래된 백업·로그·원문 아카이브 정리 안내. GUI에 "정리" 버튼 |
| 22 | database is locked |
sqlite3.OperationalError |
busy_timeout 15초 대기 → 실패 시 FAILED |
CRITICAL | 다른 인스턴스 확인. runlock이 이미 대부분 예방한다 |
| 23 | SQLite 파일 손상 | PRAGMA integrity_check 실패 |
파이프라인 시작 안 함, 종료 2 | CRITICAL, 강제 창 | GUI에 최신 백업본 목록 표시 → "이 백업으로 복원" 버튼 |
| 24 | 마이그레이션 실패 | 적용 중 예외 | 트랜잭션 롤백, 종료 2 | CRITICAL | 알림 문구에 적용 직전 백업본 절대경로 포함. 복원 후 코드 되돌리기 |
| 25 | 같은 날 중복 실행 | idempotency 가드 + runlock |
SKIPPED 기록 후 종료 0 |
없음 | 의도적 재실행은 run --force |
| 26 | 06:00에 PC 꺼져 있음 | Task Scheduler | StartWhenAvailable + AtStartup(+5분) 트리거로 자동 캐치업 |
없음 | 자동. 중복은 #25가 흡수 |
| 27 | ExecutionTimeLimit 초과 강제 종료 |
heartbeat 미갱신 | RestartCount 3회 재시도(체크포인트로 재개) |
CRITICAL(워치독) | GUI "지금 실행". 반복되면 page_size 조정·타임아웃 상향 |
| 28 | 배치가 아예 실행되지 않음 | heartbeat 나이 > 120분 | 없음(감지 전용) | CRITICAL, 강제 창 | GUI "지금 실행" / "작업 상태 확인" / "로그 폴더 열기" |
| 29 | 작업 스케줄러 등록 소실 | checks ⑨ Get-ScheduledTask 실패 |
자동 재등록하지 않는다(사용자가 의도적으로 껐을 수 있음) | CRITICAL, 강제 창 | GUI "작업 다시 등록" 버튼 → install-task |
| 30 | 연속 3일 실패 | runs 집계 |
없음 | CRITICAL, 강제 창 | 진단 화면(doctor) 자동 표시 + 실패 원인 상위 3개 요약 |
| 31 | 알림 표시 불가(로그오프) | 표시 대상 세션 없음 | alerts + state/alerts.json + Event Log에 축적 |
다음 로그온 시 즉시 표시 | AtLogOn 트리거가 밀린 알림을 한 번에 표시 |
| 32 | Python/venv 손상 | checks ①② import 실패 |
파이프라인 실행 불가 | CRITICAL — GUI도 못 뜰 수 있음 | bootstrap.cmd 재실행. Event Log가 유일한 흔적이므로 반드시 기록 |
| 33 | 미처리 예외 | run_pipeline 최상위 except |
finish_run(FAILED) + 스택트레이스 로그 |
CRITICAL | 알림에 로그 디렉터리 절대경로 포함. 워치독이 이중 확인 |
알림 문구 4요소 예시(요구 R7.3 규격)
[무엇] 06:00 DMF 수집 배치가 어제부터 실행되지 않았습니다.
[왜] Antigravity CLI 로그인이 만료되어 AI 요약 단계에서 중단됐습니다.
[어떻게] 아래 [로그인 창 열기]를 누르고 Google 계정으로 다시 로그인하세요. 1분이면 끝납니다.
[다음] [로그인 창 열기] [지금 실행] [로그 폴더 열기] [나중에]
8. 의존성 목록
8.1 런타임 의존성 (3개)
| 패키지 | 버전 제약 | 용도 | 대안과 기각 사유 |
|---|---|---|---|
httpx |
>=0.27,<1.0 |
유일한 HTTP 클라이언트. params= 자동 인코딩(Decoding 키 전제), 연결/읽기 타임아웃 분리 |
requests(타임아웃 분리 불가), stdlib urllib(재시도·타임아웃을 직접 구현해야 함), aiohttp(비동기 불필요) |
XlsxWriter |
>=3.2,<4.0 |
xlsx 생성 전담. 스파크라인·수식 캐시값·autofit은 이 라이브러리에만 있다 |
openpyxl(스파크라인 없음, 재저장 시 차트 손실), pandas.to_excel(서식 제어 불가) |
jsonschema |
>=4.21,<5.0 |
agy 응답 자체 검증(ADR-17). 손으로 짜면 반드시 틀리는 부분 | 수동 검증(오류 메시지 품질 저하), pydantic(무거움, 이 프로젝트에 다른 용도 없음) |
8.2 개발 의존성 (1개)
| 패키지 | 버전 제약 | 용도 |
|---|---|---|
pytest |
>=8.0 |
단위·골든 테스트 |
8.3 표준 라이브러리로 해결한 것
| 필요 | 사용한 stdlib | 회피한 외부 패키지 |
|---|---|---|
| 설정 파싱 | tomllib (3.11+) |
PyYAML |
| 데이터베이스 | sqlite3 |
SQLAlchemy, alembic |
| GUI·토스트 | tkinter |
PySide6, win11toast, BurntToast |
| 비밀 암호화 | ctypes → crypt32.dll DPAPI |
keyring, cryptography, python-dotenv |
| 배타 락 | msvcrt |
portalocker, filelock |
| 재시도·백오프 | 직접 구현(약 50줄, 테스트 포함) | tenacity, backoff |
| 집계 | SQLite GROUP BY |
pandas, polars |
| 이벤트 로그 | subprocess → eventcreate.exe |
pywin32 |
| CLI 파싱 | argparse |
click, typer |
| XML 파싱(폴백) | xml.etree.ElementTree |
lxml, BeautifulSoup |
8.4 외부 프로그램 전제
| 프로그램 | 필수 여부 | 확인 방법 |
|---|---|---|
| Python 3.11+ | 필수 (실측 3.14.6) | checks ① |
agy.exe v1.1.22+ |
선택 — 없으면 AI 요약만 빠진다 | checks ⑥. 없으면 GUI가 설치 제안 |
| PowerShell 7 | 작업 등록·agy 설치 시에만 | scripts/*.ps1 |
| LibreOffice | 불필요(ADR-08로 제거) | — |
| Microsoft Excel | 불필요. 리포트 열람용일 뿐 | — |
8.5 최소로 유지한 근거
요구 N5(의존성 최소)와 N8(6개월 뒤 본인이 읽고 고칠 수 있는 구조)이 직접 요구한다. 실무적으로는 이 프로젝트가 비개발자 PC에서 무인으로 도는 것이 목표이므로, 의존성 하나는 곧 "6개월 뒤 pip install이 실패할 확률 하나"다. 세 후보의 의존성은 A 3개 / B 6개(+pandas·pydantic·win11toast) / C 7개였고, 확정안은 3개로 A의 최소주의를 유지하면서 B의 구조를 얻었다.
9. 구현 순서
각 마일스톤 끝에서 실제로 무엇이 동작하는지를 기준으로 나눴다. 앞 단계가 동작하지 않으면 다음으로 넘어가지 않는다.
M0 — 뼈대와 진실 (예상 1~2일)
산출물
- 디렉터리 트리 전체 생성(빈 모듈 포함),
pyproject.toml,bootstrap.cmd,.gitignore paths.py,config.py,errors.py,logging_setup.py,runlock.pysecrets_dpapi.py+ 단위 테스트storage/db.py+migrations/0001_init.sql,repo.py의 run/checkpoint 계열cli.py의version,db,doctor(체크 ①②③④ 만)
이 시점에 동작하는 것
bootstrap.cmd 더블클릭 → venv 생성 → dmf version / dmf db migrate / dmf doctor 가 실행되고, API 키를 DPAPI로 저장·복호화할 수 있다. DB 파일과 설정이 진실로 존재한다.
M1 — 수집에서 diff까지 (예상 2~3일)
산출물
http.py(재시도·Retry-After·지터·조건부 요청) +test_http_retry.pysource_mfds.py(전량 수집·원문 아카이브·본문 시그니처 검사)normalize.py+test_normalize.pyintegrity.py+test_integrity.py(게이트 5종 경계 테스트)diff.py+test_diff.pyhealth.py(3상태 서킷)migrations/0002_enrichment.sql은 미적용,0001에 snapshots/records/events 포함pipeline.py의 preflight~persist 스테이지docs/design/02-data-model.md,docs/ops/03-api-usage-policy.md작성
이 시점에 동작하는 것
dmf run --skip-agy 가 실제 API에서 전량을 수집해 SQLite에 스냅샷·이벤트를 적재한다. 이틀 연속 돌리면 둘째 날에 진짜 diff가 나온다. 게이트가 불완전 수집을 막는 것도 확인된다. 리포트는 아직 없다.
여기서 데이터 소스 SSOT 부록 B의 미검증 항목을 전부 실측해 문서에 기록한다 —
totalCount실제 값,numOfRows최대값, 중복 등록번호 유무,type=json동작 여부.
M2 — 무인 운영 (예상 1~2일)
산출물
scripts/install_tasks.ps1/uninstall_tasks.ps1alerts.py,watchdog.py,notify/eventlog.py,notify/messages.pybackup.py(VACUUM INTO+ 보존 정리)pipeline.py의 backup·finalize 스테이지, heartbeat 기록- idempotency 가드와
--force docs/ops/01-scheduling-and-resilience.md작성
이 시점에 동작하는 것
06:00에 사람이 없어도 배치가 돈다. 재부팅해도 다음 06:00에 실행된다. 06:00을 놓치면 부팅 후 5분에 캐치업한다. 중복 실행은 흡수된다. 성공하면 백업본이 생기고 heartbeat가 갱신된다. 실패는 alerts 테이블과 Event Log에 남는다 — 아직 화면에는 안 뜬다.
M3 — 리포트 (예상 3~4일)
산출물
report/theme.py,widgets.py(한글 열너비·델타 화살표·KPI 타일),atomic.py+test_report_atomic.pyreport/data.py(시트별 SQL),build.pyreport/sheets/8종 전부cli report-only,cli backfilldocs/design/03-xlsx-report-spec.md작성
이 시점에 동작하는 것
매일 06:00에 탭별로 연동된, 디자인이 예쁜 xlsx가 나온다(요구 R3 전부). 파일을 열어둔 상태에서도 배치가 실패하지 않는다. report-only로 과거 어느 날짜든 재생성된다. 이 시점에서 프로젝트는 이미 사용자에게 가치를 준다.
M4 — 사람과의 접점 (예상 3~4일)
산출물
agy/client.py,prompt.py,extract.py,budget.py+test_agy_extract.pyprompts/daily_briefing.md,daily_briefing.schema.jsonmigrations/0002_enrichment.sql,0003_ops.sql적용pipeline.py의 enrich 스테이지, 대시보드 AI 배지checks.py12종 완성 +test_checks.pygui/app.py,steps.py,widgets.pynotify/pump.py,toast.pyscripts/make_shortcuts.ps1,bootstrap_agy.ps1docs/design/04-onboarding-wizard.md,docs/ops/02-failure-alerting.md작성
이 시점에 동작하는 것
비개발자가 바로가기를 더블클릭하면 창이 뜨고, API 키가 없으면 입력받아 검증·저장하고, agy가 없으면 설치하고, 로그인이 풀렸으면 로그인 창을 띄우고, 06:00 작업 등록까지 마친다(요구 R6 전부). 배치가 실패하면 15분 안에 창이 떠서 무엇이 왜 안 됐고 어떻게 고치는지 알려주고, 그 창에서 바로 고칠 수 있다(요구 R7 전부). 리포트에는 한국어 AI 브리핑이 붙고, agy가 죽어도 리포트는 그대로 나온다.
마일스톤 후 (범위 밖, 기록만)
| 항목 | 조건 |
|---|---|
| 워치리스트 UI | 사용자가 관심 성분·업체 목록을 확정한 뒤(요구 부록 미확정 항목) |
| 이메일·웹훅 알림 | 리포트 수신자가 2명 이상으로 늘어난 뒤 |
| 해외 소스(FDA/EDQM/PMDA) | 명시적 비목표. 붙일 때 source_mfds.py를 프로토콜로 승격 |
| 웹 대시보드 | 명시적 비목표 |
10. 심사 기록
10.1 후보 3안 요약 비교
| 축 | A — 단일 파이프라인 | B — 이벤트 소싱 + 계층 분리 | C — 소스 플러그인 + 오케스트레이터 |
|---|---|---|---|
| 철학 | 5년 뒤에도 즉시 이해 가능한 직선 | 데이터가 자산, 리포트는 순수 함수 | 소스 경계가 유일한 확장 지점 |
| 런타임 의존성 | 3개 | 6개 | 7개 |
| 설정 파일 수 | 1 | 4 | 6 |
| 모듈 수 | 약 14 | 약 30 | 약 35 |
| 스키마 마이그레이션 | 없음 | 있음 | 없음 |
| 원문 아카이브 | 없음(DB 컬럼만) | 있음(이중) | 있음 |
| 일자별 전량 스냅샷 | SCD2 이력 | append-only 전량 | run별 전량 |
| 중복 실행 방어 | 코드 가드 있음 | 스케줄러 설정만 | 스케줄러 설정만 |
| 알림 세션 분리 | 없음(전부 S4U) | 있음(AtLogOn 분리) | 절반(세션 감지만) |
| heartbeat 정책 | 성공 시 | 성공 시 | 항상 갱신(치명) |
| xlsx 엔진 | openpyxl + LibreOffice | openpyxl | XlsxWriter |
| Retry-After 존중 | 없음 | 없음 | 있음 |
| 서킷 브레이커 | 소스만(2상태) | agy만(2상태) | 소스만(3상태) |
| 진단 커맨드 | 있음(doctor) | 없음 | 없음 |
| 200-빈응답 판정 | 없음 | 없음 | 없음 |
| 자동 백업 | 없음 | 없음 | 없음 |
10.2 렌즈별 점수
| 렌즈 | A | B | C | 렌즈의 판단 요지 |
|---|---|---|---|---|
| 운영 단순성(ops) | 7.5 | 7.0 | 5.5 | 설치·아침 조사·6개월 뒤 재이해의 손비용. A가 최소지만 "이식 없이는 채택 불가", 특히 S4U 토스트 문제가 A의 대전제를 무너뜨린다 |
| 실패 내성(resilience) | 5.5 | 7.5 | 6.5 | 알림이 실제로 화면에 도달하는 안은 B뿐. C는 heartbeat를 항상 갱신해 dead-man switch를 설계상 무력화 |
| 진화(evolution) | 4.5 | 7.0 | 6.0 | "나중에 절대 복구 못 하는 것"(원문 아카이브·전량 스냅샷·마이그레이션)을 셋 다 갖춘 것은 B뿐 |
| 합계 | 17.5 | 21.5 | 18.0 | |
| 1위 렌즈 수 | 1 | 2 | 0 |
10.3 왜 이 결론인가
B가 뼈대인 이유는 세 가지다.
첫째, 비가역 축을 지켰다. 앞으로 올 변경은 두 종류다 — 나중에 해도 비용이 비슷한 것(알림 채널 추가, 워치리스트, 대시보드)과 지금 안 하면 영원히 복구 못 하는 것(원문 아카이브, 일자별 전량 스냅샷, 스키마 변경 절차). 재수집 불가능한 공고 데이터에서 후자는 시간이 갈수록 손실이 누적된다. B만 셋 다 갖췄다. A는 원문을 raw_json 컬럼에만 넣어 "파서를 고쳐 3개월치를 재파싱한다"는 시나리오 자체가 성립하지 않는다.
둘째, 알림이 실제로 화면에 도달하는 유일한 구조를 갖고 있었다. 요구 R7.2("강제로 창이 뜬다")와 agy SSOT §4.2(SYSTEM 금지, 사용자 계정 필수)는 정면으로 충돌한다 — S4U 세션에는 데스크톱이 없다. B만 이 충돌을 인지하고 헤드리스 워커와 로그온 세션 알리미로 작업을 쪼갰다. A는 워커도 워치독도 전부 S4U로 등록하면서 delivered=True를 기록하는데 화면에는 아무것도 뜨지 않는다. 세 렌즈 중 두 렌즈가 이것을 A의 치명 결함으로 지목했고, 요구 SSOT 4절이 이미 같은 제약을 각주로 적어두고 있었다.
셋째, AI를 스키마 레벨에서 격리했다. enrichment를 events와 물리적으로 분리한 것은 요구 R4.4("AI가 실패해도 리포트는 생성된다")를 문서가 아니라 데이터베이스가 강제하게 만든다.
C를 뼈대로 삼지 않은 이유. C의 소스 플러그인 논거는 "정책이 서로 다른 국내 소스 두 개가 이미 존재한다"였다. 그러나 그 사이 데이터 소스 SSOT가 확정됐다 — nedrug HTML은 쓰지 않는다(요구 R1.2·R1.3). 소스가 하나로 줄었고 해외 소스는 명시적 비목표가 됐다. 즉 C의 핵심 논거가 요구 확정으로 소멸했다. 남은 것은 문자열 동적 import, 죽은 스텁 3개, 설정 6분할, 대상 없는 셀렉터 자가복구 스테이지, 그리고 선형 7단계에 얹은 DAG 엔진뿐이다. C가 옳았던 부분(Retry-After, 3상태 서킷, 체크포인트, AI 제안 자동적용 금지, Event Log)은 전부 이식했다.
A를 뼈대로 삼지 않은 이유. A의 최소주의는 진짜 가치이고 이 확정안은 그것을 상당 부분 계승했다 — 설정 1개, 의존성 3개, 단일 진입점, doctor 커맨드. 그러나 A가 절약한 것(마이그레이션 러너 수백 줄, 원문 아카이브 수십 줄)과 잃은 것(앞으로 올 변경 다수가 2~3배 비싸지고, 재분석 시나리오 하나가 아예 불가능해짐)의 교환비가 나쁘다. 이건 YAGNI가 아니라 되돌릴 수 없는 축을 잘못 자른 것이다. 여기에 openpyxl + LibreOffice 재계산이라는 xlsx SSOT 정면 위반이 겹쳐, "시각화가 핵심 가치"라는 요구가 커지는 방향으로 재작성 부채가 예약돼 있었다.
세 후보 모두가 뚫려 있어 신규로 메운 것. ① HTTP 200 + 빈/차단 본문 판정 — 셋 다 200이면 성공으로 처리해 곧장 diff로 넘어갔고, 결과는 예외도 알림도 없이 전 레코드가 취하로 오탐된 리포트다. 요구 R2.2가 이미 안전장치 5종을 명시하고 있었으므로 integrity.py를 1급 스테이지로 승격시켰다. ② xlsx 원자적 교체 — xlsx SSOT §10이 ~$ 배타 잠금과 확정적 PermissionError를 실측했는데 원자 교체를 설계한 후보가 없었고, 하필 A·C가 권장하는 복구 절차(같은 날 report-only 재실행)가 정확히 이 잠금에 막혔다. ③ 자동 백업 — 셋 다 유일 정본을 파일 하나에 두면서 백업은 README나 "설계 밖"으로 미뤘다. VACUUM INTO 10줄을 파이프라인 스테이지로 넣었고, 이것이 동시에 마이그레이션 롤백 경로가 됐다. ④ 디스크 여유 점검. ⑤ 비개발자용 GUI 온보딩·복구(요구 R6·R7) — 세 후보가 작성될 때 존재하지 않던 요구다.
부록. 기각된 선택지와 이유
| 기각한 것 | 어느 후보/렌즈가 제안했나 | 기각 이유 |
|---|---|---|
| nedrug HTML 스크래핑 | A(비활성), B(비활성), C(활성) | robots.txt가 Disallow: / 전면 금지이고 요구 R1.2·R1.3가 크롤링을 금지했다. 동일 데이터가 공식 API로 개방돼 있어 우회할 이유가 없다 |
selectors.yaml + AI 셀렉터 자가복구 |
C | ADR-01로 셀렉터 자체가 존재하지 않는다. 대상 없는 기능이다. 대응되는 개념은 "API 스키마 변화 대응(A7)"이며 승인제로 구현한다 |
Source 프로토콜 + registry.py 동적 import |
B, C, 진화 렌즈 | 소스 1개 + 확장 비목표. 문자열 동적 import는 오타를 런타임까지 잠복시키고 grep 추적을 끊는다. 두 번째 소스가 실제로 오면 그때 승격한다 |
| DAG 위상정렬(TaskGraph) | C | 병렬성도 동적 브랜칭도 없는 선형 7단계. 스택 트레이스가 오케스트레이터 프레임을 통과해 원인이 흐려진다. 선언적 스테이지 리스트만 취했다 |
| 설정 파일 다중 분할(YAML 4~6개) | B, C | 06:05에 "어느 파일을 열지"부터 정해야 하는 시스템은 5분 안에 안 고쳐진다. PyYAML 의존성도 함께 제거했다 |
permissions.agy.json 별도 파일 |
B | agy는 ~/.gemini/antigravity-cli/settings.json만 읽는다. 별도 파일 + 병합 배포 스텝은 조용히 실패했을 때 증상과 원인의 거리가 너무 멀다. 온보딩이 표준 위치에 직접 병합한다 |
company_aliases.csv 수동 큐레이션 |
B | 사람 부채는 1인 운영에서 반드시 부도난다. 규칙 기반 정규화 + agy 제안(승인제)로 대체 |
openpyxl로 리포트 쓰기 |
A, B | 스파크라인 API 부재(요구 R3.4 불충족), load_workbook→save 시 차트·이미지 손실. xlsx SSOT §3.2 결론 |
| LibreOffice headless 재계산 | A | "켠 채로 조용히 안 도는" 상태가 1인 운영 최악의 실패 모드다. 모든 숫자를 파이썬이 계산해 값으로 쓰면 재계산 자체가 불필요해진다 |
| 동적 배열 함수(FILTER/UNIQUE/XLOOKUP) | — | xlsx SSOT §0: 값 캐시가 없어 뷰어에서 0으로 보인다. 필터링은 파이썬에서, 인터랙션은 Excel 표 + 자동필터로 |
pandas |
B, C, 07 문서 전제 | 필요한 집계가 GROUP BY 수준이다. 요구 N5와 설치 실패 표면을 고려하면 정당화되지 않는다 |
pydantic |
B, C | 설정은 tomllib + 데이터클래스, agy 응답은 jsonschema로 충분하다 |
win11toast / BurntToast |
B, A | 외부 모듈 설치가 전제여서 "설치가 안 되면 알림도 안 됨"이라는 순환 실패를 만든다. tkinter로 직접 그린다 |
| 환경변수로 API 키 전달 | A, C, 진화 렌즈 | 요구 R6.3의 사용자는 환경변수를 모른다. DPAPI + GUI 입력이 유일하게 요구를 만족한다 |
| 평문 파일에 서비스키 저장 | B | 요구 N7 위반. DPAPI는 의존성 0으로 같은 편의를 준다 |
run_daily.ps1 시작부의 self-healing 작업 재등록 |
C, 운영 렌즈 | 논리적 자기모순이다 — 작업이 사라지면 스크립트가 안 돌고, 스크립트가 안 돌면 재등록도 안 된다. 게다가 사용자가 의도적으로 끈 작업을 되살린다. checks ⑨의 읽기 전용 점검 + GUI 버튼으로 옮겼다 |
| 매 배치 agy 인증 헬스체크 | — | agy SSOT §4.3: 헬스체크 한 번에 input 28k 토큰. 실제 작업 호출의 결과로만 판정한다 |
--dangerously-skip-permissions |
— | API 응답 문자열(성분명·제조소명)이 프롬프트에 들어가므로 인젝션 표면이 있다. 이 태스크는 파일 쓰기·명령 실행이 필요 없다 |
--continue / --conversation 재사용 |
— | agy SSOT §6.5: 배치는 매 실행이 독립적이어야 한다 |
--json-schema의 structured_output 신뢰 |
— | SSOT §7.2 실측에서 필드 자체가 없었고 응답이 4회 반복 오염됐다 |
| agy 역할별 다중 호출(A1~A7 각각) | — | 첫 호출 오버헤드 28k 토큰 / 33.7초. 하나로 묶는다 |
| Windows Service로 상주 | — | "매일 정해진 시각 실행"은 정확히 크론이 하는 일이다. 상시 메모리 점유와 별도 생명주기 관리만 늘어난다 |
| WSL cron | — | agy 토큰이 Windows 사용자 프로필에 있다. 계층을 하나 더 넣을 이유가 없다 |
| SYSTEM 계정으로 작업 실행 | — | agy SSOT §4.2: 사용자 프로필의 OAuth 토큰에 접근할 수 없다 |
| 워치독을 별도 3번째 작업으로 분리 | A(07:30 워치독) | 알림 에이전트가 15분마다 로그온 세션에서 도는데, 그 안에서 heartbeat 판정을 함께 하면 작업 하나가 줄고 "알림이 뜨는 세션에서 판정한다"는 성질이 공짜로 따라온다 |
| Airflow / Prefect / Scrapy | — | 하루 1회, 소스 1개, 5~7단계 선형. 스케줄러 데몬을 24시간 띄우는 순간 "그 데몬은 누가 감시하는가"라는 요구가 하나 더 생긴다 |
| 웹 대시보드(FastAPI) | 진화 렌즈가 확장 시나리오로 언급 | 요구 3절 명시적 비목표 |
| 해외 소스(FDA/EDQM/PMDA) | B, C | 요구 3절 명시적 비목표. 확장 지점으로만 기록 |
| alembic | 진화 렌즈 | ORM이 없는 프로젝트에 과하다. 번호순 SQL + 적용 전 백업으로 충분하고, 백업본이 곧 롤백이다 |
| tenacity / backoff 라이브러리 | 운영 렌즈가 A의 약점으로 언급 | 재시도 규칙이 Retry-After 우선이라는 도메인 특수 규칙을 포함해 어차피 감싸야 한다. 50줄 + 테스트로 직접 짜는 편이 명확하다 |
| 일 2회 이상 실행 | — | 요구 R5.1이 06:00 1회다. API 갱신 주기가 실측되면(데이터 소스 SSOT §7.4) 재검토한다 |
부록 B. 이 문서가 남긴 미검증 항목
구현 중 반드시 실측하고 해당 SSOT를 갱신해야 한다.
numOfRows최대값(명세 3자리 → 999 추정).page_size기본값에 직결 — M1- 전체
totalCount실제 값. 페이지 수·실행 시간·ExecutionTimeLimit적정성 결정 — M1 type=json실제 동작 여부. 실패 시 XML 폴백 경로가 기본이 된다 — M1DMF_PERMIT_NO중복 존재 여부.normalize.make_dmf_key의 접미사 로직 유효성 — M1- 취하·말소 등록번호가 응답에서 사라지는가 남는가. WITHDRAWN 판정의 전제 — M1
- 일 10,000건 한도가 "호출 수"인가 "레코드 수"인가 — M1
- S4U 작업에서 tkinter 창이 정말 안 뜨는가(이 설계의 ADR-10·11 전제). 떠도 설계는 유효하지만, 안 뜨는 것이 확인되면 ADR-11의 가치가 증명된다 — M2
agy쿼터 소진 시 정확한error문자열.classify_error의 QUOTA 패턴 — M4agyOAuth 만료 시 정확한error문자열.classify_error의 AUTH 패턴 — M4- 등록된 MCP 서버가 배치 실행 지연을 유발하는가(agy SSOT 부록 B) — M4
--add-dir로 워크스페이스를 좁히면 첫 호출 28k 토큰이 줄어드는가 — M4
이 문서는 아키텍처 SSOT 다. 구조에 관한 새 결정은 1절 ADR 표에 행을 추가하고, 기각한 선택지는 부록에 기록한다.