8000 fix: 전체 재고·좌석 조회의 오동작 개선 by hmmhmmhm · Pull Request #225 · 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
52 changes: 52 additions & 0 deletions docs/plans/2026-10-05-quality-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 전체 조회 품질 개선 구현 계획

> 작업 에이전트는 superpowers:subagent-driven-development와 TDD를 적용합니다. 사용자 승인 범위는 2026-10-05 전체 감사 결과의 모든 개선 항목입니다.

**목표:** 실제 0개와 확인 불가를 구분하고, 요청한 상품·지역·영화 범위를 유지하며, 모든 REST/MCP/CLI 경로가 동일한 계약을 제공합니다.

**설계:** 정상 응답 필드와 명령은 유지합니다. 누락된 수량에는 null 및 확인 상태를 사용하거나 구조가 깨진 upstream 응답에는 명시적인 오류를 반환합니다. 외부 실패를 빈 검색·품절로 변환하지 않습니다. 기존 서비스별 모듈을 수정하고 공통 코드에는 필요한 검증과 오류 전달만 둡니다.

**기술:** TypeScript, Zod, Hono, MCP SDK, Vitest, Playwright 중계.

## 재고 서비스

대상: `src/services/{daiso,cu,gs25,emart24,seveneleven}`, 관련 `tests/services`와 `tests/api`.

- [x] 감사 F1–F5, L3·L5의 fixture를 회귀 테스트로 옮겨 실패를 확인합니다. `success:false`는 0으로 변환되지 않아야 합니다. `콜라제로`, `콜라` 후보에서 정확한 `콜라`를 선택해야 합니다. 부산 요청에 서울 매장을 반환하면 안 됩니다. 수량 필드가 없는 매장은 품절로 확정하면 안 됩니다. 페이지 2의 이미 페이지 처리된 상품은 다시 자르면 안 됩니다. 요청 pageSize를 지켜야 합니다.
- [x] 각 최소 수정 후 해당 서비스 테스트를 실행합니다. API와 도구의 nullable 수량 타입·집계·렌더링을 확인합니다. 메타데이터와 재고 결과를 분리하며 실제 0은 그대로 유지합니다.
- [x] CU REST 후보 선택은 공통 선택 함수를 통해 MCP와 같게 만듭니다. 공통 REST `handlers.ts` 수정은 root에게 전달합니다.

## 영화관과 생활 정보

대상: `src/services/{cgv,megabox,lottecinema,dtryx,places,opinet}`, 관련 API와 테스트, 필요 시 `src/utils/format.ts`.

- [x] F6–F10, L6의 실패 테스트를 먼저 실행합니다. `frSeatCnt:0, frtmpSeatCnt:17`은 0이어야 합니다. 잔여 수량 누락은 0·예약100으로 확정하면 안 됩니다. 극장 해석 실패 뒤 무제한 조회를 하면 안 됩니다. 잘못된 영화는 다른 영화로 완화하지 않습니다. HTML·잘못된 envelope는 정상 빈 결과가 아닙니다.
- [x] YYYYMMDD의 실제 달력 날짜, 양수 한도와 유효 좌표를 REST/MCP 모두 검증합니다. 정상 기존 요청과 0좌석은 유지합니다.
- [x] 해당 서비스와 API 테스트 및 타입 검사를 실행합니다. 공통 숫자 변환의 기존 소비자를 모두 확인합니다.

## 올리브영과 중계 복구

대상: `scripts/relay/{ownership,lifecycle,guard-runtime,supervisor}`, `src/services/oliveyoung`, 관련 테스트.

- [x] F11–F14와 정리 시간·캐시 위험을 실패 테스트로 재현합니다. 이미 소유 프로세스가 없는 close rejection은 복구 가능해야 합니다. 소유권을 확인하지 못하면 기존 안전한 차단을 유지합니다.
- [x] cleanup 전체 시간이 guard/supervisor/lifecycle 예산 안에 들어오도록 단일 deadline을 지킵니다. 가짜 시계로 최악 경로를 검증합니다.
- [x] 빈 `SUCCESS data:{}`를 확인 완료 품절로 집계하지 않습니다. 모든 매장이 미판매면 `not_sold`입니다. 지역 집계는 `nearby_store`에서 확인한 결과만 셉니다.
- [x] 검색 캐시에 저장된 과거 지역 재고를 실패 시 현재 재고로 재사용하지 않습니다. 필요하면 관측 시간과 stale 상태를 명시하되 검색 기능의 정상 캐시는 유지합니다.
- [x] API 공통 `handlers.ts` 변경은 root에게 전달합니다. 실제 운영 브라우저·quota 원장을 변경하지 않습니다.

## 공통 계약과 통합

