From 55f10c5c7a9c99a59515eb150e6550634f34dca4 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 19:30:07 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=EC=88=A8=EA=B9=80=C2=B7?= =?UTF-8?q?=ED=8F=90=EC=97=85=20=EC=B2=98=EB=A6=AC=20=EC=95=88=EC=A0=84?= =?UTF-8?q?=EC=9E=A5=EC=B9=98=EB=A5=BC=20=ED=95=AD=EB=AA=A9=20=EC=88=98?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "이번 목록이 기존의 80% 미만이면 원본 장애로 보고 숨기지 않는다"는 규칙은 항목이 적은 곳(축제 몇 건, 동네 빙수집 5곳)에서 정상적인 한 건 취소도 막았다. 사라진 수를 직접 세어, 5건을 넘고 기존의 20% 도 넘을 때 또는 기존이 전부 사라졌을 때(빈 응답)만 원본 장애로 본다. 관광공사·상가정보 동기화가 함께 쓴다. 상가정보는 업종이 바뀐 업소(카페 → 빵집)를 사라진 것으로 세지 않도록 묶음별 개수 대신 이번에 본 업소 id 전체와 비교한다. Co-Authored-By: Claude Opus 5.5 --- .../modules/place/sync/removal-guard.spec.ts | 24 +++++++++++++++++++ .../src/modules/place/sync/removal-guard.ts | 20 ++++++++++++++++ backend/src/modules/place/sync/store.sync.ts | 20 +++++++--------- .../src/modules/place/sync/tour-kor.sync.ts | 13 +++++----- 4 files changed, 60 insertions(+), 17 deletions(-) create mode 100644 backend/src/modules/place/sync/removal-guard.spec.ts create mode 100644 backend/src/modules/place/sync/removal-guard.ts diff --git a/backend/src/modules/place/sync/removal-guard.spec.ts b/backend/src/modules/place/sync/removal-guard.spec.ts new file mode 100644 index 0000000..2ede97a --- /dev/null +++ b/backend/src/modules/place/sync/removal-guard.spec.ts @@ -0,0 +1,24 @@ +import { isSuspiciousRemoval } from './removal-guard'; + +describe('isSuspiciousRemoval', () => { + it('항목이 적은 곳의 정상적인 한두 건 취소는 막지 않는다', () => { + expect(isSuspiciousRemoval(3, 1)).toBe(false); + expect(isSuspiciousRemoval(5, 2)).toBe(false); + }); + + it('기존 항목이 전부 사라지면(빈 응답) 원본 장애로 본다', () => { + expect(isSuspiciousRemoval(3, 3)).toBe(true); + expect(isSuspiciousRemoval(5, 5)).toBe(true); + }); + + it('큰 목록은 5건을 넘고 20% 를 넘게 사라질 때만 의심한다', () => { + expect(isSuspiciousRemoval(1174, 10)).toBe(false); + expect(isSuspiciousRemoval(1174, 400)).toBe(true); + expect(isSuspiciousRemoval(20, 6)).toBe(true); + expect(isSuspiciousRemoval(40, 6)).toBe(false); + }); + + it('처음 적재(기존 0)는 의심하지 않는다', () => { + expect(isSuspiciousRemoval(0, 0)).toBe(false); + }); +}); diff --git a/backend/src/modules/place/sync/removal-guard.ts b/backend/src/modules/place/sync/removal-guard.ts new file mode 100644 index 0000000..31fb4d0 --- /dev/null +++ b/backend/src/modules/place/sync/removal-guard.ts @@ -0,0 +1,20 @@ +/** + * 원본 장애 판단 (순수 함수) — 동기화가 "목록에서 사라졌다"고 숨김·폐업 처리하기 전에 쓴다. + * 비율만 보면 항목이 적은 곳(축제 몇 건, 동네 빙수집 5곳)에서 정상적인 한 건 취소도 막히고, + * 개수만 보면 큰 목록이 통째로 덜 오는 장애를 못 잡는다. 그래서 둘 다 본다. + */ + +/** 이 비율을 넘게 사라지면 의심한다 */ +export const REMOVAL_MAX_RATIO = 0.2; +/** 이 건수 이하로 사라지는 것은 정상 변동으로 본다 */ +export const REMOVAL_MIN_ABSOLUTE = 5; + +/** + * @param before 이번 동기화 전에 노출 중이던 수 + * @param missing 그중 이번 목록에 없는 수 + * @returns true 면 원본 장애일 수 있으니 숨김·폐업 처리를 건너뛴다 + */ +export function isSuspiciousRemoval(before: number, missing: number): boolean { + if (before > 0 && missing >= before) return true; // 전부 사라짐(빈 응답 등) + return missing > REMOVAL_MIN_ABSOLUTE && missing > before * REMOVAL_MAX_RATIO; +} diff --git a/backend/src/modules/place/sync/store.sync.ts b/backend/src/modules/place/sync/store.sync.ts index 603e0bc..80eebf1 100644 --- a/backend/src/modules/place/sync/store.sync.ts +++ b/backend/src/modules/place/sync/store.sync.ts @@ -6,6 +6,7 @@ import { Place } from '../entities/place.entity'; import { BudgetExceededError, CallBudget } from './call-budget'; import { PlaceDedupe } from './place-dedupe'; import { PublicDataApiFactory } from './public-data.factory'; +import { isSuspiciousRemoval } from './removal-guard'; import type { StoreItem } from './store-api.client'; import { STORE_DATE_CODES, @@ -21,8 +22,6 @@ export const DEFAULT_STORE_SIGNGU = ['27110', '27200', '27260']; const PAGE_SIZE = 1_000; // 매뉴얼 최대값 const MAX_PAGES = 30; const UPSERT_CHUNK = 200; -/** 이번에 받은 업소가 기존 노출 업소보다 이만큼 적으면 원본 장애로 보고 gone 처리를 하지 않는다 */ -const MIN_COMPLETE_RATIO = 0.8; /** upsert 때 덮어쓰는 컬럼 — 큐레이션은 다른 테이블이라 건드리지 않는다 */ const OVERWRITE_PROPS = [ @@ -121,7 +120,6 @@ export class StoreSync { }); const known = new Set(existing.map((p) => p.sourceId)); const rows = new Map>(); - const rowsPerGroup = new Map(); outer: for (const signgu of options.signguCodes) { for (const code of STORE_DATE_CODES) { @@ -134,7 +132,6 @@ export class StoreSync { gone: 0, }; result.groups.push(group); - let kept = 0; for (let page = 1; page <= MAX_PAGES; page += 1) { let items: StoreItem[]; try { @@ -172,7 +169,6 @@ export class StoreSync { lastSeenAt: runStart, updatedAt: runStart, }); - kept += 1; } } if (items.length === 0 || group.fetched >= group.totalCount) { @@ -180,7 +176,6 @@ export class StoreSync { break; } } - rowsPerGroup.set(`${signgu}/${code}`, kept); } } @@ -189,7 +184,7 @@ export class StoreSync { else result.inserted += 1; } await this.upsert([...rows.values()]); - await this.markGone(existing, result, rowsPerGroup, runStart); + await this.markGone(existing, result, new Set(rows.keys()), runStart); result.merged = (await this.dedupe.run()).merged; } catch (error) { result.errors.unshift( @@ -232,7 +227,7 @@ export class StoreSync { private async markGone( existing: Place[], result: StoreSyncResult, - rowsPerGroup: Map, + seenIds: Set, runStart: Date, ): Promise { for (const group of result.groups.filter((g) => g.complete)) { @@ -244,11 +239,14 @@ export class StoreSync { p.ldongRegnCd === regn && p.ldongSignguCd === signgu && p.sourceCategory === group.code, + ); + // 업종이 바뀐 업소(카페 → 빵집)는 다른 묶음에서 보였으므로 사라진 것으로 세지 않는다 + const missing = activeBefore.filter( + (p) => !seenIds.has(p.sourceId), ).length; - const seen = rowsPerGroup.get(`${group.signgu}/${group.code}`) ?? 0; - if (activeBefore > 0 && seen < activeBefore * MIN_COMPLETE_RATIO) { + if (isSuspiciousRemoval(activeBefore.length, missing)) { result.errors.push( - `${group.signgu}/${group.code}: 이번 결과(${seen})가 기존(${activeBefore})보다 크게 적어 폐업 처리를 건너뜀`, + `${group.signgu}/${group.code}: 기존 ${activeBefore.length}곳 중 ${missing}곳이 목록에 없어 원본 장애로 보고 폐업 처리를 건너뜀`, ); continue; } diff --git a/backend/src/modules/place/sync/tour-kor.sync.ts b/backend/src/modules/place/sync/tour-kor.sync.ts index 688df15..007e972 100644 --- a/backend/src/modules/place/sync/tour-kor.sync.ts +++ b/backend/src/modules/place/sync/tour-kor.sync.ts @@ -6,6 +6,7 @@ import { Place } from '../entities/place.entity'; import type { PlaceCategory } from '../place.constants'; import { BudgetExceededError, CallBudget } from './call-budget'; import { PlaceDedupe } from './place-dedupe'; +import { isSuspiciousRemoval } from './removal-guard'; import { SyncRunRecorder } from './sync-run.recorder'; import type { TourApi, TourItem } from './tour-api.client'; import { PublicDataApiFactory } from './public-data.factory'; @@ -24,8 +25,6 @@ export const DEFAULT_TOUR_REGION = '27'; const LIST_PAGE_SIZE = 1_000; // 2026-11-02 대구 1,193건을 2쪽으로 받음 const MAX_LIST_PAGES = 50; const UPSERT_CHUNK = 200; -/** 목록이 기존보다 이만큼 적게 오면 원본 장애로 보고 숨김 처리를 하지 않는다 */ -const MIN_COMPLETE_RATIO = 0.8; /** 상세 조회가 연속으로 이만큼 실패하면 멈춘다(키·서버 문제일 가능성) */ const MAX_CONSECUTIVE_FAILURES = 10; /** 상세를 받는 순서 — 한도 때문에 나뉘어도 앱에 먼저 필요한 분류부터 채운다 */ @@ -233,13 +232,15 @@ export class TourKorSync { await this.upsert(rows); // 목록을 끝까지 받았을 때만 사라진 장소를 숨긴다(부분 응답으로 멀쩡한 장소를 숨기지 않게) + if (!complete) return; + const seen = new Set(rows.map((r) => r.sourceId)); const activeBefore = existing.filter( (p) => p.status === 'active' && p.ldongRegnCd === regionCode, - ).length; - if (!complete) return; - if (rows.length < activeBefore * MIN_COMPLETE_RATIO) { + ); + const missing = activeBefore.filter((p) => !seen.has(p.sourceId)).length; + if (isSuspiciousRemoval(activeBefore.length, missing)) { result.errors.push( - `목록이 기존 노출 장소(${activeBefore})보다 크게 적어(${rows.length}) 숨김 처리를 건너뜀`, + `기존 노출 장소 ${activeBefore.length}곳 중 ${missing}곳이 목록에 없어 원본 장애로 보고 숨김 처리를 건너뜀`, ); return; }