# DMF Crawler — 프로젝트 개요와 개발 목표 > **이 문서의 역할**: 이 프로젝트의 **첫 장이자 최상위 요약**이다. 왜 만드는지, 무엇을 만드는지, 언제 끝난 것으로 볼지를 한 곳에서 답하고, 나머지 모든 문서로 가는 **관문**이 된다. > 이 문서는 **결정을 새로 만들지 않는다.** 결정은 `docs/00-REQUIREMENTS.md`(요구 SSOT), `docs/design/00-DATA-SOURCE-DECISION.md`(데이터 소스 SSOT), `docs/design/01-architecture.md`(아키텍처 SSOT)에 있다. 충돌하면 **그 세 문서가 우선한다.** **작성일**: 2026-09-02 **프로젝트 루트**: `D:\workspace\DMF_Crawler` **대상 환경**: Windows 11 Pro 10.0.26220 / Python 3.14.6(실측) / PowerShell 7 **현재 상태**: 설계 확정 완료, **M0 착수 가능**. 이 저장소에는 코드가 아직 없지만, **별도로 돌던 프로토타입이 이미 존재한다**(아래). --- ## 0. 한눈에 보기 이 문서가 확정하는 것. - **이 프로젝트는 스크래치가 아니다.** 이미 공식 Open API로 수집해 xlsx를 만드는 **프로토타입이 돌고 있었고**(`DMF_현황.xlsx`, **9,084건**, 시트 3장), 이 작업은 그것의 **개선**이다. 기준선 실측은 [`design/00b-baseline-data-analysis.md`](./design/00b-baseline-data-analysis.md). - **문제**: 식약처 DMF(원료의약품 등록) 현황은 **어제와 오늘을 비교해 주는 화면이 없다.** 그리고 기존 프로토타입의 산출물은 **데이터는 정확한데 읽기 도구로서의 설계가 전혀 없다** — 틀 고정·자동 필터·조건부 서식·차트가 전부 0이다. - **해법**: 매일 06:00에 공식 Open API로 전량 스냅샷을 받아 전일과 diff 하고, **탭끼리 연동된 xlsx 리포트**를 만들어 바탕화면에 놓는다. 프로토타입의 3시트 구조(전체/신규/갱신이력)를 **버리지 않고 8시트로 확장**한다. - **변경 탐지가 가능하다는 것이 실측으로 확인됐다.** 등록번호는 9,084건 중복 0인 **완전한 자연 키**이고, `발급일자`는 최초 등록일이 아니라 **최종 갱신일로 움직이는 필드**이며(44.5% 불일치), 웹 9,840건 vs API 9,084건의 **756건 차이**는 API가 취하 건을 빼고 정상 건만 준다는 강한 근거다. - **결과물**: 시트 8장짜리 워크북 하나. 대시보드를 열면 **5초 안에** 오늘 무엇이 바뀌었는지 보인다. - **제약**: 사용하는 사람은 개발자가 아니다. **더블클릭 한 번**으로 설치가 끝나야 하고, 문제가 생기면 **창이 떠서** 무엇을 어떻게 고칠지 알려줘야 한다. - **AI**: Google Antigravity CLI(`agy`)를 headless로 하루 **최대 1회** 호출해 한국어 브리핑을 붙인다. **agy가 죽어도 리포트는 나온다** — 이건 스키마 레벨에서 강제된다. - **범위 밖**: 웹 대시보드, 해외 소스(FDA/EDQM/PMDA), HTML 크롤링, 클라우드 배포, 다중 사용자. - **성공 판정**: 30일 무인 운영 중 **06:00±5분 실행 성공률 95% 이상**, 수집 완결성 100%, 리포트 생성 3분 이내, 장애 인지 15분 이내. - **일정**: M0~M4 **5개 마일스톤, 합계 10~15일**. M3(리포트)이 끝나는 시점에 이미 사용자에게 가치를 준다. - **최대 리스크**: 데이터 자체가 재수집 불가능하다는 것. API는 **현재 스냅샷만** 주므로 **오늘 안 받은 어제는 영원히 못 받는다.** 그래서 원문 아카이브와 일자별 전량 스냅샷이 1일차 요구다. 다행히 프로토타입의 9,084건이 **2026-09-02 기준선**으로 남아 있어, 첫날부터 그것을 기준으로 diff할 수 있다. --- ## 1. 왜 이걸 만드는가 ### 1.1 문제 — DMF 현황에는 "어제와의 차이"가 없다 원료의약품 등록(DMF) 제도는 식약처가 「의약품 등의 안전에 관한 규칙」 제16조에 따라 등록 사실을 **인터넷 등으로 공고**하도록 정하고 있다. 공고 항목은 성분·명칭, 등록자, 등록번호와 등록 연월일, 제조소 명칭·소재지다. 문제는 **공고가 "현황표" 형태로만 존재한다**는 것이다. | 사람이 알고 싶은 것 | 공식 채널이 주는 것 | |---|---| | 오늘 **새로** 등록된 원료는? | 전체 목록 (신규 표시 없음) | | 어제 있던 등록이 **취하**됐나? | 전체 목록 (사라진 행을 알려주지 않음) | | 내 관심 성분의 제조소가 **바뀌었나**? | 전체 목록 (변경 이력 없음) | | 경쟁사가 이번 주에 뭘 등록했나? | 전체 목록 (기간 필터 없음) | 주간 공고 게시판(`/bbs/117`)이 있었지만 **2021-02-19("2021년 2월 1,2주차")를 마지막으로 사실상 갱신이 끊겼다**(`research/01` §1). 즉 "공고를 구독한다"는 경로 자체가 이미 없어졌다. 남은 방법은 현황 테이블을 **매일 통째로 받아서 스스로 비교하는 것**뿐이다. ### 1.2 현재의 고통 — 수동 확인은 실제로는 수행되지 않는다 RA(인허가) 실무자가 이걸 손으로 하려면 매일 이런 절차를 밟아야 한다. 1. 의약품안전나라 접속 → DMF 공고 현황 메뉴 진입 2. 페이지네이션을 넘기며 표를 훑거나, 엑셀 내보내기를 눌러 파일 저장 3. 어제 저장한 파일을 열어 **VLOOKUP으로 대조** 4. 없어진 행·추가된 행·값이 바뀐 행을 눈으로 찾음 5. 관심 성분·업체에 걸리는 것만 골라 팀에 공유 현실적으로 이 절차는 **3일을 못 간다.** 그리고 하루라도 건너뛰면 그날의 상태는 **영원히 복원되지 않는다** — API도 화면도 "오늘 현재"만 보여주기 때문이다. 어제 스냅샷이 없으면 어제자 diff는 계산 자체가 불가능하다. **그리고 대상이 9,084행이다.** 헤더 고정도 자동 필터도 없는 표를 스크롤로 훑는 것은 물리적으로 불가능하다. 이건 의지의 문제가 아니라 도구의 문제다. ### 1.2b 이미 있는 것 — 프로토타입과 그 한계 이 프로젝트는 백지에서 시작하지 않는다. **이미 공식 Open API로 수집해 xlsx를 만드는 프로토타입이 돌고 있었다.** | 항목 | 실측 | |---|---| | 산출물 | `DMF_현황.xlsx` (757,331 bytes, 2026-09-02 21:38) | | 시트 | `전체`(9,084행) / `신규`(헤더만) / `갱신이력`(1행) | | 컬럼 | API 7필드 + `최초수집일` 파생 컬럼 | | 갱신이력 | `2026-09-02 / 누적 9084 / 신규 0` — 최초 실행이라 비교 대상이 없었다 | **프로토타입이 이미 옳게 한 것** — 공식 API 채택, "스냅샷 + 신규 + 실행 이력" 3층 구조, `최초수집일` 파생 컬럼. 이 설계 의도는 **버리지 않고 계승한다**(기준선 분석 D8·D9). **프로토타입이 남긴 구멍** — 이것이 이 프로젝트가 메우는 것이다. | 구멍 | 실측 | 이 프로젝트의 대응 | |---|---|---| | **서식이 전혀 없다** | 틀 고정 0, 자동 필터 0, 조건부 서식 0규칙, 차트 0, 표 0, 탭색 0 | 시트 8종 + 대시보드 + 링크 연동 (G5) | | **열 너비가 화면을 파괴한다** | D열(소재지) 너비 **255.6** — 473자를 담으려다 상한에 닿음 | 열 너비 상한 + 줄바꿈 (D10) | | **변경·취하를 못 잡는다** | `신규` 시트만 있고 변경·취하 개념이 없다 | NEW/CHANGED/WITHDRAWN 3종 diff (G3) | | **정규화가 없다** | 국가 중복 표기 496건, 성분명 흔들림 21그룹, 업체명 2그룹, 제조소명 공백 오류 42건 | 정규화 계층 4종 + 원문 보존 (D5·D6) | | **무결성 검증이 없다** | 불완전 수집 시 전건 취하 오판을 막을 장치 없음 | 게이트 5종 (G4) | | **무인 운영이 아니다** | 수동 실행. 실패해도 알 방법 없음 | 06:00 배치 + 워치독 + 강제 알림 (G8·G9) | | **이력이 남지 않는다** | 매번 덮어쓰기. 어제 상태를 복원할 수 없음 | SQLite append-only 스냅샷 + 원문 아카이브 (G11) | ### 1.3 놓치면 생기는 비용 | 놓친 이벤트 | 실무에서 생기는 비용 | |---|---| | **내가 쓰는 원료의 제조소 변경** | 완제 허가 변경(제조원 변경) 대응이 늦어진다. 최악의 경우 원료 수급이 끊긴 뒤에야 안다 | | **내가 쓰는 원료의 등록 취하** | 해당 원료로 만드는 완제품의 허가 근거가 흔들린다. 대체 원료 탐색 시간이 0이 된다 | | **경쟁사의 신규 DMF 등록** | 경쟁 제품 개발 착수 신호를 놓친다. 시장 진입 타이밍 판단이 늦어진다 | | **관심 성분의 신규 제조소 진입** | 소싱 대안·가격 협상 카드를 놓친다 | | **국가별 공급망 쏠림 변화** | 특정 제조국 의존도 상승을 늦게 인지한다 | 이 비용은 전부 **"몰랐다"에서 온다.** 정보는 공개돼 있었고, 매일 확인만 했으면 알 수 있었다. ### 1.4 자동화의 가치 자동화가 만드는 차이는 세 가지다. **① 빠뜨림이 0이 된다.** 사람은 바쁘면 건너뛴다. 06:00 배치는 건너뛰지 않고, 건너뛰면 **워치독이 15분 안에 창을 띄워** 그 사실 자체를 알린다. **② 데이터가 자산이 된다.** 매일 전량 스냅샷을 append-only로 쌓으면, 6개월 뒤에 "3월에 이 제조소가 언제 들어왔지?"를 **소급 조회**할 수 있다. 이건 오늘 시작하지 않으면 영원히 못 만드는 자산이다. **③ 판단만 남는다.** 표를 대조하는 일은 기계가 하고, 사람은 대시보드를 5초 보고 "이건 확인해야겠다"만 결정한다. `agy`가 붙이는 한국어 브리핑이 그 결정을 한 단계 더 앞당긴다. ### 1.5 이 프로젝트가 선택한 길 — 크롤링이 아니라 API 원래 계획은 nedrug 화면을 크롤링하는 것이었다. 조사 중 두 가지 사실이 확인되면서 방향이 바뀌었다. - `https://nedrug.mfds.go.kr/robots.txt` 는 `User-agent: * / Disallow: /` — **전 경로 자동화 금지**다. 예외도, `Crawl-delay`도, 사이트맵도 없다. - 그런데 **식약처가 동일 데이터를 공공데이터포털에 공식 Open API로 개방**하고 있다. 무료, 자동승인, **이용허락범위 제한 없음**. 즉 식약처의 메시지는 모순이 아니라 한 쌍이다: **"웹 화면을 긁지 말고 API를 쓰라."** 이 전환으로 봇 차단 대응·셀렉터 유지보수·법적 리스크·브라우저 자동화 의존성이 **전부 사라졌다.** 상세 비교는 `design/00-DATA-SOURCE-DECISION.md` §5. ### 1.6 API 7필드로 정말 되는가 — 실측이 답한 것 조사 초기에 심각한 우려가 있었다. `research/01`은 **"API 응답이 7필드뿐이라 `최종변경일자`·`취소/취하구분` 이 없고, 따라서 API 단독으로는 변경·취하 탐지 요구를 충족할 수 없다"** 고 결론지었다. 이 우려는 프로토타입 산출물 9,084건을 전수 분석하면서 **상당 부분 해소됐다.** | 판정 | 무엇으로 잡는가 | 실측 근거 | |---|---|---| | **신규** | 오늘 등록번호가 있고 어제 없음 | 등록번호 9,084건 중 **중복 0** — 완전한 자연 키다 | | **변경** | 등록번호는 같은데 **`발급일자`가 어제보다 최신** | 등록번호 앞 8자리(최초 등록일)와 `발급일자`가 **44.5% 불일치**하고, 불일치 건은 **전부 괄호가 붙은 갱신 건**이다. 즉 `발급일자`는 정적인 최초 등록일이 아니라 **변경 때마다 움직이는 최종 갱신일**이다 | | **변경(보조)** | 성분명·업체명·제조소명·소재지·국가 중 하나가 다름 | 내용 변경 | | **취하** | 어제 등록번호가 있고 오늘 없음 | 같은 날 측정한 웹 화면 **9,840건** vs API **9,084건** = **756건 차이**. 웹은 취하 건을 `취소/취하구분` 컬럼으로 표시만 하고 지우지 않는데, API에는 그만큼이 없다 → **API는 정상 건만 반환한다** | 불일치 사례는 극적이다 — `20050831-33-A-81-08(18)` 은 최초 등록이 2005-08-31인데 `발급일자`가 **2026-08-18**이다. 21년의 간격이 곧 "그동안 18번 갱신됐다"는 기록이다. > ⚠️ **취하 판정은 아직 증명이 아니라 강한 정황이다.** 756건 차이의 원인을 확정하려면 **이틀 이상 연속 수집해 실제로 사라지는 레코드를 관찰**해야 한다. 그때까지는 무결성 게이트(건수 급감 시 diff 중단)를 절대 완화하지 않는다. **여전히 놓치는 것**도 정직하게 적는다. `최종연차보고년도`가 API에 없어 **연차보고 이벤트는 잡지 못한다**(연차보고 시 `발급일자`가 갱신되는지도 불명 — 1~2월에 관측 예정). 변경 사유·유형, 취하와 취소의 구분, 취하 일자도 없다. `대상의약품`(별표1 / 신물질)은 등록번호 포맷(`수` 접두어 1,882건)으로 **파생 가능**하므로 실질 손실이 아니다. 부족한 6개 필드는 **크롤링으로 메우지 않는다.** 공공데이터포털 "데이터 개선요청"으로 API 항목 추가를 공식 요청하는 것이 1순위 행동이며, 채널·절차·요청 문구 초안은 [`ops/04-official-data-request-channels.md`](./ops/04-official-data-request-channels.md)에 준비돼 있다. --- ## 2. 무엇을 만드는가 ### 2.1 한 문단 요약 **DMF Crawler는 Windows PC에서 무인으로 도는 로컬 배치 프로그램이다.** 매일 06:00에 식약처 공식 Open API(`MdcDmfInfoService01/getMdcDmfList01`)에서 원료의약품 등록 현황 **약 9,000건 전량**을 받아(약 92회 호출, 일일 한도 10,000의 1%), SQLite에 일자별 전량 스냅샷으로 append-only 적재하고, 직전 성공 스냅샷과 **레코드 단위로 diff** 해 신규·변경·취하를 계산한다. 그 결과를 **시트 8장이 서로 하이퍼링크로 연결된 xlsx 리포트**로 만들어 `reports\` 에 떨군다. 변화가 있는 날에만 `agy`를 한 번 호출해 한국어 브리핑을 붙이되, agy가 실패해도 리포트는 그대로 나온다. 설치와 오류 복구는 **같은 tkinter 창 하나**가 담당하고, 배치가 죽으면 워치독이 15분 안에 그 사실을 화면에 띄운다. 기존 프로토타입의 3시트 구조를 계승하되, **서식이 전혀 없던 9,084행 평면 표**를 읽을 수 있는 도구로 바꾸는 것이 리포트 계층의 목표다. ### 2.2 결과물 — 생성되는 xlsx의 텍스트 목업 매일 아침 `reports\DMF_리포트_2026-09-02.xlsx` 와 `reports\DMF_리포트_최신.xlsx` 가 생긴다. 파일을 열면 이렇게 보인다. **워크북 하단 탭 줄** (탭 색은 Okabe-Ito 팔레트, 색각이상 안전) ``` ┌────────┬────────────┬──────────┬────────┬────────┬────────────┬────────┬──────┐ │ 대시보드 │ 오늘 변경분 │ 전체 누적 │ 성분별 │ 업체별 │ 워치리스트 │ 추이 │ 메타 │ └────────┴────────────┴──────────┴────────┴────────┴────────────┴────────┴──────┘ #0072B2 #D55E00 #5B6770 #009E73 #009E73 #CC79A7 #888888 #888888 (활성) (히트 없으면 시트 자체를 만들지 않음) ``` **시트 ①「대시보드」— 스크롤 없이 한 화면. 눈금선 숨김, 줌 90%** ``` ╔══════════════════════════════════════════════════════════════════════════════════════╗ ║ DMF 일일 모니터링 리포트 — 2026-09-02(화) 18pt bold ║ ║ 오늘 신규 12건, 변경 3건, 취하 1건. 최근 30일 평균(9.4건) 대비 신규가 28% 많습니다. ║ ╠═══════════╤═══════════╤═══════════╤═══════════╤═══════════╤═══════════════════════════╣ ║ 전체 등록 │ 신규 │ 변경 │ 취하 │ 워치 히트 │ 수집 완결성 ║ ║ 9,096 │ 12 │ 3 │ 1 │ 2 │ 100.0% ║ ║ ▲ +11 │ ▲ +4 │ ▼ -2 │ – 0 │ ▲ +2 │ 정상 ║ ║ ▁▂▃▅▃▂▁▃ │ ▁▃▂▅▇▃▂▁ │ ▁▁▂▁▃▁▁▂ │ ▁▁▁▂▁▁▁▁ │ ▁▁▂▁▁▃▁▂ │ ████████████████ 게이트 5/5 ║ ╠═══════════╧═══════════╧═══════════╧═══════════╧═══════════╧═══════════════════════════╣ ║ ┌── 최근 30일 일별 신규/변경/취하 (누적 세로 막대) ──┐ ┌── 제조국 Top 8 (가로 막대) ──┐ ║ ║ │ ▉▉ ▉▉▉ │ │ 인도 ████████████ 3,351 │ ║ ║ │ ▉▉ ▉ ▉▉ ▉ ▉▉▉ ▉▉▉ ▉▉ ▉▉ ▉▉▉ │ │ 중국 ████████ 2,231 │ ║ ║ │ ▉▉ ▉▉ ▉▉ ▉▉ ▉▉▉ ▉▉▉ ▉▉▉ ▉▉ ▉▉▉ │ │ 대한민국 ███ 845 │ ║ ║ └────────────────────────────────────────────────────┘ │ 이탈리아 █ 313 │ ║ ║ ※ 인도+중국이 전체의 61.5% — 공급망 집중도가 핵심 지표 │ 스페인 ▌ 210 │ ║ ║ └──────────────────────────────┘ ║ ╠═══════════════════════╤═══════════════════════╤══════════════════════════════════════════╣ ║ Top 10 성분 │ Top 10 업체 │ 워치리스트 히트 ║ ║ 순위 성분명 오늘 누적 │ 순위 신청인 오늘 누적 │ 키워드 매칭 성분명 신청인 상태 ║ ║ 1 히알루론산나트륨1 104│ 1 (주)삼오제약 2 392│ 메트포르민 ○ 메트포르민염산염 … 신규 ║ ║ 2 메트포르민염산염0 97│ 2 (주)파마피아 1 378│ 삼오 ○ 히알루론산나트륨 … 변경 ║ ╠═══════════════════════╧═══════════════════════╧══════════════════════════════════════════╣ ║ ▸ 오늘 변경분 ▸ 전체 누적 ▸ 성분별 ▸ 업체별 ▸ 워치리스트 ▸ 추이 ▸ 메타 ║ ║ 출처: 식품의약품안전처 원료의약품등록(DMF)현황 Open API · 수집 2026-09-02 06:03 KST ║ ╚══════════════════════════════════════════════════════════════════════════════════════════╝ ``` **시트 ②「오늘 변경분」— 리포트의 본체. diff 결과만** ``` A1: ← 대시보드 (역링크, 모든 데이터 시트 공통) A2: 정렬키│상태│등록번호│성분명│신청인│제조소명│제조국│최초등록일│발급일자(최종갱신)│변경필드│확인 ────────────────────────────────────────────────────────────────────────────────────────── 1 │■취하│ 20121228-168-I-169-04 │포르모테롤… │(주)대웅제약│SICOR… │이탈리아│2012-12-28│2015-02-26│ — │🔗 2 │●신규│ 20260902-071-K-412-01 │메트포르민… │한미약품㈜ │Zhejiang… │중국 │2026-09-02│2026-09-02│ — │🔗 3 │●신규│ 수6580-16-ND │신물질계열… │종근당㈜ │Aurobindo…│인도 │ — │2026-09-02│ — │🔗 4 │▲변경│ 20230116-200-I-647-07(A) │로수바스타… │유한양행 │Hetero… │인도 │2023-01-16│2026-09-02│발급일자, 제조소명│🔗 ────────────────────────────────────────────────────────────────────────────────────────── 틀 고정 freeze_panes(2,3) · Excel 표 + 자동필터 · 상태는 색+기호+텍스트 3중 코딩 최초등록일 = 등록번호 앞 8자리 파생 (신물질 포맷은 파싱 불가 → "—", 레코드는 정상 처리) 발급일자 = DMF_PERMIT_DATE. 최초 등록일이 아니라 **최종 갱신일**이다 (4행이 그 예) 🔗 = 의약품안전나라 검색 링크 (사람이 직접 확인. 자동으로 화면을 긁지 않는다) ``` **시트 ③「전체 누적」** — 오늘 시점 전량 원장. Excel 표 + 자동필터 + 틀고정. 각 행에서 「추이」로 이력 링크. **시트 ④「성분별」/ ⑤「업체별」** — Top N 집계와 30일 추이. 각 행에서 「전체 누적」으로 필터 이동. **시트 ⑥「워치리스트」** — 관심 성분·업체 매칭. **목록이 비어 있으면 이 시트를 아예 만들지 않는다.** **시트 ⑦「추이」** — 일자별 건수 시계열. 대시보드 스파크라인·차트의 원본 데이터. **시트 ⑧「메타」** — 수집 시각, 소스 URL, `totalCount`, 수집 건수, 콘텐츠 해시, 게이트 5종 판정, 실행 로그 경로, AI 상태(성공/스킵 사유). ### 2.3 사용자가 만지는 것은 세 개뿐 | 것 | 무엇 | 언제 | |---|---|---| | `bootstrap.cmd` | 최초 1회 더블클릭. venv 생성 → 설치 → 바로가기 생성 → 온보딩 창 | 설치할 때 딱 한 번 | | 바탕화면 `DMF 설정.lnk` | 온보딩·복구 창. 콘솔 창 없음(`pythonw.exe`) | 설정 바꿀 때, 오류 창이 떴을 때 | | `reports\DMF_리포트_최신.xlsx` | 오늘의 결과물 | 매일 아침 | 나머지(`data\`, `logs\`, `state\`, `backup\`)는 사용자가 열 일이 없다. --- ## 3. 개발 목표 요구 SSOT(`00-REQUIREMENTS.md`)의 R1~R8을 **개발 목표 12개**로 재편했다. 각 목표에 **완료 판정 기준(Definition of Done)** 을 붙인다. DoD를 만족하지 못하면 그 목표는 끝난 것이 아니다. ### G1. 매일 전량 수집이 자동으로 이뤄진다 (R1.1, R1.4) **DoD** - [ ] `dmf run` 이 `numOfRows=1` 로 `totalCount` 를 먼저 얻고, `ceil(totalCount/page_size)` 페이지를 전부 순회한다. - [ ] 수집 레코드 수 == `totalCount` 를 매 실행 검증하고 결과를 `fetch_stats` 에 기록한다. - [ ] 각 페이지 원문이 `data\raw\YYYY-MM-DD\page_NNNN.json` 으로 보존된다. **파서를 고친 뒤 과거를 재파싱할 수 있다.** - [ ] 7일 연속 실행에서 완결성 실패가 0건이다. ### G2. 크롤링하지 않고 합법적으로 수집한다 (R1.2, R1.3, N1) **DoD** - [ ] `pyproject.toml` 의 런타임 의존성에 Playwright·Selenium·BeautifulSoup·lxml이 **없다.** - [ ] 코드 전체 grep에 HTML 파싱·셀렉터 문자열이 나오지 않는다. - [ ] User-Agent에 프로젝트명과 `general.contact_email` 이 들어간다. - [ ] 리포트 「메타」 시트에 출처(식약처 / 공공데이터포털)가 표기된다. - [ ] `docs/ops/03-api-usage-policy.md` 에 호출 빈도·한도·약관 준수 기록이 작성돼 있다. ### G3. 신규·변경·취하를 정확히 판정한다 (R2.1, R2.3) **DoD** - [ ] `diff.py` 가 **DB에 접근하지 않는 순수 함수**다. 전일 스냅샷과 금일 레코드를 인자로 받는다. - [ ] 판정 규칙이 기준선 분석 D1~D3을 그대로 구현한다 — **신규**=등록번호 출현, **변경**=`발급일자` 이동 **또는** 6개 필드 중 하나가 다름, **취하**=등록번호 소멸. - [ ] 등록번호를 **PRIMARY KEY로 직접 쓴다.** 대체 키·복합 키를 만들지 않는다(9,084건 중복 0 실측). - [ ] 등록번호 **파싱 실패가 레코드를 탈락시키지 않는다.** 포맷 4종(표준 41.5% / 표준+괄호 32.7% / 신물질 `수nnnn-n-ND` 20.7% / 기타 5.0%)을 관대하게 처리하고, 실패 시 파생 필드(최초 등록일, 변경 차수)만 null로 둔다. - [ ] `대상의약품`(별표1 / 신물질)을 등록번호 포맷에서 파생한다(`수` 접두어 = 신물질, 1,882건). - [ ] 변경 건은 **어느 필드가 바뀌었는지**까지 담는다. - [ ] 정규화(NFKC·공백·전각·법인격 표기·국가 다중값 분해) 후 비교한다. `(주)대웅제약` 과 `㈜대웅제약` 이 다른 레코드로 잡히지 않는다. - [ ] **원문을 반드시 보존한다.** 정규화 값은 별도 컬럼이다(D6) — 되돌릴 수 없는 손실을 만들지 않는다. - [ ] 실측된 품질 문제 4종을 처리한다: 국가 중복 표기(`중국,중국` 등 496건), 성분명 표기 흔들림 21그룹, 업체명 흔들림 2그룹, 제조소명 공백 오류 42건. - [ ] `test_diff.py` 와 `test_normalize.py` 가 통과한다. ### G4. 불완전 수집으로 취하를 오판하지 않는다 (R2.2) — **이 프로젝트의 1급 안전장치** **DoD** — 게이트 5종이 **모두** 통과해야 diff를 수행한다. - [ ] ① 모든 페이지가 HTTP 200 이고 `resultCode == "00"` - [ ] ② 수집 건수 == `totalCount` - [ ] ③ `totalCount` 가 전일 대비 `integrity.max_drop_ratio`(기본 5%) 이상 급감하지 않음 - [ ] ④ 필수 필드 널 비율 ≤ `max_null_ratio`(기본 1%) - [ ] ⑤ 중복 `DMF_PERMIT_NO` 비율 ≤ `max_duplicate_ratio`(기본 2%) - [ ] 하나라도 실패하면 **스냅샷을 저장하지 않고**(기준선 오염 방지) **diff도 하지 않으며**, 마지막 성공 스냅샷으로 리포트를 만들고 대시보드 최상단에 경고 배너를 띄운다. - [ ] `test_integrity.py` 가 게이트 5종 각각의 통과/차단 **경계값**을 검증한다. - [ ] HTTP 200인데 본문이 비었거나 차단 HTML인 경우도 실패로 분류한다(`source.min_body_bytes`, `_.xlsx` 폴백 파일명으로 저장하고 WARN 알림을 남기며, **실행 상태는 SUCCESS**다(파일은 나왔으므로). - [ ] `test_report_atomic.py` 가 잠긴 파일 시나리오를 검증한다. ### G7. AI는 부가 계층이고, 죽어도 리포트는 나온다 (R4.1~R4.5) **DoD** - [ ] `agy -p --output-format json` 으로 호출한다. 다른 CLI를 쓰지 않는다. - [ ] 호출 조건이 전부 코드로 강제된다: diff가 비어 있지 않음 **AND** `agy.enabled` **AND** 서킷 CLOSED/HALF_OPEN **AND** 일일 토큰 상한 미달. - [ ] 브리핑·이상해석·정규화 후보를 **한 프롬프트에 묶어 하루 최대 1회** 호출한다. - [ ] `structured_output` 을 신뢰하지 않고 **자체 균형괄호 JSON 추출 + `jsonschema` 검증 + 1회 강화 재시도**를 거친다. - [ ] `enrichment` 테이블이 `events` 와 **물리적으로 분리**돼 있다. 리포트 생성 SQL이 `enrichment` 없이도 완결된다. - [ ] `agy.exe` 를 강제로 이름 바꾸고 실행해도 xlsx가 정상 생성되고, 대시보드에 "AI 요약 없음: 실행 파일 없음" 배지가 뜬다. - [ ] `agy` 미설치 상태에서 온보딩을 돌리면 자동 설치가 완료된다. ### G8. 06:00에, 재부팅해도, 놓쳐도 실행된다 (R5.1~R5.6) **DoD** - [ ] Task Scheduler 작업 3종이 idempotent하게 등록된다 — `DMF_Crawler_Daily`(S4U), `DMF_Crawler_Agent`(Interactive), `DMF_Crawler_AgyUpdate`(주간). - [ ] 7일 연속 **06:00±5분** 실행 기록이 있다(`schedule.jitter_seconds` 상한 240초). - [ ] 재부팅 후 다음 06:00에 정상 실행된다. - [ ] 06:00에 PC가 꺼져 있었으면 `StartWhenAvailable` + AtStartup(+5분) 트리거로 캐치업한다. - [ ] 중복 실행이 **3중**으로 막힌다 — idempotency 가드(오늘 SUCCESS면 종료 0) + `msvcrt` 파일 락 + `MultipleInstances=IgnoreNew`. - [ ] `ExecutionTimeLimit` 초과로 강제 종료돼도 `RestartCount` 재시도가 **체크포인트로 재개**한다. ### G9. 죽으면 화면에 뜬다 (R7.1~R7.9, N3) **DoD** - [ ] **배치 프로세스는 어떤 UI도 띄우지 않는다.** `alerts` 테이블 + `state\alerts.json` + Windows Event Log에 **의도만** 기록한다. - [ ] 로그온 세션에서 15분마다 도는 `notify-pump` 가 WARN/INFO는 자동소멸 토스트로, CRITICAL은 **강제 모달**로 띄운다. - [ ] 모든 알림 문구가 **4요소(무엇 / 왜 / 어떻게 / 다음 행동)** 를 담는다. - [ ] 동일 `dedup_key` 알림은 `notify.cooldown_minutes`(기본 240분) 안에 재표시되지 않는다. - [ ] heartbeat 나이가 `notify.watchdog_stale_minutes`(기본 120분)를 넘으면 워치독이 CRITICAL을 올린다. - [ ] 로그오프 상태에서 발생한 알림이 **다음 로그온 시 즉시** 표시된다(AtLogOn 트리거). - [ ] 실패를 유발한 뒤 **15분 안에** 화면에 창이 뜨는 것을 실제로 확인했다. ### G10. 비개발자가 더블클릭으로 설치·복구한다 (R6.1~R6.9, N4) **DoD** - [ ] 바로가기 더블클릭 시 **검은 콘솔 창이 보이지 않는다**(`pythonw.exe`). - [ ] API 키가 없으면 창에서 입력받아 **즉시 실제 API 호출로 검증**하고, 통과한 것만 DPAPI로 암호화 저장한다. - [ ] 발급 페이지(`data.go.kr`)로 가는 버튼이 있다. - [ ] `agy` 미설치 시 설치 버튼(진행률 표시), OAuth 만료 시 로그인 창 열기 버튼이 있다. - [ ] 준비가 끝나면 06:00 작업 등록 버튼이 뜬다. - [ ] **온보딩과 복구가 같은 컴포넌트**다 — `checks.py` 하나를 CLI `doctor` 와 GUI `onboard` 가 렌더링할 뿐이다. - [ ] **막다른 골목이 없다.** 모든 오류 화면에 다음 행동 버튼이 있다. ### G11. 데이터가 손실되지 않는다 (ADR-03, ADR-04, ADR-22) **DoD** - [ ] 일자별 전량 스냅샷과 이벤트가 **append-only** 로 영구 축적된다(`storage.snapshot_retain_days = 0`). - [ ] 스키마 변경은 번호순 SQL 마이그레이션 + `schema_version` 테이블로 관리되고, **적용 직전 `VACUUM INTO` 백업본**이 곧 롤백 경로다. - [ ] 파이프라인 성공 직후 `backup\dmf_YYYY-MM-DD.sqlite3` 가 생기고 `backup.keep_count`(기본 30) 초과분이 정리된다. - [ ] `PRAGMA integrity_check` 가 preflight에서 돌고, 실패 시 파이프라인을 시작하지 않는다. - [ ] DB 손상 시 GUI에 **백업본 목록과 복원 버튼**이 뜬다. ### G12. 6개월 뒤에 본인이 읽고 고칠 수 있다 (N5, N8) **DoD** - [ ] 런타임 의존성이 **3개**(`httpx`, `XlsxWriter`, `jsonschema`)를 넘지 않는다. - [ ] 설정 파일이 **1개**(`config/config.toml`)다. PC별 차이만 `config.local.toml` 이 덮는다. - [ ] `python -m dmf_crawler doctor` 한 줄로 12종 진단이 표로 나온다. - [ ] 실행 1회당 `logs\run_\` 디렉터리 하나만 열면 조사가 시작된다. - [ ] `docs/` 아래 설계 문서 7종이 전부 작성돼 있다(9절 로드맵 참조). --- ## 4. 비목표 (Non-goals) **하지 않는 것을 적어두는 이유**는, 나중에 "이것도 넣을까?"가 나왔을 때 **이미 판단이 끝났다는 사실을 기억하기 위해서**다. | 하지 않는 것 | 이유 | 다시 검토할 조건 | |---|---|---| | **HTML 크롤링·차단 우회** | `robots.txt` 전면 금지 + 동일 데이터가 공식 API로 개방. 우회는 더 어렵고 더 취약하고 더 위험하며 결과물도 더 나쁘다 | API가 폐지되거나 필수 필드를 잃는 경우 — 그때도 우회가 아니라 **식약처 문의**가 먼저다 | | **웹 대시보드(FastAPI 등)** | xlsx로 충분하고 요구에 없다. 서버는 "그 서버는 누가 감시하는가"라는 요구를 하나 더 만든다 | 리포트 수신자가 팀 단위로 늘고 실시간 조회가 필요해질 때 | | **해외 소스(FDA DMF / EDQM CEP / PMDA MF)** | 명시적 비목표. 각 소스가 정책·포맷·난이도가 전부 다르다(FDA는 WAF로 봇 차단, EDQM은 TSV 전량, PMDA는 xlsx 직접) | 국내 DMF 모니터링이 30일 안정 운영된 뒤. 붙일 때 `source_mfds.py` 를 프로토콜로 승격 | | **소스 추상화(Source 프로토콜·플러그인 레지스트리)** | 소스가 1개고 확장이 비목표다. 문자열 동적 import는 오타를 06:00 런타임까지 잠복시키고 grep 추적을 끊는다 | 두 번째 소스가 **실제로** 확정될 때. 승격 비용 0.5~1일 | | **다중 사용자·권한 관리** | 1인 운영 도구다 | — | | **실시간·다회 모니터링** | 요구가 일 1회다. 데이터 실제 갱신 주기도 아직 미실측 | 갱신 주기를 2~4주 관측한 결과 일 1회로 부족하다고 나올 때 | | **클라우드 배포** | 로컬 PC 운영이 요구다. `agy` OAuth 토큰이 사용자 프로필에 있어 계정 이동이 곧 재인증이다 | — | | **모바일·이메일·웹훅 알림** | Windows 알림으로 충분 | 리포트 수신자가 2명 이상이 될 때 | | **`pandas` 사용** | 필요한 집계가 `GROUP BY` 수준. 의존성 하나는 곧 "6개월 뒤 `pip install` 실패 확률 하나" | 피벗 형태 집계가 SQL로 감당 안 될 만큼 복잡해질 때 | | **LibreOffice headless 재계산** | "켠 채로 조용히 안 도는" 상태가 1인 운영 최악의 실패 모드. 모든 숫자를 파이썬이 계산하면 재계산 자체가 불필요 | — | | **AI 제안의 자동 적용** | 틀린 제안이 조용히 잘못된 데이터를 수집하는 2차 사고를 원천 차단. `state\proposals\` 에 저장하고 사람이 승인 | — | | **Windows Service 상주 / Airflow·Prefect** | "매일 정해진 시각 실행"은 정확히 크론이 하는 일이다. 상주 프로세스는 생명주기 관리 요구를 새로 만든다 | — | | **테스트 커버리지 목표 수치** | 급소 4모듈(`normalize`/`integrity`/`diff`/`agy.extract`)만 촘촘히 덮는다. 숫자를 맞추는 테스트는 부채다 | — | --- ## 5. 성공 기준 **측정 가능한 것만 적는다.** 각 지표는 `runs` / `fetch_stats` / `alerts` 테이블과 `logs\` 에서 자동으로 산출할 수 있어야 한다. ### 5.1 운영 지표 (30일 무인 운영 기준) | # | 지표 | 목표 | 측정 방법 | 대응 요구 | |---|---|---|---|---| | S1 | **06:00±5분 실행 성공률** | ≥ 95% (30일 중 실패 1회 이하) | `runs` 테이블의 `started_at` 분포 + `status` | R5.1 | | S2 | **수집 완결성** | 100% (수집 건수 == `totalCount`) | `fetch_stats.collected == fetch_stats.total_count` | R1.4 | | S3 | **취하 오판 건수** | **0건** | 게이트 5종 차단 로그 + 취하 이벤트 사후 확인 | R2.2 | | S4 | **리포트 생성 시간** | 수집 시작 → xlsx 저장 완료 **3분 이내** | `runs.finished_at - runs.started_at` | R3 | | S5 | **파이프라인 전체 소요** | `ExecutionTimeLimit` 30분의 **50% 이내** | 동일 | R5 | | S6 | **장애 인지 시간** | 실패 발생 → 화면 표시 **15분 이내** (에이전트 주기) | `alerts.created_at` → `alerts.shown_at` | R7.1, N3 | | S7 | **장애 복구 시간** | 대부분의 장애를 GUI에서 **5분 이내** 복구 | 복구 시나리오 12종 리허설 측정 | N4 | | S8 | **AI 없이도 리포트 생성률** | **100%** (agy 실패가 리포트 실패로 이어지지 않음) | `agy` 강제 실패 상태에서 xlsx 존재 확인 | R4.4 | | S9 | **알림 폭주** | 동일 코드 알림 **4시간에 1회 이하** | `alerts` dedup_key별 표시 간격 | R7.8 | | S10 | **백업 존재율** | 성공 실행일의 **100%** 에 백업본 존재 | `backup\` 파일 목록 vs `runs` SUCCESS 일자 | ADR-22 | | S11 | **무인 연속 운영** | 사람 개입 없이 **30일** | 개입 이벤트 로그 | N2 | | S12 | **API 호출 수** | 일일 한도 10,000의 **5% 이내** | `fetch_stats.request_count`. 실측 기준 `ceil(9084/100)+1 = 92회` = 한도의 **0.92%** | N1 | | S13 | **기준선 대비 건수 정합** | 첫 수집의 `totalCount` 가 **9,084 ± 자연 증감** 범위 | 프로토타입 실측(2026-09-02)과 대조. 크게 벗어나면 게이트 3이 차단 | R2.2 | ### 5.2 결과물 품질 지표 | # | 지표 | 목표 | 측정 방법 | |---|---|---|---| | Q1 | **5초 파악** | 처음 보는 사람이 대시보드만 보고 5초 안에 오늘의 신규/변경/취하 건수와 이상 여부를 말한다 | 사용자 3인 스톱워치 테스트 | | Q2 | **링크 무결성** | 대시보드 ↔ 8시트 하이퍼링크 **전부 동작** | 리포트 열어 전 링크 클릭 | | Q3 | **색각이상 안전** | 상태 정보가 색 없이도 읽힌다(기호 + 텍스트 병기) | 그레이스케일 인쇄 확인 | | Q4 | **파일 열림 내성** | 어제/오늘 파일을 Excel로 열어둔 채 배치를 돌려도 실행이 SUCCESS로 끝난다 | 실제 리허설 | | Q5 | **재생성 동일성** | `report-only` 로 같은 `run_id` 를 재생성하면 내용이 동일하다 | 두 파일의 시트별 셀 비교 | | Q6 | **프로토타입 대비 개선** | 기존 `DMF_현황.xlsx` 가 0이던 항목(틀 고정·자동필터·조건부서식·차트·표·탭색)이 전부 존재 | 새 리포트를 openpyxl로 열어 항목별 카운트 | | Q7 | **정규화 효과** | 국가 중복 표기 496건, 성분명 21그룹, 업체명 2그룹, 제조소명 42건이 집계 시트에서 **합쳐져 보인다** | 정규화 전후 고유값 수 비교 (국가 원문 225종 → 분해 후 49개국) | ### 5.3 설치·이식 지표 | # | 지표 | 목표 | |---|---|---| | I1 | **설치 클릭 수** | `bootstrap.cmd` 더블클릭 **1회** + GUI 버튼 클릭 5회 이내로 06:00 등록까지 완료 | | I2 | **콘솔 노출** | 최초 `bootstrap.cmd` 외에 **검은 창이 한 번도 보이지 않음** | | I3 | **신규 PC 이전** | 폴더 복사 + 온보딩 재실행으로 **30분 이내** 동작 | | I4 | **문서만으로 설치** | 개발자가 아닌 사람이 `README.md` 만 보고 설치 완료 | ### 5.4 실패로 판정하는 것 다음 중 하나라도 발생하면 **그 마일스톤은 완료가 아니다.** - 취하 오판이 리포트에 한 번이라도 나갔다. - 실패했는데 24시간 안에 사용자가 알지 못했다. - 오류 창이 떴는데 **거기서 고칠 수 없었다**(막다른 골목). - `agy` 실패가 리포트 미생성으로 이어졌다. - 리포트에 수식 캐시가 비어 `0` 으로 보이는 셀이 있다. --- ## 6. 시스템 한눈에 보기 ### 6.1 아키텍처 요약 다이어그램 ``` ┌──────────────────────────────────────────┐ Windows Task Scheduler │ 공공데이터포털 Open API │ ┌──────────────────┐ │ apis.data.go.kr/1471000/ │ │ DMF_Crawler_Daily│ │ MdcDmfInfoService01/getMdcDmfList01 │ │ 06:00 + AtStartup │ (무료 · 자동승인 · 이용허락 제한없음) │ │ S4U(비대화형) │ └────────────────┬─────────────────────────┘ │ 사용자 계정 │ │ HTTPS GET (httpx) └────────┬─────────┘ │ Retry-After 우선 · full jitter │ python -m dmf_crawler run │ 0.7s 간격 · UA에 연락처 ▼ │ ╔═══════════════════════════════════════════════════════════════════════════╗ ║ pipeline.py — STAGES 순차 실행 + 스테이지 체크포인트 ║ ║ ║ ║ preflight → fetch → normalize → integrity ⛔ → diff → persist ║ ║ │ │ │ │ │ │ ║ ║ │ │ │ 게이트 5종 순수함수 단일 트랜잭션 ║ ║ │ │ │ 실패시 차단 DB 미접근 append-only ║ ║ │ ▼ ▼ ▼ ▼ ▼ ║ ║ │ data\raw\ NFKC·법인격 R2.2 안전 NEW/CHANGED ★ 여기서 ║ ║ │ 원문 아카이브 국가분해 장치 /WITHDRAWN 리포트 완결가능 ║ ║ │ │ ║ ║ ▼ ▼ ║ ║ DPAPI 키 로드 → enrich(선택) → report ║ ║ 마이그레이션+백업 │ │ ║ ║ integrity_check │ ▼ ║ ║ 디스크 여유 │ → backup ║ ╚════════════════════════════════════════════════════│════════│═════════════╝ │ │ ┌───────────────────────────────────────────────┘ │ ▼ ▼ ┌─────────────────────┐ ┌──────────────────────────┐ │ agy.exe (headless) │ │ data\dmf.sqlite3 │ │ -p --output-format │ │ ├ runs / stage_status │ │ json │◀── 조건: diff 비어있지 │ ├ snapshots (일자별 전량)│ │ 하루 최대 1회 │ 않음 AND 서킷 CLOSED │ ├ records (현재 상태) │ │ 단일 프롬프트 │ AND 토큰 상한 미달 │ ├ events (NEW/CHG/WDR) │ │ ↓ │ │ ├ enrichment ← AI 격리 │ │ 자체 JSON 추출 │ │ ├ agy_calls │ │ + jsonschema 검증 │ │ └ component_health/alerts│ └─────────┬───────────┘ └────────┬─────────────────┘ │ 실패해도 예외 없음(값으로 반환) │ 읽기 전용 ▼ ▼ enrichment 테이블 ┌──────────────────────────┐ (없어도 리포트는 나온다) │ XlsxWriter 시트 8종 조립 │ │ 모든 숫자 파이썬 사전계산 │ │ os.replace 원자 교체 │ └────────┬─────────────────┘ ▼ reports\DMF_리포트_2026-09-02.xlsx reports\DMF_리포트_최신.xlsx ─────────────────── 알림은 발생과 표시를 분리한다 (Windows 제약의 유일한 해법) ─────────────────── 배치(S4U, 데스크톱 없음) 에이전트(Interactive, 로그온 세션) ┌────────────────────────┐ ┌─────────────────────────────────┐ │ alerts 테이블 │ ─── 15분마다 ───▶ │ DMF_Crawler_Agent │ │ state\alerts.json │ AtLogOn │ pythonw -m dmf_crawler │ │ Windows Event Log │ │ notify-pump │ │ ★ UI를 절대 띄우지 않음│ │ ① watchdog: heartbeat 신선도 │ └────────────────────────┘ │ ② WARN/INFO → tkinter 토스트 │ │ ③ CRITICAL → 강제 모달 창 │ state\heartbeat.json ─────────────────────────▶│ ④ 창에서 바로 복구 │ (성공/부분성공일 때만 갱신 = dead-man switch) └─────────────┬───────────────────┘ ▼ ┌─────────────────────────────────┐ │ gui\app.py — 온보딩 = 복구 │ │ checks.py 진단 12종을 렌더링 │ │ (CLI `doctor` 와 같은 엔진) │ └─────────────────────────────────┘ ``` ### 6.2 세 문장 설명 **첫째, 데이터는 결정론적 코드가 가져오고 AI는 뒤에 붙는다.** 수집·정규화·무결성 검증·diff·저장·리포트는 전부 순수한 파이썬 코드가 하고, `agy`는 diff와 저장이 **끝난 뒤에만** 호출되는 부가 계층이다. 이 격리는 문서가 아니라 스키마가 강제한다 — `enrichment` 테이블이 `events`와 물리적으로 분리돼 있어, 리포트를 만드는 SQL은 AI 산출물 없이도 완결된다. **둘째, 데이터는 지워지지 않고 쌓인다.** 매일 받은 전량이 `snapshots`에 append-only로 들어가고, 각 페이지의 API 원문이 `data\raw\`에 그대로 보존된다. 파서를 고쳐 과거 3개월을 재파싱하는 것도, 6개월 전 어느 날의 상태를 소급 조회하는 것도 가능하다 — API는 "오늘 현재"만 주므로, 오늘 쌓지 않으면 영원히 못 만드는 자산이다. **셋째, 알림은 발생과 표시를 분리한다.** `agy`의 OAuth 토큰이 사용자 프로필 평문 파일이라 배치는 반드시 **동일 사용자 계정(S4U)** 으로 돌아야 하는데, S4U 세션에는 데스크톱이 없어 창이 뜨지 않는다. 그래서 배치는 알림 **의도만** 기록하고, 로그온 세션에서 15분마다 도는 별도 작업이 그것을 실제 화면에 띄운다. 이것이 "죽으면 창이 뜬다"는 요구를 Windows에서 만족시키는 유일한 구조다. --- ## 7. 기술 스택 확정표 ### 7.1 런타임 의존성 — 단 3개 | 영역 | 선택 | 버전 | 이유 | |---|---|---|---| | HTTP 클라이언트 | **`httpx`** | `>=0.27,<1.0` | `params=` 자동 인코딩 전제로 **Decoding 키**를 쓴다(인코딩/디코딩 혼동은 이 API의 1위 실패 원인). 연결/읽기 타임아웃 분리 지정이 배치에 유리 | | xlsx 생성 | **`XlsxWriter`** | `>=3.2,<4.0` | 스파크라인 API가 이것에만 있다(요구 R3.4). `openpyxl`은 `load_workbook`→`save`에서 차트·이미지·도형을 잃는다 | | JSON 스키마 검증 | **`jsonschema`** | `>=4.21,<5.0` | `agy` 응답 자체 검증. 손으로 짜면 반드시 틀리는 부분 | ### 7.2 개발 의존성 — 1개 | 영역 | 선택 | 버전 | 이유 | |---|---|---|---| | 테스트 | **`pytest`** | `>=8.0` | 실제 API 응답을 고정 픽스처로 박은 골든 테스트가 중심. 급소 4모듈만 촘촘히 | ### 7.3 표준 라이브러리로 해결한 것 | 영역 | 선택 | 버전 | 이유 (회피한 외부 패키지) | |---|---|---|---| | 런타임 | **CPython** | 3.11+ (실측 3.14.6) | `tomllib`가 3.11부터 stdlib. 실측 환경이 3.14.6 | | 설정 파싱 | **`tomllib`** | stdlib | TOML 단일 파일. ← PyYAML 제거 | | 데이터베이스 | **`sqlite3`** | stdlib | 단일 파일 + 트랜잭션 + 인덱스. ← SQLAlchemy, alembic | | 집계 | **SQLite `GROUP BY`** | — | 필요한 집계가 이 수준. ← pandas, polars | | GUI·토스트 | **`tkinter`** | stdlib | `pythonw.exe`로 콘솔 없이 뜬다. ← PySide6, win11toast, BurntToast(외부 모듈 설치가 전제면 "설치 실패 시 알림도 실패"라는 순환) | | 비밀 저장 | **`ctypes` → `crypt32.dll` DPAPI** | stdlib | 사용자 범위 암호화. 같은 계정에서만 복호화 = S4U 배치가 정확히 그 계정. ← keyring, cryptography, python-dotenv | | 배타 락 | **`msvcrt.locking`** | stdlib | ← portalocker, filelock | | 재시도·백오프 | **직접 구현 (약 50줄)** | — | `Retry-After` 절대 우선이라는 도메인 규칙이 있어 어차피 감싸야 한다. ← tenacity, backoff | | 로깅 | **`logging`** | stdlib | 실행 1회당 디렉터리. ← structlog | | CLI | **`argparse`** | stdlib | ← click, typer | | 이벤트 로그 | **`subprocess` → `eventcreate.exe`** | — | ← pywin32 | | XML 폴백 | **`xml.etree.ElementTree`** | stdlib | `type=json` 실패 시 경로. ← lxml, BeautifulSoup | ### 7.4 외부 프로그램 | 영역 | 선택 | 버전 | 필수 여부 · 이유 | |---|---|---|---| | AI CLI | **Google Antigravity CLI (`agy`)** | v1.1.22+ (실측) | **선택** — 없으면 AI 브리핑만 빠진다. `%LOCALAPPDATA%\agy\bin\agy.exe`. headless `-p` 완전 지원, JSON 봉투(`status`/`response`/`usage`/`error`), 종료 코드 0/1/2 | | 스케줄러 | **Windows Task Scheduler** | OS 내장 | **필수** — 작업 3종. 배치는 S4U, 알림 에이전트는 Interactive | | 셸(설치·등록) | **PowerShell 7** | 7.x | 작업 등록·`agy` 설치 시에만 | | 패키지 관리 | **`venv` + `pip` + `pyproject.toml`** | stdlib + pip | 비개발자 PC에 추가 도구를 설치시키지 않는다. ← poetry, uv, conda | | 표 계산 | **Microsoft Excel** | — | **불필요.** 리포트 열람용일 뿐 | | 문서 변환 | **LibreOffice** | — | **불필요.** ADR-08로 재계산 단계 자체를 제거 | ### 7.5 데이터 소스 | 영역 | 선택 | 값 | 이유 | |---|---|---|---| | 정본 소스 | **식약처 원료의약품등록(DMF)현황 Open API** | `https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01` | 무료·자동승인·**이용허락범위 제한 없음**. 개발계정 10,000건/일 | | 응답 포맷 | **JSON** (`type=json`) | XML 폴백 | ⚠️ `type=json` 실제 동작은 M1에서 실측 | | 수집 필드 | **7개** | `DMF_PERMIT_NO` / `INGR_KOR_NAME` / `ENTP_NAME` / `MNFCTR_NAME` / `MNFCTR_PLACE` / `MANUF_COUNTRY_CODE_NM` / `DMF_PERMIT_DATE` | 법정 공고 항목(총리령 제16조)과 일치 | | 인증 | **serviceKey (Decoding 키)** | DPAPI 암호화 저장 | `httpx` `params=` 가 자동 인코딩하므로 Decoding 키 | | 1차 키 | **`DMF_PERMIT_NO`** | `20121228-168-I-169-04` | **9,084건 중복 0 실측** → PRIMARY KEY로 직접 사용. 포맷 4종 병존(표준 41.5% / 표준+괄호 32.7% / 신물질 `수nnnn-n-ND` 20.7% / 기타 5.0%) | | 변경 신호 | **`DMF_PERMIT_DATE`** | 최종 갱신일 | 등록번호 앞 8자리(최초 등록일)와 **44.5% 불일치**. 불일치 건은 전부 괄호 갱신 건 | | 데이터 규모 | **9,084건** (2026-09-02) | 성분 1,539 / 업체 438 / 제조소 3,051 / 국가 49 / 기간 2003-04-18~2026-09-01 | 기준선 실측. 전량 수집 92회 호출 | --- ## 8. 문서 지도 `docs/` 아래 모든 문서. **✅ = 현재 존재, ⏳ = 해당 마일스톤에서 작성 예정.** ### 8.1 최상위 | 파일 | 무엇을 결정하는 문서인가 | 언제 읽는가 | 상태 | |---|---|---|---| | [`00-PROJECT-OVERVIEW.md`](./00-PROJECT-OVERVIEW.md) | **이 문서.** 목표·성공 기준·로드맵·리스크·용어집. 다른 모든 문서의 관문 | **제일 먼저.** 프로젝트에 처음 올 때, 6개월 뒤 다시 볼 때 | ✅ | | [`00-REQUIREMENTS.md`](./00-REQUIREMENTS.md) | **요구사항 SSOT.** R1~R8, N1~N8, 비목표, 온보딩=복구 원칙, 요구 추적표 | 뭔가 만들기 전에. "이게 요구에 있나?"를 확인할 때 | ✅ | | [`README.md`](./README.md) | 문서 규칙(정보를 흘리지 않는다·한 사실은 한 문서·지어내지 않는다)과 문서 지도 | 문서를 **쓰기** 전에 | ✅ | ### 8.2 `design/` — 설계 확정 | 파일 | 무엇을 결정하는 문서인가 | 언제 읽는가 | 상태 | |---|---|---|---| | [`design/00-DATA-SOURCE-DECISION.md`](./design/00-DATA-SOURCE-DECISION.md) | **데이터 소스 SSOT.** robots.txt 실측, 공식 API 완전 명세(파라미터·응답 7필드), 전량 수집 전략, 오탐 안전장치 5종, serviceKey 발급 절차 | 수집 코드를 쓰기 전에. API 응답이 이상할 때 | ✅ | | [`design/00b-baseline-data-analysis.md`](./design/00b-baseline-data-analysis.md) | **기준선 사실 정본.** 기존 프로토타입 `DMF_현황.xlsx` 9,084건 전수 분석 — 등록번호 유일성, 발급일자의 의미, 포맷 4종 분포, 품질 문제 4종, 국가·업체·성분 분포, 기존 서식 상태, **확정 결정 D1~D12** | **데이터 모델·정규화·diff를 설계하기 전 필독.** "이 값이 실제로 어떻게 생겼지?" 할 때 | ✅ | | [`design/01-architecture.md`](./design/01-architecture.md) | **아키텍처 SSOT.** ADR 25개, 디렉터리 트리 전문, 모듈 계약 18종, 데이터 흐름(정상·실패 6경로), CLI 11커맨드, 설정 키 전체, 실패 대응표 33종, 의존성, 구현 순서 | **구현 시작 전 필독.** 모듈을 새로 만들 때. 구조를 바꾸고 싶을 때 | ✅ | | `design/02-data-model.md` | SQLite 스키마 DDL, 등록번호 파싱 규칙, 정규화 규칙, 비교 대상 필드, 변경 탐지 알고리즘, 품질 검증 | `normalize.py`·`diff.py`·마이그레이션을 쓸 때 | ⏳ M1 | | `design/03-xlsx-report-spec.md` | 시트 8종 **셀 단위** 명세. 컬럼·서식·수식·링크, 대시보드 레이아웃, 디자인 토큰, 생성 코드 | `report/sheets/` 를 쓸 때 | ⏳ M3 | | `design/04-onboarding-wizard.md` | GUI 화면 전이도, 단계별 문구, 버튼 액션, 복구 모드별 강조 규칙 | `gui/` 를 쓸 때 | ⏳ M4 | ### 8.3 `ops/` — 운영 | 파일 | 무엇을 결정하는 문서인가 | 언제 읽는가 | 상태 | |---|---|---|---| | `ops/01-scheduling-and-resilience.md` | 작업 3종 등록 파라미터 전문, 재부팅·절전 시나리오 검증 절차, 워치독 판정, 로그 구조, 일상 운영·신규 PC 설치 절차 | 스케줄이 안 돌 때. 새 PC에 설치할 때 | ⏳ M2 | | `ops/02-failure-alerting.md` | 알림 등급 기준, 문구 4요소 규격, 쿨다운·dedup 규칙, `agy` 재로그인 유도 흐름, 에스컬레이션 | 알림을 추가·수정할 때 | ⏳ M4 | | `ops/03-api-usage-policy.md` | 공공데이터 API 이용 제약, 트래픽 계산, 인증키 만료 대응, 오류 코드 대응표, 출처 표시 문구 | 호출 빈도를 바꿀 때. 법적 검토 요청이 올 때 | ⏳ M1 | | [`ops/04-official-data-request-channels.md`](./ops/04-official-data-request-channels.md) | **부족한 6개 필드를 크롤링 없이 공식 절차로 얻는 법.** 채널 6종 비교(데이터 개선요청 / 제공신청 / 국민신문고 / 정보공개청구 / 식약처 직접문의 / 식의약 데이터포털), 담당 연락처, **발송 가능한 요청 문구 초안** | `최종변경일자`·`취소취하구분` 등이 아쉬울 때. **크롤링이 하고 싶어질 때** | ✅ | ### 8.4 `research/` — 조사 정본 | 파일 | 무엇을 결정하는 문서인가 | 언제 읽는가 | 상태 | |---|---|---|---| | [`research/01-dmf-domain-and-sources.md`](./research/01-dmf-domain-and-sources.md) | **도메인 정본.** DMF 제도 연혁, 법령 근거(총리령 제16조), 등록번호 체계 완전 해부(파서 코드), 화면 컬럼 실측, 해외 4개국 소스 비교, RA 실무 4축 | 등록번호를 파싱할 때. "이 필드가 무슨 뜻이지?" 할 때. 용어가 헷갈릴 때
⚠️ **§1의 "API 단독으로는 변경·취하 탐지 불가" 결론은 `design/00b` 실측으로 정정됐다** | ✅ | | [`research/02-benchmark-github-projects.md`](./research/02-benchmark-github-projects.md) | 유사 오픈소스 프로젝트 벤치마킹, 채택/기각 결정, 모듈 경계 참고 | 모듈을 어떻게 쪼갤지 고민될 때 | ✅ | | [`research/03-crawling-theory-and-papers.md`](./research/03-crawling-theory-and-papers.md) | 크롤러 이론, 증분 수집·변경 감지 기법, 추출 기법, 크롤링 정책 | diff 알고리즘·재방문 정책을 설계할 때 | ✅ | | [`research/04-anti-bot-and-legal.md`](./research/04-anti-bot-and-legal.md) | 봇 탐지 원리, **정중한 크롤러 규칙**(요청 빈도·UA·백오프·조건부 요청), 판례·법적 리스크 | 법적 검토가 필요할 때. 보조 소스를 붙일 때 | ✅ | | [`research/05-ai-cli-headless-comparison.md`](./research/05-ai-cli-headless-comparison.md) | AI CLI 대안 비교와 headless 파이프라인 설계 원칙 (**참고용** — 채택 CLI는 `agy`로 확정) | AI CLI 선택 근거를 다시 볼 때 | ✅ | | [`research/05a-agy-cli-ssot.md`](./research/05a-agy-cli-ssot.md) | **`agy` SSOT.** 설치·인증(OAuth 토큰 위치)·headless 플래그·JSON 봉투·권한·오버헤드 실측(28k토큰/33.7초)·함정 | `agy`를 부를 때 **반드시 먼저**. 인증이 풀렸을 때 | ✅ | | [`research/06-xlsx-linking-and-formatting.md`](./research/06-xlsx-linking-and-formatting.md) | **xlsx SSOT.** 시트 연동 문법, 서식 레시피, XlsxWriter vs openpyxl 실측, 수식 캐시 문제, 파일 잠금(`~$`) 실측 | 리포트 코드를 쓸 때 | ✅ | | [`research/07-pharma-excel-dashboard-design.md`](./research/07-pharma-excel-dashboard-design.md) | **대시보드 SSOT.** 의약 정보 시각화 원칙, 시트 8종 구성 확정안, 탭색·링크 방향, KPI 레이아웃 와이어프레임 | 대시보드를 그릴 때 | ✅ | | [`research/08-windows-scheduling-and-resilience.md`](./research/08-windows-scheduling-and-resilience.md) | Windows 스케줄링 파라미터, 재부팅 내성, S4U vs Interactive, 알림 기법 | 작업을 등록할 때. 재부팅 후 안 돌 때 | ✅ | | `research/09-agy-bootstrap-and-provisioning.md` | `agy` 자동 설치·인증 부트스트랩·로그인 유도 창 설계 | `bootstrap_agy.ps1` 을 쓸 때 | ⏳ M4 | | `research/10-agy-agent-integration-patterns.md` | `agy` 통합 패턴, 프롬프트 인젝션 방어, 유스케이스별 호출 설계 | `agy/prompt.py` 를 쓸 때 | ⏳ M4 | | `research/_raw/` | 정제 전 웹 리서치 원본 덤프 | 출처 추적이 필요할 때만. **전량 읽지 말고 grep** | ✅ (디렉터리) | ### 8.5 읽는 순서 | 역할 | 순서 | |---|---| | **처음 오는 사람** | `00-PROJECT-OVERVIEW.md`(이 문서) → `00-REQUIREMENTS.md` → `design/00b-baseline-data-analysis.md` → `design/01-architecture.md` | | **구현하는 사람** | `design/01-architecture.md` → `design/00-DATA-SOURCE-DECISION.md` → **`design/00b-baseline-data-analysis.md`** → `design/02-data-model.md` → `design/03-xlsx-report-spec.md` → `research/05a-agy-cli-ssot.md` | | **운영하는 사람** | `ops/01-scheduling-and-resilience.md` → `ops/02-failure-alerting.md` → `design/01-architecture.md` §7(실패 대응표 33종) | | **법적 검토** | `design/00-DATA-SOURCE-DECISION.md` §2·§5 → `research/04-anti-bot-and-legal.md` → `ops/03-api-usage-policy.md` → `ops/04-official-data-request-channels.md` | | **"필드가 부족한데 긁으면 안 되나?"** | `ops/04-official-data-request-channels.md` → `design/00b` §6.4(놓치는 것) → `design/00-DATA-SOURCE-DECISION.md` §5 | | **도메인이 궁금한 사람** | `research/01-dmf-domain-and-sources.md` → `design/00b` §5(등록번호 실측) → 이 문서 §11(용어집) | --- ## 9. 구현 로드맵 마일스톤은 **"끝났을 때 실제로 무엇이 동작하는가"** 로 나눈다. 앞 단계가 동작하지 않으면 다음으로 넘어가지 않는다. **난이도 표기**: ★=단순(1~2시간) / ★★=보통(반나절) / ★★★=까다로움(하루) / ★★★★=이 프로젝트의 급소 ### M0 — 뼈대와 진실 (예상 1~2일) | 작업 | 난이도 | 의존 | |---|---|---| | - [ ] 디렉터리 트리 전체 생성 (빈 모듈 포함, `design/01-architecture.md` §2 그대로) | ★ | — | | - [ ] `pyproject.toml` (의존성 3개 상한 고정, 엔트리포인트 `dmf`, pytest 설정) | ★ | 트리 | | - [ ] `bootstrap.cmd` (venv 생성 → `pip install -e .` → 바로가기 → 온보딩 기동) | ★★ | pyproject | | - [ ] `.gitignore` (`.venv/ data/ logs/ reports/ state/ backup/ config.local.toml *oauth-token*`) | ★ | — | | - [ ] `paths.py` · `errors.py` (예외 계층 6종) | ★ | 트리 | | - [ ] `config.py` + `config/config.toml` (§6.1 키 전체, `config.local.toml` 병합·검증) | ★★ | paths | | - [ ] `logging_setup.py` (run_id 디렉터리, `pipeline.log` + `events.jsonl`, 마스킹) | ★★ | paths, config | | - [ ] `runlock.py` (`msvcrt` 배타 락, 컨텍스트 매니저) | ★★ | paths | | - [ ] `secrets_dpapi.py` (`ctypes` → `CryptProtectData`/`CryptUnprotectData`) + 단위 테스트 | ★★★ | — | | - [ ] `storage/db.py` + `migrations/0001_init.sql` (runs/stage_status/fetch_stats/snapshots/records/events) | ★★★ | config | | - [ ] `storage/repo.py` 의 run·checkpoint 계열 | ★★ | db | | - [ ] `cli.py` 의 `version` / `db` / `doctor`(체크 ①②③④만) | ★★ | 전부 | **완료 시 동작하는 것**: `bootstrap.cmd` 더블클릭 → venv 생성 → `dmf version` / `dmf db migrate` / `dmf doctor` 실행. API 키를 DPAPI로 저장·복호화할 수 있다. **DB 파일과 설정이 진실로 존재한다.** ### M1 — 수집에서 diff까지 (예상 2~3일) · 의존: M0 | 작업 | 난이도 | 의존 | |---|---|---| | - [ ] **serviceKey 확보** — 프로토타입이 이미 API 수집에 성공했으므로 **키가 이미 존재할 가능성이 높다.** 사용자에게 위치를 먼저 확인하고, 없으면 data.go.kr 활용신청(자동승인) | ★ | — (사람이 수동) | | - [ ] **프로토타입 수집 스크립트 확보·검토** — 있으면 재사용·개선의 출발점이 된다 | ★ | 사용자 | | - [ ] `http.py` (Retry-After 절대 우선, full jitter, 조건부 요청, UA 고정) + `test_http_retry.py` | ★★★ | config | | - [ ] `source_mfds.py` (totalCount 취득 → 페이지 순회 → 원문 아카이브 → 본문 시그니처 검사) | ★★★ | http, secrets | | - [ ] **`DMF_현황.xlsx` 9,084건을 2026-09-02 기준선 스냅샷으로 임포트** — 첫날부터 진짜 diff가 나온다 | ★★ | 스키마 | | - [ ] `normalize.py` (NFKC·공백·전각·법인격·국가 다중값 분해, 국가 코드 매핑 49개국) + `test_normalize.py` | ★★★★ | — | | - [ ] 등록번호 파서 — 포맷 4종 + 파싱 실패 허용(파생 필드만 null). `대상의약품` 파생 | ★★★ | — | | - [ ] `integrity.py` (게이트 5종) + `test_integrity.py` (경계값) | ★★★★ | normalize | | - [ ] `diff.py` (순수 함수, NEW/CHANGED/WITHDRAWN + 변경 필드 추출) + `test_diff.py` | ★★★★ | normalize | | - [ ] `health.py` (3상태 서킷 CLOSED/OPEN/HALF_OPEN) | ★★ | repo | | - [ ] `pipeline.py` 의 preflight~persist 스테이지 + 체크포인트 | ★★★ | 위 전부 | | - [ ] `docs/design/02-data-model.md` 작성 (기준선 D1~D12를 스키마로 반영) | ★★ | 구현 확정 후 | | - [ ] `docs/ops/03-api-usage-policy.md` 작성 | ★ | 실측 후 | | - [ ] **데이터 소스 SSOT 부록 B 잔여 실측 기록** (아래 부록 참조 — `totalCount`·중복은 기준선 분석으로 이미 해소) | ★★ | 첫 수집 성공 | | - [ ] **`ops/04` 1순위 행동 실행** — 공공데이터포털 "데이터 개선요청"으로 6개 필드 추가 요청 발송 | ★ | 사람이 수동 | **완료 시 동작하는 것**: `dmf run --skip-agy` 가 실제 API에서 전량(약 9,000건 / 92회 호출)을 수집해 SQLite에 적재한다. 기준선을 임포트했다면 **첫 실행부터** diff가 나오고, 아니어도 **이틀 연속 돌리면 둘째 날에** 나온다. 게이트가 불완전 수집을 막는 것도 확인된다. **여기서 취하 판정 가설(756건 = 취하 건)이 실제 소멸 레코드 관찰로 검증된다.** 리포트는 아직 없다. ### M2 — 무인 운영 (예상 1~2일) · 의존: M1 | 작업 | 난이도 | 의존 | |---|---|---| | - [ ] `scripts/install_tasks.ps1` / `uninstall_tasks.ps1` (작업 3종 idempotent 등록) | ★★★ | — | | - [ ] `alerts.py` (기록·조회·dedup·쿨다운·해소) | ★★ | repo | | - [ ] `watchdog.py` (heartbeat 신선도 판정) | ★★ | alerts | | - [ ] `notify/eventlog.py` (`eventcreate.exe`) · `notify/messages.py` (4요소 템플릿) | ★★ | — | | - [ ] `backup.py` (`VACUUM INTO` + 보존 개수 정리 + 복원 안내) | ★★ | db | | - [ ] `pipeline.py` 의 backup·finalize 스테이지, heartbeat 기록 | ★★ | backup | | - [ ] idempotency 가드 + `--force` | ★★ | repo | | - [ ] `docs/ops/01-scheduling-and-resilience.md` 작성 | ★★ | 등록 검증 후 | | - [ ] **재부팅 리허설** (끄고 켜서 다음 06:00 실행 확인) | ★★ | 작업 등록 | | - [ ] **S4U에서 tkinter 창이 정말 안 뜨는지 실측** (ADR-10·11의 전제) | ★★ | 작업 등록 | **완료 시 동작하는 것**: 06:00에 **사람이 없어도** 배치가 돈다. 재부팅해도, 06:00을 놓쳐도 캐치업한다. 중복 실행은 흡수된다. 성공하면 백업본이 생기고 heartbeat가 갱신된다. 실패는 `alerts`와 Event Log에 남는다 — **아직 화면에는 안 뜬다.** ### M3 — 리포트 (예상 3~4일) · 의존: M1 (M2와 병렬 가능) | 작업 | 난이도 | 의존 | |---|---|---| | - [ ] `report/theme.py` (Okabe-Ito 팔레트, 맑은 고딕, 상태 매핑, Format 캐시) | ★★ | — | | - [ ] `report/widgets.py` (KPI 타일, 델타 화살표 서식, 목차 링크, **한글 열너비 계산**) | ★★★ | theme | | - [ ] `report/atomic.py` (임시파일 → `os.replace`, 잠김 재시도·폴백) + `test_report_atomic.py` | ★★★ | — | | - [ ] `report/data.py` (시트별 SQL 상수 모음 → `ReportData`) | ★★★ | M1 스키마 | | - [ ] `report/build.py` (워크북 조립 오케스트레이션) | ★★ | 위 전부 | | - [ ] `sheets/s00_dashboard.py` (KPI 6타일·차트 2종·스파크라인·링크·상태 배너) | ★★★★ | widgets, data | | - [ ] `sheets/s01_changes.py` (오늘 변경분. **리포트의 본체.** 상태 3중 코딩·히든 정렬키) | ★★★ | widgets | | - [ ] `sheets/s02_ledger.py` (전체 누적. Excel 표·자동필터·틀고정·nedrug 검색 링크) | ★★ | widgets | | - [ ] `sheets/s03_ingredient.py` · `s04_company.py` (Top N + 추이) | ★★ | data | | - [ ] `sheets/s05_watchlist.py` (**목록이 비면 시트를 만들지 않는다**) | ★★ | data | | - [ ] `sheets/s06_trend.py` (일자별 시계열, 차트·스파크라인 원본) | ★★ | data | | - [ ] `sheets/s99_meta.py` (수집 시각·소스 URL·건수·해시·게이트 판정·로그 경로·AI 상태) | ★ | data | | - [ ] `cli report-only` / `cli backfill` | ★★ | build | | - [ ] `docs/design/03-xlsx-report-spec.md` 작성 | ★★★ | 구현 확정 후 | **완료 시 동작하는 것**: 매일 06:00에 **탭별로 연동된, 디자인이 예쁜 xlsx**가 나온다(요구 R3 전부). 파일을 열어둔 상태에서도 배치가 실패하지 않는다. **이 시점에서 프로젝트는 이미 사용자에게 가치를 준다.** ### M4 — 사람과의 접점 (예상 3~4일) · 의존: M2 + M3 | 작업 | 난이도 | 의존 | |---|---|---| | - [ ] `agy/client.py` (subprocess, **예외 대신 `AgyEnvelope` 값으로 반환**) | ★★★ | — | | - [ ] `agy/prompt.py` (diff+지표 → 템플릿 렌더링, **데이터를 구분자로 감싸기**) | ★★ | M1 diff | | - [ ] `agy/extract.py` (균형괄호 JSON 추출 + jsonschema 검증) + `test_agy_extract.py` | ★★★★ | — | | - [ ] `agy/budget.py` (일일 토큰 상한 강제) | ★★ | repo | | - [ ] `prompts/daily_briefing.md` + `daily_briefing.schema.json` | ★★★ | — | | - [ ] `migrations/0002_enrichment.sql` · `0003_ops.sql` 적용 | ★★ | M0 러너 | | - [ ] `pipeline.py` 의 enrich 스테이지 + 대시보드 AI 배지 | ★★ | client, M3 | | - [ ] `checks.py` 진단 12종 완성 + `test_checks.py` | ★★★ | 전부 | | - [ ] `gui/app.py` · `steps.py` · `widgets.py` (온보딩 = 복구 단일 창) | ★★★★ | checks | | - [ ] `notify/pump.py` · `toast.py` (워치독 → 알림 → 복구 GUI 기동) | ★★★ | alerts, gui | | - [ ] `scripts/make_shortcuts.ps1` · `bootstrap_agy.ps1` | ★★ | — | | - [ ] `docs/design/04-onboarding-wizard.md` · `docs/ops/02-failure-alerting.md` 작성 | ★★ | 구현 후 | | - [ ] `docs/research/09` · `10` 작성 | ★★ | 실측 후 | | - [ ] **복구 시나리오 12종 리허설** (각각 유발 → 창 확인 → 5분 내 복구 확인) | ★★★ | 전부 | **완료 시 동작하는 것**: 비개발자가 바로가기를 더블클릭하면 창이 뜨고, 키가 없으면 입력받아 검증·저장하고, `agy`가 없으면 설치하고, 로그인이 풀렸으면 로그인 창을 띄우고, 06:00 등록까지 마친다(R6 전부). 배치가 실패하면 **15분 안에 창이 떠서** 무엇이 왜 안 됐고 어떻게 고치는지 알려주고 **그 창에서 바로 고칠 수 있다**(R7 전부). ### M5 — 안정화 (예상 30일, 개발 아님) · 의존: M4 | 작업 | 난이도 | 의존 | |---|---|---| | - [ ] 30일 무인 운영 관측. §5 지표 S1~S12 실측 | ★ | M4 | | - [ ] **데이터 실제 갱신 주기 실측** (`totalCount` + 콘텐츠 해시 일별 기록) | ★ | 운영 | | - [ ] 워치리스트 초기 목록 확정 (사용자 확인 필요) | ★ | 사용자 | | - [ ] 게이트 임계값 실측 기반 재조정 (`max_drop_ratio` 등) | ★★ | 30일 데이터 | | - [ ] 오염 응답을 픽스처로 승격해 회귀 테스트 추가 | ★★ | 실제 실패 | ### 마일스톤 후 (범위 밖, 기록만) | 항목 | 착수 조건 | |---|---| | 워치리스트 UI | 사용자가 관심 성분·업체 목록을 확정한 뒤 | | 이메일·웹훅 알림 | 리포트 수신자가 2명 이상이 된 뒤 | | 해외 소스(FDA/EDQM/PMDA) | 명시적 비목표. 붙일 때 `source_mfds.py` 를 프로토콜로 승격 | | 웹 대시보드 | 명시적 비목표 | --- ## 10. 리스크 등록부 **담당 표기**: `코드` = 구현으로 방어 / `운영` = 운영 절차로 방어 / `사용자` = 사람이 해야 함 / `외부` = 우리 통제 밖(감지만 가능) | # | 리스크 | 가능성 | 영향 | 완화책 | 담당 | |---|---|---|---|---|---| | **R-01** | **취하 오판** — 불완전 수집으로 멀쩡한 등록이 전부 "취하"로 리포트에 나감 | 중 | **치명** | 무결성 게이트 5종을 **1급 스테이지**로. 하나라도 실패하면 스냅샷 저장도 diff도 하지 않는다. `totalCount` 5% 급감은 CRITICAL. 경계값 테스트 필수 | 코드 | | **R-02** | **API 스키마 변경** — 필드명·구조가 바뀌어 파싱이 깨짐 | 중 | 높음 | 필드 부재 → 널 비율 급증 → 게이트 4가 차단. 원문 아카이브가 남아 있어 **재파싱 가능**. `agy` A7이 변경점을 진단하되 **자동 반영 금지, 사람 승인**(ADR-25) | 코드 + 사용자 | | **R-03** | **API 서비스 중단·폐지** — 엔드포인트가 사라지거나 유료화 | 낮 | **치명** | 감지: 연속 실패 → 서킷 OPEN → CRITICAL 모달. 대안: 식약처 문의 → 다른 데이터셋 → **최후에도 크롤링은 하지 않는다**. 그동안 쌓은 스냅샷은 남는다 | 외부 | | **R-04** | **일일 트래픽 10,000건 초과** | 낮 | 중 | 전량 수집이 `ceil(totalCount/100)+1` 호출이라 한도의 5% 이내. 초과 시 WARN + 그날 스킵. 반복되면 운영계정 전환. ⚠️ 한도 카운트 단위(호출 수 vs 레코드 수) M1 실측 필요 | 코드 | | **R-05** | **serviceKey 만료·무효화** | 중 | 높음 | `resultCode != "00"` 또는 401/403 감지 → CRITICAL **강제 창** → GUI에서 재입력 → 즉시 실호출 검증. 기존 키는 **앞 8자만** 표시해 혼동 방지 | 코드 + 사용자 | | **R-06** | **`agy` OAuth 로그인 만료** — 토큰이 풀려 AI 단계 실패 | **높음** | 낮 | AI는 **부가 계층**이라 리포트에 영향 없음(스키마 레벨 격리). `classify_error == AUTH` → CRITICAL 창 → "로그인 창 열기" 버튼. ⚠️ 정확한 error 문자열 M4 실측 필요 | 코드 + 사용자 | | **R-07** | **`agy` 쿼터 소진 / 모델 변경 / CLI 파괴적 업데이트** | 중 | 낮 | 하루 1회·diff 있을 때만 호출로 소모 최소화. 일일 토큰 상한. `AGY_CLI_DISABLE_AUTO_UPDATE=true` 를 배치 중 주입해 **실행 중 바이너리 교체 예방**, 업데이트는 일요일 14:00 별도 작업 | 코드 | | **R-08** | **`agy` 응답 오염** — JSON이 4회 반복되고 산문이 섞임 (SSOT §7.2 실측) | **높음** | 낮 | `structured_output` 불신. 균형괄호 추출 + `jsonschema` 검증 + 강화 프롬프트 1회 재시도. 실패해도 리포트는 나옴. 오염 응답은 `agy.stdout.json` 에 보존해 픽스처로 승격 | 코드 | | **R-09** | **프롬프트 인젝션** — API 응답의 성분명·제조소명에 지시문이 섞임 | 낮 | 중 | `--dangerously-skip-permissions` 를 쓰지 않음. `--disable-slash-commands` 부착. 데이터를 명확한 구분자로 감싸고 출력은 스키마 검증. AI에 파일 쓰기·명령 실행 권한 없음 | 코드 | | **R-10** | **PC 전원 꺼짐 / 절전 / 06:00 부재** | **높음** | 중 | `StartWhenAvailable` + AtStartup(+5분) 캐치업. 중복은 idempotency 가드가 흡수. ⚠️ `WakeToRun` 설정은 **PC가 밤에 꺼지는지 사용자 확인 필요** | 코드 + 사용자 | | **R-11** | **재부팅으로 스케줄 소실 / 사용자가 작업을 지움** | 낮 | 높음 | `checks` ⑨ 가 `Get-ScheduledTask` 로 읽기 전용 점검. **자동 재등록하지 않는다**(의도적으로 껐을 수 있음) → CRITICAL 창의 "작업 다시 등록" 버튼 | 코드 + 사용자 | | **R-12** | **배치가 죽었는데 아무도 모름** (조용한 실패) | 중 | **치명** | heartbeat는 **성공/부분성공일 때만** 갱신 = dead-man switch. 나이 > 120분이면 워치독 CRITICAL. 배치는 UI를 띄우지 않고 로그온 세션 에이전트가 15분마다 표시 | 코드 | | **R-13** | **알림이 화면에 도달하지 않음** (로그오프·잠금 상태) | 중 | 높음 | 배치는 `alerts` + `state\alerts.json` + Event Log **3중 기록**. AtLogOn 트리거가 다음 로그온 시 밀린 알림을 즉시 표시 | 코드 | | **R-14** | **SQLite 파일 손상 / 디스크 장애** — 유일 정본 소실 | 낮 | **치명** | 성공 직후 `VACUUM INTO` 날짜별 백업 30개 보존. `PRAGMA integrity_check` 를 preflight에. 손상 시 GUI에 **백업본 목록 + 복원 버튼**. ⚠️ `backup.dir` 을 **다른 드라이브**로 지정하도록 온보딩이 유도 | 코드 + 사용자 | | **R-15** | **데이터 품질 불량** — 실측 확인: 국가 중복 표기 **496건**(`중국,중국`), 성분명 표기 흔들림 **21그룹**, 업체명 **2그룹**, 제조소명 공백 오류 **42건**(`CORTICOSTER OIDO`), 제조국가명 결측 5건 | **확실** | 중 | 정규화 계층 4종이 비교 전에 돈다(D5). **원문은 별도 컬럼으로 반드시 보존**(D6). 국가 코드 매핑 테이블 49개국(D12). 규칙으로 못 잡는 것은 `agy` A2/A5가 후보를 제시하되 **사람 승인 후 반영**. `company_aliases.csv` 수동 큐레이션은 기각(1인 운영에서 부도난다) | 코드 | | **R-16** | **등록번호 포맷 예외** — 포맷 **4종 이상** 병존. "기타" 455건에는 `수` 없는 `1962-17-ND`, 이중 괄호 `(1)-A(1)` 이 섞여 있다 | **확실** | 낮 (**완화됨**) | **중복은 0으로 실측됐다** — 등록번호를 PRIMARY KEY로 직접 쓴다(D1). **파싱 실패를 치명적으로 만들지 않는다**(D7) — 문자열 자체가 키이고 파싱은 파생 정보를 얻는 부가 작업이므로, 실패해도 레코드는 정상 처리하고 파생 필드만 null. 중복 비율 게이트 5가 이상 급증을 계속 감시 | 코드 | | **R-17** | **법적 이슈** — 수집·재배포의 적법성 논란 | 낮 | 중 | 공식 API + **이용허락범위 제한 없음** + robots.txt 무관(크롤링 안 함). UA에 연락처 명시, 리포트에 출처 표시, `ops/03-api-usage-policy.md` 에 준수 기록. `research/04` 를 폐기하지 않고 **근거 자료로 보존** | 코드 + 문서 | | **R-18** | **xlsx 파일 잠김** — 사용자가 리포트를 열어둔 채 06:00 도래 | **높음** | 낮 | 임시파일 → `os.replace` 원자 교체. `PermissionError` 3회 재시도 → 폴백 파일명 저장 + WARN. **실행 상태는 SUCCESS** | 코드 | | **R-19** | **리포트 숫자가 0으로 보임** — 수식 캐시 미기록 | 중 | 높음 | **모든 숫자를 파이썬이 계산해 값으로 쓴다.** LibreOffice 재계산 단계 없음. 동적 배열 함수(FILTER/UNIQUE/XLOOKUP) 금지. 수식이 필요하면 `write_formula(..., value=사전계산값)` | 코드 | | **R-20** | **초기 며칠 diff가 비어 있음** — API가 현재 스냅샷만 주므로 과거 재구성 불가 | 낮 (**완화됨**) | 낮 | **프로토타입의 9,084건이 2026-09-02 기준선으로 존재한다.** 이것을 임포트하면 첫 실행부터 diff가 나온다. 임포트하지 않아도 둘째 날부터 나온다. 첫 실행은 "기준선 수립"으로 표시 | 코드 | | **R-27** | **취하 판정 가설이 틀림** — 756건 차이가 취하가 아니라 다른 원인(필터링 조건, 데이터 동기화 지연 등)일 수 있다 | 중 | **높음** | 아직 **정황이지 증명이 아니다.** M1에서 이틀 이상 연속 수집해 실제 소멸 레코드를 관찰하고, 표본을 웹 화면과 대조해 확정한다. 그때까지 게이트 3(건수 급감 차단)을 절대 완화하지 않는다. 틀렸다면 취하를 "목록에서 사라짐"이라는 **약한 표현**으로만 리포트하고 사람 확인 링크를 강조한다 | 코드 | | **R-28** | **연차보고를 못 잡는다** — `최종연차보고년도`가 API에 없고, 연차보고 시 `발급일자`가 갱신되는지 불명 | **확실** | 중 | 실질적으로 아쉬운 유일한 필드다. **1~2월에 관측**해 `발급일자` 갱신 여부를 확인한다(연차보고는 1월 말에 몰린다). 병행해 `ops/04` 채널로 필드 추가를 공식 요청한다 | 코드 + 외부 | | **R-29** | **연도별 집계를 사용자가 오해함** — `발급일자` 기준 집계는 "최종 갱신 연도"인데 "신규 등록 연도"로 읽힌다 | **높음** | 중 | 2025년 1,185건은 신규 폭증이 아니라 갱신이 많았던 것이다. 리포트에 **"갱신 연도 기준"을 명시**하고(D11), 가능하면 등록번호 앞 8자리 기준 "최초 등록 연도" 집계를 **별도 계열로 병기**한다 | 코드 | | **R-21** | **Python/venv 손상** — 인터프리터나 패키지가 깨져 GUI조차 못 뜸 | 낮 | 높음 | `checks` ①② 가 import 실패를 감지. **GUI도 못 뜰 수 있으므로 Event Log가 유일한 흔적** — 반드시 기록. 복구는 `bootstrap.cmd` 재실행 | 코드 + 사용자 | | **R-22** | **의존성 설치 실패** — 6개월 뒤 `pip install` 이 안 됨 | 중 | 중 | 런타임 의존성 **3개**로 최소화. `pyproject.toml` 에 상한 버전 명시(lock 파일 대체). 나머지는 전부 stdlib | 코드 | | **R-23** | **비개발자가 설치를 포기함** | 중 | 높음 | 더블클릭 1회 + GUI 버튼 5회 이내. 콘솔 창 노출 0. **막다른 골목 금지** — 모든 오류 화면에 다음 행동 버튼. 온보딩과 복구가 같은 화면이라 **기억할 화면이 하나뿐** | 코드 | | **R-24** | **알림 폭주로 사용자가 무시하기 시작함** | 중 | 높음 | dedup_key + `cooldown_minutes`(기본 240) 억제. 등급 분리(CRITICAL만 강제 창, WARN/INFO는 토스트). 서킷 OPEN 전환 시 **1회만** 알림 | 코드 | | **R-25** | **필드 부족** — API 7필드에 `최종변경일자`·`최종연차보고년도`·`취소/취하구분`·`취소/취하일자`·`문서번호`·`대상의약품` 6개가 없다 | **확실** | 중 (**완화됨**) | 실측 결과 **변경·취하는 `발급일자` 이동과 레코드 소멸로 잡히고**(§1.6), `대상의약품`은 등록번호 포맷으로 파생된다. 실질 손실은 연차보고 하나(R-28). 나머지는 리포트 각 행의 **nedrug 검색 링크**로 사람이 확인. **크롤링으로 메우지 않고** `ops/04` 공식 채널로 필드 추가를 요청한다 | 코드 + 사용자 | | **R-26** | **6개월 뒤 본인이 못 고침** | 중 | 중 | 설정 1개·의존성 3개·단일 진입점·`doctor` 커맨드·실행 단위 로그 디렉터리. 모든 결정을 ADR에 근거와 함께 기록. 기각한 선택지도 이유와 함께 보존 | 문서 | --- ## 11. 용어집 ### 11.1 도메인 용어 — 제도 | 용어 | 원어 / 약어 | 뜻 | |---|---|---| | **DMF** | Drug Master File | 원료의약품 등록 제도. 완제의약품에 쓰이는 원료의 제조·품질 자료를 규제기관에 미리 등록해 두는 제도. 한국은 **KDMF**로 부르기도 한다 | | **원료의약품** | API (Active Pharmaceutical Ingredient) | 완제의약품의 약효를 내는 주성분. "성분명" 컬럼이 가리키는 것 | | **완제의약품** | Finished Product / FP | 환자가 실제로 복용·투여하는 최종 제품. 원료 + 부형제 + 제형 | | **CEP** | Certificate of Suitability (COS) | 유럽(EDQM)의 원료 적합성 인증서. DMF와 목적이 같은 유럽식 제도. `extranet.edqm.eu` 에서 TSV 전량 다운로드 가능 | | **MF** | 原薬等登録原簿 (Master File) | 일본(PMDA)의 원약등 등록원부. `pmda.go.jp` 에서 xlsx 직접 다운로드 가능(약 5,023행 실측) | | **ASMF** | Active Substance Master File | 유럽에서 DMF에 해당하는 명칭. CEP와 병존하는 다른 경로 | | **RA** | Regulatory Affairs | 인허가 업무. 이 리포트의 주 사용자. "내 성분 / 내 제조원 / 경쟁사 / 상태 변화" 4축으로 정보를 본다 | | **제조소** | Manufacturing Site | 원료를 실제로 만드는 공장. `MNFCTR_NAME` / `MNFCTR_PLACE` | | **신청인 / 업체명** | Applicant | 등록을 신청한 국내 업체. `ENTP_NAME` | | **공고** | — | 식약처가 등록 사실을 인터넷 등으로 공개하는 행위. 「의약품 등의 안전에 관한 규칙」 제16조 후단이 근거 | | **연차보고** | Annual Report | DMF 등록 유지를 위한 정기 보고. 상태 변화의 한 종류 | | **허여서** | 자료공유허여서 | 등록자가 제3자에게 자료 참조를 허용하는 문서. 등록번호 끝 괄호 `(1)~(9)` 가 이것의 순번 | ### 11.2 도메인 용어 — 등록번호 `DMF_PERMIT_NO` 샘플 `20121228-168-I-169-04` 의 구조. | 자리 | 예 | 뜻 | |---|---|---| | 1 | `20121228` | **최초 등록일(등록수리일자)** 8자리. **고정된다.** 발급일자와 **44.5%가 다르다** — 반드시 별도 필드로 저장 | | 2 | `168` | 별표1(대상 원료의약품 목록) **성분 일련번호** | | 3 | `I` | **시행일 알파벳군** (A~K). 제도 확대 시점마다 부여 | | 4 | `169` | **접수 순번** | | 5 | `04` | **동일 성분 일련번호** | | (괄호) | `(1)`, `(A)`, `(18)` | **변경/갱신 표식.** 괄호가 붙은 건은 전부 `발급일자`가 최초 등록일보다 나중이다 | **실측 포맷 분포 (9,084건)** | 포맷 | 건수 | 비율 | 예 | |---|---|---|---| | 표준 `YYYYMMDD-n-A-n-n` | 3,774 | 41.5% | `20260901-86-D-173-26` | | 표준 + 괄호 | 2,973 | 32.7% | `20230116-200-I-647-07(A)` | | **신물질** `수nnnn-n-ND` | 1,882 | 20.7% | `수6580-16-ND(20)` | | 기타 | 455 | 5.0% | `1962-17-ND`, `20100616-122-G-60-22(1)-A(1)` | > **파싱 실패는 치명적이지 않게 설계한다.** 등록번호 문자열 자체가 키이므로, 파싱은 파생 정보(최초 등록일, 대상의약품 구분)를 얻기 위한 부가 작업이다. 실패해도 레코드는 정상 처리하고 파생 필드만 null로 둔다. 상세는 `design/00b-baseline-data-analysis.md` §5, `research/01-dmf-domain-and-sources.md` §3. ### 11.3 프로젝트 용어 | 용어 | 뜻 | |---|---| | **run** | 파이프라인 1회 실행. `run_id` 로 식별하고 `runs` 테이블에 상태(SUCCESS/PARTIAL/FAILED/SKIPPED/BLOCKED)를 기록 | | **스냅샷 (snapshot)** | 특정 실행 시점의 **전량** 레코드 집합. `snapshots` 테이블에 append-only로 영구 축적 | | **이벤트 (event)** | diff 결과 한 건. `NEW` / `CHANGED` / `WITHDRAWN` 세 종류. `events` 테이블에 append-only | | **`dmf_key`** | diff의 1차 키. `DMF_PERMIT_NO` 를 정규화한 값. **9,084건 실측에서 중복이 0이었으므로** 사실상 원문 그대로다 | | **발급일자 (`DMF_PERMIT_DATE`)** | **최초 등록일이 아니라 최종 갱신일.** 변경이 있을 때마다 움직인다. 이 프로젝트의 **변경 판정 1차 신호** | | **최초 등록일** | 등록번호 앞 8자리에서 파생. 고정 값. 발급일자와 짝을 이뤄야 "언제 등록돼 언제 마지막으로 바뀌었나"가 나온다 | | **기준선 (baseline)** | 프로토타입 `DMF_현황.xlsx` 의 9,084건 / 2026-09-02 스냅샷. 첫 diff의 비교 대상 | | **`content_hash`** | 비교 대상 필드를 정규화·직렬화해 만든 해시. **변경 판정의 실체** | | **무결성 게이트 (integrity gate)** | diff 수행 전 통과해야 하는 5종 검사. 하나라도 실패하면 **스냅샷 저장도 diff도 하지 않는다** | | **스테일 폴백 (stale fallback)** | 수집 실패 시 마지막 성공 스냅샷으로 리포트를 만드는 것. 대시보드에 경고 배너가 함께 뜬다 | | **enrichment** | `agy` 가 생성한 AI 산출물. `events` 와 **물리적으로 분리된 테이블**에 저장돼 리포트가 이것 없이도 완결된다 | | **서킷 브레이커 (circuit breaker)** | 3상태(CLOSED/OPEN/HALF_OPEN). 소스와 `agy` 에 각각 적용. 3회 연속 실패 시 24시간 호출 자체를 건너뛴다 | | **체크포인트 (checkpoint)** | 스테이지별 성공 기록. 재시작 시 성공한 스테이지를 건너뛰고 재개 | | **idempotency 가드** | 오늘 이미 SUCCESS로 끝난 run이 있으면 즉시 종료 0. `--force` 로만 무시 | | **heartbeat** | `state\heartbeat.json`. **성공/부분성공일 때만** 갱신 = dead-man switch. 워치독의 유일한 판단 근거 | | **워치독 (watchdog)** | heartbeat 나이가 120분을 넘으면 CRITICAL을 올리는 판정 로직. 알림 에이전트 안에서 돈다 | | **알림 의도 (alert intent)** | 배치가 `alerts` 테이블에 기록하는 것. **표시가 아니라 의도**다 — 배치는 UI를 절대 띄우지 않는다 | | **notify-pump** | 로그온 세션에서 15분마다 도는 작업. 워치독 판정 + 알림 표시 + 복구 GUI 기동 | | **온보딩 = 복구** | 설치 마법사와 오류 복구 창이 **같은 컴포넌트**. `checks.py` 하나를 CLI `doctor` 와 GUI `onboard` 가 렌더링할 뿐 | | **S4U** | Task Scheduler의 "사용자가 로그온했는지와 무관하게 실행". **데스크톱이 없어 창이 뜨지 않는다** — 이것이 알림 분리 설계의 원인 | | **워치리스트 (watchlist)** | 관심 성분·업체 목록. 매칭되면 리포트에 별도 시트로 강조. **목록이 비면 시트를 만들지 않는다** | | **4요소 알림** | 모든 알림 문구가 담아야 할 것 — **무엇이 / 왜 / 어떻게 고치는지 / 다음 행동(버튼)** | | **ADR** | Architecture Decision Record. 되돌리기 어려운 결정 하나 = 표의 한 행. `design/01-architecture.md` §1에 25개 | | **SSOT** | Single Source of Truth. 한 사실은 한 문서에만 산다. 다른 문서는 링크로 참조하고 복사하지 않는다 | ### 11.4 기술 용어 | 용어 | 뜻 | |---|---| | **`agy`** | Google Antigravity CLI. `%LOCALAPPDATA%\agy\bin\agy.exe`. headless는 `-p --output-format json` | | **JSON 봉투 (envelope)** | `agy` 의 출력 구조 — `status` / `response` / `usage` / `error`. `structured_output` 은 **실측에서 채워지지 않았다** | | **DPAPI** | Windows Data Protection API. `CryptProtectData` / `CryptUnprotectData`. **같은 사용자 계정에서만** 복호화된다 | | **Decoding 키 / Encoding 키** | 공공데이터포털 인증키 2종. 라이브러리가 자동 인코딩하면 **Decoding 키**, 직접 URL을 조립하면 Encoding 키. **혼동이 이 API의 1위 실패 원인** | | **`Retry-After`** | HTTP 429/503 응답 헤더. 이 프로젝트는 자체 백오프보다 **이 값을 절대 우선**한다 | | **full jitter** | 백오프 지연을 `[0, base*2^n]` 범위에서 균등 랜덤 추출. 동시 재시도 몰림 방지 | | **`VACUUM INTO`** | SQLite 내장 명령. 일관된 스냅샷 백업본을 다른 파일로 만든다. 이 프로젝트에서는 **백업본이 곧 마이그레이션 롤백 경로** | | **WAL** | Write-Ahead Logging. SQLite 저널 모드. 읽기와 쓰기가 서로를 막지 않는다 | | **원자적 교체 (atomic replace)** | 임시 파일에 완전히 쓴 뒤 `os.replace` 로 바꿔치기. 중간 상태의 깨진 파일이 보이지 않는다 | | **Okabe-Ito 팔레트** | 색각이상(색맹) 안전 8색 팔레트. 이 프로젝트의 고정 팔레트 | | **`pythonw.exe`** | 콘솔 창을 만들지 않는 Python 실행 파일. GUI 기동에 사용. `.bat`/`.cmd` 는 반드시 콘솔을 띄우므로 사용자에게 노출하지 않는다 | --- ## 12. 다음 액션 지금 당장 무엇부터 하면 되는지. **위에서부터 순서대로.** ### ① 기존 프로토타입의 자산을 회수한다 (사람, 10분) — **가장 먼저** 프로토타입이 이미 API 수집에 성공했다. 즉 **serviceKey와 수집 스크립트가 이미 어딘가에 있다.** 새로 만들기 전에 이것부터 찾는다. - [ ] **`DMF_현황.xlsx` 를 만든 수집 스크립트는 어디 있는가?** → 있으면 재사용·개선의 출발점 - [ ] **serviceKey는 어디에 저장돼 있는가?** → 발급을 다시 할 필요가 없다 - [ ] `DMF_현황.xlsx` 원본을 프로젝트로 복사 → **2026-09-02 기준선 스냅샷**으로 임포트할 원자료 - [ ] 이 파일을 본인이 만들었는가, 다른 사람이 만들었는가? → 유지보수 책임 소재 키를 못 찾았을 때만 ②로 간다. 찾았다면 ③으로 건너뛴다. ### ② serviceKey를 발급받는다 (사람, 10분) — **①에서 못 찾았을 때만** 1. https://www.data.go.kr 회원가입·로그인 2. https://www.data.go.kr/data/15057075/openapi.do 접속 → **활용신청** 3. **자동승인**이므로 즉시 승인된다 (개발계정, 일 10,000건) 4. 마이페이지 → 오픈API → 개발계정에서 **일반 인증키(Decoding)** 확보 5. 아래 명령으로 **즉시 검증하고 `totalCount` 를 기록**한다 ```powershell $key = Read-Host "Decoding 인증키" $url = "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01" $resp = Invoke-RestMethod -Uri $url -Body @{ serviceKey = $key pageNo = 1 numOfRows = 1 type = 'json' } $resp | ConvertTo-Json -Depth 6 ``` 기대: `resultCode` 가 `00`, `totalCount` 가 **9,084 근처**(기준선 실측값, 2026-09-02). 크게 다르면 그 자체가 조사 대상이다. **이 숫자를 `design/00-DATA-SOURCE-DECISION.md` 부록 B에 기록하라.** > ⚠️ 키를 문서·로그·저장소에 남기지 마라. 최종적으로는 온보딩 GUI가 DPAPI로 암호화 저장한다. ### ③ 사용자에게 확인받는다 (사람, 15분) 설계에 직접 영향을 주는 것들이다. 답이 없으면 기본값으로 진행하되, 나중에 재작업이 생긴다. - [ ] **PC가 밤에 꺼지는가? 절전 모드인가?** → `WakeToRun` 설정과 AtStartup 캐치업 전략 결정 - [ ] **워치리스트(관심 성분·업체)가 필요한가? 초기 목록은?** → 시트 ⑥ 생성 여부. 실측 상위 성분(히알루론산나트륨 104, 메트포르민염산염 97…)·상위 업체((주)삼오제약 392, (주)파마피아 378…)를 후보로 제시하면 답하기 쉽다 - [ ] **리포트를 몇 명이 보는가? 공유 방식은?** (공유 폴더 / 이메일 / 수동) → `report.output_dir` 과 알림 확장 여부 - [ ] **백업을 어느 드라이브에 둘 것인가?** → `backup.dir`. 같은 디스크면 디스크 장애를 못 막는다 - [ ] **`신규` 시트의 기대 동작이 "당일 신규만"인가 "최근 N일"인가?** → 프로토타입이 남긴 미확정 항목 - [ ] **`갱신이력`에 추가로 기록하고 싶은 항목은?** (변경건수·취하건수·소요시간·오류) → 「메타」 시트 스펙 - [ ] **과거 데이터가 필요한가?** → 2026-09-02 이전은 **불가능하다.** 다만 프로토타입의 9,084건이 그날의 기준선으로 남아 있어 **거기서부터는 이어진다** ### ④ M0을 착수한다 (개발, 1~2일) `design/01-architecture.md` §2의 디렉터리 트리를 **그대로** 만들고, §9 M0의 산출물 목록을 채운다. 완료 판정은 이 문서 §9 M0의 체크박스 12개다. **첫 커밋 전에 반드시**: `.gitignore` 에 `.venv/ data/ logs/ reports/ state/ backup/ config.local.toml *oauth-token*` 를 넣는다. 프로젝트 루트는 현재 git 저장소가 아니므로 `git init` 도 함께 한다. ### ⑤ M1 첫 수집 직후 잔여 실측을 기록한다 (개발, 30분) 기준선 분석이 `totalCount`(9,084)와 등록번호 중복(0)을 이미 해소했다. **남은 것만** 첫 수집 성공 자리에서 확인해 각 SSOT를 갱신한다. - [ ] `numOfRows` 실제 최대값 (명세 3자리 → 999 추정) → `page_size` 결정 - [ ] `type=json` 실제 동작 여부 (실패 시 XML 폴백이 기본이 된다) - [ ] 일 10,000건 한도가 "호출 수"인가 "레코드 수"인가 - [ ] 제조국가명 결측 5건의 등록번호와 원인 - [ ] "기타" 포맷 455건의 하위 패턴 분류 ### ⑥ 이틀 연속 돌려 취하 가설을 검증한다 (개발, 2일) — **M1의 진짜 관문** M1 완료 판정의 실체이자, 이 프로젝트에서 **아직 증명되지 않은 유일한 핵심 전제**를 확정하는 단계다. - [ ] 둘째 날 diff가 실제로 나온다 (기준선을 임포트했다면 첫날부터) - [ ] **API 응답에서 실제로 사라지는 레코드가 관찰된다** → 취하 판정 성립 (D2 확정) - [ ] 웹 9,840 − API 9,084 = 756건 표본을 웹 화면과 대조해 취하·취소 건임을 확인 - [ ] `발급일자`가 실제로 움직이는 레코드가 관찰된다 → 변경 판정 성립 (D3 확정) - [ ] 게이트 5종이 정상 통과한다 - [ ] `data\raw\` 에 원문이 날짜별로 쌓인다 - [ ] 실행 시간이 `ExecutionTimeLimit` 30분의 절반 이내다 **가설이 틀리면** 취하를 "목록에서 사라짐"이라는 약한 표현으로 낮추고, 사람 확인 링크를 전면에 세운다. 게이트 3은 어느 쪽이든 유지한다. --- ## 부록. 미해결 / 실측 필요 이 문서가 남긴 것. 해결되면 본문 또는 해당 SSOT로 옮기고 여기서 지운다. ### 이미 해소된 것 (기록만 — 다시 조사하지 말 것) 기준선 분석(`design/00b`)이 답한 항목들이다. | 항목 | 답 | |---|---| | 전체 `totalCount` 는? | ✅ **9,084건** (2026-09-02) | | `DMF_PERMIT_NO` 중복이 있는가? | ✅ **0건.** 완전한 자연 키 | | API로 변경 탐지가 되는가? | ✅ **된다.** `발급일자`가 최종 갱신일로 움직인다 (44.5% 불일치) | | API로 취하 탐지가 되는가? | 🟡 **강한 정황.** 웹 9,840 vs API 9,084 = 756건 차이. 연속 관측으로 확정 필요 | | `type=json` 이 동작하는가? | 🟡 프로토타입이 수집에 성공했으므로 **API 자체는 동작 확인.** json 포맷 여부는 미확정 | | 데이터 규모·분포는? | ✅ 성분 1,539 / 업체 438 / 제조소 3,051 / 국가 49 / 2003-04-18~2026-09-01 | ### 사용자 확인 필요 - [ ] **`DMF_현황.xlsx` 를 만든 수집 스크립트는 어디 있는가?** → 재사용·개선 출발점 (§12 ①) - [ ] **serviceKey가 이미 발급돼 있는가? 어디에 저장돼 있는가?** → 프로토타입이 성공했으므로 존재할 것 - [ ] 이 파일을 본인이 만들었는가, 다른 사람이 만들었는가 (카카오톡 전달 경위) - [ ] `신규` 시트의 기대 동작이 "당일 신규만"인가 "최근 N일"인가 - [ ] `갱신이력`에 추가로 기록하고 싶은 항목 (변경건수·취하건수·소요시간·오류) - [ ] PC가 밤에 꺼지는가 / 절전 모드인가 → `WakeToRun` 결정 - [ ] 워치리스트 초기 목록 (관심 성분·업체) - [ ] 리포트 수신자 수와 공유 방식 → 이메일·웹훅 알림 필요 여부 - [ ] 백업 드라이브 경로 (`backup.dir`) - [ ] 리포트 보관 기간·위치 (`report.retain_days` 기본 365가 적절한가) - [ ] 해외 소스(FDA/EDQM/PMDA)를 언제 붙일 계획인가 → 현재는 명시적 비목표 ### M1에서 실측 — 데이터 - [ ] **API 응답에서 실제로 소멸하는 레코드 관찰** (취하 판정 확정 — 이 프로젝트 최대 미검증 전제) - [ ] 756건 차이가 정말 취하·취소 건인지 표본 대조 - [ ] `발급일자`가 실제로 움직이는 레코드 관찰 (변경 판정 확정) - [ ] 변경 시 등록번호의 괄호가 바뀌는가, 번호는 그대로이고 발급일자만 바뀌는가 - [ ] 제조국가명 결측 5건의 등록번호와 원인 - [ ] "기타" 포맷 455건의 하위 패턴 분류 - [ ] 연차보고 시 `발급일자`가 갱신되는가 (**1~2월에 관측** — 연차보고는 1월 말에 몰린다) ### M1에서 실측 — API - [ ] `numOfRows` 실제 최대값 (명세 3자리 → 999 추정) - [ ] `type=json` 실제 동작 여부 (실패 시 XML 폴백이 기본이 된다) - [ ] 일 10,000건 한도의 카운트 단위 (호출 수 vs 레코드 수) - [ ] `entp_name` / `ingr_kor_name` 검색 파라미터가 부분 일치인가 완전 일치인가 - [ ] 데이터 실제 갱신 주기 (2~4주 관측 후 스케줄 재검토) - [ ] 참고문서 `IROS_76_원료의약품(DMF)현황_v1.1.docx` 에 명세 외 정보가 있는가 ### M2에서 실측 - [ ] **S4U 작업에서 tkinter 창이 정말 안 뜨는가** — ADR-10·11의 전제. 떠도 설계는 유효하지만, 안 뜨는 것이 확인되면 알림 분리의 가치가 증명된다 - [ ] `StartWhenAvailable` 캐치백 실제 동작 시각 (부팅 후 몇 분에 도는가) ### M4에서 실측 - [ ] `agy` 쿼터 소진 시 정확한 `error` 문자열 → `classify_error` 의 QUOTA 패턴 - [ ] `agy` OAuth 만료 시 정확한 `error` 문자열 → `classify_error` 의 AUTH 패턴 - [ ] 등록된 MCP 서버가 배치 실행 지연을 유발하는가 - [ ] `--add-dir` 로 워크스페이스를 좁히면 첫 호출 28k 토큰이 줄어드는가 ### 문서 간 정합성 확인 필요 - [ ] **시트 ⑦의 이름이 두 문서에서 다르다.** `research/07` §8.9는 `스냅샷·로그`, `design/01-architecture.md` §2의 모듈명은 `s06_trend.py`(일자별 건수 시계열)다. 이 문서는 탭 이름을 `추이` 로 적었다. **`design/03-xlsx-report-spec.md`(M3)에서 최종 확정**하고 세 문서를 일치시킬 것 - [ ] **워크북 파일명이 두 문서에서 다르다.** `research/07` §8.0은 `DMF_Daily_YYYYMMDD.xlsx`, `design/01-architecture.md` §6은 `report.filename_pattern = "DMF_리포트_{date}.xlsx"` 다. **설정 파일 값(아키텍처 SSOT)이 우선**하나, `research/07` 에 각주를 달아둘 것 - [ ] **`research/01` §1의 "API 단독으로는 변경·취하 탐지 불가" 결론이 `design/00b` 실측으로 반증됐다.** 그 주장은 웹 화면의 `최종변경일자`·`취소/취하구분` 컬럼이 있어야만 탐지가 가능하다는 전제에 서 있었으나, 실제로는 **`발급일자`의 이동과 레코드 소멸이 같은 정보를 담고 있다.** `research/01` §1에 정정 각주를 달아 6개월 뒤 혼동을 막을 것 (기준선 분석 부록 B가 같은 항목을 지적) - [ ] **`research/07` 의 시트 컬럼 스펙을 API 7필드 + 파생 필드 기준으로 재작성**해야 한다(기준선 분석 부록 B). 현재 스펙 일부가 웹 화면 15컬럼을 전제로 쓰여 있다 - [ ] **`design/01-architecture.md` 의 `normalize.py` 계약이 `dmf_key` 중복 시 접미사 로직을 전제**하나, 실측 중복이 0이므로 이 로직은 방어 코드로만 남는다. M1에서 계약 문구를 조정할 것 - [ ] `research/05-ai-cli-headless-comparison.md.bak` 백업 파일이 남아 있다. 정본 확정 후 삭제할 것 - [ ] `design/00-DATA-SOURCE-DECISION.md` §9가 `.env` 저장을 안내하나, ADR-13이 **DPAPI + GUI 입력**으로 확정했다. 해당 절에 "최종 저장은 DPAPI, `.env` 는 개발 중 임시 수단" 각주를 달 것 - [ ] `docs/README.md` 가 `ops/03-api-usage-policy.md` 를 지도에 올려두었으나 파일이 아직 없다(M1 작성 예정). 작성 전까지는 지도에 ⏳ 표시가 필요하다 --- *이 문서는 프로젝트의 관문이다. 목표·성공 기준·로드맵·리스크가 바뀌면 여기를 먼저 고치고, 상세는 각 SSOT로 내려보낸다. 새 결정을 여기서 만들지 않는다.*