대상: `src/core/outputSchema.ts`, `src/services/compare`, registry, 비교 REST, `src/utils/convenienceTransport.ts`, 서비스 REST 오류 처리, `src/cli/http.ts`, `package.json`.

- [x] `createToolOutputSchema('megabox_find_nearby_theaters').safeParse({keyword:null,theaters:[]})`와 카탈로그 `{pages:{totalCount:0,items:[]}}`가 성공하는 테스트를 먼저 실패시킵니다. 실제 스키마를 좁게 맞춥니다.
- [x] 비교 GS25/Seven 요청이 정상 개별 조회와 같은 중계 설정을 받도록 옵션을 factory→도구/REST→client에 전달합니다. 비교 per-service 결과 한도를 지킵니다.
- [x] 가짜 relay HTTP429 및 `Retry-After:30`, `x-relay-quota-reason:minute`를 주입한 실제 REST handler가 429 및 재시도 정보를 반환하는 테스트를 만듭니다. ServiceError의 status와 quota 정보를 끝까지 보존합니다.
- [x] CLI `SERVICE_RETIRED`에 폐기된 롯데마트 명령을 안내하지 않는 테스트를 실행하고 수정합니다.
- [x] `workers-mcp`가 코드·문서·배포에서 사용되지 않는지 조사하고 미사용이면 제거합니다. production audit를 다시 실행합니다.

## 통합 검증과 전달

- [x] 영역별 수정은 독립 리뷰로 요구사항 충족과 코드 품질을 확인합니다. 450줄 규칙을 지킵니다.
- [x] `npm run check`, `npm run test:coverage`, `npm run build`, `npm audit --omit=dev`, Worker dry run을 실행합니다. 100% coverage를 유지합니다.
- [x] 감사의 각 ID를 수정·회귀 테스트·남은 제한과 연결하는 변경 기록을 저장합니다. OpenAPI를 재생성합니다.
- [ ] 변경을 커밋하고 PR을 만들며 첨부합니다. 기존 사용자 승인 범위와 저장소 규칙에 따라 통합을 진행하고, 운영 검증에서는 남은 quota를 확인해 기존 제한을 유지합니다.
42 changes: 42 additions & 0 deletions docs/quality-improvements-2026-10-05.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 조회 오동작 개선 기록

2026년 10월 5일 전체 감사에서 확인한 조회 실패와 잘못된 확정값을 수정했습니다. 정상 수량 0개는 그대로 유지하고, 관측되지 않은 수량은 확인 불가로 구분합니다. Google 지오코딩·Zyte 비활성화와 롯데마트 지원 종료 정책은 유지합니다.

## 재고와 좌석의 의미

- 다이소·CU·GS25·이마트24의 수량이 `null`이면 upstream 수량을 확인하지 못한 상태입니다. CLI는 “확인 불가”로 표시합니다. 세븐일레븐은 기존 미확인 값 `-1`을 유지합니다. 실제 숫자 0은 확인된 0개입니다.
- 다이소가 명시적으로 실패한 재고 응답을 보내면 성공 0개로 변환하지 않습니다. 매장 집계에는 `unknownStockCount`를 제공합니다.
- CU·이마트24는 정확한 상품명 후보를 우선 선택합니다. 일반 키워드의 첫 후보를 사용하는 경우 `selectionReason`으로 선택 근거를 제공합니다. 세븐일레븐의 지역 매칭 실패는 다른 지역 매장으로 대체하지 않습니다.
- GS25 매장 메타데이터와 상품 후보에서 관측되지 않은 재고는 `null`입니다. `unknownStockStoreCount`를 함께 제공하며, 알려진 수량이 하나도 없으면 합계도 `null`입니다.
- CGV의 숫자 0은 임시 수량으로 덮어쓰지 않습니다. 각 영화관의 좌석 수가 없거나 비정상이면 해당 수량과 유도된 예약 수를 `null`로 제공합니다.
- 올리브영은 지역에서 확인한 상품만 지역 재고 집계에 포함합니다. 빈 재고 payload는 미확인, 전부 미판매인 상품은 `not_sold`입니다. 지역 조회 실패 시 검색 캐시에 남은 과거 매장 수량을 재사용하지 않습니다.

수량을 무조건 숫자로 가정하던 API 소비자는 `null`을 확인 불가로 처리해야 합니다. `null`을 숫자 0으로 변환하면 이번에 수정한 품절 오판을 다시 만들게 됩니다. OpenAPI 및 MCP 출력 계약도 갱신했습니다.

## 조회 범위와 호출 계약

