diff --git a/README.md b/README.md
index 3a35b3d5..f8ff62f4 100644
--- a/README.md
+++ b/README.md
@@ -232,6 +232,7 @@ Mac 중계의 일일 예산은 100배, 분당 속도는 10배로 증액했습니
- [서비스 도구·REST API·아키텍처](docs/service-reference.md)
- [AI 도구 선택·조회 흐름](docs/ai-instruction.md)
+- [무료 위치 검색·쿼터 보호·공공 주소 DB](docs/geocoding-resilience.md)
- [개발·설치·직접 배포](CONTRIBUTING.md)
diff --git a/docs/geocoding-resilience.md b/docs/geocoding-resilience.md
new file mode 100644
index 00000000..096e97fb
--- /dev/null
+++ b/docs/geocoding-resilience.md
@@ -0,0 +1,101 @@
+# 지오코딩 무료 쿼터 보호와 공공 주소 DB 검토
+
+확인일: 2026-10-13. 이 문서는 이번 구현과 공식 자료 조사, 이후 공공 주소 DB 구축안을 구분한다. 사용자는 정부 주소·좌표 자료를 아직 보유하지 않는다고 답했다. 따라서 전국 주소 DB의 적재·조회·갱신은 구현 완료 상태가 아니다. 이번 작업은 유료 API 신청이나 추가 과금 활성화, 무료 키 재발급을 통한 한도 우회를 포함하지 않는다.
+
+## 이번 구현
+
+주소 입력은 Kakao 주소 검색 → Kakao 키워드 검색의 정확한 주소 일치 → Naver 지역 검색의 정확한 주소 일치 순서로 보완한다. 장소명 입력은 기존 Kakao 키워드 검색 → Naver 지역 검색을 유지한다. 키워드·지역 검색은 장소 검색이므로 어떤 주소든 변환하는 지오코딩 API와 동일하지 않다. 입력과 같은 주소의 업체가 검색 결과에 있어야 주소 대체가 가능하다.
+
+주소 비교에서는 입력한 지역 토큰을 독립 토큰으로 비교하고 도로 번호와 건물 번호의 순서를 보존한다. 서로 다른 지번·도로명 필드의 숫자를 합쳐 일치시키지 않는다. 장소명만 입력 주소와 같거나 가까운 다른 가게이면 주소 대체로 선택하지 않는다. 모든 결과는 한국 범위의 유한 위경도인지 검증하며, 정확한 결과가 없으면 `null`을 반환한다. 대체 조회에도 최초 요청의 남은 시간만 전달해 전체 시간 제한을 유지한다.
+
+GS25는 요청 지역과 일치하는 매장 중 유효한 자체 좌표가 있는 매장을 먼저 사용한다. 요청한 지역의 좌표를 끝내 확인하지 못하면 강남 기본 좌표로 조회하지 않고 `GS25_LOCATION_UNRESOLVED`(HTTP 503)를 반환한다.
+
+| 보호 항목 | 동작 |
+| ----------- | -------------------------------------------------------------------------------------------- |
+| 공급자 경로 | Kakao `address.json`, `keyword.json`, Naver `local.json`의 고정 HTTPS 경로에 적용 |
+| 쿼터 소진 | Kakao HTTP 400이며 JSON `code: -10`이면 해당 자격증명·API를 30분 차단 |
+| 요청 제한 | HTTP 429이면 해당 자격증명·API를 1분 차단 |
+| 기타 결과 | 빈 결과·인증 오류·일반 400·5xx를 쿼터 소진으로 저장하지 않음 |
+| 동시 요청 | 자격증명 scope·공급자·정렬한 파라미터·시간 예산이 같은 진행 중 요청만 병합 |
+| 완료된 응답 | 완료 후 진행 중 항목을 제거. 주소·좌표·응답 결과를 이후 조회용으로 보관하지 않음 |
+| 재개 | 차단 만료 뒤 사용자의 실제 요청으로만 다시 조회. 만료가 공급자의 쿼터 초기화를 보장하지 않음 |
+| 입력·본문 | GET·허용 파라미터·검색어 길이·좌표 범위·응답 크기와 요청 기한 제한 |
+| 장애 | 내부 gateway 또는 쿼터 저장소 실패를 이유로 동일 외부 API에 직접 우회하지 않음 |
+
+이 보호는 공급자 오류를 받은 후 반복 호출을 막는 방식이다. 공급자의 잔여 무료량을 원장처럼 사전에 정확히 차감하는 기능이나 유료 상품의 결제 차단 기능은 아니다. 실제 앱의 무료 쿼터·과금 설정은 공급자 콘솔의 설정이 결정한다.
+
+## 인증 scope와 저장 경계
+
+외부 Kakao 요청은 `Authorization: KakaoAK …`를 사용한다. Naver 지역 요청은 `X-Naver-Client-Id`, `X-Naver-Client-Secret`를 사용한다. 보호 어댑터는 이 자격증명 배열을 SHA-256으로 해시해 scope를 만든다. 쿼터 상태 키는 `quota::`이며 원본 키나 검색어를 포함하지 않는다. 같은 Kakao 키라도 주소·키워드 API의 차단 상태는 별도이다.
+
+Worker의 `GEOCODE_GATEWAY` 바인딩이 있는 경로는 내부 Durable Object로 조회를 전달한다. 내부 요청에는 공급자·해시 scope·검색 파라미터·시간 예산이 들어가며 외부 인증 헤더 원문을 전달하지 않는다. Durable Object는 자체 환경의 인증 정보를 읽고 scope 일치를 검증한 뒤 외부 인증 헤더를 구성한다. SQLite-backed 저장소에는 차단 만료시각만 저장한다. 검색어·좌표·응답은 요청 처리와 진행 중 병합에 필요한 메모리에서만 사용한다.
+
+CLI 또는 바인딩 없는 경로는 유한한 프로세스 내부 보호를 사용한다. 이 상태는 별도 프로세스와 공유되지 않는다. 요청별 취소는 병합된 다른 요청의 대기를 취소하지 않는다. 시간 예산이 다르면 병합하지 않는다.
+
+관련 구현: [주소 판별 및 fallback](../src/utils/geocode.ts), [보호 어댑터](../src/utils/geocodeFetch.ts), [공급자 입력·헤더·요청](../src/utils/geocodeProvider.ts), [쿼터와 진행 중 병합](../src/utils/geocodeGate.ts), [Durable Object](../src/durableObjects/geocodeGateway.ts).
+
+## 무료 대안 조사
+
+아래 한도는 확인일의 공식 안내이다. 무료 한도와 응답 저장 권한은 별개이다. 이번 구현에서 새로 가입·키 발급·승인 신청한 공급자는 없다.
+
+| 서비스 | 주소와 장소의 구분 | 무료 한도와 시작 조건 | 저장·사용 조건과 운영 판단 |
+| ----------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Kakao 주소/키워드 | 주소 API는 주소→좌표, 키워드 API는 장소 검색 | 각각 일 100,000건. 앱 사용량 합산, 전체 무료 월간 쿼터도 존재. 개발자 계정의 첫 활성 앱에 무료 지도 쿼터 제공. REST API 키 필요 | 공식 운영자는 지오코딩 위경도 저장·활용을 불허하고 실시간 호출을 요구. 공유 주소→좌표 DB와 30일 응답 캐시로 사용하지 않음 |
+| Naver 지역 검색 | 등록된 업체·기관의 장소 검색. 모든 주소를 변환하는 서비스가 아님 | 검색 API 일 25,000회, client ID별 합산. 개발자센터 앱 등록과 검색 API 선택, ID/secret 필요 | 지역정보를 수집해 별도 DB로 관리하는 행위 금지. API 재제공·이용 대가 등 약관 조건도 확인 필요. 이번 주소 fallback은 저장 없이 정확 주소가 있는 결과만 사용 |
+| Naver Cloud 새 Maps Geocoding | 지번·도로명 주소→좌표 | 대표 계정 1개에 월 3,000,000건 무료. 무료량 초과 종량 과금. Cloud 계정·Application·인증키·약관 동의 필요. 기존 AI NAVER API 지도상품은 무료량 제공하지 않음 | 현행 Maps 약관은 지도좌표 등 결과의 별도 저장·DB화·재사용 금지, 즉시 1회 사용만 허용. 30일 캐시 예외 확인되지 않음. 이번 구현에 추가하지 않았음 |
+| VWorld Geocoder 2.0 | 주소→좌표, 도로명/지번 및 출력 좌표계 선택 | 일 최대 40,000건. 회원가입·도메인/연락처 등록·개발키/운영키 신청 | 공식 지오코더 안내가 실시간 사용과 저장장치·DB 저장 금지를 명시. 현행 약관은 영리행위 사전승낙 요구. 자체DB 채우기용으로 사용하지 않음 |
+| SGIS | 주소 지오코딩·역지오코딩. 장소명 검색 대체로 가정하지 않음 | 무료, 일 50,000회 이하 기준. 회원가입 후 테스트키, 개발 완료 후 상용키 승인. 테스트키 15일이며 연장 가능 | UTM-K EPSG:5179와 WGS84 EPSG:4326 API 제공. 이번 조사에서 공유 응답 DB·30일 캐시·상업적 재제공을 명시적으로 허용한 조건은 확보하지 못했으므로 저장 허용으로 가정하지 않음 |
+| Juso 검색/좌표제공 | 일반 검색 API는 좌표 없이 주소를 반환. 좌표제공 API 별도 신청 필요. 장소 검색 서비스가 아님 | 공식 도움센터 답변상 주소검색 호출 제한 없음(과도한 호출 IP 차단 가능), 좌표제공 5초당 10건 이하. 승인키 필요 | 좌표는 UTM-K(GRS80), 위경도 사용 시 변환 필요. 좌표 API 결과의 공유DB 저장 허용은 이번 조사에서 명시적으로 확인하지 못함. 다운로드 공공자료와 구별 |
+| public Nominatim | OSM 주소·장소 검색 및 역지오코딩 | 키 없이 이용 가능하지만 전체 앱 합산 최대 1요청/초. 앱 식별 User-Agent/Referer 필요 | 반복 요청의 캐싱 요구. 대량·정기 작업 권장하지 않으며 1일 초과/정기 bulk는 4요청/분. 자동완성·체계적 전체수집·지오코딩 결과 재판매 금지. 지오코딩이 주기능인 서비스는 자체호스팅 필요. ODbL 출처·공유동일조건 적용 |
+
+공식 근거:
+
+- [Kakao 무료 쿼터](https://developers.kakao.com/docs/ko/getting-started/quota), [지오코딩 좌표 저장 불허에 대한 2026-09-04 운영자 답변](https://devtalk.kakao.com/t/api/151497).
+- [Naver 지역 검색: 일 한도·키·주소·좌표 필드](https://developers.naver.com/docs/serviceapi/search/local/local.md), [Naver 공식 API 약관: 2026-10-07 개정, 일반조건 7.3의 지역정보 DB 금지](https://github.com/naver/naver-openapi-guide/blob/master/ko/products/intro/terms/terms.md).
+- [Naver Cloud Maps 요금과 무료량](https://www.ncloud.com/api-cms/service-product/static/maps), [이용량·한도 설정](https://guide.ncloud-docs.com/docs/maps-app/), [현행 Maps 약관](https://www.ncloud.com/policy/terms/maps). 현행 페이지가 참조하는 [2025-03-20 적용 공식 PDF]()의 제7조⑪에 저장·재사용 제한이 있다.
+- [VWorld 지오코더 한도·저장 제한](https://www.vworld.kr/dev/v4dv_geocoderguide2_s001.do), [현행 이용약관](https://www.vworld.kr/v4po_prcint_a001.do). 확인일에 직접 HTTP 조회한 원문 기준이며 현행 약관의 시행일은 2023-08-18이다.
+- [SGIS 무료량·키 발급·상용 승인](https://sgis.mods.go.kr/developer/html/openApi/api/intro.html), [지오코딩·WGS84 API 정의](https://sgis.mods.go.kr/developer/html/newOpenApi/api/dataApi/addressBoundary.html).
+- [Juso 검색·좌표 API 호출 제한에 대한 공식 답변](https://business.juso.go.kr/addrlink/qna/qnaDetail.do?bulletinRefSn=126550¬iceMgtSn=126550¬iceType=QNA), [일반 검색에는 좌표가 없으며 UTM-K 변환이 필요하다는 공식 답변](https://business.juso.go.kr/addrlink/qna/qnaDetail.do?bulletinRefSn=128137¬iceMgtSn=128137¬iceType=QNA).
+- [public Nominatim 사용정책](https://operations.osmfoundation.org/policies/nominatim/). public 서버 정책은 자체호스팅에 그대로 적용되지 않지만 OSM 데이터 라이선스는 별도 준수해야 한다. 한국 주소 커버리지·정확도는 검증되지 않았다.
+
+Naver 지역 검색과 Cloud Maps Geocoding은 다른 상품이다. 이번 fallback은 전자를 사용하며 월 300만건 무료량을 적용받는다고 설명해서는 안 된다. 또한 public Nominatim의 1요청/초를 하루 보장 무료량으로 환산해 운영 예산으로 쓰지 않는다.
+
+## 부록: 정부 자료 기반 자체 주소 DB 설계안
+
+### 자료 확보와 이용 조건
+
+우선 후보는 [행정안전부 도로명주소 전자지도](https://www.data.go.kr/data/15050413/fileData.do)이다. 공식 목록은 전국의 건물·건물군·도로구간·출입구 등 11종을 시도 또는 시군구 단위로 제공하며, 신청서·본인확인·이용목적 심사·관할기관 승인을 요구한다. 자료는 무료이고 목록의 이용조건은 공공누리 제1유형이다. [공공누리 안내](https://www.kogl.or.kr/info/freeUse.do)에 따르면 제1유형은 출처표시 조건으로 상업적 활용과 변경이 가능하다.
+
+이 권한은 승인받아 제공된 정부 자료에 대한 것이다. Kakao·Naver·VWorld 응답을 정부 자료처럼 적재할 권한으로 확대하지 않는다. 자료 신청 시 서비스의 주소 조회·좌표 변환·공유 조회 용도를 정확히 적고, 승인 범위와 실제 제공 파일의 이용조건을 함께 보존해야 한다. 사용자가 아직 자료를 확보하지 않았으므로 아래는 구현안이며 데이터가 적재된 운영 DB가 아니다.
+
+### 출처와 좌표 변환
+
+원본 파일의 배포일·자료 기준일·원본 URL·제공기관·지역·라이선스·승인 범위·파일 checksum을 기록한다. 조회 결과에는 예를 들어 “출처: 행정안전부 도로명주소 전자지도, 자료 기준일 YYYY-MM-DD, 공공누리 제1유형”을 표시하고 출처 링크를 제공한다. 라이선스 안내의 제목·제공기관·연결 조건을 실제 배포본에 맞춘다. 정부가 서비스 자체를 인증·보증한다는 표현은 사용하지 않는다.
+
+공식 목록은 ITRF2000/GRS80와 투영좌표를 안내한다. 실제 다운로드 파일의 좌표계 정의·`.prj`·제공 명세를 확인한 뒤 WGS84 EPSG:4326으로 변환해야 한다. API의 UTM-K와 전자지도 배포 파일을 동일 EPSG로 단정하지 않는다. 입력 EPSG가 5179로 확인된 자료에만 5179→4326 변환을 적용한다. 원본 x/y·CRS를 보존하고 출력은 경도/위도 순서를 명시한다. 한국 좌표 범위·정상 기준점·왕복 변환 오차를 검증한 자료만 서비스에 사용한다.
+
+출입구 좌표와 건물 도형 중심점은 의미가 다르다. 원본에 출입구 좌표가 있으면 좌표 종류를 유지한다. 출입구가 없어서 중심점을 계산하는 정책은 별도 표시하고, 실제 출입구라고 설명하지 않는다.
+
+### 권장 스키마
+
+| 테이블 | 주요 필드·키 | 목적 |
+| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
+| `address_sources` | `source_id`, 기관, 원본 URL, 라이선스, 승인 범위, 자료 기준일, 파일 checksum, 원본 CRS, 적재 시각 | 출처·허가·자료 버전 추적 |
+| `address_buildings` | 원본 건물 관리 식별자(수록 시), 행정구역코드, 도로명코드, 지하여부, 건물 본번/부번, 도로명주소, `source_id`, 활성 여부 | 공식 주소 단위 조회 |
+| `address_aliases` | 별칭 유형, 정규화된 주소, 행정구역·지번코드, 산 여부, 지번 본번/부번, 연결된 건물 식별자 | 구 시도명·지번·도로명 별칭 연결. 다중 후보를 임의로 합치지 않음 |
+| `address_points` | 건물/출입구 식별자, 좌표 종류, 원본 x/y/CRS, 변환 경도/위도, 변환 버전, `source_id` | 좌표 출처와 의미 보존 |
+| `address_imports` | 적재 ID, 원본 버전, 처리 건수, 변경/삭제 건수, 검증 상태, 적용 시각 | 검증 후 원자적 버전 교체와 롤백 |
+
+원본 제공 명세의 필드와 안정적인 식별자가 확인된 후 실제 컬럼명을 확정한다. 아직 확보되지 않은 원본의 필드 구성을 사실로 가정하지 않는다. 좌표 검색 인덱스와 원본 주소 검색 인덱스는 정부 자료에만 적용한다.
+
+도로명 조회키는 `(행정구역코드, 도로명코드, 지하여부, 건물 본번, 건물 부번)`을 기본으로 검토한다. 지번 조회키는 원본의 법정동/리 코드·산 여부·지번 본번/부번을 분리한다. 문자열에서 모든 숫자를 단순 연결하는 키는 사용하지 않는다. 도로 번호와 건물 번호를 바꾸거나 `123-4`를 `1234`와 같게 취급하지 않는다. 행정구역 코드의 앞자리 0은 문자열로 보존한다.
+
+입력의 시도 명칭과 공백을 정규화하되 지역 범위를 유지한다. 동일 도로명·본번이 여러 지역에 있거나 한 주소에 여러 건물/출입구가 있으면 후보를 유지하고 사용자 입력으로 구분한다. 가까운 건물 하나를 자동 선택해 정확한 주소로 표시하지 않는다. 상점명·지점명·영업시간·폐점 정보는 주소 DB에 자동으로 생기지 않으며 별도 허용 원천이 필요하다.
+
+### 갱신과 운영
+
+[주소기반산업지원서비스](https://business.juso.go.kr/)는 주소 다운로드와 매일 변경되는 변동자료 연계 기능을 안내한다. 적용할 자료 종류가 실제로 제공하는 배포 주기와 변경 필드를 확인한 뒤 갱신 방식을 정한다. 모든 전자지도 자료가 매일 같은 형식으로 제공된다고 가정하지 않는다.
+
+권장 운영은 제공 가능한 변경자료를 순서대로 적용하고 월 단위 전체본과 대조하는 방식이다. 이는 서비스의 권장 갱신 정책이며 공급자가 월별 배포를 보장한다는 뜻은 아니다. 생성·수정·삭제·행정구역 개편·코드 변경을 처리하고, 변경자료 누락 시 임의 보간 대신 전체본 재동기화를 진행한다. 임시 테이블에 적재→건수·중복·좌표·식별자 검증→원자적 교체 순서를 사용하며 마지막 정상 버전으로 롤백할 수 있게 한다.
+
+정확한 공공 DB 매칭이 되지 않으면 기존의 저장 없는 실시간 공급자 검색으로 처리한다. 실시간 응답을 공공 DB의 빈 부분을 채우는 자료로 저장하지 않는다. DB 자료 기준일·조회 출처·매칭 수준을 사용자에게 표시하고, 오래된 자료나 다중 후보는 정확한 최신 위치라고 단정하지 않는다.
diff --git a/docs/superpowers/plans/2026-10-13-geocode-quota.md b/docs/superpowers/plans/2026-10-13-geocode-quota.md
new file mode 100644
index 00000000..ef116b3d
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-13-geocode-quota.md
@@ -0,0 +1,14 @@
+# 지오코딩 쿼터 보호 구현 계획
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans for independent tasks.
+
+**Goal:** 반복 실패를 중단하고 정확한 주소 대체 조회를 제공한다.
+**Architecture:** 공통 fetch 어댑터, 공급자별 quota gate, 내부 SQLite-backed DO를 분리한다. 성공 좌표 API 응답은 저장하지 않는다.
+**Tech Stack:** TypeScript, Cloudflare Durable Objects, Vitest.
+
+- [x] geocodeGate.ts와 단위테스트: quota 판별·보존·만료·동시 병합을 먼저 실패 테스트로 작성한 뒤 구현.
+- [x] geocodeFetch.ts/GeocodeGateway: 고정 provider descriptor·credential hash·요청/응답 제한·내부 바인딩·deadline·저장소 오류 차단 검증.
+- [x] geocode.ts: 정확한 주소 대체 조회 및 geocodeFetch 연동, 주소 번호/지역 불일치·원본 실패 회귀.
+- [x] AppBindings/index/wrangler: SQLite class migration 및 요청 context로 REST/MCP 모두 적용, 바인딩 없는 CLI 유지.
+- [x] docs/geocoding-resilience.md: 무료 공급자 비교, 현재 fallback, 공공 주소 DB 데이터/신청/좌표계/갱신 계획 기록.
+- [ ] check/build/coverage100, 독립 리뷰, PR 검증·병합·배포, 인증 fresh 운영 조회 후 PROJECT.md 기록.
diff --git a/docs/superpowers/specs/2026-10-13-geocode-quota-design.md b/docs/superpowers/specs/2026-10-13-geocode-quota-design.md
new file mode 100644
index 00000000..9b4df71e
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-13-geocode-quota-design.md
@@ -0,0 +1,20 @@
+# 지오코딩 무료 쿼터 보호와 대체 주소 검색
+
+사용자는 2026-10-13 무료 주소 API 소진 대응과 무료 대체 검색·자체 DB 검토를 승인했다. 카카오 address API는 전날 정확히 100,000회 성공했고 이후 -10 오류가 누적됐다. 기존 공통 변환에는 공급자 쿼터 차단과 동시 요청 병합이 없고 주소는 네이버 fallback을 하지 않는다.
+
+## 구현 범위
+- 카카오 주소/키워드 및 주소 대체 네이버 지역 검색의 HTTP 요청을 고정 공급자 경로로 제한한다. 동일 공급자·자격증명·파라미터·시간 예산의 동시 요청은 진행 중에만 병합하며 완료 후 결과를 보관하지 않는다.
+- 카카오 400/code -10은 해당 credential+API별 30분, HTTP429는 60초 중단한다. 재개 시 실제 조회 요청으로만 확인한다. 빈 결과·인증오류·일반 400·5xx는 quota 상태로 저장하지 않는다.
+- Cloudflare SQLite-backed Durable Object에 쿼터 상태(해시 scope, 공급자, blockedUntil)만 저장한다. 좌표·주소·응답·API 키는 저장하거나 로그로 남기지 않는다. 내부 DO가 불가하면 외부 API 폭증으로 fallback하지 않는다. CLI/무바인딩은 유한 프로세스 내부 보호를 사용한다.
+- 공급자 API URL·method·query·좌표·응답 크기를 제한하고 모든 요청에 기한을 둔다. caller 취소가 다른 병합 요청을 취소하지 않으며 서로 다른 시간 예산은 병합하지 않는다.
+- 주소는 카카오 address → 카카오 keyword의 정확한 주소 일치 → 네이버 local의 정확한 주소 일치로 보완한다. 장소명 경로는 기존 Kakao keyword → Naver local을 유지한다. 도로/건물 번호 순서·지역 토큰·한국 좌표를 검증하며 근처 다른 가게·기본 좌표로 바꾸지 않는다.
+- 저장된 정확한 공공 주소 DB 구축은 행안부 전자지도 bulk 자료를 우선한다. 본인확인·신청·이용목적 심사 자료가 필요하므로 보유 여부를 사용자에게 질문했고, 자료가 없는 상태에서 전국 DB 구축 완료라고 주장하지 않는다.
+
+## 저장 정책 조사로 수정된 부분
+카카오 공식 운영자(2026-09-04)는 좌표 저장·활용을 불허하고 실시간 호출을 요구한다(https://devtalk.kakao.com/t/api/151497). 네이버 Maps/VWorld도 별도 DB 축적이 허용되지 않는 안내가 있다. 따라서 처음 제안한 API 응답의 영구/30일 공유 캐시는 구현하지 않는다. 정부 공공누리 1유형 자료 기반의 자체 주소 DB와 API 응답 캐시는 구별한다.
+
+## GS25 위치 실패 보호
+지역을 지정했는데 좌표를 확인하지 못하면 REST/MCP 모두 GS25_LOCATION_UNRESOLVED 503으로 중단한다. 다른 지역/기본 강남 좌표로 재고를 조회하지 않는다. 일치하는 매장의 유효한 한국 native 좌표는 주소 변환보다 우선한다(REST의 기존 direct 장소 검색 후 fallback 순서는 유지). 인증 오류와 위치 오류는 구분한다.
+
+## 검증과 운영
+TDD로 동일 요청 병합/완료 후 재호출, quota 상태 지속/만료/endpoint 분리, malformed 입력/본문 제한/timeout/저장 오류/credential 분리, 주소 fallback의 엄격 일치를 검증한다. 기존 서비스 응답 계약과 재고 fresh 동작은 유지한다. check/build/coverage100/독립리뷰/PR CI 후 병합·배포 및 최소 인증 fresh REST/MCP/CLI 검증을 한다. 유료 서비스 활성화나 무료 키 재발급을 통한 한도 우회는 하지 않는다.
diff --git a/src/api/gs25Handlers.ts b/src/api/gs25Handlers.ts
index 20712bd9..79dd0efd 100644
--- a/src/api/gs25Handlers.ts
+++ b/src/api/gs25Handlers.ts
@@ -2,7 +2,7 @@ import { isFreshOperationalRequest } from './operationalFreshness.js';
import { ServiceError } from '../core/errors.js';
import { selectGs25InventoryProduct } from '../services/gs25/productSelection.js';
import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js';
-import { geocodeBindings } from '../utils/geocode.js';
+import { geocodeBindings, validKoreanCoordinates } from '../utils/geocode.js';
/**
* GS25 GET API 핸들러
*/
@@ -262,13 +262,15 @@ export async function handleGs25CheckInventory(c: ApiContext) {
},
);
- const firstAddress =
- filterGs25StoresByKeyword(baseStores.stores, storeKeyword).find(
- (store) => store.address.trim().length > 0,
- )?.address || '';
-
- if (firstAddress.length > 0) {
- const geocoded = await geocodeGs25Address(firstAddress, {
+ const candidates = filterGs25StoresByKeyword(baseStores.stores, storeKeyword);
+ const firstStore =
+ candidates.find((store) => validKoreanCoordinates(store.latitude, store.longitude)) ||
+ candidates.find((store) => store.address.trim().length > 0);
+ if (firstStore && validKoreanCoordinates(firstStore.latitude, firstStore.longitude)) {
+ latitude = firstStore.latitude;
+ longitude = firstStore.longitude;
+ } else if (firstStore?.address.trim()) {
+ const geocoded = await geocodeGs25Address(firstStore.address, {
timeout: 15000,
...geocodeBindings(c.env),
});
@@ -281,6 +283,10 @@ export async function handleGs25CheckInventory(c: ApiContext) {
}
}
+ if (storeKeyword.trim() && !validKoreanCoordinates(latitude ?? NaN, longitude ?? NaN))
+ throw new ServiceError('GS25_LOCATION_UNRESOLVED',
+ '요청한 지역의 위치를 확인하지 못했습니다. 좌표를 지정하거나 잠시 후 다시 조회해주세요.', 503, true);
+
// 1단계: totalSearch API로 키워드 → itemCode 변환
let firstProduct: Awaited>[number] | undefined;
diff --git a/src/api/response.ts b/src/api/response.ts
index fe32e62a..96167ecd 100644
--- a/src/api/response.ts
+++ b/src/api/response.ts
@@ -8,6 +8,7 @@ import { ServiceError, toServiceErrorDiagnostics, toStandardErrorDiagnostics } f
export interface AppBindings {
DAILY_RATE_LIMITER?: DurableObjectNamespace;
UPSTREAM_ROUTE_HEALTH?: DurableObjectNamespace;
+ GEOCODE_GATEWAY?: DurableObjectNamespace;
ZYTE_API_KEY?: string;
CGV_RELAY_URL?: string;
CGV_RELAY_TOKEN?: string;
diff --git a/src/durableObjects/geocodeGateway.ts b/src/durableObjects/geocodeGateway.ts
new file mode 100644
index 00000000..eba789e8
--- /dev/null
+++ b/src/durableObjects/geocodeGateway.ts
@@ -0,0 +1,50 @@
+/** 외부 API의 응답 대신 공급자 쿼터 차단 시각만 SQLite에 남깁니다. */
+import type { AppBindings } from '../api/response.js';
+import { GeocodeGate } from '../utils/geocodeGate.js';
+import {
+ validGeocodeRequest,
+ validGeocodeCredentials,
+ configuredGeocodeCredentials,
+ geocodeScope,
+ requestGeocodeProvider,
+} from '../utils/geocodeProvider.js';
+export class GeocodeGateway {
+ private readonly gate: GeocodeGate;
+ constructor(
+ state: DurableObjectState,
+ private readonly env: AppBindings,
+ ) {
+ this.gate = new GeocodeGate({
+ get: (key) => state.storage.get(key),
+ put: (key, value) => state.storage.put(key, value),
+ });
+ }
+ async fetch(request: Request): Promise {
+ if (request.method !== 'POST' || new URL(request.url).pathname !== '/lookup')
+ return new Response(null, { status: 404 });
+ let input: unknown;
+ try {
+ const text = await request.text();
+ if (text.length > 4096) return new Response(null, { status: 400 });
+ input = JSON.parse(text);
+ } catch {
+ return new Response(null, { status: 400 });
+ }
+ if (!validGeocodeRequest(input)) return new Response(null, { status: 400 });
+ const credentials = configuredGeocodeCredentials(input.provider, this.env);
+ if (!validGeocodeCredentials(credentials) || input.scope !== (await geocodeScope(credentials)))
+ return new Response(null, { status: 403 });
+ try {
+ const reply = await this.gate.run(
+ input.scope,
+ input.provider,
+ JSON.stringify(Object.entries(input.params).sort()),
+ input.timeoutMs,
+ () => requestGeocodeProvider(input, credentials, fetch),
+ );
+ return Response.json(reply);
+ } catch {
+ return Response.json({ code: 'GEOCODE_GATEWAY_UNAVAILABLE' }, { status: 503 });
+ }
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index 9cc6fa08..0c959c17 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,8 +1,9 @@
import { apiResultHtml } from './pages/apiResult.js';
-import { withRouteRouting } from './utils/routeHealth.js';
+import { withExternalRouting } from './utils/externalRouting.js';
import { refreshRouteChecks } from './utils/routeProbes.js';
import { registerRouteHealthRoutes } from './api/routes/routeHealthRoutes.js';
export { UpstreamRouteHealth } from './durableObjects/upstreamRouteHealth.js';
+export { GeocodeGateway } from './durableObjects/geocodeGateway.js';
import { convenienceTransportFromBindings } from './utils/convenienceTransport.js';
import { handleLotteMartFindStores } from './api/lottemartHandlers.js';
import { geocodeBindings } from './utils/geocode.js';
@@ -438,8 +439,7 @@ const worker = {
return createRootInfoResponse(request);
}
- return withRouteRouting(env?.UPSTREAM_ROUTE_HEALTH, executionCtx?.waitUntil?.bind(executionCtx),
- () => app.fetch(request, env, executionCtx));
+ return withExternalRouting(env, executionCtx, () => app.fetch(request, env, executionCtx));
},
scheduled(_controller: ScheduledController, env: AppBindings, ctx: ExecutionContext) {
ctx.waitUntil(refreshRouteChecks(env));
diff --git a/src/services/gs25/tools/checkInventory.ts b/src/services/gs25/tools/checkInventory.ts
index 46538733..a14dabc3 100644
--- a/src/services/gs25/tools/checkInventory.ts
+++ b/src/services/gs25/tools/checkInventory.ts
@@ -1,6 +1,7 @@
import { selectGs25InventoryProduct } from '../productSelection.js';
import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js';
-import type { GeocodeOptions } from '../../../utils/geocode.js';
+import { validKoreanCoordinates, type GeocodeOptions } from '../../../utils/geocode.js';
+import { ServiceError } from '../../../core/errors.js';
/**
* GS25 재고 확인 도구
*/
@@ -80,16 +81,18 @@ async function checkInventory(args: CheckInventoryArgs): Promise store.address.trim().length > 0,
- )?.address || '';
-
- if (firstAddress.length > 0) {
- const geocoded = await geocodeGs25Address(firstAddress, {
+ const candidates = filterGs25StoresByKeyword(baseStores.stores, storeKeyword);
+ const firstStore =
+ candidates.find((store) => validKoreanCoordinates(store.latitude, store.longitude)) ||
+ candidates.find((store) => store.address.trim().length > 0);
+ if (firstStore && validKoreanCoordinates(firstStore.latitude, firstStore.longitude)) {
+ resolvedLatitude = firstStore.latitude;
+ resolvedLongitude = firstStore.longitude;
+ } else if (firstStore?.address.trim()) {
+ const geocoded = await geocodeGs25Address(firstStore.address, {
...args,
timeout: timeoutMs,
- googleMapsApiKey,
+ googleMapsApiKey,
});
if (geocoded) {
resolvedLatitude = geocoded.latitude;
@@ -99,7 +102,11 @@ async function checkInventory(args: CheckInventoryArgs): Promise(
+ env: AppBindings,
+ executionCtx: ExecutionContext,
+ work: () => T,
+): T {
+ return withGeocodeProtection(env, () =>
+ withRouteRouting(env?.UPSTREAM_ROUTE_HEALTH, executionCtx?.waitUntil?.bind(executionCtx), work),
+ );
+}
diff --git a/src/utils/geocode.ts b/src/utils/geocode.ts
index bbcffb60..9dcd2869 100644
--- a/src/utils/geocode.ts
+++ b/src/utils/geocode.ts
@@ -1,4 +1,5 @@
import { fetchJson } from './http.js';
+import { createGeocodeFetch } from './geocodeFetch.js';
import { searchNaverLocalPlaces } from '../services/places/client.js';
export interface GeocodeOptions {
@@ -91,12 +92,48 @@ function matches(query: string, value: string): boolean {
);
}
function matchesAddress(query: string, address: string): boolean {
- // 주소 검색은 숫자 존재뿐 아니라 도로 번호와 건물 번호의 순서도 검증합니다.
+ // 지역과 도로명은 독립 토큰으로, 도로 번호와 건물 번호는 순서까지 검증합니다.
+ const tokens = (value: string) =>
+ value
+ .replace(/^대한민국\s*/u, '')
+ .match(/[0-9]+(?:-[0-9]+)?|[a-z가-힣]+/giu)
+ ?.map(normalize) ?? [];
+ const expected = tokens(query);
+ const actual = tokens(address);
return (
- matches(query, address) &&
- query.match(/\d+(?:-\d+)?/gu)!.join(',') === address.match(/\d+(?:-\d+)?/gu)!.join(',')
+ query.match(/\d+(?:-\d+)?/gu)!.join(',') ===
+ (address.match(/\d+(?:-\d+)?/gu)?.join(',') ?? '') &&
+ expected.every((token) => actual.includes(token))
);
}
+function kakaoLocation(
+ query: string,
+ documents: KakaoDocument[],
+ isAddress: boolean,
+): GeocodedLocation | null {
+ for (const document of documents) {
+ if (!isAddress && !document.place_name?.trim()) continue;
+ const latitude = Number(document.y);
+ const longitude = Number(document.x);
+ if (
+ !validKoreanCoordinates(latitude, longitude) ||
+ !(isAddress
+ ? matchesAddress(query, document.address_name || '') ||
+ matchesAddress(query, document.road_address_name || '')
+ : matches(
+ query,
+ `${document.place_name} ${document.address_name || ''} ${document.road_address_name || ''}`,
+ ))
+ )
+ continue;
+ return {
+ latitude,
+ longitude,
+ formattedAddress: document.road_address_name || document.address_name || null,
+ };
+ }
+ return null;
+}
export async function searchKakaoPlaces(
query: string,
options: GeocodeOptions,
@@ -114,7 +151,7 @@ export async function searchKakaoPlaces(
endpoint.searchParams.set('sort', 'distance');
}
const body = await fetchJson<{ documents?: KakaoDocument[] }>(endpoint.toString(), {
- fetchImpl: options.fetchImpl,
+ fetchImpl: createGeocodeFetch(options.fetchImpl, options.timeout ?? options.timeoutMs ?? 10000),
headers: { Authorization: `KakaoAK ${key}` },
timeout: options.timeout ?? options.timeoutMs ?? 10000,
});
@@ -133,58 +170,64 @@ export async function geocodeLocation(
naverClientId: input.naverClientId ?? env.naverClientId,
naverClientSecret: input.naverClientSecret ?? env.naverClientSecret,
};
+ // 대체 공급자도 최초 요청의 남은 시간만 사용합니다.
+ const deadline = Date.now() + (options.timeout ?? options.timeoutMs ?? 10000);
+ const remaining = () => deadline - Date.now();
const isAddress = /(?:로|길|동|읍|면|리)\s*\d/u.test(keyword);
let documents: KakaoDocument[] = [];
- if (options.kakaoRestApiKey?.trim()) {
+ const kakaoTimeout = remaining();
+ if (options.kakaoRestApiKey?.trim() && kakaoTimeout > 0) {
try {
if (isAddress) {
const endpoint = new URL('https://dapi.kakao.com/v2/local/search/address.json');
endpoint.searchParams.set('query', keyword);
const body = await fetchJson<{ documents?: KakaoDocument[] }>(endpoint.toString(), {
- fetchImpl: options.fetchImpl,
+ fetchImpl: createGeocodeFetch(
+ options.fetchImpl,
+ kakaoTimeout,
+ ),
headers: { Authorization: `KakaoAK ${options.kakaoRestApiKey.trim()}` },
- timeout: options.timeout ?? options.timeoutMs ?? 10000,
+ timeout: kakaoTimeout,
});
documents = Array.isArray(body.documents) ? body.documents.filter(validKakaoDocument) : [];
- } else documents = await searchKakaoPlaces(keyword, options);
+ } else documents = await searchKakaoPlaces(keyword, { ...options, timeout: kakaoTimeout });
} catch {
- // 장소명은 네이버 지역 검색으로 보완하고 주소는 잘못된 장소로 대체하지 않습니다.
+ // 오류나 빈 결과는 정확하게 일치하는 대체 검색으로 보완합니다.
}
}
- for (const document of documents) {
- if (!isAddress && !document.place_name?.trim()) continue;
- const latitude = Number(document.y);
- const longitude = Number(document.x);
- if (
- !validKoreanCoordinates(latitude, longitude) ||
- !(isAddress
- ? matchesAddress(keyword, document.address_name || '') ||
- matchesAddress(keyword, document.road_address_name || '')
- : matches(
- keyword,
- `${document.place_name} ${document.address_name || ''} ${document.road_address_name || ''}`,
- ))
- )
- continue;
- return {
- latitude,
- longitude,
- formattedAddress: document.road_address_name || document.address_name || null,
- };
+ const location = kakaoLocation(keyword, documents, isAddress);
+ if (location) return location;
+ const keywordTimeout = remaining();
+ if (isAddress && options.kakaoRestApiKey?.trim() && keywordTimeout > 0) {
+ try {
+ const fallback = kakaoLocation(
+ keyword, await searchKakaoPlaces(keyword, { ...options, timeout: keywordTimeout }), true,
+ );
+ if (fallback) return fallback;
+ } catch {
+ // 주소가 정확하게 일치하는 네이버 결과가 있으면 보완합니다.
+ }
}
- if (!isAddress && options.naverClientId && options.naverClientSecret) {
+ const naverTimeout = remaining();
+ if (options.naverClientId && options.naverClientSecret && naverTimeout > 0) {
try {
const result = await searchNaverLocalPlaces({
...options,
keyword,
- timeoutMs: options.timeout ?? options.timeoutMs,
+ fetchImpl: createGeocodeFetch(
+ options.fetchImpl,
+ naverTimeout,
+ ),
+ timeoutMs: naverTimeout,
});
const place = result.places.find(
(item) =>
item.latitude !== null &&
item.longitude !== null &&
validKoreanCoordinates(item.latitude, item.longitude) &&
- matches(keyword, `${item.name} ${item.address} ${item.roadAddress}`),
+ (isAddress
+ ? matchesAddress(keyword, item.address) || matchesAddress(keyword, item.roadAddress)
+ : matches(keyword, `${item.name} ${item.address} ${item.roadAddress}`)),
);
if (place)
return {
diff --git a/src/utils/geocodeFetch.ts b/src/utils/geocodeFetch.ts
new file mode 100644
index 00000000..db8c32d8
--- /dev/null
+++ b/src/utils/geocodeFetch.ts
@@ -0,0 +1,100 @@
+/** REST와 MCP에 같은 공급자 보호를 적용하고 좌표 결과는 캐시하지 않습니다. */
+import { AsyncLocalStorage } from 'node:async_hooks';
+import type { AppBindings } from '../api/response.js';
+import { GeocodeGate, type GeocodeReply } from './geocodeGate.js';
+import {
+ geocodeProvider,
+ geocodeScope,
+ geocodeCredentials,
+ configuredGeocodeCredentials,
+ validGeocodeCredentials,
+ validGeocodeRequest,
+ requestGeocodeProvider,
+ waitGeocode,
+ type GeocodeRequest,
+} from './geocodeProvider.js';
+const context = new AsyncLocalStorage>();
+const gates = new WeakMap();
+export function withGeocodeProtection(env: Partial | undefined, work: () => T): T {
+ return env?.GEOCODE_GATEWAY ? context.run(env, work) : context.exit(work);
+}
+function replyResponse(reply: GeocodeReply): Response {
+ if (![204, 205, 304].includes(reply.status))
+ return Response.json(reply.body, { status: reply.status });
+ return new Response(null, { status: reply.status });
+}
+export function createGeocodeFetch(
+ fetchImpl: typeof fetch = fetch,
+ timeoutMs = 10000,
+): typeof fetch {
+ const budget = Number.isFinite(timeoutMs)
+ ? Math.max(1, Math.min(20000, Math.trunc(timeoutMs)))
+ : 10000;
+ return async (input, init) => {
+ const url = new URL(input instanceof Request ? input.url : String(input));
+ const provider = geocodeProvider(url);
+ if (!provider) return fetchImpl(input, init);
+ if (
+ url.username ||
+ url.password ||
+ (init?.method || (input instanceof Request ? input.method : 'GET')) !== 'GET'
+ )
+ throw new Error('GEOCODE_INVALID_REQUEST');
+ const headers = new Headers(
+ init?.headers || (input instanceof Request ? input.headers : undefined),
+ );
+ const credentials = geocodeCredentials(provider, headers);
+ if (!validGeocodeCredentials(credentials)) throw new Error('GEOCODE_INVALID_CREDENTIAL');
+ const signal = init?.signal || (input instanceof Request ? input.signal : undefined);
+ if (signal?.aborted) throw new Error('GEOCODE_CANCELLED');
+ const row: GeocodeRequest = {
+ provider,
+ params: Object.fromEntries(url.searchParams),
+ scope: await geocodeScope(credentials),
+ timeoutMs: budget,
+ };
+ if (!validGeocodeRequest(row)) throw new Error('GEOCODE_INVALID_REQUEST');
+ if (signal?.aborted) throw new Error('GEOCODE_CANCELLED');
+ const env = context.getStore();
+ const configured = env && configuredGeocodeCredentials(provider, env);
+ let work: Promise;
+ if (env?.GEOCODE_GATEWAY && configured?.every((key, index) => key === credentials[index])) {
+ work = (async () => {
+ const ns = env.GEOCODE_GATEWAY!;
+ const response = await ns
+ .get(ns.idFromName('geocode-gateway-v1'))
+ .fetch('https://geocode.internal/lookup', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(row),
+ signal: AbortSignal.timeout(budget),
+ });
+ if (!response.ok) throw new Error('GEOCODE_GATEWAY_UNAVAILABLE');
+ const packet = (await response.json()) as GeocodeReply;
+ if (
+ !packet ||
+ !Number.isInteger(packet.status) ||
+ packet.status < 200 ||
+ packet.status > 599 ||
+ !Object.hasOwn(packet, 'body')
+ )
+ throw new Error('GEOCODE_INVALID_RESPONSE');
+ return packet;
+ })();
+ } else {
+ let gate = gates.get(fetchImpl);
+ if (!gate) {
+ gate = new GeocodeGate();
+ gates.set(fetchImpl, gate);
+ }
+ work = gate.run(
+ row.scope,
+ provider,
+ JSON.stringify(Object.entries(row.params).sort()),
+ budget,
+ () => requestGeocodeProvider(row, credentials, fetchImpl),
+ );
+ }
+ return replyResponse(await waitGeocode(work, budget, signal));
+ };
+}
diff --git a/src/utils/geocodeGate.ts b/src/utils/geocodeGate.ts
new file mode 100644
index 00000000..897622fa
--- /dev/null
+++ b/src/utils/geocodeGate.ts
@@ -0,0 +1,121 @@
+/** 응답을 보관하지 않고 진행 중 요청과 공급자 쿼터 상태만 관리합니다. */
+export type GeocodeProvider = 'kakao-address' | 'kakao-keyword' | 'naver-local';
+export interface GeocodeReply {
+ status: number;
+ body: unknown;
+}
+export interface GeocodeQuotaStore {
+ get(key: string): Promise;
+ put(key: string, value: number): Promise;
+}
+interface QuotaState {
+ blockedUntil: number;
+ write: Promise;
+}
+interface ScopeState {
+ active: number;
+ providers: Map;
+}
+const storeError = () => new Error('GEOCODE_QUOTA_STORE_UNAVAILABLE');
+const capacityError = () => new Error('GEOCODE_GATE_CAPACITY');
+function cooldown(provider: GeocodeProvider, reply: GeocodeReply): number {
+ if (reply.status === 429) return 60000;
+ if (
+ provider !== 'naver-local' && reply.status === 400 &&
+ reply.body !== null && typeof reply.body === 'object' &&
+ (reply.body as { code?: unknown }).code === -10
+ ) return 1800000;
+ return 0;
+}
+export class GeocodeGate {
+ private readonly pending = new Map>();
+ private readonly scopes = new Map();
+ constructor(
+ private readonly store?: GeocodeQuotaStore,
+ private readonly now: () => number = Date.now,
+ ) {}
+
+ async run(
+ scope: string,
+ provider: GeocodeProvider,
+ key: string,
+ timeoutMs: number,
+ work: () => Promise,
+ ): Promise {
+ const requestKey = JSON.stringify([scope, provider, key, timeoutMs]);
+ const existing = this.pending.get(requestKey);
+ if (existing) return structuredClone(await existing);
+ if (this.pending.size >= 32) throw capacityError();
+ this.prune();
+ let state = this.scopes.get(scope);
+ if (!state) {
+ if (this.scopes.size >= 64) throw capacityError();
+ state = { active: 0, providers: new Map() };
+ this.scopes.set(scope, state);
+ }
+ let quota = state.providers.get(provider);
+ if (!quota) {
+ quota = { blockedUntil: 0, write: Promise.resolve() };
+ state.providers.set(provider, quota);
+ }
+ state.active++;
+ const task = Promise.resolve().then(() => this.perform(scope, provider, quota, work));
+ this.pending.set(requestKey, task);
+ try {
+ return structuredClone(await task);
+ } finally {
+ this.pending.delete(requestKey);
+ state.active--;
+ }
+ }
+
+ private prune(): void {
+ const at = this.now();
+ for (const [scope, state] of this.scopes) {
+ if (state.active === 0 && [...state.providers.values()].every((quota) => quota.blockedUntil <= at))
+ this.scopes.delete(scope);
+ }
+ }
+
+ private async perform(
+ scope: string,
+ provider: GeocodeProvider,
+ quota: QuotaState,
+ work: () => Promise,
+ ): Promise {
+ const storageKey = `quota:${scope}:${provider}`;
+ if (this.store) {
+ try {
+ const saved = await this.store.get(storageKey);
+ if (saved !== undefined) {
+ if (!Number.isSafeInteger(saved) || saved < 0) throw storeError();
+ quota.blockedUntil = Math.max(quota.blockedUntil, saved);
+ }
+ } catch {
+ throw storeError();
+ }
+ }
+ if (quota.blockedUntil > this.now())
+ return { status: 429, body: { code: 'GEOCODE_QUOTA_BLOCKED' } };
+ const reply = await work();
+ const duration = cooldown(provider, reply);
+ if (duration > 0) {
+ const until = this.now() + duration;
+ // 저장을 직렬화하고 만료 시에는 쓰지 않아 오래된 해제 요청의 덮어쓰기를 막습니다.
+ const update = quota.write.then(async () => {
+ const blockedUntil = Math.max(quota.blockedUntil, until);
+ quota.blockedUntil = blockedUntil;
+ if (this.store) {
+ try {
+ await this.store.put(storageKey, blockedUntil);
+ } catch {
+ throw storeError();
+ }
+ }
+ });
+ quota.write = update.catch(() => {});
+ await update;
+ }
+ return reply;
+ }
+}
diff --git a/src/utils/geocodeProvider.ts b/src/utils/geocodeProvider.ts
new file mode 100644
index 00000000..7ef81c56
--- /dev/null
+++ b/src/utils/geocodeProvider.ts
@@ -0,0 +1,196 @@
+/** 공급자 URL·입력을 고정하며 API 키와 조회 응답은 저장하지 않습니다. */
+import type { AppBindings } from '../api/response.js';
+import type { GeocodeProvider, GeocodeReply } from './geocodeGate.js';
+export interface GeocodeRequest {
+ provider: GeocodeProvider;
+ scope: string;
+ params: Record;
+ timeoutMs: number;
+}
+const endpoints = {
+ 'kakao-address': 'https://dapi.kakao.com/v2/local/search/address.json',
+ 'kakao-keyword': 'https://dapi.kakao.com/v2/local/search/keyword.json',
+ 'naver-local': 'https://openapi.naver.com/v1/search/local.json',
+} as const;
+export const invalidGeocode = () => new Error('GEOCODE_INVALID_REQUEST');
+export function geocodeProvider(url: URL): GeocodeProvider | undefined {
+ return (Object.keys(endpoints) as GeocodeProvider[]).find(
+ (provider) => url.origin + url.pathname === endpoints[provider],
+ );
+}
+export async function geocodeScope(credentials: string[]): Promise {
+ const digest = await crypto.subtle.digest(
+ 'SHA-256',
+ new TextEncoder().encode(JSON.stringify(credentials)),
+ );
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
+}
+export function geocodeCredentials(provider: GeocodeProvider, headers: Headers): string[] {
+ const authorization = headers.get('Authorization') || '';
+ return provider === 'naver-local'
+ ? [headers.get('X-Naver-Client-Id') || '', headers.get('X-Naver-Client-Secret') || '']
+ : [authorization.startsWith('KakaoAK ') ? authorization.slice(8) : ''];
+}
+export function geocodeHeaders(
+ provider: GeocodeProvider,
+ credentials: string[],
+): Record {
+ return provider === 'naver-local'
+ ? { 'X-Naver-Client-Id': credentials[0], 'X-Naver-Client-Secret': credentials[1] }
+ : { Authorization: `KakaoAK ${credentials[0]}` };
+}
+export function configuredGeocodeCredentials(
+ provider: GeocodeProvider,
+ env: Partial,
+): string[] {
+ return provider === 'naver-local'
+ ? [env.NAVER_CLIENT_ID?.trim() || '', env.NAVER_CLIENT_SECRET?.trim() || '']
+ : [env.KAKAO_REST_API_KEY?.trim() || ''];
+}
+export function validGeocodeCredentials(credentials: string[]): boolean {
+ return credentials.every(
+ (value) => value.length > 0 && value.length <= 512 && !/[\p{Cc}]/u.test(value),
+ );
+}
+export function validGeocodeRequest(value: unknown): value is GeocodeRequest {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ const row = value as GeocodeRequest;
+ if (
+ Object.keys(row).some((key) => !['provider', 'scope', 'params', 'timeoutMs'].includes(key)) ||
+ typeof row.provider !== 'string' ||
+ !Object.hasOwn(endpoints, row.provider) ||
+ typeof row.scope !== 'string' ||
+ row.scope.length !== 64 ||
+ !/^[a-f0-9]{64}$/u.test(row.scope) ||
+ !Number.isInteger(row.timeoutMs) ||
+ row.timeoutMs < 1 ||
+ row.timeoutMs > 20000 ||
+ !row.params ||
+ typeof row.params !== 'object' ||
+ Array.isArray(row.params)
+ )
+ return false;
+ const allowed =
+ row.provider === 'kakao-address'
+ ? ['query']
+ : row.provider === 'kakao-keyword'
+ ? ['query', 'size', 'x', 'y', 'radius', 'sort']
+ : ['query', 'display', 'start', 'sort'];
+ const p = row.params;
+ if (
+ Object.entries(p).some(([key, text]) => !allowed.includes(key) || typeof text !== 'string') ||
+ !p.query?.trim() ||
+ p.query.length > 300 ||
+ /[\p{Cc}]/u.test(p.query)
+ )
+ return false;
+ if (row.provider === 'kakao-keyword') {
+ if (p.size !== '15') return false;
+ if (p.x !== undefined || p.y !== undefined) {
+ if (
+ !/^-?\d+(?:\.\d+)?$/u.test(p.x || '') ||
+ !/^-?\d+(?:\.\d+)?$/u.test(p.y || '') ||
+ Number(p.x) < 124 ||
+ Number(p.x) > 132 ||
+ Number(p.y) < 33 ||
+ Number(p.y) > 39 ||
+ p.radius !== '20000' ||
+ p.sort !== 'distance'
+ )
+ return false;
+ } else if (p.radius !== undefined || p.sort !== undefined) return false;
+ }
+ if (
+ row.provider === 'naver-local' &&
+ (!/^[1-5]$/u.test(p.display || '') ||
+ p.start !== '1' ||
+ !['random', 'comment'].includes(p.sort))
+ )
+ return false;
+ return true;
+}
+export function geocodeUrl(row: GeocodeRequest): string {
+ const url = new URL(endpoints[row.provider]);
+ url.search = new URLSearchParams(row.params).toString();
+ return url.toString();
+}
+export async function waitGeocode(
+ work: Promise,
+ timeoutMs: number,
+ signal?: AbortSignal | null,
+ abort?: () => void,
+): Promise {
+ let timer: ReturnType;
+ let listener: () => void;
+ const stop = (reject: (reason: unknown) => void, reason: string) => {
+ abort?.();
+ reject(new Error(reason));
+ };
+ try {
+ return await Promise.race([
+ work,
+ new Promise((_, reject) => {
+ listener = () => stop(reject, 'GEOCODE_CANCELLED');
+ if (signal?.aborted) {
+ listener();
+ return;
+ }
+ signal?.addEventListener('abort', listener, { once: true });
+ timer = setTimeout(() => stop(reject, 'GEOCODE_TIMEOUT'), timeoutMs);
+ }),
+ ]);
+ } finally {
+ clearTimeout(timer!);
+ signal?.removeEventListener('abort', listener!);
+ }
+}
+export async function requestGeocodeProvider(
+ row: GeocodeRequest,
+ credentials: string[],
+ fetchImpl: typeof fetch,
+): Promise {
+ const controller = new AbortController();
+ let reader: ReadableStreamDefaultReader | undefined;
+ const work = (async () => {
+ const response = await fetchImpl(geocodeUrl(row), {
+ method: 'GET',
+ headers: geocodeHeaders(row.provider, credentials),
+ redirect: 'manual',
+ signal: controller.signal,
+ });
+ if (controller.signal.aborted) {
+ await response.body?.cancel();
+ throw new Error('GEOCODE_TIMEOUT');
+ }
+ reader = response.body?.getReader();
+ const chunks: Uint8Array[] = [];
+ let bytes = 0;
+ while (reader) {
+ const chunk = await reader.read();
+ if (chunk.done) break;
+ bytes += chunk.value.length;
+ if (bytes > 524288) {
+ await reader.cancel();
+ throw new Error('GEOCODE_RESPONSE_TOO_LARGE');
+ }
+ chunks.push(chunk.value);
+ }
+ const body = new Uint8Array(bytes);
+ let offset = 0;
+ for (const chunk of chunks) {
+ body.set(chunk, offset);
+ offset += chunk.length;
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(new TextDecoder().decode(body));
+ } catch {
+ return { status: 502, body: { code: 'GEOCODE_INVALID_RESPONSE' } };
+ }
+ return { status: response.status, body: parsed };
+ })();
+ return waitGeocode(work, row.timeoutMs, undefined, () => {
+ controller.abort();
+ void reader?.cancel().catch(() => {});
+ });
+}
diff --git a/tests/api/gs25-handlers.test.ts b/tests/api/gs25-handlers.test.ts
index 9cddc347..fadaed63 100644
--- a/tests/api/gs25-handlers.test.ts
+++ b/tests/api/gs25-handlers.test.ts
@@ -334,39 +334,11 @@ describe('handleGs25CheckInventory', () => {
(ctx as { env: Record }).env = { KAKAO_REST_API_KEY: 'test-google-key' };
mockFetch
- .mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- stores: [
- {
- storeCode: 'A1',
- storeName: 'GS25 안산중앙점',
- storeAddress: '경기 안산시 단원구 중앙대로 907',
- },
- ],
- }),
- ),
- )
- .mockResolvedValueOnce(
- new Response(
- JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남구', y: 37.3187, x: 126.8389 }] }),
- ),
- )
- .mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- stores: [
- {
- storeCode: 'A1',
- storeName: 'GS25 안산중앙점',
- storeAddress: '경기 안산시 단원구 중앙대로 907',
- searchItemName: '핫식스250ML',
- realStockQuantity: 6,
- },
- ],
- }),
- ),
- );
+ .mockResolvedValueOnce(Response.json({ documents: [{ place_name: '안산 중앙역', address_name: '경기 안산시', y: '37.3187', x: '126.8389' }] }))
+ .mockResolvedValueOnce(Response.json({ stores: [{
+ storeCode: 'A1', storeName: 'GS25 안산중앙점', storeAddress: '경기 안산시 단원구 중앙대로 907',
+ searchItemName: '핫식스250ML', realStockQuantity: 6,
+ }] }));
await handleGs25CheckInventory(ctx);
@@ -494,52 +466,20 @@ describe('handleGs25CheckInventory', () => {
expect(String(mockFetch.mock.calls[2][0])).toContain('myPositionYCoordination=37.5');
});
- it('storeKeyword가 있어도 지오코딩 실패 시 location은 null이다', async () => {
+ it('storeKeyword 위치를 확인하지 못하면 재고 조회 없이 503을 반환한다', async () => {
vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key');
const ctx = createMockContext({ keyword: '오감자', storeKeyword: '강남' });
(ctx as { env: Record }).env = { KAKAO_REST_API_KEY: 'test-google-key' };
-
- mockFetch
- // 1. storeKeyword 기준 매장 조회 (지오코딩 주소 획득용)
- .mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- stores: [{ storeCode: 'B', storeName: '강남역점', storeAddress: '서울 강남구' }],
- }),
- ),
- )
- // 2. 지오코딩 실패
- .mockResolvedValueOnce(new Response(JSON.stringify({ documents: [] })))
- // 3. totalSearch API (keyword → itemCode)
- .mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- SearchQueryResult: {
- Collection: [
- { Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } },
- ],
- },
- }),
- ),
- )
- // 4. store/stock API (기본 좌표 사용)
- .mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- stores: [
- { storeCode: '1', storeName: '강남역점', searchItemName: '', realStockQuantity: 0 },
- ],
- }),
- ),
- );
-
+ mockFetch.mockImplementation(async () => Response.json({ documents: [], stores: [] }));
await handleGs25CheckInventory(ctx);
-
- const payload = (ctx.json as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][0] as {
- data: { geocodeUsed: boolean; location: null };
- };
- expect(payload.data.geocodeUsed).toBe(false);
- expect(payload.data.location).toBeNull();
+ expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({
+ success: false, error: expect.objectContaining({ code: 'GS25_LOCATION_UNRESOLVED' }),
+ }), 503);
+ expect(mockFetch.mock.calls.some(([url]) => {
+ const params = new URL(String(url)).searchParams;
+ return params.has('itemCode') || params.has('keyword');
+ })).toBe(false);
+ expect(mockFetch.mock.calls.some(([url]) => String(url).includes('totalSearch'))).toBe(false);
});
it('재고 검색 중 알 수 없는 예외를 처리한다', async () => {
diff --git a/tests/api/gs25-location-resilience.test.ts b/tests/api/gs25-location-resilience.test.ts
new file mode 100644
index 00000000..a46d3199
--- /dev/null
+++ b/tests/api/gs25-location-resilience.test.ts
@@ -0,0 +1,111 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { handleGs25CheckInventory } from '../../src/api/gs25Handlers.js';
+import { createCheckInventoryTool } from '../../src/services/gs25/tools/checkInventory.js';
+import { ServiceError } from '../../src/core/errors.js';
+import type { Gs25Store } from '../../src/services/gs25/types.js';
+const mocks = vi.hoisted(() => ({ stores: vi.fn(), products: vi.fn(), geocode: vi.fn() }));
+vi.mock('../../src/services/gs25/client.js', async (original) => ({
+ ...await original(),
+ fetchGs25Stores: mocks.stores, fetchGs25SearchProducts: mocks.products,
+ geocodeGs25Address: mocks.geocode,
+}));
+const message = '요청한 지역의 위치를 확인하지 못했습니다. 좌표를 지정하거나 잠시 후 다시 조회해주세요.';
+function store(latitude = 0, longitude = 0, address = '부산 중구 중앙대로 1'): Gs25Store {
+ return {
+ storeCode: 'B1', storeName: '부산중앙점', address, latitude, longitude,
+ realStockQuantity: 1, searchItemName: '콜라', searchItemSellPrice: 1000,
+ propertyNames: [], properties: [], phone: '', serviceCode: '01',
+ pickupStockQuantity: null, deliveryStockQuantity: null, isSoldOut: false, distanceM: null,
+ };
+}
+function context(query: Record) {
+ return { env: {}, header: vi.fn(), req: { query: (key: string) => query[key] }, json: vi.fn((data, status = 200) => ({ data, status })) } as unknown as Parameters[0];
+}
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.stores.mockResolvedValue({ stores: [store()], totalCount: 1, cacheHit: false });
+ mocks.geocode.mockResolvedValue(null);
+ mocks.products.mockResolvedValue([]);
+});
+
+describe('GS25 지정 지역 위치 보호', () => {
+ it('MCP는 지역 좌표 확인 실패 시 상품 재고 조회 전에 503을 던진다', async () => {
+ const tool = createCheckInventoryTool();
+ await expect(tool.handler({ keyword: '콜라', itemCode: 'ITEM', storeKeyword: '부산' })).rejects.toMatchObject({ code: 'GS25_LOCATION_UNRESOLVED', message, status: 503, retryable: true });
+ expect(mocks.stores).toHaveBeenCalledTimes(1);
+ expect(mocks.stores.mock.calls[0][0]).not.toHaveProperty('itemCode');
+ expect(mocks.products).not.toHaveBeenCalled();
+ });
+ it('REST는 지역 좌표 확인 실패 시 상품 재고 조회 전에 503을 반환한다', async () => {
+ const c = context({ keyword: '콜라', itemCode: 'ITEM', storeKeyword: '부산' });
+ await handleGs25CheckInventory(c);
+ expect(c.json).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: { code: 'GS25_LOCATION_UNRESOLVED', message }, diagnostics: expect.objectContaining({ status: 503, retryable: true }) }), 503);
+ expect(mocks.stores).toHaveBeenCalledTimes(1);
+ expect(mocks.stores.mock.calls[0][0]).not.toHaveProperty('itemCode');
+ expect(mocks.products).not.toHaveBeenCalled();
+ });
+ it('MCP는 일치 매장의 한국 좌표를 사용하여 지오코딩을 생략한다', async () => {
+ mocks.stores.mockResolvedValue({ stores: [store(35.1, 129.03)], totalCount: 1, cacheHit: false });
+ const result = await createCheckInventoryTool().handler({ keyword: '콜라', itemCode: 'ITEM', storeKeyword: '부산' });
+ expect(mocks.geocode).not.toHaveBeenCalled();
+ expect(mocks.stores.mock.calls[1][0]).toMatchObject({ itemCode: 'ITEM', latitude: 35.1, longitude: 129.03 });
+ expect(JSON.parse(result.content[0].text)).toMatchObject({ geocodeUsed: false, location: { latitude: 35.1, longitude: 129.03 } });
+ });
+ it('REST는 직접 변환 실패 후 일치 매장 좌표를 사용하고 주소 변환을 생략한다', async () => {
+ mocks.stores.mockResolvedValue({ stores: [store(35.1, 129.03)], totalCount: 1, cacheHit: false });
+ const c = context({ keyword: '콜라', itemCode: 'ITEM', storeKeyword: '부산' });
+ await handleGs25CheckInventory(c);
+ expect(mocks.geocode).toHaveBeenCalledTimes(1);
+ expect(mocks.stores.mock.calls[1][0]).toMatchObject({ itemCode: 'ITEM', latitude: 35.1, longitude: 129.03 });
+ expect(c.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: expect.objectContaining({ geocodeUsed: false, location: { latitude: 35.1, longitude: 129.03 } }) }));
+ });
+ it('주소가 없어도 일치 매장 자체 좌표를 사용한다', async () => {
+ mocks.stores.mockResolvedValue({ stores: [store(35.1, 129.03, '')], totalCount: 1, cacheHit: false });
+ await createCheckInventoryTool().handler({ keyword: '콜라', itemCode: 'ITEM', storeKeyword: '부산' });
+ expect(mocks.geocode).not.toHaveBeenCalled();
+ expect(mocks.stores.mock.calls[1][0]).toMatchObject({ latitude: 35.1, longitude: 129.03 });
+ });
+ it('MCP는 지역 미지정 요청에서 기존 기본 좌표를 유지한다', async () => {
+ await createCheckInventoryTool().handler({ keyword: '콜라', itemCode: 'ITEM' });
+ expect(mocks.stores.mock.calls[0][0]).toMatchObject({ itemCode: 'ITEM', latitude: 37.4979, longitude: 127.0276 });
+ });
+ it.each([['MCP', async () => createCheckInventoryTool().handler({ keyword: '콜라', itemCode: 'ITEM', storeKeyword: '부산', latitude: 0, longitude: 0 })], ['REST', async () => {
+ const c = context({ keyword: '콜라', itemCode: 'ITEM', storeKeyword: '부산', lat: '0', lng: '0' });
+ await handleGs25CheckInventory(c);
+ expect(c.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.objectContaining({ code: 'GS25_LOCATION_UNRESOLVED' }) }), 503);
+ }]] as const)('%s 잘못된 지정 좌표를 재고 조회에 전달하지 않는다', async (kind, work) => {
+ if (kind === 'MCP') await expect(work()).rejects.toMatchObject({ code: 'GS25_LOCATION_UNRESOLVED' });
+ else await work();
+ expect(mocks.stores).not.toHaveBeenCalled();
+ });
+ it('유효한 지정 좌표는 위치 변환 없이 재고 조회한다', async () => {
+ await createCheckInventoryTool().handler({ keyword: '콜라', itemCode: 'ITEM', storeKeyword: '부산', latitude: 35.1, longitude: 129.03 });
+ expect(mocks.geocode).not.toHaveBeenCalled();
+ expect(mocks.stores).toHaveBeenCalledTimes(1);
+ expect(mocks.stores.mock.calls[0][0]).toMatchObject({ itemCode: 'ITEM', latitude: 35.1, longitude: 129.03 });
+ });
+ it('릴레이 인증 오류를 위치 미해결 오류로 바꾸지 않는다', async () => {
+ mocks.stores.mockRejectedValue(new ServiceError('GS25_AUTH_REQUIRED', '인증이 필요합니다.', 503, false));
+ await expect(createCheckInventoryTool().handler({ keyword: '콜라', itemCode: 'ITEM', storeKeyword: '부산' })).rejects.toMatchObject({ code: 'GS25_AUTH_REQUIRED' });
+ const c = context({ keyword: '콜라', itemCode: 'ITEM', storeKeyword: '부산' });
+ await handleGs25CheckInventory(c);
+ expect(c.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.objectContaining({ code: 'GS25_AUTH_REQUIRED' }) }), 503);
+ });
+ it('MCP는 주소만 있는 첫 후보보다 뒤의 유효한 매장 좌표를 우선한다', async () => {
+ mocks.stores.mockResolvedValueOnce({ stores: [store(), store(35.1, 129.03)], totalCount: 2, cacheHit: false });
+ const result = await createCheckInventoryTool().handler({ keyword: '콜라', itemCode: 'ITEM', storeKeyword: '부산' });
+ expect(mocks.geocode).not.toHaveBeenCalled();
+ expect(mocks.stores.mock.calls[1][0]).toMatchObject({ itemCode: 'ITEM', latitude: 35.1, longitude: 129.03 });
+ expect(JSON.parse(result.content[0].text)).toMatchObject({ geocodeUsed: false, location: { latitude: 35.1, longitude: 129.03 } });
+ });
+ it('REST는 주소만 있는 첫 후보보다 뒤의 유효한 매장 좌표를 우선한다', async () => {
+ mocks.stores.mockResolvedValueOnce({ stores: [store(), store(35.1, 129.03)], totalCount: 2, cacheHit: false });
+ const c = context({ keyword: '콜라', itemCode: 'ITEM', storeKeyword: '부산' });
+ await handleGs25CheckInventory(c);
+ expect(mocks.geocode).toHaveBeenCalledTimes(1);
+ expect(mocks.geocode.mock.calls[0][0]).toBe('부산');
+ expect(mocks.stores.mock.calls[1][0]).toMatchObject({ itemCode: 'ITEM', latitude: 35.1, longitude: 129.03 });
+ expect(c.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: expect.objectContaining({ geocodeUsed: false, location: { latitude: 35.1, longitude: 129.03 } }) }));
+ });
+
+});
diff --git a/tests/app/app-api-actions.test.ts b/tests/app/app-api-actions.test.ts
index 26e1a25f..721a37e9 100644
--- a/tests/app/app-api-actions.test.ts
+++ b/tests/app/app-api-actions.test.ts
@@ -92,19 +92,6 @@ describe('GET /api/actions/query', () => {
}),
),
)
- .mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- stores: [
- {
- storeCode: 'A1',
- storeName: 'GS25 안산중앙점',
- storeAddress: '경기 안산시 단원구 중앙대로 907',
- },
- ],
- }),
- ),
- )
.mockResolvedValueOnce(
new Response(
JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '', y: 37.3187, x: 126.8389 }] }),
diff --git a/tests/app/app-api-gs25.test.ts b/tests/app/app-api-gs25.test.ts
index 85e99d18..d4a32047 100644
--- a/tests/app/app-api-gs25.test.ts
+++ b/tests/app/app-api-gs25.test.ts
@@ -116,19 +116,6 @@ describe('GET /api/gs25/inventory', () => {
it('itemCode만으로도 GS25 재고 검색 결과를 반환한다', async () => {
vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key');
mockFetch
- .mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- stores: [
- {
- storeCode: 'A1',
- storeName: 'GS25 안산중앙점',
- storeAddress: '경기 안산시 단원구 중앙대로 907',
- },
- ],
- }),
- ),
- )
.mockResolvedValueOnce(
new Response(
JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '', y: 37.3187, x: 126.8389 }] }),
diff --git a/tests/durableObjects/geocodeGateway.test.ts b/tests/durableObjects/geocodeGateway.test.ts
new file mode 100644
index 00000000..4d0da54e
--- /dev/null
+++ b/tests/durableObjects/geocodeGateway.test.ts
@@ -0,0 +1,161 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { GeocodeGateway } from '../../src/durableObjects/geocodeGateway.js';
+import { geocodeScope, type GeocodeRequest } from '../../src/utils/geocodeProvider.js';
+import type { AppBindings } from '../../src/api/response.js';
+const env: AppBindings = { KAKAO_REST_API_KEY: 'test-key', NAVER_CLIENT_ID: 'test-id', NAVER_CLIENT_SECRET: 'test-secret' };
+function storageFixture() {
+ const values = new Map();
+ const storage = {
+ get: vi.fn(async (key: string) => values.get(key)),
+ put: vi.fn(async (key: string, value: number) => { values.set(key, value); }),
+ };
+ return { storage, values, state: { storage } as unknown as DurableObjectState };
+}
+async function payload(provider: GeocodeRequest['provider'] = 'kakao-address'): Promise {
+ return {
+ provider, timeoutMs: 100, scope: await geocodeScope(provider === 'naver-local' ? ['test-id', 'test-secret'] : ['test-key']),
+ params: provider === 'kakao-address' ? { query: '서울 중구 세종대로 110' } : provider === 'kakao-keyword' ? { query: 'CGV', size: '15' } : { query: '서울역', display: '5', start: '1', sort: 'random' },
+ };
+}
+function lookup(gateway: GeocodeGateway, body: unknown): Promise {
+ return gateway.fetch(new Request('https://geocode.internal/lookup', { method: 'POST', body: JSON.stringify(body) }));
+}
+afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.useRealTimers(); });
+
+describe('GeocodeGateway', () => {
+ it.each(['kakao-address', 'kakao-keyword', 'naver-local'] as const)('%s의 구성 인증으로 고정 공급자에 요청한다', async (provider) => {
+ const fixture = storageFixture();
+ const fetcher = vi.fn(async () => Response.json({ documents: [] }));
+ vi.stubGlobal('fetch', fetcher);
+ const result = await lookup(new GeocodeGateway(fixture.state, env), await payload(provider));
+ expect(result.status).toBe(200);
+ expect(await result.json()).toEqual({ status: 200, body: { documents: [] } });
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ expect(fixture.storage.put).not.toHaveBeenCalled();
+ const options = (fetcher.mock.calls[0] as unknown as [string, RequestInit])[1];
+ expect(options).toMatchObject({ method: 'GET', redirect: 'manual' });
+ expect(options.headers).toEqual(provider === 'naver-local' ? { 'X-Naver-Client-Id': 'test-id', 'X-Naver-Client-Secret': 'test-secret' } : { Authorization: 'KakaoAK test-key' });
+ });
+
+ it('원본 JSON 타입을 응답 packet에 보존한다', async () => {
+ vi.stubGlobal('fetch', async () => Response.json(null, { status: 400 }));
+ const fixture = storageFixture();
+ expect(await (await lookup(new GeocodeGateway(fixture.state, env), await payload())).json()).toEqual({ status: 400, body: null });
+ });
+
+ it('다른 method와 경로는 외부 요청 없이 404를 반환한다', async () => {
+ const fetcher = vi.fn();
+ vi.stubGlobal('fetch', fetcher);
+ const gateway = new GeocodeGateway(storageFixture().state, env);
+ expect((await gateway.fetch(new Request('https://geocode.internal/lookup'))).status).toBe(404);
+ expect((await gateway.fetch(new Request('https://geocode.internal/other', { method: 'POST' }))).status).toBe(404);
+ expect(fetcher).not.toHaveBeenCalled();
+ });
+
+ it('본문 크기와 JSON 구문과 스키마를 검증한다', async () => {
+ const fixture = storageFixture();
+ const gateway = new GeocodeGateway(fixture.state, env);
+ const fetcher = vi.fn();
+ vi.stubGlobal('fetch', fetcher);
+ for (const body of ['x', 'x'.repeat(4097), 'null', '[]', JSON.stringify({ ...await payload(), unexpected: 'field' })]) {
+ const result = await gateway.fetch(new Request('https://geocode.internal/lookup', { method: 'POST', body }));
+ expect(result.status).toBe(400);
+ }
+ const stream = new ReadableStream({ start(controller) { controller.error(new Error('private body')); } });
+ const broken = new Request('https://geocode.internal/lookup', { method: 'POST', body: stream, duplex: 'half' } as RequestInit);
+ expect((await gateway.fetch(broken)).status).toBe(400);
+ expect(fetcher).not.toHaveBeenCalled();
+ expect(fixture.storage.get).not.toHaveBeenCalled();
+ });
+
+ it('scope mismatch와 누락 인증을 외부 호출 없이 거부한다', async () => {
+ const fixture = storageFixture();
+ const fetcher = vi.fn();
+ vi.stubGlobal('fetch', fetcher);
+ expect((await lookup(new GeocodeGateway(fixture.state, env), { ...await payload(), scope: 'a'.repeat(64) })).status).toBe(403);
+ expect((await lookup(new GeocodeGateway(fixture.state, {}), await payload())).status).toBe(403);
+ expect(fetcher).not.toHaveBeenCalled();
+ expect(fixture.storage.get).not.toHaveBeenCalled();
+ });
+
+ it.each(['get', 'put'] as const)('SQLite %s 실패는 외부 fallback 없이 503 고정 진단을 반환한다', async (operation) => {
+ const fixture = storageFixture();
+ fixture.storage[operation].mockImplementation(async () => { throw new Error('private storage error'); });
+ const fetcher = vi.fn(async () => Response.json({ code: -10 }, { status: 400 }));
+ vi.stubGlobal('fetch', fetcher);
+ const result = await lookup(new GeocodeGateway(fixture.state, env), await payload());
+ expect(result.status).toBe(503);
+ expect(await result.json()).toEqual({ code: 'GEOCODE_GATEWAY_UNAVAILABLE' });
+ expect(fetcher).toHaveBeenCalledTimes(operation === 'get' ? 0 : 1);
+ });
+
+ it('재생성한 DO가 저장된 quota를 유지하고 만료 후 실제 요청으로 재개한다', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(1000);
+ const fixture = storageFixture();
+ const fetcher = vi.fn(async () => Response.json({ code: -10 }, { status: 400 }));
+ vi.stubGlobal('fetch', fetcher);
+ const request = await payload();
+ const first = await lookup(new GeocodeGateway(fixture.state, env), request);
+ expect(await first.json()).toEqual({ status: 400, body: { code: -10 } });
+ expect([...fixture.values]).toEqual([[`quota:${request.scope}:kakao-address`, 1801000]]);
+ const second = await lookup(new GeocodeGateway(fixture.state, env), request);
+ expect(await second.json()).toEqual({ status: 429, body: { code: 'GEOCODE_QUOTA_BLOCKED' } });
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ vi.setSystemTime(1801000);
+ await lookup(new GeocodeGateway(fixture.state, env), request);
+ expect(fetcher).toHaveBeenCalledTimes(2);
+ for (const [key, value] of fixture.values) {
+ expect(key).toMatch(/^quota:[a-f0-9]{64}:kakao-address$/u);
+ expect(typeof value).toBe('number');
+ expect(key).not.toContain(request.params.query);
+ expect(key).not.toContain('test-key');
+ }
+ });
+
+ it('네이버 quota와 카카오 endpoint 상태를 격리한다', async () => {
+ const fixture = storageFixture();
+ const fetcher = vi.fn(async () => Response.json({}, { status: 429 }));
+ vi.stubGlobal('fetch', fetcher);
+ const gateway = new GeocodeGateway(fixture.state, env);
+ await lookup(gateway, await payload('naver-local'));
+ await lookup(gateway, await payload('kakao-address'));
+ await lookup(gateway, await payload('kakao-keyword'));
+ expect(fetcher).toHaveBeenCalledTimes(3);
+ expect(fixture.values.size).toBe(3);
+ });
+
+ it('비동기 전송 실패·기한·응답 크기 실패를 같은 안전한 오류로 반환한다', async () => {
+ const fixture = storageFixture();
+ const gateway = new GeocodeGateway(fixture.state, env);
+ const request = await payload();
+ vi.stubGlobal('fetch', async () => { throw new Error('private transport'); });
+ expect((await lookup(gateway, request)).status).toBe(503);
+ vi.stubGlobal('fetch', async () => new Response('x'.repeat(524289)));
+ expect((await lookup(gateway, request)).status).toBe(503);
+ vi.useFakeTimers();
+ const hanging = vi.fn(() => new Promise(() => {}));
+ vi.stubGlobal('fetch', hanging);
+ const timeout = lookup(gateway, request);
+ await vi.waitFor(() => expect(hanging).toHaveBeenCalledTimes(1));
+ await vi.advanceTimersByTimeAsync(100);
+ expect((await timeout).status).toBe(503);
+ expect(fixture.storage.put).not.toHaveBeenCalled();
+ });
+
+ it('서로 다른 param 순서의 진행 중 요청을 병합하고 응답 완료 후 다시 호출한다', async () => {
+ let resolve!: (response: Response) => void;
+ const fetcher = vi.fn(() => new Promise((done) => { resolve = done; }));
+ vi.stubGlobal('fetch', fetcher);
+ const gateway = new GeocodeGateway(storageFixture().state, env);
+ const request = await payload('kakao-keyword');
+ const one = lookup(gateway, request);
+ const two = lookup(gateway, { ...request, params: { size: '15', query: request.params.query } });
+ await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
+ resolve(Response.json({ documents: [] }));
+ expect(await (await one).json()).toEqual(await (await two).json());
+ fetcher.mockImplementation(async () => Response.json({ documents: [] }));
+ await lookup(gateway, request);
+ expect(fetcher).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/tests/freeGeocode.test.ts b/tests/freeGeocode.test.ts
index b5736d78..cb298132 100644
--- a/tests/freeGeocode.test.ts
+++ b/tests/freeGeocode.test.ts
@@ -102,8 +102,8 @@ it.each([
expect(await geocodeLocation(query, { kakaoRestApiKey: 'free' })).toBeNull();
vi.unstubAllGlobals();
});
-it('주소 검색은 Naver Local로 우회하지 않는다', async () => {
- const mockFetch = vi.fn();
+it('주소 대체 검색도 정확한 주소 결과가 없으면 선택하지 않는다', async () => {
+ const mockFetch = vi.fn().mockImplementation(async () => Response.json({ items: [] }));
vi.stubGlobal('fetch', mockFetch);
expect(
await geocodeLocation('부산 동구 중앙대로 206', {
@@ -111,7 +111,7 @@ it('주소 검색은 Naver Local로 우회하지 않는다', async () => {
naverClientSecret: 'secret',
}),
).toBeNull();
- expect(mockFetch).not.toHaveBeenCalled();
+ expect(mockFetch).toHaveBeenCalledTimes(1);
vi.unstubAllGlobals();
});
it('Kakao 오류는 이름이 일치하는 Naver 장소로 보완한다', async () => {
@@ -269,7 +269,8 @@ it('주소 응답이 누락되었거나 지번과 도로명 번호가 섞이면
vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ documents: {} })))
- .mockResolvedValueOnce(
+ .mockResolvedValueOnce(new Response(JSON.stringify({ documents: [] })))
+ .mockImplementation(() => Promise.resolve(
new Response(
JSON.stringify({
documents: [
@@ -282,7 +283,7 @@ it('주소 응답이 누락되었거나 지번과 도로명 번호가 섞이면
],
}),
),
- ),
+ )),
);
expect(await geocodeLocation('서울 강남구 역삼동 123', { kakaoRestApiKey: 'free' })).toBeNull();
expect(await geocodeLocation('서울 강남구 역삼동 4', { kakaoRestApiKey: 'free' })).toBeNull();
diff --git a/tests/services/gs25/tools/checkInventory.test.ts b/tests/services/gs25/tools/checkInventory.test.ts
index ead61559..cb110b7e 100644
--- a/tests/services/gs25/tools/checkInventory.test.ts
+++ b/tests/services/gs25/tools/checkInventory.test.ts
@@ -193,7 +193,7 @@ describe('createCheckInventoryTool', () => {
mockFetch.mockResolvedValueOnce(
new Response(
- JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남구 강남대로 1', y: 37.4979, x: 127.0276 }] }),
+ JSON.stringify({ documents: [{ address_name: '서울 강남구 테헤란로 1', y: 37.4979, x: 127.0276 }] }),
),
);
diff --git a/tests/utils/externalRouting.test.ts b/tests/utils/externalRouting.test.ts
new file mode 100644
index 00000000..9bce7969
--- /dev/null
+++ b/tests/utils/externalRouting.test.ts
@@ -0,0 +1,24 @@
+import { expect, it, vi } from 'vitest';
+import { withExternalRouting } from '../../src/utils/externalRouting.js';
+import { createGeocodeFetch } from '../../src/utils/geocodeFetch.js';
+it('Worker REST/MCP 요청 context의 지오코딩 바인딩을 연결한다', async () => {
+ const rpc = vi.fn(async () => Response.json({ status: 200, body: { documents: [] } }));
+ const namespace = {
+ idFromName: vi.fn(),
+ get: () => ({ fetch: rpc }),
+ } as unknown as DurableObjectNamespace;
+ const result = await withExternalRouting(
+ { GEOCODE_GATEWAY: namespace, KAKAO_REST_API_KEY: 'key' },
+ { waitUntil: vi.fn() } as unknown as ExecutionContext,
+ () =>
+ createGeocodeFetch(vi.fn())(
+ 'https://dapi.kakao.com/v2/local/search/address.json?query=부산%20중앙대로%20206',
+ { headers: { Authorization: 'KakaoAK key' } },
+ ),
+ );
+ expect(result.status).toBe(200);
+ expect(rpc).toHaveBeenCalledTimes(1);
+});
+it('상태 바인딩 없는 기존 Worker 실행도 유지한다', () => {
+ expect(withExternalRouting({}, undefined as unknown as ExecutionContext, () => 'ok')).toBe('ok');
+});
diff --git a/tests/utils/geocode-fallback.test.ts b/tests/utils/geocode-fallback.test.ts
new file mode 100644
index 00000000..c4c98639
--- /dev/null
+++ b/tests/utils/geocode-fallback.test.ts
@@ -0,0 +1,412 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ geocodeLocation,
+ searchKakaoPlaces,
+ geocodeBindings,
+ readGeocodeEnvironment,
+ validKoreanCoordinates,
+} from '../../src/utils/geocode.js';
+
+const address = '서울 강남구 예시로5길 30';
+const keys = {
+ kakaoRestApiKey: 'test-kakao',
+ naverClientId: 'test-id',
+ naverClientSecret: 'test-secret',
+};
+const kakao = (value: string = address) => ({
+ place_name: '예시가게',
+ address_name: value,
+ x: '127',
+ y: '37.5',
+});
+const naver = (value: string = address) => ({
+ title: '예시가게',
+ roadAddress: value,
+ mapx: '1270000000',
+ mapy: '375000000',
+});
+const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
+function responses(...values: Response[]) {
+ return vi
+ .fn()
+ .mockImplementation(async () => (values.shift() ?? json({ documents: [], items: [] })).clone());
+}
+
+describe('정확한 주소 fallback', () => {
+ beforeEach(() => {
+ vi.stubEnv('KAKAO_REST_API_KEY', '');
+ vi.stubEnv('NAVER_CLIENT_ID', '');
+ vi.stubEnv('NAVER_CLIENT_SECRET', '');
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ });
+
+ it('주소 API 쿼터 소진 후 정확한 Kakao keyword 주소를 사용한다', async () => {
+ const fetchImpl = responses(json({ code: -10 }, 400), json({ documents: [kakao()] }));
+ expect(await geocodeLocation(address, { ...keys, fetchImpl })).toEqual({
+ latitude: 37.5,
+ longitude: 127,
+ formattedAddress: address,
+ });
+ expect(fetchImpl.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([
+ '/v2/local/search/address.json',
+ '/v2/local/search/keyword.json',
+ ]);
+ });
+
+ it('keyword의 다른 주소를 버리고 Naver의 정확한 주소로 보완한다', async () => {
+ const fetchImpl = responses(
+ json({ documents: [] }),
+ json({ documents: [kakao('서울 강남구 예시로5길 31')] }),
+ json({ items: [naver()] }),
+ );
+ expect(await geocodeLocation(address, { ...keys, fetchImpl })).toMatchObject({
+ formattedAddress: address,
+ });
+ expect(fetchImpl).toHaveBeenCalledTimes(3);
+ });
+
+ it('Kakao 키가 없어도 Naver의 정확한 주소를 사용한다', async () => {
+ const fetchImpl = responses(json({ items: [naver()] }));
+ expect(
+ await geocodeLocation(address, {
+ naverClientId: 'test-id',
+ naverClientSecret: 'test-secret',
+ fetchImpl,
+ }),
+ ).toMatchObject({ latitude: 37.5 });
+ });
+
+ it.each(['서울 강남구 예시로30길 5', '부산 강남구 예시로5길 30', '서울 동강남구 예시로5길 30'])(
+ 'fallback은 순서나 지역이 다른 주소를 거절한다: %s',
+ async (other) => {
+ const fetchImpl = responses(
+ json({ documents: [] }),
+ json({ documents: [kakao(other)] }),
+ json({ items: [naver(other)] }),
+ );
+ expect(await geocodeLocation(address, { ...keys, fetchImpl })).toBeNull();
+ expect(fetchImpl).toHaveBeenCalledTimes(3);
+ },
+ );
+
+ it('장소명만 입력주소와 같아도 다른 주소를 선택하지 않는다', async () => {
+ const other = '서울 강남구 예시로5길 31';
+ const fetchImpl = responses(
+ json({ documents: [] }),
+ json({ documents: [{ ...kakao(other), place_name: address }] }),
+ json({ items: [{ ...naver(other), title: address }] }),
+ );
+ expect(await geocodeLocation(address, { ...keys, fetchImpl })).toBeNull();
+ });
+
+ it('모든 fallback에서 한국 밖의 좌표를 거절한다', async () => {
+ const fetchImpl = responses(
+ json({ documents: [] }),
+ json({ documents: [{ ...kakao(), x: '140' }] }),
+ json({ items: [{ ...naver(), mapy: '420000000' }] }),
+ );
+ expect(await geocodeLocation(address, { ...keys, fetchImpl })).toBeNull();
+ });
+
+ it('정식 전북 명칭과 축약 지역명을 일치시킨다', async () => {
+ const fetchImpl = responses(
+ json({ documents: [] }),
+ json({ documents: [kakao('전북 예시시 예시로 30')] }),
+ );
+ expect(
+ await geocodeLocation('전북특별자치도 예시시 예시로 30', { ...keys, fetchImpl }),
+ ).toMatchObject({ latitude: 37.5 });
+ });
+
+ it('모든 키가 없으면 주소 요청을 하지 않는다', async () => {
+ const fetchImpl = vi.fn();
+ expect(await geocodeLocation(address, { fetchImpl })).toBeNull();
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it.each([null, {}, [null, 7, { address_name: 30 }]])(
+ '잘못된 documents 뒤에도 정확한 fallback을 찾는다: %j',
+ async (documents) => {
+ const fetchImpl = responses(json({ documents }), json({ documents: [kakao()] }));
+ expect(await geocodeLocation(address, { ...keys, fetchImpl })).toMatchObject({
+ latitude: 37.5,
+ });
+ },
+ );
+
+ it('주소 응답의 잘못된 JSON 이후에도 정확한 fallback을 찾는다', async () => {
+ const fetchImpl = responses(new Response('invalid json'), json({ documents: [kakao()] }));
+ expect(await geocodeLocation(address, { ...keys, fetchImpl })).toMatchObject({
+ latitude: 37.5,
+ });
+ });
+
+ it('주소 요청 timeout 뒤에도 정확한 fallback을 찾는다', async () => {
+ const fetchImpl = responses(json({ documents: [kakao()] }));
+ fetchImpl.mockRejectedValueOnce(new DOMException('timeout', 'TimeoutError'));
+ expect(await geocodeLocation(address, { ...keys, fetchImpl, timeoutMs: 20 })).toMatchObject({
+ latitude: 37.5,
+ });
+ });
+
+ it('기존 장소 검색은 Kakao keyword에서 Naver 이름 검색으로 보완한다', async () => {
+ const fetchImpl = responses(
+ json({ documents: [] }),
+ json({ items: [{ ...naver(), title: '예시역' }] }),
+ );
+ expect(await geocodeLocation('예시역', { ...keys, fetchImpl })).toMatchObject({
+ latitude: 37.5,
+ });
+ expect(fetchImpl.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([
+ '/v2/local/search/keyword.json',
+ '/v1/search/local.json',
+ ]);
+ });
+ it('빈 입력과 환경키 없는 입력은 요청하지 않는다', async () => {
+ expect(await geocodeLocation(' ')).toBeNull();
+ expect(await geocodeLocation('예시역')).toBeNull();
+ expect(geocodeBindings()).toEqual({
+ kakaoRestApiKey: undefined,
+ naverClientId: undefined,
+ naverClientSecret: undefined,
+ });
+ expect(
+ geocodeBindings({
+ KAKAO_REST_API_KEY: 'test-kakao',
+ NAVER_CLIENT_ID: 'test-id',
+ NAVER_CLIENT_SECRET: 'test-secret',
+ }),
+ ).toEqual(keys);
+ });
+
+ it.each([
+ [NaN, 127],
+ [37, NaN],
+ [32, 127],
+ [40, 127],
+ [37, 123],
+ [37, 133],
+ [33, 124],
+ [39, 132],
+ ])('한국 좌표 경계를 검증한다: %s %s', (latitude, longitude) => {
+ expect(validKoreanCoordinates(latitude, longitude)).toBe(
+ latitude >= 33 && latitude <= 39 && longitude >= 124 && longitude <= 132,
+ );
+ });
+
+ it('근처 Kakao 장소검색은 거리순 파라미터와 지정 timeout을 유지한다', async () => {
+ const fetchImpl = responses(json({ documents: [null, { place_name: 3 }, kakao()] }));
+ expect(
+ await searchKakaoPlaces(
+ '예시역',
+ { ...keys, fetchImpl, timeout: 20 },
+ { latitude: 37.5, longitude: 127 },
+ ),
+ ).toEqual([kakao()]);
+ const url = new URL(String(fetchImpl.mock.calls[0][0]));
+ expect(Object.fromEntries(url.searchParams)).toMatchObject({
+ x: '127',
+ y: '37.5',
+ radius: '20000',
+ sort: 'distance',
+ });
+ });
+
+ it('Kakao 장소검색의 누락된 documents와 키는 빈 배열로 처리한다', async () => {
+ const fetchImpl = responses(json({}));
+ expect(await searchKakaoPlaces('예시역', { ...keys, fetchImpl, timeoutMs: 20 })).toEqual([]);
+ expect(await searchKakaoPlaces('예시역', {})).toEqual([]);
+ });
+
+ it('keyword 장애 후에도 Naver의 지번주소 일치로 보완한다', async () => {
+ const fetchImpl = responses(
+ json({ documents: [] }),
+ new Response('invalid json'),
+ json({ items: [{ ...naver(), roadAddress: '', address }] }),
+ );
+ expect(await geocodeLocation(address, { ...keys, fetchImpl, timeout: 20 })).toMatchObject({
+ formattedAddress: address,
+ });
+ });
+
+ it.each([null, {}, [null], [{ title: '예시가게', address: 3 }]])(
+ 'Naver의 malformed items는 위치로 사용하지 않는다: %j',
+ async (items) => {
+ const fetchImpl = responses(json({ items }));
+ expect(
+ await geocodeLocation(address, {
+ naverClientId: 'test-id',
+ naverClientSecret: 'test-secret',
+ fetchImpl,
+ }),
+ ).toBeNull();
+ },
+ );
+
+ it.each([{ mapx: undefined }, { mapy: undefined }, { mapx: 'not a coordinate' }])(
+ 'Naver의 누락되거나 잘못된 좌표를 거절한다: %j',
+ async (invalid) => {
+ const fetchImpl = responses(json({ items: [{ ...naver(), ...invalid }] }));
+ expect(
+ await geocodeLocation(address, {
+ naverClientId: 'test-id',
+ naverClientSecret: 'test-secret',
+ fetchImpl,
+ }),
+ ).toBeNull();
+ },
+ );
+
+ it('정확한 첫 주소를 찾으면 대체 공급자를 호출하지 않는다', async () => {
+ const fetchImpl = responses(json({ documents: [kakao()] }));
+ expect(await geocodeLocation(address, { ...keys, fetchImpl })).toMatchObject({
+ latitude: 37.5,
+ });
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ });
+ it('Worker에서 process가 없어도 환경 읽기를 처리한다', () => {
+ vi.stubGlobal('process', undefined);
+ const options = readGeocodeEnvironment();
+ vi.unstubAllGlobals();
+ expect(options).toEqual({
+ kakaoRestApiKey: undefined,
+ naverClientId: undefined,
+ naverClientSecret: undefined,
+ });
+ });
+
+ it.each([
+ [
+ '예시가게',
+ { ...kakao(), address_name: undefined, road_address_name: undefined },
+ { formattedAddress: null },
+ ],
+ ['예시가게 30', { ...kakao(), place_name: '예시가게 30' }, { latitude: 37.5 }],
+ ['예시가게 30', { ...kakao(), place_name: '예시가게', address_name: undefined }, null],
+ [
+ '예시가게',
+ { ...kakao(), place_name: '다른가게', road_address_name: '예시가게' },
+ { latitude: 37.5 },
+ ],
+ ['!!!', kakao(), null],
+ ['예시역', { ...kakao(), place_name: '예시역점' }, null],
+ ['예시역', { ...kakao(), place_name: undefined }, null],
+ ])('Kakao 장소의 이름·주소·숫자 매칭을 유지한다: %s', async (query, document, expected) => {
+ const fetchImpl = responses(json({ documents: [document] }));
+ const result = await geocodeLocation(query, { kakaoRestApiKey: 'test-kakao', fetchImpl });
+ if (expected === null) expect(result).toBeNull();
+ else expect(result).toMatchObject(expected);
+ });
+
+ it('Kakao 도로명주소만 있어도 정확한 입력주소를 일치시킨다', async () => {
+ const fetchImpl = responses(
+ json({ documents: [{ ...kakao(), address_name: undefined, road_address_name: address }] }),
+ );
+ expect(await geocodeLocation(address, { ...keys, fetchImpl })).toMatchObject({
+ formattedAddress: address,
+ });
+ });
+
+ it('Naver 장소의 주소가 없으면 formattedAddress는 null이다', async () => {
+ const fetchImpl = responses(
+ json({ items: [{ ...naver(), title: '예시역', roadAddress: '' }] }),
+ );
+ expect(
+ await geocodeLocation('예시역', {
+ naverClientId: 'test-id',
+ naverClientSecret: 'test-secret',
+ fetchImpl,
+ }),
+ ).toEqual({ latitude: 37.5, longitude: 127, formattedAddress: null });
+ });
+ it('주소 단계가 전체 기한을 소비하면 keyword와 Naver를 호출하지 않는다', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(1000);
+ const fetchImpl = vi.fn(async () => {
+ vi.setSystemTime(1100);
+ return json({ documents: [] });
+ });
+ expect(await geocodeLocation(address, { ...keys, fetchImpl, timeoutMs: 100 })).toBeNull();
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ });
+
+ it('keyword 단계 뒤 전체 기한이 소진되면 Naver를 호출하지 않는다', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(1000);
+ const fetchImpl = vi.fn(async () => {
+ vi.setSystemTime(fetchImpl.mock.calls.length === 1 ? 1040 : 1100);
+ return json({ documents: [] });
+ });
+ expect(await geocodeLocation(address, { ...keys, fetchImpl, timeout: 100 })).toBeNull();
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
+ });
+
+ it('앞 단계 지연을 빼고 남은 시간만 keyword에 적용한다', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(1000);
+ let started!: () => void;
+ const keywordStarted = new Promise((resolve) => { started = resolve; });
+ const fetchImpl = vi.fn(async (_url, init) => {
+ if (fetchImpl.mock.calls.length === 1) {
+ vi.setSystemTime(1040);
+ return json({ documents: [] });
+ }
+ started();
+ return new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
+ });
+ });
+ const task = geocodeLocation(address, { ...keys, fetchImpl, timeoutMs: 100 });
+ await keywordStarted;
+ await vi.advanceTimersByTimeAsync(60);
+ let done = false;
+ void task.then(() => { done = true; });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(done).toBe(true);
+ expect(await task).toBeNull();
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
+ });
+
+ it('장소명 검색도 Kakao 지연 뒤 Naver에서 전체 남은 시간만 사용한다', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(1000);
+ let started!: () => void;
+ const naverStarted = new Promise((resolve) => { started = resolve; });
+ const fetchImpl = vi.fn(async (_url, init) => {
+ if (fetchImpl.mock.calls.length === 1) {
+ vi.setSystemTime(1070);
+ return json({ documents: [] });
+ }
+ started();
+ return new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
+ });
+ });
+ const task = geocodeLocation('예시역', { ...keys, fetchImpl, timeoutMs: 100 });
+ await naverStarted;
+ await vi.advanceTimersByTimeAsync(30);
+ let done = false;
+ void task.then(() => { done = true; });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(done).toBe(true);
+ expect(await task).toBeNull();
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
+ });
+
+ it('독립적인 Kakao 장소검색은 명시 timeout 없이도 기본 예산으로 동작한다', async () => {
+ const fetchImpl = responses(json({ documents: [kakao()] }));
+ expect(await searchKakaoPlaces('예시가게', { ...keys, fetchImpl })).toEqual([kakao()]);
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ });
+
+ it('이미 소진된 전체 예산으로는 공급자를 호출하지 않는다', async () => {
+ const fetchImpl = vi.fn();
+ expect(await geocodeLocation(address, { ...keys, fetchImpl, timeoutMs: 0 })).toBeNull();
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+});
diff --git a/tests/utils/geocodeFetch.test.ts b/tests/utils/geocodeFetch.test.ts
new file mode 100644
index 00000000..20bbf3da
--- /dev/null
+++ b/tests/utils/geocodeFetch.test.ts
@@ -0,0 +1,243 @@
+import { afterEach, expect, it, vi } from 'vitest';
+import { createGeocodeFetch, withGeocodeProtection } from '../../src/utils/geocodeFetch.js';
+const url =
+ 'https://dapi.kakao.com/v2/local/search/address.json?query=서울%20강남구%20테헤란로%201';
+const init = { headers: { Authorization: 'KakaoAK test' } };
+afterEach(() => vi.restoreAllMocks());
+it('같은 주소의 진행 요청은 한 번만 호출하고 완료 후에는 다시 호출한다', async () => {
+ let finish!: (r: Response) => void;
+ const upstream = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((r) => {
+ finish = r;
+ }),
+ )
+ .mockImplementation(async () => Response.json({ documents: [] }));
+ const run = createGeocodeFetch(upstream);
+ const a = run(url, init);
+ const b = run(url, init);
+ await vi.waitFor(() => expect(upstream).toHaveBeenCalled());
+ finish(Response.json({ documents: [] }));
+ expect((await a).status).toBe(200);
+ expect((await b).status).toBe(200);
+ expect(upstream).toHaveBeenCalledTimes(1);
+ await run(url, init);
+ expect(upstream).toHaveBeenCalledTimes(2);
+});
+it('quota -10 뒤 다른 주소도 호출하지 않지만 keyword는 별도로 조회한다', async () => {
+ const upstream = vi
+ .fn()
+ .mockImplementation(async () => Response.json({ code: -10 }, { status: 400 }));
+ const run = createGeocodeFetch(upstream);
+ expect((await run(url, init)).status).toBe(400);
+ expect((await run(url.replace('테헤란로', '도산대로'), init)).status).toBe(429);
+ expect(upstream).toHaveBeenCalledTimes(1);
+ await run('https://dapi.kakao.com/v2/local/search/keyword.json?query=강남역&size=15', init);
+ expect(upstream).toHaveBeenCalledTimes(2);
+});
+it('configured DO 장애 때 원본 직접 호출로 우회하지 않는다', async () => {
+ const upstream = vi.fn();
+ const stub = { fetch: vi.fn().mockRejectedValue(new Error('private')) };
+ const ns = { idFromName: vi.fn(), get: () => stub } as unknown as DurableObjectNamespace;
+ await expect(
+ withGeocodeProtection({ GEOCODE_GATEWAY: ns, KAKAO_REST_API_KEY: 'test' }, () =>
+ createGeocodeFetch(upstream)(url, init),
+ ),
+ ).rejects.toThrow();
+ expect(upstream).not.toHaveBeenCalled();
+});
+it('caller 취소는 다른 병합 caller를 끊지 않는다', async () => {
+ let finish!: (r: Response) => void;
+ const upstream = vi.fn(
+ () =>
+ new Promise((r) => {
+ finish = r;
+ }),
+ );
+ const run = createGeocodeFetch(upstream);
+ const controller = new AbortController();
+ const a = run(url, { ...init, signal: controller.signal });
+ const b = run(url, init);
+ const failed = expect(a).rejects.toThrow();
+ await vi.waitFor(() => expect(upstream).toHaveBeenCalledTimes(1));
+ controller.abort();
+ await failed;
+ finish(Response.json({ documents: [] }));
+ expect((await b).status).toBe(200);
+});
+it('내부 바인딩에는 자격증명 원문 대신 해시만 전달한다', async () => {
+ const rpc = vi.fn(async () =>
+ Response.json({ status: 200, body: { documents: [] } }),
+ );
+ const ns = {
+ idFromName: vi.fn(),
+ get: () => ({ fetch: rpc }),
+ } as unknown as DurableObjectNamespace;
+ const source = vi.fn();
+ const response = await withGeocodeProtection(
+ { GEOCODE_GATEWAY: ns, KAKAO_REST_API_KEY: 'test' },
+ () => createGeocodeFetch(source)(url, init),
+ );
+ expect(response.status).toBe(200);
+ expect(source).not.toHaveBeenCalled();
+ const body = JSON.parse(String(rpc.mock.calls[0][1]?.body));
+ expect(body.scope).toMatch(/^[a-f0-9]{64}$/);
+ expect(body.provider).toBe('kakao-address');
+ expect(body.params.query).toContain('테헤란로');
+ expect(Object.keys(body)).toEqual(['provider', 'params', 'scope', 'timeoutMs']);
+});
+it('네이버 지역 검색에도 동일한 공유 보호를 적용한다', async () => {
+ const rpc = vi.fn(async () => Response.json({ status: 200, body: { items: [] } }));
+ const ns = {
+ idFromName: vi.fn(),
+ get: () => ({ fetch: rpc }),
+ } as unknown as DurableObjectNamespace;
+ const source = vi.fn();
+ await withGeocodeProtection(
+ { GEOCODE_GATEWAY: ns, NAVER_CLIENT_ID: 'id', NAVER_CLIENT_SECRET: 'secret' },
+ () =>
+ createGeocodeFetch(source)(
+ 'https://openapi.naver.com/v1/search/local.json?query=부산역&display=5&start=1&sort=random',
+ { headers: { 'X-Naver-Client-Id': 'id', 'X-Naver-Client-Secret': 'secret' } },
+ ),
+ );
+ expect(rpc).toHaveBeenCalledTimes(1);
+ expect(source).not.toHaveBeenCalled();
+});
+it('다른 자격증명은 서버 바인딩의 quota나 key와 섞지 않는다', async () => {
+ const rpc = vi.fn();
+ const source = vi.fn(async () => Response.json({ documents: [] }));
+ const ns = {
+ idFromName: vi.fn(),
+ get: () => ({ fetch: rpc }),
+ } as unknown as DurableObjectNamespace;
+ await withGeocodeProtection({ GEOCODE_GATEWAY: ns, KAKAO_REST_API_KEY: 'other' }, () =>
+ createGeocodeFetch(source)(url, init),
+ );
+ expect(source).toHaveBeenCalledTimes(1);
+ expect(rpc).not.toHaveBeenCalled();
+});
+it('다른 API 주소는 공급자 게이트나 요청 내용을 변경하지 않는다', async () => {
+ const response = new Response('plain');
+ const source = vi.fn(async () => response);
+ expect(await createGeocodeFetch(source)('https://example.com/data')).toBe(response);
+});
+it.each([
+ ['credentials', url, {}],
+ ['credential-prefix', url, { headers: { Authorization: 'Bearer test' } }],
+ ['method', url, { ...init, method: 'POST' }],
+ ['query', url + '&invalid=yes', init],
+ ['userinfo', url.replace('https://', 'https://private@'), init],
+])('잘못된 공급자 요청은 외부로 보내지 않는다: %s', async (_, target, options) => {
+ const source = vi.fn();
+ await expect(createGeocodeFetch(source)(target, options)).rejects.toThrow();
+ expect(source).not.toHaveBeenCalled();
+});
+it.each([
+ null,
+ {},
+ { status: 600, body: {} },
+ { status: 200.5, body: {} },
+ { status: 199, body: {} },
+ { status: 200 },
+])('잘못된 내부 packet은 사용하지 않는다: %j', async (packet) => {
+ const rpc = vi.fn(async () => Response.json(packet));
+ const ns = {
+ idFromName: vi.fn(),
+ get: () => ({ fetch: rpc }),
+ } as unknown as DurableObjectNamespace;
+ await expect(
+ withGeocodeProtection({ GEOCODE_GATEWAY: ns, KAKAO_REST_API_KEY: 'test' }, () =>
+ createGeocodeFetch(vi.fn())(url, init),
+ ),
+ ).rejects.toThrow('GEOCODE_INVALID_RESPONSE');
+});
+it('내부 503이 원본의 성공 응답처럼 해석되지 않는다', async () => {
+ const ns = {
+ idFromName: vi.fn(),
+ get: () => ({ fetch: async () => new Response(null, { status: 503 }) }),
+ } as unknown as DurableObjectNamespace;
+ await expect(
+ withGeocodeProtection({ GEOCODE_GATEWAY: ns, KAKAO_REST_API_KEY: 'test' }, () =>
+ createGeocodeFetch(vi.fn())(url, init),
+ ),
+ ).rejects.toThrow('GEOCODE_GATEWAY_UNAVAILABLE');
+});
+it.each([204, 205, 304])('본문 없는 공급자 상태는 본문을 만들지 않는다: %i', async (status) => {
+ const ns = {
+ idFromName: vi.fn(),
+ get: () => ({ fetch: async () => Response.json({ status, body: null }) }),
+ } as unknown as DurableObjectNamespace;
+ const r = await withGeocodeProtection({ GEOCODE_GATEWAY: ns, KAKAO_REST_API_KEY: 'test' }, () =>
+ createGeocodeFetch(vi.fn())(url, init),
+ );
+ expect(r.status).toBe(status);
+ expect(await r.text()).toBe('');
+});
+it('이미 취소된 caller는 query 해시나 원본 작업을 시작하지 않는다', async () => {
+ const source = vi.fn();
+ const c = new AbortController();
+ c.abort();
+ await expect(createGeocodeFetch(source)(url, { ...init, signal: c.signal })).rejects.toThrow(
+ 'GEOCODE_CANCELLED',
+ );
+ expect(source).not.toHaveBeenCalled();
+});
+it('자격증명 해시 중 취소된 caller도 새 작업을 만들지 않는다', async () => {
+ const source = vi.fn();
+ const c = new AbortController();
+ const result = createGeocodeFetch(source)(url, { ...init, signal: c.signal });
+ c.abort();
+ await expect(result).rejects.toThrow('GEOCODE_CANCELLED');
+ expect(source).not.toHaveBeenCalled();
+});
+it('Request 객체의 GET·header·signal도 유지한다', async () => {
+ const source = vi.fn(async () => Response.json({ documents: [] }));
+ const r = await withGeocodeProtection(undefined, () =>
+ createGeocodeFetch(source)(new Request(url, init)),
+ );
+ expect(r.status).toBe(200);
+ expect(source).toHaveBeenCalledTimes(1);
+});
+it('무바인딩 내부 호출은 부모 요청 context를 상속하지 않는다', async () => {
+ const rpc = vi.fn();
+ const source = vi.fn(async () => Response.json({ documents: [] }));
+ const ns = {
+ idFromName: vi.fn(),
+ get: () => ({ fetch: rpc }),
+ } as unknown as DurableObjectNamespace;
+ await withGeocodeProtection({ GEOCODE_GATEWAY: ns, KAKAO_REST_API_KEY: 'test' }, () =>
+ withGeocodeProtection({}, () => createGeocodeFetch(source)(url, init)),
+ );
+ expect(source).toHaveBeenCalledTimes(1);
+ expect(rpc).not.toHaveBeenCalled();
+});
+it.each([NaN, 0, 25000])(
+ '시간 예산은 공유 요청에도 유한한 범위로 제한한다: %s',
+ async (timeout) => {
+ const rpc = vi.fn(async () => Response.json({ status: 200, body: {} }));
+ const ns = {
+ idFromName: vi.fn(),
+ get: () => ({ fetch: rpc }),
+ } as unknown as DurableObjectNamespace;
+ await withGeocodeProtection({ GEOCODE_GATEWAY: ns, KAKAO_REST_API_KEY: 'test' }, () =>
+ createGeocodeFetch(vi.fn(), timeout)(url, init),
+ );
+ expect(JSON.parse(String(rpc.mock.calls[0][1]?.body)).timeoutMs).toBe(
+ Number.isNaN(timeout) ? 10000 : Math.max(1, Math.min(20000, timeout)),
+ );
+ },
+);
+it('반환하지 않는 내부 RPC는 기한 뒤 실패한다', async () => {
+ const ns = {
+ idFromName: vi.fn(),
+ get: () => ({ fetch: () => new Promise(() => {}) }),
+ } as unknown as DurableObjectNamespace;
+ await expect(
+ withGeocodeProtection({ GEOCODE_GATEWAY: ns, KAKAO_REST_API_KEY: 'test' }, () =>
+ createGeocodeFetch(vi.fn(), 5)(url, init),
+ ),
+ ).rejects.toThrow('GEOCODE_TIMEOUT');
+});
diff --git a/tests/utils/geocodeGate.test.ts b/tests/utils/geocodeGate.test.ts
new file mode 100644
index 00000000..089bf311
--- /dev/null
+++ b/tests/utils/geocodeGate.test.ts
@@ -0,0 +1,215 @@
+import { describe, expect, it, vi } from 'vitest';
+import { GeocodeGate, type GeocodeReply, type GeocodeQuotaStore } from '../../src/utils/geocodeGate.js';
+
+const scope = 'a'.repeat(64);
+const ok: GeocodeReply = { status: 200, body: { documents: [] } };
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
+ return { promise, resolve, reject };
+}
+function memoryStore(): GeocodeQuotaStore & { values: Map } {
+ const values = new Map();
+ return { values, get: async (key) => values.get(key), put: async (key, value) => { values.set(key, value); } };
+}
+
+describe('GeocodeGate', () => {
+ it('동일 진행 요청만 병합하고 반환값을 복제하며 완료 후 다시 호출한다', async () => {
+ const gate = new GeocodeGate();
+ const held = deferred();
+ const work = vi.fn(() => held.promise);
+ const one = gate.run(scope, 'kakao-address', 'query', 100, work);
+ const two = gate.run(scope, 'kakao-address', 'query', 100, work);
+ await Promise.resolve();
+ expect(work).toHaveBeenCalledTimes(1);
+ held.resolve({ status: 200, body: { nested: { value: 1 } } });
+ const [a, b] = await Promise.all([one, two]);
+ expect(a).toEqual(b);
+ expect(a.body).not.toBe(b.body);
+ await gate.run(scope, 'kakao-address', 'query', 100, work);
+ expect(work).toHaveBeenCalledTimes(2);
+ });
+
+ it.each(['kakao-address', 'kakao-keyword', 'naver-local'] as const)('429는 %s 공급자를 60초 차단한다', async (provider) => {
+ let now = 1000;
+ const store = memoryStore();
+ const gate = new GeocodeGate(store, () => now);
+ const work = vi.fn(async () => ({ status: 429, body: {} }));
+ await gate.run(scope, provider, 'one', 10, work);
+ expect(store.values.get(`quota:${scope}:${provider}`)).toBe(61000);
+ expect(await gate.run(scope, provider, 'two', 10, work)).toEqual({ status: 429, body: { code: 'GEOCODE_QUOTA_BLOCKED' } });
+ expect(work).toHaveBeenCalledTimes(1);
+ now = 61000;
+ await gate.run(scope, provider, 'three', 10, work);
+ expect(work).toHaveBeenCalledTimes(2);
+ });
+
+ it('카카오 -10은 30분 저장하고 다른 scope와 endpoint를 차단하지 않는다', async () => {
+ const store = memoryStore();
+ const gate = new GeocodeGate(store, () => 1000);
+ await gate.run(scope, 'kakao-address', 'q', 10, async () => ({ status: 400, body: { code: -10 } }));
+ expect([...store.values]).toEqual([[`quota:${scope}:kakao-address`, 1801000]]);
+ const work = vi.fn(async () => ok);
+ await gate.run(scope, 'kakao-keyword', 'q', 10, work);
+ await gate.run('b'.repeat(64), 'kakao-address', 'q', 10, work);
+ expect(work).toHaveBeenCalledTimes(2);
+ const restarted = new GeocodeGate(store, () => 1001);
+ await restarted.run(scope, 'kakao-address', 'q', 10, work);
+ expect(work).toHaveBeenCalledTimes(2);
+ });
+
+ it.each([
+ { status: 400, body: { code: -1 } }, { status: 400, body: null },
+ { status: 400, body: { code: '-10' } }, { status: 401, body: { code: -10 } },
+ { status: 403, body: {} }, { status: 500, body: {} }, ok,
+ ])('일반 오류와 빈 결과는 저장하지 않는다: %j', async (reply) => {
+ const store = memoryStore();
+ const gate = new GeocodeGate(store);
+ await gate.run(scope, 'kakao-address', 'q', 10, async () => reply);
+ expect(store.values.size).toBe(0);
+ });
+
+ it('네이버의 400 -10은 카카오 quota로 취급하지 않는다', async () => {
+ const store = memoryStore();
+ await new GeocodeGate(store).run(scope, 'naver-local', 'q', 10, async () => ({ status: 400, body: { code: -10 } }));
+ expect(store.values.size).toBe(0);
+ });
+
+ it('다른 시간 예산과 파라미터는 병합하지 않는다', async () => {
+ const gate = new GeocodeGate();
+ const held = deferred();
+ const work = vi.fn(() => held.promise);
+ const pending = [gate.run(scope, 'kakao-address', 'q', 10, work), gate.run(scope, 'kakao-address', 'q', 20, work), gate.run(scope, 'kakao-address', 'other', 10, work)];
+ await Promise.resolve();
+ expect(work).toHaveBeenCalledTimes(3);
+ held.resolve(ok);
+ await Promise.all(pending);
+ });
+
+ it('전송 실패도 공유하고 완료된 실패는 재사용하지 않는다', async () => {
+ const gate = new GeocodeGate();
+ const held = deferred();
+ const work = vi.fn(() => held.promise);
+ const one = gate.run(scope, 'kakao-address', 'q', 10, work);
+ const two = gate.run(scope, 'kakao-address', 'q', 10, work);
+ const checks = Promise.all([expect(one).rejects.toThrow('transport'), expect(two).rejects.toThrow('transport')]);
+ held.reject(new Error('transport'));
+ await checks;
+ await expect(gate.run(scope, 'kakao-address', 'q', 10, async () => ok)).resolves.toEqual(ok);
+ });
+
+ it.each(['get', 'put'] as const)('저장소 %s 오류는 고정 오류로 닫힌다', async (operation) => {
+ const store = memoryStore();
+ store[operation] = async () => { throw new Error('secret address'); };
+ const gate = new GeocodeGate(store);
+ await expect(gate.run(scope, 'kakao-address', 'q', 10, async () => ({ status: 429, body: {} }))).rejects.toThrow('GEOCODE_QUOTA_STORE_UNAVAILABLE');
+ });
+
+ it.each([NaN, Infinity, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])('손상된 저장 timestamp를 거부한다: %s', async (value) => {
+ const work = vi.fn(async () => ok);
+ const store = { get: async () => value, put: async () => {} };
+ await expect(new GeocodeGate(store).run(scope, 'kakao-address', 'q', 10, work)).rejects.toThrow('GEOCODE_QUOTA_STORE_UNAVAILABLE');
+ expect(work).not.toHaveBeenCalled();
+ });
+
+ it('고유 pending을 32개로 제한하고 완료 후 자리를 회수한다', async () => {
+ const gate = new GeocodeGate();
+ const held = deferred();
+ const calls = Array.from({ length: 32 }, (_, i) => gate.run(scope, 'kakao-address', String(i), 10, () => held.promise));
+ await expect(gate.run(scope, 'kakao-address', 'overflow', 10, async () => ok)).rejects.toThrow('GEOCODE_GATE_CAPACITY');
+ held.resolve(ok);
+ await Promise.all(calls);
+ await expect(gate.run(scope, 'kakao-address', 'next', 10, async () => ok)).resolves.toEqual(ok);
+ });
+
+ it('차단 scope를 64개로 제한하고 만료 후 회수한다', async () => {
+ let now = 1;
+ const gate = new GeocodeGate(undefined, () => now);
+ for (let i = 0; i < 64; i++) await gate.run(i.toString(16).padStart(64, '0'), 'kakao-address', 'q', 10, async () => ({ status: 429, body: {} }));
+ await expect(gate.run('f'.repeat(64), 'kakao-address', 'q', 10, async () => ok)).rejects.toThrow('GEOCODE_GATE_CAPACITY');
+ now = 60001;
+ await expect(gate.run('f'.repeat(64), 'kakao-address', 'q', 10, async () => ok)).resolves.toEqual(ok);
+ });
+
+ it('동시 quota 저장은 더 긴 중단 시간을 짧은 값으로 덮어쓰지 않는다', async () => {
+ const store = memoryStore();
+ const gate = new GeocodeGate(store, () => 1);
+ const long = deferred();
+ const short = deferred();
+ const a = gate.run(scope, 'kakao-address', 'long', 10, () => long.promise);
+ const b = gate.run(scope, 'kakao-address', 'short', 10, () => short.promise);
+ await Promise.resolve();
+ long.resolve({ status: 400, body: { code: -10 } });
+ await a;
+ short.resolve({ status: 429, body: {} });
+ await b;
+ expect(store.values.get(`quota:${scope}:kakao-address`)).toBe(1800001);
+ });
+ it('느린 초기 읽기가 새 차단 상태를 해제하지 않는다', async () => {
+ const stale = deferred();
+ let reads = 0;
+ const store = memoryStore();
+ store.get = async () => ++reads === 2 ? stale.promise : undefined;
+ const gate = new GeocodeGate(store, () => 1);
+ const held = deferred();
+ const first = gate.run(scope, 'kakao-address', 'first', 10, () => held.promise);
+ const work = vi.fn(async () => ok);
+ const second = gate.run(scope, 'kakao-address', 'second', 10, work);
+ await Promise.resolve();
+ held.resolve({ status: 400, body: { code: -10 } });
+ await first;
+ stale.resolve(undefined);
+ expect(await second).toEqual({ status: 429, body: { code: 'GEOCODE_QUOTA_BLOCKED' } });
+ expect(work).not.toHaveBeenCalled();
+ expect(store.values.get(`quota:${scope}:kakao-address`)).toBe(1800001);
+ });
+
+ it('진행 중 quota 저장을 직렬화하고 저장 실패 뒤 다시 시도한다', async () => {
+ const saving = deferred();
+ const long = deferred();
+ const short = deferred();
+ const store = memoryStore();
+ const writes: number[] = [];
+ store.put = async (_key, value) => {
+ writes.push(value);
+ if (writes.length === 1) await saving.promise;
+ };
+ const gate = new GeocodeGate(store, () => 1);
+ const a = gate.run(scope, 'kakao-address', 'long', 10, () => long.promise);
+ const b = gate.run(scope, 'kakao-address', 'short', 10, () => short.promise);
+ await Promise.resolve();
+ long.resolve({ status: 400, body: { code: -10 } });
+ short.resolve({ status: 429, body: {} });
+ const checks = Promise.all([expect(a).rejects.toThrow('GEOCODE_QUOTA_STORE_UNAVAILABLE'), expect(b).resolves.toEqual({ status: 429, body: {} })]);
+ await vi.waitFor(() => expect(writes).toHaveLength(1));
+ saving.reject(new Error('private'));
+ await checks;
+ expect(writes).toEqual([1800001, 1800001]);
+ });
+
+ it('공유 초기화 실패는 모든 caller에 전파하며 다음 요청에서 읽기를 재시도한다', async () => {
+ const loading = deferred();
+ const store = memoryStore();
+ const get = vi.fn(() => loading.promise);
+ store.get = get;
+ const gate = new GeocodeGate(store);
+ const work = vi.fn(async () => ok);
+ const a = gate.run(scope, 'kakao-address', 'q', 10, work);
+ const b = gate.run(scope, 'kakao-address', 'q', 10, work);
+ const checks = Promise.all([expect(a).rejects.toThrow('GEOCODE_QUOTA_STORE_UNAVAILABLE'), expect(b).rejects.toThrow('GEOCODE_QUOTA_STORE_UNAVAILABLE')]);
+ loading.reject(new Error('private'));
+ await checks;
+ expect(get).toHaveBeenCalledTimes(1);
+ expect(work).not.toHaveBeenCalled();
+ store.get = async () => undefined;
+ await expect(gate.run(scope, 'kakao-address', 'q', 10, work)).resolves.toEqual(ok);
+ });
+
+ it('기본 시계와 저장된 0 timestamp를 처리한다', async () => {
+ const store = memoryStore();
+ store.values.set(`quota:${scope}:kakao-address`, 0);
+ await expect(new GeocodeGate(store).run(scope, 'kakao-address', 'q', 10, async () => ok)).resolves.toEqual(ok);
+ });
+
+});
diff --git a/tests/utils/geocodeProvider.test.ts b/tests/utils/geocodeProvider.test.ts
new file mode 100644
index 00000000..67c6c9c1
--- /dev/null
+++ b/tests/utils/geocodeProvider.test.ts
@@ -0,0 +1,175 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ configuredGeocodeCredentials, geocodeCredentials, geocodeHeaders, geocodeProvider,
+ geocodeScope, geocodeUrl, invalidGeocode, requestGeocodeProvider,
+ validGeocodeCredentials, validGeocodeRequest, waitGeocode, type GeocodeRequest,
+} from '../../src/utils/geocodeProvider.js';
+const scope = 'a'.repeat(64);
+const row = (provider: GeocodeRequest['provider'] = 'kakao-address'): GeocodeRequest => ({
+ provider, scope, timeoutMs: 100,
+ params: provider === 'kakao-address' ? { query: '서울 중구 세종대로 110' }
+ : provider === 'kakao-keyword' ? { query: 'CGV', size: '15' }
+ : { query: '서울역', display: '5', start: '1', sort: 'random' },
+});
+afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); });
+
+describe('지오코딩 공급자 입력', () => {
+ it.each([
+ ['kakao-address', 'https://dapi.kakao.com/v2/local/search/address.json'],
+ ['kakao-keyword', 'https://dapi.kakao.com/v2/local/search/keyword.json'],
+ ['naver-local', 'https://openapi.naver.com/v1/search/local.json'],
+ ] as const)('공급자 %s의 origin과 경로를 고정한다', (provider, endpoint) => {
+ expect(geocodeProvider(new URL(`${endpoint}?query=x`))).toBe(provider);
+ const url = new URL(geocodeUrl(row(provider)));
+ expect(url.origin + url.pathname).toBe(endpoint);
+ expect(url.searchParams.get('query')).toBe(row(provider).params.query);
+ expect(validGeocodeRequest(row(provider))).toBe(true);
+ });
+ it.each(['http://dapi.kakao.com/v2/local/search/address.json', 'https://evil.test/v2/local/search/address.json', 'https://dapi.kakao.com/v2/local/search/address.json/'])('다른 URL을 지오코딩 공급자로 취급하지 않는다', (url) => {
+ expect(geocodeProvider(new URL(url))).toBeUndefined();
+ });
+ it('자격증명 해시는 순서와 원본 경계를 보존하며 비밀을 노출하지 않는다', async () => {
+ const one = await geocodeScope(['ab', 'c']);
+ expect(one).toMatch(/^[a-f0-9]{64}$/u);
+ expect(await geocodeScope(['ab', 'c'])).toBe(one);
+ expect(await geocodeScope(['a', 'bc'])).not.toBe(one);
+ expect(await geocodeScope(['c', 'ab'])).not.toBe(one);
+ });
+ it('공급자별 인증 헤더를 읽고 구성하며 환경값 공백을 제거한다', () => {
+ const naver = new Headers({ 'X-Naver-Client-Id': 'id', 'X-Naver-Client-Secret': 'secret' });
+ expect(geocodeCredentials('naver-local', naver)).toEqual(['id', 'secret']);
+ expect(geocodeCredentials('naver-local', new Headers())).toEqual(['', '']);
+ expect(geocodeHeaders('naver-local', ['id', 'secret'])).toEqual({ 'X-Naver-Client-Id': 'id', 'X-Naver-Client-Secret': 'secret' });
+ expect(geocodeCredentials('kakao-address', new Headers({ Authorization: 'KakaoAK key' }))).toEqual(['key']);
+ expect(geocodeCredentials('kakao-keyword', new Headers())).toEqual(['']);
+ expect(geocodeHeaders('kakao-keyword', ['key'])).toEqual({ Authorization: 'KakaoAK key' });
+ expect(configuredGeocodeCredentials('naver-local', { NAVER_CLIENT_ID: ' id ', NAVER_CLIENT_SECRET: ' secret ' })).toEqual(['id', 'secret']);
+ expect(configuredGeocodeCredentials('naver-local', {})).toEqual(['', '']);
+ expect(configuredGeocodeCredentials('kakao-address', { KAKAO_REST_API_KEY: ' key ' })).toEqual(['key']);
+ expect(configuredGeocodeCredentials('kakao-address', {})).toEqual(['']);
+ expect(validGeocodeCredentials(['id', 'secret'])).toBe(true);
+ expect(validGeocodeCredentials(['x'.repeat(512)])).toBe(true);
+ expect(validGeocodeCredentials([''])).toBe(false);
+ expect(validGeocodeCredentials(['x'.repeat(513)])).toBe(false);
+ expect(validGeocodeCredentials(['a\nb'])).toBe(false);
+ expect(invalidGeocode().message).toBe('GEOCODE_INVALID_REQUEST');
+ });
+
+ const invalidRows: [string, unknown][] = [
+ ['null', null], ['primitive', 1], ['array', []],
+ ['extra field', { ...row(), url: 'https://evil.test' }],
+ ['missing provider', { ...row(), provider: undefined }],
+ ['unknown provider', { ...row(), provider: 'constructor' }],
+ ['scope type', { ...row(), scope: 1 }], ['scope length', { ...row(), scope: 'a' }],
+ ['scope nonhex', { ...row(), scope: 'z'.repeat(64) }],
+ ['timeout fraction', { ...row(), timeoutMs: 1.1 }],
+ ['timeout zero', { ...row(), timeoutMs: 0 }], ['timeout huge', { ...row(), timeoutMs: 20001 }],
+ ['missing params', { ...row(), params: null }], ['params primitive', { ...row(), params: 'query' }],
+ ['params array', { ...row(), params: [] }],
+ ['extra param', { ...row(), params: { query: 'x', url: 'https://evil.test' } }],
+ ['query number', { ...row(), params: { query: 1 } }],
+ ['query missing', { ...row(), params: {} }], ['query blank', { ...row(), params: { query: ' ' } }],
+ ['query long', { ...row(), params: { query: 'x'.repeat(301) } }],
+ ['query control', { ...row(), params: { query: '서울\n역' } }],
+ ['keyword size', { ...row('kakao-keyword'), params: { query: 'x', size: '16' } }],
+ ...[
+ { x: '127', y: '37' }, { x: '127', radius: '20000', sort: 'distance' },
+ { y: '37', radius: '20000', sort: 'distance' },
+ { x: 'NaN', y: '37', radius: '20000', sort: 'distance' },
+ { x: '127', y: 'NaN', radius: '20000', sort: 'distance' },
+ { x: '123', y: '37', radius: '20000', sort: 'distance' },
+ { x: '133', y: '37', radius: '20000', sort: 'distance' },
+ { x: '127', y: '32', radius: '20000', sort: 'distance' },
+ { x: '127', y: '40', radius: '20000', sort: 'distance' },
+ { x: '127', y: '37', radius: '1', sort: 'distance' },
+ { x: '127', y: '37', radius: '20000', sort: 'accuracy' },
+ { radius: '20000' }, { sort: 'distance' },
+ ].map((params, index): [string, unknown] => [`keyword coordinate case ${index}`, { ...row('kakao-keyword'), params: { query: 'x', size: '15', ...params } }]),
+ ...[
+ { display: '6', start: '1', sort: 'random' },
+ { start: '1', sort: 'random' }, { display: '5', start: '2', sort: 'random' },
+ { display: '5', start: '1', sort: 'distance' },
+ ].map((params, index): [string, unknown] => [`naver parameter case ${index}`, { ...row('naver-local'), params: { query: 'x', ...params } }]),
+ ];
+ it.each(invalidRows)('거부한 입력은 외부 요청을 만들지 않는다: %s', (_name, value) => {
+ expect(validGeocodeRequest(value)).toBe(false);
+ });
+ it('한국 좌표 경계와 최대 시간·검색어 길이를 허용한다', () => {
+ expect(validGeocodeRequest({ ...row(), timeoutMs: 20000, params: { query: 'x'.repeat(300) } })).toBe(true);
+ for (const [x, y] of [['124', '33'], ['132', '39'], ['127.1', '37.1']])
+ expect(validGeocodeRequest({ ...row('kakao-keyword'), params: { query: 'CGV', size: '15', x, y, radius: '20000', sort: 'distance' } })).toBe(true);
+ expect(validGeocodeRequest({ ...row('naver-local'), params: { query: 'x', display: '1', start: '1', sort: 'comment' } })).toBe(true);
+ });
+});
+
+describe('공급자 응답과 요청 기한', () => {
+ it.each(['kakao-address', 'kakao-keyword', 'naver-local'] as const)('%s는 GET과 수동 redirect만 사용한다', async (provider) => {
+ const fetcher = vi.fn(async () => Response.json({ documents: [] }));
+ await expect(requestGeocodeProvider(row(provider), ['key', 'secret'], fetcher)).resolves.toEqual({ status: 200, body: { documents: [] } });
+ expect(fetcher.mock.calls[0]).toEqual([geocodeUrl(row(provider)), expect.objectContaining({ method: 'GET', redirect: 'manual', signal: expect.any(AbortSignal), headers: geocodeHeaders(provider, ['key', 'secret']) })]);
+ });
+ it.each([null, 1, 'text', [], true, { code: -10 }])('JSON 원본 타입을 그대로 반환한다: %j', async (body) => {
+ const result = await requestGeocodeProvider(row(), ['key'], async () => Response.json(body, { status: 400 }));
+ expect(result).toEqual({ status: 400, body });
+ });
+ it.each([new Response('not json'), new Response(null)])('JSON이 아닌 응답은 고정 502 진단으로 바꾼다', async (response) => {
+ expect(await requestGeocodeProvider(row(), ['key'], async () => response)).toEqual({ status: 502, body: { code: 'GEOCODE_INVALID_RESPONSE' } });
+ });
+ it('512 KiB 경계는 읽고 초과하면 reader를 취소한다', async () => {
+ const exact = '"' + 'x'.repeat(524286) + '"';
+ expect((await requestGeocodeProvider(row(), ['key'], async () => new Response(exact))).body).toHaveLength(524286);
+ const cancel = vi.fn();
+ const stream = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(524289)); }, cancel });
+ await expect(requestGeocodeProvider(row(), ['key'], async () => new Response(stream))).rejects.toThrow('GEOCODE_RESPONSE_TOO_LARGE');
+ expect(cancel).toHaveBeenCalledTimes(1);
+ });
+ it('body 읽기가 멈춰도 기한 후 취소한다', async () => {
+ vi.useFakeTimers();
+ const cancel = vi.fn();
+ const stream = new ReadableStream({ cancel });
+ const task = requestGeocodeProvider(row(), ['key'], async () => new Response(stream));
+ const check = expect(task).rejects.toThrow('GEOCODE_TIMEOUT');
+ await vi.advanceTimersByTimeAsync(100);
+ await check;
+ expect(cancel).toHaveBeenCalledTimes(1);
+ });
+ it('fetch가 abort를 무시하고 늦게 반환해도 body를 취소한다', async () => {
+ vi.useFakeTimers();
+ let resolve!: (response: Response) => void;
+ const cancel = vi.fn();
+ const response = new Response(new ReadableStream({ cancel }));
+ const fetcher = vi.fn(() => new Promise((done) => { resolve = done; }));
+ const task = requestGeocodeProvider(row(), ['key'], fetcher);
+ const check = expect(task).rejects.toThrow('GEOCODE_TIMEOUT');
+ await vi.advanceTimersByTimeAsync(100);
+ await check;
+ expect((fetcher.mock.calls[0] as unknown as [string, RequestInit])[1].signal?.aborted).toBe(true);
+ resolve(response);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(cancel).toHaveBeenCalledTimes(1);
+ });
+ it('호출자 취소와 선행 취소를 처리하고 성공하면 listener를 정리한다', async () => {
+ const controller = new AbortController();
+ const remove = vi.spyOn(controller.signal, 'removeEventListener');
+ expect(await waitGeocode(Promise.resolve('ok'), 100, controller.signal)).toBe('ok');
+ expect(remove).toHaveBeenCalled();
+ const abort = vi.fn();
+ const task = waitGeocode(new Promise(() => {}), 100, controller.signal, abort);
+ controller.abort();
+ await expect(task).rejects.toThrow('GEOCODE_CANCELLED');
+ expect(abort).toHaveBeenCalledTimes(1);
+ await expect(waitGeocode(new Promise(() => {}), 100, controller.signal)).rejects.toThrow('GEOCODE_CANCELLED');
+ });
+ it('전송 오류 원본은 상위 gateway가 처리하도록 전파한다', async () => {
+ await expect(requestGeocodeProvider(row(), ['key'], async () => { throw new Error('transport'); })).rejects.toThrow('transport');
+ });
+ it('기한 만료 중 stream 취소가 실패해도 고정 timeout을 유지한다', async () => {
+ vi.useFakeTimers();
+ const stream = new ReadableStream({ cancel() { throw new Error('private cancel error'); } });
+ const task = requestGeocodeProvider(row(), ['key'], async () => new Response(stream));
+ const check = expect(task).rejects.toThrow('GEOCODE_TIMEOUT');
+ await vi.advanceTimersByTimeAsync(100);
+ await check;
+ });
+
+});
diff --git a/wrangler.toml b/wrangler.toml
index 6f3e1380..e4753f8f 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -41,3 +41,11 @@ new_sqlite_classes = ["UpstreamRouteHealth"]
[triggers]
crons = ["*/5 * * * *"]
+
+[[durable_objects.bindings]]
+name = "GEOCODE_GATEWAY"
+class_name = "GeocodeGateway"
+
+[[migrations]]
+tag = "v3"
+new_sqlite_classes = ["GeocodeGateway"]