fix(backend): 숨김·폐업 처리 안전장치를 항목 수에 맞게 조정

"이번 목록이 기존의 80% 미만이면 원본 장애로 보고 숨기지 않는다"는 규칙은
항목이 적은 곳(축제 몇 건, 동네 빙수집 5곳)에서 정상적인 한 건 취소도 막았다.
사라진 수를 직접 세어, 5건을 넘고 기존의 20% 도 넘을 때 또는 기존이 전부
사라졌을 때(빈 응답)만 원본 장애로 본다. 관광공사·상가정보 동기화가 함께 쓴다.

상가정보는 업종이 바뀐 업소(카페 → 빵집)를 사라진 것으로 세지 않도록
묶음별 개수 대신 이번에 본 업소 id 전체와 비교한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 19:30:07 +09:00
parent 3d2cd99ef0
commit 55f10c5c7a
4 changed files with 60 additions and 17 deletions
@@ -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);
});
});
@@ -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;
}
+9 -11
View File
@@ -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<string, StorePlace & Partial<Place>>();
const rowsPerGroup = new Map<string, number>();
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<string, number>,
seenIds: Set<string>,
runStart: Date,
): Promise<void> {
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;
}
@@ -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;
}