Loading the catalog…
Loading the catalog…
1. 서비스 소개 '샥(syak)'은 매장의 빈자리나 갑작스럽게 발생한 취소석을 실시간으로 감지하고, 해당 자리를 필요로 하는 소비자에게 맞춤형으로 매칭하여 예약을 연결해 주는 자동 매칭 서비스다. 매장(살롱 등)의 원장님에게는 비어 버린 예약 슬롯을 빠르게 채워 매출 손실을 막아주고, 소비자에게는 예약하기 힘든 인기 매장의 취소석을 선점할 수 있는 기회를 제공한다. 이를 위해 백엔드와 관리자 시스템은 실시간 슬롯 상태 변화와 정교한 카탈로그 조회 기능을 안정적으로 처리해야 한다. 2. 문제 이번 주 개발자 y10b가 해결하고자 한 핵심 문제는 운영 환경의 자원 제약으로 인한 데이터베이스 부하 와 수동 마케팅 작업의 비효율성 이었다. 첫째, 서비스가 구동되는 운영 환경은 912MB 메모리를 가진 소형 EC2 인스턴스였다. 이 환경에는 Redis가 따로 설정되어 있지 않아, 기존의 캐시 레이어가 캐싱을 전혀 수행하지 않는 NullCacheService (no-op)로 동작하고 있었다. 이로 인해 트래픽이 집중되는 소비자 카탈로그 조회와 지도 검색 요청이 매번 Supabase 실시간 DB로 직행했다. 무료 티어 Supabase의 지연 시간(간헐적으로 1초 이상 발생)이 고스란히 사용자 경험 저하로 이어졌다. 특히 지도 검색 시 사용자가 화면을 미세하게 움직일 때마다 정밀한 위경도 좌표가 매번 달라져 캐시 키가 미스되는 구조적 한계가 존재했다. 둘째, 관리자 페이지의 통계 및 파트너숍 조회 성능 문제였다. 기존 코드는 Supabase의 기본 1000행 제한(PostgREST limit) 범위 안에서 데이터를 조회할 때, 수집 범위가 늘어남에 따라 페이지네이션 루프를 중복 작성하다가 일부 데이터가 누락되는 버그를 안고 있었다. 또한 대량의 JSONB 데이터를 정렬 조건과 함께 조회하면서 발생하는 JSONB detoast 부하로 인해 데이터베이스 타임아웃 위험에 상시 노출되어 있었다. 셋째, 인스타그램 쓰레드(Threads)를 통한 소통 공수가 너무 컸다. 우리 채널에 달린 외부 고객의 댓글에 답글을 달거나 새로운 마케팅 글을 올리는 과정이 완전히 수동으로 처리되어 원장님들의 마케팅 피로도가 누적되고 있었다. 3. 판단 문제를 해결하기 위해 인프라를 무리하게 확장하는 대신, 주어진 소형 서버 환경에서 효율을 극대화할 수 있는 소프트웨어적 해결책을 선택했다. 인메모리 캐시 fallback 및 보수적 상한 설정: Redis를 추가로 띄우기 어려운 912MB EC2 환경임을 감안하여, 가용 메모리(~460MB) 범위 내에서 안전하게 동작할 수 있는 프로세스 내 LRU+TTL 기반의 InMemoryCacheService 를 구현했다. 리스트 엔트리가 커질 경우를 대비해 캐시 저장소의 상한을 300개로 보수적으로 제한했다. 이는 인기 매장 상세 정보와 자주 조회되는 목록을 커버하기에 충분한 크기다. 지도 좌표의 격자 스냅(Grid Snapping): 위경도 좌표를 소수점 둘째 자리(약 1.1km 격자)로 반올림하여 스냅한 뒤 캐시 키로 사용하기로 했다. 5km 박스 검색 범위 내에서는 중심점이 0.5km 내외로 이동하더라도 결과 차이가 사실상 없기 때문에, 근처 화면 이동 시 동일한 캐시를 공유하게 만들어 캐시 적중률을 극적으로 끌어올릴 수 있다. 페이지네이션 헬퍼 통합 및 통계 캐싱: 중복되던 range 루프를 fetchAllRows 헬퍼 함수 하나로 통일하여 데이터 누락 위험을 제거했다. 또한 관리자 통계 조회 결과에 2분의 짧은 인메모리 캐시를 적용하여, 관리자가 대시보드를 반복 새로고침하더라도 데이터베이스 왕복 지연을 발생시키지 않도록 설계했다. 원장님 말투를 학습한 쓰레드 마케팅 자동화: 쓰레드 API를 연동하여 외부 댓글을 수집하고, 우리가 이전에 작성했던 답글들을 '톤 샘플'로 추출했다. 이를 Gemini API에 few-shot 샘플로 전달함으로써, 인공지능이 원장님 특유의 친근한 말투("~더라고요", "프로필 링크에 정리해뒀습니다")를 학습하여 맞춤형 답글 초안을 추천하도록 구현했다. 4. 변경 전 / 변경 후 캐시 폴백 구조 변경 (BE: composition-root.ts ) 기존에는 Redis 연결 정보가 없으면 아무런 캐싱도 하지 않는 무력화 상태였으나, 변경 후에는 메모리 상한을 300으로 둔 인메모리 캐시 서비스로 안전하게 폴백하도록 개선했다. // 변경 전 const cache: ICacheService = process.env.REDIS_URL ? new RedisCacheService(process.env.REDIS_URL) : new NullCacheService(); // 변경 후 const cache: ICacheService = process.env.REDIS_URL ? new RedisCacheService(process.env.REDIS_URL) : new InMemoryCacheService(300); 지도 좌표 격자 스냅 적용 (BE: PgShopRepository.ts ) 정밀 좌표를 그대로 캐시 키로 쓰던 로직을 소수점 둘째 자리로 반올림 처리하여 미세한 지도 조작 시에도 캐시를 재사용할 수 있도록 변경했다. // 변경 후 async findMany(filter: ShopFilter): Promise<ShopListResult> { if (filter.lat != null && filter.lng != null) { filter = { ...filter, lat: Math.round(filter.lat * 100) / 100, lng: Math.round(filter.lng * 100) / 100 }; } const cacheKey = filterCacheKey(filter); const cached = await this.cache.get<ShopListResult>(cacheKey); if (cached) return cached; // DB 조회 및 캐시 저장 로직 수행... } 1000행 한도 누락 방지용 공통 페이지네이션 헬퍼 (BE: AdminController.ts ) 각 통계 조회 핸들러마다 개별적으로 작성하면서 누락 버그를 유발했던 페이지네이션 흐름을 안전한 공통 헬퍼 함수로 통합했다. // 변경 후 async function fetchAllRows<T>( buildPage: (from: number, to: number) => PromiseLike<{ data: T[] | null; error: unknown }>, batch = 1000, ): Promise<T[]> { const all: T[] = []; let offset = 0; for (;;) { const { data, error } = await buildPage(offset, offset + batch - 1); if (error) throw error; const rows = data ?? []; all.push(...rows); if (rows.length < batch) break; offset += batch; } return all; } LRU 및 TTL 지원 인메모리 캐시 구현 (BE: InMemoryCacheService.ts ) 별도 Redis 인프라가 없는 환경에서도 프로세스 내에서 안전하게 만료 시간과 최대 엔트리 수를 관리하는 캐시 서비스를 새로 구축했다. // 변경 후 export class InMemoryCacheService implements ICacheService { private store = new Map<string, { value: unknown; expiresAt: number }>(); constructor(private readonly maxEntries = 1000) {} async get<T>(key: string): Promise<T | null> { const hit = this.store.get(key); if (!hit) return null; if (Date.now() > hit.expiresAt) { this.store.delete(key); return null; } this.store.delete(key); this.store.set(key, hit); // LRU 순서 갱신 return hit.value as T; } async set(key: string, value: unknown, ttlSeconds: number): Promise<void> { if (this.store.has(key)) this.store.delete(key); this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 }); while (this.store.size > this.maxEntries) { const oldest = this.store.keys().next().value; if (oldest === undefined) break; this.store.delete(oldest); } } async del(key: string): Promise<void> { this.store.delete(key); } } 5. 결과 이번 최적화와 기능 개발을 통해 인프라 비용을 추가로 지출하지 않고도 시스템의 응답성과 마케팅 생산성을 대폭 개선했다. 서버 자원 보호 및 캐시 적중률 상승: 912MB 소형 서버 환경에서 안전한 인메모리 캐시 레이어가 정상 작동하기 시작했다. 특히 지도 좌표 스냅 덕분에 사용자가 지도를 움직여 주변 매장을 검색할 때 Supabase 직행 쿼리가 차단되고, 캐시 범위 내에서 즉각적인 응답이 가능해졌다. 관리자 도구의 안정성 확보: 1000행 한도 누락 문제를 헬퍼 함수로 완전 차단했고, 관리자 통계 조회 API에 2분의 TTL 캐시를 적용하여 관리자 새로고침 시 발생하는 Supabase 지연을 제거(응답 속도 0~1ms 수준으로 단축)했다. 마케팅 자동화 파이프라인 구축: 관리자 페이지의 마케팅 탭에서 쓰레드 댓글과 원장님 말투를 모방한 AI 추천 답글을 실시간으로 확인할 수 있게 되었다. 추천된 초안을 대시보드 내에서 간편하게 편집한 뒤, API 연동을 통해 즉시 쓰레드 답글로 등록하거나 새 글을 원클릭으로 발행하는 기능까지 성공적으로 배포되었다.
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
Redis 없는 소형 서버에서 인메모리 캐시와 좌표 스냅으로 DB 부하를 차단하고 쓰레드 마케팅을 자동화하기. 1. 서비스 소개 '샥(syak)'은 매장의 빈자리나 갑작스럽게 발생한 취소석을 실시간으로 감지하고, 해당 자리를 필요로 하는 소비자에게 맞춤형으로 매칭하여 예약을 연결해 주는 자동 매칭 서비스다. 매장(살롱 등)의 원장님에게는 비어 버린 예약 슬롯을 빠르게 채워 매출 손실을 막아주고, 소비자에게는 예약하기 힘든 인기 매장의 취소석을 선점할 수 있는 기회를 제공한다. 이를 위해 백엔드와 관리자 시스템은 실시간 슬롯 상태 변화와 정교한 카탈로그 조회 기능을 안정적으로 처리해야 한다. 2. 문제 이번 주 개발자 y10b가 해결하고자 한 핵심 문제는 운영 환경의 자원 제약으로 인한 데이터베이스 부하 와…
Open source