| 감사 항목 | 개선 결과 | 회귀 검증 |
|---|---|---|
| L1·L2 | 좌표 전용 메가박스 MCP의 null 검색어, 세븐일레븐 카탈로그의 `{totalCount,items}` 구조 허용 | `quality-contracts.test.ts` |
| L3·L5 | 세븐일레븐 MCP의 중복 페이지 처리 제거와 전체 개수 보존, 이마트24 개수 한도 적용 | `inventory-quality`, `productKeyword` |
| L4 | REST/MCP 가격 비교에도 편의점 중계 설정 전달, 모든 서비스 결과 개수 한도 적용 | `quality-contracts`의 중계 경로·서비스별 과다 응답 검사 |
| L6·F6–F8 | 영화 코드 필터 엄격 유지, 실제 0좌석 보존, 누락 좌석 nullable, 메가박스 위치 해석 실패 시 범위 확대 금지 | 영화관 `quality-regressions`, `seat-count` |
| L7 | 편의점의 typed 중계 오류를 보존. HTTP 429와 Retry-After, diagnostics.quotaReason/retryAfter 전달 | 실제 Hono GS25·CU·Seven handler 검사 및 `inventory-quota` |
| L8 | 롯데마트 지원 종료 오류에 폐기된 조회 명령 안내 제거 | `quality-cli` |
| F1–F5 | 미확인 수량, 정확한 후보 선택, 지역 매칭 및 합계 수정 | `inventory-quality`, `inventory-summary`, `inventoryQuantity` |
| F9·F10 | Places·Opinet 잘못된 JSON/envelope 거절. 영화관 날짜·양수 정수 한도·좌표 쌍 검증 | 각 client 및 `cinema-validation` |
| F11–F14 | 올리브영 종료 복구, 미확인/미판매 상태, 지역 집계, 과거 재고 재사용 방지 | OY `quality-regressions`, REST `quality-oliveyoung-counters`, relay 테스트 |

## 올리브영 정리 시간과 복구

정상 종료의 상위 lifecycle이 정한 10초 절대 deadline을 supervisor·guard·ownership으로 전달합니다. ownership은 guard의 profile 정리와 종료 확인을 위해 500ms를 남깁니다. IPC 연결 해제 같은 독립 정리는 로컬 8초 예산을 사용합니다. 서로 다른 층에서 시간을 새로 시작해 종료 기한을 넘기는 문제를 방지합니다.

이미 소유 프로세스가 없다는 snapshot을 확인했다면 Playwright close rejection 때문에 영구 실패를 유지하지 않습니다. 소유권이 불확실하거나 프로세스·profile 종료를 시간 안에 확인하지 못하면 marker와 실패 상태를 유지합니다. 임의 Chrome을 종료하거나 marker를 무조건 지우지 않습니다. 가짜 시계 테스트에서 느린 close·강제 회수 확인·profile 완료와 시간 초과를 함께 검증했습니다.

## 의존성과 검증

코드·스크립트에서 사용하지 않는 `workers-mcp`를 제거했습니다. 이 의존성이 가져오던 `braces` 취약점 경로도 제거되어 production 및 전체 의존성 audit는 0건입니다.

수정 후 검증은 228개 파일·2,631개 테스트, `npm run check`, 100% Statements/Branches/Functions/Lines coverage, build, Worker dry run을 통과했습니다. 독립 리뷰에서 비교 결과 한도·GS25 미확인 합계·메타데이터의 잘못된 0·REST/MCP 빈 결과 차이를 찾아 추가 수정하고 재검증했습니다.

