Skip to content
541 changes: 541 additions & 0 deletions docs/superpowers/plans/2026-07-27-convenience-store-recovery.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# 편의점 Zyte 폴백 및 헬스체크 복구 설계

## 배경

2026년 7월 27일 운영 점검에서 편의점 연동 상태를 다음과 같이 확인했다.

- 이마트24의 매장·상품·재고 조회는 정상이다.
- GS25는 활성 Zyte 키 반영 후 매장·상품·재고 조회가 복구됐다.
- CU 매장 검색은 원본 웹 요청이 차단될 때 기존 Zyte 폴백으로 복구되지만, 재고 검색은 원본 JSON API의 `400 Request Blocked`를 그대로 반환한다. 활성 키로 재검증한 결과 Zyte HTTP 요청도 `520 Website Ban`, Zyte 브라우저 내부 요청도 `403`이어서 현재 공개 경로만으로 재고 원문을 복구할 수 없다.
- 세븐일레븐의 매장·상품·재고·인기 검색어 요청은 Imperva `403 Forbidden`을 받으며 Zyte 폴백이 없다.

운영 비용을 억제하면서 차단된 요청만 복구하고, 편의점의 모든 주요 기능을 정기 헬스체크에서 확인하는 것이 목표다.

## 범위와 성공 조건

### 범위

- CU JSON API 요청의 선택적 Zyte 폴백
- 세븐일레븐 JSON API 요청의 선택적 Zyte 폴백
- Worker 바인딩의 `ZYTE_API_KEY`를 해당 클라이언트까지 전달
- 편의점 주요 API를 포괄하는 운영 헬스체크와 CLI 스모크 시나리오 보강
- 폴백과 오류 전파에 대한 회귀 테스트

### 성공 조건

- 원본 요청이 성공하면 Zyte를 호출하지 않는다.
- 원본 요청이 `400`, `403`, `429`로 차단될 때만 Zyte를 한 번 시도한다.
- Zyte 응답의 대상 상태 코드가 성공이고 JSON 본문이 유효하면 기존 정규화 로직을 그대로 사용한다.
- Zyte 인증, 계정 정지, 대상 오류 또는 잘못된 JSON은 진단 가능한 오류로 반환한다.
- CU 재고는 직접 호출과 Zyte가 모두 명시적으로 차단될 때 HTTP 500 대신 `available: false`와 차단 원인을 담은 성공 envelope를 반환한다.
- CU 재고와 세븐일레븐 매장·상품·재고·인기 검색어가 운영 헬스체크에 포함된다.
- 기존 이마트24와 GS25 동작 및 직접 호출 우선 정책을 회귀시키지 않는다.
- 모든 소스 파일은 프로젝트 정책인 450줄 이하를 유지한다.

## 검토한 접근

### 1. 계정 복구만 수행

GS25와 기존 CU 매장 폴백은 복구되지만 CU 재고와 세븐일레븐은 원본 차단이 계속된다. 코드상 누락을 해결하지 못하므로 채택하지 않는다.

### 2. 직접 호출 우선, 차단 응답에만 Zyte 폴백

정상 원본 요청은 무료 경로를 유지하고, 명시적인 차단 응답만 Zyte로 재시도한다. 비용과 복구 가능성의 균형이 가장 좋아 채택한다.

### 3. Zyte 우선 호출

구현은 단순하지만 모든 요청에 비용이 발생하고, 과거 사용량 급증 문제를 재발시킬 가능성이 높아 채택하지 않는다.

## 설계

### 공통 전송 흐름

CU와 세븐일레븐 클라이언트는 기존 헤더, 메서드, 요청 본문을 사용해 원본 API를 먼저 호출한다.

1. 원본이 성공하면 기존 JSON 파싱 결과를 반환한다.
2. 원본이 `400`, `403`, `429`이면 같은 URL, 메서드, 헤더, 본문을 `requestByZyte`로 전달한다.
3. Zyte의 대상 응답 상태가 `2xx`이고 본문이 있으면 Base64 본문을 JSON으로 디코딩한다.
4. 그 외 원본 오류는 기존 오류를 유지한다.
5. 폴백 자체가 실패하면 Zyte 오류를 숨기지 않고 서비스·원인을 식별할 수 있는 메시지로 전달한다. 단, CU 재고의 확인된 차단 상태는 호출 전체를 실패시키지 않고 degraded 데이터로 변환한다.

공통화가 두 서비스의 의미를 흐리지 않는 범위에서 작은 전송 유틸리티를 사용한다. 서비스별 요청 형식과 응답 정규화는 각 클라이언트에 남긴다.

### CU

