# 축제-펜션 연동 시스템 설계 문서
> CastAD 플랫폼에 전국 축제 정보와 펜션 데이터를 연동하여, 축제 기반 숙박 마케팅 플랫폼으로 확장하는 기능 설계서
**작성일**: 2024-12-08
**버전**: 1.0
**상태**: 설계 단계
---
## 목차
1. [개요](#1-개요)
2. [핵심 기능](#2-핵심-기능)
3. [외부 API 연동](#3-외부-api-연동)
4. [데이터베이스 설계](#4-데이터베이스-설계)
5. [백엔드 서비스 설계](#5-백엔드-서비스-설계)
6. [API 엔드포인트](#6-api-엔드포인트)
7. [프론트엔드 설계](#7-프론트엔드-설계)
8. [AI 콘텐츠 융합](#8-ai-콘텐츠-융합)
9. [구현 로드맵](#9-구현-로드맵)
10. [기술 스택](#10-기술-스택)
---
## 1. 개요
### 1.1 배경
현재 CastAD는 펜션 홍보 영상 생성에 특화된 플랫폼입니다. 여기에 전국 축제 정보를 연동하면:
- **펜션 주인**: 인근 축제와 연계한 마케팅 콘텐츠 자동 생성
- **여행객**: 축제 + 숙소를 한 번에 찾는 원스톱 서비스
- **플랫폼**: 축제 시즌 트래픽 증가, 펜션 가입 유도
### 1.2 목표
1. 전국 축제 데이터 실시간 연동 (TourAPI)
2. 전국 펜션 마스터 DB 구축 (~10,000개)
3. 위치 기반 축제-펜션 자동 매칭
4. 랜딩페이지에서 축제/펜션 노출로 가입 유도
5. AI 콘텐츠(가사, 영상)에 축제 정보 융합
### 1.3 사용자 시나리오
```
[시나리오 A: 축제 → 펜션]
1. 방문자가 "보령 머드축제" 검색
2. 축제 상세페이지에서 "주변 숙소" 탭 확인
3. 차량 30분 거리 펜션 목록 표시
4. 펜션 클릭 → 홍보 영상 시청 → 예약 링크 이동
[시나리오 B: 펜션 → 축제]
1. 펜션 주인이 "가평 OO펜션" 등록
2. 시스템이 자동으로 인근 축제 탐지
3. "자라섬 재즈페스티벌" (10월) 매칭
4. 홍보 영상에 "재즈도 즐기고 펜션에서 힐링~" 자동 생성
[시나리오 C: 랜딩페이지 가입 유도]
1. 첫 방문자가 랜딩페이지 접속
2. "이번 달 전국 축제" 섹션 확인
3. 관심 지역 클릭 → 해당 지역 펜션 목록
4. "우리 펜션도 등록하기" CTA → 회원가입 유도
```
---
## 2. 핵심 기능
### 2.1 기능 목록
| 기능 | 설명 | 우선순위 |
|-----|------|---------|
| 축제 데이터 동기화 | TourAPI에서 전국 축제 정보 수집/갱신 | P0 |
| 펜션 마스터 DB | 공공데이터 기반 전국 펜션 DB 구축 | P0 |
| 위치 기반 매칭 | GPS 좌표로 축제-펜션 거리 계산 및 매칭 | P0 |
| 지역별 펜션 목록 | 시/도/군별 펜션 검색 및 표시 | P1 |
| 축제 캘린더 | 월별/지역별 축제 일정 표시 | P1 |
| 펜션 클레임 | 펜션 주인이 본인 펜션 인증/등록 | P1 |
| AI 콘텐츠 융합 | 축제 정보를 가사/영상에 자동 반영 | P2 |
| 축제 검색 | 키워드/지역/기간별 축제 검색 | P2 |
### 2.2 매칭 기준
```
[동일 지역 매칭 - same_region]
- 같은 시/도 (예: 경기도 가평군 ↔ 경기도 가평군)
- 같은 시/군/구
[근거리 매칭 - nearby]
- 0~15km: "차량 15분 거리" ⭐⭐⭐ (최우선 추천)
- 15~30km: "차량 30분 거리" ⭐⭐
- 30~50km: "차량 50분 거리" ⭐
- 50~80km: "차량 1시간 거리"
```
---
## 3. 외부 API 연동
### 3.1 필요한 API 키
| 서비스 | 용도 | 발급처 | 비용 |
|-------|------|-------|------|
| TourAPI 4.0 | 축제/숙박 데이터 | [공공데이터포털](https://www.data.go.kr/data/15101578/openapi.do) | 무료 |
| 카카오 Local API | 주소 → 좌표 변환 | [카카오 개발자](https://developers.kakao.com/) | 무료 (일 30만건) |
| 카카오맵 API | 지도 표시 | 위와 동일 | 무료 |
### 3.2 TourAPI 엔드포인트
```
Base URL: http://apis.data.go.kr/B551011/KorService1
[축제/행사 관련]
GET /searchFestival - 축제 목록 조회
GET /detailCommon - 축제 상세 정보
GET /detailIntro - 축제 소개 정보
GET /detailImage - 축제 이미지
[숙박 관련]
GET /searchStay - 숙박시설 조회
GET /areaBasedList - 지역기반 관광정보
GET /locationBasedList - 위치기반 관광정보
[코드 조회]
GET /areaCode - 지역코드 조회
GET /sigunguCode - 시군구코드 조회
```
### 3.3 TourAPI 호출 예시
```javascript
// 축제 조회
const festivalParams = {
serviceKey: process.env.TOURAPI_KEY,
numOfRows: 100,
pageNo: 1,
MobileOS: 'ETC',
MobileApp: 'CastAD',
_type: 'json',
listYN: 'Y',
arrange: 'A',
eventStartDate: '20241201', // YYYYMMDD
eventEndDate: '20241231',
areaCode: '31', // 경기도
};
// 숙박 조회 (펜션)
const stayParams = {
...commonParams,
contentTypeId: '32', // 숙박
cat3: 'B02010700', // 펜션
};
```
### 3.4 카카오 Geocoding API
```javascript
// 주소 → 좌표 변환
const response = await axios.get(
'https://dapi.kakao.com/v2/local/search/address.json',
{
params: { query: '경기도 가평군 청평면 호반로 1234' },
headers: { Authorization: `KakaoAK ${KAKAO_REST_KEY}` }
}
);
// 응답: { x: 127.123, y: 37.456 } (경도, 위도)
```
### 3.5 공공데이터 - 전국 펜션 데이터
| 데이터셋 | URL | 형태 |
|---------|-----|------|
| 전국관광펜션업소표준데이터 | [링크](https://www.data.go.kr/data/15013105/standard.do) | CSV |
| 행정안전부 숙박업 | [링크](https://www.data.go.kr/data/15044968/fileData.do) | CSV |
| LOCALDATA | [링크](https://www.localdata.go.kr) | API |
---
## 4. 데이터베이스 설계
### 4.1 ERD 개요
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ festivals │ │ pension_festival │ │ public_pensions │
│ │ │ _matches │ │ │
│ id (PK) │◄────│ festival_id │ │ id (PK) │
│ content_id │ │ pension_id │────►│ source │
│ title │ │ distance_km │ │ name │
│ addr1 │ │ match_type │ │ address │
│ mapx, mapy │ │ travel_time_min │ │ mapx, mapy │
│ event_start │ └─────────────────┘ │ sido, sigungu │
│ event_end │ │ is_claimed │
│ ... │ ┌─────────────────┐ │ claimed_by │
└─────────────────┘ │ pension_profiles │ └─────────────────┘
│ │ ▲
│ id (PK) │ │
│ user_id │──────────────┘
│ public_pension │ (연결)
│ _id (FK) │
│ brand_name │
│ address │
│ mapx, mapy │
└─────────────────┘
```
### 4.2 테이블 정의
#### 4.2.1 festivals (축제 정보)
```sql
CREATE TABLE festivals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- TourAPI 식별자
content_id TEXT UNIQUE NOT NULL,
content_type_id TEXT DEFAULT '15', -- 15: 축제/행사
-- 기본 정보
title TEXT NOT NULL, -- 축제명
overview TEXT, -- 축제 소개
-- 주소 정보
addr1 TEXT, -- 주소
addr2 TEXT, -- 상세주소
zipcode TEXT, -- 우편번호
-- 지역 코드
area_code TEXT, -- 시도 코드
sigungu_code TEXT, -- 시군구 코드
sido TEXT, -- 시도명
sigungu TEXT, -- 시군구명
-- 좌표
mapx REAL, -- 경도 (longitude)
mapy REAL, -- 위도 (latitude)
-- 기간
event_start_date TEXT, -- 시작일 (YYYYMMDD)
event_end_date TEXT, -- 종료일 (YYYYMMDD)
-- 이미지
first_image TEXT, -- 대표이미지
first_image2 TEXT, -- 썸네일
-- 연락처
tel TEXT, -- 전화번호
homepage TEXT, -- 홈페이지
-- 상세 정보
place TEXT, -- 개최장소
place_info TEXT, -- 장소안내
play_time TEXT, -- 공연시간
program TEXT, -- 행사프로그램
use_fee TEXT, -- 이용요금
age_limit TEXT, -- 관람가능연령
-- 주최/주관
sponsor1 TEXT, -- 주최자
sponsor1_tel TEXT, -- 주최자 연락처
sponsor2 TEXT, -- 주관사
sponsor2_tel TEXT, -- 주관사 연락처
-- 부대정보
sub_event TEXT, -- 부대행사
booking_place TEXT, -- 예매처
-- 시스템
is_active INTEGER DEFAULT 1, -- 활성화 여부
view_count INTEGER DEFAULT 0, -- 조회수
last_synced_at TEXT, -- 마지막 동기화
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스
CREATE INDEX idx_festivals_area ON festivals(area_code, sigungu_code);
CREATE INDEX idx_festivals_date ON festivals(event_start_date, event_end_date);
CREATE INDEX idx_festivals_coords ON festivals(mapx, mapy);
CREATE INDEX idx_festivals_active ON festivals(is_active, event_end_date);
```
#### 4.2.2 public_pensions (전국 펜션 마스터)
```sql
CREATE TABLE public_pensions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- 데이터 출처
source TEXT NOT NULL, -- 'PENSION_STD' | 'TOURAPI' | 'LOCALDATA'
source_id TEXT, -- 원본 데이터 ID
content_id TEXT, -- TourAPI contentid
-- 기본 정보
name TEXT NOT NULL, -- 펜션명
name_normalized TEXT, -- 검색용 정규화 이름
-- 주소 정보
address TEXT, -- 전체 주소
road_address TEXT, -- 도로명 주소
jibun_address TEXT, -- 지번 주소
zipcode TEXT, -- 우편번호
-- 지역 코드
sido TEXT, -- 시도 (서울특별시, 경기도 등)
sigungu TEXT, -- 시군구
eupmyeondong TEXT, -- 읍면동
area_code TEXT, -- TourAPI 지역코드
sigungu_code TEXT, -- TourAPI 시군구코드
-- 좌표
mapx REAL, -- 경도 (longitude)
mapy REAL, -- 위도 (latitude)
-- 연락처
tel TEXT, -- 전화번호
homepage TEXT, -- 홈페이지
-- 이미지
thumbnail TEXT, -- 대표이미지 URL
images TEXT, -- 추가이미지 JSON 배열
-- 숙박 상세 (TourAPI)
checkin_time TEXT, -- 체크인 시간
checkout_time TEXT, -- 체크아웃 시간
room_count INTEGER, -- 객실 수
room_type TEXT, -- 객실유형
facilities TEXT, -- 부대시설 JSON
parking TEXT, -- 주차정보
reservation_url TEXT, -- 예약 URL
-- 특성
pet_allowed INTEGER DEFAULT 0, -- 반려동물 가능
pickup_available INTEGER DEFAULT 0, -- 픽업 가능
cooking_available INTEGER DEFAULT 0, -- 취사 가능
barbecue_available INTEGER DEFAULT 0, -- 바베큐 가능
-- 영업 상태 (LOCALDATA)
business_status TEXT DEFAULT '영업중', -- 영업중, 폐업, 휴업
license_date TEXT, -- 인허가일
closure_date TEXT, -- 폐업일
-- 소유권
is_verified INTEGER DEFAULT 0, -- 정보 검증 완료
is_claimed INTEGER DEFAULT 0, -- 소유자 인증 완료
claimed_by INTEGER, -- 인증한 user_id
claimed_at TEXT, -- 인증 일시
-- 통계
view_count INTEGER DEFAULT 0, -- 조회수
-- 타임스탬프
last_synced_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (claimed_by) REFERENCES users(id)
);
-- 인덱스
CREATE INDEX idx_public_pensions_sido ON public_pensions(sido);
CREATE INDEX idx_public_pensions_sigungu ON public_pensions(sido, sigungu);
CREATE INDEX idx_public_pensions_coords ON public_pensions(mapx, mapy);
CREATE INDEX idx_public_pensions_name ON public_pensions(name_normalized);
CREATE INDEX idx_public_pensions_source ON public_pensions(source, source_id);
CREATE INDEX idx_public_pensions_claimed ON public_pensions(is_claimed, claimed_by);
CREATE UNIQUE INDEX idx_public_pensions_unique ON public_pensions(source, source_id);
```
#### 4.2.3 pension_festival_matches (펜션-축제 매칭)
```sql
CREATE TABLE pension_festival_matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- 연결
pension_id INTEGER NOT NULL, -- public_pensions.id 또는 pension_profiles.id
pension_type TEXT DEFAULT 'public', -- 'public' | 'profile'
festival_id INTEGER NOT NULL,
-- 거리 정보
distance_km REAL, -- 직선 거리 (km)
travel_time_min INTEGER, -- 예상 이동시간 (분)
-- 매칭 유형
match_type TEXT, -- 'same_region' | 'nearby'
match_score INTEGER DEFAULT 0, -- 매칭 점수 (0-100)
-- 추천
is_featured INTEGER DEFAULT 0, -- 추천 여부
-- 타임스탬프
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (festival_id) REFERENCES festivals(id),
UNIQUE(pension_id, pension_type, festival_id)
);
-- 인덱스
CREATE INDEX idx_matches_pension ON pension_festival_matches(pension_id, pension_type);
CREATE INDEX idx_matches_festival ON pension_festival_matches(festival_id);
CREATE INDEX idx_matches_distance ON pension_festival_matches(distance_km);
```
#### 4.2.4 area_codes (지역 코드)
```sql
-- TourAPI 지역 코드
CREATE TABLE area_codes (
code TEXT PRIMARY KEY,
name TEXT NOT NULL, -- 서울특별시, 경기도 등
name_short TEXT, -- 서울, 경기 등
name_en TEXT -- Seoul, Gyeonggi 등
);
-- 시군구 코드
CREATE TABLE sigungu_codes (
area_code TEXT NOT NULL,
sigungu_code TEXT NOT NULL,
name TEXT NOT NULL, -- 강남구, 수원시 등
name_en TEXT,
PRIMARY KEY (area_code, sigungu_code),
FOREIGN KEY (area_code) REFERENCES area_codes(code)
);
```
#### 4.2.5 pension_claims (펜션 인증 요청)
```sql
CREATE TABLE pension_claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_pension_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
-- 인증 정보
status TEXT DEFAULT 'pending', -- pending, approved, rejected
verification_method TEXT, -- phone, email, document
verification_data TEXT, -- JSON (전화번호, 사업자등록증 등)
-- 처리 정보
reviewed_by INTEGER, -- 처리한 관리자
reviewed_at TEXT,
reject_reason TEXT,
-- 타임스탬프
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (public_pension_id) REFERENCES public_pensions(id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (reviewed_by) REFERENCES users(id)
);
```
#### 4.2.6 pension_profiles 테이블 수정
```sql
-- 기존 pension_profiles에 컬럼 추가
ALTER TABLE pension_profiles ADD COLUMN mapx REAL;
ALTER TABLE pension_profiles ADD COLUMN mapy REAL;
ALTER TABLE pension_profiles ADD COLUMN area_code TEXT;
ALTER TABLE pension_profiles ADD COLUMN sigungu_code TEXT;
ALTER TABLE pension_profiles ADD COLUMN public_pension_id INTEGER
REFERENCES public_pensions(id);
```
### 4.3 지역 코드 초기 데이터
```sql
INSERT INTO area_codes (code, name, name_short, name_en) VALUES
('1', '서울특별시', '서울', 'Seoul'),
('2', '인천광역시', '인천', 'Incheon'),
('3', '대전광역시', '대전', 'Daejeon'),
('4', '대구광역시', '대구', 'Daegu'),
('5', '광주광역시', '광주', 'Gwangju'),
('6', '부산광역시', '부산', 'Busan'),
('7', '울산광역시', '울산', 'Ulsan'),
('8', '세종특별자치시', '세종', 'Sejong'),
('31', '경기도', '경기', 'Gyeonggi'),
('32', '강원특별자치도', '강원', 'Gangwon'),
('33', '충청북도', '충북', 'Chungbuk'),
('34', '충청남도', '충남', 'Chungnam'),
('35', '경상북도', '경북', 'Gyeongbuk'),
('36', '경상남도', '경남', 'Gyeongnam'),
('37', '전북특별자치도', '전북', 'Jeonbuk'),
('38', '전라남도', '전남', 'Jeonnam'),
('39', '제주특별자치도', '제주', 'Jeju');
```
---
## 5. 백엔드 서비스 설계
### 5.1 파일 구조
```
server/
├── services/
│ ├── festivalService.js # 축제 API 연동
│ ├── pensionDataService.js # 펜션 데이터 수집
│ ├── matchingService.js # 축제-펜션 매칭
│ ├── geocodingService.js # 주소-좌표 변환
│ └── tourApiClient.js # TourAPI 클라이언트
├── scripts/
│ ├── syncFestivals.js # 축제 동기화 스크립트
│ ├── syncPensions.js # 펜션 동기화 스크립트
│ └── initAreaCodes.js # 지역코드 초기화
├── routes/
│ ├── festivalRoutes.js # 축제 API 라우트
│ └── publicPensionRoutes.js # 공개 펜션 API 라우트
└── cron/
└── syncJob.js # 정기 동기화 작업
```
### 5.2 핵심 서비스
#### 5.2.1 TourAPI 클라이언트
```javascript
// server/services/tourApiClient.js
const axios = require('axios');
const BASE_URL = 'http://apis.data.go.kr/B551011/KorService1';
class TourApiClient {
constructor(serviceKey) {
this.serviceKey = serviceKey;
this.client = axios.create({
baseURL: BASE_URL,
timeout: 30000,
});
}
async request(endpoint, params = {}) {
const response = await this.client.get(endpoint, {
params: {
serviceKey: this.serviceKey,
MobileOS: 'ETC',
MobileApp: 'CastAD',
_type: 'json',
...params,
},
});
const body = response.data.response?.body;
if (!body) {
throw new Error('Invalid API response');
}
return {
items: body.items?.item || [],
totalCount: body.totalCount,
pageNo: body.pageNo,
numOfRows: body.numOfRows,
};
}
// 축제 목록 조회
async searchFestival(params) {
return this.request('/searchFestival', {
listYN: 'Y',
arrange: 'A',
...params,
});
}
// 숙박시설 조회
async searchStay(params) {
return this.request('/searchStay', {
listYN: 'Y',
arrange: 'A',
...params,
});
}
// 위치기반 조회
async locationBasedList(mapX, mapY, radius = 10000) {
return this.request('/locationBasedList', {
mapX,
mapY,
radius, // 미터 단위
listYN: 'Y',
arrange: 'E', // 거리순
});
}
// 상세 정보 조회
async detailCommon(contentId) {
return this.request('/detailCommon', {
contentId,
defaultYN: 'Y',
firstImageYN: 'Y',
addrinfoYN: 'Y',
mapinfoYN: 'Y',
overviewYN: 'Y',
});
}
// 소개 정보 조회 (축제 상세)
async detailIntro(contentId, contentTypeId = '15') {
return this.request('/detailIntro', {
contentId,
contentTypeId,
});
}
// 지역코드 조회
async getAreaCodes() {
return this.request('/areaCode', { numOfRows: 50 });
}
// 시군구코드 조회
async getSigunguCodes(areaCode) {
return this.request('/areaCode', {
areaCode,
numOfRows: 100,
});
}
}
module.exports = TourApiClient;
```
#### 5.2.2 축제 서비스
```javascript
// server/services/festivalService.js
const TourApiClient = require('./tourApiClient');
const db = require('../db');
class FestivalService {
constructor() {
this.tourApi = new TourApiClient(process.env.TOURAPI_KEY);
}
// 축제 데이터 동기화
async syncFestivals(options = {}) {
const {
areaCode = null,
startDate = this.getTodayString(),
endDate = this.getDateAfterMonths(6),
} = options;
console.log(`축제 동기화 시작: ${startDate} ~ ${endDate}`);
let pageNo = 1;
let totalSynced = 0;
while (true) {
const params = {
numOfRows: 100,
pageNo,
eventStartDate: startDate,
eventEndDate: endDate,
};
if (areaCode) {
params.areaCode = areaCode;
}
const result = await this.tourApi.searchFestival(params);
if (!result.items.length) break;
for (const item of result.items) {
await this.upsertFestival(item);
totalSynced++;
}
if (result.items.length < 100) break;
pageNo++;
// API 호출 제한 대응
await this.sleep(100);
}
console.log(`축제 동기화 완료: ${totalSynced}건`);
return totalSynced;
}
// 축제 데이터 저장/업데이트
async upsertFestival(item) {
const exists = await db.get(
'SELECT id FROM festivals WHERE content_id = ?',
[item.contentid]
);
const data = {
content_id: item.contentid,
content_type_id: item.contenttypeid || '15',
title: item.title,
addr1: item.addr1,
addr2: item.addr2,
area_code: item.areacode,
sigungu_code: item.sigungucode,
mapx: parseFloat(item.mapx) || null,
mapy: parseFloat(item.mapy) || null,
event_start_date: item.eventstartdate,
event_end_date: item.eventenddate,
first_image: item.firstimage,
first_image2: item.firstimage2,
tel: item.tel,
last_synced_at: new Date().toISOString(),
};
if (exists) {
await db.run(`
UPDATE festivals SET
title = ?, addr1 = ?, addr2 = ?, area_code = ?, sigungu_code = ?,
mapx = ?, mapy = ?, event_start_date = ?, event_end_date = ?,
first_image = ?, first_image2 = ?, tel = ?, last_synced_at = ?,
updated_at = CURRENT_TIMESTAMP
WHERE content_id = ?
`, [
data.title, data.addr1, data.addr2, data.area_code, data.sigungu_code,
data.mapx, data.mapy, data.event_start_date, data.event_end_date,
data.first_image, data.first_image2, data.tel, data.last_synced_at,
data.content_id
]);
} else {
await db.run(`
INSERT INTO festivals (
content_id, content_type_id, title, addr1, addr2, area_code, sigungu_code,
mapx, mapy, event_start_date, event_end_date, first_image, first_image2,
tel, last_synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
data.content_id, data.content_type_id, data.title, data.addr1, data.addr2,
data.area_code, data.sigungu_code, data.mapx, data.mapy, data.event_start_date,
data.event_end_date, data.first_image, data.first_image2, data.tel,
data.last_synced_at
]);
}
}
// 축제 상세 정보 가져오기
async fetchFestivalDetail(contentId) {
const [common, intro] = await Promise.all([
this.tourApi.detailCommon(contentId),
this.tourApi.detailIntro(contentId, '15'),
]);
const commonItem = common.items[0] || {};
const introItem = intro.items[0] || {};
await db.run(`
UPDATE festivals SET
overview = ?, homepage = ?, place = ?, place_info = ?,
play_time = ?, program = ?, use_fee = ?, age_limit = ?,
sponsor1 = ?, sponsor1_tel = ?, sponsor2 = ?, sponsor2_tel = ?,
sub_event = ?, booking_place = ?, updated_at = CURRENT_TIMESTAMP
WHERE content_id = ?
`, [
commonItem.overview,
commonItem.homepage,
introItem.eventplace,
introItem.placeinfo,
introItem.playtime,
introItem.program,
introItem.usetimefestival,
introItem.agelimit,
introItem.sponsor1,
introItem.sponsor1tel,
introItem.sponsor2,
introItem.sponsor2tel,
introItem.subevent,
introItem.bookingplace,
contentId
]);
}
// 활성 축제 조회
async getActiveFestivals(options = {}) {
const {
areaCode = null,
limit = 20,
offset = 0,
} = options;
const today = this.getTodayString();
let query = `
SELECT * FROM festivals
WHERE is_active = 1
AND event_end_date >= ?
`;
const params = [today];
if (areaCode) {
query += ' AND area_code = ?';
params.push(areaCode);
}
query += ' ORDER BY event_start_date ASC LIMIT ? OFFSET ?';
params.push(limit, offset);
return db.all(query, params);
}
// 진행중인 축제 조회
async getOngoingFestivals(areaCode = null) {
const today = this.getTodayString();
let query = `
SELECT * FROM festivals
WHERE is_active = 1
AND event_start_date <= ?
AND event_end_date >= ?
`;
const params = [today, today];
if (areaCode) {
query += ' AND area_code = ?';
params.push(areaCode);
}
query += ' ORDER BY event_end_date ASC';
return db.all(query, params);
}
// 유틸리티 함수
getTodayString() {
return new Date().toISOString().slice(0, 10).replace(/-/g, '');
}
getDateAfterMonths(months) {
const date = new Date();
date.setMonth(date.getMonth() + months);
return date.toISOString().slice(0, 10).replace(/-/g, '');
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
module.exports = new FestivalService();
```
#### 5.2.3 매칭 서비스
```javascript
// server/services/matchingService.js
const db = require('../db');
class MatchingService {
// Haversine 공식으로 두 좌표 간 거리 계산 (km)
calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371; // 지구 반경 (km)
const dLat = this.toRad(lat2 - lat1);
const dLon = this.toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
toRad(deg) {
return deg * (Math.PI / 180);
}
// 거리 → 예상 이동시간 (분) 변환
estimateTravelTime(distanceKm) {
// 평균 시속 40km 가정 (국도/지방도 기준)
const hours = distanceKm / 40;
return Math.round(hours * 60);
}
// 매칭 점수 계산 (0-100)
calculateMatchScore(distanceKm, isSameRegion) {
let score = 100;
// 거리에 따른 감점
if (distanceKm <= 10) score -= 0;
else if (distanceKm <= 20) score -= 10;
else if (distanceKm <= 30) score -= 20;
else if (distanceKm <= 50) score -= 35;
else if (distanceKm <= 80) score -= 50;
else score -= 70;
// 같은 지역이면 보너스
if (isSameRegion) score += 10;
return Math.max(0, Math.min(100, score));
}
// 펜션과 축제 매칭
async matchPensionToFestivals(pensionId, pensionType = 'public') {
// 펜션 정보 조회
const pensionTable = pensionType === 'public' ? 'public_pensions' : 'pension_profiles';
const pension = await db.get(`SELECT * FROM ${pensionTable} WHERE id = ?`, [pensionId]);
if (!pension || !pension.mapx || !pension.mapy) {
console.log(`펜션 ID ${pensionId}: 좌표 정보 없음`);
return [];
}
// 활성 축제 조회 (6개월 이내)
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const festivals = await db.all(`
SELECT * FROM festivals
WHERE is_active = 1
AND event_end_date >= ?
AND mapx IS NOT NULL
AND mapy IS NOT NULL
`, [today]);
const matches = [];
for (const festival of festivals) {
const distance = this.calculateDistance(
pension.mapy, pension.mapx, // 펜션 위도, 경도
festival.mapy, festival.mapx // 축제 위도, 경도
);
// 80km 이내만 매칭
if (distance > 80) continue;
const isSameRegion = pension.area_code === festival.area_code;
const matchType = isSameRegion ? 'same_region' : 'nearby';
const travelTime = this.estimateTravelTime(distance);
const matchScore = this.calculateMatchScore(distance, isSameRegion);
// 매칭 저장
await db.run(`
INSERT OR REPLACE INTO pension_festival_matches
(pension_id, pension_type, festival_id, distance_km, travel_time_min, match_type, match_score)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [pensionId, pensionType, festival.id, distance.toFixed(2), travelTime, matchType, matchScore]);
matches.push({
festival,
distance: distance.toFixed(2),
travelTime,
matchType,
matchScore,
});
}
return matches.sort((a, b) => a.distance - b.distance);
}
// 축제 주변 펜션 조회
async getPensionsNearFestival(festivalId, options = {}) {
const { maxDistance = 50, limit = 20 } = options;
const festival = await db.get('SELECT * FROM festivals WHERE id = ?', [festivalId]);
if (!festival || !festival.mapx || !festival.mapy) {
return [];
}
// 이미 계산된 매칭이 있으면 사용
const cached = await db.all(`
SELECT pp.*, m.distance_km, m.travel_time_min, m.match_type, m.match_score
FROM pension_festival_matches m
JOIN public_pensions pp ON m.pension_id = pp.id AND m.pension_type = 'public'
WHERE m.festival_id = ? AND m.distance_km <= ?
ORDER BY m.match_score DESC, m.distance_km ASC
LIMIT ?
`, [festivalId, maxDistance, limit]);
if (cached.length > 0) {
return cached;
}
// 캐시 없으면 실시간 계산
const pensions = await db.all(`
SELECT * FROM public_pensions
WHERE mapx IS NOT NULL AND mapy IS NOT NULL
AND business_status = '영업중'
`);
const results = [];
for (const pension of pensions) {
const distance = this.calculateDistance(
festival.mapy, festival.mapx,
pension.mapy, pension.mapx
);
if (distance <= maxDistance) {
results.push({
...pension,
distance_km: distance.toFixed(2),
travel_time_min: this.estimateTravelTime(distance),
});
}
}
return results
.sort((a, b) => a.distance_km - b.distance_km)
.slice(0, limit);
}
// 전체 매칭 갱신 (배치)
async refreshAllMatches() {
console.log('전체 매칭 갱신 시작...');
const pensions = await db.all(`
SELECT id FROM public_pensions
WHERE mapx IS NOT NULL AND mapy IS NOT NULL
`);
let processed = 0;
for (const pension of pensions) {
await this.matchPensionToFestivals(pension.id, 'public');
processed++;
if (processed % 100 === 0) {
console.log(`매칭 진행: ${processed}/${pensions.length}`);
}
}
console.log(`전체 매칭 갱신 완료: ${processed}건`);
}
}
module.exports = new MatchingService();
```
#### 5.2.4 Geocoding 서비스
```javascript
// server/services/geocodingService.js
const axios = require('axios');
class GeocodingService {
constructor() {
this.kakaoClient = axios.create({
baseURL: 'https://dapi.kakao.com/v2/local',
headers: {
Authorization: `KakaoAK ${process.env.KAKAO_REST_KEY}`,
},
});
}
// 주소 → 좌표 변환
async geocode(address) {
try {
const response = await this.kakaoClient.get('/search/address.json', {
params: { query: address },
});
const documents = response.data.documents;
if (!documents.length) {
return null;
}
const result = documents[0];
return {
mapx: parseFloat(result.x), // 경도
mapy: parseFloat(result.y), // 위도
address: result.address_name,
roadAddress: result.road_address?.address_name,
};
} catch (error) {
console.error('Geocoding 실패:', address, error.message);
return null;
}
}
// 좌표 → 주소 변환 (역지오코딩)
async reverseGeocode(mapx, mapy) {
try {
const response = await this.kakaoClient.get('/geo/coord2address.json', {
params: { x: mapx, y: mapy },
});
const documents = response.data.documents;
if (!documents.length) {
return null;
}
const result = documents[0];
return {
address: result.address?.address_name,
roadAddress: result.road_address?.address_name,
region1: result.address?.region_1depth_name, // 시도
region2: result.address?.region_2depth_name, // 시군구
region3: result.address?.region_3depth_name, // 읍면동
};
} catch (error) {
console.error('역지오코딩 실패:', mapx, mapy, error.message);
return null;
}
}
// 좌표 → 지역코드 매핑
async getAreaCodeFromCoords(mapx, mapy) {
const region = await this.reverseGeocode(mapx, mapy);
if (!region) return null;
// 시도명 → 지역코드 매핑
const areaCodeMap = {
'서울': '1', '서울특별시': '1',
'인천': '2', '인천광역시': '2',
'대전': '3', '대전광역시': '3',
'대구': '4', '대구광역시': '4',
'광주': '5', '광주광역시': '5',
'부산': '6', '부산광역시': '6',
'울산': '7', '울산광역시': '7',
'세종': '8', '세종특별자치시': '8',
'경기': '31', '경기도': '31',
'강원': '32', '강원도': '32', '강원특별자치도': '32',
'충북': '33', '충청북도': '33',
'충남': '34', '충청남도': '34',
'경북': '35', '경상북도': '35',
'경남': '36', '경상남도': '36',
'전북': '37', '전라북도': '37', '전북특별자치도': '37',
'전남': '38', '전라남도': '38',
'제주': '39', '제주특별자치도': '39',
};
return {
areaCode: areaCodeMap[region.region1] || null,
sido: region.region1,
sigungu: region.region2,
};
}
// 주소에서 시도/시군구 파싱
parseAddress(address) {
if (!address) return { sido: null, sigungu: null };
const sidoPatterns = [
/^(서울특별시|서울)/,
/^(부산광역시|부산)/,
/^(대구광역시|대구)/,
/^(인천광역시|인천)/,
/^(광주광역시|광주)/,
/^(대전광역시|대전)/,
/^(울산광역시|울산)/,
/^(세종특별자치시|세종)/,
/^(경기도|경기)/,
/^(강원특별자치도|강원도|강원)/,
/^(충청북도|충북)/,
/^(충청남도|충남)/,
/^(전북특별자치도|전라북도|전북)/,
/^(전라남도|전남)/,
/^(경상북도|경북)/,
/^(경상남도|경남)/,
/^(제주특별자치도|제주)/,
];
let sido = null;
let sigungu = null;
for (const pattern of sidoPatterns) {
const match = address.match(pattern);
if (match) {
sido = match[1];
// 시군구 추출
const remaining = address.slice(match[0].length).trim();
const sigunguMatch = remaining.match(/^([가-힣]+[시군구])/);
if (sigunguMatch) {
sigungu = sigunguMatch[1];
}
break;
}
}
return { sido, sigungu };
}
}
module.exports = new GeocodingService();
```
---
## 6. API 엔드포인트
### 6.1 축제 API
```javascript
// server/routes/festivalRoutes.js
const express = require('express');
const router = express.Router();
const festivalService = require('../services/festivalService');
const matchingService = require('../services/matchingService');
// 축제 목록 조회
// GET /api/festivals?areaCode=31&status=ongoing&page=1&limit=20
router.get('/', async (req, res) => {
try {
const { areaCode, status, page = 1, limit = 20 } = req.query;
const offset = (page - 1) * limit;
let festivals;
if (status === 'ongoing') {
festivals = await festivalService.getOngoingFestivals(areaCode);
} else {
festivals = await festivalService.getActiveFestivals({
areaCode,
limit: parseInt(limit),
offset,
});
}
res.json({ success: true, data: festivals });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 축제 상세 조회
// GET /api/festivals/:id
router.get('/:id', async (req, res) => {
try {
const festival = await db.get('SELECT * FROM festivals WHERE id = ?', [req.params.id]);
if (!festival) {
return res.status(404).json({ success: false, error: '축제를 찾을 수 없습니다' });
}
// 상세 정보 없으면 가져오기
if (!festival.overview) {
await festivalService.fetchFestivalDetail(festival.content_id);
festival = await db.get('SELECT * FROM festivals WHERE id = ?', [req.params.id]);
}
res.json({ success: true, data: festival });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 축제 주변 펜션 조회
// GET /api/festivals/:id/pensions?maxDistance=50&limit=20
router.get('/:id/pensions', async (req, res) => {
try {
const { maxDistance = 50, limit = 20 } = req.query;
const pensions = await matchingService.getPensionsNearFestival(
req.params.id,
{ maxDistance: parseFloat(maxDistance), limit: parseInt(limit) }
);
res.json({ success: true, data: pensions });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 월별 축제 조회
// GET /api/festivals/calendar/:year/:month
router.get('/calendar/:year/:month', async (req, res) => {
try {
const { year, month } = req.params;
const startDate = `${year}${month.padStart(2, '0')}01`;
const endDate = `${year}${month.padStart(2, '0')}31`;
const festivals = await db.all(`
SELECT * FROM festivals
WHERE is_active = 1
AND event_start_date <= ?
AND event_end_date >= ?
ORDER BY event_start_date ASC
`, [endDate, startDate]);
res.json({ success: true, data: festivals });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 지역별 축제 통계
// GET /api/festivals/stats/by-region
router.get('/stats/by-region', async (req, res) => {
try {
const stats = await db.all(`
SELECT
area_code,
COUNT(*) as total,
SUM(CASE WHEN event_start_date <= date('now') AND event_end_date >= date('now') THEN 1 ELSE 0 END) as ongoing
FROM festivals
WHERE is_active = 1
GROUP BY area_code
`);
res.json({ success: true, data: stats });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 관리자: 축제 동기화
// POST /api/admin/festivals/sync
router.post('/admin/sync', requireAdmin, async (req, res) => {
try {
const count = await festivalService.syncFestivals(req.body);
res.json({ success: true, message: `${count}건 동기화 완료` });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;
```
### 6.2 공개 펜션 API
```javascript
// server/routes/publicPensionRoutes.js
const express = require('express');
const router = express.Router();
const matchingService = require('../services/matchingService');
// 지역별 펜션 목록
// GET /api/public/pensions?sido=경기&sigungu=가평군&page=1&limit=20
router.get('/', async (req, res) => {
try {
const { sido, sigungu, page = 1, limit = 20 } = req.query;
const offset = (page - 1) * limit;
let query = `
SELECT * FROM public_pensions
WHERE business_status = '영업중'
`;
const params = [];
if (sido) {
query += ' AND sido LIKE ?';
params.push(`%${sido}%`);
}
if (sigungu) {
query += ' AND sigungu LIKE ?';
params.push(`%${sigungu}%`);
}
query += ' ORDER BY view_count DESC, name ASC LIMIT ? OFFSET ?';
params.push(parseInt(limit), offset);
const pensions = await db.all(query, params);
// 총 개수
let countQuery = `SELECT COUNT(*) as total FROM public_pensions WHERE business_status = '영업중'`;
if (sido) countQuery += ` AND sido LIKE '%${sido}%'`;
if (sigungu) countQuery += ` AND sigungu LIKE '%${sigungu}%'`;
const { total } = await db.get(countQuery);
res.json({
success: true,
data: pensions,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages: Math.ceil(total / limit)
}
});
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 펜션 상세 조회
// GET /api/public/pensions/:id
router.get('/:id', async (req, res) => {
try {
const pension = await db.get(
'SELECT * FROM public_pensions WHERE id = ?',
[req.params.id]
);
if (!pension) {
return res.status(404).json({ success: false, error: '펜션을 찾을 수 없습니다' });
}
// 조회수 증가
await db.run(
'UPDATE public_pensions SET view_count = view_count + 1 WHERE id = ?',
[req.params.id]
);
res.json({ success: true, data: pension });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 펜션 검색
// GET /api/public/pensions/search?q=가평펜션
router.get('/search', async (req, res) => {
try {
const { q, limit = 20 } = req.query;
if (!q || q.length < 2) {
return res.status(400).json({ success: false, error: '검색어는 2자 이상 입력하세요' });
}
const pensions = await db.all(`
SELECT * FROM public_pensions
WHERE business_status = '영업중'
AND (name LIKE ? OR address LIKE ?)
ORDER BY view_count DESC
LIMIT ?
`, [`%${q}%`, `%${q}%`, parseInt(limit)]);
res.json({ success: true, data: pensions });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 근처 펜션 조회 (좌표 기반)
// GET /api/public/pensions/nearby?lat=37.5&lng=127.5&radius=30
router.get('/nearby', async (req, res) => {
try {
const { lat, lng, radius = 30, limit = 20 } = req.query;
if (!lat || !lng) {
return res.status(400).json({ success: false, error: '좌표를 입력하세요' });
}
// 간단한 경계 박스 필터링 (정확한 거리 계산 전 1차 필터)
const latRange = parseFloat(radius) / 111; // 위도 1도 ≈ 111km
const lngRange = parseFloat(radius) / 88; // 경도 1도 ≈ 88km (한국 기준)
const pensions = await db.all(`
SELECT * FROM public_pensions
WHERE business_status = '영업중'
AND mapy BETWEEN ? AND ?
AND mapx BETWEEN ? AND ?
`, [
parseFloat(lat) - latRange,
parseFloat(lat) + latRange,
parseFloat(lng) - lngRange,
parseFloat(lng) + lngRange,
]);
// 정확한 거리 계산 및 필터링
const results = pensions
.map(pension => ({
...pension,
distance: matchingService.calculateDistance(
parseFloat(lat), parseFloat(lng),
pension.mapy, pension.mapx
)
}))
.filter(p => p.distance <= parseFloat(radius))
.sort((a, b) => a.distance - b.distance)
.slice(0, parseInt(limit));
res.json({ success: true, data: results });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 펜션 주변 축제 조회
// GET /api/public/pensions/:id/festivals
router.get('/:id/festivals', async (req, res) => {
try {
const matches = await db.all(`
SELECT f.*, m.distance_km, m.travel_time_min, m.match_type, m.match_score
FROM pension_festival_matches m
JOIN festivals f ON m.festival_id = f.id
WHERE m.pension_id = ? AND m.pension_type = 'public'
AND f.event_end_date >= date('now')
ORDER BY m.match_score DESC, f.event_start_date ASC
LIMIT 10
`, [req.params.id]);
res.json({ success: true, data: matches });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// 지역별 통계
// GET /api/public/pensions/stats/by-region
router.get('/stats/by-region', async (req, res) => {
try {
const stats = await db.all(`
SELECT
sido,
COUNT(*) as total,
SUM(CASE WHEN is_claimed = 1 THEN 1 ELSE 0 END) as claimed
FROM public_pensions
WHERE business_status = '영업중'
GROUP BY sido
ORDER BY total DESC
`);
res.json({ success: true, data: stats });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;
```
### 6.3 펜션 클레임 API
```javascript
// server/routes/claimRoutes.js
// 내 펜션 찾기 (이름/주소로 검색)
// GET /api/claim/search?q=OO펜션
router.get('/search', authenticateToken, async (req, res) => {
const { q } = req.query;
const pensions = await db.all(`
SELECT id, name, address, thumbnail, is_claimed, claimed_by
FROM public_pensions
WHERE business_status = '영업중'
AND (name LIKE ? OR address LIKE ?)
LIMIT 20
`, [`%${q}%`, `%${q}%`]);
res.json({ success: true, data: pensions });
});
// 펜션 클레임 신청
// POST /api/claim
router.post('/', authenticateToken, async (req, res) => {
const { public_pension_id, verification_method, verification_data } = req.body;
const userId = req.user.id;
// 이미 클레임된 펜션인지 확인
const pension = await db.get(
'SELECT * FROM public_pensions WHERE id = ?',
[public_pension_id]
);
if (!pension) {
return res.status(404).json({ success: false, error: '펜션을 찾을 수 없습니다' });
}
if (pension.is_claimed) {
return res.status(400).json({ success: false, error: '이미 인증된 펜션입니다' });
}
// 중복 신청 확인
const existingClaim = await db.get(`
SELECT * FROM pension_claims
WHERE public_pension_id = ? AND user_id = ? AND status = 'pending'
`, [public_pension_id, userId]);
if (existingClaim) {
return res.status(400).json({ success: false, error: '이미 신청 중입니다' });
}
// 클레임 신청 저장
await db.run(`
INSERT INTO pension_claims (public_pension_id, user_id, verification_method, verification_data)
VALUES (?, ?, ?, ?)
`, [public_pension_id, userId, verification_method, JSON.stringify(verification_data)]);
res.json({ success: true, message: '인증 신청이 완료되었습니다' });
});
// 클레임 승인 (관리자)
// POST /api/admin/claims/:id/approve
router.post('/admin/claims/:id/approve', requireAdmin, async (req, res) => {
const claim = await db.get('SELECT * FROM pension_claims WHERE id = ?', [req.params.id]);
if (!claim) {
return res.status(404).json({ success: false, error: '신청을 찾을 수 없습니다' });
}
// 클레임 승인
await db.run(`
UPDATE pension_claims
SET status = 'approved', reviewed_by = ?, reviewed_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [req.user.id, req.params.id]);
// 펜션 소유권 설정
await db.run(`
UPDATE public_pensions
SET is_claimed = 1, claimed_by = ?, claimed_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [claim.user_id, claim.public_pension_id]);
// 사용자 pension_profiles와 연결
await db.run(`
UPDATE pension_profiles
SET public_pension_id = ?
WHERE user_id = ? AND is_default = 1
`, [claim.public_pension_id, claim.user_id]);
res.json({ success: true, message: '승인 완료' });
});
```
### 6.4 API 엔드포인트 요약
| 메서드 | 엔드포인트 | 설명 | 인증 |
|-------|-----------|------|------|
| **축제** |
| GET | `/api/festivals` | 축제 목록 | - |
| GET | `/api/festivals/:id` | 축제 상세 | - |
| GET | `/api/festivals/:id/pensions` | 축제 주변 펜션 | - |
| GET | `/api/festivals/calendar/:year/:month` | 월별 축제 | - |
| GET | `/api/festivals/stats/by-region` | 지역별 통계 | - |
| POST | `/api/admin/festivals/sync` | 축제 동기화 | 관리자 |
| **펜션** |
| GET | `/api/public/pensions` | 펜션 목록 | - |
| GET | `/api/public/pensions/:id` | 펜션 상세 | - |
| GET | `/api/public/pensions/search` | 펜션 검색 | - |
| GET | `/api/public/pensions/nearby` | 근처 펜션 | - |
| GET | `/api/public/pensions/:id/festivals` | 펜션 주변 축제 | - |
| GET | `/api/public/pensions/stats/by-region` | 지역별 통계 | - |
| **클레임** |
| GET | `/api/claim/search` | 내 펜션 찾기 | 로그인 |
| POST | `/api/claim` | 클레임 신청 | 로그인 |
| POST | `/api/admin/claims/:id/approve` | 클레임 승인 | 관리자 |
---
## 7. 프론트엔드 설계
### 7.1 새로운 페이지 구조
```
src/pages/
├── FestivalsPage.tsx # 축제 검색/목록
├── FestivalDetailPage.tsx # 축제 상세 + 주변 펜션
├── RegionalPensionsPage.tsx # 지역별 펜션 목록
├── PensionClaimPage.tsx # 펜션 인증 신청
└── LandingPage.tsx # (수정) 축제/펜션 섹션 추가
components/
├── festival/
│ ├── FestivalCard.tsx # 축제 카드
│ ├── FestivalCalendar.tsx # 월별 축제 캘린더
│ ├── FestivalFilter.tsx # 지역/기간 필터
│ └── FestivalMap.tsx # 축제 지도 표시
├── pension/
│ ├── PublicPensionCard.tsx # 공개 펜션 카드
│ ├── PensionSearchBar.tsx # 펜션 검색
│ ├── RegionalPensionGrid.tsx # 지역별 펜션 그리드
│ └── DistanceBadge.tsx # "차량 30분" 배지
├── match/
│ ├── FestivalPensionMatch.tsx # 축제-펜션 매칭 표시
│ └── NearbyFestivals.tsx # 펜션 상세 내 축제 목록
└── landing/
├── FestivalCarousel.tsx # 랜딩 축제 캐러셀
└── RegionalSection.tsx # 랜딩 지역별 섹션
```
### 7.2 랜딩페이지 수정
```tsx
// src/pages/LandingPage.tsx - 추가 섹션
// 실시간 축제 섹션
축제도 보고, 펜션에서 쉬어가세요! 전국 축제와 연계한 마케팅 영상을 무료로 만들어보세요!🎪 이번 달 전국 축제
🏡 전국 {totalPensionCount.toLocaleString()}개 펜션
우리 펜션도 등록하고 싶으신가요?
{festival.overview}
축제 후 편하게 쉴 수 있는 주변 숙소입니다
{formatDate(festival.event_start_date)} ~ {formatDate(festival.event_end_date)}
{festival.addr1}
{formatDateRange(festival.event_start_date, festival.event_end_date)}
{pension.address}
축제도 즐기고, 펜션에서 편하게 쉬어가세요!