Загружаем каталог…
Загружаем каталог…
1. 서비스 소개 '샥(syak)'은 뷰티 매장의 빈자리와 취소석을 실시간으로 확인하고 네이버 예약 등으로 연결해 주는 빈자리 취소석 자동 매칭 서비스다. 사용자는 지도와 지역 필터로 매장을 탐색하고, 특정 매장의 남은 시간 슬롯을 확인해 예약 플랫폼으로 이동한다. 2. 문제: 전체 로딩으로 인한 트래픽 폭발과 수집 배치 타임아웃 서비스 지역을 서울에서 경기, 부산, 대구 등으로 확장하면서 데이터 전송량과 수집 파이프라인에서 두 가지 문제가 발생했다. 첫 번째 문제는 클라이언트 데이터 전송량(Egress) 급증이었다. 기존 카탈로그는 앱 진입 시 전체 샵 목록을 통째로 받아와 클라이언트에서 필터링했다. 샵 수가 늘어나자 Supabase Egress가 월 21GB까지 치솟아 무료 티어 한도를 위협했다. 지도 위에 매장 밀집도를 표시하려면 수천 개의 마커가 필요한데, 마커 표시에 불필요한 상세 정보(대표 이미지 URL, 리뷰 수, 가격대, 시술 메뉴 등)까지 전부 포함해 받아오고 있었다. 두 번째 문제는 예약 슬롯 수집 및 관리 배치의 타임아웃이었다. 크론 작업이 네이버 예약 슬롯을 수집한 뒤 기존 슬롯을 날리고 새로 넣는 과정에서, 날짜 범위 통삭제( slots?slot_date=gte... ) 쿼리가 Supabase Statement Timeout에 걸렸다. 또한 수집 대상이 7일 치로 과도하게 넓어 러너 실행 시간과 DB 부담이 컸다. 3. 판단: 뷰포트 기반 조회, 핀/리스트 모델 분리, 청크 삭제 문제를 해결하기 위해 다음과 같이 데이터 흐름을 재설계했다. 뷰포트(Bounds) 기반 로딩 : 전체 샵 조회를 폐기하고, 지도가 멈췄을 때 현재 화면 영역의 남서·북동 좌표( Bounds ) 안쪽에 들어오는 매장만 쿼리하도록 변경했다. ShopPin과 ShopSummary 분리 : 지도 마커 표시에 필요한 정보는 ID, 이름, 카테고리, 구, 좌표 5개뿐이다. 요약 정보 대비 약 6분의 1 크기(1핀당 약 70B)인 경량 마커 타입 ShopPin 을 새로 정의했다. 지도에는 최대 5,000개의 경량 핀을 띄워 밀집도를 표현하고, 하단 카드 리스트는 최대 300개의 ShopSummary 만 불러오도록 책임을 분리했다. 슬롯 수집 범위 3일 축소 및 청크 삭제 : 유저가 실제로 주로 확인하는 범위에 맞춰 수집 기간을 7일에서 3일로 단축했다. DB 삭제 쿼리는 타임아웃을 피하기 위해 날짜별로 쪼개고, 그래도 실패하면 시간대 구간(00:00 11:00, 11:00 13:00 등)으로 더 잘게 쪼개어 삭제하도록 예외 처리를 뒀다. 슬롯 단위 예약 전환 추적 : 유저가 예약 버튼을 누를 때 클릭한 날짜와 시간( slot_date , slot_time )을 이벤트로 남기고, 배치 리포트가 네이버 GraphQL API로 해당 슬롯의 bookingCount 가 실제로 올라갔는지 재확인하도록 설계했다. 4. 변경 전 / 변경 후 4.1. 카탈로그 리포지토리: 뷰포트 쿼리와 경량 핀 분리 (FE) 기존에는 모든 매장을 한꺼번에 읽어와 클라이언트 메모리에 보관했다. // 변경 전: src/contexts/catalog/infrastructure/supabase-shop-repository.ts export class SupabaseShopRepository implements ShopRepository { private summaries: ShopSummary[] | null = null; async all(): Promise<ShopSummary[]> { if (this.summaries) return this.summaries; const q = `shops?select=${SUMMARY_COLS}&order=review_count.desc`; const first = await sbFetch(q, { headers: { Prefer: "count=exact", Range: `0-${PAGE - 1}` } }); // ...전체 페이지네이션 순회 후 summaries에 캐싱 return this.summaries; } } 개편 후에는 뷰포트 사각 영역 기준으로 쿼리하며, 리스트용 요약 목록과 지도 핀용 경량 데이터를 각각 별도 메서드로 요청한다. // 변경 후: src/contexts/catalog/infrastructure/supabase-shop-repository.ts export class SupabaseShopRepository implements ShopRepository { // 리스트용: 뷰포트 내 상위 300개 요약 데이터 async inBounds(b: Bounds, limit = 300): Promise<ShopSummary[]> { const q = `shops?select=${SUMMARY_COLS}` + `&lat=gte.${b.swLat}&lat=lte.${b.neLat}&lng=gte.${b.swLng}&lng=lte.${b.neLng}` + `&order=review_count.desc&limit=${limit}`; return (await this.rows(q)).map(toSummary); } // 지도 마커용: 필요한 최소 필드만 조회 (1핀 ~70B, 최대 5000개) async pinsInBounds(b: Bounds, limit = 5000): Promise<ShopPin[]> { const q = `shops?select=id,name,category,gu,lat,lng` + `&lat=gte.${b.swLat}&lat=lte.${b.neLat}&lng=gte.${b.swLng}&lng=lte.${b.neLng}` + `&order=review_count.desc&limit=${limit}`; return (await this.rows(q)).map((r: any) => ({ id: r.id, name: r.name, category: r.category, gu: r.gu, coord: { lat: r.lat, lng: r.lng }, })); } } 4.2. 예약 슬롯 타임아웃 회피 청크 삭제 (Scraper) 기존에는 수집 시작 날짜 이후의 모든 슬롯을 단일 DELETE 쿼리로 삭제했다. 데이터가 많아지면 DB 타임아웃으로 크론 전체가 중단되었다. # 변경 전: scraper/slot_ingest.py # 날짜창 비우고 새로 INSERT (단일 요청 시 statement timeout 발생) sb("DELETE", f"slots?slot_date=gte.{start_ymd}", prefer="return=minimal") 수집 기간을 3일로 줄이고, 삭제 쿼리를 날짜 단위로 분할 실행하도록 변경했다. 특정 날짜의 삭제가 실패하면 시간대 구간 버킷으로 다시 쪼개어 삭제한다. # 변경 후: scraper/slot_ingest.py def sb_del(path, tries=4): for i in range(tries): try: sb("DELETE", path, prefer="return=minimal") return True except Exception: time.sleep(2 * (i + 1)) return False def del_date(d): if sb_del(f"slots?slot_date=eq.{d}"): return # 한 날짜 데이터가 너무 크면 시간대로 쪼개서 삭제 bks = ["00:00", "11:00", "13:00", "15:00", "17:00", "19:00", "23:59"] for a, b in zip(bks, bks[1:]): sb_del(f"slots?slot_date=eq.{d}&start_time=gte.{a}:00&start_time=lt.{b}:00") sb_del(f"slots?slot_date=lt.{start_ymd}") # 과거 정리 for off in range(DAYS): # 3일치 날짜별로 비우기 del_date((today + timedelta(days=off)).strftime("%Y-%m-%d")) 4.3. 예약 클릭 슬롯의 실제 예약 완료 역추적 (Scraper) 사용자가 상세 시트에서 특정 슬롯을 클릭했을 때 날짜와 시간 정보를 이벤트에 포함하도록 바꿨다. 디스코드 리포트 스크립트는 클릭 후 20분이 지난 건에 대해 네이버 예약 GraphQL API를 조회해 실제로 예약이 찼는지( bookingCount > 0 ) 확인한다. # 변경 후: scraper/discord_report.py GQL = "https://m.booking.naver.com/graphql?opName=hourlySchedule" SCHED_Q = "query h($p: ScheduleParams){schedule(input:$p){bizItemSchedule{hourly{unitStartTime bookingCount stock}}}}" def naver_slot_booked(bt, biz, items, date, hhmm): """클릭한 슬롯(date, hhmm)이 네이버에서 지금 예약 걸렸나 -> bookingCount>0면 True.""" for it in (items or [])[:6]: pl = { "operationName": "h", "variables": { "p": { "businessTypeId": int(bt or 13), "businessId": str(biz), "bizItemId": str(it), "startDateTime": f"{date}T00:00:00", "endDateTime": f"{date}T23:59:59", "fixedTime": True, "includesHolidaySchedules": True, } }, "query": SCHED_Q, } try: req = urllib.request.Request(GQL, data=json.dumps(pl).encode(), method="POST", headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=15) as r: hourly = (((json.loads(r.read()).get("data") or {}).get("schedule") or {}) .get("bizItemSchedule", {}) or {}).get("hourly") or [] for h in hourly: if h.get("unitStartTime", "")[11:16] == hhmm and (h.get("bookingCount") or 0) > 0: return True except Exception: pass return False 5. 결과 Egress 절감 및 무료 티어 유지 : 뷰포트 기반 로딩으로 전환하면서 월 데이터 전송량이 21GB에서 2GB로 줄어 무료 티어 한도 내에서 운영이 가능해졌다. 지도 밀집도 표현 유지 : 최소 컬럼만 조회하는 ShopPin 분리를 통해 화면 내 최대 5,000개의 매장 핀을 트래픽 낭비 없이 렌더링할 수 있게 되었다. 슬롯 배치 안정화 : 슬롯 저장 기간을 3일로 줄이고 날짜·시간대 버킷 청크 삭제를 적용해 DB 타임아웃 문제를 해결했다. 예약 전환 측정 : 사용자가 누른 슬롯(매장, 날짜, 시간)이 실제로 네이버에서 마감되었는지 추적하여 디스코드 리포트로 확인할 수 있게 되었다.
То, что RADAR обнаружил и классифицировал для этой возможности. Это опубликованный источником текст, а не подтверждение, что предложение ещё действует.
뷰포트 쿼리와 경량 핀 분리로 Supabase Egress 21GB를 2GB로 줄이기. 1. 서비스 소개 '샥(syak)'은 뷰티 매장의 빈자리와 취소석을 실시간으로 확인하고 네이버 예약 등으로 연결해 주는 빈자리 취소석 자동 매칭 서비스다. 사용자는 지도와 지역 필터로 매장을 탐색하고, 특정 매장의 남은 시간 슬롯을 확인해 예약 플랫폼으로 이동한다. 2. 문제: 전체 로딩으로 인한 트래픽 폭발과 수집 배치 타임아웃 서비스 지역을 서울에서 경기, 부산, 대구 등으로 확장하면서 데이터 전송량과 수집 파이프라인에서 두 가지 문제가 발생했다. 첫 번째 문제는 클라이언트 데이터 전송량(Egress) 급증이었다. 기존 카탈로그는 앱 진입 시 전체 샵 목록을 통째로 받아와 클라이언트에서 필터링했다. 샵 수가 늘어나자…
Открыть источник