DEV Community

김이더
김이더

Posted on

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

네이버 쇼핑 API는 연동이 쉽다. 호출하면 JSON으로 상품 리스트가 온다.

근데 그걸 "가격비교 서비스"로 만들려면 얘기가 달라진다. API가 주는 데이터를 그대로 보여주면 쓸 수 없는 앱이 된다. VapeCompare를 만들면서 문제가 하나씩 드러난 순서대로 기록한다.

API를 붙이기 전에는 몰랐다

네이버 쇼핑 API 연동 자체는 간단했다. 서버에서 API를 호출하고, HTML 태그를 벗기고, 필요한 필드만 뽑아서 프론트에 넘기면 된다.

const items = data.items.map(item => ({
  title: item.title.replace(/<[^>]*>/g, ''),
  link: item.link,
  image: item.image,
  lprice: Number(item.lprice),
  mallName: item.mallName,
  productId: item.productId,
  category1: item.category1,
  category2: item.category2,
  category3: item.category3,
}));
Enter fullscreen mode Exit fullscreen mode

처음 연동한 버전은 이게 끝이었다. 필터링 없이 API 응답을 그대로 프론트에 넘겼다. 하드코딩된 data.js에서는 내가 직접 고른 상품만 들어있으니까 노이즈가 없었다. API는 다르다.

"전자담배 액상"을 검색했는데 화면에 충전기가 나온다. 기기가 나온다. 케이스, 드립팁, 유리탱크까지 섞여 있다.

이전 글에서 UI를 먼저 만들고 API를 나중에 붙인다고 했는데, 그 이유가 여기서 증명됐다. 화면이 이미 있으니까 데이터가 이상한 게 바로 보인다. API를 붙이자마자 "이건 아니다"가 명확했다.

제외 키워드가 포함 키워드보다 중요하다

첫 반응은 단순했다. '액상' 키워드가 포함된 것만 보여주면 되겠지.

근데 "액상형 전자담배 기기"라는 상품이 있다. '액상'이 들어있으니까 필터를 통과해버린다. 포함 키워드만으로는 노이즈를 못 잡는다.

제외 키워드를 먼저 걸어야 했다.

const LIQUID_KEYWORDS = ['액상', '쥬스', 'juice', 'liquid', '리퀴드'];
const EXCLUDE_KEYWORDS = ['기기', '본체', '배터리', '충전기', '코일', '',
  '케이스', '파우치', '거치대', '드립팁', '유리탱크', '실리콘'];

items = mapped.filter(item => {
  const text = (item.title + ' ' + item.category3).toLowerCase();
  if (EXCLUDE_KEYWORDS.some(kw => text.includes(kw))) return false;
  if (LIQUID_KEYWORDS.some(kw => text.includes(kw))) return true;
  if (item.category3 && item.category3.includes('액세서리')) return true;
  if (item.lprice > 50000) return false;
  return true;
});
Enter fullscreen mode Exit fullscreen mode

순서가 핵심이다. 제외를 먼저 걸고, 통과한 것만 포함 키워드로 확인한다. 카테고리3에 '액세서리'가 있으면 통과시키고, 그래도 빠져나온 것 중에 가격이 5만원 넘으면 기기일 확률이 높으니까 마지막 안전장치로 걸렀다.

같은 커밋에서 검색어 자동보정도 넣었다. 사용자가 "멘솔"만 입력하면 앱이 "전자담배 액상 멘솔"로 바꿔서 API를 호출한다.

const input = searchInput.trim();
query = input
  ? (input.includes('액상') ? input : `전자담배 액상 ${input}`)
  : '전자담배 액상';
Enter fullscreen mode Exit fullscreen mode

필터링만 하면 네이버 API가 애초에 엉뚱한 결과를 보내는 경우를 못 막는다. 검색어 자체를 정확하게 만들어서 입력 단계에서 노이즈를 줄이고, 그래도 빠져나온 것들은 서버에서 키워드로 걸러내는 이중 방어다.

게임 서버에서 입력 검증할 때랑 같은 패턴이다. 클라이언트에서 한 번, 서버에서 한 번. 한쪽만 믿으면 뚫린다.

카테고리를 분리할 수밖에 없었다

필터링으로 액상 검색 결과가 깔끔해졌다. 근데 새로운 문제가 생겼다. 사용자가 기기를 검색하고 싶으면? 코일을 찾고 싶으면?

기존 코드는 액상만 생각하고 짠 거라, 제외 키워드에 '기기', '코일'이 들어있었다. 기기를 보여주려면 이 필터링 로직 자체를 카테고리마다 다르게 적용해야 했다.

결국 API 엔드포인트에 category 파라미터를 추가하고, 서버에서 카테고리에 따라 다른 필터를 태우게 만들었다.

const { query, display = 20, sort = 'sim', category = 'liquid' } = req.query;
const cacheKey = `${query}|${display}|${sort}|${category}`;

if (category === 'device') {
  items = mapped.filter(item => {
    const text = (item.title + ' ' + item.category3).toLowerCase();
    return DEVICE_KEYWORDS.some(kw => text.includes(kw));
  });
} else if (category === 'coil') {
  items = mapped.filter(item => {
    const text = (item.title + ' ' + item.category3).toLowerCase();
    return COIL_KEYWORDS.some(kw => text.includes(kw));
  });
} else {
  // liquid: 제외 → 포함 순서로 필터링
  const EXCLUDE = ['기기', '본체', '배터리', '충전기', '코일', '케이스', ...];
  items = mapped.filter(item => { ... });
}
Enter fullscreen mode Exit fullscreen mode

