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,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 스크립트로 재분석하고 수치를 갱신한다.*