DMF_Crawler/docs/00-PROJECT-OVERVIEW.md
Yun Chan 56a6e2da93 chore: 저장소 구조 정리 및 문서화, 첫 커밋
- 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 문서 지도 갱신
2026-09-04 09:25:44 +09:00

1051 lines
104 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`, `<html` 검사).
### G5. 탭끼리 연동된, 디자인이 예쁜 xlsx를 만든다 (R3.1~R3.4)
**DoD**
- [ ] 시트 8종이 생성되고, 대시보드 → 각 시트 하이퍼링크와 각 시트 `A1``← 대시보드` 역링크가 **양방향으로 동작**한다.
- [ ] 맑은 고딕 10pt, Okabe-Ito 팔레트, `hide_gridlines(2)`, `set_zoom(90)` 이 적용돼 기본 서식 잔재가 없다.
- [ ] 대시보드에 KPI 타일 6개, 차트 2종, 스파크라인, 조건부 서식이 모두 들어간다.
- [ ] **모든 숫자는 파이썬이 계산해 값으로 쓴다.** LibreOffice 재계산 단계가 없고 동적 배열 함수(FILTER/UNIQUE/XLOOKUP)를 쓰지 않는다.
- [ ] 상태는 **색 + 기호 + 텍스트 3중 코딩**이다(색각이상 대응).
- [ ] 처음 보는 사람이 대시보드만 보고 **5초 안에** 오늘의 신규/변경/취하 건수와 이상 여부를 말할 수 있다.
- [ ] **프로토타입이 못 하던 것이 전부 된다** — 틀 고정, 자동 필터, 조건부 서식, 차트, Excel 표, 탭 색상.
- [ ] **소재지 열이 화면을 파괴하지 않는다.** 최대 473자를 담느라 열 너비가 255.6까지 벌어지던 문제를 열 너비 상한 + 줄바꿈으로 해결한다(D10).
- [ ] 연도별 집계에 **"갱신 연도 기준"임이 명시**된다(D11) — `발급일자`가 최종 갱신일이므로 "2025년 1,185건"은 신규 폭증이 아니라 갱신이 많았다는 뜻이다. 이 구분이 없으면 사용자가 반드시 오해한다.
### G6. 사용자가 파일을 열어둬도 배치가 실패하지 않는다 (R3.5)
**DoD**
- [ ] 임시 파일에 쓴 뒤 `os.replace` 로 원자 교체한다.
- [ ] `PermissionError` 시 2초 백오프로 `report.lock_retries`(기본 3)회 재시도한다.
- [ ] 끝내 실패하면 `DMF_리포트_<date>_<HHMMSS>.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_<run_id>\` 디렉터리 하나만 열면 조사가 시작된다.
- [ ] `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축 | 등록번호를 파싱할 때. "이 필드가 무슨 뜻이지?" 할 때. 용어가 헷갈릴 때 <br>⚠️ **§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로 내려보낸다. 새 결정을 여기서 만들지 않는다.*