diff --git a/backend/.env.development.example b/backend/.env.development.example
index 8702de8..397073f 100644
--- a/backend/.env.development.example
+++ b/backend/.env.development.example
@@ -18,6 +18,9 @@ JWT_REFRESH_EXPIRES_IN=14d
# 한 키로 활용신청이 승인된 모든 API(관광공사 국문·영문·반려동물, 기상청 단기예보, 상가정보)를 호출한다.
# 개발 계정은 관광공사 API 하루 1,000건 제한이 있다.
PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key
+# 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령이 이 안에서 멈춘다). 비우면 1000(개발 계정).
+# 운영 계정 승인 후 승인된 한도로 올린다.
+TOUR_API_DAILY_LIMIT=
# 카카오 REST API 키 — 장소 검색(로컬 API)을 서버에서만 호출한다.
# 응답(이름·주소·좌표)은 저장·캐시·LLM 전송 금지. 장소 ID·place_url 만 저장할 수 있다.
diff --git a/backend/.env.production.example b/backend/.env.production.example
index 1f0cc1c..4bede34 100644
--- a/backend/.env.production.example
+++ b/backend/.env.production.example
@@ -16,6 +16,9 @@ JWT_REFRESH_EXPIRES_IN=14d
# 공공데이터포털 일반 인증키(Decoding) — 운영 전 관광공사 API 는 활용사례를 등록해 운영 계정으로 트래픽을 늘린다.
PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key
+# 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령이 이 안에서 멈춘다). 비우면 1000(개발 계정).
+# 운영 계정 승인 후 승인된 한도로 올린다.
+TOUR_API_DAILY_LIMIT=
# 카카오 REST API 키 — 운영에 쓸 카카오 앱의 키. 응답 저장·캐시·LLM 전송 금지.
KAKAO_REST_API_KEY=change-me-kakao-rest-api-key
diff --git a/backend/package.json b/backend/package.json
index ccc5784..ccda9b5 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -25,7 +25,9 @@
"migration:create": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:create",
"migration:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert",
- "migration:show": "npm run typeorm -- migration:show"
+ "migration:show": "npm run typeorm -- migration:show",
+ "place:sync": "ts-node -r tsconfig-paths/register src/commands/place-sync.ts",
+ "place:sync:prod": "node dist/commands/place-sync.js"
},
"dependencies": {
"@nestjs/cache-manager": "^3.0.1",
diff --git a/backend/src/commands/place-sync.cli.module.ts b/backend/src/commands/place-sync.cli.module.ts
new file mode 100644
index 0000000..c276609
--- /dev/null
+++ b/backend/src/commands/place-sync.cli.module.ts
@@ -0,0 +1,31 @@
+import { Module } from '@nestjs/common';
+import { ConfigModule } from '@nestjs/config';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { PlaceSyncModule } from '@/modules/place/sync/place-sync.module';
+import { buildDatabaseOptions } from '@/shared/config/database.config';
+import { validateEnv } from '@/shared/config/env.validation';
+
+/**
+ * 장소 동기화 CLI 전용 루트 모듈 — HTTP·Rate Limit·캐시 없이 설정·DB·동기화만 올린다.
+ * 설정은 API 서버와 같은 경로로 읽는다(backend/.env.{NODE_ENV} + compose 주입값).
+ */
+@Module({
+ imports: [
+ ConfigModule.forRoot({
+ isGlobal: true,
+ envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
+ validate: validateEnv,
+ }),
+ TypeOrmModule.forRootAsync({
+ useFactory: () => ({
+ ...buildDatabaseOptions(),
+ autoLoadEntities: true,
+ // 수천 건 upsert 의 SQL 로그는 진행 상황을 가린다
+ logging: false,
+ }),
+ }),
+ PlaceSyncModule,
+ ],
+})
+export class PlaceSyncCliModule {}
diff --git a/backend/src/commands/place-sync.ts b/backend/src/commands/place-sync.ts
new file mode 100644
index 0000000..d61bf7d
--- /dev/null
+++ b/backend/src/commands/place-sync.ts
@@ -0,0 +1,78 @@
+import 'reflect-metadata';
+import { Logger } from '@nestjs/common';
+import { NestFactory } from '@nestjs/core';
+import { WinstonModule } from 'nest-winston';
+
+import { PlaceSyncStatus } from '@/modules/place/sync/place-sync-status';
+import {
+ DEFAULT_TOUR_REGION,
+ TourKorSync,
+} from '@/modules/place/sync/tour-kor.sync';
+import { TourPetSync } from '@/modules/place/sync/tour-pet.sync';
+import { winstonConfig } from '@/shared/logger/winston.config';
+import { PlaceSyncCliModule } from './place-sync.cli.module';
+
+/**
+ * 장소 동기화 명령 (설계: docs/design/place-db.md §4).
+ * 공공데이터 키를 쓰므로 키가 있는 환경(개발 컨테이너·운영 서버)에서 실행한다.
+ *
+ * 개발: docker compose ... exec backend npm run place:sync -- <작업> [옵션]
+ * 운영: node dist/commands/place-sync.js <작업> [옵션] (cron)
+ */
+const USAGE = `사용법: place:sync <작업> [옵션]
+ tour-kor 관광공사 국문 장소 적재·갱신 (목록 → 상세). 한도에 닿으면 멈추고, 다시 실행하면 이어서 받는다
+ --region=27 법정동 시·도 코드 (기본 27 대구)
+ --skip-details 목록만 갱신
+ tour-pet 관광공사 반려동물 동반 정보 반영 (전국 약 10회 호출)
+ status 지역별 적재 현황과 오늘 쓴 호출 수
+ --region=27`;
+
+const JOBS = ['tour-kor', 'tour-pet', 'status'] as const;
+type Job = (typeof JOBS)[number];
+
+function parseArgs(argv: string[]): {
+ job: Job | null;
+ region: string;
+ skipDetails: boolean;
+} {
+ const [first, ...rest] = argv;
+ const job = (JOBS as readonly string[]).includes(first ?? '')
+ ? (first as Job)
+ : null;
+ const regionArg = rest.find((a) => a.startsWith('--region='));
+ const region = regionArg?.split('=')[1]?.trim() || DEFAULT_TOUR_REGION;
+ return { job, region, skipDetails: rest.includes('--skip-details') };
+}
+
+async function main(): Promise {
+ const { job, region, skipDetails } = parseArgs(process.argv.slice(2));
+ if (!job || !/^\d{2}$/.test(region)) {
+ Logger.error(USAGE, 'PlaceSync');
+ process.exitCode = 1;
+ return;
+ }
+
+ const app = await NestFactory.createApplicationContext(PlaceSyncCliModule, {
+ logger: WinstonModule.createLogger(winstonConfig),
+ });
+ try {
+ if (job === 'tour-kor') {
+ await app.get(TourKorSync).run({ regionCode: region, skipDetails });
+ } else if (job === 'tour-pet') {
+ await app.get(TourPetSync).run();
+ } else {
+ await app.get(PlaceSyncStatus).report(region);
+ }
+ } finally {
+ await app.close();
+ }
+}
+
+main().catch((error: unknown) => {
+ // 오류 메시지에는 키가 들어가지 않는다(클라이언트가 가린다)
+ Logger.error(
+ error instanceof Error ? error.message : String(error),
+ 'PlaceSync',
+ );
+ process.exitCode = 1;
+});
diff --git a/backend/src/migrations/1793599990257-AddPlaceSourceTypeAndDetailSync.ts b/backend/src/migrations/1793599990257-AddPlaceSourceTypeAndDetailSync.ts
new file mode 100644
index 0000000..a29d73a
--- /dev/null
+++ b/backend/src/migrations/1793599990257-AddPlaceSourceTypeAndDetailSync.ts
@@ -0,0 +1,40 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * 관광공사 동기화 준비:
+ * - source_type: 관광타입(contenttypeid) — 소개정보(detailIntro2) 조회에 필요
+ * - detail_synced_at: 상세를 받은 시각 — 하루 한도로 나뉜 초기 적재를 이어서 받는다
+ * - 분류에 'other'(교육시설·연회장 등) 추가
+ *
+ * ⚠️ CHECK 제약 변경은 migration:generate 가 감지하지 못해(이름이 같으면 같은 제약으로 본다) 직접 적었다.
+ */
+export class AddPlaceSourceTypeAndDetailSync1793599990257 implements MigrationInterface {
+ name = 'AddPlaceSourceTypeAndDetailSync1793599990257';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "place" ADD "source_type" text`);
+ await queryRunner.query(
+ `ALTER TABLE "place" ADD "detail_synced_at" TIMESTAMP WITH TIME ZONE`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "place" DROP CONSTRAINT "ck_place_category"`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "place" ADD CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging', 'other'))`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ // 'other' 로 들어간 장소가 있으면 되돌리기 전에 정리해야 한다(제약 위반으로 실패한다)
+ await queryRunner.query(
+ `ALTER TABLE "place" DROP CONSTRAINT "ck_place_category"`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "place" ADD CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging'))`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "place" DROP COLUMN "detail_synced_at"`,
+ );
+ await queryRunner.query(`ALTER TABLE "place" DROP COLUMN "source_type"`);
+ }
+}
diff --git a/backend/src/modules/place/entities/place.entity.ts b/backend/src/modules/place/entities/place.entity.ts
index 2da5980..78bc027 100644
--- a/backend/src/modules/place/entities/place.entity.ts
+++ b/backend/src/modules/place/entities/place.entity.ts
@@ -69,6 +69,10 @@ export class Place {
@Column({ type: 'text', nullable: true })
sourceCategory!: string | null;
+ /** 관광공사 관광타입(contenttypeid: 12 관광지·14 문화시설·39 음식점 …) — 소개정보 조회에 필요하다 */
+ @Column({ type: 'text', nullable: true })
+ sourceType!: string | null;
+
@Column({ type: 'text' })
name!: string;
@@ -156,6 +160,13 @@ export class Place {
@Column({ type: 'timestamptz', nullable: true })
lastSeenAt!: Date | null;
+ /**
+ * 상세(개요·영업시간 등)를 받은 시각. null 이면 아직 못 받았거나 원본이 바뀌어 다시 받아야 한다.
+ * 개발 계정 한도(오퍼레이션별 하루 1,000건) 때문에 초기 적재가 며칠에 나뉘므로, 이 값으로 이어서 받는다.
+ */
+ @Column({ type: 'timestamptz', nullable: true })
+ detailSyncedAt!: Date | null;
+
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
diff --git a/backend/src/modules/place/place.constants.ts b/backend/src/modules/place/place.constants.ts
index 6d0e37f..3124eb1 100644
--- a/backend/src/modules/place/place.constants.ts
+++ b/backend/src/modules/place/place.constants.ts
@@ -1,13 +1,20 @@
/**
* 장소 DB 의 코드값 — 엔티티 CHECK 제약·place_listing 뷰·서비스가 이 목록 하나를 공유한다.
* 설계: docs/design/place-db.md
+ *
+ * ⚠️ 목록을 바꾸면 CHECK 제약도 바뀌는데, migration:generate 는 이름이 같은 CHECK 제약의 식 변경을
+ * 감지하지 못한다. 새 마이그레이션에 DROP CONSTRAINT / ADD CONSTRAINT 를 직접 적는다
+ * (예: 1793599990257-AddPlaceSourceTypeAndDetailSync). 뷰 식 변경은 generate 가 감지한다.
*/
/** 데이터 출처: 관광공사(TourAPI) · 소상공인 상가정보 */
export const PLACE_SOURCES = ['tour', 'store'] as const;
export type PlaceSource = (typeof PLACE_SOURCES)[number];
-/** 자체 분류 — 검색 탭·AI 코스 슬롯에 쓴다 (설계 §2.6) */
+/**
+ * 자체 분류 — 검색 탭·AI 코스 슬롯에 쓴다 (설계 §2.6, 대응표는 sync/tour-mapper.ts).
+ * other: 교육시설·연회장처럼 데이트 장소가 아닌 곳 — 검색 '전체'에만 나오고 AI 후보가 아니다.
+ */
export const PLACE_CATEGORIES = [
'nature',
'attraction',
@@ -18,6 +25,7 @@ export const PLACE_CATEGORIES = [
'bar',
'shopping',
'lodging',
+ 'other',
] as const;
export type PlaceCategory = (typeof PLACE_CATEGORIES)[number];
diff --git a/backend/src/modules/place/sync/call-budget.spec.ts b/backend/src/modules/place/sync/call-budget.spec.ts
new file mode 100644
index 0000000..c3160a7
--- /dev/null
+++ b/backend/src/modules/place/sync/call-budget.spec.ts
@@ -0,0 +1,34 @@
+import { BudgetExceededError, CallBudget, sumCalls } from './call-budget';
+
+describe('CallBudget', () => {
+ it('오늘 이미 쓴 수를 빼고 남은 호출 수를 센다', () => {
+ const budget = new CallBudget(10, { detailIntro2: 7 });
+ expect(budget.remaining('detailIntro2')).toBe(3);
+ expect(budget.remaining('detailCommon2')).toBe(10);
+ });
+
+ it('한도를 넘기면 BudgetExceededError 를 던지고 수를 늘리지 않는다', () => {
+ const budget = new CallBudget(2);
+ budget.take('detailCommon2');
+ budget.take('detailCommon2');
+ expect(() => budget.take('detailCommon2')).toThrow(BudgetExceededError);
+ expect(budget.spent()).toEqual({ detailCommon2: 2 });
+ });
+
+ it('이번 실행에서 쓴 수만 따로 돌려준다(이전 실행분 제외)', () => {
+ const budget = new CallBudget(100, { areaBasedList2: 5 });
+ budget.take('areaBasedList2');
+ expect(budget.spent()).toEqual({ areaBasedList2: 1 });
+ });
+});
+
+describe('sumCalls', () => {
+ it('여러 실행 기록의 오퍼레이션별 호출 수를 합친다', () => {
+ expect(
+ sumCalls([
+ { detailIntro2: 400, areaBasedList2: 2 },
+ { detailIntro2: 300 },
+ ]),
+ ).toEqual({ detailIntro2: 700, areaBasedList2: 2 });
+ });
+});
diff --git a/backend/src/modules/place/sync/call-budget.ts b/backend/src/modules/place/sync/call-budget.ts
new file mode 100644
index 0000000..d2cf4b5
--- /dev/null
+++ b/backend/src/modules/place/sync/call-budget.ts
@@ -0,0 +1,70 @@
+/**
+ * 관광공사 API 하루 호출 한도 관리. 개발 계정은 **오퍼레이션마다** 하루 1,000건이다.
+ * 한도에 닿으면 실패가 아니라 "오늘은 여기까지" — 동기화는 진행 상황을 저장하고 멈춘다.
+ */
+
+/** 한도에 닿았을 때 던진다. 동기화는 이 오류를 받으면 정상 종료한다 */
+export class BudgetExceededError extends Error {
+ constructor(
+ readonly operation: string,
+ readonly reason: 'local' | 'server',
+ ) {
+ super(
+ reason === 'local'
+ ? `관광공사 ${operation} 오늘 호출 한도에 닿았습니다`
+ : `관광공사 ${operation} 호출이 서버에서 한도 초과로 거부되었습니다`,
+ );
+ this.name = 'BudgetExceededError';
+ }
+}
+
+/** 오퍼레이션별 호출 수를 센다. 오늘 이전 실행에서 쓴 수부터 시작한다 */
+export class CallBudget {
+ private readonly used = new Map();
+ private readonly spentThisRun = new Map();
+
+ constructor(
+ private readonly limit: number,
+ usedToday: Record = {},
+ ) {
+ for (const [op, n] of Object.entries(usedToday)) this.used.set(op, n);
+ }
+
+ remaining(operation: string): number {
+ return Math.max(0, this.limit - (this.used.get(operation) ?? 0));
+ }
+
+ canSpend(operation: string, count = 1): boolean {
+ return this.remaining(operation) >= count;
+ }
+
+ /** 호출 1회를 쓴다. 남은 한도가 없으면 BudgetExceededError */
+ take(operation: string): void {
+ if (!this.canSpend(operation)) {
+ throw new BudgetExceededError(operation, 'local');
+ }
+ this.used.set(operation, (this.used.get(operation) ?? 0) + 1);
+ this.spentThisRun.set(
+ operation,
+ (this.spentThisRun.get(operation) ?? 0) + 1,
+ );
+ }
+
+ /** 이번 실행에서 쓴 호출 수 — place_sync_run.calls 에 남긴다 */
+ spent(): Record {
+ return Object.fromEntries(this.spentThisRun);
+ }
+}
+
+/** 여러 실행 기록의 오퍼레이션별 호출 수를 합친다 */
+export function sumCalls(
+ records: readonly Record[],
+): Record {
+ const total: Record = {};
+ for (const rec of records) {
+ for (const [op, n] of Object.entries(rec)) {
+ if (Number.isFinite(n)) total[op] = (total[op] ?? 0) + n;
+ }
+ }
+ return total;
+}
diff --git a/backend/src/modules/place/sync/place-sync-status.ts b/backend/src/modules/place/sync/place-sync-status.ts
new file mode 100644
index 0000000..975be49
--- /dev/null
+++ b/backend/src/modules/place/sync/place-sync-status.ts
@@ -0,0 +1,85 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { Place } from '../entities/place.entity';
+import { SyncRunRecorder } from './sync-run.recorder';
+import { TourApiFactory } from './tour-api.factory';
+
+/** 관광공사 국문 적재에 쓰는 오퍼레이션 */
+const TOUR_KOR_OPERATIONS = [
+ 'areaBasedList2',
+ 'detailCommon2',
+ 'detailIntro2',
+ 'detailPetTour2',
+];
+
+export interface PlaceSyncStatusReport {
+ byStatus: Record;
+ byCategory: Record;
+ detailPending: number;
+ withPet: number;
+ usedToday: Record;
+ dailyLimit: number;
+}
+
+/** 적재 진행 상황 — 지역별 장소 수, 상세 남은 수, 오늘 쓴 호출 수 */
+@Injectable()
+export class PlaceSyncStatus {
+ private readonly logger = new Logger(PlaceSyncStatus.name);
+
+ constructor(
+ @InjectRepository(Place) private readonly places: Repository,
+ private readonly recorder: SyncRunRecorder,
+ private readonly apiFactory: TourApiFactory,
+ ) {}
+
+ async report(regionCode: string): Promise {
+ const count = async (column: 'status' | 'category') => {
+ const rows = await this.places
+ .createQueryBuilder('p')
+ .select(`p.${column}`, 'key')
+ .addSelect('COUNT(*)', 'n')
+ .where('p.source = :source', { source: 'tour' })
+ .andWhere('p.ldong_regn_cd = :region', { region: regionCode })
+ .groupBy(`p.${column}`)
+ .getRawMany<{ key: string; n: string }>();
+ return Object.fromEntries(rows.map((r) => [r.key, Number(r.n)]));
+ };
+ const pendingAndPet = await this.places
+ .createQueryBuilder('p')
+ .select(
+ `COUNT(*) FILTER (WHERE p.status = 'active' AND p.detail_synced_at IS NULL)`,
+ 'pending',
+ )
+ .addSelect(
+ `COUNT(*) FILTER (WHERE p.public_pet_policy IS NOT NULL)`,
+ 'pet',
+ )
+ .where('p.source = :source', { source: 'tour' })
+ .andWhere('p.ldong_regn_cd = :region', { region: regionCode })
+ .getRawOne<{ pending: string; pet: string }>();
+
+ const report: PlaceSyncStatusReport = {
+ byStatus: await count('status'),
+ byCategory: await count('category'),
+ detailPending: Number(pendingAndPet?.pending ?? 0),
+ withPet: Number(pendingAndPet?.pet ?? 0),
+ usedToday: await this.recorder.usedToday(),
+ dailyLimit: this.apiFactory.dailyLimit(),
+ };
+
+ this.logger.log(
+ `지역 ${regionCode} 관광공사 장소 — 상태 ${JSON.stringify(report.byStatus)}`,
+ );
+ this.logger.log(`분류 ${JSON.stringify(report.byCategory)}`);
+ this.logger.log(
+ `상세 남은 장소 ${report.detailPending} · 반려동물 정보 있는 장소 ${report.withPet}`,
+ );
+ const used = TOUR_KOR_OPERATIONS.map(
+ (op) => `${op} ${report.usedToday[op] ?? 0}/${report.dailyLimit}`,
+ ).join(' · ');
+ this.logger.log(`오늘(KST) 이 저장소에서 쓴 호출 — ${used}`);
+ return report;
+ }
+}
diff --git a/backend/src/modules/place/sync/place-sync.module.ts b/backend/src/modules/place/sync/place-sync.module.ts
new file mode 100644
index 0000000..7c42c62
--- /dev/null
+++ b/backend/src/modules/place/sync/place-sync.module.ts
@@ -0,0 +1,27 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { Place } from '../entities/place.entity';
+import { PlaceSyncRun } from '../entities/place-sync-run.entity';
+import { PlaceSyncStatus } from './place-sync-status';
+import { SyncRunRecorder } from './sync-run.recorder';
+import { TourApiFactory } from './tour-api.factory';
+import { TourKorSync } from './tour-kor.sync';
+import { TourPetSync } from './tour-pet.sync';
+
+/**
+ * 장소 동기화 — 공공데이터를 place 에 적재한다.
+ * API 서버(AppModule)에는 넣지 않는다. CLI 명령(src/commands/place-sync.ts)에서만 쓴다.
+ */
+@Module({
+ imports: [TypeOrmModule.forFeature([Place, PlaceSyncRun])],
+ providers: [
+ TourApiFactory,
+ SyncRunRecorder,
+ TourKorSync,
+ TourPetSync,
+ PlaceSyncStatus,
+ ],
+ exports: [TourKorSync, TourPetSync, PlaceSyncStatus],
+})
+export class PlaceSyncModule {}
diff --git a/backend/src/modules/place/sync/sync-run.recorder.ts b/backend/src/modules/place/sync/sync-run.recorder.ts
new file mode 100644
index 0000000..327acd7
--- /dev/null
+++ b/backend/src/modules/place/sync/sync-run.recorder.ts
@@ -0,0 +1,60 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { MoreThanOrEqual, Repository } from 'typeorm';
+
+import { kstStartOfDay } from '@/shared/utils/kst';
+import { PlaceSyncRun } from '../entities/place-sync-run.entity';
+import type { SyncJob } from '../place.constants';
+import { sumCalls } from './call-budget';
+
+/** 실행 기록에 남기는 오류 수 상한 */
+const MAX_RECORDED_ERRORS = 20;
+
+/** place_sync_run 기록 — 실행 시작·종료와 "오늘(KST) 이미 쓴 호출 수" */
+@Injectable()
+export class SyncRunRecorder {
+ constructor(
+ @InjectRepository(PlaceSyncRun)
+ private readonly runs: Repository,
+ ) {}
+
+ start(job: SyncJob, scope: string | null): Promise {
+ return this.runs.save(
+ this.runs.create({ job, scope, startedAt: new Date() }),
+ );
+ }
+
+ /**
+ * 오늘(한국 날짜) 이 저장소에서 기록된 호출 수를 오퍼레이션별로 합친다.
+ * 같은 키를 다른 도구(점검 스크립트 등)에서 쓴 호출은 여기에 없다 — 그 경우는 서버의 한도 초과 응답으로 멈춘다.
+ */
+ async usedToday(now = new Date()): Promise> {
+ const today = await this.runs.find({
+ where: { startedAt: MoreThanOrEqual(kstStartOfDay(now)) },
+ select: { calls: true },
+ });
+ return sumCalls(today.map((r) => r.calls));
+ }
+
+ async finish(
+ run: PlaceSyncRun,
+ result: Partial<
+ Pick<
+ PlaceSyncRun,
+ | 'fetched'
+ | 'inserted'
+ | 'updated'
+ | 'hidden'
+ | 'gone'
+ | 'calls'
+ | 'snapshot'
+ >
+ > & { errors?: string[] },
+ ): Promise {
+ await this.runs.update(run.id, {
+ ...result,
+ errors: (result.errors ?? []).slice(0, MAX_RECORDED_ERRORS),
+ finishedAt: new Date(),
+ });
+ }
+}
diff --git a/backend/src/modules/place/sync/tour-api.client.spec.ts b/backend/src/modules/place/sync/tour-api.client.spec.ts
new file mode 100644
index 0000000..5628088
--- /dev/null
+++ b/backend/src/modules/place/sync/tour-api.client.spec.ts
@@ -0,0 +1,134 @@
+import { BudgetExceededError, CallBudget } from './call-budget';
+import {
+ FetchLike,
+ TourApiClient,
+ TourApiError,
+ parseTourPage,
+} from './tour-api.client';
+
+const KEY = 'abc+def/ghi==';
+
+const okBody = (item: unknown, totalCount = 1) =>
+ JSON.stringify({
+ response: {
+ header: { resultCode: '0000', resultMsg: 'OK' },
+ body: { items: item === '' ? '' : { item }, totalCount },
+ },
+ });
+
+function client(
+ responses: Array<{ status?: number; body?: string; throws?: Error }>,
+ budget = new CallBudget(100),
+) {
+ const urls: string[] = [];
+ const fetchImpl: FetchLike = (url) => {
+ urls.push(url);
+ const next = responses.shift();
+ if (!next) throw new Error('예상보다 많이 호출했다');
+ if (next.throws) return Promise.reject(next.throws);
+ const status = next.status ?? 200;
+ return Promise.resolve({
+ ok: status < 400,
+ status,
+ text: () => Promise.resolve(next.body ?? ''),
+ });
+ };
+ const api = new TourApiClient({
+ serviceKey: KEY,
+ budget,
+ fetchImpl,
+ sleep: () => Promise.resolve(),
+ });
+ return { api, urls, budget };
+}
+
+describe('parseTourPage', () => {
+ it('항목이 1건이면 객체로, 0건이면 빈 문자열로 와도 배열로 바꾼다', () => {
+ expect(parseTourPage(okBody({ contentid: '1' }), 'x').items).toEqual([
+ { contentid: '1' },
+ ]);
+ expect(parseTourPage(okBody('', 0), 'x')).toEqual({
+ items: [],
+ totalCount: 0,
+ });
+ });
+
+ it('결과 코드가 0000 이 아니면 오류로 본다', () => {
+ const body = JSON.stringify({
+ response: { header: { resultCode: '10', resultMsg: 'INVALID_REQUEST' } },
+ });
+ expect(() => parseTourPage(body, 'detailIntro2')).toThrow(
+ /detailIntro2: 결과 코드 10 INVALID_REQUEST/,
+ );
+ });
+
+ it('한도 초과 응답(XML, 코드 22)은 한도 오류로 본다', () => {
+ const xml =
+ 'LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR22';
+ expect(() => parseTourPage(xml, 'detailCommon2')).toThrow(
+ BudgetExceededError,
+ );
+ });
+
+ it('키 오류 같은 XML 응답은 사유를 담은 오류로 본다', () => {
+ const xml =
+ 'SERVICE_KEY_IS_NOT_REGISTERED_ERROR30';
+ expect(() => parseTourPage(xml, 'areaBasedList2')).toThrow(
+ /SERVICE_KEY_IS_NOT_REGISTERED_ERROR/,
+ );
+ });
+});
+
+describe('TourApiClient', () => {
+ it('공통 파라미터와 인코딩된 키를 붙여 호출하고 한도를 차감한다', async () => {
+ const { api, urls, budget } = client([
+ { body: okBody({ contentid: '7' }) },
+ ]);
+ const page = await api.get('detailCommon2', { contentId: 7 });
+ expect(page.items[0]).toEqual({ contentid: '7' });
+ const url = new URL(urls[0]);
+ expect(url.pathname).toMatch(/\/KorService2\/detailCommon2$/);
+ expect(url.searchParams.get('serviceKey')).toBe(KEY);
+ expect(url.searchParams.get('_type')).toBe('json');
+ expect(url.searchParams.get('contentId')).toBe('7');
+ expect(budget.spent()).toEqual({ detailCommon2: 1 });
+ });
+
+ it('5xx·네트워크 오류는 다시 시도하고, 재시도도 한도에서 차감한다', async () => {
+ const { api, budget } = client([
+ { status: 503 },
+ { throws: new Error('socket hang up') },
+ { body: okBody({ contentid: '1' }) },
+ ]);
+ await expect(api.get('areaBasedList2', {})).resolves.toMatchObject({
+ totalCount: 1,
+ });
+ expect(budget.spent()).toEqual({ areaBasedList2: 3 });
+ });
+
+ it('한도가 없으면 호출하지 않고 BudgetExceededError 를 던진다', async () => {
+ const { api, urls } = client([], new CallBudget(0));
+ await expect(api.get('detailIntro2', {})).rejects.toThrow(
+ BudgetExceededError,
+ );
+ expect(urls).toHaveLength(0);
+ });
+
+ it('오류 메시지에 키가 섞여도 가린다', async () => {
+ // 최종 메시지는 마지막 시도의 오류로 만든다 — 그 오류에 키가 원문·인코딩 둘 다 섞인 경우
+ const { api } = client([
+ { throws: new Error('connect failed') },
+ { throws: new Error('connect failed') },
+ {
+ throws: new Error(
+ `connect failed ?serviceKey=${encodeURIComponent(KEY)} (${KEY})`,
+ ),
+ },
+ ]);
+ const error = await api.get('detailCommon2', {}).catch((e: Error) => e);
+ expect(error).toBeInstanceOf(TourApiError);
+ expect((error as Error).message).toContain('***');
+ expect((error as Error).message).not.toContain(KEY);
+ expect((error as Error).message).not.toContain(encodeURIComponent(KEY));
+ });
+});
diff --git a/backend/src/modules/place/sync/tour-api.client.ts b/backend/src/modules/place/sync/tour-api.client.ts
new file mode 100644
index 0000000..f2bd0a6
--- /dev/null
+++ b/backend/src/modules/place/sync/tour-api.client.ts
@@ -0,0 +1,182 @@
+import { BudgetExceededError, CallBudget } from './call-budget';
+
+/** 관광공사 국문 관광정보 서비스(KorService2, 매뉴얼 v4.4) */
+export const TOUR_API_BASE_URL = 'https://apis.data.go.kr/B551011/KorService2';
+
+const COMMON_PARAMS = { MobileOS: 'ETC', MobileApp: 'WanderGo', _type: 'json' };
+const REQUEST_TIMEOUT_MS = 20_000;
+const MAX_ATTEMPTS = 3; // 네트워크 오류·5xx 만 다시 시도한다
+const RETRY_DELAY_MS = 1_000;
+const CALL_INTERVAL_MS = 100; // 연속 호출 간격(서버 부담·초당 제한 대비)
+
+/** 공공데이터포털이 한도 초과로 거부할 때의 표시(오류 코드 22) */
+const QUOTA_EXCEEDED_RE =
+ /LIMITED_NUMBER_OF_SERVICE_REQUESTS|\s*22\s*;
+
+export interface TourPage {
+ items: TourItem[];
+ totalCount: number;
+}
+
+/** 동기화가 쓰는 관광공사 호출 창구 — 테스트에서는 가짜로 바꿔 끼운다 */
+export interface TourApi {
+ get(
+ operation: string,
+ params: Record,
+ ): Promise;
+}
+
+export class TourApiError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'TourApiError';
+ }
+}
+
+interface FetchResponse {
+ ok: boolean;
+ status: number;
+ text(): Promise;
+}
+export type FetchLike = (
+ url: string,
+ init: { signal: AbortSignal },
+) => Promise;
+
+export interface TourApiClientOptions {
+ serviceKey: string;
+ budget: CallBudget;
+ baseUrl?: string;
+ fetchImpl?: FetchLike;
+ sleep?: (ms: number) => Promise;
+}
+
+const defaultSleep = (ms: number) =>
+ new Promise((resolve) => setTimeout(resolve, ms));
+
+function asRecord(value: unknown): Record | undefined {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : undefined;
+}
+
+/**
+ * 응답 본문을 목록으로 바꾼다. 관광공사는 항목이 1건이면 배열 대신 객체를, 0건이면 빈 문자열을 준다.
+ * 키가 틀리면 _type=json 이어도 XML 로 답한다.
+ */
+export function parseTourPage(body: string, operation: string): TourPage {
+ let data: unknown;
+ try {
+ data = JSON.parse(body);
+ } catch {
+ if (QUOTA_EXCEEDED_RE.test(body)) {
+ throw new BudgetExceededError(operation, 'server');
+ }
+ const reason = /([^<]+) x !== undefined);
+ const totalCount = Number(resBody?.totalCount ?? items.length);
+ return {
+ items,
+ totalCount: Number.isFinite(totalCount) ? totalCount : items.length,
+ };
+}
+
+/**
+ * 관광공사 API 클라이언트. 호출마다 한도를 차감하고(재시도 포함 — 재시도도 실제 호출이다),
+ * **키는 어떤 오류 메시지에도 남기지 않는다.**
+ */
+export class TourApiClient implements TourApi {
+ private readonly baseUrl: string;
+ private readonly fetchImpl: FetchLike;
+ private readonly sleep: (ms: number) => Promise;
+ private lastCallAt = 0;
+
+ constructor(private readonly options: TourApiClientOptions) {
+ this.baseUrl = options.baseUrl ?? TOUR_API_BASE_URL;
+ this.fetchImpl = options.fetchImpl ?? fetch;
+ this.sleep = options.sleep ?? defaultSleep;
+ }
+
+ async get(
+ operation: string,
+ params: Record,
+ ): Promise {
+ const query = new URLSearchParams({
+ serviceKey: this.options.serviceKey,
+ ...COMMON_PARAMS,
+ ...Object.fromEntries(
+ Object.entries(params).map(([k, v]) => [k, String(v)]),
+ ),
+ });
+ const url = `${this.baseUrl}/${operation}?${query.toString()}`;
+
+ let lastError: unknown;
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
+ this.options.budget.take(operation);
+ await this.pace();
+ try {
+ const res = await this.fetchImpl(url, {
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ const body = await res.text();
+ if (res.status >= 500) {
+ lastError = new TourApiError(`${operation}: HTTP ${res.status}`);
+ } else if (!res.ok) {
+ if (QUOTA_EXCEEDED_RE.test(body)) {
+ throw new BudgetExceededError(operation, 'server');
+ }
+ throw new TourApiError(`${operation}: HTTP ${res.status}`);
+ } else {
+ return parseTourPage(body, operation);
+ }
+ } catch (error) {
+ if (
+ error instanceof BudgetExceededError ||
+ error instanceof TourApiError
+ ) {
+ throw this.mask(error);
+ }
+ lastError = error; // 네트워크 오류·시간 초과 → 다시 시도
+ }
+ if (attempt < MAX_ATTEMPTS) await this.sleep(RETRY_DELAY_MS * attempt);
+ }
+ const reason =
+ lastError instanceof Error ? lastError.message : String(lastError);
+ throw this.mask(
+ new TourApiError(`${operation}: ${MAX_ATTEMPTS}회 시도 실패 — ${reason}`),
+ );
+ }
+
+ private async pace(): Promise {
+ const wait = this.lastCallAt + CALL_INTERVAL_MS - Date.now();
+ if (wait > 0) await this.sleep(wait);
+ this.lastCallAt = Date.now();
+ }
+
+ /** 오류 메시지에 키(원문·URL 인코딩)가 섞였으면 가린다 */
+ private mask(error: E): E {
+ const key = this.options.serviceKey;
+ for (const variant of [key, encodeURIComponent(key)]) {
+ if (variant) error.message = error.message.split(variant).join('***');
+ }
+ return error;
+ }
+}
diff --git a/backend/src/modules/place/sync/tour-api.factory.ts b/backend/src/modules/place/sync/tour-api.factory.ts
new file mode 100644
index 0000000..7d9ba92
--- /dev/null
+++ b/backend/src/modules/place/sync/tour-api.factory.ts
@@ -0,0 +1,37 @@
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+
+import { parsePositiveInt } from '@/shared/utils/env';
+import { CallBudget } from './call-budget';
+import { TourApi, TourApiClient } from './tour-api.client';
+
+/** 개발 계정의 오퍼레이션별 하루 한도. 운영 계정 승인 후 TOUR_API_DAILY_LIMIT 로 올린다 */
+const DEFAULT_DAILY_LIMIT = 1_000;
+/** 한도 끝까지 쓰지 않고 남겨 두는 여유분 — 같은 날 점검 스크립트 등으로 확인할 여지를 둔다 */
+const SAFETY_MARGIN = 20;
+
+/** 키·한도 설정을 읽어 관광공사 클라이언트를 만든다. 테스트에서는 이 프로바이더를 바꿔 끼운다 */
+@Injectable()
+export class TourApiFactory {
+ constructor(private readonly config: ConfigService) {}
+
+ /** 이번 실행에서 쓸 오퍼레이션별 상한(하루 한도 − 여유분) */
+ dailyLimit(): number {
+ const limit = parsePositiveInt(
+ this.config.get('TOUR_API_DAILY_LIMIT'),
+ DEFAULT_DAILY_LIMIT,
+ );
+ return Math.max(0, limit - SAFETY_MARGIN);
+ }
+
+ create(budget: CallBudget): TourApi {
+ const key = this.config.get('PUBLIC_DATA_SERVICE_KEY')?.trim();
+ if (!key || /change[-_]?me/i.test(key)) {
+ // 키 값은 어떤 경우에도 출력하지 않는다
+ throw new Error(
+ 'PUBLIC_DATA_SERVICE_KEY 가 설정되지 않았습니다 — backend/.env.{환경} 에 공공데이터포털 Decoding 키를 넣으세요',
+ );
+ }
+ return new TourApiClient({ serviceKey: key, budget });
+ }
+}
diff --git a/backend/src/modules/place/sync/tour-kor.sync.ts b/backend/src/modules/place/sync/tour-kor.sync.ts
new file mode 100644
index 0000000..1d8fe6b
--- /dev/null
+++ b/backend/src/modules/place/sync/tour-kor.sync.ts
@@ -0,0 +1,357 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { IsNull, Repository } from 'typeorm';
+
+import { Place } from '../entities/place.entity';
+import type { PlaceCategory } from '../place.constants';
+import { BudgetExceededError, CallBudget } from './call-budget';
+import { SyncRunRecorder } from './sync-run.recorder';
+import type { TourApi, TourItem } from './tour-api.client';
+import { TourApiFactory } from './tour-api.factory';
+import {
+ INTRO_FIELDS,
+ SkipReason,
+ TourListPlace,
+ commonToDetail,
+ introToDetail,
+ listItemToPlace,
+} from './tour-mapper';
+
+/** 법정동 시·도 코드 — 1차 출시 지역 대구 */
+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;
+/** 상세를 받는 순서 — 한도 때문에 나뉘어도 앱에 먼저 필요한 분류부터 채운다 */
+const DETAIL_PRIORITY: PlaceCategory[] = [
+ 'nature',
+ 'attraction',
+ 'culture',
+ 'cafe',
+ 'food',
+ 'leisure',
+ 'bar',
+ 'shopping',
+ 'other',
+ 'lodging',
+];
+
+/** 목록 upsert 때 덮어쓰는 컬럼(상세·큐레이션 관련 컬럼은 건드리지 않는다) */
+const LIST_PROPS = [
+ 'sourceType',
+ 'category',
+ 'sourceCategory',
+ 'name',
+ 'addressRoad',
+ 'lat',
+ 'lng',
+ 'ldongRegnCd',
+ 'ldongSignguCd',
+ 'phone',
+ 'imageUrl',
+ 'imageThumbUrl',
+ 'imageLicense',
+ 'sourceModifiedAt',
+ 'status',
+ 'lastSeenAt',
+ 'detailSyncedAt',
+ 'updatedAt',
+] as const satisfies readonly (keyof Place)[];
+
+export interface TourKorOptions {
+ regionCode: string;
+ /** 목록만 갱신하고 상세는 받지 않는다 */
+ skipDetails?: boolean;
+}
+
+export interface TourKorResult {
+ fetched: number;
+ totalCount: number;
+ inserted: number;
+ updated: number;
+ hidden: number;
+ skipped: Partial>;
+ detailed: number;
+ detailFailed: number;
+ detailRemaining: number;
+ stoppedByBudget: boolean;
+ calls: Record;
+ errors: string[];
+}
+
+/**
+ * 관광공사 국문 장소 동기화(초기 적재 겸 전체 갱신).
+ * 1) 시·도 지역 목록을 전부 받아 place 에 upsert — 바뀐 장소는 상세를 다시 받도록 표시
+ * 2) 상세(개요·영업시간)를 한도 안에서 받는다. 남은 것은 다음 실행이 이어서 받는다
+ * 3) 목록에서 사라진 장소는 hidden (지우지 않는다)
+ */
+@Injectable()
+export class TourKorSync {
+ private readonly logger = new Logger(TourKorSync.name);
+
+ constructor(
+ @InjectRepository(Place) private readonly places: Repository,
+ private readonly recorder: SyncRunRecorder,
+ private readonly apiFactory: TourApiFactory,
+ ) {}
+
+ async run(options: TourKorOptions): Promise {
+ const { regionCode } = options;
+ const budget = new CallBudget(
+ this.apiFactory.dailyLimit(),
+ await this.recorder.usedToday(),
+ );
+ const api = this.apiFactory.create(budget);
+ const run = await this.recorder.start('tour_kor', regionCode);
+ const result: TourKorResult = {
+ fetched: 0,
+ totalCount: 0,
+ inserted: 0,
+ updated: 0,
+ hidden: 0,
+ skipped: {},
+ detailed: 0,
+ detailFailed: 0,
+ detailRemaining: 0,
+ stoppedByBudget: false,
+ calls: {},
+ errors: [],
+ };
+
+ try {
+ await this.syncList(api, regionCode, result);
+ if (!options.skipDetails && !result.stoppedByBudget) {
+ await this.syncDetails(api, budget, regionCode, result);
+ }
+ result.detailRemaining = await this.places.countBy({
+ source: 'tour',
+ ldongRegnCd: regionCode,
+ status: 'active',
+ detailSyncedAt: IsNull(),
+ });
+ } catch (error) {
+ result.errors.unshift(
+ error instanceof Error ? error.message : String(error),
+ );
+ throw error;
+ } finally {
+ result.calls = budget.spent();
+ await this.recorder.finish(run, {
+ fetched: result.fetched,
+ inserted: result.inserted,
+ updated: result.updated,
+ hidden: result.hidden,
+ calls: result.calls,
+ errors: result.errors,
+ });
+ this.report(result);
+ }
+ return result;
+ }
+
+ private async syncList(
+ api: TourApi,
+ regionCode: string,
+ result: TourKorResult,
+ ): Promise {
+ const runStart = new Date();
+ const existing = await this.places.find({
+ where: { source: 'tour' },
+ select: {
+ id: true,
+ sourceId: true,
+ sourceModifiedAt: true,
+ detailSyncedAt: true,
+ status: true,
+ ldongRegnCd: true,
+ },
+ });
+ const bySourceId = new Map(existing.map((p) => [p.sourceId, p]));
+
+ const rows: Array> = [];
+ let complete = false;
+ for (let page = 1; page <= MAX_LIST_PAGES; page += 1) {
+ let items: TourItem[];
+ try {
+ const res = await api.get('areaBasedList2', {
+ lDongRegnCd: regionCode,
+ numOfRows: LIST_PAGE_SIZE,
+ pageNo: page,
+ arrange: 'A', // 제목순 — 쪽을 넘기는 동안 순서가 바뀌지 않게
+ });
+ items = res.items;
+ result.totalCount = res.totalCount;
+ } catch (error) {
+ if (error instanceof BudgetExceededError) {
+ result.stoppedByBudget = true;
+ result.errors.push(error.message);
+ break;
+ }
+ throw error;
+ }
+ result.fetched += items.length;
+ for (const item of items) {
+ const mapped = listItemToPlace(item);
+ if ('skip' in mapped) {
+ result.skipped[mapped.skip] = (result.skipped[mapped.skip] ?? 0) + 1;
+ continue;
+ }
+ const prev = bySourceId.get(mapped.sourceId);
+ const changed =
+ !prev ||
+ prev.sourceModifiedAt?.getTime() !==
+ mapped.sourceModifiedAt?.getTime();
+ rows.push({
+ ...mapped,
+ ldongRegnCd: mapped.ldongRegnCd ?? regionCode,
+ status: 'active',
+ lastSeenAt: runStart,
+ updatedAt: runStart,
+ // 원본이 바뀌었거나 새 장소면 상세를 다시 받는다
+ detailSyncedAt: changed ? null : (prev?.detailSyncedAt ?? null),
+ });
+ if (prev) result.updated += 1;
+ else result.inserted += 1;
+ }
+ if (items.length === 0 || result.fetched >= result.totalCount) {
+ complete = true;
+ break;
+ }
+ }
+
+ await this.upsert(rows);
+
+ // 목록을 끝까지 받았을 때만 사라진 장소를 숨긴다(부분 응답으로 멀쩡한 장소를 숨기지 않게)
+ const activeBefore = existing.filter(
+ (p) => p.status === 'active' && p.ldongRegnCd === regionCode,
+ ).length;
+ if (!complete) return;
+ if (rows.length < activeBefore * MIN_COMPLETE_RATIO) {
+ result.errors.push(
+ `목록이 기존 노출 장소(${activeBefore})보다 크게 적어(${rows.length}) 숨김 처리를 건너뜀`,
+ );
+ return;
+ }
+ const hidden = await this.places
+ .createQueryBuilder()
+ .update(Place)
+ .set({ status: 'hidden' })
+ .where('source = :source', { source: 'tour' })
+ .andWhere('ldong_regn_cd = :region', { region: regionCode })
+ .andWhere('status = :active', { active: 'active' })
+ .andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', {
+ runStart,
+ })
+ .execute();
+ result.hidden = hidden.affected ?? 0;
+ }
+
+ private async upsert(rows: Partial[]): Promise {
+ const overwrite = LIST_PROPS.map(
+ (prop) =>
+ this.places.metadata.findColumnWithPropertyName(prop)!.databaseName,
+ );
+ for (let i = 0; i < rows.length; i += UPSERT_CHUNK) {
+ await this.places
+ .createQueryBuilder()
+ .insert()
+ .into(Place)
+ .values(rows.slice(i, i + UPSERT_CHUNK))
+ .orUpdate(overwrite, ['source', 'source_id'])
+ .execute();
+ }
+ }
+
+ private async syncDetails(
+ api: TourApi,
+ budget: CallBudget,
+ regionCode: string,
+ result: TourKorResult,
+ ): Promise {
+ const pending = await this.places.find({
+ where: {
+ source: 'tour',
+ ldongRegnCd: regionCode,
+ status: 'active',
+ detailSyncedAt: IsNull(),
+ },
+ select: { id: true, sourceId: true, sourceType: true, category: true },
+ });
+ pending.sort(
+ (a, b) =>
+ DETAIL_PRIORITY.indexOf(a.category) -
+ DETAIL_PRIORITY.indexOf(b.category),
+ );
+
+ let consecutiveFailures = 0;
+ for (const place of pending) {
+ const typeId = place.sourceType ?? '';
+ const needIntro = typeId in INTRO_FIELDS;
+ if (
+ !budget.canSpend('detailCommon2') ||
+ (needIntro && !budget.canSpend('detailIntro2'))
+ ) {
+ result.stoppedByBudget = true;
+ break;
+ }
+ try {
+ const common = await api.get('detailCommon2', {
+ contentId: place.sourceId,
+ });
+ const intro = needIntro
+ ? await api.get('detailIntro2', {
+ contentId: place.sourceId,
+ contentTypeId: typeId,
+ })
+ : null;
+ await this.places.update(place.id, {
+ ...commonToDetail(common.items[0] ?? {}),
+ ...(intro ? introToDetail(typeId, intro.items[0] ?? {}) : {}),
+ detailSyncedAt: new Date(),
+ });
+ result.detailed += 1;
+ consecutiveFailures = 0;
+ } catch (error) {
+ if (error instanceof BudgetExceededError) {
+ result.stoppedByBudget = true;
+ result.errors.push(error.message);
+ break;
+ }
+ result.detailFailed += 1;
+ consecutiveFailures += 1;
+ result.errors.push(
+ `${place.sourceId}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
+ result.errors.push(
+ `상세 조회가 ${MAX_CONSECUTIVE_FAILURES}번 연속 실패해 멈춤`,
+ );
+ break;
+ }
+ }
+ }
+ }
+
+ private report(r: TourKorResult): void {
+ const skipped = Object.entries(r.skipped)
+ .map(([k, v]) => `${k} ${v}`)
+ .join(', ');
+ this.logger.log(
+ `목록 ${r.fetched}/${r.totalCount}건 → 새 장소 ${r.inserted} · 갱신 ${r.updated} · 숨김 ${r.hidden}` +
+ (skipped ? ` · 건너뜀(${skipped})` : ''),
+ );
+ this.logger.log(
+ `상세 ${r.detailed}건 받음 · 실패 ${r.detailFailed} · 남은 상세 ${r.detailRemaining}` +
+ (r.stoppedByBudget
+ ? ' — 오늘 한도에 닿아 멈춤, 내일 같은 명령을 다시 실행하면 이어서 받는다'
+ : ''),
+ );
+ this.logger.log(`호출 수 ${JSON.stringify(r.calls)}`);
+ for (const e of r.errors.slice(0, 5)) this.logger.warn(e);
+ }
+}
diff --git a/backend/src/modules/place/sync/tour-mapper.spec.ts b/backend/src/modules/place/sync/tour-mapper.spec.ts
new file mode 100644
index 0000000..7f87a20
--- /dev/null
+++ b/backend/src/modules/place/sync/tour-mapper.spec.ts
@@ -0,0 +1,181 @@
+import {
+ commonToDetail,
+ introToDetail,
+ listItemToPlace,
+ petToDetail,
+ toCategory,
+} from './tour-mapper';
+
+// 2026-11-02 대구 후보표에서 실제로 본 항목을 줄인 것
+const item = (over: Record = {}) => ({
+ contentid: '126536',
+ contenttypeid: '12',
+ title: '대구 근대골목(근대로의 여행)',
+ addr1: '대구광역시 중구 달구벌대로 2029',
+ addr2: '',
+ mapx: '128.5867',
+ mapy: '35.8677',
+ lDongRegnCd: '27',
+ lDongSignguCd: '110',
+ lclsSystm1: 'VE',
+ lclsSystm2: 'VE04',
+ lclsSystm3: 'VE040100',
+ cpyrhtDivCd: 'Type1',
+ firstimage: 'http://tong.visitkorea.or.kr/a.jpg',
+ firstimage2: 'http://tong.visitkorea.or.kr/a_s.jpg',
+ tel: '053-661-2194',
+ modifiedtime: '20261015093000',
+ ...over,
+});
+
+describe('toCategory', () => {
+ it('소분류·중분류·대분류 순서로 자체 분류를 고른다', () => {
+ expect(toCategory('12', 'VE', 'VE04', 'VE040300')).toBe('nature'); // 둘레길
+ expect(toCategory('12', 'VE', 'VE04', 'VE040100')).toBe('attraction'); // 골목길
+ expect(toCategory('12', 'VE', 'VE03', 'VE030100')).toBe('nature'); // 시민공원
+ expect(toCategory('14', 'VE', 'VE07', 'VE070100')).toBe('culture'); // 박물관
+ expect(toCategory('14', 'VE', 'VE09', 'VE090300')).toBe('other'); // 도서관
+ expect(toCategory('39', 'FD', 'FD05', 'FD050100')).toBe('cafe');
+ expect(toCategory('39', 'FD', 'FD03', 'FD030100')).toBe('cafe'); // 제과
+ expect(toCategory('39', 'FD', 'FD04', 'FD040100')).toBe('bar');
+ expect(toCategory('39', 'FD', 'FD01', 'FD010100')).toBe('food');
+ expect(toCategory('38', 'SH', 'SH04', 'SH040300')).toBe('shopping'); // 사후면세점
+ expect(toCategory('32', 'AC', 'AC06', 'AC060200')).toBe('lodging');
+ expect(toCategory('12', 'NA', 'NA04', 'NA040700')).toBe('nature');
+ });
+
+ it('축제·여행코스는 장소가 아니다', () => {
+ expect(toCategory('15', 'EV', 'EV01', 'EV010200')).toBeNull();
+ expect(toCategory('25', null, null, null)).toBeNull();
+ });
+
+ it('분류 코드가 비면 관광타입으로 대신한다', () => {
+ expect(toCategory('39', null, null, null)).toBe('food');
+ expect(toCategory('99', null, null, null)).toBeNull();
+ });
+});
+
+describe('listItemToPlace', () => {
+ it('목록 항목을 place 기본 컬럼으로 바꾼다(mapx=경도, mapy=위도)', () => {
+ expect(listItemToPlace(item())).toEqual({
+ source: 'tour',
+ sourceId: '126536',
+ sourceType: '12',
+ category: 'attraction',
+ sourceCategory: 'VE040100',
+ name: '대구 근대골목(근대로의 여행)',
+ addressRoad: '대구광역시 중구 달구벌대로 2029',
+ lat: 35.8677,
+ lng: 128.5867,
+ ldongRegnCd: '27',
+ ldongSignguCd: '110',
+ phone: '053-661-2194',
+ imageUrl: 'http://tong.visitkorea.or.kr/a.jpg',
+ imageThumbUrl: 'http://tong.visitkorea.or.kr/a_s.jpg',
+ imageLicense: 'Type1',
+ sourceModifiedAt: new Date('2026-10-15T00:30:00.000Z'),
+ });
+ });
+
+ it('축제·좌표 없음·한국 밖 좌표는 건너뛴다', () => {
+ expect(
+ listItemToPlace(item({ contenttypeid: '15', lclsSystm1: 'EV' })),
+ ).toEqual({ skip: 'not_place' });
+ expect(listItemToPlace(item({ mapx: '', mapy: '' }))).toEqual({
+ skip: 'bad_coords',
+ });
+ expect(listItemToPlace(item({ mapx: '35.8', mapy: '128.5' }))).toEqual({
+ skip: 'bad_coords',
+ });
+ });
+
+ it('알 수 없는 저작권 유형은 비우고, 주소 두 줄은 이어 붙인다', () => {
+ const p = listItemToPlace(
+ item({ cpyrhtDivCd: 'Type9', addr2: '(남산동)' }),
+ );
+ expect(p).toMatchObject({
+ imageLicense: null,
+ addressRoad: '대구광역시 중구 달구벌대로 2029 (남산동)',
+ });
+ });
+});
+
+describe('commonToDetail', () => {
+ it('개요의 HTML 을 지우고 홈페이지 URL 만 꺼낸다', () => {
+ expect(
+ commonToDetail({
+ overview: '근대 건축물이 모인 골목
계산성당과 청라언덕',
+ homepage:
+ 'www.jung.daegu.kr',
+ }),
+ ).toEqual({
+ overview: '근대 건축물이 모인 골목\n계산성당과 청라언덕',
+ homepage: 'https://www.jung.daegu.kr',
+ });
+ });
+});
+
+describe('introToDetail', () => {
+ it('관광타입에 맞는 영업시간·쉬는날 필드를 읽고 해석한다(39 음식점)', () => {
+ const d = introToDetail('39', {
+ opentimefood: '11:00~21:00
(준비 시간 15:30~17:00)',
+ restdatefood: '매주 월요일',
+ parkingfood: '가능',
+ firstmenu: '찜갈비',
+ usetime: '이 값은 관광지 필드라 읽지 않는다',
+ });
+ expect(d.publicHoursText).toBe('11:00~21:00\n(준비 시간 15:30~17:00)');
+ expect(d.publicRestText).toBe('매주 월요일');
+ expect(d.publicHours?.status).toBe('parsed');
+ expect(d.publicHours?.closedWeekdays).toEqual([1]);
+ expect(d.publicExtra).toEqual({ parking: '가능', firstMenu: '찜갈비' });
+ });
+
+ it('문화시설은 usetimeculture 와 이용요금을 읽는다', () => {
+ const d = introToDetail('14', {
+ usetimeculture: '09:00~18:00',
+ restdateculture: '매주 월요일',
+ usefee: '무료',
+ });
+ expect(d.publicHoursText).toBe('09:00~18:00');
+ expect(d.publicExtra).toEqual({ fee: '무료' });
+ });
+
+ it('값이 하나도 없으면 해석하지 않는다(미확인)', () => {
+ expect(introToDetail('12', {})).toEqual({
+ publicHoursText: null,
+ publicRestText: null,
+ publicHours: null,
+ publicExtra: null,
+ });
+ });
+});
+
+describe('petToDetail', () => {
+ it('전구역·일부구역을 요약하고 원문을 남긴다', () => {
+ expect(
+ petToDetail({
+ contentid: '1',
+ acmpyTypeCd: '전구역 동반가능',
+ acmpyPsblCpam: '전 견종 동반 가능',
+ acmpyNeedMtr: '목줄 착용',
+ etcAcmpyInfo: '',
+ }),
+ ).toEqual({
+ publicPet: {
+ acmpyTypeCd: '전구역 동반가능',
+ acmpyPsblCpam: '전 견종 동반 가능',
+ acmpyNeedMtr: '목줄 착용',
+ },
+ publicPetPolicy: 'allowed',
+ });
+ expect(
+ petToDetail({ acmpyTypeCd: '일부구역 동반가능' }).publicPetPolicy,
+ ).toBe('partial');
+ });
+
+ it('표시가 애매하면 요약하지 않는다(불가로 단정하지 않음)', () => {
+ expect(petToDetail({ acmpyTypeCd: '동반불가' }).publicPetPolicy).toBeNull();
+ expect(petToDetail({ acmpyPsblCpam: '소형견' }).publicPetPolicy).toBeNull();
+ });
+});
diff --git a/backend/src/modules/place/sync/tour-mapper.ts b/backend/src/modules/place/sync/tour-mapper.ts
new file mode 100644
index 0000000..9167b9c
--- /dev/null
+++ b/backend/src/modules/place/sync/tour-mapper.ts
@@ -0,0 +1,301 @@
+import { extractUrl, htmlToText } from '@/shared/utils/html-text';
+import { parseKstTimestamp } from '@/shared/utils/kst';
+import { parseOpeningHours } from '@/shared/utils/opening-hours';
+import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
+import { IMAGE_LICENSES } from '../place.constants';
+import type {
+ ImageLicense,
+ PlaceCategory,
+ PublicPetPolicy,
+} from '../place.constants';
+import type { TourItem } from './tour-api.client';
+
+/**
+ * 관광공사 응답 → place 컬럼 변환 (순수 함수).
+ * 분류 대응은 「신분류체계정보 관광타입정보 연계 정의서」(docs/data-openapi/) 의 소분류 240개를 보고 정했다.
+ */
+
+/** 장소가 아닌 관광타입 — 15 축제·공연·행사는 festival_event, 25 여행코스는 저장하지 않는다 */
+const SKIP_TYPES = new Set(['15', '25']);
+
+/** 소분류 단위 예외 */
+const CATEGORY_BY_LCLS3: Record = {
+ VE040300: 'nature', // 둘레길
+ FD030100: 'cafe', // 제과(빵집) — 상가정보 빵/도넛과 같은 칸
+};
+
+/** 중분류 단위 */
+const CATEGORY_BY_LCLS2: Record = {
+ VE01: 'attraction', // 랜드마크(건물·전망대·다리…)
+ VE02: 'attraction', // 테마공원(테마파크·동물원·수족관…)
+ VE03: 'nature', // 도시공원 — '산책·자연' 탭
+ VE04: 'attraction', // 골목길·문화거리·마을관광지(둘레길은 위 예외)
+ VE05: 'attraction', // 관광단지·리조트
+ VE06: 'culture', // 공연장·영화관
+ VE07: 'culture', // 박물관·기념관·전시관·미술관
+ VE08: 'other', // 연회장
+ VE09: 'other', // 문화원·도서관·학교 등 교육시설
+ VE10: 'leisure', // 경기장·스포츠센터
+ VE12: 'culture', // 서점·기타문화시설
+ FD04: 'bar', // 바·펍·생맥주·전통주점
+ FD05: 'cafe', // 카페·찻집
+};
+
+/** 대분류 단위 */
+const CATEGORY_BY_LCLS1: Record = {
+ NA: 'nature', // 자연관광
+ HS: 'attraction', // 역사관광
+ VE: 'attraction', // 문화관광(위 중분류에 없는 것)
+ EX: 'leisure', // 체험관광(공예·템플스테이·웰니스…)
+ LS: 'leisure', // 레저스포츠
+ SH: 'shopping', // 쇼핑(백화점·시장·사후면세점…)
+ FD: 'food', // 음식(카페·주점·제과 제외)
+ AC: 'lodging', // 숙박
+};
+
+/** 분류 코드가 비었을 때 관광타입으로 대신한다 */
+const CATEGORY_BY_TYPE: Record = {
+ '12': 'attraction',
+ '14': 'culture',
+ '28': 'leisure',
+ '32': 'lodging',
+ '38': 'shopping',
+ '39': 'food',
+};
+
+/** 관광타입별 소개정보(detailIntro2) 필드. 32 숙박은 코스 장소가 아니라 MVP 에서는 부르지 않는다 */
+export const INTRO_FIELDS: Record<
+ string,
+ { hours: string; rest: string; extra: Record }
+> = {
+ '12': {
+ hours: 'usetime',
+ rest: 'restdate',
+ extra: {
+ parking: 'parking',
+ infocenter: 'infocenter',
+ season: 'useseason',
+ },
+ },
+ '14': {
+ hours: 'usetimeculture',
+ rest: 'restdateculture',
+ extra: {
+ fee: 'usefee',
+ parking: 'parkingculture',
+ parkingFee: 'parkingfee',
+ infocenter: 'infocenterculture',
+ spendTime: 'spendtime',
+ },
+ },
+ '28': {
+ hours: 'usetimeleports',
+ rest: 'restdateleports',
+ extra: {
+ fee: 'usefeeleports',
+ parking: 'parkingleports',
+ parkingFee: 'parkingfeeleports',
+ infocenter: 'infocenterleports',
+ openPeriod: 'openperiod',
+ reservation: 'reservation',
+ },
+ },
+ '38': {
+ hours: 'opentime',
+ rest: 'restdateshopping',
+ extra: {
+ parking: 'parkingshopping',
+ infocenter: 'infocentershopping',
+ fairDay: 'fairday',
+ saleItem: 'saleitem',
+ },
+ },
+ '39': {
+ hours: 'opentimefood',
+ rest: 'restdatefood',
+ extra: {
+ parking: 'parkingfood',
+ infocenter: 'infocenterfood',
+ firstMenu: 'firstmenu',
+ treatMenu: 'treatmenu',
+ reservation: 'reservationfood',
+ packing: 'packing',
+ },
+ },
+};
+
+/** 한국 영역 대략값 — 좌표가 0 이거나 뒤바뀐 원본을 거른다 */
+const KOREA_BOUNDS = { latMin: 33, latMax: 39.5, lngMin: 124, lngMax: 132 };
+
+function str(item: TourItem, key: string): string | null {
+ const v = item[key];
+ if (typeof v !== 'string' && typeof v !== 'number') return null;
+ const s = String(v).trim();
+ return s === '' ? null : s;
+}
+
+export function toCategory(
+ typeId: string | null,
+ lcls1: string | null,
+ lcls2: string | null,
+ lcls3: string | null,
+): PlaceCategory | null {
+ if ((typeId && SKIP_TYPES.has(typeId)) || lcls1 === 'EV') return null;
+ return (
+ (lcls3 && CATEGORY_BY_LCLS3[lcls3]) ||
+ (lcls2 && CATEGORY_BY_LCLS2[lcls2]) ||
+ (lcls1 && CATEGORY_BY_LCLS1[lcls1]) ||
+ (typeId && CATEGORY_BY_TYPE[typeId]) ||
+ null
+ );
+}
+
+export interface TourListPlace {
+ source: 'tour';
+ sourceId: string;
+ sourceType: string | null;
+ category: PlaceCategory;
+ sourceCategory: string | null;
+ name: string;
+ addressRoad: string | null;
+ lat: number;
+ lng: number;
+ ldongRegnCd: string | null;
+ ldongSignguCd: string | null;
+ phone: string | null;
+ imageUrl: string | null;
+ imageThumbUrl: string | null;
+ imageLicense: ImageLicense | null;
+ sourceModifiedAt: Date | null;
+}
+
+export type SkipReason = 'not_place' | 'no_category' | 'bad_coords' | 'no_id';
+
+/** 목록(areaBasedList2) 항목 1건 → place 기본 컬럼. 장소가 아니거나 쓸 수 없으면 사유를 돌려준다 */
+export function listItemToPlace(
+ item: TourItem,
+): TourListPlace | { skip: SkipReason } {
+ const sourceId = str(item, 'contentid');
+ const name = htmlToText(item.title);
+ if (!sourceId || !name) return { skip: 'no_id' };
+ const typeId = str(item, 'contenttypeid');
+ if (typeId && SKIP_TYPES.has(typeId)) return { skip: 'not_place' };
+ const lcls1 = str(item, 'lclsSystm1');
+ if (lcls1 === 'EV') return { skip: 'not_place' };
+ const lcls2 = str(item, 'lclsSystm2');
+ const lcls3 = str(item, 'lclsSystm3');
+ const category = toCategory(typeId, lcls1, lcls2, lcls3);
+ if (!category) return { skip: 'no_category' };
+
+ const lat = Number(str(item, 'mapy'));
+ const lng = Number(str(item, 'mapx'));
+ const b = KOREA_BOUNDS;
+ if (
+ !Number.isFinite(lat) ||
+ !Number.isFinite(lng) ||
+ lat < b.latMin ||
+ lat > b.latMax ||
+ lng < b.lngMin ||
+ lng > b.lngMax
+ ) {
+ return { skip: 'bad_coords' };
+ }
+
+ const license = str(item, 'cpyrhtDivCd');
+ const address = [str(item, 'addr1'), str(item, 'addr2')]
+ .filter(Boolean)
+ .join(' ');
+ return {
+ source: 'tour',
+ sourceId,
+ sourceType: typeId,
+ category,
+ sourceCategory: lcls3 ?? lcls2 ?? lcls1,
+ name,
+ addressRoad: address === '' ? null : address,
+ lat,
+ lng,
+ ldongRegnCd: str(item, 'lDongRegnCd'),
+ ldongSignguCd: str(item, 'lDongSignguCd'),
+ phone: htmlToText(item.tel),
+ imageUrl: str(item, 'firstimage'),
+ imageThumbUrl: str(item, 'firstimage2'),
+ imageLicense: (IMAGE_LICENSES as readonly string[]).includes(license ?? '')
+ ? (license as ImageLicense)
+ : null,
+ sourceModifiedAt: parseKstTimestamp(str(item, 'modifiedtime')),
+ };
+}
+
+export interface TourCommonDetail {
+ overview: string | null;
+ homepage: string | null;
+}
+
+/** 공통정보(detailCommon2) → 개요·홈페이지 */
+export function commonToDetail(item: TourItem): TourCommonDetail {
+ return {
+ overview: htmlToText(item.overview),
+ homepage: extractUrl(item.homepage),
+ };
+}
+
+export interface TourIntroDetail {
+ publicHoursText: string | null;
+ publicRestText: string | null;
+ publicHours: ParsedOpeningHours | null;
+ publicExtra: Record | null;
+}
+
+/** 소개정보(detailIntro2) → 영업시간·쉬는날 원문, 해석 결과, 부가 정보(요금·주차 등) */
+export function introToDetail(typeId: string, item: TourItem): TourIntroDetail {
+ const fields = INTRO_FIELDS[typeId];
+ if (!fields) {
+ return {
+ publicHoursText: null,
+ publicRestText: null,
+ publicHours: null,
+ publicExtra: null,
+ };
+ }
+ const hours = htmlToText(item[fields.hours]);
+ const rest = htmlToText(item[fields.rest]);
+ const extra: Record = {};
+ for (const [name, key] of Object.entries(fields.extra)) {
+ const v = htmlToText(item[key]);
+ if (v) extra[name] = v;
+ }
+ return {
+ publicHoursText: hours,
+ publicRestText: rest,
+ publicHours: hours || rest ? parseOpeningHours(hours, rest) : null,
+ publicExtra: Object.keys(extra).length > 0 ? extra : null,
+ };
+}
+
+export interface TourPetDetail {
+ publicPet: Record | null;
+ publicPetPolicy: PublicPetPolicy | null;
+}
+
+/**
+ * 반려동물 동반 정보(detailPetTour2) → 원문 + 요약.
+ * 관광공사 정보는 "동반 가능한 곳"만 담는다 — 표시가 애매하면 요약하지 않고(null) 원문만 둔다.
+ */
+export function petToDetail(item: TourItem): TourPetDetail {
+ const raw: Record = {};
+ for (const [key, value] of Object.entries(item)) {
+ if (key === 'contentid') continue;
+ const v = htmlToText(value);
+ if (v) raw[key] = v;
+ }
+ const type = raw.acmpyTypeCd ?? '';
+ let policy: PublicPetPolicy | null = null;
+ if (/불가/.test(type)) policy = null;
+ else if (/일부/.test(type)) policy = 'partial';
+ else if (/전구역|동반\s*가능/.test(type)) policy = 'allowed';
+ return {
+ publicPet: Object.keys(raw).length > 0 ? raw : null,
+ publicPetPolicy: policy,
+ };
+}
diff --git a/backend/src/modules/place/sync/tour-pet.sync.ts b/backend/src/modules/place/sync/tour-pet.sync.ts
new file mode 100644
index 0000000..45a6f18
--- /dev/null
+++ b/backend/src/modules/place/sync/tour-pet.sync.ts
@@ -0,0 +1,132 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { Place } from '../entities/place.entity';
+import { BudgetExceededError, CallBudget } from './call-budget';
+import { SyncRunRecorder } from './sync-run.recorder';
+import type { TourItem } from './tour-api.client';
+import { TourApiFactory } from './tour-api.factory';
+import { TourPetDetail, petToDetail } from './tour-mapper';
+
+const PAGE_SIZE = 1_000;
+const MAX_PAGES = 30; // 2026-10 전국 약 9,700건
+
+export interface TourPetResult {
+ fetched: number;
+ totalCount: number;
+ updated: number;
+ cleared: number;
+ complete: boolean;
+ calls: Record;
+}
+
+/**
+ * 관광공사 반려동물 동반 정보 반영. detailPetTour2 는 contentId 없이 부르면 전국 전체를 준다(약 10회).
+ * 받은 정보를 place(관광공사) 에 붙이고, 목록에서 빠진 장소는 정보를 지운다(끝까지 받았을 때만).
+ */
+@Injectable()
+export class TourPetSync {
+ private readonly logger = new Logger(TourPetSync.name);
+
+ constructor(
+ @InjectRepository(Place) private readonly places: Repository,
+ private readonly recorder: SyncRunRecorder,
+ private readonly apiFactory: TourApiFactory,
+ ) {}
+
+ async run(): Promise {
+ const budget = new CallBudget(
+ this.apiFactory.dailyLimit(),
+ await this.recorder.usedToday(),
+ );
+ const api = this.apiFactory.create(budget);
+ const run = await this.recorder.start('tour_pet', null);
+ const result: TourPetResult = {
+ fetched: 0,
+ totalCount: 0,
+ updated: 0,
+ cleared: 0,
+ complete: false,
+ calls: {},
+ };
+ const errors: string[] = [];
+
+ try {
+ const byId = new Map();
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
+ let items: TourItem[];
+ try {
+ const res = await api.get('detailPetTour2', {
+ numOfRows: PAGE_SIZE,
+ pageNo: page,
+ });
+ items = res.items;
+ result.totalCount = res.totalCount;
+ } catch (error) {
+ if (error instanceof BudgetExceededError) {
+ errors.push(error.message);
+ break;
+ }
+ throw error;
+ }
+ result.fetched += items.length;
+ for (const item of items) {
+ const raw = item.contentid;
+ const id =
+ typeof raw === 'string' || typeof raw === 'number'
+ ? String(raw)
+ : null;
+ if (id) byId.set(id, petToDetail(item));
+ }
+ if (items.length === 0 || result.fetched >= result.totalCount) {
+ result.complete = true;
+ break;
+ }
+ }
+
+ const places = await this.places.find({
+ where: { source: 'tour' },
+ select: {
+ id: true,
+ sourceId: true,
+ publicPet: true,
+ publicPetPolicy: true,
+ },
+ });
+ for (const place of places) {
+ const next = byId.get(place.sourceId);
+ if (!next && !result.complete) continue; // 덜 받았으면 지우지 않는다
+ const target: TourPetDetail = next ?? {
+ publicPet: null,
+ publicPetPolicy: null,
+ };
+ const same =
+ JSON.stringify(place.publicPet) ===
+ JSON.stringify(target.publicPet) &&
+ place.publicPetPolicy === target.publicPetPolicy;
+ if (same) continue;
+ await this.places.update(place.id, target);
+ if (next) result.updated += 1;
+ else result.cleared += 1;
+ }
+ } catch (error) {
+ errors.unshift(error instanceof Error ? error.message : String(error));
+ throw error;
+ } finally {
+ result.calls = budget.spent();
+ await this.recorder.finish(run, {
+ fetched: result.fetched,
+ updated: result.updated + result.cleared,
+ calls: result.calls,
+ errors,
+ });
+ this.logger.log(
+ `반려동물 정보 ${result.fetched}/${result.totalCount}건 받음 → 장소 갱신 ${result.updated} · 정보 삭제 ${result.cleared}` +
+ (result.complete ? '' : ' — 끝까지 받지 못해 삭제는 건너뜀'),
+ );
+ for (const e of errors.slice(0, 5)) this.logger.warn(e);
+ }
+ return result;
+ }
+}
diff --git a/backend/src/shared/config/env.validation.spec.ts b/backend/src/shared/config/env.validation.spec.ts
index 65ef407..01d17d1 100644
--- a/backend/src/shared/config/env.validation.spec.ts
+++ b/backend/src/shared/config/env.validation.spec.ts
@@ -32,6 +32,12 @@ describe('validateEnv', () => {
validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }),
).not.toThrow();
});
+
+ it('관광공사 API 하루 한도가 숫자가 아니면 실패한다', () => {
+ expect(() =>
+ validateEnv({ NODE_ENV: 'development', TOUR_API_DAILY_LIMIT: '천' }),
+ ).toThrow(/TOUR_API_DAILY_LIMIT 는 양의 정수/);
+ });
});
describe('운영 환경', () => {
diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts
index 674a545..8d26011 100644
--- a/backend/src/shared/config/env.validation.ts
+++ b/backend/src/shared/config/env.validation.ts
@@ -24,6 +24,8 @@ const POSITIVE_INT_KEYS = [
'DB_PORT',
'THROTTLE_TTL_MS',
'THROTTLE_LIMIT',
+ // 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령). 개발 계정 1,000 — 운영 계정 승인 후 올린다
+ 'TOUR_API_DAILY_LIMIT',
] as const;
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
diff --git a/backend/src/shared/utils/html-text.spec.ts b/backend/src/shared/utils/html-text.spec.ts
new file mode 100644
index 0000000..00e06f8
--- /dev/null
+++ b/backend/src/shared/utils/html-text.spec.ts
@@ -0,0 +1,40 @@
+import { extractUrl, htmlToText } from './html-text';
+
+describe('htmlToText', () => {
+ it('줄바꿈 태그는 줄바꿈으로, 나머지 태그는 지운다', () => {
+ expect(htmlToText('주차 가능
소형 20대
대형 2대')).toBe(
+ '주차 가능\n소형 20대\n대형 2대',
+ );
+ expect(htmlToText('무료 입장')).toBe('무료 입장');
+ });
+
+ it('자주 쓰는 HTML 엔티티를 되돌린다', () => {
+ expect(htmlToText('A & B <안내>')).toBe('A & B <안내>');
+ });
+
+ it('빈 값·공백뿐인 값은 null 이다', () => {
+ expect(htmlToText('')).toBeNull();
+ expect(htmlToText('
')).toBeNull();
+ expect(htmlToText(undefined)).toBeNull();
+ });
+});
+
+describe('extractUrl', () => {
+ it('a 태그의 href 를 꺼낸다', () => {
+ expect(
+ extractUrl(
+ 'tour.daegu.go.kr',
+ ),
+ ).toBe('https://tour.daegu.go.kr');
+ });
+
+ it('태그 없이 주소만 있으면 그대로, www 로 시작하면 https 를 붙인다', () => {
+ expect(extractUrl('http://example.kr/a')).toBe('http://example.kr/a');
+ expect(extractUrl('www.example.kr')).toBe('https://www.example.kr');
+ });
+
+ it('http(s) 가 아닌 링크는 버린다', () => {
+ expect(extractUrl('x')).toBeNull();
+ expect(extractUrl('홈페이지 없음')).toBeNull();
+ });
+});
diff --git a/backend/src/shared/utils/html-text.ts b/backend/src/shared/utils/html-text.ts
new file mode 100644
index 0000000..06bc163
--- /dev/null
+++ b/backend/src/shared/utils/html-text.ts
@@ -0,0 +1,49 @@
+/**
+ * 공공데이터 텍스트 정리 유틸 (순수 함수).
+ * 관광공사 응답에는 `
`, `` 같은 HTML 이 섞여 온다(개요·주차·홈페이지 등).
+ */
+
+const ENTITIES: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ ''': "'",
+ ''': "'",
+ ' ': ' ',
+};
+
+/**
+ * HTML 흔적을 지운 일반 텍스트를 돌려준다.
+ * `
`·`
` 는 줄바꿈으로, 나머지 태그는 지우고, 자주 쓰는 엔티티를 되돌린다. 비면 null.
+ */
+export function htmlToText(value: unknown): string | null {
+ if (typeof value !== 'string' && typeof value !== 'number') return null;
+ const text = String(value)
+ .replace(/
/gi, '\n')
+ .replace(/<\/p>/gi, '\n')
+ .replace(/<[^>]+>/g, '')
+ .replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (m) => ENTITIES[m] ?? m)
+ .replace(/[ \t]+\n/g, '\n')
+ .replace(/\n[ \t]+/g, '\n')
+ .replace(/\n{3,}/g, '\n\n')
+ .replace(/[ \t]{2,}/g, ' ')
+ .trim();
+ return text === '' ? null : text;
+}
+
+/**
+ * 홈페이지 값에서 URL 만 꺼낸다. 관광공사는 `…` 를 통째로 준다.
+ * http(s) 가 아닌 값은 버린다(javascript: 등). `www.` 로 시작하면 https 를 붙인다.
+ */
+export function extractUrl(value: unknown): string | null {
+ if (typeof value !== 'string') return null;
+ const href = /href\s*=\s*["']([^"']+)["']/i.exec(value)?.[1];
+ const raw = (
+ href ?? /(https?:\/\/[^\s"'<>]+|www\.[^\s"'<>]+)/i.exec(value)?.[1]
+ )?.trim();
+ if (!raw) return null;
+ if (/^https?:\/\//i.test(raw)) return raw;
+ if (/^www\./i.test(raw)) return `https://${raw}`;
+ return null;
+}
diff --git a/backend/src/shared/utils/kst.spec.ts b/backend/src/shared/utils/kst.spec.ts
new file mode 100644
index 0000000..b015f52
--- /dev/null
+++ b/backend/src/shared/utils/kst.spec.ts
@@ -0,0 +1,38 @@
+import { kstStartOfDay, parseKstTimestamp } from './kst';
+
+describe('kstStartOfDay', () => {
+ it('한국 날짜의 0시를 돌려준다', () => {
+ // 2026-11-02 14:00 KST = 05:00 UTC → 그날 0시 KST = 11-01 15:00 UTC
+ expect(kstStartOfDay(new Date('2026-11-02T05:00:00Z')).toISOString()).toBe(
+ '2026-11-01T15:00:00.000Z',
+ );
+ });
+
+ it('UTC 로는 전날이어도 한국 날짜가 바뀌었으면 새 날로 본다', () => {
+ // 2026-11-02 00:30 KST = 11-01 15:30 UTC
+ expect(kstStartOfDay(new Date('2026-11-01T15:30:00Z')).toISOString()).toBe(
+ '2026-11-01T15:00:00.000Z',
+ );
+ });
+});
+
+describe('parseKstTimestamp', () => {
+ it('14자리 한국 시간을 해석한다', () => {
+ expect(parseKstTimestamp('20261102143000')?.toISOString()).toBe(
+ '2026-11-02T05:30:00.000Z',
+ );
+ });
+
+ it('8자리 날짜는 그날 0시(KST)로 본다', () => {
+ expect(parseKstTimestamp('20261102')?.toISOString()).toBe(
+ '2026-11-01T15:00:00.000Z',
+ );
+ });
+
+ it('형식이 다르거나 없는 날짜면 null', () => {
+ expect(parseKstTimestamp('2026-11-02')).toBeNull();
+ expect(parseKstTimestamp('20260230')).toBeNull();
+ expect(parseKstTimestamp('')).toBeNull();
+ expect(parseKstTimestamp(null)).toBeNull();
+ });
+});
diff --git a/backend/src/shared/utils/kst.ts b/backend/src/shared/utils/kst.ts
new file mode 100644
index 0000000..302ec38
--- /dev/null
+++ b/backend/src/shared/utils/kst.ts
@@ -0,0 +1,36 @@
+/**
+ * 한국 시간(KST, UTC+9) 유틸 (순수 함수). 한국은 서머타임이 없어 고정 오프셋으로 계산한다.
+ */
+
+const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
+
+/** 주어진 시각이 속한 한국 날짜의 0시(KST)를 Date 로 돌려준다 */
+export function kstStartOfDay(now: Date): Date {
+ const kst = new Date(now.getTime() + KST_OFFSET_MS);
+ const midnightUtc = Date.UTC(
+ kst.getUTCFullYear(),
+ kst.getUTCMonth(),
+ kst.getUTCDate(),
+ );
+ return new Date(midnightUtc - KST_OFFSET_MS);
+}
+
+/**
+ * 'YYYYMMDD' 또는 'YYYYMMDDhhmmss'(한국 시간) 문자열을 Date 로 바꾼다.
+ * 관광공사 modifiedtime·createdtime 형식이다. 형식이 다르거나 없는 날짜면 null.
+ */
+export function parseKstTimestamp(value: unknown): Date | null {
+ if (typeof value !== 'string' && typeof value !== 'number') return null;
+ const m = /^(\d{4})(\d{2})(\d{2})(?:(\d{2})(\d{2})(\d{2}))?$/.exec(
+ String(value).trim(),
+ );
+ if (!m) return null;
+ const [y, mo, d, h, mi, s] = m.slice(1).map((v) => Number(v ?? 0));
+ if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) {
+ return null;
+ }
+ const date = new Date(Date.UTC(y, mo - 1, d, h, mi, s) - KST_OFFSET_MS);
+ // 2월 30일처럼 넘어가는 날짜는 거른다
+ const back = new Date(date.getTime() + KST_OFFSET_MS);
+ return back.getUTCDate() === d ? date : null;
+}
diff --git a/docs/design/place-db.md b/docs/design/place-db.md
index 318c7a4..e39e545 100644
--- a/docs/design/place-db.md
+++ b/docs/design/place-db.md
@@ -1,7 +1,7 @@
-# 장소 DB 설계 (v1.1, 2026-11-02)
+# 장소 DB 설계 (v1.2, 2026-11-02)
WanderGo 의 장소·축제 데이터를 어떻게 저장하고, 갱신하고, 앱에 노출하는지 정한다.
-1단계(엔티티·마이그레이션)를 구현했다 — `backend/src/modules/place/`, `backend/src/migrations/1793597729768-AddPlaceTables.ts`.
+1단계(엔티티·마이그레이션)와 2단계(관광공사 국문·반려동물 동기화 명령)를 구현했다 — `backend/src/modules/place/`, `backend/src/commands/place-sync.ts`, `backend/src/migrations/`.
이 문서와 코드가 다르면 코드가 기준이다. 바꿀 때는 이 문서도 함께 고친다.
## 0. 전제 (이미 정한 것)
@@ -88,6 +88,7 @@ erDiagram
| `source_id` | text | contentid / bizesId. **`(source, source_id)` 유니크** — 동기화·큐레이션 가져오기의 연결 키 |
| `category` | text | 자체 분류(§2.6). 검색 탭·AI 슬롯에 쓴다 |
| `source_category` | text | 원본 코드(lclsSystm3 / indsSclsCd) — 분류 규칙을 바꿔도 다시 계산할 수 있게 |
+| `source_type` | text | 관광공사 관광타입(contenttypeid). 소개정보(detailIntro2) 조회에 필요하다 |
| `name`, `branch_name` | text | 한국어 이름, 지점명(상가정보 brchNm) |
| `address_road`, `address_jibun` | text | |
| `lat`, `lng` | double precision | WGS84. §5 위치 검색 |
@@ -105,6 +106,7 @@ erDiagram
| `source_modified_at` | timestamptz | 관광공사 modifiedtime |
| `source_snapshot` | text | 상가정보 기준년월(stdrYm) |
| `last_seen_at` | timestamptz | 마지막 동기화에서 확인된 시각 |
+| `detail_synced_at` | timestamptz | 상세(개요·영업시간)를 받은 시각. null 이면 아직 못 받았거나 원본이 바뀌어 다시 받아야 한다 — 하루 한도로 나뉜 적재를 이어서 받는 기준 |
| `created_at`, `updated_at` | timestamptz | |
- **지우지 않는다.** 사라진 장소는 `status=gone` 으로 숨긴다. 큐레이션과 코스가 그 장소를 가리키고 있을 수 있다.
@@ -172,19 +174,25 @@ erDiagram
검색 탭(관광지 · 축제 · 맛집·카페 · 숙소 · 산책·자연)과 AI 코스 슬롯을 이 값으로 고른다.
-| category | 관광공사(lclsSystm1 기준) | 상가정보 | 검색 탭 |
-| --- | --- | --- | --- |
-| `nature` | NA | — | 산책·자연 |
-| `attraction` | HS, VE(문화시설 제외) | — | 관광지 |
-| `culture` | VE07(문화시설) | — | 관광지 |
-| `leisure` | LS | — | 관광지 |
-| `food` | FD(카페 제외) | 그 외 음식 업종(지금은 적재하지 않음) | 맛집·카페 |
-| `cafe` | FD05 | I21201 카페 · I21001 빵/도넛 · I21008 아이스크림/빙수 · I21005 샌드위치/샐러드 | 맛집·카페 |
-| `bar` | — | I21104 요리 주점 · I21103 생맥주 | 맛집·카페 |
-| `shopping` | SH | — | 관광지 |
-| `lodging` | AC | — | 숙소 |
+관광공사 대응은 「신분류체계정보 관광타입정보 연계 정의서」(`docs/data-openapi/`, 소분류 240개)를 보고 확정했다.
+**소분류 → 중분류 → 대분류 → 관광타입** 순서로 찾는다. 코드는 `backend/src/modules/place/sync/tour-mapper.ts` 가 기준이다.
-- 위 대응은 2026-11-02 표본에서 본 코드로 만든 것이다. `lclsSystmCode2` 로 전체 코드표를 받아 확정한다.
+| category | 관광공사 | 상가정보 | 검색 탭 | AI 2순위 |
+| --- | --- | --- | --- | --- |
+| `nature` | NA 자연관광 전체 · VE03 도시공원 · VE040300 둘레길 | — | 산책·자연 | O |
+| `attraction` | HS 역사관광 · VE01 랜드마크 · VE02 테마공원 · VE04 골목길·문화거리·마을 · VE05 관광단지 | — | 관광지 | O |
+| `culture` | VE06 공연장·영화관 · VE07 박물관·전시관·미술관 · VE12 서점·기타문화시설 | — | 관광지 | O |
+| `leisure` | LS 레저스포츠 · EX 체험관광(공예·템플스테이·웰니스) · VE10 경기장·스포츠센터 | — | 관광지 | O |
+| `food` | FD 음식(아래 제외) | 그 외 음식 업종(지금은 적재하지 않음) | 맛집·카페 | O |
+| `cafe` | FD05 카페·찻집 · FD030100 제과 | I21201 카페 · I21001 빵/도넛 · I21008 아이스크림/빙수 · I21005 샌드위치/샐러드 | 맛집·카페 | O(관광공사만) |
+| `bar` | FD04 바·펍·주점 | I21104 요리 주점 · I21103 생맥주 | 맛집·카페 | — |
+| `shopping` | SH 쇼핑(백화점·쇼핑몰·시장·사후면세점…) | — | 관광지 | — |
+| `lodging` | AC 숙박 | — | 숙소 | — |
+| `other` | VE08 연회장 · VE09 문화원·도서관·학교 | — | (전체에만) | — |
+
+- 저장하지 않는 것: EV 축제·공연·행사(→ `festival_event`), 관광타입 25 여행코스, 좌표가 없거나 한국 밖인 항목.
+- 2026-11-02 대구 도심 쇼핑 135곳 대부분이 `SH040300` **사후면세점**(더현대 입점 브랜드 매장)이었다. 쇼핑은 큐레이션으로만 AI 후보가 된다.
+- 분류 코드가 비면 관광타입(12 관광지·14 문화시설·28 레포츠·32 숙박·38 쇼핑·39 음식점)으로 대신한다.
## 3. "앱이 보는 값" — `place_listing` 뷰
@@ -230,6 +238,27 @@ erDiagram
- 비표출 레코드는 법정동이 비어 있으므로 비표출 확인은 지역 필터 없이 한다(매뉴얼 검토 노트).
- 동기화는 `place` 만 쓴다. `place_curation` 은 읽지도 쓰지도 않는다.
+### 4.1 실행 방법 (구현됨: 관광공사 국문·반려동물)
+
+```bash
+# 개발 — 키가 있는 백엔드 컨테이너 안에서 (dc = docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development)
+dc exec backend npm run migration:run # 스키마 먼저
+dc exec backend npm run place:sync -- tour-kor # 대구(27) 목록 → 상세. 한도에 닿으면 멈춘다
+dc exec backend npm run place:sync -- tour-pet # 반려동물 동반 정보(약 10회)
+dc exec backend npm run place:sync -- status # 적재 현황·오늘 쓴 호출 수
+# 다음 날 같은 tour-kor 명령을 다시 실행하면 남은 상세를 이어서 받는다
+
+# 운영 — cron
+node dist/commands/place-sync.js tour-kor
+```
+
+- 한도: `TOUR_API_DAILY_LIMIT`(비우면 1,000) − 여유분 20. 오늘(KST) `place_sync_run` 에 기록된 호출부터 센다.
+ 같은 키를 점검 스크립트 등 다른 곳에서 쓴 호출은 기록에 없으므로, 서버가 한도 초과(코드 22)로 거부하면 그때 멈춘다.
+- 상세는 nature → attraction → culture → cafe → food … 순서로 받는다(이틀에 나뉘어도 앱에 먼저 필요한 것부터).
+- 목록을 끝까지 받았을 때만 사라진 장소를 숨기고, 목록이 기존 노출 장소의 80% 미만이면 원본 장애로 보고 숨기지 않는다.
+- 원본 수정일(modifiedtime)이 바뀐 장소는 `detail_synced_at` 을 비워 상세를 다시 받는다.
+- 2026-11-02 임시 DB + 가짜 API 로 확인한 흐름: 한도 3회로 멈춤 → 같은 날 재실행 시 추가 호출 없음 → 다음 날 이어받기 → 수정 1곳만 재조회·사라진 1곳 숨김 → 반려동물 반영.
+
## 5. 위치 검색
- `lat`·`lng` double precision + 인덱스. 조회는 "경계 사각형으로 1차 필터 → 하버사인 거리로 정렬".
@@ -265,7 +294,6 @@ erDiagram
| 항목 | 언제 |
| --- | --- |
-| `lclsSystm` 전체 코드표로 §2.6 분류 확정 | 엔티티 작성 전 (`lclsSystmCode2` 1회 호출) |
| `areaBasedSyncList2` 의 modifiedtime 이 "그날"인지 "그 이후"인지 | 변경분 동기화 첫 실행 |
| 영문 ↔ 국문 매칭률 | 영문 첫 적재 |
| Type3(변경금지) 이미지를 카드에서 잘라 보여 주는 것이 '변경'인지 | 화면 구현 전 (공공누리 기준) |
@@ -275,7 +303,7 @@ erDiagram
## 10. 구현 순서 (제안)
1. ✅ 엔티티 + 마이그레이션: `place`, `place_curation`, `place_i18n`, `festival_event`, `place_sync_run`, `place_listing` 뷰 (2026-11-02)
-2. 관광공사 국문 초기 적재 명령(대구) — 개발 계정으로 2일
+2. ✅ 관광공사 국문 적재 명령(대구) + 반려동물 동기화 + 현황 명령 (2026-11-02, 실제 적재는 개발 계정으로 2일)
3. 상가정보 적재 명령(대상 행정동)
4. 큐레이션 CSV 가져오기 명령(미리보기 → 반영)
5. 조회 API: `GET /api/places`(검색·필터·커서 페이지네이션), `GET /api/places/{id}`, `GET /api/events`