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 문서 지도 갱신
This commit is contained in:
Yun Chan 2026-09-04 09:25:44 +09:00
commit 56a6e2da93
159 changed files with 145825 additions and 0 deletions

View file

@ -0,0 +1,386 @@
# 데이터 소스 결정 — 크롤링에서 공식 Open API 로
> **이 문서의 역할**: 이 프로젝트가 데이터를 **어디서 어떻게 가져올지**를 확정한다. 이 결정이 아키텍처·법적 리스크·운영 비용 전부를 바꾸므로, 다른 모든 설계 문서보다 상위에 있다.
**결정일**: 2026-09-02
**결정 상태**: ✅ 확정 — **2026-09-02 실측으로 검증 완료**
**영향 범위**: 아키텍처, 봇 차단 대응, 법적 검토, agy 역할 정의, 운영 복잡도 전부
> ### ✅ 실측 검증 완료 (2026-09-02)
>
> 이 결정을 내릴 당시 남아 있던 최대 불확실성은 **"API 7필드만으로 신규·변경·취하를 탐지할 수 있는가"** 였다.
> 이후 사용자가 이미 운영 중이던 실제 산출물 `DMF_현황.xlsx`(9,084건) 를 전수 분석해 **세 가지 모두 탐지 가능함을 확인**했다.
>
> | 확인된 사실 | 값 |
> |---|---|
> | API 전체 건수 | **9,084건** (웹 화면 9,840건과 **756건 차이** → API 는 정상 건만 반환) |
> | 등록번호 중복 | **0건** → 완전한 자연 키 |
> | 등록번호 앞 8자리 vs `발급일자` 불일치 | **44.5%**`발급일자`는 **최종 갱신일**로 움직인다 = 변경의 직접 신호 |
>
> 상세는 **[`00b-baseline-data-analysis.md`](./00b-baseline-data-analysis.md)** 참조. 이 문서의 §7.1 판정 규칙과 부록 B 는 그 결과로 갱신되었다.
---
## 0. 한눈에 보기
- 의약품안전나라(`nedrug.mfds.go.kr`)의 `robots.txt`**`User-agent: * / Disallow: /`** — 전 경로 자동화 접근 금지다.
- 그런데 **식약처가 동일한 DMF 데이터를 공공데이터포털에 공식 Open API 로 공개**하고 있다. 무료, 자동승인, 이용허락범위 제한 없음.
- 즉 식약처의 메시지는 모순이 아니라 한 쌍이다: **"웹 화면을 긁지 말고 API 를 쓰라."**
- 따라서 이 프로젝트는 **HTML 크롤링을 하지 않는다.** 공식 API 를 정본 소스로 삼는다.
- 이 전환으로 봇 차단 대응, 셀렉터 유지보수, 법적 리스크, 브라우저 자동화 의존성이 **전부 사라진다.** 우회 기법을 설계하는 것보다 이 길이 모든 축에서 우월하다.
- API 는 "현황(스냅샷)"만 준다. 공고 이벤트(신규/변경/취하)는 **매일 전량 스냅샷을 받아 자체 diff 로 계산**한다. 공고문을 파싱하는 것보다 오히려 정확하고 누락이 없다.
- `agy` 는 차단 우회 도구가 아니라 **데이터 해석·요약·품질 관리 도구**로 재정의한다. 역할은 5절 참조.
---
## 1. 목차
1. [robots.txt 실측](#2-robotstxt-실측)
2. [공식 Open API 발견](#3-공식-open-api-발견)
3. [API 완전 명세](#4-api-완전-명세)
4. [왜 우회가 아니라 API 인가](#5-왜-우회가-아니라-api-인가)
5. [agy 역할 재정의](#6-agy-역할-재정의)
6. [API 로 커버되지 않는 것과 대응](#7-api-로-커버되지-않는-것과-대응)
7. [보조·확장 소스](#8-보조확장-소스)
8. [발급 절차](#9-serviceKey-발급-절차)
9. [이 결정이 무효화하는 기존 설계](#10-이-결정이-무효화하는-기존-설계)
10. [부록 A. 출처](#부록-a-출처)
11. [부록 B. 미해결 / 실측 필요](#부록-b-미해결--실측-필요)
---
## 2. robots.txt 실측
`https://nedrug.mfds.go.kr/robots.txt` 를 직접 열어 확인한 전문:
```
User-agent: *
Disallow: /
```
**해석**: 모든 사용자 에이전트에 대해 전 경로 크롤링을 금지한다. 예외 경로도, `Crawl-delay` 도, 사이트맵도 없다. 가장 강한 형태의 거부다.
**법적 지위**: robots.txt 자체는 법률이 아니라 관례(RFC 9309 는 표준 규격일 뿐 강제력이 아니다). 그러나 실무적으로 다음을 의미한다.
| 관점 | 의미 |
|---|---|
| 사이트 운영자의 의사 | 자동화 수집을 원하지 않는다는 **명시적 의사표시** |
| 분쟁 발생 시 | "명시적 거부를 알고도 접근했다"는 사실은 불리한 정황이 된다 |
| 기술적 현실 | 명시적 거부는 통상 서버 측 차단 로직과 함께 온다. 차단·IP 밴 위험이 상시 존재한다 |
| 지속 가능성 | 우회는 상대의 방어 갱신마다 깨진다. 유지보수가 영구히 따라붙는다 |
또한 `nedrug.mfds.go.kr/pbp/CCBGE01` 등 추정 경로는 **HTTP 404** 를 반환했고, 메인 페이지에서도 DMF 관련 링크의 href 를 확인하지 못했다. 즉 **URL 구조를 확정하는 것부터가 이미 비용**이다.
---
## 3. 공식 Open API 발견
공공데이터포털(`data.go.kr`)에서 "DMF" 로 검색한 결과 **정확히 이 프로젝트가 필요로 하는 데이터셋 2건**이 공개돼 있다.
| # | 제목 | 유형 | 경로 | 수정일 | 조회수 |
|---|---|---|---|---|---|
| 1 | **식품의약품안전처_원료의약품등록(DMF)현황** | 오픈API (XML/JSON) | `/data/15057075/openapi.do` | 2025-09-19 | 31,330 |
| 2 | DMF현황 | 연계데이터 (JSON+XML) | `/data/2095/linkedData.do` | 2026-07-08 | 4 |
2번 연계데이터의 엔드포인트는 `http://data.mfds.go.kr/openapi/MdcDmfInfoService/getMdcDmfList` 로, 1번의 구버전으로 보인다. **1번을 정본으로 채택한다** (HTTPS, 최신 버전, 상세 명세 제공, 활용신청 548건으로 검증됨).
---
## 4. API 완전 명세
### 4.1 기본 정보
| 항목 | 값 |
|---|---|
| API 명칭 | 식품의약품안전처_원료의약품등록(DMF)현황 |
| 제공기관 | 식품의약품안전처 |
| 관리부서 | 데이터혁신기획팀 |
| API 유형 | REST |
| 기본 데이터 포맷 | XML (`type=json` 으로 JSON 가능) |
| 서비스 URL | `https://apis.data.go.kr/1471000/MdcDmfInfoService01` |
| **요청 주소** | `https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01` |
| 오퍼레이션 | 1개 — "DMF현황 조회하기" |
| 등록일 | 2019-09-27 |
| 수정일 | 2025-09-19 |
| 활용신청 수 | 548 |
| **이용허락범위** | **제한 없음** |
| 비용 | 무료 |
| 심의 | 개발단계 **자동승인** / 운영단계 **자동승인** |
| 트래픽 | 개발계정 **10,000건/일**. 운영계정은 활용사례 등록 후 증량 신청 가능 |
| 참고문서 | `IROS_76_원료의약품(DMF)현황_v1.1.docx` (포털에서 다운로드) |
오퍼레이션 설명 원문:
> "등록번호, 발급일자, 업체명, 성분명, 제조소명 등의 원료의약품 현황 정보를 조회"
### 4.2 요청 파라미터
| 항목명(국문) | 영문명 | 크기 | 필수 | 샘플 | 설명 |
|---|---|---|---|---|---|
| 인증키 | `serviceKey` | 100 | **필수** | 인증키 | 공공데이터포털에서 발급받은 인증키. **URL Encode 필요** |
| 업체명 | `entp_name` | 200 | 선택 | 업체명 | 검색 조건 |
| 성분명 | `ingr_kor_name` | 3000 | 선택 | 성분명 | 검색 조건 |
| 페이지 번호 | `pageNo` | 5 | 선택 | 1 | 페이지번호 |
| 한 페이지 결과 수 | `numOfRows` | 3 | 선택 | 3 | 한 페이지 결과 수 |
| 데이터포맷 | `type` | 4 | 선택 | xml | 응답데이터 형식(xml/json), 기본값 xml |
> ⚠️ `numOfRows` 의 크기가 **3자리**로 명세돼 있다. 최대 999 일 가능성이 높다. 전량 수집 시 호출 횟수 계산에 영향을 준다. → 부록 B.
### 4.3 응답 필드
**공통 헤더**
| 항목명 | 영문명 | 크기 | 필수 | 샘플 |
|---|---|---|---|---|
| 결과코드 | `resultCode` | 4 | 필수 | `00` |
| 결과메시지 | `resultMsg` | 50 | 필수 | `NORMAL SERVICE.` |
| 한 페이지 결과 수 | `numOfRows` | 3 | 선택 | 3 |
| 페이지 번호 | `pageNo` | 5 | 선택 | 1 |
| 전체 결과 수 | `totalCount` | 7 | 선택 | 1 |
**데이터 항목** — 이것이 이 프로젝트의 원천 레코드다.
| 항목명(국문) | 영문명 | 크기 | 샘플 |
|---|---|---|---|
| 등록번호 | `DMF_PERMIT_NO` | 200 | `20121228-168-I-169-04` |
| 성분명 | `INGR_KOR_NAME` | 3000 | `포르모테롤푸마르산염수화물` |
| 업체명 | `ENTP_NAME` | 200 | `(주)대웅제약` |
| 제조소명 | `MNFCTR_NAME` | 150 | `SICOR SOCIETA'ITALIANA CORTICOSTER OIDO S.R.L.` |
| 제조소 소재지 | `MNFCTR_PLACE` | 2000 | `Rho(MI) - Via Terrazzano, 77, Italy` |
| 제조국가명 | `MANUF_COUNTRY_CODE_NM` | 1000 | `이탈리아,스위스` |
| 발급일자 | `DMF_PERMIT_DATE` | 30 | `2015-02-26` |
**필드 관찰**:
- `DMF_PERMIT_NO` 샘플 `20121228-168-I-169-04``발급일자8자리-숫자-알파벳-숫자-일련번호` 구조다. 앞 8자리가 최초 수리일로 보인다. 상세 해부는 `docs/research/01-dmf-domain-and-sources.md` 참조.
- `MANUF_COUNTRY_CODE_NM``이탈리아,스위스` 처럼 **콤마 다중값**이다. 정규화가 필요하다.
- `MNFCTR_NAME` 샘플에 `CORTICOSTER OIDO` 처럼 **공백이 잘못 들어간 원본 오류**가 보인다. 원본 데이터 품질이 완벽하지 않다는 신호다. 표기 정규화 계층이 필요하다.
- 상태 필드(신규/변경/취하)가 **없다.** 따라서 변경 탐지는 우리가 계산해야 한다 (7절).
### 4.4 호출 예시
```
https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01
?serviceKey=<URL_ENCODED_KEY>
&pageNo=1
&numOfRows=100
&type=json
```
인증 없이 호출하면 **HTTP 403** 을 반환한다 (실측). 즉 엔드포인트는 살아 있고 인증을 강제한다.
### 4.5 전량 수집 전략
1. `numOfRows=1`, `pageNo=1` 로 한 번 호출해 `totalCount` 를 얻는다.
2. `ceil(totalCount / numOfRows)` 만큼 페이지를 순회한다.
3. 페이지 간 지연을 둔다 (0.5~1초). 하루 10,000 호출 한도 안에서 여유롭다.
4. 마지막 페이지까지 받은 뒤 **수집 건수 == totalCount** 를 검증한다. 불일치하면 그 실행은 **불완전 수집**으로 표시하고, 취하 판정을 하지 않는다 (7.2 안전장치).
---
## 5. 왜 우회가 아니라 API 인가
원래 계획은 nedrug 화면을 크롤링하고, 차단되면 `agy` 로 에이전틱하게 접근하는 것이었다. 공식 API 를 발견한 지금, 두 경로를 정면으로 비교한다.
| 축 | HTML 크롤링 (+우회) | 공식 Open API |
|---|---|---|
| robots.txt | 전면 금지를 무릅씀 | **무관. 애초에 공개용 채널** |
| 이용 허락 | 불명확 | **"이용허락범위 제한 없음"** 명시 |
| 데이터 구조 | HTML DOM. 사이트 개편마다 파괴 | **고정 스키마 7필드.** 버전 URL 로 관리 |
| 차단 위험 | 상시. IP 밴 시 복구 어려움 | 없음. 인증키 기반 정상 트래픽 |
| 필요 기술 | 브라우저 자동화, 스텔스, TLS 지문, 프록시 | **HTTP GET 한 줄** |
| 의존성 | Playwright + Chromium (수백 MB) | `httpx` 하나 |
| 실행 시간 | 브라우저 기동 포함 수십 초~분 | 수 초 |
| 유지보수 | 셀렉터 깨짐 상시 대응 | 스키마 변경 시에만 |
| 법적 리스크 | 데이터베이스제작자 권리·정보통신망법 논쟁 소지 | **없음** |
| 재현성 | 낮음 (렌더링 타이밍 의존) | 높음 (결정론적) |
| 무인 운영 적합성 | 낮음 | **높음** |
**결론**: 우회 기법은 이 프로젝트에서 **더 어렵고, 더 취약하고, 더 위험하고, 결과물도 더 나쁘다.** 공식 API 채택은 타협이 아니라 모든 축에서의 개선이다.
> 이 프로젝트는 `robots.txt` 를 우회하는 기법을 설계하거나 구현하지 않는다. 봇 차단 관련 조사(`docs/research/04-anti-bot-and-legal.md`)는 **폐기하지 않고 보존**한다. 이유는 두 가지다. 첫째, 향후 API 가 없는 보조 소스를 붙일 때 "정중한 접근" 규칙(요청 빈도, UA 명시, 백오프, 조건부 요청)이 그대로 필요하다. 둘째, 우리가 왜 이 길을 택하지 않았는지의 근거 자료다.
---
## 6. agy 역할 재정의
`agy` 는 원래 "차단을 에이전틱하게 뚫는" 역할로 구상됐다. 그 필요가 사라진 지금, **더 가치 있는 자리로 옮긴다.** 데이터를 가져오는 일은 결정론적 코드가 하고, `agy` 는 **사람이 해야 할 판단을 돕는 일**을 한다.
| # | 역할 | 입력 | 출력 | 실패 시 |
|---|---|---|---|---|
| **A1** | 일일 변경 브리핑 | 오늘의 신규/변경/취하 레코드 JSON | 실무자용 한국어 요약 + 중요도 태깅 | 리포트에 요약 없이 표만 |
| **A2** | 성분명 정규화·매칭 | 표기가 흔들리는 성분명 목록 | 표준명 매핑 후보 + 신뢰도 | 규칙 기반 정규화만 적용 |
| **A3** | 이상 신호 해석 | 건수 급변·0건·중복 급증 등 지표 | 원인 가설과 조치 제안 | 임계값 경보만 발송 |
| **A4** | 워치리스트 매칭 보조 | 관심 성분·업체 목록 + 오늘 데이터 | 유사 매칭 후보 | 완전 일치만 |
| **A5** | 제조소·국가 표기 정리 | `MANUF_COUNTRY_CODE_NM` 다중값, 오탈자 있는 제조소명 | 정규화된 국가 리스트, 정제된 제조소명 | 원문 그대로 |
| **A6** | 주간 트렌드 코멘터리 | 최근 N주 집계 | 서술형 트렌드 요약 | 생략 |
| **A7** | API 스키마 변화 대응 | 예상과 다른 응답 구조 | 변경점 진단과 매핑 제안 (**자동 반영 금지, 사람 승인 필수**) | 실행 중단 + 알림 |
**불변 원칙**: `agy` 가 죽어도, 인증이 만료돼도, 쿼터가 소진돼도 **xlsx 리포트는 반드시 생성된다.** AI 산출물은 리포트의 부가 가치이지 전제 조건이 아니다.
**프롬프트 인젝션 방어**: API 응답 문자열(성분명, 제조소명 등)이 `agy` 프롬프트에 들어간다. 원본 데이터에 악의적 지시문이 섞일 가능성은 낮지만 0은 아니다. 따라서 `--dangerously-skip-permissions` 를 쓰지 않고, `--disable-slash-commands` 를 붙이며, 데이터는 명확한 구분자로 감싸고, 출력은 스키마로 검증한다. 상세는 `docs/research/10-agy-agent-integration-patterns.md`.
---
## 7. API 로 커버되지 않는 것과 대응
### 7.1 상태 필드(신규/변경/취하)가 없다
API 는 **현재 시점의 등록 현황 스냅샷**만 준다. "오늘 무엇이 새로 등록됐고 무엇이 취하됐는가"는 없다.
**대응**: 매일 전량 스냅샷을 저장하고 **전일 스냅샷과 diff** 한다.
| 판정 | 규칙 | 실측 근거 |
|---|---|---|
| **신규** | 오늘 키가 있고 어제 없음 | 등록번호 중복 0건 → 유일 키로 안전 |
| **변경(1차)** | 양쪽에 키가 있고 **`DMF_PERMIT_DATE` 가 어제보다 최신** | 발급일자가 최종 갱신일로 이동 (불일치 44.5%) |
| **변경(2차)** | 양쪽에 키가 있고, 나머지 6필드 중 하나 이상이 다름 | 내용 변경 포착 |
| **취하** | 어제 키가 있고 오늘 없음 | API 가 정상 건만 반환 (웹과 756건 차이) |
| 동일 | 그 외 | |
키는 `DMF_PERMIT_NO` 를 1순위로 한다. 비교 대상 필드와 정규화 규칙은 `docs/design/02-data-model.md` 에서 확정한다.
**`DMF_PERMIT_DATE` 가 변경의 직접 신호라는 것이 실측의 가장 큰 수확이다.** 등록번호 앞 8자리는 최초 등록일로 고정되고, 발급일자는 갱신마다 움직인다. 예를 들어 `20050831-33-A-81-08(18)` 은 2005년 등록 건이지만 발급일자가 `2026-08-18` 이다. 괄호 안의 값이 변경 차수로 보인다. 상세는 [`00b-baseline-data-analysis.md`](./00b-baseline-data-analysis.md) §6.
이 방식은 공고문 파싱보다 **오히려 우월하다.** 공고에 실리지 않는 조용한 변경까지 잡아내고, 공고문 형식 변경에 영향받지 않는다.
**여전히 놓치는 것**(정직하게 기록): 연차보고 이벤트(`최종연차보고년도` 가 API 에 없음), 변경 사유·유형, 취하와 취소의 구분, 정확한 취하 일자. 각각의 영향과 대응은 [`00b-baseline-data-analysis.md`](./00b-baseline-data-analysis.md) §6.4 참조. `대상의약품`(별표1/신물질) 은 등록번호 포맷에서 파생 가능하므로 실질 손실이 아니다.
### 7.2 오탐 안전장치 (필수)
전량 수집이 불완전하면 **멀쩡한 레코드가 전부 "취하"로 오판**된다. 이건 가장 위험한 실패 모드다. 다음 조건을 **전부** 통과하지 못하면 그 실행은 diff 를 수행하지 않고 경보만 낸다.
- [ ] 모든 페이지 요청이 HTTP 200 이고 `resultCode == "00"`
- [ ] 수집 레코드 수 == 응답의 `totalCount`
- [ ] `totalCount` 가 전일 대비 **-5% 이상 급감하지 않음** (임계값은 설정 가능)
- [ ] 수집 레코드의 필수 필드 널 비율이 임계값 이하
- [ ] 중복 `DMF_PERMIT_NO` 비율이 임계값 이하
### 7.3 공고 원문·변경 사유가 없다
변경이 감지돼도 "왜 변경됐는지"는 API 에 없다. **대응**: 리포트의 각 변경 행에 **의약품안전나라 검색 링크를 붙여** 사람이 클릭해 확인하게 한다. 자동으로 화면을 긁지 않는다. 이것이 robots.txt 를 존중하면서 사람의 필요를 충족하는 방법이다.
### 7.4 갱신 주기가 명시되지 않았다
포털에 데이터 갱신주기가 표기돼 있지 않다 (연계데이터 쪽은 "수시"). **대응**: 매일 06:00 에 받되, `totalCount` 와 데이터 해시를 기록해 **실제 갱신 빈도를 실측**한다. 2~4주 관측 후 스케줄을 조정한다.
---
## 8. 보조·확장 소스
같은 제공기관의 인접 데이터셋. 지금 붙이지 않되, 확장 지점으로 기록한다.
| 데이터셋 | 유형 | 경로 | 수정일 | 이 프로젝트에서의 가치 |
|---|---|---|---|---|
| 의약품 제품 허가정보 | 오픈API | `/data/15095677/openapi.do` | 2025-10-31 | 완제의약품 허가와 DMF 원료 연결. 성분→제품 역추적 |
| 의약품 낱알식별 정보 | 오픈API | `/data/15057639/openapi.do` | 2025-11-10 | 낮음 |
| 의약품개요정보(e약은요) | 오픈API | `/data/15075057/openapi.do` | 2025-09-19 | 성분 설명 보강 |
| 의약품 생산·수입실적현황 | 오픈API | `/data/15056880/openapi.do` | 2026-03-20 | 원료 수입 실적과 DMF 등록 대조 |
| 약가마스터_의약품표준코드 | 파일데이터 | `/data/15067462/fileData.do` | 2025-12-01 | 표준코드 매핑 |
| 식품의약품안전처 의약품 관련 정보 | 파일데이터 (CSV) | `/data/15020627/fileData.do` | 2025-09-10 | 통계 보강 |
해외 소스(FDA DMF list, EDQM CEP, PMDA MF)는 `docs/research/01-dmf-domain-and-sources.md` 참조. 이들은 별도 배포 채널(다운로드 파일)이 있어 크롤링 없이 접근 가능한지 확인이 필요하다.
---
## 9. serviceKey 발급 절차
사용자가 직접 해야 하는 유일한 수동 단계다.
1. https://www.data.go.kr 회원가입·로그인
2. https://www.data.go.kr/data/15057075/openapi.do 접속
3. **활용신청** 클릭 → 활용 목적 기재 → 신청
4. **자동승인**이므로 즉시 승인된다 (개발계정, 일 10,000건)
5. 마이페이지 → 오픈API → 개발계정에서 **일반 인증키(Encoding)****일반 인증키(Decoding)** 를 확인
6. 프로젝트 루트의 `.env` 에 저장:
```dotenv
# 공공데이터포털 인증키 (Decoding 키를 넣고 코드에서 URL 인코딩한다)
DATA_GO_KR_SERVICE_KEY=여기에_디코딩_인증키
```
7. `.env` 는 반드시 `.gitignore` 에 포함한다. **인증키를 문서·로그·저장소에 남기지 않는다.**
> ⚠️ 인코딩/디코딩 키 혼동은 공공데이터포털 API 의 1위 실패 원인이다. 라이브러리가 자동 인코딩하는 경우 **Decoding 키**를 쓰고, 직접 URL 문자열을 조립하는 경우 **Encoding 키**를 쓴다. 우리는 `httpx``params=` 로 넘겨 자동 인코딩되게 하므로 **Decoding 키**를 사용한다.
**최초 검증 명령** (키 발급 후 즉시 실행):
```powershell
$key = (Get-Content .env | Select-String '^DATA_GO_KR_SERVICE_KEY=').ToString().Split('=',2)[1]
$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` 에 전체 DMF 등록 건수가 나온다. **이 숫자를 문서 부록 B 에 기록하라.**
---
## 10. 이 결정이 무효화하는 기존 설계
이 결정 이전에 작성된 설계·조사 문서 중 다음 부분은 **더 이상 이 프로젝트의 구현 대상이 아니다.** 삭제하지 않고 "채택하지 않음" 표시를 달아 근거로 보존한다.
| 문서 | 무효화되는 부분 | 처리 |
|---|---|---|
| `research/04-anti-bot-and-legal.md` | 스텔스 도구, TLS 지문 위장, 헤드리스 지문 회피 | **참고 자료로 보존.** 구현하지 않음 |
| `research/03-crawling-theory-and-papers.md` | wrapper induction, 셀렉터 안정성, DOM 추출 | 보존. **증분·변경 감지 이론은 그대로 유효**하며 오히려 핵심이 된다 |
| `design/01-architecture.md` | `fetch` 계층의 브라우저 자동화 전제, Playwright 의존성 | **개정 필요.** HTTP 클라이언트 단일 경로로 축소 |
| `research/10-agy-agent-integration-patterns.md` | UC2 "셀렉터 자가 복구" | **API 스키마 변화 대응(A7)** 으로 대체 |
| `research/01-dmf-domain-and-sources.md` | nedrug 화면 파라미터·컬럼 조사 | 보존. **API 필드와 화면 컬럼의 대응 관계** 파악에 여전히 유용 |
> 아키텍처 문서가 작성 완료되면 이 절을 근거로 **개정 작업**을 수행한다. 개정 내용은 `design/01-architecture.md` 의 ADR 표에 "데이터 소스: 공식 Open API" 항목으로 기록한다.
**이 결정이 살려낸 것**: 데이터 흐름 뒷단(정규화 → diff → 저장 → xlsx 리포트 → 알림 → 스케줄링)은 **전혀 영향받지 않는다.** 이 프로젝트 가치의 대부분은 그쪽에 있고, 앞단이 단순해진 만큼 그쪽에 더 투자할 수 있다.
---
## 부록 A. 출처
| 제목 | URL | 확인 |
|---|---|---|
| nedrug robots.txt | https://nedrug.mfds.go.kr/robots.txt | ✅ 전문 확인 (`User-agent: * / Disallow: /`) |
| 의약품안전나라 메인 | https://nedrug.mfds.go.kr/index | ✅ 열람 (DMF 링크 href 확인 실패) |
| nedrug 추정 경로 | https://nedrug.mfds.go.kr/pbp/CCBGE01 | ✅ HTTP 404 확인 |
| **DMF 오픈API 상세** | https://www.data.go.kr/data/15057075/openapi.do | ✅ 전체 명세 확인 (핵심 출처) |
| DMF 연계데이터 | https://www.data.go.kr/data/2095/linkedData.do | ✅ 열람 |
| 공공데이터포털 DMF 검색 | https://www.data.go.kr/tcs/dss/selectDataSetList.do?keyword=DMF | ✅ 열람 |
| 공공데이터포털 원료의약품 등록 검색 | https://www.data.go.kr/tcs/dss/selectDataSetList.do?keyword=원료의약품+등록 | ✅ 열람 |
| 식약처 의약품 데이터셋 목록 | https://www.data.go.kr/tcs/dss/selectDataSetList.do?keyword=의약품&org=식품의약품안전처 | ✅ 열람 |
| API 엔드포인트 생존 확인 | https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01 | ✅ HTTP 403 (인증 강제, 엔드포인트 정상) |
| 연계데이터 구버전 엔드포인트 | http://data.mfds.go.kr/openapi/MdcDmfInfoService/getMdcDmfList | 미호출 |
| API 참고문서 | `IROS_76_원료의약품(DMF)현황_v1.1.docx` | ⚠️ 미다운로드 |
---
## 부록 B. 미해결 / 실측 필요
### ✅ 해소된 항목 (2026-09-02 `DMF_현황.xlsx` 전수 분석)
- [x] ~~전체 DMF 등록 건수(`totalCount`)는 몇 건인가?~~ → **9,084건**
- [x] ~~응답에 `DMF_PERMIT_NO` 중복이 존재하는가?~~ → **중복 0건. 완전한 자연 키**
- [x] ~~API 응답에 취하·말소된 등록번호가 남아 있는가?~~**남지 않을 가능성 매우 높음.** 웹 9,840 vs API 9,084 = 756건 차이. 연속 관측으로 최종 확정 예정
- [x] ~~`type=json` 이 실제로 동작하는가?~~ → 프로토타입이 수집에 성공했으므로 API 자체는 정상 동작 확인. 포맷 파라미터는 별도 확인
- [x] ~~변경 탐지가 7필드로 가능한가?~~**가능. `DMF_PERMIT_DATE` 가 최종 갱신일로 이동** (앞 8자리와 44.5% 불일치)
### 남은 항목
- [ ] `numOfRows` 의 실제 최대값은? 명세 크기가 3자리이므로 999 로 추정되나 실측 필요.
- [ ] 데이터 실제 갱신 주기는? 2~4주 관측 필요.
- [ ] **연차보고 시 `DMF_PERMIT_DATE` 가 갱신되는가?** 연차보고는 매년 1~2월에 몰리므로 그때 관측. 갱신된다면 변경으로 잡히고, 아니면 영구히 놓친다.
- [ ] 변경 시 등록번호의 괄호 부분이 바뀌는가, 번호는 그대로이고 발급일자만 바뀌는가? 전자면 신규로 오탐할 위험이 있다.
- [ ] 웹 9,840 API 9,084 = 756건이 정말 취하·취소 건인지 표본 대조.
- [ ] 제조국가명 결측 5건의 등록번호와 원인.
- [ ] 참고문서 `IROS_76_원료의약품(DMF)현황_v1.1.docx` 에 명세 외 추가 정보가 있는가?
- [ ] `entp_name` / `ingr_kor_name` 검색 파라미터가 부분 일치인가 완전 일치인가?
- [ ] API 응답에 취하·말소된 등록번호가 남아 있는가, 아니면 사라지는가? (취하 판정 로직의 전제)
- [ ] 일 10,000건 한도의 카운트 단위가 "호출 수"인가 "레코드 수"인가?
- [ ] 운영계정 전환 조건과 증량 한도는?
- [ ] 해외 소스(FDA/EDQM/PMDA)의 robots.txt 와 공식 배포 채널은?
- [ ] 인접 데이터셋 "의약품 제품 허가정보" 로 DMF 원료↔완제품 연결이 실제로 가능한가?
---
*이 문서는 데이터 소스에 관한 SSOT 다. 소스 관련 새 사실은 여기를 갱신한다.*

View file

@ -0,0 +1,591 @@
# 기준선 데이터 분석 — 기존 `DMF_현황.xlsx` 실측
> **이 문서의 역할**: 사용자가 이미 운영 중이던 `DMF_현황.xlsx` 를 전수 분석한 결과. **이 프로젝트는 스크래치가 아니라 기존 프로토타입의 개선**이며, 이 문서가 그 출발점의 사실 기록이다. 데이터 모델·변경 탐지·정규화 규칙의 근거가 전부 여기서 나온다.
**분석일**: 2026-09-02
**대상 파일**: `C:\Users\encep\OneDrive\문서\카카오톡 받은 파일\DMF_현황.xlsx` (757,331 bytes, 수정 2026-09-02 21:38)
**분석 도구**: openpyxl 3.1.5, pandas 2.2.3
---
## 0. 한눈에 보기
- **이 프로젝트는 스크래치가 아니다.** 이미 공식 Open API 로 수집해 xlsx 를 만드는 프로토타입이 돌고 있었고, 그 산출물이 이 파일이다. 컬럼이 API 7필드와 정확히 일치한다.
- **API 전체 건수는 9,084건이다.** 웹 화면 실측 9,840건과 **756건 차이**가 난다. 이 차이가 **API 는 취하·취소 건을 제외하고 정상 건만 반환한다**는 강력한 증거다.
- **따라서 취하 탐지는 diff 로 가능하다.** 레코드가 API 응답에서 사라지면 취하다. "API 7필드로는 취하를 탐지할 수 없다"던 우려는 해소 방향이다.
- **변경 탐지도 가능하다.** 등록번호 앞 8자리(최초 등록일)와 `발급일자`**44.5% 불일치**하며, 불일치하는 건은 전부 괄호가 붙은 갱신 건이다. 즉 **`발급일자`는 최종 갱신일로 움직이는 필드**다. 이것이 변경의 직접 신호다.
- **등록번호는 완전한 자연 키다.** 9,084건 중 중복 0건. 별도 대체 키가 필요 없다.
- **데이터 품질 문제 4종을 확인했다.** 국가 중복 표기 496건, 성분명 표기 흔들림 21그룹, 업체명 표기 흔들림 2그룹, 제조소명 공백 오류 42건. 정규화 계층이 반드시 필요하다.
- **기존 xlsx 에는 서식이 전혀 없다.** 틀 고정, 자동 필터, 조건부 서식, 차트가 모두 0이다. "디자인 예쁘게" 요구가 겨냥하는 지점이 정확히 여기다.
- 데이터는 **2003-04-18부터 2026-09-01까지 23년치**다. 성분 1,539종, 업체 438개, 제조소 3,051개, 국가 49개.
---
## 1. 목차
1. [파일 구조](#2-파일-구조)
2. [컬럼과 API 필드 대응](#3-컬럼과-api-필드-대응)
3. [건수 검증 — 취하 탐지 가능성의 결정적 근거](#4-건수-검증--취하-탐지-가능성의-결정적-근거)
4. [등록번호 분석](#5-등록번호-분석)
5. [변경 탐지 가능성 — 발급일자의 의미](#6-변경-탐지-가능성--발급일자의-의미)
6. [데이터 규모와 분포](#7-데이터-규모와-분포)
7. [데이터 품질 문제](#8-데이터-품질-문제)
8. [기존 서식 상태와 개선 여지](#9-기존-서식-상태와-개선-여지)
9. [확정되는 설계 결정](#10-확정되는-설계-결정)
10. [부록 A. 재현 스크립트](#부록-a-재현-스크립트)
11. [부록 B. 미해결 / 확인 필요](#부록-b-미해결--확인-필요)
---
## 2. 파일 구조
| 시트 | 범위 | 행 | 열 | 내용 |
|---|---|---|---|---|
| `전체` | A1:H9085 | 9,085 (헤더 1 + 데이터 9,084) | 8 | 누적 DMF 등록 목록 |
| `신규` | A1:H1 | 1 (헤더만) | 8 | 오늘의 신규 건. 첫 실행이라 비어 있음 |
| `갱신이력` | A1:C2 | 2 (헤더 1 + 1행) | 3 | 실행 로그 |
`갱신이력` 시트의 유일한 데이터 행:
| 실행일 | 누적건수 | 신규건수 |
|---|---|---|
| 2026-09-02 | 9084 | 0 |
**해석**: 2026-09-02 에 최초 실행되어 9,084건을 기준선으로 적재했고, 비교 대상이 없어 신규는 0으로 기록됐다. 설계 의도가 이미 "스냅샷 + 신규 + 실행 이력" 3층 구조였다는 뜻이다. **이 구조를 버리지 않고 확장한다.**
---
## 3. 컬럼과 API 필드 대응
| # | xlsx 컬럼 | API 응답 필드 | 비고 |
|---|---|---|---|
| 1 | 등록번호 | `DMF_PERMIT_NO` | 자연 키 |
| 2 | 성분명 | `INGR_KOR_NAME` | |
| 3 | 업체명 | `ENTP_NAME` | 화면에서는 "신청인" |
| 4 | 제조소명 | `MNFCTR_NAME` | |
| 5 | 제조소소재지 | `MNFCTR_PLACE` | |
| 6 | 제조국가명 | `MANUF_COUNTRY_CODE_NM` | 콤마 다중값 |
| 7 | 발급일자 | `DMF_PERMIT_DATE` | **최종 갱신일로 동작** (6절) |
| 8 | 최초수집일 | *(파생)* | 이 시스템이 처음 이 레코드를 본 날 |
**확인 사항**: 8개 컬럼 중 7개가 API 필드와 1:1로 정확히 대응한다. 8번째 `최초수집일`은 프로토타입이 스스로 만든 파생 컬럼이다. 좋은 설계이므로 **유지하고, 여기에 `최종변경감지일`·`상태`·`변경차수`를 추가**한다.
**웹 화면에만 있는 컬럼**(API 미제공): `대상의약품`, `최종변경일자`, `최종연차보고년도`, `취소/취하구분`, `취소/취하일자`, `문서번호`. 이 중 실질적 손실과 대체 수단은 10절 참조.
---
## 4. 건수 검증 — 취하 탐지 가능성의 결정적 근거
| 소스 | 건수 | 출처 |
|---|---|---|
| 웹 화면 (`/pbp/CCBAC03`) | **9,840** | 조사 문서 `research/01` 실측 (2026-09-02) |
| 공식 Open API | **9,084** | 이 파일 실측 (2026-09-02) |
| **차이** | **756** | |
두 수치는 같은 날 측정됐다. 756건의 차이를 설명하는 가설은 둘뿐이다.
| 가설 | 내용 | 판정 |
|---|---|---|
| **A** | API 가 취하·취소된 건을 제외하고 **정상 건만** 반환한다 | **유력** |
| B | API 데이터가 웹보다 오래됐다 | **기각** |
가설 B가 기각되는 근거: 이 파일의 최신 `발급일자`**2026-09-01** 이고, 조사 문서가 기록한 웹 화면의 최신 `최초등록일자`**2026-09-01** 로 동일하다. API 가 뒤처져 있지 않다. 하루치 지연으로는 756건을 설명할 수 없다.
또한 웹 화면의 `취소/취하구분` 컬럼 실측값이 `정상`이었다는 사실은, 웹 테이블이 취하 건을 **삭제하지 않고 상태 컬럼으로 표시**한다는 뜻이다. 두 사실을 합치면 756건은 취하·취소 건일 가능성이 높다.
### 이것이 의미하는 것
**API 응답에서 등록번호가 사라지면 취하로 판정할 수 있다.** 이는 이 프로젝트 변경 탐지 설계의 핵심 전제이며, 이 분석으로 강하게 뒷받침된다.
> ⚠️ **아직 증명은 아니다.** 이틀 이상 연속 수집해 실제로 사라지는 레코드를 관찰해야 확정된다. 그때까지는 `design/00-DATA-SOURCE-DECISION.md` §7.2 의 안전장치(건수 급감 시 diff 중단)를 반드시 유지한다.
---
## 5. 등록번호 분석
### 5.1 유일성
| 항목 | 값 |
|---|---|
| 전체 행 | 9,084 |
| 고유 등록번호 | **9,084** |
| 중복 | **0** |
**결론: 등록번호는 완전한 자연 키다.** 복합 키나 해시 대체 키가 필요 없다. SQLite 의 `PRIMARY KEY` 로 그대로 쓴다.
### 5.2 포맷 분포
| 포맷 | 건수 | 비율 | 예시 |
|---|---|---|---|
| 표준 `YYYYMMDD-n-A-n-n` | 3,774 | 41.5% | `20260901-86-D-173-26`, `20260831-209-J-2250` |
| 표준 + 괄호 | 2,973 | 32.7% | `20230116-200-I-647-07(A)`, `20250219-32-C-423-28(1)` |
| 신물질 `수nnnn-n-ND` | 1,882 | 20.7% | `수6580-16-ND(20)`, `수6256-1-ND`, `수582-34-ND(A)` |
| 기타 | 455 | 5.0% | `1962-17-ND`, `20100616-122-G-60-22(1)-A(1)`, `20150918-135-H-305-43(2)-A(A)` |
**설계 시사점**: 파서는 **4종 이상의 변형을 관대하게 처리**해야 한다. 특히 "기타" 455건에는 `수` 접두어 없는 `1962-17-ND` 형태와 이중 괄호 `(1)-A(1)` 형태가 섞여 있다.
**파싱 실패는 치명적이지 않게 설계한다.** 등록번호는 문자열 그대로가 키이므로, 파싱은 파생 정보(최초 등록일, 변경 차수)를 얻기 위한 부가 작업이다. 실패해도 레코드는 정상 처리하고 파생 필드만 null 로 둔다.
### 5.3 괄호의 의미
괄호가 붙은 건은 **표준+괄호 2,973건 + 신물질 일부 + 기타 일부**로 전체의 약 3분의 1이다. 다음 절의 분석이 괄호의 의미를 밝힌다.
---
## 6. 변경 탐지 가능성 — 발급일자의 의미
### 6.1 실측
표준 포맷 등록번호 6,768건에 대해, 앞 8자리(`YYYYMMDD`)와 `발급일자`를 비교했다.
| 항목 | 건수 | 비율 |
|---|---|---|
| 앞 8자리 == 발급일자 | 3,753 | 55.5% |
| **앞 8자리 ≠ 발급일자** | **3,015** | **44.5%** |
불일치 사례:
| 등록번호 | 앞 8자리(최초 등록) | 발급일자(최종) | 간격 |
|---|---|---|---|
| `20230116-200-I-647-07(A)` | 2023-01-16 | 2026-08-28 | 3년 7개월 |
| `20180814-209-J-127(A)` | 2018-08-14 | 2026-08-25 | 8년 |
| `20131031-84-D-126-07(A)` | 2013-10-31 | 2026-08-25 | 12년 10개월 |
| `20050831-33-A-81-08(18)` | 2005-08-31 | 2026-08-18 | 21년 |
| `20250219-32-C-423-28(1)` | 2025-02-19 | 2026-08-21 | 1년 6개월 |
| `20210721-209-J-1073(6)` | 2021-07-21 | 2026-08-18 | 5년 1개월 |
### 6.2 해석
불일치 건은 **전부 괄호가 붙어 있다.** 따라서 다음 구조가 성립한다.
| 요소 | 의미 |
|---|---|
| 등록번호 앞 8자리 | **최초 등록일** (고정) |
| 괄호 안의 값 `(A)`, `(1)`, `(18)` | **변경/갱신 표식** |
| `발급일자` (`DMF_PERMIT_DATE`) | **최종 갱신일** (움직인다) |
**즉 `발급일자`는 정적인 최초 등록일이 아니라 변경 때마다 갱신되는 동적 필드다.**
### 6.3 이것이 의미하는 것
**API 7필드만으로 신규·변경·취하 세 가지를 모두 탐지할 수 있다.**
| 판정 | 규칙 | 근거 |
|---|---|---|
| **신규** | 오늘 등록번호가 있고 어제 없음 | 등록번호가 유일 키 (5.1) |
| **변경** | 등록번호 동일 + `발급일자`가 어제보다 최신 | 발급일자가 최종 갱신일로 동작 (6.2) |
| **변경(보조)** | 등록번호 동일 + 성분명·업체명·제조소명·소재지·국가 중 하나가 다름 | 내용 변경 |
| **취하** | 어제 등록번호가 있고 오늘 없음 | API 가 정상 건만 반환 (4절) |
이로써 조사 문서 `research/01` 이 제기한 "API 단독으로는 변경·취하 탐지 요구를 충족할 수 없다"는 주장은 **반증된다.** 그 주장은 웹 화면의 `최종변경일자`·`취소/취하구분` 컬럼이 있어야만 탐지가 가능하다는 전제에 서 있었으나, 실제로는 `발급일자`의 이동과 레코드 소멸이 같은 정보를 담고 있다.
### 6.4 여전히 놓치는 것
정직하게 기록한다. API 로 탐지되지 않는 이벤트가 있다.
| 놓치는 것 | 이유 | 영향 | 대응 |
|---|---|---|---|
| **연차보고** | `최종연차보고년도` 컬럼이 API 에 없고, 연차보고 시 `발급일자`가 갱신되는지 불명 | 중간. 연차보고는 매년 1월 말에 몰린다 | 리포트에서 별도 이벤트로 다루지 않음. 확인 필요 |
| **변경 사유·유형** | 어떤 항목이 왜 바뀌었는지 API 에 없음 | 낮음 | 리포트 각 행에 웹 화면 검색 링크를 붙여 사람이 확인 |
| **취하 vs 취소 구분** | 소멸했다는 사실만 알고 사유를 모름 | 낮음 | "목록에서 사라짐"으로 표기 |
| **취하 일자** | 소멸을 감지한 날짜만 앎 | 낮음 | 감지일로 기록 |
| **대상의약품 구분** (`별표1`/`신물질`) | API 에 없음 | 낮음 | **등록번호 포맷으로 추론 가능** (`수` 접두어 = 신물질) |
`대상의약품`은 등록번호 포맷에서 파생할 수 있으므로 실질 손실이 아니다. 실질적으로 아쉬운 것은 연차보고 하나다.
---
## 7. 데이터 규모와 분포
### 7.1 기본 통계
| 항목 | 값 |
|---|---|
| 전체 레코드 | 9,084 |
| 고유 성분명 | 1,539 |
| 고유 업체명 | 438 |
| 고유 제조소명 | 3,051 |
| 고유 제조소소재지 | 4,270 |
| 고유 제조국가명(원문) | 225 |
| 고유 국가(콤마 분해 후) | **49** |
| 고유 발급일자 | 2,397 |
| 발급일자 범위 | 2003-04-18 ~ 2026-09-01 |
| 결측치 | 제조국가명 5건. 나머지 컬럼 0건 |
### 7.2 연도별 등록 건수 (최근 12년)
| 연도 | 건수 |
|---|---|
| 2015 | 278 |
| 2016 | 246 |
| 2017 | 284 |
| 2018 | 473 |
| 2019 | 502 |
| 2020 | 668 |
| 2021 | 939 |
| 2022 | 633 |
| 2023 | 463 |
| 2024 | 534 |
| 2025 | **1,185** |
| 2026 (9월 2일까지) | 610 |
> 주의: 이 집계는 `발급일자` 기준이므로 **최초 등록이 아니라 최종 갱신 연도**다. 2025년이 급증한 것은 최근 갱신된 건이 많다는 뜻이지 신규 등록이 폭증했다는 뜻이 아니다. **리포트에서 이 구분을 명확히 표기해야 사용자가 오해하지 않는다.**
### 7.3 제조국가 분포 (원문 기준 상위 15)
| 국가 | 건수 |
|---|---|
| 인도 | 3,351 |
| 중국 | 2,231 |
| 대한민국 | 845 |
| 이탈리아 | 313 |
| 스페인 | 210 |
| 대한민국,중국 | 156 |
| 일본 | 149 |
| 대만 | 121 |
| 독일 | 117 |
| 프랑스 | 88 |
| 미국 | 83 |
| 스위스 | 81 |
| 아일랜드 | 77 |
| 중국,중국 | 76 |
| 인도,인도 | 74 |
인도와 중국이 전체의 **61.5%** 를 차지한다. 원료의약품 공급망이 이 두 나라에 집중돼 있다는 사실이 데이터로 확인된다. **리포트 대시보드의 핵심 지표가 될 만하다.**
### 7.4 업체 분포 (상위 15)
| 업체명 | 건수 |
|---|---|
| (주)삼오제약 | 392 |
| (주)파마피아 | 378 |
| 에이징생명과학(주) | 277 |
| (주)국전 | 271 |
| 에이스바이오팜주식회사 | 248 |
| 대신무약(주) | 215 |
| 화일약품(주) | 188 |
| (주)마성엘에스 | 176 |
| 성우화학(주) | 155 |
| (주)성진엑심 | 153 |
| ㈜하이플 | 147 |
| (주)휴시드 | 124 |
| 이성인터내쇼날(주) | 117 |
| 성이바이오(주) | 117 |
| 주식회사토루 | 116 |
### 7.5 성분 분포 (상위 15)
| 성분명 | 건수 |
|---|---|
| 히알루론산나트륨 | 104 |
| 메트포르민염산염 | 97 |
| 로수바스타틴칼슘 | 95 |
| 아세트아미노펜 | 92 |
| 세레콕시브 | 73 |
| 발사르탄 | 73 |
| 암로디핀베실산염 | 72 |
| 레바미피드 | 69 |
| 덱시부프로펜 | 67 |
| 에제티미브 | 67 |
| 트라마돌염산염 | 66 |
| 프레가발린 | 61 |
| 엠파글리플로진 | 61 |
| 세파클러수화물 | 58 |
| 아토르바스타틴칼슘 | 55 |
---
## 8. 데이터 품질 문제
정규화 계층에서 반드시 처리해야 할 실제 문제들이다. 모두 실측으로 확인했다.
### 8.1 제조국가명 — 같은 국가 반복 표기 (496건)
원본에 같은 국가가 콤마로 반복된다.
| 원문 | 건수 |
|---|---|
| `중국,중국` | 76 |
| `인도,인도` | 74 |
| `대한민국,대한민국` | 56 |
| `이탈리아,이탈리아` | 32 |
| `대한민국,중국,중국` | 30 |
| `중국,중국,중국` | 23 |
| `인도,인도,인도` | 22 |
| `독일,독일` | 19 |
| `스위스,스위스` | 12 |
| `일본,일본` | 10 |
| `스페인,스페인` | 10 |
| `이탈리아,중국,중국,중국` | 8 |
**총 496건.** 제조소가 여럿이고 같은 국가에 있을 때 국가명이 그만큼 반복되는 것으로 보인다.
**정규화 규칙**: 콤마로 분해 → 공백 제거 → 중복 제거 → 정렬 → 재결합. 원문은 별도 컬럼에 보존한다.
정규화 후 고유 국가는 **49개**다.
```
남아프리카 공화국, 네덜란드, 노르웨이, 뉴질랜드, 대만, 대한민국, 덴마크, 독일, 라트비아,
루마니아, 말레이지아, 멕시코, 몰타, 미국, 바하마, 벨기에, 불가리아, 브라질, 스웨덴,
스위스, 스페인, 슬로바키아, 슬로베니아, 싱가포르, 아르헨티나, 아일랜드, 영국, 오만,
오스트리아, 우크라이나, 이란, 이스라엘, 이탈리아, 인도, 인도네시아, 일본, 중국,
체코공화국, 캐나다, 크로아티아, 태국, 튀르키예, 포르투갈, 폴란드, 푸에르토리코,
프랑스, 핀란드, 헝가리, 호주
```
> 표기 특이점: `말레이지아`(표준 표기는 말레이시아), `체코공화국`(체코) 처럼 비표준 표기가 섞여 있다. 국가 코드 매핑 테이블을 두면 지도 시각화나 그룹화에 유리하다.
### 8.2 성분명 표기 흔들림 (21그룹)
공백·구분자만 다른 같은 성분이 별개 값으로 존재한다.
| 흔들리는 표기 |
|---|
| `다비가트란 에텍실레이트 메실산염` / `다비가트란에텍실레이트메실산염` |
| `DL-메틸에페드린염산염` / `dl-메틸에페드린염산염` |
| `L-아스파르트산-L-오르니틴` / `L-아스파르트산·L-오르니틴` |
| `로베글리타존 황산염` / `로베글리타존황산염` |
| `항독성간장 엑스` / `항독성간장엑스` |
| `오메가-3-산에틸에스테르90` / `오메가3산에틸에스테르90` |
| `싸이모신 알파 1` / `싸이모신-알파1` / `싸이모신알파1` |
| `은행엽 건조엑스` / `은행엽건조엑스` |
| `덱스클로르페니라민 말레산염` / `덱스클로르페니라민말레산염` |
| `톨밥탄 분무건조분말` / `톨밥탄분무건조분말` |
**총 21그룹.** 1,539개 성분 중 21그룹이므로 비율은 낮지만, **성분별 집계 시트에서 같은 성분이 두 줄로 갈라져 보이는 문제**를 만든다.
**정규화 규칙**: 공백·중점(`·`)·하이픈·괄호를 제거하고 소문자화한 값을 그룹 키로 삼는다. 표시는 원문 중 최빈값을 대표로 쓴다. **원문은 반드시 보존한다.**
### 8.3 업체명 표기 흔들림 (2그룹)
| 흔들리는 표기 |
|---|
| `삼진제약(주)` / `삼진제약주식회사` |
| `(주)유일팜테크` / `주식회사 유일팜테크` |
438개 업체 중 2그룹으로 매우 적다. `㈜``(주)`, `주식회사``(주)`, 공백 제거로 해소된다.
### 8.4 제조소명 형식 오류 (42건)
원본에 연속 공백이나 마침표 중복이 들어 있다.
| 예시 |
|---|
| `BDR LIFESCIENCES PVT. LTD..` (마침표 2개) |
| `Nanjing King-Friend Biochemical Pharmaceutical Co., Ltd.` (연속 공백) |
| `North China Pharmaceutical Group Semisyntech Co., Ltd` (연속 공백) |
또한 제조소가 여러 곳인 경우 콤마로 이어붙어 있고, `[출발물질제조소]` 같은 **역할 표시가 문자열 안에 섞여** 있다.
```
Sichuan Renan Pharmaceutical Co, Ltd,[출발물질제조소]North China Pharmaceutical Group Semisyntech Co., Ltd
```
**주의**: 제조소명 자체에 콤마가 포함되므로(`Co., Ltd.`) **콤마로 단순 분할하면 안 된다.** 다중 제조소 분해는 신뢰할 수 없으니, 정규화는 연속 공백 압축과 양끝 정리에 그친다.
### 8.5 발급일자 형식
**9,084건 전부 `YYYY-MM-DD` 형식으로 일관**된다. 파싱 실패 0건. 날짜 처리는 안전하다.
### 8.6 제조소소재지 길이
| 통계 | 값 |
|---|---|
| 평균 | 81자 |
| 중앙값 | 78자 |
| 75분위 | 107자 |
| **최대** | **473자** |
**xlsx 설계 시사점**: 이 컬럼을 그대로 표시하면 열 너비가 파괴된다(기존 파일의 D열 너비가 255.6으로 최대치에 붙어 있는 이유다). **줄바꿈 + 행 높이 고정 + 열 너비 상한**을 적용하거나, 목록 시트에서는 앞 60자만 보이고 전체는 셀 메모나 상세 시트에서 보게 해야 한다.
---
## 9. 기존 서식 상태와 개선 여지
`전체` 시트의 서식 실측 결과다.
| 항목 | 현재 상태 |
|---|---|
| 틀 고정 (freeze_panes) | **없음** |
| 자동 필터 | **없음** |
| 조건부 서식 | **0개 규칙** |
| 병합 셀 | 0 |
| 차트 | **0** |
| 이미지 | 0 |
| Excel 표(ListObject) | **없음** |
| 열 너비 지정 | A 30.6 / B 85.6 / C 33.1 / **D 255.6** / E~H 미지정 |
| 시트 탭 색상 | **없음** |
**진단**: 데이터는 정확한데 **읽기 도구로서의 설계가 전혀 없다.** 9,084행을 헤더 고정 없이 스크롤해야 하고, 필터가 없어 특정 성분·업체를 찾을 수 없으며, 무엇이 새 건인지 색으로 구분되지 않는다. D열 너비 255.6은 소재지 473자를 담으려다 최대치에 닿은 것으로, 화면이 가로로 파괴된다.
**"디자인 예쁘게, 한눈에" 요구가 겨냥하는 지점이 정확히 여기다.** 개선 방향은 `design/03-xlsx-report-spec.md` 에서 확정하되, 이 분석에서 나오는 필수 항목은 다음과 같다.
- 헤더 행 고정과 자동 필터 (9,084행을 다루려면 필수)
- 열 너비 상한과 소재지 줄바꿈 처리
- 신규·변경·취하 상태에 따른 행 색상 구분
- 대시보드 시트 신설: 오늘 요약, 국가 분포(인도·중국 61.5% 집중), 상위 성분·업체, 최근 추이
- 성분별·업체별·국가별 집계 시트 (정규화된 값 기준)
- 시트 탭 색상과 목차 하이퍼링크
---
## 10. 확정되는 설계 결정
이 분석으로 확정 또는 강하게 뒷받침되는 결정들이다.
| # | 결정 | 근거 |
|---|---|---|
| D1 | **등록번호를 기본 키로 쓴다.** 대체 키 불필요 | 9,084건 중복 0 (5.1) |
| D2 | **취하는 "API 응답에서 소멸"로 판정한다** | 웹 9,840 vs API 9,084, 756건 차이 (4절) |
| D3 | **변경은 `발급일자` 이동 + 6개 필드 diff 로 판정한다** | 발급일자가 최종 갱신일로 동작, 44.5% 불일치 (6절) |
| D4 | **`대상의약품`(별표1/신물질)은 등록번호 포맷에서 파생한다** | `수` 접두어 1,882건 식별 (5.2) |
| D5 | **정규화 계층을 반드시 둔다.** 국가·성분명·업체명·제조소명 4종 | 품질 문제 실측 (8절) |
| D6 | **원문을 반드시 보존한다.** 정규화 값은 별도 컬럼 | 되돌릴 수 없는 손실 방지 |
| D7 | **등록번호 파싱 실패를 허용한다.** 파생 필드만 null | 포맷 4종 이상, 기타 455건 (5.2) |
| D8 | **기존 3시트 구조(전체/신규/갱신이력)를 계승·확장한다** | 프로토타입 설계 의도 존중 (2절) |
| D9 | **`최초수집일` 파생 컬럼을 유지하고 형제 컬럼을 추가한다** | 기존 설계가 이미 옳았음 (3절) |
| D10 | **소재지 컬럼은 표시 폭을 제한한다** | 최대 473자 (8.6) |
| D11 | **연도별 집계는 "갱신 연도"임을 명시한다** | 발급일자가 최종 갱신일 (7.2) |
| D12 | **국가 코드 매핑 테이블을 둔다** | 비표준 표기 존재, 49개국 (8.1) |
### 이 분석이 해소한 기존 미해결 항목
`design/00-DATA-SOURCE-DECISION.md` 부록 B 의 항목들이 다음과 같이 해소됐다.
| 기존 미해결 항목 | 해소 상태 |
|---|---|
| 전체 DMF 등록 건수(`totalCount`)는? | ✅ **9,084건** (2026-09-02) |
| 응답에 `DMF_PERMIT_NO` 중복이 존재하는가? | ✅ **중복 0** |
| API 에 취하·말소된 등록번호가 남아 있는가? | 🟡 **남지 않을 가능성 높음** (756건 차이). 연속 관측으로 확정 필요 |
| `numOfRows` 실제 최대값 | ❌ 미해소 |
| `type=json` 실동작 여부 | 🟡 프로토타입이 수집에 성공했으므로 API 자체는 동작 확인 |
---
## 부록 A. 재현 스크립트
이 분석을 재현하는 스크립트다. 데이터가 갱신되면 다시 돌려 비교한다.
```python
# -*- coding: utf-8 -*-
"""DMF_현황.xlsx 기준선 분석 재현 스크립트"""
import re
import collections
import pandas as pd
import openpyxl
XLSX = r"C:\Users\encep\OneDrive\문서\카카오톡 받은 파일\DMF_현황.xlsx"
def inspect_structure(path: str) -> None:
"""시트 구조와 서식 상태를 출력한다."""
wb = openpyxl.load_workbook(path, data_only=True)
for ws in wb.worksheets:
rules = sum(len(r.rules) for r in ws.conditional_formatting)
print(f"[{ws.title}] rows={ws.max_row} cols={ws.max_column} "
f"freeze={ws.freeze_panes} filter={ws.auto_filter.ref} "
f"cf_rules={rules} charts={len(ws._charts)}")
def normalize_countries(value: str) -> str:
"""콤마 다중값에서 중복을 제거하고 정렬해 재결합한다."""
if not isinstance(value, str) or not value.strip():
return ""
parts = [p.strip() for p in value.split(",") if p.strip()]
return ",".join(sorted(set(parts)))
def normalize_ingredient(value: str) -> str:
"""성분명 그룹 키. 공백·중점·하이픈·괄호를 제거한다."""
return re.sub(r"[\s\u00a0()()·・\-–—,]", "", str(value)).lower()
def normalize_entity(value: str) -> str:
"""업체명 그룹 키. 법인 표기를 통일한다."""
s = str(value).replace("㈜", "(주)")
s = re.sub(r"주식회사", "(주)", s)
return re.sub(r"[\s\u00a0().]", "", s).lower()
def classify_permit_no(value: str) -> str:
"""등록번호 포맷을 4종으로 분류한다."""
if re.match(r"^\d{8}-\d+-[A-Z]+-\d+(-\d+)?$", value):
return "std"
if re.match(r"^\d{8}-\d+-[A-Z]+-\d+(-\d+)?\([^)]*\)$", value):
return "std_paren"
if re.match(r"^수\d+-\d+-[A-Z]+", value):
return "new_substance"
return "other"
def analyze(path: str) -> None:
df = pd.read_excel(path, sheet_name="전체", dtype=str)
print(f"행수={len(df)} 고유등록번호={df['등록번호'].nunique()}")
issued = pd.to_datetime(df["발급일자"], errors="coerce")
print(f"발급일자 {issued.min().date()} ~ {issued.max().date()}")
# 등록번호 앞 8자리 vs 발급일자
std = df[df["등록번호"].str.match(r"^\d{8}-")]
head = pd.to_datetime(std["등록번호"].str[:8], format="%Y%m%d", errors="coerce")
same = (head == pd.to_datetime(std["발급일자"], errors="coerce")).sum()
print(f"std {len(std)}건 중 앞8자리==발급일자 {same}건 ({same / len(std) * 100:.1f}%)")
# 국가 중복 표기
redundant = df["제조국가명"].fillna("").apply(
lambda v: "," in v and len({p.strip() for p in v.split(",")}) != len(v.split(","))
)
print(f"국가 중복 표기 {redundant.sum()}건")
# 표기 흔들림
for name, fn, col in (
("성분명", normalize_ingredient, "성분명"),
("업체명", normalize_entity, "업체명"),
):
groups = collections.defaultdict(set)
for v in df[col].fillna(""):
groups[fn(v)].add(v)
collisions = {k: v for k, v in groups.items() if len(v) > 1}
print(f"{name} 표기 흔들림 그룹 {len(collisions)}개")
# 포맷 분포
counts = collections.Counter(classify_permit_no(v) for v in df["등록번호"].fillna(""))
print("등록번호 포맷:", dict(counts))
if __name__ == "__main__":
inspect_structure(XLSX)
analyze(XLSX)
```
---
## 부록 B. 미해결 / 확인 필요
**사용자에게 확인해야 할 것**
- [ ] 이 xlsx 를 만든 **수집 스크립트가 어디에 있는가?** 있다면 재사용·개선의 출발점이 된다.
- [ ] **`serviceKey` 를 이미 발급받았는가?** 프로토타입이 API 수집에 성공했으므로 키가 존재할 것이다. 어디에 저장돼 있는가.
- [ ] 이 파일이 카카오톡으로 전달된 경위 — 본인이 만든 것인가, 다른 사람이 만든 것인가.
- [ ] `신규` 시트가 비어 있는데, 기대하는 동작이 "당일 신규만" 인가 "최근 N일" 인가.
- [ ] `갱신이력` 시트에 추가로 기록하고 싶은 항목이 있는가 (변경건수, 취하건수, 소요시간, 오류).
**데이터로 확인해야 할 것**
- [ ] **API 응답 건수 9,084 가 실제로 정상 건만인지** 연속 2일 이상 수집해 소멸 레코드를 관찰
- [ ] 웹 9,840 API 9,084 = 756건이 정말 취하·취소 건인지 표본 대조
- [ ] 연차보고가 일어날 때 `발급일자`가 갱신되는가 (1~2월에 관측)
- [ ] 변경 시 등록번호의 괄호 부분이 실제로 바뀌는가, 아니면 번호는 그대로이고 발급일자만 바뀌는가
- [ ] `numOfRows` 최대값과 전량 수집에 필요한 호출 횟수
- [ ] 제조국가명 결측 5건의 등록번호와 원인
- [ ] "기타" 포맷 455건의 하위 패턴 분류
**설계에 반영해야 할 것**
- [ ] `research/01` 의 "API 로는 변경·취하 탐지 불가" 결론을 이 분석 결과로 정정
- [ ] `research/07` 의 시트 컬럼 스펙을 API 7필드 + 파생 필드 기준으로 재작성
- [ ] 국가 코드 매핑 테이블 작성 (49개국, 비표준 표기 포함)
---
*이 문서는 기준선 사실의 정본이다. 데이터가 갱신되면 부록 A 스크립트로 재분석하고 수치를 갱신한다.*

File diff suppressed because it is too large Load diff

2894
docs/design/02-data-model.md Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,433 @@
# DMF Crawler TDD · E2E RED · 디자인 감사 운영 체계
> 이 문서는 DMF Crawler 의 **TDD/RED 운영 SSOT** 다.
> 이론 근거는 `docs/research/11-tdd-red-and-design-audit-theory.md` 를 따른다.
**상태**: v1 확정
**적용 시점**: 이 문서가 생성된 뒤의 모든 코드·GUI·리포트·에이전트 작업
**핵심 명령**: **테스트 없이 새 코드를 쓰지 않는다. RED 실패를 먼저 본다.**
---
## 1. 법칙
### 1.1 신성한 루프
모든 구현 작업은 다음 순서를 따른다.
1. **RED** — 실패하는 자동화 테스트를 먼저 만든다.
2. **RED 확인** — 그 테스트가 실제로 실패하며, 실패 이유가 기대한 요구 위반인지 확인한다.
3. **GREEN** — 통과에 필요한 최소 구현만 한다.
4. **REFACTOR** — 전체 관련 테스트가 Green 인 상태에서만 구조·중복·이름·디자인을 정리한다.
5. **REGRESSION** — 영향 테스트 + 전체 smoke 를 돌리고 결과를 기록한다.
### 1.2 금지
- 테스트가 없는 `src/` 기능 구현 금지.
- 실패를 보지 않은 테스트를 “RED” 라고 부르기 금지.
- 테스트를 약하게 바꿔 Green 만들기 금지.
- `pytest.skip`, `xfail`, 느슨한 `except Exception: pass`, `assert True` 류로 실패 숨기기 금지.
- 실패 중에 리팩터링 금지.
- 의미 없는 테스트를 무한히 추가하기 금지.
- 디자인 감사를 “눈으로 대충 봄”으로 대체 금지.
### 1.3 예외
다음은 테스트 선행이 어려울 수 있다. 그래도 문서화가 필요하다.
| 예외 | 허용 조건 | 대체 증거 |
|---|---|---|
| 문서만 수정 | 코드 동작이 변하지 않음 | 링크/목차/SSOT 일관성 검사 또는 변경 요약 |
| 설치 스크립트/작업 스케줄러 | 실제 Windows 권한이 필요 | dry-run/PowerShell parser/ASCII/BOM 검사 + 수동 프로브 로그 |
| 실제 공공 사이트 접근 | 사용자 승인 필요 | fixture/mock 서버 E2E + 수동 실행 체크리스트 |
| 순수 스타일 문구 | 자동화 어려움 | 디자인 RED 체크리스트와 스크린샷/수기 판정 기록 |
예외를 남길 때는 “왜 자동화 못 했는가 / 어떤 수동 증거로 대체했는가 / 나중에 자동화할 조건”을 적는다.
---
## 2. RED 계층 구조
### R0 — 계약/부팅 RED
목표: 프로젝트가 import/CLI/패키지 수준에서 깨지지 않음을 보장한다.
필수 검사:
```powershell
.\.venv\Scripts\python.exe -m compileall -q src tests
.\.venv\Scripts\python.exe - <<'PY'
import importlib, pathlib
for p in pathlib.Path('src/dmf_crawler').rglob('*.py'):
if p.name == '__init__.py':
continue
importlib.import_module('.'.join(p.with_suffix('').relative_to('src').parts))
PY
.\.venv\Scripts\python.exe -m dmf_crawler --help
.\.venv\Scripts\python.exe -m dmf_crawler doctor --json
```
RED 예시:
- `cli.py` 가 없으면 `python -m dmf_crawler --help` 실패.
- `storage/repo.py` 가 없으면 `pipeline.run_once()` 런타임 실패.
- `.ps1` 에 BOM 이 없으면 PowerShell 5.1 한글 깨짐 위험.
### R1 — 도메인 단위 RED
대상:
- `normalize.py`
- `diff.py`
- `integrity.py`
- `source_mfds.py` 파서
- `agy/extract.py` JSON 추출
원칙:
- 네트워크 없음.
- DB 없음.
- clock/random 고정.
- fixture 로 실패 재현.
대표 RED:
| 위험 | 테스트 |
|---|---|
| 등록번호 파서 회귀 | 표준/신물질/허여서/깨진 입력 10종 |
| openpyxl 함정 재발 | nedrug xlsx 는 sheet XML 직접 파싱 요구 문서/테스트 |
| API 오류 봉투 2종 | JSON 정상 봉투 + XML/JSON Gateway 오류 봉투 |
| 조용한 빈 파일/빈 응답 | 200 OK + 헤더만/0건은 무결성 게이트 차단 |
| 발급일자 변경 신호 | permit_date 변경 시 CHANGED |
| 취하 오탐 | totalCount 급감/전건 취하율 임계 초과 시 diff 폐기 |
### R2 — 저장소/통합 RED
대상:
- `storage/db.py`
- `storage/repo.py`
- migrations
- `pipeline.py`
필수 RED:
- 새 DB 에 migrations 0001~0003 적용.
- 중복 적용 시 checksum 검증 후 무변화.
- `start_run` → checkpoint → snapshot → events → report data 조회.
- 같은 날짜 SUCCESS 중복 방지.
- 기준선 첫 실행은 이벤트 0건.
- 스냅샷 저장 전 무결성 실패면 DB 오염 없음.
### R3 — 파이프라인 E2E RED
목표: 사람이 실제로 쓰는 흐름을 fixture 로 끝까지 검증한다.
시나리오:
1. **첫 설치/키 없음**
- `doctor` 가 API 키 없음 WARN 을 낸다. 인증키는 선택 기능이다.
- `source.mode=auto` 는 API 키가 없으면 AGY headful Chrome CCBAC03 xlsx 수집을 시도한다.
- `source.mode=api` 에서 키가 없거나 headful 수집이 실패하면 BLOCKED 가 아니라 마지막 성공 자료 PARTIAL 리포트로 폴백한다.
2. **첫 수집 기준선**
- fixture API 3건 → DB snapshot 3건.
- diff 이벤트 0건.
- 대시보드 배너 “기준선 수립일”.
3. **둘째 날 변경**
- 신규 1 / 변경 1 / 취하 1 fixture.
- events 3건, report changes 3행.
4. **무결성 차단**
- 200 OK 이지만 0건.
- diff/persist 스킵.
- 마지막 성공 자료로 PARTIAL 리포트.
5. **리포트 파일 잠김**
- 대상 xlsx 잠김 또는 `~$` 파일 존재.
- `_HHMMSS` 폴백 이름 사용.
- 최신본 갱신 실패는 WARN 으로 기록.
6. **AI 실패 독립성**
- agy timeout/schema fail.
- 리포트는 생성되고 AI 없음 배너만 표시.
### R4 — xlsx 디자인 감사 RED
DMF Crawler 의 가장 중요한 사용자 화면은 Excel 리포트다. xlsx 는 zip/XML 이므로 stdlib 로 검사한다.
필수 감사:
| 항목 | RED 조건 |
|---|---|
| 파일 구조 | `[Content_Types].xml`, `xl/workbook.xml`, worksheets, styles 존재 |
| 시트 순서 | 대시보드가 첫 시트, 메타가 마지막, 워치리스트는 조건부 |
| 시트명 | `00_대시보드` 등 SSOT 명칭과 1:1 |
| 탭 색 | `report/theme.py``TAB_COLORS` 반영 |
| 대시보드 viewport | zoom 90, 행/열 폭 합이 1366×768 무스크롤 목표 초과 금지 |
| 틀 고정 | 변경분/전체현황/집계 시트에 freeze panes |
| 자동필터/표 | 데이터 표가 ListObject 또는 autofilter 를 가짐 |
| 내부 링크 | 대시보드 내비, 변경분→전체현황 링크 존재 |
| 조건부서식 | 신규/변경/취하/워치/게이트 실패 서식 존재 |
| 대비 | 팔레트 상태 배경/글자 contrast 최소 AA, 핵심 상태 AAA 목표 |
| 색 단독 금지 | 신규/변경/취하가 라벨+기호+색으로 표현 |
| 긴 텍스트 | 한글 긴 성분명/제조소/소재지 fixture 가 열폭 255 같은 폭주를 만들지 않음 |
| 빈 상태 | 변경 0건, 워치리스트 없음, AI 없음이 깨진 표/빈 차트로 보이지 않음 |
| 출처 | 식약처/공공데이터포털 출처 문구가 메타/푸터에 존재 |
### R5 — GUI 디자인·UX 감사 RED
대상: `tkinter` 온보딩/복구 GUI.
자동화 전략:
- 실제 창을 띄우되 가능하면 withdraw/offscreen 으로 둔다.
- 위젯 트리(`winfo_children`)를 검사한다.
- `Entry` 에 실제 텍스트를 넣고 버튼 command 를 호출한다.
- 텍스트 길이를 늘려 레이아웃 clipping 을 검사한다.
- geometry propagation 이후 `winfo_reqwidth/height` 와 screen/컨테이너 크기를 비교한다.
필수 감사 항목:
| 범주 | 실패 조건 |
|---|---|
| 끔찍한 중앙 정렬 | 폼 라벨/입력/버튼이 의미 없이 전체 중앙에 뭉쳐 스캔 불가 |
| 시각적 치우침 | 주요 column/버튼 그룹의 x/y 정렬선이 크게 어긋남 |
| 폰트 | 본문 10pt 미만, 대비 낮은 muted text, 제목/본문 hierarchy 없음 |
| overflow | 긴 한글/영문/API 키/경로가 컨테이너 밖으로 나가거나 잘림 |
| input label | 모든 입력에 visible label 또는 설명 텍스트 없음 |
| input padding | 입력 내부 텍스트와 경계/버튼 간격이 너무 좁음 |
| 실제 입력 | API 키/웹훅/워치리스트 텍스트를 넣어도 깨지지 않음 |
| 버튼 클릭 | [키 입력], [다시 검사], [지금 실행], [로그 열기] command 가 연결됨 |
| 복합 시나리오 | 키 없음→입력→검증 실패→오류 표시→재입력 흐름이 끊기지 않음 |
| 아이콘/텍스트 | 아이콘만 있는 버튼에 텍스트/툴팁 없음, vertical align 어긋남 |
| focus | Tab 순서가 논리적이고 기본 포커스가 위험 버튼에 가지 않음 |
| 모달 | 닫기/나중에/복구 행동이 명확하고 ESC/창닫기 처리됨 |
| 자연스러운 한국어 안내 | 온보딩 상단 안내가 `무엇:/왜:/어떻게:/다음:` 같은 AI식 라벨을 노출하지 않고, 현재 막힌 항목과 누를 행동을 1~3개의 자연스러운 문장으로 설명 |
### R6 — 브라우저 수집 E2E RED
브라우저 수집은 수집용 AGY가 visible/headful Chrome 을 조작하는 경로다. 요약용 AGY와 혼동하지 않는다.
- [ ] 전용 Chrome 프로필 Preferences 에 다운로드 경로가 고정된다.
- [ ] CDP 포트 소유권 sentinel 을 확인하지 못하면 중단한다.
- [x] `.tmp`/`.crdownload` 가 사라진 뒤 최종 xlsx 만 집는다. (`test_headful_browser_collect_rejects_incomplete_downloads`)
- [x] 다운로드 파일이 최소 크기/zip 구조/행 수 임계 검증을 통과한다. (`test_headful_browser_collect_validates_download_and_returns_raw_records`)
- [x] CCBAC03 웹 xlsx 헤더 alias(`신청인`, `제조국가`, `최초등록일자`, `최종변경일자`)를 RawRecord 표준 필드로 매핑한다. (`test_headful_browser_accepts_public_ccbac03_xlsx_headers`)
- [x] 로그인 상태가 아니면 “세션 만료”로 감지하고 비밀번호를 agy prompt 로 보내지 않는다. (`test_headful_browser_prompt_contract_forbids_headless_http_and_secrets`)
- [x] `mcp`/`execute_url` permission auto-deny 를 빈 JSON 오류로 뭉개지 않고 원인별로 보고한다. (`test_headful_browser_collect_reports_mcp_permission_denial_from_stderr`, `test_headful_browser_collect_reports_execute_url_permission_denial_from_stderr`)
- [x] AGY 권한은 `mcp(chrome-devtools/*)`, `execute_url(nedrug.mfds.go.kr)`만 좁게 추가한다. `mcp(*)`, `execute_url(*)`, `--dangerously-skip-permissions` 금지. (`test_headful_mcp_allow_rule_is_scoped_and_does_not_use_dangerous_skip`)
- [x] 프롬프트는 ambiguous 버튼 클릭 대신 visible Chrome 에서 `/pbp/CCBAC03/getExcel` 을 열고 wrong export 를 guard 한다. (`test_headful_browser_prompt_blocks_wrong_review_result_export`)
- [x] `source.mode=auto` + API 키 없음은 AGY headful browser 로 라우팅한다. (`test_source_auto_without_api_key_routes_to_headful_browser`)
- [x] `source.mode=browser` 는 API 키가 있어도 AGY headful browser 를 강제한다. (`test_source_browser_mode_routes_to_headful_even_when_api_key_exists`)
- [x] 실제 nedrug 접근은 기본 테스트가 아니라 사용 승인 수동 smoke 로 분리한다. 2026-09-03 smoke `run_20260903_130332`: fetch/normalize/persist/report SUCCESS, 9,816건.
### R7 — 에이전트 지침/정책 RED
`AGENTS.md`, `CLAUDE.md`, 이 문서가 서로 어긋나면 agent 가 나쁜 습관으로 돌아간다.
필수 검사 후보:
- `AGENTS.md` 가 이 문서를 링크한다.
- “No code without RED” 규칙이 존재한다.
- “테스트 약화 금지”가 존재한다.
- “디자인 감사 RED” 체크리스트가 존재한다.
- “실패를 숨기지 말 것”이 존재한다.
- `CLAUDE.md` 는 중복 SSOT 가 아니라 `AGENTS.md`/이 문서 포인터다.
---
## 3. RED 작성 품질 기준
새 RED 는 아래 8문항을 통과해야 한다.
1. 어떤 사용자/운영 위험을 막는가?
2. 어느 요구사항/설계 문서를 근거로 하는가?
3. 실패했을 때 메시지가 원인을 좁혀 주는가?
4. 현재 코드에서 실제로 실패하는가?
5. 실패 이유가 기대한 이유인가?
6. network/clock/random/shared DB 에 의존하지 않는가?
7. 같은 위험을 이미 더 강한 테스트가 덮고 있지 않은가?
8. Green 후에도 리팩터링을 방해하지 않는 행동 중심 테스트인가?
통과하지 못하면 RED 가 아니라 메모다.
---
## 4. RED 통폐합·삭제 기준
TDD 는 계속 RED 를 늘리는 행위가 아니다. 테스트 스위트도 제품이다.
### 4.1 통합해야 하는 경우
- 같은 fixture 로 같은 실패를 여러 테스트가 반복한다.
- 단위 테스트와 E2E 가 같은 단순 분기만 검사한다.
- 실패하면 원인이 항상 같은 내부 구현 세부다.
처리:
- 가장 사용자 의미가 강한 테스트 하나를 남긴다.
- 나머지는 helper/fixture 로 합친다.
- 커밋/작업 로그에 “어떤 위험이 어디로 이동했는지” 기록한다.
### 4.2 삭제해야 하는 경우
- 구현 세부를 강제해 좋은 리팩터링을 막는다.
- flaky 해서 신뢰를 깎는다.
- 요구사항이 사라졌다.
- 더 강한 상위 테스트가 같은 결함을 잡는다.
삭제 금지:
- 단지 Green 을 만들기 어렵다는 이유.
- 실행 시간이 길다는 이유만으로 삭제. 먼저 계층 이동/fixture 축소/impact run 을 시도한다.
### 4.3 완화해야 하는 경우
디자인 임계값(예: 폭/대비/간격)은 완화 가능하지만, 다음이 있어야 한다.
- 실패 스크린샷 또는 xlsx XML 증거.
- 사용자 유즈케이스에서 문제가 되지 않는 이유.
- 완화 후에도 잡히는 결함 목록.
---
## 5. 작업 절차 — 에이전트 필수 프로토콜
### 5.1 시작 전
1. `docs/HANDOFF.md` 를 읽는다.
2. 이 문서와 관련 설계 문서를 읽는다.
3. 변경할 파일 목록을 정한다.
4. 영향 테스트 목록을 쓴다.
5. 아직 테스트가 없으면 먼저 RED 를 작성한다.
### 5.2 구현 중
1. RED 를 실행해 실패 로그를 확인한다.
2. 최소 구현으로 Green 을 만든다.
3. 관련 테스트를 다시 실행한다.
4. Green 상태에서만 리팩터링한다.
5. 전체 smoke 를 돌린다.
### 5.3 보고
보고에는 최소 다음을 포함한다.
- 추가/수정한 RED.
- RED 실패가 기대한 이유였는지.
- Green 을 위해 바꾼 파일.
- 실행한 명령과 결과.
- 남은 실패/미검증 항목.
- 디자인 감사 결과(해당 시).
---
## 6. 영향 테스트 지도 v1
| 변경 파일 | 먼저 돌릴 테스트 | 그 다음 |
|---|---|---|
| `models.py` | 전체 import, dataclass contract tests | 전체 pytest |
| `normalize.py` | `tests/test_normalize.py` | diff/integrity/report data |
| `diff.py` | diff unit tests | pipeline scenario |
| `integrity.py` | integrity gate tests | pipeline stale fallback |
| `source_mfds.py` | API fixture parser tests | fetch E2E mock |
| `storage/db.py` | migration tests | repo/pipeline/report data |
| `storage/repo.py` | repo roundtrip tests | pipeline/report-only |
| `pipeline.py` | pipeline fixture E2E | alerts/watchdog/report |
| `agy/*` | polluted JSON/schema/budget tests | pipeline AI failure independence |
| `report/data.py` | SQL fixture report data tests | xlsx design audit |
| `report/*sheets*` | xlsx XML/visual audit tests | report-only smoke |
| `gui/*` | tkinter structure/input/button tests | onboard smoke |
| `notify/*`, `alerts.py` | template 4요소/DB state tests | notify-pump once |
| `scripts/*.ps1` | BOM/parser tests | 수동 Windows registration probe |
| `AGENTS.md`, `CLAUDE.md` | agent policy contract tests | docs link check |
---
## 7. 현재 프로젝트에 즉시 필요한 RED backlog
이전 구현 워크플로우가 중간에 끊겨 있으므로, 아래 RED 부터 만든다.
### 7.1 R0 계약 RED
- [x] `test_all_modules_import` — 현재 이름: `test_all_expected_runtime_entry_modules_import` (`tests/test_project_contracts.py`)
- [x] `test_cli_help_exits_zero` (`tests/test_project_contracts.py`)
- [x] `test_doctor_json_never_crashes_without_key` (`tests/test_project_contracts.py`)
- [x] `test_powershell_scripts_have_bom` — 현재 이름: `test_powershell_scripts_have_utf8_bom` (`tests/test_project_contracts.py`)
- [x] `test_cmd_files_are_ascii_only` (`tests/test_project_contracts.py`)
### 7.2 R2 저장소 RED
- [x] `test_migrations_apply_to_empty_db` — 현재 이름: `test_migrations_apply_to_empty_db_and_are_checksum_idempotent` (`tests/test_storage_repo.py`)
- [x] `test_repo_baseline_roundtrip` — 현재 이름: `test_repo_baseline_roundtrip_creates_snapshot_without_events` (`tests/test_storage_repo.py`)
- [x] `test_repo_changed_withdrawn_events_roundtrip` — 현재 이름: `test_repo_changed_new_withdrawn_events_roundtrip` (`tests/test_storage_repo.py`)
- [x] `test_same_day_success_is_idempotent` — 현재 이름: `test_same_day_success_is_enforced_by_database_guard` (`tests/test_storage_repo.py`)
### 7.3 R3 파이프라인 RED
- [x] `test_run_without_service_key_uses_existing_baseline_instead_of_blocking`
- [ ] `test_first_success_run_creates_baseline_report`
- [ ] `test_empty_success_body_blocks_diff_and_preserves_previous_snapshot`
- [ ] `test_agy_failure_does_not_block_report`
### 7.4 R4 xlsx 디자인 RED
- [x] `test_report_sheet_order_and_names``tests/test_report_e2e_design.py` 의 seeded DB → 실제 xlsx XML 감사가 검사한다.
- [ ] `test_dashboard_has_no_scroll_viewport_budget`
- [x] `test_report_has_freeze_panes_autofilter_internal_links``tests/test_report_e2e_design.py`
- [x] `test_report_status_styles_are_not_color_only``tests/test_report_e2e_design.py` 의 상태 라벨/기호/조건부서식 감사가 검사한다.
- [ ] `test_report_palette_contrast_ratios`
- [x] `test_long_korean_values_do_not_create_extreme_column_widths``tests/test_report_e2e_design.py`
### 7.5 R5 GUI 디자인 RED
- [ ] `test_onboard_all_inputs_have_visible_labels`
- [ ] `test_onboard_can_type_api_key_and_trigger_save_action`
- [ ] `test_recover_message_explains_problem_and_action_in_natural_korean`
- [ ] `test_main_actions_have_specific_button_labels`
- [ ] `test_long_korean_error_text_does_not_exceed_window_budget`
- [ ] `test_tab_order_reaches_primary_actions_before_secondary_actions`
- [x] `test_onboarding_footer_buttons_render_visible_text_after_refresh` — 사용자 스크린샷 `C:\Users\encep\AppData\Local\Temp\pi-clipboard-5df73a57-a813-41be-b025-819e48bd73c2.png` 회귀. 하단 버튼이 빈 버튼처럼 보이면 실패해야 한다. (`tests/test_ux_regressions.py`)
- [x] `test_onboarding_footer_buttons_keep_text_when_required_items_exist` — 필수/권장 조치가 있는 상태에서도 footer 버튼 텍스트가 짜부라지거나 사라지면 실패해야 한다. (`tests/test_ux_regressions.py`)
- [x] `test_onboarding_row_action_buttons_are_not_clipped_inside_scroll_view` — 행별 액션 버튼이 스크롤 영역/창 하단에 잘리면 실패해야 한다. (`tests/test_ux_regressions.py`)
- [x] `test_onboarding_new_grouped_layout_keeps_primary_actions_visible` — 새 그룹 레이아웃에서 주요 버튼이 아예 안 보이면 실패해야 한다. (`tests/test_ux_regressions.py`)
- [x] `test_scroll_frame_mousewheel_scrolls_when_pointer_is_over_content` — scroll canvas 안의 실제 텍스트/카드 위에서 휠을 굴려도 스크롤되어야 한다. (`tests/test_ux_regressions.py`)
- [x] `test_onboarding_status_panel_names_blockers_instead_of_generic_yellow_info_box` — 상단 안내문은 `무엇:/왜:/어떻게:/다음:` 같은 AI식 라벨이 아니라 자연스러운 한국어 문장이어야 한다. (`tests/test_ux_regressions.py`)
- [x] `test_agy_google_login_is_optional_when_ai_disabled_for_api_mode` — 공식 API만 쓰고 AI 요약이 꺼져 있으면 AGY/Google 로그인을 요구하지 않는다. (`tests/test_ux_regressions.py`)
- [x] `test_agy_is_prompted_for_headful_collection_when_auto_has_no_api_key` — auto+API 키 없음이면 AI 요약이 꺼져 있어도 최신 수집용 headful AGY 준비를 안내한다. (`tests/test_ux_regressions.py`)
- [x] `test_agy_is_prompted_for_forced_browser_mode_even_with_api_key` — browser 모드는 API 키가 있어도 수집용 headful AGY 준비를 안내한다. (`tests/test_ux_regressions.py`)
### 7.6 R6 브라우저 수집 RED
- [x] `test_headful_browser_prompt_contract_forbids_headless_http_and_secrets`
- [x] `test_headful_browser_collect_validates_download_and_returns_raw_records`
- [x] `test_headful_browser_collect_rejects_incomplete_downloads`
- [x] `test_headful_browser_collect_reports_mcp_permission_denial_from_stderr`
- [x] `test_headful_browser_collect_reports_execute_url_permission_denial_from_stderr`
- [x] `test_headful_mcp_allow_rule_is_scoped_and_does_not_use_dangerous_skip`
- [x] `test_headful_browser_prompt_blocks_wrong_review_result_export`
- [x] `test_headful_browser_accepts_public_ccbac03_xlsx_headers`
- [x] `test_source_auto_without_api_key_routes_to_headful_browser`
- [x] `test_source_browser_mode_routes_to_headful_even_when_api_key_exists`
- [ ] 전용 Chrome profile Preferences / CDP 포트 sentinel / 스케줄러 headful smoke RED
---
## 8. 이 문서와 다른 문서의 관계
| 문서 | 관계 |
|---|---|
| `docs/00-REQUIREMENTS.md` | 사용자 요구 SSOT. 이 문서는 테스트 운영 방법 SSOT. |
| `docs/design/01-architecture.md` | 아키텍처 SSOT. 이 문서는 검증 게이트를 추가한다. |
| `docs/design/03-xlsx-report-spec.md` | xlsx 디자인 명세 SSOT. R4 RED 는 이 문서를 실행 가능하게 만든다. |
| `docs/design/04-onboarding-wizard.md` | GUI 명세 SSOT. R5 RED 는 이 문서를 실행 가능하게 만든다. |
| `docs/research/11-tdd-red-and-design-audit-theory.md` | 이론/근거 아카이브. |
| `AGENTS.md` | AI agent 실무 지침. 이 문서를 압축해 적용한다. |
| `CLAUDE.md` | Claude Code 진입 포인터. 중복 정본이 아니다. |
---
## 9. 미해결 항목
- [x] `tests/test_project_contracts.py` 를 만들어 R0 계약 RED 를 실제 코드화한다.
- [x] xlsx XML 감사 RED 를 실제 코드화한다. 현재 파일: `tests/test_report_e2e_design.py`.
- [x] tkinter 구조/입력/버튼 감사 RED 를 실제 코드화한다. 현재 파일: `tests/test_gui_design_contracts.py`, `tests/test_ux_regressions.py`.
- [x] 브라우저 수집 경로 확정 후 R6 RED 를 코드화한다. 현재 파일: `tests/test_agy_headful_collection.py`.
- [ ] 영향 테스트 지도를 자동 생성/검증할지 결정한다(TDAD 방식의 단순 텍스트 맵부터 시작).