- `requestCuJson`이 `RequestOptions.apiKey`를 받고 차단 응답에 Zyte를 시도한다.
- `fetchCuStores`와 본 재고 요청은 같은 옵션을 끝까지 전달한다.
- API 핸들러는 `c.env.ZYTE_API_KEY`를 재고 조회에도 전달한다.
- 선택적 워밍업은 실패해도 무시되는 호출이므로 원본 직결만 사용해 불필요한 Zyte 비용을 막는다.
- 워밍업 요청 실패는 기존처럼 본 요청을 막지 않지만, 본 재고 요청의 폴백 실패는 호출자에게 전달한다.
- 본 재고 요청도 확인된 `400`/`403`/Zyte `520` 차단이면 빈 항목, `available: false`, 비민감 오류 설명을 반환한다. 매장 검색이 가능하면 매장 결과는 계속 제공한다.
- MCP 도구에는 요청 컨텍스트의 Zyte 및 Google Maps 바인딩을 생성자 옵션으로 주입한다.

### 세븐일레븐

- 클라이언트와 재고 모듈의 요청 옵션에 `zyteApiKey`를 추가한다.
- 상품, 매장, 인기 검색어, 상품 메타, 실재고 요청에서 동일한 직접 호출 우선 정책을 사용한다.
- API 핸들러는 Worker 바인딩의 키를 각 서비스 함수에 전달한다.
- MCP 도구에도 같은 Worker 바인딩을 서비스 생성자 옵션으로 주입한다.
- 카탈로그 스냅샷은 현재 여러 선택적 요청을 합치는 기존 특성을 유지하되, 내부 JSON 요청에는 같은 옵션을 적용한다.

### 운영 헬스체크

기존 상품 중심 체크에 누락된 편의점 기능을 추가한다.

- CU: 매장, 재고
- GS25: 매장, 상품, 재고
- 세븐일레븐: 매장, 상품, 재고, 인기 검색어
- 이마트24: 매장, 상품, 재고

카탈로그는 빈 배열도 정상 응답이 될 수 있어 연결성 신호로는 사용하지 않는다. 기존의 알려진 upstream 차단 패턴은 폴백 실패 시 degraded 진단을 위해 유지하되, 정상 폴백은 `pass`로 기록한다.

CLI 스모크는 최소 한 시나리오만 있던 서비스에 CU를 추가하고, API 헬스체크가 세부 엔드포인트를 담당하도록 역할을 나눈다. 테스트와 운영 호출은 사용자 요청에 따라 병렬화하지 않는다.

## 오류 및 비용 제어

- Zyte 호출은 차단 상태에만 발생한다.
- 연결 오류나 일반 `5xx`는 기존 재시도 정책을 따르며 곧바로 Zyte 비용을 발생시키지 않는다.
- 비밀키는 Worker/GitHub Secret으로만 관리하고 로그, 응답, 저장소에 기록하지 않는다.
- 기존 일일 IP별 검색 제한과 캐시 정책은 변경하지 않는다.
- 헬스체크는 작은 페이지 크기와 고정 검색어를 사용해 요청량을 제한한다.

## 테스트 전략

테스트 주도 방식으로 다음 실패 사례를 먼저 추가한다.

1. 직접 호출 성공 시 Zyte 미호출
2. `400`, `403`, `429`에서 Zyte 폴백 성공
3. Zyte 대상 오류, 빈 본문, 잘못된 JSON 및 계정 오류 전파
4. CU 재고까지 API 키 전달 및 확인된 차단 시 degraded 응답
5. 세븐일레븐 상품·매장·재고·인기 검색어까지 API 키 전달
6. 편의점 전체 헬스체크 정의와 degraded 판정
7. CLI 스모크의 CU 시나리오

관련 테스트 파일을 하나씩 실행한 뒤 전체 단위 테스트, 커버리지, 린트, 타입 검사, 빌드, 소스 줄 수 검사를 순차 실행한다. 배포 후에도 편의점별 엔드포인트를 한 번에 하나씩 검증한다.

## 배포 및 롤백

구현 브랜치에서 검증 후 PR을 만들고 CI가 모두 통과하면 `main`에 병합한다. 기존 CI/CD가 Worker를 배포한 뒤 운영 헬스체크를 실행한다.

문제가 발생하면 폴백을 추가한 커밋을 되돌릴 수 있다. 직접 호출 경로와 서비스별 정규화는 유지되므로 롤백 시 기존 동작으로 복귀한다.
9 changes: 9 additions & 0 deletions scripts/ops/cli-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { spawn } from 'node:child_process';
import path from 'node:path';
import {
CU_UPSTREAM_BLOCK_PATTERNS,
EMART24_UPSTREAM_403_PATTERNS,
SEVENELEVEN_UPSTREAM_403_PATTERNS,
} from '../../src/api/healthCheckDefinitions.js';
Expand All @@ -20,6 +21,7 @@ type WriteFn = (message: string) => void;
type Validator = (stdout: string, stderr: string) => string | null;
type SmokeService =
| 'daiso'
| 'cu'
| 'gs25'
| 'seveneleven'
| 'emart24'
Expand Down Expand Up @@ -120,6 +122,13 @@ export const CLI_SMOKE_COMMANDS: CliSmokeCommand[] = [
expectedExitCode: 1,
validate: validateStderrContains('알 수 없는 옵션: --store'),
},
{
service: 'cu',
scenario: 'CU 매장 검색',
args: ['cu-stores', '강남', '--limit', '1', '--json'],
degradedFailurePatterns: CU_UPSTREAM_BLOCK_PATTERNS,
validate: (stdout) => validateApiEnvelope(stdout, expectDataField('keyword', '강남')),
},
{
service: 'gs25',
scenario: 'GS25 상품명 검색',
Expand Down
3 changes: 3 additions & 0 deletions src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ export async function handleCuCheckInventory(c: ApiContext) {
},
{
timeout: 15000,
apiKey: c.env?.ZYTE_API_KEY,
},
);

