원하는 날짜·도시의 항공권 최저가를 매시간 대신 지켜보다가, 조건이 맞으면 알림 → 최저가 사이트로 바로 이동시켜 주는 가격 감시 메타서치.
Java 21 · Spring Boot 4.1 · PostgreSQL · Redis · Next.js · Kotlin(Compose)
홈 대시보드 (실데이터 라이브 캡처) — 워치마다 현재가 · 역대최저 대비 · 딜 스코어 · 30일 스파크라인 · 일시정지, API 호출은 /api/watches/summary 한 번.
여행을 한 달 전쯤 계획하면 그 사이 가격은 하루에도 몇 번씩 출렁인다. farewatch는 노선 + 유연한 날짜 + 알림 규칙을 등록받아 여러 소스를 매시간 폴링하고, 규칙이 발화하면 푸시/이메일로 알린 뒤 그 가격을 파는 사이트로 딥링크한다. 예약·결제는 그 사이트의 몫 — 스카이스캐너와 같은 메타서치 모델이다.
단순 크롤러가 아니라 분산 가격-감시 엔진이 심장:
- 중복 없는 분산 스케줄링 — N개 인스턴스의 시간당 폴링을 Redis 분산락 → Redis Streams 큐 샤딩으로 정확히 한 번 실행
- 큐 장애복구 — 워커가 죽어도 손실 0: XPENDING+XCLAIM으로 reclaim, 반복 실패 잡은 DLQ 격리
- 외부 API 보호 — 소스별 토큰버킷 레이트리밋 + 서킷브레이커(직접 구현) + 적응형 폴링(예산 내 고가치 워치 우선)
- 멀티소스 정규화 — GDS(Amadeus) · 애그리게이터(Travelpayouts) · 시뮬레이터를 하나의
FarePriceProvider로 합류 - 멱등 알림 — 트랜잭션 아웃박스 +
dedup_key, 같은 하락을 두 번 쏘지 않음 · 재시도→FAILED - 결정 레이어 — 백분위·추세·변동성·D-day 기반 매수 신호 + 0–100 딜 스코어 (ML 없이 숫자로 방어 가능)
- 에러요금 이상탐지 — 새 최저가가 노선 이력 대비 z-score ≤ −2.5면 🔥 에러요금 의심 우선 알림
웹 — 스타크 화이트 + DM Sans + 블랙 필 CTA + 코랄 "현재 최저가" 카드 하나. 차트·캘린더·히트맵 전부 의존성 없는 SVG.
워치 상세 (코랄 최저가 · 매수신호 · 알림 내역 · 도착지 날씨 · 히트맵) | 워치 만들기 (스카이스캐너식 검색바)
전역 알림 피드 — 목표가 도달 · 새 최저가 · 🔥에러요금 의심이 규칙 칩으로 구분되어 쌓인다 (/api/alerts)
검색 UX 더 보기 — 공항 자동완성 · 유연 날짜 캘린더 · 한국어 검색 · 왕복
자동완성(근처공항·거리, OurAirports 실데이터) · 2개월 캘린더 · 전 세계 한국어 검색(Wikidata+큐레이션) · 왕복(가는/오는 편 시간대)
Android (Kotlin · Jetpack Compose) — 같은 /api를 소비하는 네이티브 앱, 웹과 완전 파리티. MuMu(arm64) 실기기에서 실데이터로 검증.
홈 대시보드(딜점수 정렬·일시정지) · 상세(코랄 카드·차트) · 검색/생성 · 알림 피드+조건 편집
관측성 — 인스턴스를 2개 띄우면 "poll throughput by instance"에 두 계열이 쌓인다. 같은 워치를 중복 폴하지 않으면서 처리량이 합산되는, 분산 스케줄의 시각적 증명.
Grafana 16패널 라이브 캡처 (instances=2 · 소스 p95 · 에러요금 · 알림 발송 · JVM) — .\run-cluster.ps1 -Instances 2 -Simulator로 재현 · ADR-0004
flowchart LR
U["사용자"] -->|워치 생성| API["farewatch API<br/>(Spring Boot :8101)"]
API --> PG[("PostgreSQL :5435<br/>watch · price_point(시계열) · alert")]
PG -.->|due 스캔| SWEEP
SWEEP["시간당 스윕<br/>ShedLock(MVP)→Redis Streams 샤딩"] -->|due 워치| WORKER["폴 워커"]
WORKER -->|토큰버킷+서킷브레이커| AGG["FarePriceProvider<br/>애그리게이터 (정규화·최저가 채택)"]
AGG --> AM["Amadeus (GDS)"]
AGG --> TP["Travelpayouts"]
AGG --> SC["LCC 스크래퍼"]
AGG --> SIM["Simulator"]
WORKER -->|규칙 발화?| DET["변화 감지"]
DET -->|멱등 dedup + 재시도| NOTIF["Notifier"]
NOTIF --> FCM["FCM 푸시 → Android(Compose)"]
NOTIF --> MAIL["Email"]
PG --> WEB["웹 (Next.js)<br/>대시보드·알림 피드·차트"]
말이 아니라 실행으로 증명 — 전 항목 Testcontainers(실 PostgreSQL·Redis) 통합 테스트 + 라이브 확인.
- 다른 인스턴스가 락 보유 시 중복 폴링 0 (Redis 분산락)
- 컨슈머그룹 샤딩 — 두 워커가 잡 분할, 각 워치 정확히 1회 폴
- 장애복구 — 죽은 워커의 미-ack 잡을 다른 워커가 reclaim해 정확히 1회 폴 · 반복 실패 잡은 DLQ 격리
- 토큰버킷 버스트→거부 · 서킷브레이커 상태머신 · 적응형 폴링(예산 내 고가치 우선)
- 알림 멀티채널 발송 — 아웃박스 · 멱등 dedup · 재시도→FAILED
- 알림 규칙 — 목표가 이하가 첫 폴에 발화해 전역 피드에 노출 · PATCH 규칙 편집 + 파라미터 검증
- 대시보드 요약 — 가격·신호·스파크라인 단일 호출 · 일시정지/재개
- 폴 부하 테스트 — k6 30VU: ~145 polls/s · p95 211ms · 0% 에러 (
load/k6-poll.js) - 라이브 — 실 Travelpayouts 폴로 새 최저가 알림 발화(EMAIL/PUSH SENT), 목표가 규칙은
newLow:false에도 발화하는 것까지 확인
| 영역 | 기술 |
|---|---|
| 백엔드 | Java 21 · Spring Boot 4.1 (Jackson 3) · Gradle |
| DB | PostgreSQL 16 + Flyway · ddl-auto: none · price_point 시계열 + 일별 롤업 리텐션 |
| 스케줄/큐 | 스윕 하트비트 + Redis 분산락 · Redis Streams 컨슈머그룹 샤딩 + XCLAIM/DLQ |
| 회복탄력성 | 토큰버킷 레이트리밋 · 서킷브레이커 (둘 다 직접 구현, Redis Lua) |
| 외부 소스 | Amadeus · Travelpayouts · Open-Meteo(날씨) — 전부 config-gated |
| 프론트 | 웹 Next.js(App Router·TS strict) · Android Kotlin + Jetpack Compose |
| 관측 | Micrometer → Prometheus :9096 + Grafana :3004 (코드 프로비저닝 16패널) |
| 테스트 | Testcontainers (@ServiceConnection) · GitHub Actions CI (backend + android) |
| 소스 | 잡는 재고 풀 |
|---|---|
| Amadeus Self-Service | 레거시 항공사 (GDS) |
| Travelpayouts (Aviasales) | 폭넓은 캐시 최저가 + 가격이력 |
| LCC 스크래퍼 (옵션·기본 off) | GDS가 못 잡는 LCC 직판 |
| Simulator | 합성 가격워크 (부하·상시 데모) |
⚠️ 정직성 노트: 공개 항공권 가격 API는 사실상 없다(스카이스캐너 = 파트너 전용, 구글 플라이트 = API 없음). 그래서 합법적으로 접근 가능한 상보적 소스를FarePriceProvider로 합류하고, 부하테스트·데모는 Simulator로 돌린다. 엔진(스케줄·레이트리밋·dedup·알림)은 소스와 무관하게 동일하다. → ADR-0002
docker compose up -d # PostgreSQL·Redis + Prometheus·Grafana
./gradlew bootRun # API :8101
cd web && npm install && npm run dev # 웹 :3005 (/api/* → :8101 프록시).\run.ps1 # 실소스(.env 토큰 주입) + 메모리 바운드 jar 실행
.\run-cluster.ps1 -Instances 2 -Simulator # 2 인스턴스 분산 drain을 Grafana(:3004)로 관전| 엔드포인트 | 설명 |
|---|---|
POST /api/watches · GET /api/watches |
워치 생성 · 목록 |
GET /api/watches/summary |
홈 대시보드 페이로드 — 현재가·매수신호·30일 스파크라인 (배치 쿼리) |
PATCH /api/watches/{id} |
일시정지/재개 · 알림 규칙 편집(목표가/급락%, 서버 검증) |
GET /api/watches/{id}/prices · calendar · alerts · weather · signal |
시계열 · 날짜별 최저가 · 알림 내역 · 날씨 · 매수신호 |
POST /api/watches/{id}/poll |
즉시 폴 (소스 합류 → 최저가 적재 → 규칙 평가) |
GET /api/alerts |
전역 알림 피드 (노선·발송상태·딥링크 포함) |
GET /api/airports?q= · /{iata}/nearby |
공항 자동완성(한국어 포함) · 근처 공항 |
| 단계 | 한 줄 요약 |
|---|---|
| ✅ P0–P1 | 스캐폴드(Flyway·Testcontainers·CI·ADR) · 워치 CRUD + 소스 추상화 + 시계열 |
| ✅ P2 | 분산 스케줄 — 시간당 스윕 + Redis 분산락, 중복 폴링 0 |
| ✅ P3 | 알림 — 트랜잭션 아웃박스 · 멀티채널 · 멱등 dedup · 재시도 |
| ✅ P4 | 스케일 — Streams 샤딩 · 토큰버킷 · 서킷브레이커 · 적응형 폴링 · k6 145 polls/s |
| ✅ P5 | 도착지 날씨 — Open-Meteo 실예보(D-16) ↔ 평년값 크로스오버 |
| ✅ 실소스 | Amadeus(OAuth2) · Travelpayouts(Aviasales 실링크) — config-gated |
| ✅ Android | Compose+Retrofit 네이티브 앱, 웹 완전 파리티 (MuMu 실기기 검증) |
| ✅ 차별점 | 매수 신호+딜 스코어 · 에러요금 z-score 탐지 · 큐 장애복구(XCLAIM+DLQ) |
| ✅ 운영 | 가격이력 리텐션(일별 롤업+90d purge) · Micrometer 전 핫패스 · 런처 스크립트 |
| ✅ 관측성 | Prometheus+Grafana 16패널 · 2-인스턴스 분산 drain 라이브 캡처 |
| ✅ 대시보드 UX | summary 단일 호출 카드(가격·신호·스파크라인) · 정렬 · 일시정지 — 웹·Android |
| ✅ 알림 UX | 규칙 3종 편집(새 최저가/목표가/급락%) · 전역 알림 피드 — 웹·Android |
| ⬜ 남은 것 | FCM 실수신(Firebase 설정) · 웹 데모 GIF |
















