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

116 KiB
Raw Blame History

공공데이터 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.3R6.5(키 입력·검증), R7.1R7.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. 전제 — 상위 문서와의 정합
  2. 제약 사실 확정표
  3. 활용신청·승인 절차 — 비개발자용 화면 단계별
  4. 트래픽 계산
  5. 인증키 만료·폐기 대응 — 무인 운영의 시한폭탄
  6. 오류 코드 대응표
  7. 출처 표시 문구 확정
  8. API 버전·중단 대응과 스키마 변경 감지
  9. 레이트 리밋 매너 — 확정 파라미터
  10. 부록 A. 출처
  11. 부록 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.pyHttpClient 아키텍처 §3.3. §9 가 파라미터의 근거를 제공
전량 수집·페이지네이션·본문 시그니처 src/dmf_crawler/source_mfds.pyfetch_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.pypython -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 와의 관계

그 문서는 .envDATA_GO_KR_SERVICE_KEYDecoding 키를 넣으라고 안내한다. 요구 R6.3(다이얼로그 입력·안전 저장)과 N7(키를 로그·저장소·문서에 남기지 않음)이 추가된 지금, 배포본의 정본 저장소는 DPAPI 파일이다.

우선순위 소스 용도
1 %LOCALAPPDATA%\DMF_Crawler\service_key.bin (DPAPI, 현재 사용자) 정본. 비개발자 경로
2 환경변수 DMF_SERVICE_KEY 임시 진단·테스트 (세션 한정)
3 .envDATA_GO_KR_SERVICE_KEY 개발자 전용 폴백. 배포본에 포함하지 않음

httpxparams= 는 값을 자동 인코딩하므로 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 httpxparams= 는 자동 인코딩하므로 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 의 다의성 코드 20SERVICE_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.pyenter_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 이 아니라 코드 상수로 고정한다(설정으로 바꿀 값이 아니다).

# 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 시트에 함께 실린다.

# 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.tomlpage_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)이 함께 들어간다.

# -*- 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)

{
  "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.pyCRITICAL 알림 기록 (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 체크 ⑬)

# 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.logevents.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 는 예외 계층(DmfErrorFetchError …) 전용이며 여기에 오류 코드 표를 넣지 않는다.

# 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.pydedup_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 는 이 프로젝트에서 유일하게 "비개발자의 정상적인 행동이 시스템을 깨뜨리는" 지점이다. 회귀 테스트로 못박는다.

# -*- 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 을 쓰지 않는다.

# 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.krapis.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_FIELDSnormalize.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 를 전부 통과하면서 데이터가 망가진다.

# 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 순응을 책임진다.

# 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.tomlpage_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 상위 문서에 반영이 필요한 사항

  • A1design/01-architecture.md §3.6 integrity.py게이트 6(스키마 드리프트) 추가 (§8.4)
  • A2design/01-architecture.md §3.15 checks.py체크 ⑬(인증키 수명) 추가 (§5.5)
  • A3design/01-architecture.md §3.15 checks.py체크 ⑭(트래픽 예산) 추가 (§4.4)
  • A4design/01-architecture.md §2 트리의 state/key_meta.json 추가 (§5.3)
  • A5design/01-architecture.md §2 트리의 tests/test_service_key.py 추가 (§6.6)
  • A6design/03-xlsx-report-spec.md 의 출처 문구를 정식 명칭 식품의약품안전처_원료의약품등록(DMF)현황 으로 교체 (§7.2·§7.3). 현재 「의약품 원료의약품 등록 정보」라는 포털에 없는 이름을 쓰고 있다
  • A7design/00-DATA-SOURCE-DECISION.md §9 에 "배포본은 DPAPI 저장소(secrets_dpapi.py), .env 는 개발자 폴백. 어느 형태의 키를 넣어도 저장 시 Decoding 형태로 정규화된다" 한 줄 추가 (§1.4)
  • A8ops/02-failure-alerting.md 작성 시 §6.5 의 등급 → 상태 → 종료 코드 → GUI 매핑과 §5.7 의 4요소 문구표를 그대로 채택할 것. 두 문서가 다른 매핑을 가지면 복구 경로가 어긋난다
  • A9design/04-onboarding-wizard.md 작성 시 §3.1 의 9단계 화면 절차와 §5.6 의 액션 4종을 반영할 것

이 문서는 오픈API 이용 정책과 운영 제약에 관한 SSOT 다. 트래픽·인증키·오류코드·출처표시·레이트리밋에 관한 새 사실은 여기를 갱신한다. 디렉터리·모듈 계약·종료 코드가 바뀌면 design/01-architecture.md 를 먼저 고치고 이 문서를 맞춘다.