8000 fix: 무료 위치 검색 쿼터 보호와 정확한 대체 조회 by hmmhmmhm · Pull Request #267 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
<a id="운영-헬스-체크"></a><a id="운영-통계"></a>

Expand Down
101 changes: 101 additions & 0 deletions docs/geocoding-resilience.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions docs/superpowers/plans/2026-10-13-geocode-quota.md
Original file line number Diff line number Diff line change
@@ -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 기록.
20 changes: 20 additions & 0 deletions docs/superpowers/specs/2026-10-13-geocode-quota-design.md
Original file line number Diff line number Diff line change
@@ -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 검증을 한다. 유료 서비스 활성화나 무료 키 재발급을 통한 한도 우회는 하지 않는다.
22 changes: 14 additions & 8 deletions src/api/gs25Handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 핸들러
*/
Expand Down Expand Up @@ -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),
});
Expand All @@ -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<ReturnType<typeof fetchGs25SearchProducts>>[number] | undefined;

Expand Down
1 change: 1 addition & 0 deletions src/api/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
50 changes: 50 additions & 0 deletions src/durableObjects/geocodeGateway.ts
Original file line number Diff line number Diff line change
@@ -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<number>(key),
put: (key, value) => state.storage.put(key, value),
});
}
async fetch(request: Request): Promise<Response> {
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 });
}
}
}
6 changes: 3 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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));
Expand Down
27 changes: 17 additions & 10 deletions src/services/gs25/tools/checkInventory.ts
Original file line number Diff line number Diff line change
@@ -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 재고 확인 도구
*/
Expand Down Expand Up @@ -80,16 +81,18 @@ async function checkInventory(args: CheckInventoryArgs): Promise<McpToolResponse
apiKey,
},
);
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)) {
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;
Expand All @@ -99,7 +102,11 @@ async function checkInventory(args: CheckInventoryArgs): Promise<McpToolResponse
}
}

// 좌표가 없으면 기본 좌표 (서울 강남) 사용
if (storeKeyword.trim() && !validKoreanCoordinates(resolvedLatitude ?? NaN, resolvedLongitude ?? NaN))
throw new ServiceError('GS25_LOCATION_UNRESOLVED',
'요청한 지역의 위치를 확인하지 못했습니다. 좌표를 지정하거나 잠시 후 다시 조회해주세요.', 503, true);

// 지역 요청이 없고 좌표가 없으면 기본 좌표 (서울 강남) 사용
// itemCode 기반 재고 조회는 좌표가 필수
if (typeof resolvedLatitude !== 'number' || typeof resolvedLongitude !== 'number') {
resolvedLatitude = 37.4979;
Expand Down
13 changes: 13 additions & 0 deletions src/utils/externalRouting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** 내부 상태 바인딩을 같은 요청 수명에 연결합니다. */
import type { AppBindings } from '../api/response.js';
import { withRouteRouting } from './routeHealth.js';
import { withGeocodeProtection } from './geocodeFetch.js';
export function withExternalRouting<T>(
env: AppBindings,
executionCtx: ExecutionContext,
work: () => T,
): T {
return withGeocodeProtection(env, () =>
withRouteRouting(env?.UPSTREAM_ROUTE_HEALTH, executionCtx?.waitUntil?.bind(executionCtx), work),
);
}
Loading
Loading