DEV Community

김이더
김이더

Posted on

전자담배 가격비교 앱을 Svelte 5로 하루 만에 만든 방법

전자담배 액상을 살 때마다 네이버 쇼핑에서 같은 검색을 반복하고 있었다.

"폐호흡 액상" 검색하고, 기기가 섞여 나오면 다시 "액상"만 골라보고, 가격 비교하려고 탭을 5개씩 열고. 매번 이 짓을 하는 게 귀찮아서 그냥 만들었다. 전자담배 액상, 기기, 코일을 한 화면에서 검색하고 최저가를 바로 비교할 수 있는 앱.

이름은 VapeCompare.

React + 하드코딩으로 시작했다

처음에는 React + Vite로 프로젝트를 만들었다. data.js에 상품 데이터를 직접 넣어놓고, 브랜드별, 맛별, 타입별 필터를 만들고, 판매처마다 가격을 비교하는 구조. API 연동 없이 정적 데이터로 화면을 먼저 잡은 거다.

import { products, brands, flavors, types, getLowestPrice, getSortedSellers } from './data.js';
Enter fullscreen mode Exit fullscreen mode

data.js 안에는 쥬스고릴라, 배달의쥬스, 베이프쏘, 스무디액상, 전담성지 같은 판매처 정보도 하드코딩돼 있었다. 각 판매처별로 직접 URL을 넣어뒀다. juicegorilla.com/product/xxx, smartstore.naver.com/delivery_juice/xxx 이런 식으로.

근데 이 방식은 금방 한계가 왔다. 상품을 추가할 때마다 data.js를 직접 수정해야 한다. 가격이 바뀌면 수동으로 업데이트해야 한다. 판매처 URL이 바뀌면? 또 수정이다.

특히 구매 링크가 문제였다. 판매처마다 상품 URL 구조가 다 다르고, 상품 slug도 일관성이 없다. 이걸 하나하나 관리하는 건 현실적이지 않았다.

결국 직접 URL 대신 네이버 쇼핑 검색 링크로 바꿨다. 상품명과 판매처명을 조합해서 네이버 쇼핑 검색 결과로 연결하는 방식이다.

// Before: 판매처별 직접 URL
const shops = {
  jg: { name: '쥬스고릴라', base: 'https://juicegorilla.com/product/' },
  bj: { name: '배달의쥬스', base: 'https://smartstore.naver.com/delivery_juice/' },
};

// After: 네이버 쇼핑 검색으로 연결
export function makeShopUrl(productName, shopName) {
  const query = encodeURIComponent(`${productName} ${shopName} 전자담배 액상`);
  return `https://search.shopping.naver.com/search/all?query=${query}`;
}
Enter fullscreen mode Exit fullscreen mode

이 시점에서 깨달았다. 어차피 네이버 쇼핑 검색으로 보내는 거면, 아예 네이버 쇼핑 API로 실시간 데이터를 가져오면 되잖아. 하드코딩을 유지할 이유가 없다.

React를 버리고 Svelte 5로 전환했다

API 기반으로 전환하기 전에, 프레임워크부터 갈아탔다. React가 무거웠다.

검색, 카테고리 전환, 맛 필터, 정렬, 상세 페이지, URL 동기화까지 상태가 6개 이상이었다. useState에 setter 함수까지 매번 짝으로 선언하는 게 보일러플레이트가 많았다. 사이드프로젝트에서 이 무게감은 속도를 깎는다.

Svelte 5로 바꿨다. React 의존성, eslint 플러그인, JSX 설정까지 전부 삭제하고 처음부터 다시 짰다.

// React: useState 6개 + setter 6개 = 12줄
const [selectedType, setSelectedType] = useState('');
const [selectedBrands, setSelectedBrands] = useState([]);
const [selectedFlavors, setSelectedFlavors] = useState([]);
const [sortBy, setSortBy] = useState('price');
const [showFilters, setShowFilters] = useState(false);
const [selectedProduct, setSelectedProduct] = useState(null);

// Svelte 5: $state 6개 = 6줄
let selectedType = $state('');
let selectedBrands = $state([]);
let selectedFlavors = $state([]);
let sortBy = $state('price');
let showFilters = $state(false);
let selectedProduct = $state(null);
Enter fullscreen mode Exit fullscreen mode

줄 수만의 문제가 아니다. Svelte에서는 selectedType = '폐호흡'이면 끝이다. React에서는 setSelectedType('폐호흡')을 써야 한다. 상태를 바꾸는 곳마다 setter를 불러야 하니까, 코드 전체에서 차이가 쌓인다.

