DMF_Crawler/docs/ops/03-api-usage-policy.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

1796 lines
116 KiB
Markdown
Raw 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.

# 공공데이터 Open API 이용 정책과 운영 제약 대응
> **이 문서의 역할**: 이 프로젝트가 쓰는 공공데이터포털 오픈API 의 **법적·계약적·기술적 제약을 확정**하고, 무인 배치가 그 제약에 부딪혔을 때 **무엇을 자동으로 하고 무엇을 사람에게 넘길지**를 코드 수준까지 규정한다. 데이터를 "어디서" 가져올지는 `design/00-DATA-SOURCE-DECISION.md` 가, "어떤 구조로" 처리할지는 `design/01-architecture.md` 가 정한다. 이 문서는 **"그 소스를 어떤 규칙으로 오래 쓸 것인가"** 를 정한다.
**작성일**: 2026-09-02
**상태**: ✅ 확정 (미검증 항목은 `⚠️ 미검증` 표기)
**상위 문서**: `design/00-DATA-SOURCE-DECISION.md` (데이터 소스 SSOT) · `design/01-architecture.md` (구조 SSOT) · `design/00b-baseline-data-analysis.md` (기준선 실측)
**관련 요구**: `docs/00-REQUIREMENTS.md` — N1(법적 안전성), N2(무인 운영), N7(보안), R1.4(수집 완결성), R6.3~R6.5(키 입력·검증), R7.1~R7.8(오류 알림)
---
## 0. 한눈에 보기
- **이 API 에는 사실상 "제약"이 없다.** 개발·운영 모두 자동승인이고, DCAT 메타의 `license``"이용허락범위 제한 없음"` 이며, 심사·서류·활용사례 등록이 **전부 불필요**하다. 걱정할 것은 이용 허락이 아니라 **인증키의 수명**이다.
- **트래픽 10,000 은 레코드 수가 아니라 하루 API 호출 건수**이고 매일 자정 00시에 초기화된다(공식 FAQ). 실측 `totalCount` **9,084건**(`design/00b-baseline-data-analysis.md`) 기준 하루 소요는 **약 15호출, 한도의 0.15%** 다. **운영계정 전환·활용사례 등록·트래픽 증량 신청은 영원히 필요 없다.**
- **진짜 시한폭탄은 두 개다.** ① 서비스키는 회원당 **단 1개**이고 재발급하면 기존 키가 **자동 폐기**된다(에러 30). ② 인증키에는 **사용 기한**이 있어 만료되면 에러 31 이 난다. 둘 다 사용자가 포털에서 무심코 한 행동으로 촉발되며, 배치는 조용히 죽는다. → §5 에서 이중 경보로 흡수한다.
- **오류 봉투는 두 종류다.** GW 레벨(HTTP 401/403 + `OpenAPI_ServiceResponse/cmmMsgHeader`)과 서비스 레벨(HTTP 200 + `response/header`). `type=json` 을 줘도 XML 이 올 수 있다. **JSON→XML 폴백 파서가 필수**이며, 분기는 `returnReasonCode` 가 아니라 **`errMsg` 문자열을 1차 키**로 한다.
- **모든 오류를 7개 조치 등급으로 환산한다**: `OK` / `EMPTY` / `RETRY` / `SLOW_DOWN` / `QUOTA` / `USER_ACTION` / `SCHEMA`. 등급이 **파이프라인 상태**(SUCCESS/PARTIAL/BLOCKED)와 **알림 등급**을 결정한다. 배치는 오류 코드를 사용자에게 절대 노출하지 않는다.
- **레이트 리밋은 아키텍처 §3.3 `http.py` 의 고정 정책을 그대로 따른다.** 요청 간 최소 간격 **0.7초 + 0~0.3초 지터**, 최대 **4회** 시도, 백오프 **base 5s · factor 2 · cap 300s · full jitter**, `Retry-After` **절대 우선**, 동시성 **1(직렬)**. 2025년 신설된 코드 23(초당 제한)·29(IP 차단)과 이용약관 제14조5항 때문에 병렬 호출은 **금지**한다.
- **출처 표시는 법적 의무가 확인되지 않았으나 무조건 넣는다.** 리포트 xlsx(XlsxWriter)와 문서에 들어갈 정확한 문구를 §7 에서 확정한다.
- **API 폐기·버전 상승 감지는 코드 12 관측이 유일한 실용 수단**이다. 여기에 응답 필드 집합을 매 실행 대조하는 **스키마 드리프트 게이트**를 `integrity.py` 의 여섯 번째 게이트로 추가한다(§8.4).
- **인증키는 `.env` 평문이 아니라 Windows DPAPI 로 암호화해 `%LOCALAPPDATA%\DMF_Crawler\service_key.bin` 에 저장한다**(아키텍처 §3.2 `secrets_dpapi.py`). 이는 `design/00-DATA-SOURCE-DECISION.md` §9 의 `.env` 안내를 **배포 경로에 한해 대체**한다(§1.4).
---
## 1. 목차와 전제
1. [전제 — 상위 문서와의 정합](#14-전제--상위-문서와의-정합)
2. [제약 사실 확정표](#2-제약-사실-확정표)
3. [활용신청·승인 절차 — 비개발자용 화면 단계별](#3-활용신청승인-절차--비개발자용-화면-단계별)
4. [트래픽 계산](#4-트래픽-계산)
5. [인증키 만료·폐기 대응 — 무인 운영의 시한폭탄](#5-인증키-만료폐기-대응--무인-운영의-시한폭탄)
6. [오류 코드 대응표](#6-오류-코드-대응표)
7. [출처 표시 문구 확정](#7-출처-표시-문구-확정)
8. [API 버전·중단 대응과 스키마 변경 감지](#8-api-버전중단-대응과-스키마-변경-감지)
9. [레이트 리밋 매너 — 확정 파라미터](#9-레이트-리밋-매너--확정-파라미터)
10. [부록 A. 출처](#부록-a-출처)
11. [부록 B. 미해결 / 실측 필요](#부록-b-미해결--실측-필요)
### 1.4 전제 — 상위 문서와의 정합
`design/01-architecture.md` 가 디렉터리 구조·모듈 계약·CLI·종료 코드의 **정본**이다. 이 문서는 그 안에서 **API 이용 정책에 해당하는 부분만** 채운다. 새 모듈·새 파일·새 서브커맨드를 만들지 않는다.
**이 문서가 다루는 정책이 사는 곳**
| 이 문서의 관심사 | 구현 위치 (아키텍처 정본) | 비고 |
|---|---|---|
| 인증키 저장·읽기·지문 | `src/dmf_crawler/secrets_dpapi.py``%LOCALAPPDATA%\DMF_Crawler\service_key.bin` | 아키텍처 §3.2 |
| HTTP 정중함·재시도·백오프 | `src/dmf_crawler/http.py``HttpClient` | 아키텍처 §3.3. §9 가 파라미터의 근거를 제공 |
| 전량 수집·페이지네이션·본문 시그니처 | `src/dmf_crawler/source_mfds.py``fetch_all()`, `parse_body()` | 아키텍처 §3.4 |
| **오류코드 → 조치등급 분류기** | `src/dmf_crawler/source_mfds.py` (같은 모듈 안) | §6.4. **새 모듈을 만들지 않는다** |
| 수집 완결성·스키마 드리프트 게이트 | `src/dmf_crawler/integrity.py` — 게이트 5종 + **게이트 6(신규 제안)** | 아키텍처 §3.6, 이 문서 §8.4 |
| 키 존재·실호출 검증·수명 경고 | `src/dmf_crawler/checks.py` — 체크 ④⑤ + **체크 ⑬(신규 제안)** | 아키텍처 §3.15, 이 문서 §5.5 |
| 알림 문구·등급·쿨다운 | `src/dmf_crawler/alerts.py`, `notify/messages.py` | 아키텍처 §3.14·§3.16. §5.7 이 문구를 제공 |
| 복구 GUI (키 재입력) | `src/dmf_crawler/gui/app.py`, `gui/steps.py``python -m dmf_crawler onboard --mode recover` | 아키텍처 §3.17 |
| 출처 표시 | `src/dmf_crawler/report/widgets.py`, `report/sheets/s99_meta.py` | 아키텍처 §3.13. **XlsxWriter** |
| 트래픽 예산 판정 | `src/dmf_crawler/checks.py` 체크 ⑭(신규 제안) + 문서 §4.4 참고 스크립트 | 상시 통과할 체크이므로 가벼움 |
| 키 수명 메타 | `state/key_meta.json` (런타임, 아키텍처 `state/` 규약) | 잠금 없이 읽는 가벼운 상태 파일 |
**이 문서가 아키텍처에 추가를 요청하는 것 — 3건**
| # | 요청 | 대상 | 이유 |
|---|---|---|---|
| A1 | `integrity.py`**게이트 6 — 스키마 드리프트** 추가 | 아키텍처 §3.6 | 필드가 조용히 바뀌면 게이트 1~5 를 전부 통과하면서 빈 값이 들어온다 (§8.4) |
| A2 | `checks.py`**체크 ⑬ — 인증키 수명 경고** 추가 | 아키텍처 §3.15 | 만료는 무인 운영을 깨는 1순위 원인인데 기존 12종에 없다 (§5.5) |
| A3 | `checks.py`**체크 ⑭ — 트래픽 예산** 추가 | 아키텍처 §3.15 | 상시 통과하지만, 통과 사실을 숫자로 보여주는 것이 "증량 신청이 필요한가"라는 질문을 영구히 닫는다 (§4.4) |
| A4 | `state/key_meta.json` 을 런타임 상태 파일 목록에 추가 | 아키텍처 §2 트리 `state/` | 키 지문·최초 성공일·만료일·경고 이력 (§5.3) |
**`design/00-DATA-SOURCE-DECISION.md` §9 와의 관계**
그 문서는 `.env``DATA_GO_KR_SERVICE_KEY` 에 **Decoding 키**를 넣으라고 안내한다. 요구 R6.3(다이얼로그 입력·안전 저장)과 N7(키를 로그·저장소·문서에 남기지 않음)이 추가된 지금, **배포본의 정본 저장소는 DPAPI 파일**이다.
| 우선순위 | 소스 | 용도 |
|---|---|---|
| 1 | `%LOCALAPPDATA%\DMF_Crawler\service_key.bin` (DPAPI, 현재 사용자) | **정본.** 비개발자 경로 |
| 2 | 환경변수 `DMF_SERVICE_KEY` | 임시 진단·테스트 (세션 한정) |
| 3 | `.env``DATA_GO_KR_SERVICE_KEY` | **개발자 전용 폴백.** 배포본에 포함하지 않음 |
`httpx``params=` 는 값을 자동 인코딩하므로 **Decoding 키가 전제**다(아키텍처 §8.1). 비개발자는 마이페이지에서 보이는 아무 키나 복사하므로, `secrets_dpapi.save_service_key()` 가 **저장 시점에 정규화**해 항상 Decoding 형태로 보관한다(§5.3). 그러면 `http.py` 이하는 이 문제를 영원히 신경 쓰지 않아도 된다.
> **개정 요청**: `design/00-DATA-SOURCE-DECISION.md` §9 6~7항에 "배포본은 DPAPI 저장소(`secrets_dpapi.py`)를 쓰고 `.env` 는 개발자 폴백이다. 어느 형태의 키를 넣어도 저장 시 Decoding 형태로 정규화된다 — `ops/03-api-usage-policy.md` §1.4" 를 추가할 것.
---
## 2. 제약 사실 확정표
### 2.1 확정표
| # | 항목 | 실제 제약 내용 (원문 근거) | 출처 URL | 이 프로젝트에 미치는 영향 | 대응 |
|---|---|---|---|---|---|
| C1 | **활용신청 심의** | 「심의유형: 개발단계 : 자동승인 / 운영단계 : 자동승인」 | https://www.data.go.kr/data/15057075/openapi.do | **없음.** 심사 대기가 없다 | 온보딩에서 "신청 버튼을 누르면 즉시 승인됩니다"로 안내 (§3) |
| C2 | **이용허락범위** | DCAT/schema.org 메타에 `"license":"이용허락범위 제한 없음"`. 상세페이지 표기도 동일 | https://www.data.go.kr/catalog/15057075/openapi.json | **없음.** 재가공·사내 배포에 제약 없음 | 리포트 배포에 별도 절차 불필요 |
| C3 | **비용** | 「비용부과유무: 무료」 | https://www.data.go.kr/data/15057075/openapi.do | 없음 | — |
| C4 | **일일 트래픽** | 「트래픽은 하나의 서비스키로 하루 동안 호출할 수 있는 **API 호출 건수**를 의미합니다. … 매일 자정(00시)에 초기화됩니다」. 개발계정 **10,000** | https://www.data.go.kr/bbs/faq/selectFaqList.do (FAQ_0000000000000208) | 하루 약 15호출 = 한도의 **0.15%**. 실질 무제한 | 예산 체크 상시화(§4.4). 코드 22 는 자정 이후 재예약 |
| C5 | **초당 호출 제한** | `LIMITED_NUMBER_OF_SERVICE_REQUESTS_PER_SECOND_EXCEEDS_ERROR` / **23** — 「짧은 시간에 많은 요청이 발생하여 초당 호출 허용량을 초과했습니다」 (2025-09-19 갱신 표에 신설) | https://www.data.go.kr/data/15057075/openapi.do | 병렬·고속 페이징이 곧 실패 | **동시성 1 + 요청 간 0.7초 최소 간격**(§9) |
| C6 | **IP 차단** | `BLACKLIST_IP_ACCESS_ERROR` / **29** — 「차단된 IP에서 호출한 요청입니다」 (2025-09-19 신설) | https://www.data.go.kr/data/15057075/openapi.do | 걸리면 코드로 복구 불가. 사람이 전화해야 함 | §9 파라미터 준수. 29 관측 시 **재시도 금지**·즉시 CRITICAL 알림 |
| C7 | **이용약관상 이용 제한** | 제14조5항 「제공기관은 특정 회원의 이용형태로 인해 제공기관의 업무에 지장을 초래하거나 제공시스템의 성능 저하 등의 문제가 발생할 경우 서비스 이용을 제한할 수 있습니다」 | https://www.data.go.kr/ugs/selectPortalPolicyView.do | 하루 15호출·직렬은 사정권 밖 | §9 파라미터를 규범으로 고정. 임의 상향 금지 |
| C8 | **🔴 서비스키 단일성** | 「회원 계정 단위로 서비스키가 **1개만** 발급되며 … 기존 키를 보유한 상태에서 새로 신청하여 키를 재발급하면 **기존 키는 자동 폐기**되므로」 | https://www.data.go.kr/bbs/faq/selectFaqList.do (FAQ_0000000000000171, 159) | **최우선 위험.** 사용자가 무관한 목적으로 재발급하면 다음 06:00 에 코드 30 으로 사망 | 키 지문 저장 + 코드 30 → 강제 복구 GUI "인증키가 바뀐 것 같습니다" (§5.4) |
| C9 | **🔴 인증키 사용 기한** | 「`DEADLINE_HAS_EXPIRED_ERROR` / 31 / API 인증키의 사용 기한이 만료되었습니다. 공공데이터포털에서 이용 기간을 확인하거나 갱신해 주세요」 | https://www.data.go.kr/data/15057075/openapi.do | **최우선 위험.** 언젠가 반드시 온다 | 이중 경보: 주=코드 31 관측, 보조=수명 카운터 D-30/D-7 (§5) |
| C10 | **활용기간의 길이** | 2차 출처 다수가 「승인일로부터 24개월」이라 서술. 1차 출처(신청 폼)는 비로그인 시 `/index.do` 로 리다이렉트되어 **원문 확인 실패** | https://beaver-sohyun.tistory.com/38 | 하드코딩하면 오경보 또는 무경보 | **24개월을 하드코딩하지 않는다.** 사용자 입력값 우선, 없으면 "가정치" 라벨을 붙여 보조 경보만 (§5.5) `⚠️ 미검증` |
| C11 | **활용사례 등록** | 「운영신청 시 개발된 어플리케이션에 대해 내용을 활용 사례로 등록해야 함」 — **운영계정 신청 시에만** | https://data.mfds.go.kr/cntnts/11 | **없음.** 개발계정으로 완결 | 운영계정 전환 자체를 비목표로 확정(§3.3) |
| C12 | **상업적 이용·재가공** | 「공공기관이 제공하는 … 오픈API는 「공공데이터법」에 따라 **상업적 이용이 원칙적으로 허용**됩니다」. 단 「사실적 내용을 위·변조 및 왜곡하여 사용하는 것까지 보장하는 것은 아닙니다」 | https://www.data.go.kr/bbs/faq/selectFaqList.do (FAQ 210, 186) | 사내 xlsx 배포에 법적 걸림돌 없음 | **정규화 컬럼 옆에 원본 컬럼을 병기**해 왜곡 아님을 구조로 증명 (§7.4) |
| C13 | **출처표시 의무** | 이 데이터셋에 **공공누리 배지가 없고**, `license` 는 공공누리 유형이 아니라 "이용허락범위 제한 없음". 공공누리 전 유형은 출처표시가 필수지만 이 API 는 해당하지 않음 | https://www.kogl.or.kr/info/license.do | 법적 의무 **미확인** | 의무 여부와 무관하게 **항상 표기**한다. 문구 확정은 §7 |
| C14 | **CORS** | 「공공데이터포털의 오픈API(https://apis.data.go.kr)는 **CORS 허용 상태**로 제공되고 있습니다」 | https://www.data.go.kr/bbs/faq/selectFaqList.do (FAQ_0000000000000211) | 브라우저 직접 호출이 기술적으로 가능 → 키 노출 유혹 | **금지.** 키는 DPAPI 로 로컬 보관, 호출은 `http.py` 만 (아키텍처 §3.3 "유일한 네트워크 창구") |
| C15 | **serviceKey 인코딩** | 요청변수 표 원문: 「인증키 / serviceKey / 100 / 필 / **인증키 (URL Encode)**」 | https://www.data.go.kr/data/15057075/openapi.do | `httpx``params=` 는 자동 인코딩하므로 Encoding 키를 넣으면 `%2B``%252B` 이중 인코딩 → 코드 30 | **저장 시점 정규화**로 원천 차단. `secrets_dpapi` 가 항상 Decoding 형태로 보관 (§5.3, §6.6) |
| C16 | **오류 봉투 이원화** | 실측: 잘못된 키 → HTTP **403** + `{"OpenAPI_ServiceResponse":{"cmmMsgHeader":{...}}}`, 키 누락 → HTTP **401** + XML 동일 구조. 정상·서비스 오류 → HTTP **200** + `response/header` | https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01 (실측) | 단일 봉투 가정 파서는 인증 오류에서 즉사 | `parse_body()` 에 JSON→XML 폴백 (§6.4) |
| C17 | **코드 20 의 다의성** | 코드 `20``SERVICE_KEY_IS_NULL`·`PERMISSION_DENIED`·`SERVICE_ACCESS_DENIED_ERROR` 가 모두 매핑. FAQ 214: 「해당 API 서비스를 신청하지 않았거나, 변경신청 등으로 인해 일시 중지된 경우」 | https://www.data.go.kr/bbs/faq/selectFaqList.do (FAQ_0000000000000214) | 코드로 분기하면 엉뚱한 안내를 띄움 | **`errMsg` 1차 키, 코드는 폴백** (§6.4) |
| C18 | **문의 응답 지연** | Q&A 안내문: 「각 기관담당자에게 … 이관된 경우에는 기관담당자 답변까지 **최대 10일** 정도 소요될 수 있습니다」 | https://www.data.go.kr/catalog/15057075/openapi.json (contactPoint) | 장애 시 외부 지원에 기댈 수 없음 | 배치는 자립적으로 실패를 견디고 설명해야 함 (§6.5) |
| C19 | **구버전 엔드포인트** | `http://data.mfds.go.kr/openapi/MdcDmfInfoService/getMdcDmfList` 는 생존하나 실측 응답이 HTTP 200 + `<resultCode>20</resultCode>` `SERVICE ACCESS DENIED ERROR!`**별도 활용신청 필요**, 봉투 구조도 다름 | https://www.data.go.kr/data/2095/linkedData.do | 드롭인 폴백 **불가** | 문서에 폴백 후보로만 기록. 코드 재사용 금지 (§8.5) |
| C20 | **갱신 주기** | 현행 데이터셋 페이지에 갱신주기 표기 없음. 연계데이터 2095 는 「수시」 | https://www.data.go.kr/data/2095/linkedData.do | 06:00 이 최적인지 불명 | `totalCount`·데이터 해시를 매일 기록해 2~4주 관측 후 재조정 (부록 B) |
| C21 | **API 는 정상 건만 반환한다** | 웹 화면 실측 **9,840건** vs API 실측 **9,084건****756건 차이** | `design/00b-baseline-data-analysis.md` §4 | 취하 판정이 "API 응답에서의 소멸"로 성립한다 (결정 D2) | 전량 수집 + 완결성 게이트가 취하 판정의 **전제조건**이 된다 (§6.7) |
### 2.2 이 표에서 나오는 단 하나의 결론
> **"제약"이라 불릴 만한 것은 이 API 에 거의 없다. 있는 것은 인증키의 수명과 단일성뿐이며, 그 둘은 코드가 아니라 GUI 로 해소해야 한다.**
트래픽·라이선스·심의·상업적 이용은 전부 통과다. 그러므로 이 문서의 분량 대부분(§5, §6)은 **인증키와 오류 처리**에 배정된다. 그것이 실제 위험이 있는 곳이다.
---
## 3. 활용신청·승인 절차 — 비개발자용 화면 단계별
> **원칙**: 사용자에게 "API 키"라는 말을 최소한만 쓰고 **"인증키"** 로 통일한다. URL 구조를 설명하지 않는다. 모든 단계에 **버튼**을 제공한다. 이 절의 문구는 `gui/steps.py` 의 `enter_api_key` 액션과 `notify/messages.py` 가 그대로 가져다 쓴다.
### 3.1 최초 발급 — 9단계
| # | 화면 | 사용자가 하는 일 | GUI 가 돕는 방법 | 걸리는 시간 |
|---|---|---|---|---|
| 1 | 공공데이터포털 메인 | 회원가입 (없으면) | [회원가입 페이지 열기] → `https://www.data.go.kr` | 3분 |
| 2 | 로그인 | 로그인 | 동일 버튼 | 30초 |
| 3 | 데이터셋 상세 | 페이지 진입 | [인증키 발급 페이지 열기] → `https://www.data.go.kr/data/15057075/openapi.do` | 즉시 |
| 4 | 상세 페이지 우측 상단 | **[활용신청]** 버튼 클릭 | 안내 문구로 위치 설명 | 즉시 |
| 5 | 활용신청 폼 — 활용목적 | 라디오에서 **"기타"** 또는 **"참고자료"** 선택, 목적란에 한 줄 입력 | 복사용 예시 문구 제공: `사내 원료의약품 등록(DMF) 현황 일일 모니터링 및 내부 보고서 작성` | 1분 |
| 6 | 활용신청 폼 — 상세기능정보 | `getMdcDmfList01` 체크 | "체크박스가 하나만 있습니다. 그것을 켜세요" | 즉시 |
| 7 | 활용신청 폼 — 라이선스 | 「이용허락범위 제한 없음」 동의 체크 → **[신청]** | — | 즉시 |
| 8 | **자동승인** | 아무것도 안 함 | "심사가 없습니다. 바로 승인됩니다" | **즉시** |
| 9 | 마이페이지 | **마이페이지 > 데이터 활용 > Open API > 활용신청 현황** 에서 인증키 복사 | [포털 마이페이지 열기] + "인증키 두 개(Encoding/Decoding) 중 **아무거나** 복사해 붙여넣으세요" | 1분 |
> **9단계의 핵심 UX 결정**: 포털은 같은 키를 `일반 인증키(Encoding)` 와 `일반 인증키(Decoding)` 두 형태로 보여준다. 개발자도 여기서 자주 틀린다. 우리 GUI 는 **"아무거나 복사하세요"** 라고 말한다. `secrets_dpapi.save_service_key()` 가 저장 시점에 정규화하기 때문이다(§5.3). 이 한 줄이 비개발자 온보딩의 가장 큰 실패 지점을 제거한다.
### 3.2 GUI 확정 문구 (최초 입력 화면)
```
처음 실행입니다. 공공데이터포털에서 발급받은 인증키가 필요합니다.
아직 없다면 아래 [인증키 발급 페이지 열기] 를 누르세요.
로그인한 뒤 '활용신청' 버튼을 누르면 심사 없이 바로 승인됩니다.
승인 후
마이페이지 > 데이터 활용 > Open API > 활용신청 현황
에서 인증키를 복사해 아래 칸에 붙여넣으세요.
인증키가 두 개(Encoding / Decoding) 보이더라도
아무거나 복사하시면 됩니다. 프로그램이 알아서 처리합니다.
```
포털 경로 문자열은 **공식 FAQ 두 곳(FAQ_0000000000000264, FAQ_0000000000000208)이 지정한 그대로** 쓴다. 프로젝트 전역에서 이 문자열은 하나여야 하므로 `config/config.toml` 이 아니라 **코드 상수**로 고정한다(설정으로 바꿀 값이 아니다).
```python
# src/dmf_crawler/source_mfds.py (발췌) — 사용자 대면 문자열 상수
PORTAL_MYPAGE_HINT = "마이페이지 > 데이터 활용 > Open API > 활용신청 현황"
PORTAL_HOME_URL = "https://www.data.go.kr"
PORTAL_DATASET_URL = "https://www.data.go.kr/data/15057075/openapi.do"
SUPPORT_CENTER = "공공데이터포털 활용지원센터(1566-0025, 평일 09~18시)"
```
### 3.3 개발계정 vs 운영계정
| 구분 | 개발계정 | 운영계정 |
|---|---|---|
| 신청 시점 | 활용신청 시 자동 부여 | 개발계정 사용 후 별도 신청 |
| 심의 | 자동승인 | 자동승인 (이 API 한정) |
| 일일 트래픽 | **10,000** | 증량 신청 가능 (기본값 ⚠️ 미검증, 2차 출처는 10만) |
| 전제조건 | 없음 | **활용사례 등록 필수** |
| 이 프로젝트 | ✅ **채택** | ❌ **영구 비채택** |
**언제 운영계정이 필요한가 — 판정 규칙**
운영계정은 다음이 **모두** 참일 때만 검토한다.
- [ ] 트래픽 예산 체크(§4.4)의 사용률이 **50%** 를 넘는다, 그리고
- [ ] `numOfRows` 를 실측 최대값까지 올렸는데도 넘는다, 그리고
- [ ] 하루 실행 횟수를 줄일 수 없다
§4 의 계산에 따르면 사용률은 **0.15%** 이고, DMF 등록 건수가 지금의 **약 300배(약 273만 건)** 가 되어야 50% 에 닿는다. 이 조건은 이 프로젝트의 수명 안에 성립하지 않는다. **운영계정 전환을 검토하지 않는다.**
### 3.4 연장·재발급 시의 절차 (기존 사용자)
| 상황 | 사용자가 갈 곳 | 결과 |
|---|---|---|
| 사용 기한 만료 임박/만료 (코드 31) | 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 → 해당 API → **[활용연장신청]** | 기간 연장. 키 문자열 유지 여부는 ⚠️ 미검증 |
| 키가 안 먹음 (코드 30) | 같은 화면에서 **현재 인증키를 다시 복사** | 재발급을 누르지 말고 **복사만** 하도록 안내 |
| 활용신청 상태 확인 (코드 20) | 같은 화면에서 상태 확인 | "승인완료"가 아니면 신청이 안 끝난 것 |
> **GUI 의 절대 금기**: "인증키 **재발급**" 버튼을 누르라고 안내하지 않는다. 재발급은 회원의 **모든** 오픈API 를 끊는다(C8). 우리 안내는 언제나 **"복사"** 또는 **"활용연장신청"** 이다.
---
## 4. 트래픽 계산
### 4.1 입력값
| 항목 | 값 | 근거 | 신뢰도 |
|---|---|---|---|
| 전체 DMF 건수 (`totalCount`) | **9,084건** | `design/00b-baseline-data-analysis.md` — 프로토타입이 API 로 받아 적재한 xlsx 실측 (2026-09-02). 고유 등록번호 9,084, 중복 0 | ✅ **실측** |
| 참고: 웹 화면 총건수 | 9,840건 | `research/01-dmf-domain-and-sources.md` §4.1. API 와 **756건 차이** = 취하·취소 건 (C21) | ✅ 실측 |
| `numOfRows` 최대값 | **999** (추정) | 명세상 항목크기 3자리 | ⚠️ 미검증 |
| 일일 실행 횟수 | **1회** (06:00) | `00-REQUIREMENTS.md` R5.1 | 확정 |
| totalCount 취득 호출 | **1회/실행** | 아키텍처 §3.4 — `numOfRows=1` 선행 호출 | 확정 |
| 재시도 여유율 | **1.3배 (30%)** | 일시 오류 대비 | 확정 |
| 일일 한도 | **10,000 호출** | 「개발계정 : 10,000」 | 확정 |
### 4.2 계산
```
페이지 수 = ceil(9,084 / 999) = 10
1회 수집 호출 = 10 + 1(totalCount 취득) = 11
하루 호출 = ceil(11 × 1회 × 1.3) = 15
사용률 = 15 / 10,000 = 0.15 %
남는 여유 = 10,000 15 = 9,985 호출
```
### 4.3 시나리오 표 — 최악을 가정해도 안전한가
| 시나리오 | `numOfRows` | 하루 실행 | 페이지 수 | 하루 호출(×1.3) | 사용률 | 판정 |
|---|---|---|---|---|---|---|
| **기준(확정)** | 999 | 1 | 10 | **15** | **0.15%** | ✅ |
| 999 가 안 먹혀 100 으로 후퇴 | 100 | 1 | 91 | 120 | 1.20% | ✅ |
| 100 + 하루 2회 (오후 재수집) | 100 | 2 | 91 | 240 | 2.40% | ✅ |
| 명세 샘플값 그대로 10 | 10 | 1 | 909 | 1,183 | 11.83% | ✅ |
| 10 + 하루 2회 | 10 | 2 | 909 | 2,366 | 23.66% | ⚠️ 안전마진(50%) 이내이나 낭비 |
| 건수 10배 성장(90,840) + 999 | 999 | 1 | 91 | 120 | 1.20% | ✅ |
| 건수 300배(2,725,200) + 999 | 999 | 1 | 2,728 | 3,548 | 35.48% | ⚠️ 여기서 처음 검토 대상 |
| 30일 백필을 하루에 (`backfill --rereport` 는 호출 0) | 999 | 30 | 10 | 429 | 4.29% | ✅ |
**결론**: **어떤 현실적 시나리오에서도 한도의 절반에 닿지 않는다.** 트래픽은 이 프로젝트의 제약이 아니다.
> **백필은 호출을 거의 쓰지 않는다.** 아키텍처 §5 의 `backfill` 은 `--reparse`(원문 아카이브 재파싱)·`--rediff`·`--rereport` 를 쓰므로 **네트워크를 타지 않는다.** `data/raw/<date>/page_%04d.json` 원문 보존이 트래픽 절약 장치이기도 하다는 뜻이다.
### 4.4 트래픽 예산 판정 (요청 A3 — `checks.py` 체크 ⑭)
상시 통과할 체크지만, 통과 사실을 **숫자로** 보여주는 것이 "증량 신청이 필요한가"라는 질문을 영구히 닫는다. `doctor --json` 출력과 리포트 `s99_meta` 시트에 함께 실린다.
```python
# src/dmf_crawler/checks.py (발췌) — 체크 ⑭ 트래픽 예산
#
# 확정 사실 (공공데이터포털 공식 FAQ_0000000000000208):
# * 트래픽 = '하나의 서비스키로 하루 동안 호출할 수 있는 API 호출 건수'.
# 레코드 수가 아니다.
# * 제한은 매일 자정(00시)에 초기화된다.
# * 이 데이터셋의 개발계정 한도는 10,000.
import math
from dataclasses import dataclass
from datetime import datetime, time as dtime, timedelta
DAILY_QUOTA_DEV = 10_000 # 데이터셋 상세페이지 '신청 가능 트래픽: 개발계정 : 10,000'
SAFETY_RATIO = 0.5 # 한도의 50% 를 넘으면 재검토
RETRY_FACTOR = 1.3 # 일시 오류로 인한 재호출 여유 30%
@dataclass(frozen=True, slots=True)
class Budget:
total_count: int
page_size: int
runs_per_day: int
calls_per_run: int
calls_per_day: int
quota: int = DAILY_QUOTA_DEV
@property
def usage_ratio(self) -> float:
return self.calls_per_day / self.quota if self.quota else float("inf")
@property
def headroom(self) -> int:
return self.quota - self.calls_per_day
@property
def needs_review(self) -> bool:
return self.calls_per_day > self.quota * SAFETY_RATIO
def detail(self) -> str:
return (f"전체 {self.total_count:,}건 / 페이지당 {self.page_size:,}건 "
f"→ 1회 {self.calls_per_run:,}호출 × {self.runs_per_day}회 "
f"= 하루 {self.calls_per_day:,}호출 "
f"(한도 {self.quota:,}, 사용률 {self.usage_ratio * 100:.2f}%, "
f"여유 {self.headroom:,})")
def fix_hint(self) -> str:
if not self.needs_review:
return ("여유가 충분합니다. 운영계정 전환도, 활용사례 등록도, "
"트래픽 증량 신청도 필요하지 않습니다.")
return ("한도의 50%를 넘습니다. ① numOfRows 상향 ② 하루 실행 횟수 축소 "
"순으로 조정하고, 그래도 넘으면 운영계정 전환을 검토하세요.")
def plan_budget(total_count: int, page_size: int, runs_per_day: int = 1) -> Budget:
"""수집 계획의 호출 예산을 계산한다.
calls_per_run = ceil(totalCount / numOfRows) + 1(totalCount 취득 선행 호출)
"""
page_size = max(1, page_size)
pages = max(1, math.ceil(total_count / page_size))
calls_per_run = pages + 1
return Budget(
total_count=total_count,
page_size=page_size,
runs_per_day=runs_per_day,
calls_per_run=calls_per_run,
calls_per_day=int(math.ceil(calls_per_run * runs_per_day * RETRY_FACTOR)),
)
def next_quota_reset(now: datetime | None = None) -> datetime:
"""트래픽이 초기화되는 다음 자정(00시) + 5분 여유.
공식 FAQ: '해당 제한은 매일 자정(00시)에 초기화됩니다.'
코드 22(QUOTA)를 만나면 이 시각 이후로 재시도를 예약한다.
"""
now = now or datetime.now()
return datetime.combine((now + timedelta(days=1)).date(), dtime(0, 5))
def seconds_until_reset(now: datetime | None = None) -> int:
now = now or datetime.now()
return max(0, int((next_quota_reset(now) - now).total_seconds()))
def check_quota_budget(cfg, last_total_count: int | None) -> "CheckResult":
"""체크 ⑭. 마지막 성공 실행의 totalCount 를 근거로 예산을 판정한다."""
total = last_total_count if last_total_count else 9_084 # 기준선 실측값
budget = plan_budget(
total_count=total,
page_size=cfg.source.page_size,
runs_per_day=cfg.source.runs_per_day,
)
return CheckResult(
key="quota_budget",
title="일일 트래픽 예산",
ok=not budget.needs_review,
detail=budget.detail(),
fix_hint=budget.fix_hint(),
fix_action=None,
severity=Severity.WARN if budget.needs_review else Severity.INFO,
)
```
### 4.5 여유가 부족해질 경우의 대응 순서 (사전 확정)
| 순위 | 대응 | 비용 | 효과 |
|---|---|---|---|
| 1 | `numOfRows` 를 실측 최대값까지 상향 (`config.toml``page_size`) | 없음 | 호출 수 선형 감소 |
| 2 | 하루 실행 횟수를 1회로 고정 | 없음 | 배수 감소 |
| 3 | `totalCount` 선행 호출을 첫 페이지 응답의 `totalCount` 로 대체 | 진단 정보 소폭 감소 | 11→10 |
| 4 | 증분 수집 — `entp_name` / `ingr_kor_name` 로 부분 조회 | **취하 판정 불가**해짐 | ❌ **채택 불가** |
| 5 | 운영계정 전환 + 활용사례 등록 후 증량 신청 | 사람 작업 + 사례 공개 | 최후 수단 |
> **4번(증분 수집)은 트래픽 절감 수단으로 채택하지 않는다.** 전량 스냅샷을 받지 않으면 "어제 있던 키가 오늘 없다"를 판정할 수 없고, 그러면 **취하 탐지가 원리적으로 불가능**해진다(C21, 결정 D2). 트래픽은 남아도는데 핵심 기능을 버리는 거래는 성립하지 않는다.
---
## 5. 인증키 만료·폐기 대응 — 무인 운영의 시한폭탄
> **이 절이 이 문서에서 가장 중요하다.** 30일 무인 운영(N2)을 깨뜨리는 원인 1위는 네트워크도, 스키마 변경도 아니고 **인증키**다.
### 5.1 위협 모델 — 키는 세 가지 방식으로 죽는다
| # | 죽는 방식 | 촉발 원인 | 관측되는 오류 | 사용자가 원인을 아는가 |
|---|---|---|---|---|
| K1 | **재발급으로 폐기** | 사용자가 다른 목적(날씨 API 등)으로 포털에서 "일반 인증키 재발급"을 누름. 회원당 키는 1개이므로 기존 키 자동 폐기 (C8) | `SERVICE_KEY_IS_NOT_REGISTERED_ERROR` / **30** / HTTP 403 | ❌ **전혀 모른다.** 몇 주 전 행동의 결과다 |
| K2 | **기한 만료** | 활용기간 경과 (C9) | `DEADLINE_HAS_EXPIRED_ERROR` / **31** | ❌ 모른다 |
| K3 | **일시 중지 / 변경신청 중** | 사용자가 포털에서 활용신청 내용을 변경 중이거나 신청이 미완료 (C17) | `SERVICE_ACCESS_DENIED_ERROR` / **20**, `TEMPORARILY_DISABLE_THE_SERVICEKEY_ERROR` / **21** | ❌ 모른다 |
셋 다 공통점이 있다. **재시도해도 절대 낫지 않고, 사용자만 고칠 수 있으며, 사용자는 무엇이 깨졌는지 모른다.** 그래서 이 셋은 `USER_ACTION` 등급으로 묶여 파이프라인을 **BLOCKED(종료 코드 2)** 로 끝내고 복구 GUI 를 띄운다(R7.2).
### 5.2 이중 경보 체계
```
┌───────────────────────────────────────────────┐
주 경보 (확실) │ checks.py 체크 ⑤ (API 키 실호출 검증) │ → USER_ACTION
Primary │ → 코드 30 / 31 / 20 / 21 관측 │ BLOCKED(2) + 복구 GUI
│ = 이미 죽었다. 사후 탐지. │
└───────────────────────────────────────────────┘
┌───────────────────────────────────────────────┐
보조 경보 (예측) │ checks.py 체크 ⑬ (인증키 수명) │ → WARN 알림
Secondary │ → state/key_meta.json 의 카운터 │ D-30 / D-7 각 1회
│ = 아직 살아 있다. 사전 예방. │ 배치는 계속 진행
└───────────────────────────────────────────────┘
```
**주 경보가 진실이고 보조 경보는 편의다.** 보조 경보는 만료일을 모를 때 가정치로 돌아가므로 틀릴 수 있다(C10). 따라서 **보조 경보만으로 배치를 멈추지 않고, 주 경보만으로 배치를 멈춘다.**
### 5.3 인증키 저장소 — `secrets_dpapi.py` 구현 (완결 코드)
아키텍처 §3.2 가 정한 공개 API 4개(`save_service_key` / `load_service_key` / `delete_service_key` / `key_fingerprint`)를 이 문서가 정책까지 포함해 완성한다. 여기에 **키 수명 메타 갱신**(요청 A4)이 함께 들어간다.
```python
# -*- coding: utf-8 -*-
"""secrets_dpapi.py — 공공데이터포털 serviceKey 를 사용자 범위 DPAPI 로 보관한다.
설계 결정 (ops/03-api-usage-policy.md 1.4, 5.3):
* 저장 위치는 %LOCALAPPDATA%\\DMF_Crawler\\service_key.bin (아키텍처 3.2).
다른 사용자 계정이나 다른 PC 로 복사해도 복호화되지 않는다.
* 평문은 메모리에만 존재한다. 로그·예외 메시지·표준출력에 절대 넣지 않는다 (요구 N7).
* **저장 시점에 정규화**한다. 포털이 보여주는 Encoding 키든 Decoding 키든
항상 Decoding(원본) 형태로 보관하므로, httpx 의 params= 자동 인코딩과
정확히 한 번만 맞물린다. 이중 인코딩(코드 30)이 원천적으로 불가능해진다.
* 키가 바뀌면 state/key_meta.json 의 수명 카운터를 초기화한다.
새 키는 새 활용기간을 갖기 때문이다.
"""
from __future__ import annotations
import ctypes
import ctypes.wintypes as wintypes
import hashlib
import json
import os
import re
import sys
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
from . import paths
KEY_FILE = paths.local_app_dir() / "service_key.bin" # %LOCALAPPDATA%\DMF_Crawler\
META_FILE = paths.state_dir() / "key_meta.json" # <프로젝트>\state\
_PCT = re.compile(r"%[0-9A-Fa-f]{2}")
_CRYPTPROTECT_UI_FORBIDDEN = 0x01
_crypt32 = ctypes.WinDLL("crypt32.dll") if sys.platform == "win32" else None
_kernel32 = ctypes.WinDLL("kernel32.dll") if sys.platform == "win32" else None
# --- DPAPI 바인딩 ----------------------------------------------------------
class _DataBlob(ctypes.Structure):
_fields_ = [("cbData", wintypes.DWORD),
("pbData", ctypes.POINTER(ctypes.c_char))]
def _blob_in(data: bytes) -> _DataBlob:
buf = ctypes.create_string_buffer(data, len(data))
return _DataBlob(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
def _blob_out(blob: _DataBlob) -> bytes:
out = ctypes.string_at(blob.pbData, int(blob.cbData))
_kernel32.LocalFree(blob.pbData)
return out
def _protect(plaintext: bytes) -> bytes:
if _crypt32 is None:
raise RuntimeError("DPAPI 는 Windows 에서만 사용할 수 있습니다.")
src, dst = _blob_in(plaintext), _DataBlob()
if not _crypt32.CryptProtectData(ctypes.byref(src), None, None, None, None,
_CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref(dst)):
raise OSError(ctypes.get_last_error(), "CryptProtectData 실패")
return _blob_out(dst)
def _unprotect(ciphertext: bytes) -> bytes:
if _crypt32 is None:
raise RuntimeError("DPAPI 는 Windows 에서만 사용할 수 있습니다.")
src, dst = _blob_in(ciphertext), _DataBlob()
if not _crypt32.CryptUnprotectData(ctypes.byref(src), None, None, None, None,
_CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref(dst)):
raise OSError(ctypes.get_last_error(), "CryptUnprotectData 실패")
return _blob_out(dst)
# --- 정규화 — Encoding / Decoding 어느 쪽을 붙여넣어도 같은 결과 ----------
def normalize_service_key(key: str) -> str:
"""포털이 보여주는 두 형태 중 어느 쪽이 와도 '디코딩된 원본'으로 되돌린다.
Decoding : abc+def/ghi=
Encoding : abc%2Bdef%2Fghi%3D
비개발자는 둘 중 아무거나 복사한다. 공백·따옴표·개행도 함께 흡수한다.
멱등하다 — 이미 원본인 값을 다시 넣어도 변하지 않는다.
"""
k = key.strip().strip('"').strip("'").strip()
if _PCT.search(k):
decoded = urllib.parse.unquote(k)
if decoded != k:
return decoded
return k
# --- 공개 API (아키텍처 3.2 계약) ------------------------------------------
def save_service_key(plaintext: str) -> Path:
"""정규화 → DPAPI 암호화 → 원자적 교체. 저장 경로를 돌려준다."""
normalized = normalize_service_key(plaintext)
if not normalized:
raise ValueError("빈 인증키는 저장할 수 없습니다.")
KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = KEY_FILE.with_suffix(".tmp")
tmp.write_bytes(_protect(normalized.encode("utf-8")))
os.replace(tmp, KEY_FILE)
_on_key_saved(_fingerprint_of(normalized))
return KEY_FILE
def load_service_key() -> str | None:
"""우선순위: DPAPI 파일 → 환경변수 → .env(개발자 폴백). 실패는 예외 없이 None."""
if KEY_FILE.exists():
try:
return _unprotect(KEY_FILE.read_bytes()).decode("utf-8")
except OSError:
pass # 다른 계정에서 복사된 파일 등 — 미설정과 동일 취급
env_key = os.environ.get("DMF_SERVICE_KEY", "").strip()
if env_key:
return normalize_service_key(env_key)
dotenv = paths.project_root() / ".env"
if dotenv.exists():
for line in dotenv.read_text(encoding="utf-8", errors="replace").splitlines():
if line.strip().startswith("DATA_GO_KR_SERVICE_KEY="):
return normalize_service_key(line.split("=", 1)[1])
return None
def delete_service_key() -> None:
if KEY_FILE.exists():
KEY_FILE.unlink()
def key_fingerprint() -> str | None:
"""sha256 앞 8자. 로그·GUI 표시용이며 원문이 아니다 (아키텍처 3.2)."""
key = load_service_key()
return _fingerprint_of(key) if key else None
def _fingerprint_of(key: str) -> str:
return hashlib.sha256(normalize_service_key(key).encode("utf-8")).hexdigest()[:8]
# --- 키 수명 메타 (state/key_meta.json) ------------------------------------
def _utcnow() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def load_meta() -> dict:
if not META_FILE.exists():
return {}
try:
return json.loads(META_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
def save_meta(meta: dict) -> None:
META_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = META_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, META_FILE)
def _on_key_saved(fp: str) -> None:
"""키가 바뀌었으면 수명 카운터를 초기화한다. 새 키는 새 활용기간을 갖는다."""
meta = load_meta()
if meta.get("keyFingerprint") != fp:
meta = {
"keyFingerprint": fp,
"savedAtUtc": _utcnow(),
"firstSuccessUtc": None,
"lastSuccessUtc": None,
"expiresOn": None,
"expiresOnSource": None,
"lastErrorCode": None,
"warnedStages": [],
}
else:
meta["savedAtUtc"] = _utcnow()
save_meta(meta)
def mark_success() -> None:
"""수집이 성공했을 때 파이프라인이 호출한다."""
meta = load_meta()
now = _utcnow()
meta.setdefault("keyFingerprint", key_fingerprint())
if not meta.get("firstSuccessUtc"):
meta["firstSuccessUtc"] = now
meta["lastSuccessUtc"] = now
meta["lastErrorCode"] = None
save_meta(meta)
def mark_error(code: str) -> None:
meta = load_meta()
meta["lastErrorCode"] = code
meta["lastErrorUtc"] = _utcnow()
save_meta(meta)
def set_expiry(date_str: str, source: str = "user_input") -> None:
"""사용자가 마이페이지에서 확인한 활용기간 만료일을 등록한다.
이 값이 들어오는 순간 보조 경보가 '가정치'에서 '실측치'로 승격된다.
GUI 의 '인증키 만료일 등록' 액션이 호출한다.
"""
datetime.strptime(date_str, "%Y-%m-%d") # 형식 검증
meta = load_meta()
meta["expiresOn"] = date_str
meta["expiresOnSource"] = source
meta["warnedStages"] = [] # 경고 이력 리셋
save_meta(meta)
```
**`state/key_meta.json` 스키마 (요청 A4)**
```json
{
"keyFingerprint": "3f9a1c07",
"savedAtUtc": "2026-09-02T21:10:00+00:00",
"firstSuccessUtc": "2026-09-02T21:10:12+00:00",
"lastSuccessUtc": "2026-09-14T21:00:41+00:00",
"expiresOn": null,
"expiresOnSource": null,
"lastErrorCode": null,
"warnedStages": []
}
```
| 필드 | 의미 | 왜 필요한가 |
|---|---|---|
| `keyFingerprint` | sha256 앞 8자 | 키 원문 없이 "키가 바뀌었는지"만 판정 (C8 대응) |
| `firstSuccessUtc` | 최초 성공 호출 시각 | 만료일을 모를 때 가정치 계산의 기준점 |
| `lastSuccessUtc` | 최근 성공 시각 | `doctor` 표시, 워치독 보조 |
| `expiresOn` | 사용자가 등록한 실제 만료일 | 있으면 가정치를 대체 |
| `expiresOnSource` | `"user_input"` / `null` | 경고 문구에 "추정/확정"을 표시 |
| `warnedStages` | `["D-30", "D-7", "EXPIRED"]` | 같은 단계를 매일 반복 경고하지 않는다 (R7.8) |
### 5.4 파이프라인의 동작 — 만료·폐기가 감지되면
키 검증은 **`checks.py` 체크 ⑤(API 키 실호출 검증, `numOfRows=1`)** 가 담당한다. 아키텍처가 이미 이 체크를 정의해 두었으므로, 파이프라인은 수집 스테이지 진입 전에 이 체크의 결과를 본다. **잘못된 키로 10페이지를 때려 트래픽과 시간을 낭비하는 일을 막는다.**
| 단계 | 동작 |
|---|---|
| 1 | 파이프라인 첫 스테이지에서 체크 ⑤ 실행 — 호출 1건 |
| 2 | 결과가 `USER_ACTION` 이면 → `secrets_dpapi.mark_error(code)`**수집 스테이지를 시작하지 않는다** |
| 3 | `alerts.py`**CRITICAL** 알림 기록 (dedup_key = `api_key:<code>`) |
| 4 | 파이프라인 상태 `BLOCKED`, **종료 코드 2** (아키텍처 5절) |
| 5 | `notify/pump.py` 가 CRITICAL 알림을 보고 **복구 GUI 를 강제 기동**: `python -m dmf_crawler onboard --mode recover --focus api_key_live` |
| 6 | GUI 에서 새 키 입력 → `save_service_key()` → 체크 ⑤ 재실행 → 통과하면 [지금 실행] 활성 |
| 7 | 사용자가 창을 닫으면 알림은 **미해소 상태로 남고**, 다음 `notify-pump` 실행(로그온·주기)에서 재표시 (R7.2 제약 대응) |
**리포트는 어떻게 되는가** — 아키텍처 §3.6 과 일관되게:
| 상황 | 리포트 | 파이프라인 상태 | 종료 코드 |
|---|---|---|---|
| 키 무효·만료 (`USER_ACTION`) — 수집 스테이지 미진입 | **미생성.** 직전 리포트 파일은 그대로 보존 | `BLOCKED` | **2** |
| 수집 실패(`QUOTA`·`RETRY` 소진) + **직전 성공 스냅샷 있음** | 마지막 성공 스냅샷으로 재생성 + 대시보드에 "오늘 수집 실패 — 마지막 성공: YYYY-MM-DD" 배너 | `PARTIAL` | **0** |
| 수집 실패 + **직전 성공 스냅샷 없음** (최초 실행) | 미생성 | `FAILED` | **1** |
| 스키마 오류(`SCHEMA`) | 미생성 | `BLOCKED` | **2** |
> **R4.4("AI 가 실패해도 리포트는 생성된다")와의 구분**: AI 실패는 **부가가치의 상실**이므로 리포트를 만든다. **인증 실패는 전제조건의 부재**이므로 만들지 않는다. 빈 리포트를 덮어써서 어제 것마저 잃는 것이 최악이다. 이 구분은 종료 코드 2(BLOCKED)의 정의 — "전제조건 미충족, 재시도해도 같은 결과" — 와 정확히 일치한다.
### 5.5 사전 경고 — D-30 / D-7 (요청 A2 — `checks.py` 체크 ⑬)
```python
# src/dmf_crawler/checks.py (발췌) — 체크 ⑬ 인증키 수명
#
# 원칙 (C9, C10):
# * 활용기간의 실제 길이를 확정하지 못했다(2차 출처는 24개월, 1차 출처 미확인).
# 따라서 24개월을 '사실'로 다루지 않고 '가정치'로만 쓰며, 문구에 그 사실을 밝힌다.
# * 사용자가 마이페이지에서 실제 만료일을 등록하면(set_expiry) 가정치를 대체한다.
# * 이 체크는 **보조 경보**다. 배치를 멈추지 않는다.
# 배치를 멈추는 것은 오직 체크 5 의 코드 31 관측(주 경보)뿐이다.
from dataclasses import dataclass
from datetime import date, datetime, timezone
from . import secrets_dpapi
ASSUMED_VALIDITY_MONTHS = 24 # 미검증 — 2차 출처 기준의 가정치
WARN_STAGES: tuple[int, ...] = (30, 7) # D-30, D-7
PORTAL_MYPAGE_HINT = "마이페이지 > 데이터 활용 > Open API > 활용신청 현황"
@dataclass(frozen=True, slots=True)
class ExpiryView:
expires_on: date | None
is_assumed: bool
days_left: int | None
stage: int | None # 30 또는 7. 아직 아니면 None
expired: bool
def message(self) -> str:
if self.expires_on is None:
return "아직 성공한 호출이 없어 인증키 수명을 계산할 수 없습니다."
qualifier = (" (정확한 만료일을 등록하지 않아 '승인일 + 24개월'로 추정한 값입니다. "
"포털에서 실제 날짜를 확인해 등록하면 더 정확해집니다.)"
if self.is_assumed else "")
if self.expired:
return (f"인증키의 사용 기한이 지난 것으로 보입니다 "
f"(만료일 {self.expires_on:%Y-%m-%d}). "
f"{PORTAL_MYPAGE_HINT} 에서 '활용연장신청'을 해주세요.{qualifier}")
return (f"인증키 사용 기한이 {self.days_left}일 남았습니다 "
f"(만료 예정 {self.expires_on:%Y-%m-%d}). "
f"{PORTAL_MYPAGE_HINT} 에서 '활용연장신청'을 미리 해두세요.{qualifier}")
def _add_months(base: date, months: int) -> date:
total = base.month - 1 + months
year, month = base.year + total // 12, total % 12 + 1
leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
last = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
return date(year, month, min(base.day, last))
def _parse_utc(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def compute_expiry(today: date | None = None) -> ExpiryView:
today = today or datetime.now(timezone.utc).date()
meta = secrets_dpapi.load_meta()
explicit = meta.get("expiresOn")
if explicit:
try:
expires, assumed = datetime.strptime(explicit, "%Y-%m-%d").date(), False
except ValueError:
expires, assumed = None, True
else:
anchor = _parse_utc(meta.get("firstSuccessUtc")) or _parse_utc(meta.get("savedAtUtc"))
if anchor is None:
return ExpiryView(None, True, None, None, False)
expires, assumed = _add_months(anchor.date(), ASSUMED_VALIDITY_MONTHS), True
if expires is None:
return ExpiryView(None, True, None, None, False)
days_left = (expires - today).days
stage = next((t for t in WARN_STAGES if days_left <= t), None)
return ExpiryView(expires, assumed, days_left, stage, days_left < 0)
def check_key_lifetime(cfg) -> "CheckResult":
"""체크 13. 경고 단계에 처음 진입할 때만 WARN 을 낸다 (R7.8 알림 폭주 방지)."""
view = compute_expiry()
if view.expires_on is None:
return CheckResult(
key="key_lifetime", title="인증키 사용 기한",
ok=True, detail="아직 성공한 호출이 없어 계산할 수 없습니다.",
fix_hint="", fix_action=None, severity=Severity.INFO,
)
label = "추정" if view.is_assumed else "확정"
detail = (f"{view.expires_on:%Y-%m-%d} 만료({label}) — "
+ ("만료됨" if view.expired else f"{view.days_left}일 남음"))
if view.stage is None and not view.expired:
return CheckResult(
key="key_lifetime", title="인증키 사용 기한",
ok=True, detail=detail, fix_hint="", fix_action=None,
severity=Severity.INFO,
)
# 같은 단계를 매일 반복해서 알리지 않는다.
meta = secrets_dpapi.load_meta()
warned = list(meta.get("warnedStages") or [])
stage_key = "EXPIRED" if view.expired else f"D-{view.stage}"
first_time = stage_key not in warned
if first_time:
warned.append(stage_key)
meta["warnedStages"] = warned
secrets_dpapi.save_meta(meta)
return CheckResult(
key="key_lifetime", title="인증키 사용 기한",
ok=False, detail=detail, fix_hint=view.message(),
fix_action="open_portal_mypage",
severity=Severity.WARN if first_time else Severity.INFO,
)
```
**경고 단계 확정표**
| 단계 | 트리거 | 등급 | 복구 GUI 강제 기동 | 배치 진행 | 반복 |
|---|---|---|---|---|---|
| D-30 | 남은 일수 ≤ 30 | WARN | 아니오 (토스트) | 계속 | 1회만 |
| D-7 | 남은 일수 ≤ 7 | WARN | 아니오 (토스트) | 계속 | 1회만 |
| 추정 만료일 경과 | 남은 일수 < 0 | WARN | 아니오 (토스트) | 계속 | 1회만 |
| **코드 31 관측 (체크 ⑤)** | API 응답 | **CRITICAL** | **예** | **BLOCKED(2)** | 실행 (dedup 쿨다운 적용) |
> 추정 만료일이 지나도 배치를 멈추지 않는 이유: **24개월이 틀렸을 수 있기 때문이다**(C10). 실제로 키가 죽었다면 같은 실행의 체크 ⑤ 가 코드 31 로 잡아낸다. **가정치로 서비스를 멈추지 않는다**는 것이 이 설계의 규율이다.
### 5.6 복구 GUI — 온보딩과 같은 컴포넌트
별도의 PowerShell 다이얼로그를 만들지 않는다. 요구사항 4절("온보딩 = 복구, 하나의 컴포넌트") 아키텍처 §3.17 따라 **`gui/app.py` 화면** 진입 모드를 모두 처리한다.
| 진입 | 명령 | 화면 |
|---|---|---|
| 최초 설정 | `pythonw -m dmf_crawler onboard --mode setup` | 체크 14종 전체 체크리스트 |
| 복구 | `pythonw -m dmf_crawler onboard --mode recover --focus api_key_live` | **실패한 항목만 강조** + 원인 설명 + 액션 버튼 |
| 점검 | `pythonw -m dmf_crawler onboard --mode inspect` | 전체 통과 화면 + [지금 실행] |
`gui/steps.py` 제공해야 하는 **API 키 관련 액션 4종**:
| 액션 | 버튼 라벨 | 동작 |
|---|---|---|
| `enter_api_key` | 인증키 입력 | 텍스트 입력 `secrets_dpapi.save_service_key()` 체크 재실행 |
| `open_issue_page` | 인증키 발급 페이지 열기 | `webbrowser.open(PORTAL_DATASET_URL)` |
| `open_portal_mypage` | 포털 마이페이지 열기 | `webbrowser.open(PORTAL_HOME_URL)` + 경로 문구 표시 |
| `set_key_expiry` | 인증키 만료일 등록 | 날짜 입력 `secrets_dpapi.set_expiry()` 체크 재실행 |
**입력 위생 규칙** (구현 반드시 지킬 )
- 원문을 **명령행 인자로 전달하지 않는다.** 프로세스 목록·이벤트 로그에 남는다. GUI 같은 프로세스 안에서 `save_service_key()` 직접 호출한다.
- 입력 필드는 붙여넣기 직후 정규화한 **길이와 지문만** 표시한다(예: "인증키 확인됨 84자, 지문 3f9a1c07"). 원문을 화면에 다시 렌더하지 않는다.
- 저장 실패·검증 실패 메시지에 조각을 넣지 않는다.
### 5.7 사용자 대면 문구 확정 (기술 용어 금지)
`notify/messages.py` 요구하는 **4요소(무엇 / 왜 / 어떻게 / 다음 행동)** 형식으로 확정한다.
| 코드 | 무엇이 | | 어떻게 고치는가 | 다음 행동(버튼) |
|---|---|---|---|---|
| **30** | 오늘 자료를 받지 못했습니다. | 저장된 인증키가 이상 유효하지 않습니다. 공공데이터포털에서 인증키를 새로 발급받으면 예전 키는 자동으로 폐기됩니다. | 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서 **현재 인증키를 복사**해 다시 입력해 주세요. (재발급 버튼은 누르지 마세요.) | [인증키 입력] [포털 마이페이지 열기] |
| **31** | 오늘 자료를 받지 못했습니다. | 인증키의 사용 기한이 끝났습니다. | 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서 **'활용연장신청'** 을 해주세요. | [포털 마이페이지 열기] [인증키 입력] |
| **20** `SERVICE_ACCESS_DENIED_ERROR` | 오늘 자료를 받지 못했습니다. | 활용신청이 아직 완료되지 않았거나, 신청 내용을 변경하는 중이라 일시 중지된 상태입니다. | 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서 상태가 '승인완료'인지 확인해 주세요. | [포털 마이페이지 열기] |
| **20** `SERVICE_KEY_IS_NULL` | 자료를 받을 수 없습니다. | 인증키가 저장되어 있지 않습니다. | 인증키를 입력해 주세요. 아직 없다면 발급 페이지에서 '활용신청'을 누르면 즉시 승인됩니다. | [인증키 입력] [인증키 발급 페이지 열기] |
| **21** | 오늘 자료를 받지 못했습니다. | 인증키가 일시적으로 사용 중지된 상태입니다. | 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서 상태를 확인해 주세요. | [포털 마이페이지 열기] |
| **22** | 오늘 자료를 다 받지 못했습니다. 어제 자료로 보고서를 만들었습니다. | 오늘 사용할 수 있는 조회 횟수를 모두 썼습니다. | 자정(00시)이 지나면 자동으로 초기화됩니다. 아무것도 하지 않으셔도 됩니다. | [확인] |
| **29** | 자료를 받을 수 없습니다. | 이 컴퓨터의 인터넷 주소가 차단되어 있습니다. | 공공데이터포털 활용지원센터(1566-0025, 평일 09~18시)로 문의해 주세요. | [전화번호 복사] |
| **32** | 오늘 자료를 받지 못했습니다. | 활용신청 때 등록한 인터넷 주소와 지금 이 컴퓨터의 주소가 다릅니다. | 마이페이지 > 데이터 활용 > Open API > 활용신청 현황 에서 변경신청을 해주세요. | [포털 마이페이지 열기] |
| **12** | 자료를 받을 수 없습니다. | 식약처가 제공하는 데이터의 주소가 바뀐 것 같습니다. | 프로그램 수정이 필요합니다. 담당자에게 알려주세요. | [데이터 안내 페이지 열기] [로그 폴더 열기] |
| **10 / 11 / 33** | 자료를 받을 수 없습니다. | 요청 형식이 서버와 맞지 않습니다. | 프로그램 수정이 필요합니다. 담당자에게 알려주세요. | [로그 폴더 열기] |
> 이 문구들에는 `SERVICE_KEY_IS_NOT_REGISTERED_ERROR` 같은 영문 상수도, `resultCode` 같은 필드명도, HTTP 상태 코드도 **등장하지 않는다.** 그런 정보는 `logs/run_*/pipeline.log` 와 `events.jsonl` 에만 남는다.
---
## 6. 오류 코드 대응표
### 6.1 조치 등급 정의
오류를 "무엇이 잘못됐는가"가 아니라 **"배치가 무엇을 해야 하는가"** 로 환산한다. 이 등급이 재시도 여부·파이프라인 상태·알림 등급·GUI 화면을 전부 결정한다.
| 등급 | 의미 | 배치의 자동 대응 | 사용자 알림 | 재시도 |
|---|---|---|---|---|
| `OK` | 정상 | 진행 | 없음 | — |
| `EMPTY` | 데이터 없음. **오류가 아니다** | 빈 결과로 진행 | 없음 (단, 전량 0건이면 §6.7 이상 경보) | — |
| `RETRY` | 일시적 장애 | `http.py` 백오프 재시도 (최대 4회) | 최종 실패 시 WARN | ✅ |
| `SLOW_DOWN` | 너무 빠름 | 요청 간격 상향 후 재시도 | 없음 | ✅ |
| `QUOTA` | 오늘 한도 소진 | 즉시 중단. **다음 자정+5분으로 재예약** | WARN | ❌ (오늘은) |
| `USER_ACTION` | 사람이 포털에서 조치해야 함 | **즉시 중단** | **CRITICAL + 복구 GUI 강제 기동** | ❌ |
| `SCHEMA` | 엔드포인트·파라미터·응답 구조 불일치 | 즉시 중단 | **CRITICAL** | ❌ |
### 6.2 전체 오류 코드 대응표
출처 ① `Open API 에러 코드 정리.docx` (data.go.kr 배포)
출처 ② 데이터셋 상세페이지 '오픈API 에러코드 안내' / '인증 에러' 표 (2025-09-19 갱신)
| 코드 | `errMsg` | 한국어 원인 | 봉투 | 등급 | 배치 자동 대응 | 사용자 알림 | 복구 절차 |
|---|---|---|---|---|---|---|---|
| `00` | `NORMAL_CODE` | 정상 | 서비스 (200) | `OK` | 진행 | 없음 | — |
| `01` | `APPLICATION_ERROR` | 「GW 내부 처리 중 예기치 않은 오류가 발생했습니다」 | 양쪽 | `RETRY` | 백오프 재시도 ×4 | 최종 실패 시 WARN | 자동 회복. 반복 시 활용지원센터 |
| `02` | `DB_ERROR` | 데이터베이스 에러 | 서비스 | `RETRY` | 백오프 재시도 ×4 | 최종 실패 시 WARN | 자동 회복 |
| `03` | `NODATA_ERROR` | 데이터없음 | 서비스 | `EMPTY` | 빈 결과로 진행 | 없음 | — (첫 페이지에서 나오면 §6.7) |
| `04` | `HTTP_ERROR` | 「허용되지 않은 HTTP 요청이거나 기관 API 응답 처리에 실패했습니다」 | 양쪽 | `RETRY` | 백오프 재시도 ×4 | 최종 실패 시 WARN | 자동 회복 |
| `05` | `SERVICETIMEOUT_ERROR` | 「기관 API 또는 GW 연계 서비스와의 연결에 실패했거나 응답 대기시간을 초과했습니다」 | 양쪽 | `RETRY` | 백오프 재시도 ×4 | 최종 실패 시 WARN | 자동 회복 |
| `10` | `INVALID_REQUEST_PARAMETER_ERROR` | 잘못된 요청 파라메터 | 양쪽 | `SCHEMA` | **즉시 중단** (단, `numOfRows` 후퇴 협상 중이면 예외 — §9.4) | **CRITICAL** | 코드 수정. 데이터셋 페이지에서 명세 확인 |
| `11` | `NO_MANDATORY_REQUEST_PARAMETERS_ERROR` | 필수요청 파라메터 없음 | 양쪽 | `SCHEMA` | **즉시 중단** | **CRITICAL** | 코드 수정 |
| `12` | `NO_OPENAPI_SERVICE_ERROR` | 「요청한 오픈API 서비스가 존재하지 않거나 폐기되었습니다」 | 양쪽 | `SCHEMA` | **즉시 중단** | **CRITICAL** + 데이터셋 페이지 열기 버튼 | **버전 상승 의심.** §8.3 절차 |
| `20` | `SERVICE_KEY_IS_NULL` | 인증키 미전송 | GW (401) | `USER_ACTION` | **즉시 중단** | **CRITICAL — 키 입력** | 키 입력 |
| `20` | `PERMISSION_DENIED` | GW 접근 권한 거부 | GW | `USER_ACTION` | **즉시 중단** | **CRITICAL** | 활용신청 완료 여부 확인 |
| `20` | `SERVICE_ACCESS_DENIED_ERROR` | 「해당 API 서비스를 신청하지 않았거나, 변경신청 등으로 인해 일시 중지된 경우」 | 서비스 (200) | `USER_ACTION` | **즉시 중단** | **CRITICAL** | 마이페이지에서 신청 상태 확인 |
| `21` | `TEMPORARILY_DISABLE_THE_SERVICEKEY_ERROR` | 일시적으로 사용할 수 없는 서비스 키 | GW | `USER_ACTION` | **즉시 중단** | **CRITICAL** | 마이페이지에서 상태 확인 |
| `22` | `LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR` | 「API 서비스의 일일 호출 허용량을 초과했습니다」 | GW | `QUOTA` | **즉시 중단 + 자정+5분 재예약** | WARN | 자동. 자정 후 재시도 |
| `23` | `LIMITED_NUMBER_OF_SERVICE_REQUESTS_PER_SECOND_EXCEEDS_ERROR` | 「짧은 시간에 많은 요청이 발생하여 초당 호출 허용량을 초과했습니다」 | GW | `SLOW_DOWN` | 요청 간격 ×2 (상한 10초) 후 재시도 | 없음 | 자동 |
| `29` | `BLACKLIST_IP_ACCESS_ERROR` | 「차단된 IP에서 호출한 요청입니다」 | GW | `USER_ACTION` | **즉시 중단. 재시도 금지** | **CRITICAL** | 활용지원센터 1566-0025 전화 |
| `30` | `SERVICE_KEY_IS_NOT_REGISTERED_ERROR` | 등록되지 않은 서비스키 | GW (403) | `USER_ACTION` | **즉시 중단** | **CRITICAL — 키 재입력** | 마이페이지에서 현재 키 **복사**(재발급 금지) |
| `31` | `DEADLINE_HAS_EXPIRED_ERROR` | 「API 인증키의 사용 기한이 만료되었습니다」 | GW | `USER_ACTION` | **즉시 중단** | **CRITICAL** | 마이페이지 → **활용연장신청** |
| `32` | `UNREGISTERED_IP_ERROR` | 등록되지 않은 IP | GW | `USER_ACTION` | **즉시 중단** | **CRITICAL** | 마이페이지 → 변경신청 |
| `33` | `UNSIGNED_CALL_ERROR` | 서명되지 않은 호출 | GW | `SCHEMA` | **즉시 중단** | **CRITICAL** | 코드 수정 |
| `99` | `UNKNOWN_ERROR` | 기타에러 | 양쪽 | `RETRY` | 백오프 재시도 ×4 | 최종 실패 시 WARN | 로그 확인 |
| — | (네트워크 예외) | DNS 실패·연결 거부·타임아웃 | — | `RETRY` | 백오프 재시도 ×4 | 최종 실패 시 WARN | 인터넷 연결 확인 |
| — | (본문 시그니처 실패) | HTTP 200 인데 본문이 HTML·차단 페이지·200바이트 미만 | — | `RETRY` → 2회 후 `SCHEMA` | 아키텍처 §3.4 본문 시그니처 검사가 잡는다 | WARN → CRITICAL | 응답 원문(`data/raw/`)을 확인 |
| — | (JSON·XML 모두 파싱 실패) | 봉투 구조가 전혀 다름 | — | `RETRY` → 2회 후 `SCHEMA` | 원문을 아카이브에 남기고 중단 | CRITICAL | §8.3 절차 |
### 6.3 재시도 가능 / 즉시 중단 요약
**재시도 가능 (배치가 알아서 회복)**`01`, `02`, `04`, `05`, `23`, `99`, 네트워크 예외
**오늘은 포기 (내일 자동 회복)**`22`
**즉시 중단 + 사람 호출**`10`, `11`, `12`, `20`(3종), `21`, `29`, `30`, `31`, `32`, `33`
**오류가 아님**`03`
> **판정 규칙**: 재시도가 상황을 개선할 가능성이 0 이면 재시도하지 않는다. 잘못된 키를 4번 더 보내봐야 트래픽만 태우고, 코드 29(IP 차단) 상황에서는 오히려 **상황을 악화**시킨다.
### 6.4 오류 분류기 — `source_mfds.py` 안에 산다 (완결 코드)
**새 모듈을 만들지 않는다.** 아키텍처 §3.4 의 `parse_body()` 가 이미 봉투를 해석하고 `resultCode` 를 돌려주므로, 분류 테이블도 같은 모듈에 둔다. `errors.py` 는 예외 계층(`DmfError``FetchError` …) 전용이며 여기에 오류 코드 표를 넣지 않는다.
```python
# src/dmf_crawler/source_mfds.py (발췌) — GW/서비스 오류코드 분류
#
# 설계 근거:
# * 오류 봉투가 두 종류다 (실측).
# GW 레벨 : HTTP 401/403 + OpenAPI_ServiceResponse/cmmMsgHeader
# 서비스 : HTTP 200 + response/header/{resultCode,resultMsg}
# * returnReasonCode '20' 에 세 원인이 겹친다 → errMsg 를 1차 키로 분기한다.
# * type=json 을 줘도 GW 가 XML 로 응답할 수 있다 → JSON→XML 폴백 필수.
# * 코드 23(초당 제한), 29(IP 차단)는 2025-09-19 갱신 표에 신설되었다.
from __future__ import annotations
import json
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from enum import StrEnum
PORTAL_MYPAGE_HINT = "마이페이지 > 데이터 활용 > Open API > 활용신청 현황"
SUPPORT_CENTER = "공공데이터포털 활용지원센터(1566-0025, 평일 09~18시)"
class Act(StrEnum):
OK = "OK"
EMPTY = "EMPTY"
RETRY = "RETRY"
SLOW_DOWN = "SLOW_DOWN"
QUOTA = "QUOTA"
USER_ACTION = "USER_ACTION"
SCHEMA = "SCHEMA"
@dataclass(frozen=True, slots=True)
class ErrorSpec:
code: str
message: str
korean: str
act: Act
user_text: str = "" # 사용자에게 그대로 보여줄 문장 (기술 용어 금지, 5.7 표와 일치)
_SPECS: tuple[ErrorSpec, ...] = (
ErrorSpec("00", "NORMAL_CODE", "정상", Act.OK),
ErrorSpec("01", "APPLICATION_ERROR", "GW 내부 처리 중 예기치 않은 오류", Act.RETRY),
ErrorSpec("02", "DB_ERROR", "데이터베이스 에러", Act.RETRY),
ErrorSpec("03", "NODATA_ERROR", "데이터없음 에러", Act.EMPTY),
ErrorSpec("04", "HTTP_ERROR", "허용되지 않은 HTTP 요청 또는 기관 API 응답 처리 실패", Act.RETRY),
ErrorSpec("05", "SERVICETIMEOUT_ERROR", "연결 실패 또는 응답 대기시간 초과", Act.RETRY),
ErrorSpec("10", "INVALID_REQUEST_PARAMETER_ERROR", "잘못된 요청 파라메터", Act.SCHEMA,
"요청 형식이 서버와 맞지 않습니다. 프로그램 수정이 필요합니다. "
"담당자에게 알려주세요."),
ErrorSpec("11", "NO_MANDATORY_REQUEST_PARAMETERS_ERROR", "필수요청 파라메터 없음", Act.SCHEMA,
"요청에 빠진 항목이 있습니다. 프로그램 수정이 필요합니다. 담당자에게 알려주세요."),
ErrorSpec("12", "NO_OPENAPI_SERVICE_ERROR", "오픈API 서비스가 없거나 폐기됨", Act.SCHEMA,
"식약처가 제공하는 데이터의 주소가 바뀐 것 같습니다. "
"프로그램 수정이 필요합니다. 담당자에게 알려주세요."),
ErrorSpec("20", "SERVICE_KEY_IS_NULL", "인증키가 요청에 포함되지 않음", Act.USER_ACTION,
"인증키가 저장되어 있지 않습니다. 인증키를 입력해 주세요."),
ErrorSpec("20", "PERMISSION_DENIED", "GW 접근 권한 검사에서 거부됨", Act.USER_ACTION,
"이 데이터에 대한 사용 권한이 확인되지 않습니다. "
"공공데이터포털에서 활용신청이 완료되었는지 확인해 주세요."),
ErrorSpec("20", "SERVICE_ACCESS_DENIED_ERROR",
"활용신청 미완료 또는 변경신청으로 일시중지", Act.USER_ACTION,
"활용신청이 아직 완료되지 않았거나, 신청 내용을 변경하는 중이라 "
"일시 중지된 상태입니다. " + PORTAL_MYPAGE_HINT +
" 에서 상태가 '승인완료'인지 확인해 주세요."),
ErrorSpec("21", "TEMPORARILY_DISABLE_THE_SERVICEKEY_ERROR",
"일시적으로 사용할 수 없는 서비스 키", Act.USER_ACTION,
"인증키가 일시적으로 사용 중지된 상태입니다. "
+ PORTAL_MYPAGE_HINT + " 에서 상태를 확인해 주세요."),
ErrorSpec("22", "LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR",
"일일 호출 허용량 초과", Act.QUOTA,
"오늘 사용할 수 있는 조회 횟수를 모두 썼습니다. "
"자정(00시)이 지나면 자동으로 초기화됩니다. 아무것도 하지 않으셔도 됩니다."),
ErrorSpec("23", "LIMITED_NUMBER_OF_SERVICE_REQUESTS_PER_SECOND_EXCEEDS_ERROR",
"초당 호출 허용량 초과", Act.SLOW_DOWN),
ErrorSpec("29", "BLACKLIST_IP_ACCESS_ERROR", "차단된 IP에서의 호출", Act.USER_ACTION,
"이 컴퓨터의 인터넷 주소가 차단되어 있습니다. "
+ SUPPORT_CENTER + "로 문의해 주세요."),
ErrorSpec("30", "SERVICE_KEY_IS_NOT_REGISTERED_ERROR", "등록되지 않은 서비스키",
Act.USER_ACTION,
"저장된 인증키가 더 이상 유효하지 않습니다. 공공데이터포털에서 인증키를 "
"새로 발급받으면 예전 키는 자동으로 폐기됩니다. " + PORTAL_MYPAGE_HINT +
" 에서 현재 인증키를 복사해 다시 입력해 주세요."),
ErrorSpec("31", "DEADLINE_HAS_EXPIRED_ERROR", "기한만료된 서비스키", Act.USER_ACTION,
"인증키의 사용 기한이 끝났습니다. " + PORTAL_MYPAGE_HINT +
" 에서 '활용연장신청'을 해주세요."),
ErrorSpec("32", "UNREGISTERED_IP_ERROR", "등록되지 않은 IP", Act.USER_ACTION,
"활용신청 때 등록한 인터넷 주소와 지금 이 컴퓨터의 주소가 다릅니다. "
+ PORTAL_MYPAGE_HINT + " 에서 변경신청을 해주세요."),
ErrorSpec("33", "UNSIGNED_CALL_ERROR", "서명되지 않은 호출", Act.SCHEMA,
"요청 방식이 서버와 맞지 않습니다. 프로그램 수정이 필요합니다. "
"담당자에게 알려주세요."),
ErrorSpec("99", "UNKNOWN_ERROR", "기타에러", Act.RETRY),
)
_BY_MESSAGE: dict[str, ErrorSpec] = {s.message: s for s in _SPECS}
# 코드 폴백 — 20 은 가장 흔한 SERVICE_ACCESS_DENIED_ERROR 로 접는다(등록 순서상 첫 20).
_BY_CODE: dict[str, ErrorSpec] = {}
for _s in _SPECS:
_BY_CODE.setdefault(_s.code, _s)
_BY_CODE.setdefault(_s.code.lstrip("0") or "0", _s)
_UNKNOWN = ErrorSpec("99", "UNKNOWN_ERROR", "미상", Act.RETRY,
"알 수 없는 오류가 발생했습니다. 잠시 후 자동으로 다시 시도합니다.")
def classify(message: str | None, code: str | None) -> ErrorSpec:
"""errMsg 를 1차 키, resultCode 를 폴백으로 삼아 조치 등급을 결정한다.
'SERVICE ACCESS DENIED ERROR!' 처럼 공백·느낌표 변형도 흡수한다.
코드로만 분기하면 20(세 가지 원인)에서 반드시 오진한다.
"""
if message:
norm = re.sub(r"[\s!.]+", "_", message.strip().upper()).strip("_")
if norm in _BY_MESSAGE:
return _BY_MESSAGE[norm]
for key, spec in _BY_MESSAGE.items():
if norm.startswith(key) or key.startswith(norm):
return spec
if code is not None:
c = str(code).strip()
if c in _BY_CODE:
return _BY_CODE[c]
c2 = c.lstrip("0") or "0"
if c2 in _BY_CODE:
return _BY_CODE[c2]
return _UNKNOWN
# --- 두 종류의 봉투를 모두 해석한다 (아키텍처 3.4 parse_body 의 구현) -------
def _xml_text(root: ET.Element, path: str) -> str | None:
node = root.find(path)
return node.text.strip() if (node is not None and node.text) else None
def parse_envelope(text: str) -> tuple[ErrorSpec | None, dict | None]:
"""(오류스펙, 정상페이로드) 중 하나를 채워 돌려준다.
(None, payload) 이면 정상, (spec, None) 이면 오류다.
JSON 을 먼저 시도하고 실패하면 XML 로 폴백한다.
"""
body = text.strip()
if not body:
return _UNKNOWN, None
# --- JSON 시도 ---
if body[0] in "{[":
try:
data = json.loads(body)
except json.JSONDecodeError:
data = None
if isinstance(data, dict):
gw = data.get("OpenAPI_ServiceResponse")
if isinstance(gw, dict):
hdr = gw.get("cmmMsgHeader") or {}
return classify(hdr.get("errMsg"), hdr.get("returnReasonCode")), None
resp = data.get("response") or data
hdr = (resp.get("header") or {}) if isinstance(resp, dict) else {}
code, msg = hdr.get("resultCode"), hdr.get("resultMsg")
if code is not None and str(code).strip().lstrip("0") not in ("", "0"):
return classify(msg, code), None
return None, (resp if isinstance(resp, dict) else data)
# --- XML 폴백 ---
try:
root = ET.fromstring(body)
except ET.ParseError:
return _UNKNOWN, None
if root.tag == "OpenAPI_ServiceResponse":
return classify(_xml_text(root, "./cmmMsgHeader/errMsg"),
_xml_text(root, "./cmmMsgHeader/returnReasonCode")), None
code = _xml_text(root, "./header/resultCode")
msg = _xml_text(root, "./header/resultMsg")
if code is not None and code.strip().lstrip("0") not in ("", "0"):
return classify(msg, code), None
return None, {
"header": {"resultCode": code, "resultMsg": msg},
"body": {
"totalCount": _xml_text(root, "./body/totalCount"),
"pageNo": _xml_text(root, "./body/pageNo"),
"numOfRows": _xml_text(root, "./body/numOfRows"),
"items": [
{child.tag: (child.text or "").strip() for child in item}
for item in root.findall(".//items/item")
],
},
}
```
### 6.5 등급 → 파이프라인 상태 → 종료 코드 → GUI (확정 매핑)
아키텍처 §5 가 정한 종료 코드 규약(`0` SUCCESS/PARTIAL/SKIPPED · `1` FAILED · `2` BLOCKED · `130` 중단)을 그대로 쓴다. **이 문서가 새 종료 코드를 만들지 않는다.**
| 등급 | 파이프라인 상태 | 종료 코드 | 리포트 | 알림 등급 | GUI |
|---|---|---|---|---|---|
| `OK` / `EMPTY` | `SUCCESS` | `0` | 신규 생성 | 없음 | 없음 (성공 팝업을 띄우지 않는다) |
| `RETRY` 소진 (직전 스냅샷 있음) | `PARTIAL` | `0` | 마지막 성공 스냅샷 + 배너 | WARN | 토스트 |
| `RETRY` 소진 (직전 스냅샷 없음) | `FAILED` | `1` | 미생성 | WARN | 토스트. 스케줄러가 최대 3회 재시도 |
| `SLOW_DOWN` | (재시도 후 흡수) | — | — | 없음 | 없음 |
| `QUOTA` | `PARTIAL` 또는 `FAILED` | `0` / `1` | 위와 동일 | WARN | 토스트 + **자정+5분 일회성 작업 등록** |
| `USER_ACTION` | `BLOCKED` | `2` | **미생성** | **CRITICAL** | **복구 GUI 강제 기동** |
| `SCHEMA` | `BLOCKED` | `2` | **미생성** | **CRITICAL** | **복구 GUI + 데이터셋 페이지 열기** |
> **성공 시 아무 창도 띄우지 않는 것은 의도적이다.** 매일 아침 성공 팝업이 뜨면 사용자는 팝업을 무시하는 습관을 들이고, 그러면 진짜 경고도 무시한다(운영 렌즈의 "학습된 무시").
**알림 중복 억제**`alerts.py``dedup_key` 규약:
| 상황 | `dedup_key` | 쿨다운 |
|---|---|---|
| 인증키 오류 | `api_key:<code>` | 해소될 때까지 하루 1회 |
| 트래픽 초과 | `quota:daily` | 24시간 |
| 스키마 오류 | `schema:<code 또는 drift>` | 해소될 때까지 하루 1회 |
| 수집 일시 실패 | `fetch:transient` | 6시간 |
| 키 수명 경고 | `key_lifetime:D-30` / `D-7` / `EXPIRED` | **영구 1회** (`warnedStages` 로 관리) |
### 6.6 이중 인코딩 방지 자기검증 (요청 A5 — `tests/test_service_key.py`)
C15 는 이 프로젝트에서 유일하게 "비개발자의 정상적인 행동이 시스템을 깨뜨리는" 지점이다. 회귀 테스트로 못박는다.
```python
# -*- coding: utf-8 -*-
"""tests/test_service_key.py — serviceKey 이중 인코딩 방지 회귀 테스트.
배경 (공식 명세): 「인증키 / serviceKey / 100 / 필 / 인증키 (URL Encode)」
Decoding : abc+def/ghi=jkl
Encoding : abc%2Bdef%2Fghi%3Djkl
httpx 의 params= 는 값을 자동 인코딩한다. 여기에 Encoding 키를 넣으면
%2B → %252B 가 되어 서버가 다른 키로 인식하고 코드 30 을 돌려준다.
secrets_dpapi.normalize_service_key() 가 저장 시점에 원본으로 되돌리므로
어느 형태를 붙여넣어도 같은 요청이 나가야 한다. 이 파일이 그것을 증명한다.
"""
from __future__ import annotations
import urllib.parse
import httpx
import pytest
from dmf_crawler.secrets_dpapi import normalize_service_key
ENDPOINT = "https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01"
DECODED_KEY = "Xk9+aB/cD3fG=hIjKlM4nOp+qR/sT7uVwXyZ012345678=="
ENCODED_KEY = urllib.parse.quote(DECODED_KEY, safe="")
PARAMS = {"pageNo": 1, "numOfRows": 999, "type": "json"}
def server_side_view(url: str) -> str:
"""서버가 실제로 보게 되는 serviceKey 값을 재현한다."""
query = urllib.parse.urlparse(str(url)).query
return urllib.parse.parse_qs(query, keep_blank_values=True).get("serviceKey", [""])[0]
def request_url(service_key: str) -> str:
"""httpx 가 params= 로 조립하는 실제 URL."""
request = httpx.Request(
"GET", ENDPOINT,
params={"serviceKey": service_key, **PARAMS},
)
return str(request.url)
def test_encoding_key_without_normalization_breaks():
"""실패 재현 — 정규화 없이 Encoding 키를 넣으면 서버가 다른 키를 본다."""
seen = server_side_view(request_url(ENCODED_KEY))
assert seen != DECODED_KEY, "이 단언이 깨지면 httpx 동작이 바뀐 것이다"
assert "%25" in str(request_url(ENCODED_KEY)), "이중 인코딩 흔적(%25)이 있어야 한다"
def test_decoding_key_without_normalization_works():
"""정상 — Decoding 키는 정규화 없이도 올바르다."""
assert server_side_view(request_url(DECODED_KEY)) == DECODED_KEY
@pytest.mark.parametrize("raw", [
DECODED_KEY,
ENCODED_KEY,
f" {DECODED_KEY} ",
f'"{DECODED_KEY}"',
f"'{ENCODED_KEY}'",
f"\t{ENCODED_KEY}\n",
])
def test_normalized_key_always_produces_same_request(raw: str):
"""어느 형태를 붙여넣어도 서버가 보는 키는 언제나 원본이다."""
assert server_side_view(request_url(normalize_service_key(raw))) == DECODED_KEY
def test_normalize_is_idempotent():
once = normalize_service_key(ENCODED_KEY)
assert normalize_service_key(once) == once == DECODED_KEY
def test_normalize_rejects_nothing_silently():
"""빈 문자열은 빈 문자열로 남는다 — 저장 단계에서 ValueError 로 걸린다."""
assert normalize_service_key(" ") == ""
```
### 6.7 오류가 아니지만 경보해야 하는 것
| 상황 | 왜 오류가 아닌가 | 왜 경보해야 하는가 | 게이트 | 등급 |
|---|---|---|---|---|
| `NODATA_ERROR`(03) 이 **첫 페이지**에서 발생 | 프로토콜상 정상 응답 | DMF 전체가 0건일 리 없다. 원천 이상 신호 | 게이트 2 (`len == totalCount`, 0 ≠ 9,084) | WARN + diff 중단 |
| 수집 건수 ≠ `totalCount` | 각 호출은 200 이었다 | **취하 오탐의 직접 원인** (C21) | 게이트 2 | WARN + diff 중단 |
| `totalCount` 가 전일 대비 5% 이상 급감 | 정상 응답 | 원천 데이터 사고 또는 API 이상 | 게이트 3 | WARN + diff 중단 |
| 필수 필드 널 비율 급증 | 정상 응답 | 스키마가 조용히 바뀌었을 가능성 | 게이트 4 | WARN + diff 중단 |
| 중복 `DMF_PERMIT_NO` 급증 | 정상 응답 | 기준선은 중복 0건 (`00b` §5.1) | 게이트 5 | WARN + diff 중단 |
| **응답 필드 집합 변화** | 정상 응답 | 새 필드 추가·기존 필드 개명 | **게이트 6 (신설, §8.4)** | WARN 또는 CRITICAL |
> 이 여섯 게이트가 전부 통과해야 diff 를 수행한다. 하나라도 실패하면 **스냅샷 INSERT 도 하지 않는다**(기준선 오염 방지, 아키텍처 §3.6).
---
## 7. 출처 표시 문구 확정
### 7.1 법적 지위 정리
| 질문 | 답 | 근거 |
|---|---|---|
| 공공누리 유형이 붙어 있는가? | **아니다.** 배지도 없고 `license``"이용허락범위 제한 없음"` | DCAT 메타 (C2, C13) |
| 출처표시가 법적 의무인가? | **확인되지 않았다** (공공누리였다면 전 유형 필수였을 것) | https://www.kogl.or.kr/info/license.do |
| 그럼 왜 넣는가? | ① 데이터 신뢰성의 근거 ② 분쟁 시 방어 ③ 리포트를 받는 사람이 원본을 찾아갈 수 있다. 비용은 셀 한 줄이다 | — |
| 하면 안 되는 것은? | 「사실적 내용을 위·변조 및 왜곡」 (FAQ 186) | C12 |
### 7.2 데이터셋 정식 명칭 — 지어내지 않는다
포털에 등록된 **정식 명칭은 하나**다. 리포트·문서·알림 어디서도 다른 이름을 쓰지 않는다.
| 항목 | 확정값 |
|---|---|
| 데이터셋 정식 명칭 | **`식품의약품안전처_원료의약품등록(DMF)현황`** |
| 인용 표기 형태 | 식품의약품안전처 「원료의약품등록(DMF)현황」 |
| 데이터셋 번호 | `15057075` |
| 데이터셋 URL | `https://www.data.go.kr/data/15057075/openapi.do` |
| 제공기관 | 식품의약품안전처 |
| 관리부서 | 데이터혁신기획팀 |
> ⚠️ **개정 요청 A6**: `design/03-xlsx-report-spec.md` 의 블록 6(출처·면책)과 `s99_meta.py` 예시가 「의약품 원료의약품 등록 정보」라는 **포털에 존재하지 않는 명칭**을 쓰고 있다. 아래 §7.3 의 확정 문구로 교체할 것. 없는 이름을 리포트에 박으면 받아보는 사람이 원본을 찾지 못하고, "이 데이터 어디서 났느냐"는 질문에 답할 수 없게 된다.
### 7.3 확정 문구 3종
**① 정식 (긴 형태)** — 대시보드 블록 6, `s99_meta` 시트, README, 배포 메일 본문
```
출처: 식품의약품안전처 「원료의약품등록(DMF)현황」 오픈API (공공데이터포털 데이터셋 15057075,
https://www.data.go.kr/data/15057075/openapi.do)
수집 2026-09-02 06:02 KST · 9,084건 · 제공기관 식품의약품안전처 데이터혁신기획팀
본 리포트는 공공데이터를 자동 수집·가공한 참고 자료이며 법적 효력이 없습니다.
```
**② 표준 (짧은 형태)** — 각 데이터 시트 마지막 행 아래 한 줄, 인쇄 바닥글
```
출처: 식품의약품안전처_원료의약품등록(DMF)현황 (공공데이터포털 15057075) · 수집 2026-09-02
```
**③ 각주 (문서용)** — `docs/` 하위 문서에서 이 데이터를 인용할 때
```
[출처] 식품의약품안전처, 「원료의약품등록(DMF)현황」, 공공데이터포털 오픈API
(데이터셋 15057075), https://www.data.go.kr/data/15057075/openapi.do, 조회일 YYYY-MM-DD.
```
### 7.4 xlsx 삽입 위치와 서식 (확정)
| 시트 | 위치 | 문구 | 서식 |
|---|---|---|---|
| `s00_dashboard` | 블록 6 (`A63:E66` 영역) | ① 정식 (4줄) | 9pt, `#5B6770`, 좌측 정렬 |
| `s99_meta` | 메타 표 하단 | ① 정식 + `run_id` · 원문 아카이브 경로 | 동일 |
| `s01_changes` · `s02_ledger` · `s03_ingredient` · `s04_company` · `s05_watchlist` · `s06_trend` | 데이터 마지막 행 + 2, A열 | ② 표준 | 8pt, `#5B6770` |
| 모든 시트 | 인쇄 바닥글 좌측(`&L`) | ② 표준 | 8pt |
**구현 — `report/widgets.py` (XlsxWriter)**
아키텍처 §8.1 이 xlsx 생성을 **XlsxWriter 전담**으로 확정했다. `openpyxl` 을 쓰지 않는다.
```python
# src/dmf_crawler/report/widgets.py (발췌) — 출처 표시
#
# 문구의 정본은 ops/03-api-usage-policy.md 7.3 이다.
# 다른 모듈에서 이 문자열을 복제하지 않는다. 반드시 이 함수를 호출한다.
from __future__ import annotations
from datetime import datetime
DATASET_TITLE = "식품의약품안전처_원료의약품등록(DMF)현황"
DATASET_TITLE_QUOTED = "식품의약품안전처 「원료의약품등록(DMF)현황」"
DATASET_ID = "15057075"
DATASET_URL = "https://www.data.go.kr/data/15057075/openapi.do"
PROVIDER = "식품의약품안전처"
PROVIDER_TEAM = "데이터혁신기획팀"
DISCLAIMER = "본 리포트는 공공데이터를 자동 수집·가공한 참고 자료이며 법적 효력이 없습니다."
def attribution_lines(collected_at: datetime, record_count: int) -> list[str]:
"""① 정식 (긴 형태) — 4줄. 대시보드 블록 6 과 s99_meta 가 쓴다."""
return [
f"출처: {DATASET_TITLE_QUOTED} 오픈API",
f"(공공데이터포털 데이터셋 {DATASET_ID}, {DATASET_URL})",
f"수집 {collected_at:%Y-%m-%d %H:%M} KST · {record_count:,}건 · "
f"제공기관 {PROVIDER} {PROVIDER_TEAM}",
DISCLAIMER,
]
def attribution_short(collected_at: datetime) -> str:
"""② 표준 (짧은 형태) — 데이터 시트 하단과 인쇄 바닥글."""
return (f"출처: {DATASET_TITLE} (공공데이터포털 {DATASET_ID}) · "
f"수집 {collected_at:%Y-%m-%d}")
def attribution_footnote(collected_at: datetime) -> str:
"""③ 각주 (문서용)."""
return (f"[출처] {PROVIDER}, 「원료의약품등록(DMF)현황」, 공공데이터포털 오픈API "
f"(데이터셋 {DATASET_ID}), {DATASET_URL}, 조회일 {collected_at:%Y-%m-%d}.")
def write_attribution_block(ws, theme, first_row: int, first_col: int,
last_col: int, collected_at: datetime,
record_count: int) -> int:
"""① 정식 문구를 블록으로 쓴다. 마지막으로 사용한 행 번호를 돌려준다."""
fmt = theme.fmt("attribution") # 9pt / #5B6770 / 좌측 정렬 / 줄바꿈 없음
row = first_row
for line in attribution_lines(collected_at, record_count):
ws.merge_range(row, first_col, row, last_col, line, fmt)
row += 1
return row - 1
def write_attribution_line(ws, theme, row: int, col: int,
last_col: int, collected_at: datetime) -> None:
"""② 표준 문구를 데이터 시트 하단에 한 줄로 쓴다."""
fmt = theme.fmt("attribution_small") # 8pt / #5B6770
text = attribution_short(collected_at)
if last_col > col:
ws.merge_range(row, col, row, last_col, text, fmt)
else:
ws.write(row, col, text, fmt)
def set_attribution_footer(ws, collected_at: datetime, sheet_label: str) -> None:
"""인쇄 바닥글: 좌측에 출처, 우측에 시트명과 페이지."""
left = attribution_short(collected_at).replace("&", "&&")
ws.set_footer(
f'&L&"맑은 고딕"&8{left}'
f'&R&"맑은 고딕"&8{sheet_label} · &P/&N'
)
```
> `&` 는 XlsxWriter 의 머리글·바닥글 서식 이스케이프 문자다. 출처 문구에는 지금 `&` 가 없지만, 나중에 기관명이 바뀌어 들어올 수 있으므로 `&&` 치환을 미리 넣는다.
### 7.5 왜곡 금지 — 구조로 보장한다
FAQ 186 은 「사실적 내용을 위·변조 및 왜곡하여 사용하는 것까지 보장하는 것은 아닙니다」라고 못박는다. 이 프로젝트는 성분명·제조소명·국가명을 **정규화**한다(`normalize.py`, `agy` 역할 A2/A5). 정규화는 왜곡이 아니지만, **원본을 지우면 왜곡과 구분할 수 없게 된다.**
**확정 규칙**: 정규화된 값을 넣는 모든 컬럼에는 **원본 컬럼을 나란히 보존**한다.
| 리포트 컬럼 | 내용 | 출처 |
|---|---|---|
| `성분명` | 원본 `INGR_KOR_NAME` **그대로** | API 원본 |
| `성분명(정규화)` | 표기 통일 결과 | 파생 |
| `제조소명` | 원본 `MNFCTR_NAME` **그대로** (`CORTICOSTER OIDO` 같은 원본 오타 포함) | API 원본 |
| `제조소명(정리)` | 공백·대소문자 정리 결과 | 파생 |
| `제조국가` | 원본 `MANUF_COUNTRY_CODE_NM` **그대로** (`이탈리아,스위스`) | API 원본 |
| `제조국가(분리)` | 콤마 분해 결과 | 파생 |
파생 컬럼의 헤더에는 셀 주석(`write_comment`)으로 「이 값은 원본을 읽기 쉽게 정리한 것입니다. 원본은 왼쪽 열에 있습니다.」를 단다.
**AI 산출물의 구분 표시**: `agy` 가 만든 요약·해석은 **반드시 "AI 요약"이라는 라벨과 함께** 표시하고, 원본 데이터 셀에 섞어 넣지 않는다. AI 문장을 사실처럼 제시하는 것은 §C12 의 "왜곡"에 가장 가까운 행위다.
---
## 8. API 버전·중단 대응과 스키마 변경 감지
### 8.1 URL 의 `01` 접미사
```
https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01
^^^^^^^ ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
기관코드 서비스명 + "01" 오퍼레이션 + "01"
```
| 관찰 | 해석 | 신뢰도 |
|---|---|---|
| 구버전은 `data.mfds.go.kr/openapi/MdcDmfInfoService/getMdcDmfList`**접미사 없음** | 식약처가 `data.mfds.go.kr``apis.data.go.kr` 로 이관하며 `01` 을 붙였다 | 정황 근거 |
| `01` 이 "버전 번호"라고 명시한 문서 | **찾지 못했다** | ⚠️ **미검증** |
| `02` 로 올라간 전례 | 확인하지 못했다 | ⚠️ 미검증 |
**설계 결론**: `01` 을 버전으로 **가정**하되, 그 가정에 의존하는 자동 동작을 만들지 않는다. `02` 를 자동으로 시도해보는 로직은 **넣지 않는다** — 존재하지 않는 엔드포인트를 때리는 것은 무의미한 트래픽이고, 만에 하나 `02` 가 다른 스키마라면 **조용히 잘못된 데이터를 넣게 된다.** 사람이 확인하고 `config/config.toml` 의 URL 을 고친다.
> 엔드포인트 URL 을 코드 상수가 아니라 **`config.toml` 의 `[source]` 키**로 두는 이유가 여기에 있다. 버전이 올라갔을 때 재배포 없이 설정 한 줄로 대응할 수 있어야 한다.
### 8.2 변경·중단 통보를 확인하는 방법
| 경로 | 통보가 오는가 | 확인 방법 | 자동화 |
|---|---|---|---|
| 포털 이용약관 제9조 | **포털 서비스 전체**의 영구 중단만 「3개월전 회원에게 공지」 | 포털 공지사항 | ❌ |
| 개별 오픈API 폐기·버전 상승 시 활용신청자에게 이메일 | ⚠️ **미검증** — 발송 여부 확인 못 함 | 가입 이메일 확인 | ❌ |
| 데이터셋 상세 페이지의 **수정일** | 명세가 바뀌면 갱신됨 (현재 2025-09-19) | 사람이 페이지 열람 | ❌ (HTML 파싱을 하지 않는다 — R1.3) |
| **코드 12 관측** | 폐기 시 반드시 발생 | 배치가 자동 감지 | ✅ **유일한 실용 수단** |
| **스키마 드리프트 게이트** | 필드가 바뀌면 발생 | 배치가 자동 감지 | ✅ |
**결론**: 통보에 기대지 않는다. **코드 12 관측 + 스키마 드리프트 게이트**가 이 프로젝트의 조기 경보 체계 전부다.
### 8.3 코드 12 발생 시 절차 (사람이 하는 일)
1. 복구 GUI: 「식약처가 제공하는 데이터의 주소가 바뀐 것 같습니다. 프로그램 수정이 필요합니다.」 + [데이터 안내 페이지 열기] [로그 폴더 열기]
2. 담당자가 `https://www.data.go.kr/data/15057075/openapi.do` 를 열어 **요청 주소**와 **수정일**을 확인
3. 주소가 `...Service02/getMdcDmfList02` 등으로 바뀌었으면 **`config/config.toml``[source] base_url` 한 줄 수정** → 재배포 불필요
4. 응답 필드가 바뀌었으면 §8.4 의 `EXPECTED_ITEM_FIELDS``normalize.py` 의 매핑을 함께 갱신
5. `python -m dmf_crawler doctor` 로 검증 (체크 ⑤ 통과 확인)
6. `python -m dmf_crawler backfill --reparse --from <날짜> --to <날짜>` 로 원문 아카이브를 새 파서로 재처리
7. 이 문서 §8.1 과 `design/00-DATA-SOURCE-DECISION.md` §4.1 을 갱신
8. 데이터셋 자체가 사라졌다면 폴백 검토 (§8.5)
### 8.4 스키마 드리프트 게이트 (요청 A1 — `integrity.py` 게이트 6)
코드 12(서비스 폐기)는 극단적인 경우이고, 실제로 더 흔한 것은 **같은 엔드포인트가 필드를 하나 추가하거나 이름을 바꾸는 조용한 변화**다. 그런 변화는 오류 코드로 나타나지 않으며, 파서가 조용히 빈 값을 넣는다. 기존 게이트 1~5 를 **전부 통과하면서** 데이터가 망가진다.
```python
# src/dmf_crawler/integrity.py (발췌) — 게이트 6: 스키마 드리프트
#
# 판정 3종:
# * 핵심 필드 누락 → CRITICAL. diff 중단 + BLOCKED.
# * 기타 필드 누락 → WARN. diff 중단.
# * 신규 필드 등장 → WARN. diff 는 수행하되 알림을 남긴다.
# (필드가 늘어난 것은 데이터를 망가뜨리지 않는다)
#
# agy 역할 A7(API 스키마 변화 대응)이 이 게이트의 출력을 입력으로 받는다.
# 자동 반영은 금지이며 사람 승인이 필요하다 — 제안은 state/proposals/ 에 쌓인다.
from __future__ import annotations
from dataclasses import dataclass
# design/00-DATA-SOURCE-DECISION.md 4.3 의 응답 필드 7개
EXPECTED_ITEM_FIELDS: frozenset[str] = frozenset({
"DMF_PERMIT_NO",
"INGR_KOR_NAME",
"ENTP_NAME",
"MNFCTR_NAME",
"MNFCTR_PLACE",
"MANUF_COUNTRY_CODE_NM",
"DMF_PERMIT_DATE",
})
# 이것이 없으면 레코드를 식별하거나 diff 할 수 없다.
CRITICAL_ITEM_FIELDS: frozenset[str] = frozenset({
"DMF_PERMIT_NO",
"INGR_KOR_NAME",
"DMF_PERMIT_DATE",
})
@dataclass(frozen=True, slots=True)
class SchemaDrift:
observed: frozenset[str]
missing: frozenset[str]
missing_critical: frozenset[str]
added: frozenset[str]
@property
def is_critical(self) -> bool:
return bool(self.missing_critical)
@property
def blocks_diff(self) -> bool:
# 필드가 사라지면 diff 를 막는다. 늘어난 것만으로는 막지 않는다.
return bool(self.missing)
def log_text(self) -> str:
parts = []
if self.missing:
parts.append("missing=" + ",".join(sorted(self.missing)))
if self.added:
parts.append("added=" + ",".join(sorted(self.added)))
return " ".join(parts) if parts else "schema=OK"
def user_text(self) -> str:
if self.missing_critical:
return ("식약처가 제공하는 데이터의 항목이 바뀌었습니다. "
"프로그램 수정이 필요합니다. 담당자에게 알려주세요.")
if self.missing:
return ("식약처 데이터에서 일부 항목이 빠졌습니다. "
"오늘 리포트의 변경 판정을 건너뛰었습니다. 담당자에게 알려주세요.")
if self.added:
return ("식약처 데이터에 새로운 항목이 추가되었습니다. "
"리포트에는 아직 반영되지 않았습니다. 담당자에게 알려주세요.")
return ""
def detect_schema_drift(raw_records: list[dict[str, str]]) -> SchemaDrift:
"""수집된 원본 레코드의 필드 집합을 기대치와 대조한다.
표본이 아니라 **전량**을 본다. 일부 레코드에만 새 필드가 붙는 경우가 있고,
그것이야말로 놓치면 안 되는 신호다.
"""
observed: set[str] = set()
for row in raw_records:
observed.update(row.keys())
observed_fs = frozenset(observed)
if not raw_records:
# 0건은 게이트 2가 이미 막는다. 여기서는 판정 불가로 둔다.
return SchemaDrift(frozenset(), frozenset(), frozenset(), frozenset())
return SchemaDrift(
observed=observed_fs,
missing=EXPECTED_ITEM_FIELDS - observed_fs,
missing_critical=CRITICAL_ITEM_FIELDS - observed_fs,
added=observed_fs - EXPECTED_ITEM_FIELDS,
)
def gate_schema_drift(raw_records: list[dict[str, str]]) -> "Gate":
"""게이트 6. evaluate() 의 게이트 튜플에 추가한다."""
drift = detect_schema_drift(raw_records)
return Gate(
name="schema_drift",
passed=not drift.blocks_diff,
detail=drift.log_text(),
)
```
**게이트 6 의 판정과 결과**
| 관측 | `passed` | diff | 파이프라인 상태 | 알림 |
|---|---|---|---|---|
| 필드 집합 일치 | ✅ | 수행 | `SUCCESS` | 없음 |
| 신규 필드만 추가됨 | ✅ | 수행 | `SUCCESS` | WARN (1회, dedup `schema:added:<필드명>`) |
| 비핵심 필드 누락 | ❌ | 중단 | `PARTIAL` | WARN |
| **핵심 필드 누락** | ❌ | 중단 | **`BLOCKED`(2)** | **CRITICAL** |
> **신규 필드에 WARN 을 붙이되 막지 않는 이유**: 식약처가 필드를 하나 추가했다는 것은 리포트에 넣을 정보가 늘었다는 뜻이지, 오늘 자료가 틀렸다는 뜻이 아니다. 막으면 좋은 소식 때문에 서비스가 멈춘다. 대신 알림으로 남겨 사람이 리포트 확장을 검토하게 한다.
### 8.5 폴백 경로 — 기록만 하고 쓰지 않는다
| 후보 | 경로 | 상태 | 왜 드롭인 폴백이 아닌가 |
|---|---|---|---|
| 연계데이터 2095 | `http://data.mfds.go.kr/openapi/MdcDmfInfoService/getMdcDmfList` | 생존 (실측) | ① HTTP 평문 ② **별도 활용신청 필요** — 더미 키로도 `SERVICE ACCESS DENIED ERROR!` ③ 오류 봉투가 HTTP 200 + `response/header` 로 다름 → **파서 재작성 필요** |
| 파일데이터(벌크 CSV/XLSX) | 확인되지 않음 | ⚠️ 미검증 | 존재 여부 자체가 미확인 |
| 의약품안전나라 화면 | `nedrug.mfds.go.kr/pbp/CCBAC03` | 생존 | **`robots.txt` 전면 금지.** 요구 R1.3 에 따라 절대 쓰지 않는다 |
| 공식 데이터 제공 요청 | — | — | `ops/04-official-data-request-channels.md` 참조 |
**폴백 발동 조건**: 현행 API 가 **코드 12 로 7일 연속 실패**하고, 데이터셋 페이지 확인 결과 **대체 엔드포인트도 없을 때**. 그때 연계데이터 2095 에 활용신청을 하고 `parse_body()` 에 분기를 추가한다. 그 전에는 절대 자동 전환하지 않는다. 자동 전환은 "다른 데이터를 같은 데이터인 척 넣는" 최악의 실패로 이어질 수 있다.
---
## 9. 레이트 리밋 매너 — 확정 파라미터
### 9.1 왜 보수적으로 가는가
| 근거 | 함의 |
|---|---|
| 코드 23 (초당 제한) 신설 (C5) | 병렬 호출 금지, 요청 간 지연 필수 |
| 코드 29 (IP 차단) 신설 (C6) | 걸리면 코드로 복구 불가. 사람이 전화해야 한다 |
| 이용약관 제14조5항 (C7) | "특정 회원의 이용형태"가 문제되지 않게 |
| 트래픽 여유 **99.85%** (§4) | **빨리 받을 이유가 전혀 없다.** 10페이지에 8초 더 쓰는 것은 공짜다 |
### 9.2 확정값 표
정책의 정본은 아키텍처 §3.3 `http.py` 다. 이 문서는 **그 값들이 왜 그 값인지의 근거**와, `source_mfds.py` 층에서 추가로 지킬 규칙을 확정한다.
| 파라미터 | 확정값 | 정본 | 근거 |
|---|---|---|---|
| **동시성 (병렬 요청 수)** | **1 (완전 직렬)** | 이 문서 | 코드 23·29 회피. **협상 불가** |
| 요청 간 최소 간격 | **0.7초** (`min_interval_seconds`) | 아키텍처 §3.3 | `00-DATA-SOURCE-DECISION` §4.5 의 "0.5~1초" 범위 안 |
| 간격 지터 | **+0 ~ +0.3초** | 아키텍처 §3.3 | 정확히 주기적인 트래픽은 봇으로 보인다 |
| 최대 시도 횟수 | **4회** | 아키텍처 §3.3 | 그 이상은 회복 가능성이 낮다 |
| 백오프 | **base 5s · factor 2 · cap 300s · full jitter** | 아키텍처 §3.3 | 5 → 10 → 20 → 40 (각각 0~값 사이 난수). full jitter 는 동시 재시도 몰림을 없앤다 |
| `Retry-After` | **절대 우선** (계산된 백오프를 무시) | 아키텍처 §3.3 | 429/503 에서 서버가 말한 값을 따르는 것이 정중함의 정의 |
| 조건부 요청 | `If-None-Match` / `If-Modified-Since` | 아키텍처 §3.3 | 304 면 본문 전송이 없다. 서버 부하 절감 |
| User-Agent | `DMF-Crawler/<version> (+contact: <config 의 연락처>)` | 아키텍처 §3.3 | 식별 가능한 클라이언트. 문제 시 기관이 연락할 수 있다 |
| **코드 23 관측 시** | 간격 **×2**, 상한 **10초** | 이 문서 | 서버가 느리라고 하면 즉시 순응 |
| **간격 회복** | 연속 **5회** 성공 시 **×0.8**, 하한 = 0.7초 | 이 문서 | 한 번 느려진 채로 남지 않게 |
| `numOfRows` | **999** (실패 시 500 → 100 자동 후퇴) | `config.toml` `[source] page_size` | ⚠️ 최대값 미검증 |
| `type` | `json` | `config.toml` | XML 폴백 파서를 항상 함께 둔다 (C16) |
| 연결 타임아웃 | **10초** | `config.toml` | |
| 읽기 타임아웃 | **30초** | `config.toml` | `numOfRows=999` 응답이 클 수 있다 |
| 페이지 순회 상한 | **10,000 페이지** | 이 문서 | 무한 루프 안전핀 |
| 전체 수집 시간 상한 | **15분** | `config.toml` | 초과 시 워치독이 종료 (R5.4) |
| **코드 22 재시도** | **금지.** 다음 자정+5분 재예약 | 이 문서 | 재시도가 카운터만 더 태운다 |
| **코드 29 재시도** | **절대 금지** | 이 문서 | 재시도가 상황을 악화시킨다 |
### 9.3 하루 트래픽 프로파일 (이 값들의 결과)
```
06:00:00 체크 ⑤ (numOfRows=1) 1 호출
06:00:01 totalCount 취득 (numOfRows=1) 1 호출 ← 아키텍처 3.4 의 선행 호출
06:00:02 page 1 ~0.7s + 지터
06:00:03 page 2 ~0.7s + 지터
...
06:00:11 page 10
06:00:12 수집 완료 — 총 12 호출, 소요 약 12~20초
```
하루 총 12~15 호출, 초당 최대 약 1.2회, 병렬 0. **어떤 기준으로도 정중하다.**
### 9.4 `source_mfds.py` 층의 추가 규칙 (완결 코드)
`http.py` 가 전송 계층의 정중함을 책임지고, `source_mfds.py` 는 **페이지 순회 전략**과 **코드 23 순응**을 책임진다.
```python
# src/dmf_crawler/source_mfds.py (발췌) — 페이지 순회와 코드 23 순응
#
# 레이트 리밋 매너 파라미터의 근거는 ops/03-api-usage-policy.md 9.2 다.
# 전송 계층(타임아웃·백오프·Retry-After)은 http.HttpClient 가 이미 처리한다.
# 여기서는 '페이지 사이'의 정중함과 numOfRows 후퇴 협상만 다룬다.
from __future__ import annotations
import math
import random
import time
SLOW_DOWN_FACTOR = 2.0
MAX_INTERVAL_SEC = 10.0
RECOVERY_AFTER_SUCCESSES = 5
RECOVERY_FACTOR = 0.8
PAGE_LIMIT = 10_000
PAGE_SIZE_FALLBACKS = (999, 500, 100)
class PoliteInterval:
"""페이지 간 간격을 관리한다. 코드 23 을 만나면 늘리고, 안정되면 되돌린다."""
def __init__(self, base_seconds: float = 0.7, jitter_seconds: float = 0.3) -> None:
self._base = base_seconds
self._jitter = jitter_seconds
self._current = base_seconds
self._streak = 0
def sleep(self) -> None:
time.sleep(self._current + random.uniform(0.0, self._jitter))
def on_slow_down(self) -> float:
"""코드 23 관측. 간격을 배증한다(상한 10초)."""
self._current = min(self._current * SLOW_DOWN_FACTOR, MAX_INTERVAL_SEC)
self._streak = 0
return self._current
def on_success(self) -> None:
self._streak += 1
if self._streak >= RECOVERY_AFTER_SUCCESSES and self._current > self._base:
self._current = max(self._base, self._current * RECOVERY_FACTOR)
self._streak = 0
@property
def current(self) -> float:
return self._current
def negotiate_page_size(fetch_one, preferred: int) -> int:
"""numOfRows 최대값이 미검증이므로 999 → 500 → 100 순으로 후퇴한다.
코드 10(INVALID_REQUEST_PARAMETER_ERROR)이 나면 다음 후보로 내려간다.
그 외 오류는 그대로 올려보낸다 — 여기서 삼키면 인증 오류가 숨는다.
확정된 값은 호출부가 로그와 fetch_stats 에 남겨 부록 B 를 갱신한다.
fetch_one(size) -> None : 성공하면 반환, 실패하면 FetchError(spec) 을 던지는 콜러블
"""
candidates = [preferred] + [c for c in PAGE_SIZE_FALLBACKS if c != preferred]
last_error: Exception | None = None
for size in candidates:
try:
fetch_one(size)
return size
except FetchError as exc:
if getattr(exc, "spec", None) is not None and exc.spec.code == "10":
last_error = exc
continue
raise
raise last_error or FetchError("모든 numOfRows 후보가 거부되었습니다.")
def plan_pages(total_count: int, page_size: int) -> int:
"""순회할 페이지 수. 안전핀으로 상한을 건다."""
return min(max(1, math.ceil(total_count / page_size)), PAGE_LIMIT)
```
### 9.5 하지 말아야 할 것 (금지 목록)
| 금지 | 왜 |
|---|---|
| `ThreadPoolExecutor` / `asyncio.gather` 로 페이지 병렬 수집 | 코드 23 → 코드 29(IP 차단). 이득은 10초, 손실은 사람이 전화해야 하는 복구 |
| 요청 간격을 0.1초로 축소 | 같은 이유. 트래픽 여유가 99.85% 인데 아낄 것이 없다 |
| 코드 22 를 만나고 즉시 재시도 | 카운터만 더 태운다. 자정까지 아무것도 낫지 않는다 |
| 코드 29 를 만나고 재시도 | 상황을 악화시킨다 |
| `http.py` 를 우회해 직접 `httpx.get()` 호출 | 아키텍처 §3.3 — "유일한 네트워크 창구". 우회하면 정중함 정책이 통째로 빠진다 |
| 브라우저(WebView)에서 API 직접 호출 | CORS 는 허용되지만 키가 프론트엔드에 노출된다 (C14) |
| 키를 명령행 인자로 전달 | 프로세스 목록·이벤트 로그에 남는다 (§5.6) |
| 예외·로그에 요청 URL 을 그대로 남기기 | URL 에 `serviceKey` 가 들어 있다. 아키텍처 §3.3 이 요구하는 **마스킹**을 반드시 적용 |
| 응답 원문 전체를 `pipeline.log` 에 남기기 | 용량 문제. 원문은 `data/raw/<date>/` 에만 두고, 로그에는 앞 500자만 |
| `numOfRows` 를 낮춰 "부담을 줄인다"고 생각하기 | 정반대다. 낮추면 **호출 수가 늘어** 초당 제한에 가까워진다 |
---
## 부록 A. 출처
| # | 제목 | URL | 확인 |
|---|---|---|---|
| 1 | **DMF 오픈API 데이터셋 상세** — 요청변수표·오류코드표·인증에러표·트래픽·심의유형·이용허락범위 | https://www.data.go.kr/data/15057075/openapi.do | ✅ 전문 확인 (핵심 출처) |
| 2 | **DCAT/schema.org 기계판독 메타**`"license":"이용허락범위 제한 없음"`, contactPoint | https://www.data.go.kr/catalog/15057075/openapi.json | ✅ 전문 확인 |
| 3 | **공식 FAQ — 트래픽 정의와 자정 초기화** (FAQ_0000000000000208) | https://www.data.go.kr/bbs/faq/selectFaqList.do | ✅ 원문 인용 |
| 4 | **공식 FAQ — 서비스키 1인 1개·재발급 시 자동 폐기** (FAQ_0000000000000171, 159) | https://www.data.go.kr/bbs/faq/selectFaqList.do | ✅ 원문 인용 |
| 5 | **공식 FAQ — 코드 20 의 원인** (FAQ_0000000000000214) | https://www.data.go.kr/bbs/faq/selectFaqList.do | ✅ 원문 인용 |
| 6 | **공식 FAQ — 상업적 이용 허용·왜곡 금지** (FAQ_0000000000000210, 186) | https://www.data.go.kr/bbs/faq/selectFaqList.do | ✅ 원문 인용 |
| 7 | **공식 FAQ — CORS 허용** (FAQ_0000000000000211) | https://www.data.go.kr/bbs/faq/selectFaqList.do | ✅ 원문 인용 |
| 8 | **공식 FAQ — 마이페이지 경로 / 승인 확인** (FAQ_0000000000000264, 208) | https://www.data.go.kr/bbs/faq/selectFaqList.do | ✅ 원문 인용 |
| 9 | **공식 배포 문서 `Open API 에러 코드 정리.docx`** — 코드 0~99 전체표 | https://www.data.go.kr/cmm/cmm/fileDownload.do?atchFileId=FILE_000000001260631&fileDetailSn=0 | ✅ 다운로드·파싱 완료 |
| 10 | **포털 이용약관** — 제9조(중단 공지), 제14조5항(이용 제한), 제15조(지식재산권) | https://www.data.go.kr/ugs/selectPortalPolicyView.do | ✅ 원문 인용 |
| 11 | **API 엔드포인트 실측** — 403 + `OpenAPI_ServiceResponse`(JSON), 401 + 동일 구조(XML) | https://apis.data.go.kr/1471000/MdcDmfInfoService01/getMdcDmfList01 | ✅ 직접 호출 |
| 12 | **연계데이터 2095 (구버전 엔드포인트)** — HTTP 200 + `SERVICE ACCESS DENIED ERROR!` | https://www.data.go.kr/data/2095/linkedData.do | ✅ 열람 + 실측 |
| 13 | **식약처 식의약 데이터 포털** — 개발계정/운영계정 정의, 활용사례 등록 조건 | https://data.mfds.go.kr/cntnts/11 | ✅ 원문 인용 |
| 14 | 공공누리 이용허락범위 유형 (제1~4유형, 출처표시 필수) | https://www.kogl.or.kr/info/license.do | ✅ 열람 |
| 15 | 활용기간 "승인일로부터 24개월" 서술 (2차 출처) | https://beaver-sohyun.tistory.com/38 | ⚠️ **미검증** — 1차 출처 확인 실패 |
| 16 | 개발계정 신청 폼 (활용기간 원문 확인 시도) | https://www.data.go.kr/tcs/dss/redirectDevAcountRequestForm.do | ❌ 비로그인 시 `/index.do` 리다이렉트 |
| 17 | 활용지원센터 1566-0025 (평일 09~18시) · opendata_help@nia.or.kr · 식약처 데이터혁신기획팀 043-719-1623 | https://www.data.go.kr/catalog/15057075/openapi.json (contactPoint) | ✅ 확인 |
| 18 | 상위 문서 — 데이터 소스 결정 SSOT | `docs/design/00-DATA-SOURCE-DECISION.md` | ✅ 정독 |
| 19 | 상위 문서 — **아키텍처 SSOT** (디렉터리·모듈 계약·CLI·종료 코드) | `docs/design/01-architecture.md` | ✅ 정독 (§2, §3.2~3.6, §3.10, §3.15, §5, §6, §8.1) |
| 20 | 상위 문서 — 기준선 실측 (`totalCount` 9,084건, 중복 0, 취하 판정 근거 D2) | `docs/design/00b-baseline-data-analysis.md` | ✅ 정독 |
| 21 | 상위 문서 — 요구사항 SSOT | `docs/00-REQUIREMENTS.md` | ✅ 정독 |
| 22 | `agy` CLI 정본 (인증 실패 감지·headless 규약·권한 모델) | `docs/research/05a-agy-cli-ssot.md` | ✅ 정독 |
| 23 | DMF 웹 화면 총건수 실측 9,840건 (API 와 756건 차이의 근거) | `docs/research/01-dmf-domain-and-sources.md` §4.1 | ✅ 확인 |
| 24 | xlsx 리포트 명세 (출처 블록 위치·서식 토큰) | `docs/design/03-xlsx-report-spec.md` | ✅ 확인 — §7.2 에 개정 요청 A6 |
| 25 | 공식 데이터 제공 요청 경로 (폴백 검토 시 참조) | `docs/ops/04-official-data-request-channels.md` | 참조 |
---
## 부록 B. 미해결 / 실측 필요
### B.1 인증키 관련 (최우선)
- [ ] **개발계정 활용기간의 정확한 길이.** 2차 출처는 「승인일로부터 24개월」이나 신청 폼이 로그인 벽 뒤에 있어 원문 미확인. 사용자가 로그인 후 **마이페이지 > 데이터 활용 > Open API > 활용신청 현황** 화면에서 만료일을 확인해 GUI 의 [인증키 만료일 등록] 으로 넣을 것. 등록되는 순간 §5.5 의 경보가 가정치에서 실측치로 승격된다.
- [ ] **연장 신청의 조건.** 만료 전에만 가능한가, 만료 후에도 가능한가? 몇 회까지 가능한가? 연장 시 **키 문자열이 유지되는가 바뀌는가?** 바뀐다면 연장 후 재입력이 필요하므로 §5.7 의 코드 31 문구를 고쳐야 한다.
- [ ] **🔴 '프로젝트 서비스키' vs '개인 서비스키'.** 활용신청 모달에 「기업회원입니다. 활용신청할 서비스키를 선택해주세요 / 프로젝트 서비스키 / 개인 서비스키」라는 선택지가 나타난다. '서비스키는 1인당 1개'라는 FAQ 와 충돌하는 것처럼 보이는 2025년 이후 신설 개념이다. **프로젝트 서비스키가 별도 수명·별도 트래픽을 갖고 재발급 시 개인 키와 독립적이라면, C8(재발급으로 배치가 죽는 위험)을 근본적으로 제거할 수 있다.** 이 프로젝트에 가장 값어치 있는 미해결 질문이다.
- [ ] DPAPI 저장 파일(`service_key.bin`)이 Windows 사용자 프로필 백업·이전 시 어떻게 되는가? 새 PC 이전 절차(N6)에서 키를 다시 입력해야 한다는 사실을 `ops/01-scheduling-and-resilience.md` 의 신규 PC 설치 절차에 명시할 것.
### B.2 API 명세 관련
- [ ] **`numOfRows` 의 실제 최대값.** 999 / 1000 / 5000 을 각각 호출해 어디서 코드 10 이 나거나 값이 잘리는지 실측. `negotiate_page_size()` 가 자동 후퇴하지만, 확정되면 `config.toml``page_size` 를 정확한 값으로 고정할 수 있다.
- [ ] **API `totalCount` 가 실제로 9,084 인가.** 기준선은 프로토타입이 만든 xlsx 의 행 수다(`00b` §4). API 응답의 `totalCount` 필드를 직접 읽어 대조할 것. 최초 실행 로그와 `fetch_stats` 에 남는다.
- [ ] **웹 9,840 API 9,084 = 756건이 정말 취하·취소 건인가.** 표본 대조 필요. 이 가정이 결정 D2(취하 판정)의 토대다.
- [ ] `type=json` 이 **정상 응답**에서도 실제로 동작하는가? (오류 응답이 XML 로 오는 것은 실측 확인. 정상 응답은 미확인 — 그래서 XML 폴백 파서를 둔다)
- [ ] 응답에 `DMF_PERMIT_NO` 중복이 존재하는가? 기준선은 중복 0건이나, 동일 등록번호에 제조소가 여럿인 경우가 나중에 나타날 수 있다. 게이트 5 의 임계값(2%)이 적절한지 재검토.
- [ ] 취하·말소된 등록번호가 응답에서 **사라지는가**를 연속 2일 이상 수집해 관찰 (`00b` 부록의 미해결 항목과 동일)
- [ ] 참고문서 `IROS_76_원료의약품(DMF)현황_v1.1.docx` 에 명세 외 추가 정보가 있는가? (특히 `numOfRows` 상한과 오류 응답 예시)
### B.3 운영·정책 관련
- [ ] **데이터 실제 갱신 주기.** 갱신주기 표기가 없다(C20). `totalCount` 와 데이터 해시를 매일(가능하면 하루 여러 시각) 기록해 2~4주 관측 후 06:00 이 적절한지 재판정.
- [ ] **운영계정의 기본 트래픽 한도.** 2차 출처는 「하루 최대 10만 건」이나 공식 문구 미확인. 이 프로젝트는 운영계정이 필요 없으므로 실무상 무해하나, 문서에 숫자를 쓸 거라면 재확인 필요.
- [ ] **개별 오픈API 폐기·버전 상승 시 활용신청자에게 이메일이 발송되는가?** 발송되지 않는다면 코드 12 관측이 유일한 탐지 수단이다 (§8.2).
- [ ] **URL 의 `01` 접미사가 버전 번호라는 해석의 근거.** 다른 식약처 API 중 `02` 로 올라간 전례가 있는가? 있다면 §8.3 대응 시나리오를 구체화할 수 있다.
- [ ] **파일데이터(벌크 CSV/XLSX) 제공 여부.** 존재한다면 API 장애 시 완전한 대체 경로가 된다 (§8.5).
- [ ] **연계데이터 2095 의 활용신청 경로와 조건.** 폴백으로 쓸 계획이라면 자동승인인지, 응답 필드가 현행과 동일한지 미리 확인해둘 것.
- [ ] **`Retry-After` 헤더가 이 API 에서 실제로 오는가?** 아키텍처 §3.3 이 이 헤더를 절대 우선으로 두는데, 공공데이터포털 GW 가 429/503 에서 이 헤더를 붙이는지 확인되지 않았다. 오지 않으면 계산된 백오프만 동작한다(무해).
- [ ] **조건부 요청(`If-None-Match` / `If-Modified-Since`)에 304 를 돌려주는가?** 이 API 는 매 요청이 동적 조회이므로 304 가 오지 않을 가능성이 높다. 오면 트래픽이 더 줄어든다.
### B.4 상위 문서에 반영이 필요한 사항
- [ ] **A1**`design/01-architecture.md` §3.6 `integrity.py`**게이트 6(스키마 드리프트)** 추가 (§8.4)
- [ ] **A2**`design/01-architecture.md` §3.15 `checks.py`**체크 ⑬(인증키 수명)** 추가 (§5.5)
- [ ] **A3**`design/01-architecture.md` §3.15 `checks.py`**체크 ⑭(트래픽 예산)** 추가 (§4.4)
- [ ] **A4**`design/01-architecture.md` §2 트리의 `state/`**`key_meta.json`** 추가 (§5.3)
- [ ] **A5**`design/01-architecture.md` §2 트리의 `tests/`**`test_service_key.py`** 추가 (§6.6)
- [ ] **A6**`design/03-xlsx-report-spec.md` 의 출처 문구를 **정식 명칭 `식품의약품안전처_원료의약품등록(DMF)현황`** 으로 교체 (§7.2·§7.3). 현재 「의약품 원료의약품 등록 정보」라는 포털에 없는 이름을 쓰고 있다
- [ ] **A7**`design/00-DATA-SOURCE-DECISION.md` §9 에 "배포본은 DPAPI 저장소(`secrets_dpapi.py`), `.env` 는 개발자 폴백. 어느 형태의 키를 넣어도 저장 시 Decoding 형태로 정규화된다" 한 줄 추가 (§1.4)
- [ ] **A8**`ops/02-failure-alerting.md` 작성 시 §6.5 의 **등급 → 상태 → 종료 코드 → GUI 매핑**과 §5.7 의 **4요소 문구표**를 그대로 채택할 것. 두 문서가 다른 매핑을 가지면 복구 경로가 어긋난다
- [ ] **A9**`design/04-onboarding-wizard.md` 작성 시 §3.1 의 **9단계 화면 절차**와 §5.6 의 **액션 4종**을 반영할 것
---
*이 문서는 오픈API 이용 정책과 운영 제약에 관한 SSOT 다. 트래픽·인증키·오류코드·출처표시·레이트리밋에 관한 새 사실은 여기를 갱신한다. 디렉터리·모듈 계약·종료 코드가 바뀌면 `design/01-architecture.md` 를 먼저 고치고 이 문서를 맞춘다.*