Expand Down Expand Up @@ -381,6 +382,8 @@ export async function handleCuCheckInventory(c: ApiContext) {
stores: storeResult.stores.slice(0, storeLimit),
},
inventory: {
available: stockResult.available,
unavailableReason: stockResult.unavailableReason,
totalCount: stockResult.totalCount,
spellModifyYn: stockResult.spellModifyYn,
items: stockResult.items,
Expand Down
28 changes: 28 additions & 0 deletions src/api/healthCheckDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [
requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS,
},
{
id: 'emart24.stores',
service: 'emart24',
target: 'stores',
mode: 'quick',
path: '/api/emart24/stores?keyword=%EA%B0%95%EB%82%A8&limit=1',
collectionKey: 'stores',
requiredFields: ['storeCode', 'storeName', 'name'],
degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS,
},
{
id: 'gs25.products',
service: 'gs25',
Expand Down Expand Up @@ -92,6 +102,24 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [
requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS,
},
{
id: 'seveneleven.stores',
service: 'seveneleven',
target: 'stores',
mode: 'quick',
path: '/api/seveneleven/stores?keyword=%EA%B0%95%EB%82%A8&limit=1',
collectionKey: 'stores',
requiredFields: ['storeCode', 'storeName', 'name'],
degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS,
},
{
id: 'seveneleven.popwords',
service: 'seveneleven',
target: 'popwords',
mode: 'quick',
path: '/api/seveneleven/popwords?label=home',
degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS,
},
{
id: 'lottemart.products',
service: 'lottemart',
Expand Down
32 changes: 21 additions & 11 deletions src/api/sevenelevenHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,15 @@ export async function handleSevenElevenSearchProducts(c: ApiContext) {
}

try {
const result = await searchSevenElevenProducts({
query,
page,
size,
sort,
});
const result = await searchSevenElevenProducts(
{
query,
page,
size,
sort,
},
{ zyteApiKey: c.env?.ZYTE_API_KEY },
);

return successResponse(
c,
Expand Down Expand Up @@ -85,10 +88,13 @@ export async function handleSevenElevenSearchStores(c: ApiContext) {
}

try {
const result = await fetchSevenElevenStoresByKeyword({
keyword,
limit: safeLimit,
});
const result = await fetchSevenElevenStoresByKeyword(
{
keyword,
limit: safeLimit,
},
{ zyteApiKey: c.env?.ZYTE_API_KEY },
);

return successResponse(
c,
Expand Down Expand Up @@ -135,6 +141,7 @@ export async function handleSevenElevenCheckInventory(c: ApiContext) {
},
{
timeout: safeTimeoutMs,
zyteApiKey: c.env?.ZYTE_API_KEY,
},
);

Expand Down Expand Up @@ -179,7 +186,9 @@ export async function handleSevenElevenGetSearchPopwords(c: ApiContext) {
const label = c.req.query('label') || 'home';

try {
const keywords = await fetchSevenElevenSearchPopwords(label);
const keywords = await fetchSevenElevenSearchPopwords(label, {
zyteApiKey: c.env?.ZYTE_API_KEY,
});

return successResponse(c, {
label,
Expand Down Expand Up @@ -210,6 +219,7 @@ export async function handleSevenElevenGetCatalogSnapshot(c: ApiContext) {
const result = await fetchSevenElevenCatalogSnapshot({
includeIssues,
includeExhibition,
zyteApiKey: c.env?.ZYTE_API_KEY,
});

return successResponse(c, {
Expand Down
8 changes: 6 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const createRegistry = (bindings?: AppBindings) => {
googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
zyteApiKey: bindings?.ZYTE_API_KEY,
}),
createSevenElevenService,
() => createSevenElevenService({ zyteApiKey: bindings?.ZYTE_API_KEY }),
createCompareService,
() =>
createFeedbackService({
Expand All @@ -99,7 +99,11 @@ const createRegistry = (bindings?: AppBindings) => {
apiKey: bindings?.OPINET_API_KEY,
googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
}),
createCuService,
() =>
createCuService({
zyteApiKey: bindings?.ZYTE_API_KEY,
googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
}),
createEmart24Service,
() =>
createLotteMartService({
Expand Down
Loading