전환하자마자 문제가 생겼다. Express 5에서 SPA fallback용 와일드카드 라우트가 안 먹었다.

// Express 5에서 이게 안 됨
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
});

// 이렇게 바꿔야 됨
app.get('/{*splat}', (req, res) => {
  res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
});
Enter fullscreen mode Exit fullscreen mode

Express 5는 path-to-regexp v8을 쓰는데, 와일드카드 문법이 바뀌었다. Express 4 습관대로 *를 쓰면 라우트 매칭이 안 된다. 에러 메시지도 친절하지 않아서, 새로고침할 때마다 404가 뜨는데 원인을 바로 못 찾는 타입의 버그다.

UI를 다 만들고 나서 API를 붙였다

Svelte로 전환한 뒤에도 바로 API를 연동하지 않았다. data.js를 유지한 채로 UI를 먼저 완성했다.

필터 상태를 URL 쿼리 파라미터에 동기화해서 뒤로가기/앞으로가기가 작동하게 만들었다. $effect로 상태 변경을 감지하고, pushState로 URL을 업데이트하고, popstate 이벤트로 다시 복원하는 구조다.

// 필터 변경 → URL 업데이트
$effect(() => {
  selectedType; selectedBrands; selectedFlavors; sortBy;
  pushToURL();
});

// 뒤로가기 → 필터 복원
window.addEventListener('popstate', () => {
  skipPush = true;
  loadFromURL();
});
Enter fullscreen mode Exit fullscreen mode

그 다음에 다나와 스타일의 상세 페이지를 만들었다. 해시 라우팅(#/product/123)으로 상품 상세를 SPA 안에서 처리하고, 최저가 판매처 목록을 보여주는 레이아웃.

화면이 다 갖춰진 상태에서 네이버 쇼핑 API를 연동했다. data.js import를 전부 걷어내고, App.svelte를 통째로 다시 짰다. 하드코딩된 products, brands, flavors를 import하던 코드가 fetch('/api/search?query=...')로 바뀐 거다.

let query = $state('전자담배 액상');
let items = $state([]);
let loading = $state(false);

async function search(q = query) {
  loading = true;
  try {
    const res = await fetch(`/api/search?query=${encodeURIComponent(q)}&display=40&sort=${sortBy}`);
    const data = await res.json();
    items = data.items || [];
  } catch (e) {
    console.error(e);
    items = [];
  }
  loading = false;
}
Enter fullscreen mode Exit fullscreen mode

게임 개발할 때도 항상 이 순서다. 화면이 먼저 있으면 서버 데이터가 들어왔을 때 뭐가 빠졌는지 바로 보인다.

캐싱은 API와 동시에 넣었다

네이버 API는 일일 호출 제한이 있다. 이건 처음부터 알고 있었기 때문에 API 연동 직후에 바로 서버 메모리에 캐시를 넣었다.

쿼리 + 정렬 + 표시 개수를 조합한 문자열을 키로 쓰고, 10분 안에 같은 요청이 오면 캐시에서 바로 응답한다. Redis 같은 외부 스토어 없이 Map 하나로 충분하다.

const cache = new Map();
const CACHE_TTL = 10 * 60 * 1000;

function getCached(key) {
  const entry = cache.get(key);
  if (!entry) return null;
  if (Date.now() - entry.ts > CACHE_TTL) { cache.delete(key); return null; }
  return entry.data;
}

function setCache(key, data) {
  cache.set(key, { data, ts: Date.now() });
  if (cache.size > 500) {
    const oldest = cache.keys().next().value;
    cache.delete(oldest);
  }
}
Enter fullscreen mode Exit fullscreen mode

캐시 크기는 500개로 제한했다. 넘으면 가장 오래된 항목부터 삭제한다. Map의 삽입 순서가 보장되니까 keys().next().value가 항상 가장 먼저 들어온 키다.

health check 엔드포인트에도 cacheSize를 붙여서, 캐시가 제대로 쌓이고 있는지 확인할 수 있게 했다. 모니터링 없는 캐시는 블랙박스다.

여기까지가 "돌아가는 앱"이다. 근데 검색 결과를 보는 순간 문제가 터졌다. 액상을 검색했는데 충전기가 나온다. 같은 제품이 10번 나온다. 다음 글에서 이 삽질들을 다룬다.

"매일 하는 반복 작업이 보이면, 그게 사이드프로젝트 소재다."

Top comments (0)