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 타임아웃 문제를 해결했다. 예약 전환 측정 : 사용자가 누른 슬롯(매장, 날짜, 시간)이 실제로 네이버에서 마감되었는지 추적하여 디스코드 리포트로 확인할 수 있게 되었다.
본 문서는 Claude를 사용해 정리했습니다. 『실무로 통하는 타입스크립트』 함수 밖에서 인수/반환 타입 재사용하기 — Parameters와 ReturnType 상황 : 함수 안에서는 인수 타입과 반환 타입을 알 수 있지만, 그 타입을 함수 정의 바깥 에서도 써야 할 때가 있다. 해결 : 내장 헬퍼 타입 Parameters<F> 와 ReturnType<F> 를 쓴다. defer — 함수 호출을 나중으로 미루기 type Fn = (...args: any[]) => any; function defer<F extends Fn>( fn: F, ...args: Parameters<F> ): () => ReturnType<F> { return () => fn(...args); } Parameters<F> — 함수 F 의 매개변수 목록을 튜플 타입 으로 뽑아낸다. ReturnType<F> — 함수 F 의 반환 타입을 뽑아낸다. defer 는 함수와 그 함수에 넘길 인자들을 미리 받아두고, 인자 없이 호출하면 그제서야 원래 함수를 실행하는 함수 를 돌려준다. type Result = { page: URL; title: string; description: string; }; function search(query: string, tags: string[]): Promise<Result[]> { throw "to be done"; } const deferredSearch = defer(search, "tuple types", ["typescript"]); // deferredSearch의 타입: () => Promise<Result[]> 직접 검증한 결과, defer 는 다음을 전부 타입 레벨에서 강제한다. 인자 개수가 search 의 매개변수 개수와 정확히 일치해야 한다 (부족하면 TS2554: Expected 3 arguments, but got 2 ) 각 인자의 타입도 search 의 매개변수 타입과 일치해야 한다 (틀리면 TS2345: Argument of type 'number' is not assignable to parameter of type 'string' ) 최종 반환 타입( () => Promise<Result[]> )도 search 의 반환 타입에서 자동으로 이어진다 fn 을 직접 손으로 타이핑하지 않고, search 라는 이미 존재하는 함수 하나로부터 defer 의 모든 타입 제약이 자동으로 도출된다 는 게 이 패턴의 핵심이다. 함수 시그니처와 정확히 맞는 인자 목록 만들기 const searchParams: Parameters<typeof search> = [ "tuple types", ["typescript", "javascript"], ]; search(...searchParams); search 의 매개변수 타입이 바뀌면 searchParams 도 그에 맞춰 타입 검사가 자동으로 갱신된다. 별도로 [string, string[]] 처럼 타입을 직접 적어둘 필요가 없다.
수신자 등록부터 실제 SMTP 발송까지 진짜 한 번에 잘 이어질까? 단위 테스트에서는 각 기능을 따로 확인할 수 있었다. 근데 실제로는 수신자 등록 ↓ 공지 변경 감지 ↓ 알림 후보 생성 ↓ 수신자 조회 ↓ SMTP 발송 ↓ 발송 결과 저장 이 흐름이 전부 연결돼야 한다. 그래서 이번에는 실제 SMTP를 사용하는 통합 테스트를 하나 만들었다. 실제 공지를 쓰는 건 좀 아닌 것 같았다 처음에는 실제 PIA 공지를 하나 가져와서 테스트할 수도 있었다. 근데 그렇게 하면 테스트할 때마다 실제 공지 데이터와 발송 이력이 섞일 수 있다. 잘못하면 기존 baseline이나 알림 기록까지 건드릴 수도 있었다. 메일은 실제로 보내되 데이터는 테스트용으로 따로 만들자. 그래서 테스트 전용 H2 DB를 사용했다. @SpringBootTest(properties = { "spring.datasource.url=jdbc:h2:mem:notification-subscriber-smtp-live;DB_CLOSE_DELAY=-1", "external-notice.scheduler.enabled=false" }) 스케줄러도 꺼두었다. 테스트 중에 자동 수집까지 같이 실행될 필요는 없었기 때문이다. 공지도 실제 포털 데이터를 쓰지 않고 테스트용 공지를 직접 만들었다. ExternalNotice testNotice = notice( externalId, fingerprint, "Biz Assist PIA 알림 SMTP 통합 테스트" ); 이렇게 하면 실제 SMTP 연결은 확인하면서도 기존 데이터와는 분리해서 테스트할 수 있다. 수신자 등록도 API부터 시작했다 이번 테스트에서는 DB에 수신자를 바로 넣지 않았다. 실제로 사용할 때와 최대한 비슷하게 확인하고 싶었다. 그래서 수신자 등록 API부터 호출했다. mockMvc.perform(post("/api/notification-subscribers") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isCreated()); 등록된 수신자가 실제 활성 수신자로 조회되는지도 확인했다. List<NotificationSubscriber> subscribers = subscriberService.findEnabled( NotificationType.PIA_EXTERNAL_NOTICE ); assertEquals(1, subscribers.size()); 여기까지 되면 적어도 API 등록 → DB 저장 → 활성 수신자 조회 까지는 실제 흐름대로 연결된 셈이다. 첫 수집에서는 메일이 가면 안 된다 이 테스트에서도 기존에 만들었던 baseline 정책을 그대로 확인했다. 처음 수집한 공지는 기준점만 만들고 알림 후보를 만들지 않는다. NoticeChangeResult baselineChange = changeService.process(notice( "baseline-" + inputs.runToken(), "baseline-" + inputs.runToken(), "PIA SMTP baseline fixture" )); assertTrue( notificationService .createCandidates(collectionResult(baselineChange)) .isEmpty() ); 이 부분을 빼고 바로 새 공지를 넣으면 메일이 발송되는지만 볼 수는 있다. 근데 실제 Biz Assist의 흐름과는 다르다. 메일 기능만 확인하는 게 아니라 지금까지 만든 알림 정책까지 같이 확인하고 싶었다. 그래서 baseline부터 거치도록 했다. 그다음 새 공지를 만들었다 baseline이 만들어진 뒤에는 새로운 PIA 공지를 하나 넣었다. NoticeChangeResult newChange = changeService.process(testNotice); assertEquals( NoticeChangeType.NEW, newChange.getChangeType() ); 이 공지에서는 알림 후보가 하나 만들어져야 한다. 그리고 아직 메일을 보내기 전이니까 상태는 PENDING 이어야 한다. List<ExternalNoticeNotificationCandidate> candidates = notificationService.createCandidates( collectionResult(newChange) ); assertEquals(1, candidates.size()); NotificationDelivery pending = findDelivery(candidates.getFirst().getDeliveryId()); assertEquals( NotificationDeliveryStatus.PENDING, pending.getStatus() ); 이제 진짜 발송만 남았다. 실제 SMTP로 보내봤다 발송은 기존 NotificationDispatcher 를 그대로 사용했다. NotificationDispatchResult firstDispatch = dispatcher.dispatchPending(); 그리고 결과를 확인했다. assertEquals(1, firstDispatch.sentCount()); assertEquals(0, firstDispatch.failedCount()); 알림 이벤트도 SENT . 수신자별 발송 이력도 SENT . 둘 다 확인했다. assertEquals( NotificationDeliveryStatus.SENT, sentEvent.getStatus() ); assertEquals( NotificationDeliveryStatus.SENT, subscriberDelivery.getStatus() ); 여기서 중요한 건 단순히 SMTP 메일 한 통을 보내본 게 아니라 수신자 등록부터 실제 발송 이력 저장까지 전부 연결해서 확인했다는 것 이다. 두 번 실행하면 또 보내지 않을까? 실제 메일을 보내는 테스트라서 이것도 확인해야 했다. 한 번 성공한 알림을 다시 실행했을 때 같은 메일이 또 가면 안 된다. 그래서 같은 상태에서 dispatchPending() 을 한 번 더 실행했다. NotificationDispatchResult duplicateDispatch = dispatcher.dispatchPending(); assertEquals(0, duplicateDispatch.pendingCount()); assertEquals(0, duplicateDispatch.sentCount()); assertEquals(0, duplicateDispatch.failedCount()); 수신자별 발송 기록도 하나만 남아 있는지 확인했다. assertEquals( 1, subscriberDeliveryRepository .findByNotificationDeliveryId(deliveryId) .size() ); 실제 SMTP까지 연결된 상태에서도 중복 발송이 막히는지 확인한 셈이다. 근데 이 테스트를 매번 실행하면 안 된다 이건 실제 메일을 보내는 테스트다. 일반 테스트 실행할 때마다 메일이 날아가면 곤란하다. 그래서 환경변수를 명시적으로 켰을 때만 실행하도록 했다. @EnabledIfEnvironmentVariable( named = "BIZ_ASSIST_SUBSCRIBER_SMTP_LIVE_TEST", matches = "true" ) SMTP 계정이나 수신자 정보도 테스트 코드에 직접 넣지 않았다. 필요한 값은 전부 환경변수로 받도록 했다. 테스트할 때만 의도적으로 켜고 평소에는 실행되지 않게 했다. 이제 진짜 한 번 연결해봤다 22편에서는 SMTP 서버로 메일 자체가 발송되는지 확인했다. 23편에서는 수신자를 여러 명 관리하고 발송 결과를 따로 저장하도록 만들었다. 이번에는 그 두 가지를 실제 흐름으로 연결해서 확인했다. 수신자 API 등록 ↓ baseline 생성 ↓ 새 PIA 공지 감지 ↓ 알림 후보 생성 ↓ 실제 SMTP 발송 ↓ 알림 SENT ↓ 수신자별 발송 SENT ↓ 재실행 시 중복 발송 없음 기능을 하나씩 테스트하는 것과 실제로 처음부터 끝까지 한 번 연결해 보는 건 조금 달랐다. 이번에는 “메일을 보낼 수 있다”가 아니라 “Biz Assist의 알림 흐름으로 실제 메일이 한 번만 발송된다”까지 확인했다.