캐시 키에도 category를 추가했다. 안 그러면 "전자담배 액상"으로 액상 탭에서 검색한 캐시가 기기 탭에서도 그대로 나온다. 같은 쿼리라도 카테고리가 다르면 결과가 달라야 하니까.

프론트에서는 액상, 기기, 코일 세 개의 탭을 만들었다. 각 탭은 기본 검색어가 다르다. "전자담배 액상", "전자담배 기기", "전자담배 코일"로 각각 API를 호출한다.

let activeCategory = $state('liquid');
const categories = [
  { id: 'liquid', label: '💧 액상', query: '전자담배 액상' },
  { id: 'device', label: '🔧 기기', query: '전자담배 기기' },
  { id: 'coil', label: '🔩 코일', query: '전자담배 코일' },
];
Enter fullscreen mode Exit fullscreen mode

맛 퀵 필터(폐호흡, 입호흡, 소다, 멘솔 등)도 액상 카테고리 전용으로 분리했다. 기기 탭에서 "소다맛" 필터가 보이면 이상하니까.

같은 API인데 검색어와 필터 조합으로 완전히 다른 결과를 만들어내는 구조. 여기까지 오니까 카테고리 안에서 또 다른 문제가 보였다.

같은 상품이 화면을 도배한다

"전자담배 액상 멘솔"로 검색하면 40개 결과가 오는데, 같은 제품이 판매처만 다르게 여러 번 등장한다. 가격비교 앱에서 이건 치명적이다. 같은 제품이 화면을 도배하면 비교 자체가 안 된다.

처음엔 productId로 중복을 잡으면 될 줄 알았다. 근데 같은 제품인데 productId가 다른 경우가 있다. 판매처마다 상품을 따로 등록하니까 ID가 다르다.

결국 2단계 중복 제거를 만들었다.

// 1단계: productId 기준
const byProductId = new Map();
for (const item of items) {
  const key = item.productId || item.title;
  if (!byProductId.has(key) || item.lprice < byProductId.get(key).lprice) {
    byProductId.set(key, item);
  }
}

// 2단계: 제목 정규화 — 공백/특수문자 제거 후 비교
const normalize = (s) => s.replace(/[\s\-\_\(\)\[\]\/]/g, '').toLowerCase();
const byTitle = new Map();
for (const item of byProductId.values()) {
  const key = normalize(item.title);
  if (!byTitle.has(key) || item.lprice < byTitle.get(key).lprice) {
    byTitle.set(key, item);
  }
}
const deduped = [...byTitle.values()];
Enter fullscreen mode Exit fullscreen mode

productId로 한 번 거르고, 제목에서 공백과 특수문자를 전부 날린 뒤 다시 한 번 거른다. 같은 제품이면 최저가만 남긴다.

게임 서버에서 엔티티 중복 처리할 때랑 똑같은 패턴이다. 키를 정규화해서 Map에 넣고, 충돌하면 우선순위(여기서는 최저가)로 덮어쓴다.

이 커밋에서 응답의 totaldata.total(API 원본 개수)에서 deduped.length(중복 제거 후 개수)로 바꿨다. 안 그러면 "40개 결과"라고 뜨는데 실제로는 25개만 보이는 상황이 생긴다. 숫자가 안 맞으면 사용자는 버그라고 느낀다.


React + 하드코딩에서 여기까지

돌아보면 이 세 가지 문제는 순서대로 나타났다. API를 연동하니까 노이즈가 보였고, 노이즈를 걸러내니까 카테고리 분리가 필요해졌고, 카테고리를 나누니까 같은 카테고리 안에서 중복이 눈에 들어왔다. 한 문제를 해결하면 다음 문제가 드러나는 구조.

처음에는 React + data.js 하드코딩이었다. 판매처 URL을 하나하나 넣어두고, 상품을 직접 골라서 추가하는 정적 카탈로그. 지금은 검색어 하나 치면 네이버 쇼핑 전체에서 실시간으로 상품을 가져오고, 노이즈를 걸러내고, 중복을 제거해서 최저가만 보여주는 앱이 됐다.

Before: data.js에 상품 30개 하드코딩 → 수동 업데이트
After:  네이버 쇼핑 API → 필터링 → 카테고리 분리 → 2단계 중복 제거 → 최저가 표시
Enter fullscreen mode Exit fullscreen mode

서버 파일 하나, Svelte 컴포넌트 하나, CSS 하나. 전체 서버 코드 120줄. 커밋 11개. 하루 저녁에 끝냈다.

코드 양이 적다는 게 포인트가 아니다. 이 120줄에 도달하기까지 필터링 → 검색어 보정 → 카테고리 분기 → 중복 제거 → 캐싱을 차례로 쌓아올린 거고, 이 순서는 API 데이터를 직접 받아보기 전에는 알 수 없었다.

사이드프로젝트에서 배운 건 항상 같다. 완벽한 설계로 시작하는 게 아니라, 돌아가는 걸 먼저 만들고 나서 부딪히는 문제를 하나씩 잡아가는 거다. React에서 Svelte로 갈아엎은 것도, 하드코딩에서 API로 전환한 것도, 필터링을 세 번 고친 것도 전부 그 과정이다.

"완벽하게 설계하고 시작하는 프로젝트는 시작도 못 한다. 일단 돌아가게 만들고, 부딪혀라."

Top comments (0)