- 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 문서 지도 갱신
1796 lines
116 KiB
Markdown
1796 lines
116 KiB
Markdown
# 공공데이터 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` 를 먼저 고치고 이 문서를 맞춘다.*
|