운영 확인은 배포 후 같은 상품 코드·매장·지역의 결과를 REST/MCP/공개 CLI로 확인해야 합니다. 정상 검색 결과만으로 모든 실패 경로가 정상이라는 결론을 내리지 않습니다. 편의점 공유 매분 30회·일일 3,000회 원장과 인증 세션을 보존하고, 예산이 부족하면 재시도 시각을 기다립니다.
33 changes: 32 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,7 @@
},
"quantity": {
"type": "integer",
"nullable": true,
"description": "재고 수량",
"example": 12
},
Expand Down Expand Up @@ -952,6 +953,7 @@
},
"onlineStock": {
"type": "integer",
"nullable": true,
"description": "온라인 재고 수량",
"example": 150
},
Expand All @@ -966,6 +968,10 @@
"type": "integer",
"description": "재고 있는 매장 수"
},
"unknownStockCount": {
"type": "integer",
"description": "수량을 확인하지 못한 매장 수"
},
"stores": {
"type": "array",
"items": {
Expand Down Expand Up @@ -1075,7 +1081,9 @@
"type": "string",
"enum": [
"in_stock",
"out_of_stock"
"out_of_stock",
"not_sold",
"unknown"
],
"example": "in_stock"
},
Expand Down Expand Up @@ -1326,6 +1334,9 @@
"outOfStockCount": {
"type": "integer"
},
"notSoldCount": {
"type": "integer"
},
"products": {
"type": "array",
"items": {
Expand Down Expand Up @@ -1421,10 +1432,12 @@
},
"totalSeats": {
"type": "integer",
"nullable": true,
"example": 120
},
"remainingSeats": {
"type": "integer",
"nullable": true,
"example": 42
}
}
Expand Down Expand Up @@ -1643,6 +1656,7 @@
},
"stock": {
"type": "integer",
"nullable": true,
"example": 10
},
"deliveryYn": {
Expand Down Expand Up @@ -2058,6 +2072,7 @@
},
"bizQty": {
"type": "integer",
"nullable": true,
"example": 3
},
"storeName": {
Expand Down Expand Up @@ -2113,6 +2128,7 @@
},
"realStockQuantity": {
"type": "integer",
"nullable": true,
"example": 3
},
"distanceM": {
Expand Down Expand Up @@ -2152,8 +2168,13 @@
"type": "integer",
"example": 42
},
"unknownStockStoreCount": {
"type": "integer",
"example": 0
},
"totalStockQuantity": {
"type": "integer",
"nullable": true,
"example": 110
}
}
Expand Down Expand Up @@ -2264,8 +2285,13 @@
"type": "integer",
"example": 35
},
"unknownStockStoreCount": {
"type": "integer",
"example": 0
},
"totalStockQuantity": {
"type": "integer",
"nullable": true,
"example": 90
},
"stores": {
Expand Down Expand Up @@ -2591,14 +2617,17 @@
},
"totalSeats": {
"type": "integer",
"nullable": true,
"example": 32
},
"bookedSeats": {
"type": "integer",
"nullable": true,
"example": 4
},
"remainingSeats": {
"type": "integer",
"nullable": true,
"example": 28
}
}
Expand Down Expand Up @@ -2900,10 +2929,12 @@
},
"totalSeats": {
"type": "integer",
"nullable": true,
"example": 150
},
"remainingSeats": {
"type": "integer",
"nullable": true,
"example": 42
}
}
Expand Down
27 changes: 27 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,7 @@ components:
example: 0.5km
quantity:
type: integer
nullable: true
description: 재고 수량
example: 12
options:
Expand Down Expand Up @@ -675,6 +676,7 @@ components:
format: float
onlineStock:
type: integer
nullable: true
description: 온라인 재고 수량
example: 150
storeInventory:
Expand All @@ -686,6 +688,9 @@ components:
inStockCount:
type: integer
description: 재고 있는 매장 수
unknownStockCount:
type: integer
description: 수량을 확인하지 못한 매장 수
stores:
type: array
items:
Expand Down Expand Up @@ -765,6 +770,8 @@ components:
enum:
- in_stock
- out_of_stock
- not_sold
- unknown
example: in_stock
stockSource:
type: string
Expand Down Expand Up @@ -940,6 +947,8 @@ components:
type: integer
outOfStockCount:
type: integer
notSoldCount:
type: integer
products:
type: array
items:
Expand Down Expand Up @@ -1006,9 +1015,11 @@ components:
example: "11:20"
totalSeats:
type: integer
nullable: true
example: 120
remainingSeats:
type: integer
nullable: true
example: 42
MegaboxTheaterSearchResponse:
type: object
Expand Down Expand Up @@ -1156,6 +1167,7 @@ components:
example: 97
stock:
type: integer
nullable: true
example: 10
deliveryYn:
type: boolean
Expand Down Expand Up @@ -1446,6 +1458,7 @@ components:
example: 28339
bizQty:
type: integer
nullable: true
example: 3
storeName:
type: string
Expand Down Expand Up @@ -1483,6 +1496,7 @@ components:
example: 127.0276
realStockQuantity:
type: integer
nullable: true
example: 3
distanceM:
type: number
Expand Down Expand Up @@ -1511,8 +1525,12 @@ components:
inStockStoreCount:
type: integer
example: 42
unknownStockStoreCount:
type: integer
example: 0
totalStockQuantity:
type: integer
nullable: true
example: 110
Gs25StoreSearchResponse:
type: object
Expand Down Expand Up @@ -1589,8 +1607,12 @@ components:
inStockStoreCount:
type: integer
example: 35
unknownStockStoreCount:
type: integer
example: 0
totalStockQuantity:
type: integer
nullable: true
example: 90
stores:
type: array
Expand Down Expand Up @@ -1823,12 +1845,15 @@ components:
example: "12:47"
totalSeats:
9F84 type: integer
nullable: true
example: 32
bookedSeats:
type: integer
nullable: true
example: 4
remainingSeats:
type: integer
nullable: true
example: 28
LotteCinemaTheaterSearchResponse:
type: object
Expand Down Expand Up @@ -2035,9 +2060,11 @@ components:
example: "11:20"
totalSeats:
type: integer
nullable: true
example: 150
remainingSeats:
type: integer
nullable: true
example: 42
CgvTheaterSearchResponse:
type: object
Expand Down
Loading
Loading
0