18 KiB
18 KiB
Air-Watcher 항공권 가격 추적 및 알림 프로젝트 기획서
1. 프로젝트 개요
- 프로젝트 명칭: 에어-워처 (Air-Watcher)
- 목표: 사용자가 원하는 항공권의 가격 변동을 실시간으로 추적하고, 가격이 변동될 경우 알림을 제공하여 최적의 구매 시점을 찾을 수 있도록 돕는다.
- 핵심 가치: 항공권 구매에 드는 시간과 비용을 절약하고, 사용자에게 경제적 이익을 제공한다.
2. 주요 기능 (Features)
2.1 항공권 검색
- 기능: 출발지, 도착지, 날짜(왕복/편도)를 기준으로 항공권을 검색할 수 있다.
- 사용자 인터페이스: 직관적인 검색 폼을 제공하며, 자동 완성 기능을 통해 도시/공항 코드 입력이 용이해야 한다.
2.2 가격 추적 (Watching)
- 기능: 검색된 항공권 또는 특정 노선(예: 서울-도쿄)을 관심 목록에 추가하여 가격 변동을 추적할 수 있다.
- 구현: 사용자가
추적하기버튼을 누르면, 해당 항공편 정보가 데이터베이스에 저장된다.
2.3 가격 변동 알림
- 기능: 관심 목록에 추가된 항공권의 가격이 변경되면 사용자에게 알림을 보낸다.
- 운영 알림 채널:
- 기본 운영: 텔레그램 봇 알림
- 개발/로컬 fallback: 콘솔 출력
- 확장 옵션: 웹훅 중계(슬랙 등)
- 알림 조건: 사용자가 설정한 특정 가격 이하로 떨어졌을 때, 또는 모든 가격 변동 시 알림을 선택할 수 있다.
2.4 대시보드
- 기능: 사용자는 자신의 관심 목록, 추적 중인 항공권의 현재 가격, 과거 가격 변동 내역을 차트로 한눈에 볼 수 있다.
- 시각화: 가격 변동 추이를 쉽게 파악할 수 있도록 그래프 형태로 시각화한다.
2.5 접근 제어 (Nginx 권장)
- 앱 내부 계정/로그인은 필수 요구사항이 아니다.
- 서버 접근 제어는 Nginx 단에서 처리한다.
- 필요 시 Nginx
basic auth를 강제해 외부 접근을 제한한다.
3. 기술 스택 (Tech Stack)
3.1 프론트엔드 (Frontend)
- React.js 또는 Vue.js: 동적인 사용자 인터페이스 구축
- TypeScript: 타입 안정성 확보
- Tailwind CSS 또는 Styled-Components: 빠르고 일관된 스타일링
3.2 백엔드 (Backend)
- Node.js with Express 또는 FastAPI (Python): RESTful API 서버 구축
- TypeScript (Node.js 사용 시)
3.3 데이터베이스 (Database)
- MySQL: watch 설정, 최신 스냅샷, 알림 이벤트, 시스템 토글 상태 저장
3.4 항공권 데이터
- 웹페이지 크롤링 기반 수집:
- Patchright/Puppeteer: JavaScript 렌더링이 필요한 페이지 수집
- Cheerio(또는 BeautifulSoup): HTML 파싱 및 데이터 추출
- 사이트별 크롤러 어댑터: 각 사이트의 DOM 구조 변화에 대응
- 멀티 소스 호환(필수):
Skyscanner/Naver 항공권/Google Flights를 공통 인터페이스로 수집 - 공통 스키마 정규화: 소스별 응답을
offers[]단일 포맷으로 변환 - 소스 라우팅/폴백: 1순위 소스 실패 시 다음 소스로 자동 전환
- 프록시/재시도/백오프 전략: 차단 및 일시 오류 대응
3.5 주기적 작업 (Scheduler)
node-cron(Node.js) 또는 Celery (Python): 정해진 시간마다 크롤링 작업을 실행해 최신 가격 데이터를 수집
3.6 알림 서비스
- Telegram Bot API 기반 메시지 발송
- 웹훅 연동(외부 자동화/메신저 게이트웨이 확장)
3.7 배포 (Deployment)
- Vercel (프론트엔드), AWS(EC2, RDS) 또는 Heroku (백엔드)
4. 시스템 아키텍처
- 클라이언트 (React/Vue): 사용자가 항공권을 검색하고 추적 요청을 보낸다.
- API 서버 (Node.js/Express): 클라이언트 요청을 처리하고, 데이터베이스 및 크롤링 결과와 상호작용한다.
- 데이터베이스 (MySQL): 추적할 항공권 정보와 수집된 가격 데이터를 저장한다.
- 스케줄러 (
node-cron): 주기적으로 가격 수집기를 실행한다. - 가격 수집기 (Crawler Worker): 데이터베이스에서 추적 중인 항공권 목록을 가져와 대상 웹페이지를 크롤링하고 최신 가격을 추출한다.
- 정규화 레이어 (Normalizer): 사이트별로 다른 데이터 형식을 공통 스키마로 정리한다.
- 알림 서비스 (Notifier): 가격 변동이 감지되면 텔레그램(기본) 또는 웹훅/콘솔로 알림을 발송한다.
4.1 멀티 소스 크롤러 호환 설계 (Skyscanner/Naver/Google)
- 결론: 가능하다. 단, 소스별 정책/약관/차단 리스크가 달라 어댑터 분리가 필요하다.
- 공통 크롤러 인터페이스 예시:
getQuotes(searchParams) -> { currency, offers[] }
- 소스 어댑터:
skyscannerAdapternaverFlightsAdaptergoogleFlightsAdapter
- 라우팅 전략:
primaryOnly: 지정 소스만 사용priorityFallback: 우선순위 목록 기반 자동 폴백 (권장)parallelRace: 병렬 조회 후 가장 먼저 성공한 결과 사용
- 운영 권장:
- 소스별 rate limit, 차단 감지, 선택자 회귀 테스트를 분리 운영
- 소스별 장애율/응답속도 메트릭을 수집해 우선순위를 동적으로 조정
- 법적/정책 이슈가 있는 소스는 API/제휴 방식 우선 검토
4.2 크롤링 운영 원칙
- 사이트 이용약관 및 robots.txt를 확인하고 허용 범위 내에서 수집한다.
- 사이트별 요청 간격(rate limiting)을 두고 비정상적인 트래픽 패턴을 피한다.
- DOM 변경 감지를 위해 선택자 검증 로직과 실패 알림을 둔다.
- 크롤링 실패 시 재시도 정책(지수 백오프)을 적용한다.
- 수집 원본(raw HTML)과 파싱 결과를 분리 저장해 장애 분석을 용이하게 한다.
5. 개발 단계 (Milestones)
1단계: 핵심 백엔드 구축 (1주)
- API 서버 기본 구조 설정
- 사이트별 크롤러 어댑터 골격 구현 (Skyscanner/Naver/Google 3종)
- 크롤링 결과 정규화/저장 로직 구현
- 데이터베이스 스키마 설계
2단계: 핵심 프론트엔드 구축 (1주)
- 항공권 검색 폼 및 결과 표시 페이지 개발
3단계: 가격 추적 기능 구현 (2주)
- 관심 목록 추가/삭제 기능 백엔드 API 개발
- 주기적으로 가격을 가져오는 스케줄러 및 크롤러 워커 로직 구현
- 프론트엔드에서 관심 목록 관리 UI 개발
4단계: 알림 기능 구현 (1주)
- 텔레그램 Bot API 연동
- 가격 변동 시 알림 발송 로직 구현
5단계: 배포 및 테스트 (1주)
- 개발된 애플리케이션을 클라우드 환경에 배포
- 통합 테스트 및 버그 수정
6. 자연어 입력 파서 + 가격 추적 CLI (POC)
6.0 현재 반영된 요구사항 흐름
사용자 문장 -> LLM 파라미터 가공(실패 시 규칙 파서 fallback) -> 크롤러 주기 조회 -> 가격 알림- 구현 완료 항목:
watch명령 추가 및 옵션 파싱/실행 루프 연결 (src/cli.js)- OpenAI 호출 기반 파라미터 추출기 + fallback (
src/llmParameterExtractor.js) - 크롤러 어댑터(엔드포인트/모의 크롤러) (
src/crawlerClient.js) - 가격 추적 상태관리/알림 판정 (
src/priceWatcher.js) - 알림기(콘솔/웹훅/텔레그램) (
src/notifier.js) - 테스트 추가 (
test/llmParameterExtractor.test.js,test/priceWatcher.test.js,test/notifier.test.js)
아래처럼 자유 문장을 입력하면 항공권 검색 조건 JSON으로 변환한다.
npm run parse -- "11월 말부터 12월 초까지 출발하는 일정 여행 기간은 대략 12~14일, 비즈니스 2개, 프리미엄 이코노미 1개, 동일 항공편 인천 -> 마드리드 인, 바르셀로나 -> 인천 아웃 총 1회 여정 시간은 20시간 미만"
LLM 기반 파라미터 가공 + 주기적 가격 추적 실행:
npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드, 20시간 이하, 비즈니스 2명" --interval-sec 3600 --target-price 1300000 --alert-on both
한 번만 조회(테스트용):
npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드, 20시간 이하, 비즈니스 2명" --once
Skyscanner 단독 샘플(로컬):
npm run sample:skyscanner
다른 터미널에서:
CRAWLER_PROVIDERS=skyscanner \
CRAWLER_ENDPOINT_SKYSCANNER=http://127.0.0.1:8787/skyscanner \
CRAWLER_ROUTING_STRATEGY=primaryOnly \
npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드, 20시간 이하, 비즈니스 2명" --once
옵션:
--interval-sec: 폴링 주기(초, 기본3600,3600미만 입력 시 즉시 에러)--target-price: 목표 가격 이하 도달 시 알림 기준--alert-on both|change|threshold: 알림 조건 (가격 변동/임계값)--rule-only: LLM 호출 없이 규칙 파서만 사용
환경 변수:
OPENAI_API_KEY: 설정 시 자연어 입력 파라미터를 LLM으로 보정한다.OPENAI_MODEL(선택): 기본값gpt-4.1-miniLLM_REQUEST_TIMEOUT_MS(선택): LLM HTTP 타임아웃(ms), 기본값20000CRAWLER_ENDPOINT(선택): 설정 시 해당 엔드포인트로 POST 하여 실크롤러 결과를 받는다. 미설정 시 mock 크롤러 사용.CRAWLER_PROVIDERS(선택):skyscanner,naver,google형태 우선순위 목록. 미설정 시 단일CRAWLER_ENDPOINT또는 mock 사용.CRAWLER_ENDPOINT_SKYSCANNER(선택): Skyscanner 전용 엔드포인트CRAWLER_ENDPOINT_NAVER(선택): Naver 전용 엔드포인트CRAWLER_ENDPOINT_GOOGLE(선택): Google 전용 엔드포인트CRAWLER_ROUTING_STRATEGY(선택):priorityFallback(기본) |primaryOnly|parallelRaceCRAWLER_REQUEST_TIMEOUT_MS(선택): 크롤러 HTTP 타임아웃(ms), 기본값15000CRAWLER_MAX_ATTEMPTS(선택): 크롤러 요청 최대 시도 횟수(재시도 포함), 기본값2CRAWLER_RETRY_BASE_DELAY_MS(선택): 재시도 백오프 시작 지연(ms), 기본값300CRAWLER_RETRY_MAX_DELAY_MS(선택): 재시도 백오프 최대 지연(ms), 기본값3000NOTIFY_CHANNEL(선택):telegram|webhook|consoleTELEGRAM_BOT_TOKEN(텔레그램 사용 시 필수)TELEGRAM_CHAT_ID(텔레그램 사용 시 필수)NOTIFY_WEBHOOK_URL(웹훅 사용 시 필수)TELEGRAM_API_BASE(선택): 기본값https://api.telegram.orgSKYSCANNER_SAMPLE_HOST(선택): 샘플 서버 host (기본값127.0.0.1)SKYSCANNER_SAMPLE_PORT(선택): 샘플 서버 port (기본값8787)SKYSCANNER_SAMPLE_PATH(선택): 샘플 서버 path (기본값/skyscanner)- CLI는 현재 작업 디렉터리의
.env파일을 자동 로드한다(동일 키가 이미 OS 환경변수에 있으면 OS 값을 우선).
.env 예시(값은 사용자가 직접 입력):
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4.1-mini
LLM_REQUEST_TIMEOUT_MS=20000
CRAWLER_ENDPOINT=
CRAWLER_PROVIDERS=skyscanner,naver,google
CRAWLER_ENDPOINT_SKYSCANNER=
CRAWLER_ENDPOINT_NAVER=
CRAWLER_ENDPOINT_GOOGLE=
CRAWLER_ROUTING_STRATEGY=priorityFallback
CRAWLER_REQUEST_TIMEOUT_MS=15000
CRAWLER_MAX_ATTEMPTS=2
CRAWLER_RETRY_BASE_DELAY_MS=300
CRAWLER_RETRY_MAX_DELAY_MS=3000
NOTIFY_CHANNEL=telegram
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
TELEGRAM_API_BASE=https://api.telegram.org
CRAWLER_ENDPOINT 요청/응답 스키마(고정):
{
"request": {
"watchId": "string",
"searchParams": {
"segments": [{"from": "ICN", "to": "MAD"}]
}
},
"response": {
"currency": "KRW",
"offers": [
{
"provider": "string",
"price": 1234567,
"currency": "KRW",
"metadata": {}
}
]
}
}
테스트 실행:
npm test
7. 웹 대시보드 (LLM 파싱 + 추적 관리 + 토글)
대시보드 실행:
npm run dashboard
Fastify 기반 전환 뼈대 실행:
npm run dashboard:fastify
기본 접속 주소:
http://127.0.0.1:3000
대시보드에서 가능한 작업:
- 자연어 입력을 LLM/규칙 파서로 파싱해 검색 조건 JSON 확인
- 파싱된 조건으로 watch 생성
- watch별
크롤링 ON/OFF,알림 ON/OFF토글 - 전역
전체 크롤링 ON/OFF,전체 알림 ON/OFF토글 - 최근 가격 이벤트(목표가 도달/가격 변동) 확인
7.1 MySQL 연동 (외부 DB 사용)
환경변수 설정:
DASHBOARD_DB=mysql
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=your_user
MYSQL_PASSWORD=your_password
MYSQL_DATABASE=your_database
또는 단일 URL 사용:
DASHBOARD_DB=mysql
MYSQL_URL=mysql://user:password@db.example.com:3306/your_database
추가 서버 환경변수:
DASHBOARD_HOST(기본127.0.0.1)DASHBOARD_PORT(기본3000)DASHBOARD_POLL_INTERVAL_SEC(기본3600,3600미만/비정수 입력 시 서버 시작 에러)DASHBOARD_USERS(선택,username:password,username2:password2형식. 설정 시 로그인 페이지 + 계정별 세션 인증 활성화)DASHBOARD_ADMIN_USERS(선택, 전역 제어 권한 계정 목록. 미설정 시DASHBOARD_USERS첫 번째 계정을 admin으로 간주)DASHBOARD_SESSION_TTL_SEC(선택, 기본604800초 = 7일)DASHBOARD_REQUIRE_AUTH(기본:NODE_ENV=production이면true, 아니면false)DASHBOARD_API_TOKEN(DASHBOARD_REQUIRE_AUTH=true일 때 필수, APIAuthorization: Bearer <token>또는X-API-Key로 전달)DASHBOARD_ALLOW_MEMORY_FALLBACK(기본false,true일 때만 MySQL 초기화 실패/설정 누락 시 메모리 저장소 fallback 허용)DASHBOARD_DB_SCHEMA(선택,auto|playground, 기본playground)DASHBOARD_PROJECT_KEY(선택, 기본air-watcher, playground 스키마에서 프로젝트 네임스페이스 키)LLM_REQUEST_TIMEOUT_MS(기본20000, LLM 파싱 요청 타임아웃)
참고:
- 애플리케이션은 외부 MySQL에 연결해 row CRUD만 수행한다.
- 애플리케이션은 DB/테이블 생성이나 마이그레이션을 수행하지 않는다.
DASHBOARD_DB=mysql인데 MySQL 연결 정보가 없으면 서버가 시작 실패한다. (DASHBOARD_ALLOW_MEMORY_FALLBACK=true면 메모리 fallback 가능)- MySQL 초기화 실패도 동일하게 기본은 시작 실패이며, fallback은 명시적으로 허용해야 한다.
DASHBOARD_DB를 비우고 MySQL 환경변수도 없으면 메모리 저장소로 동작한다.CRAWLER_ENDPOINT미설정 시 mock 가격으로 동작하므로 실데이터 추적 시 실제 크롤러 API를 연결해야 한다.DASHBOARD_DB_SCHEMA는playground(또는auto)만 지원한다.
7.1.1 계정 분리 + 텔레그램 설정 페이지
- 로그인 페이지:
/login - 텔레그램 설정/가이드 페이지:
/setup - 계정별로 watch/event가 분리되어, 일반 사용자는 본인 watch만 조회/수정 가능
- 텔레그램은 계정별
chatId/botToken(선택, 서버 기본 토큰 fallback) 저장 가능
예시:
DASHBOARD_USERS=alice:alice_pw,bob:bob_pw
DASHBOARD_ADMIN_USERS=alice
참고:
DASHBOARD_USERS를 설정하면 브라우저 로그인 기반 인증이 우선 사용된다.- 토큰 인증(
DASHBOARD_API_TOKEN)은 기존 자동화/API 호출 용도로 병행 가능하다.
7.1.2 단일 Playground DB에 여러 소형 프로젝트 수용
소형 프로젝트를 여러 개 운영한다면 DB를 프로젝트별로 쪼개기보다, 하나의 DB(예: playground)에서 project_key 네임스페이스로 분리하는 방식이 실용적이다.
- 스키마 파일:
playground_schema.sql - 핵심 테이블:
projects: 프로젝트 목록/메타project_documents: 프로젝트별 문서(JSON) 저장project_events: 프로젝트별 이벤트 로그project_settings: 프로젝트별 설정 key-value
적용 예시:
mysql -u root -p playground < playground_schema.sql
Air-Watcher를 playground 스키마로 실행하려면:
DASHBOARD_DB=mysql
DASHBOARD_DB_SCHEMA=playground
DASHBOARD_PROJECT_KEY=air-watcher
Air-Watcher 매핑 권장:
- watch 레코드:
project_documents(doc_type='watch',doc_key=<watchId>) - 이벤트:
project_events(stream='watch_events') - 전역/유저 설정:
project_settings(global_controls,user_profiles등)
7.2 Fastify 전환 뼈대
- 엔트리 파일:
src/fastifyDashboardServer.js - 기존
http서버와 병행 운영 가능 (dashboard스크립트는 유지됨) - 동일한 대시보드 정적 파일(
src/dashboard/*)과 주요 API를 Fastify 라우트로 제공
빠른 확인:
DASHBOARD_HOST=127.0.0.1 DASHBOARD_PORT=3000 npm run dashboard:fastify
7.3 Docker / Compose (앱 단독)
Docker 이미지 빌드:
docker build -t airwatcher .
Docker Compose 기동 (app only):
docker compose up --build app
주요 기본값:
- 앱:
http://127.0.0.1:3000 - DB 모드:
DASHBOARD_DB=mysql - MySQL은 외부 서버를 사용하며 Compose에서 DB 컨테이너를 띄우지 않는다.
- 인증:
DASHBOARD_REQUIRE_AUTH=true(토큰 미설정 시 앱 시작 실패)
참고:
.env또는 쉘 환경변수로MYSQL_URL또는MYSQL_HOST/MYSQL_USER/MYSQL_DATABASE를 전달해야 한다.
메모리 모드로 앱만 쓰려면:
DASHBOARD_DB=memory docker compose up --build --no-deps app
7.4 Nginx 리버스 프록시 + Basic Auth (선택)
대시보드는 127.0.0.1:3000에서만 열고, 외부 공개는 Nginx를 통해 처리한다.
server {
listen 80;
server_name your-domain.example;
# 필요할 때만 활성화
# auth_basic "Restricted";
# auth_basic_user_file /etc/nginx/.htpasswd;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}