또 다른 ETL: 야간 리프트 티켓

발행: (2025년 12월 23일 오전 04:00 GMT+9)
5 분 소요
원문: Dev.to

Source: Dev.to

배경

저는 시카고에 살고 있으며, 겨울에 스노보드를 탈 수 있다는 점을 좋아합니다. 저는 스노보드를 잘 타지는 못하지만 즐깁니다. 보통 갈 곳을 찾기 위해 여러 웹사이트를 열어보곤 합니다. 비슷한 내용의 여러 사이트를 같은 목적을 위해 반복해서 열게 될 때면, 제 머릿속은 바로 ETL을 떠올립니다.

저는 야간 리프트 티켓에 관심이 있는데, 가격이 가장 저렴하고, 지도에서 위치를 확인해 집에서 얼마나 떨어져 있는지 알고 싶습니다.

Source:

ETL 구조

내 모든 ETL은 동일한 구조를 공유합니다:

  • Extract – 대상 페이지의 HTML을 가져옵니다.
  • Transform – HTML을 파싱하고 가격을 추출합니다.
  • Load – 결과를 JSON 파일에 저장합니다.

extract와 load 단계는 모든 스키 리조트에 대해 동일하므로 common.js 파일에 공통으로 두었습니다. 유일하게 커스텀이 필요한 부분은 transform 함수이며, 여기서는 CSS 선택자를 사용해 HTML에서 가격을 찾습니다.

Transform (Node.js)

// transform.js
const cheerio = require("cheerio");
const { extract, load, loggerInfo } = require("./common");

async function transform(html) {
  const $ = cheerio.load(html);

  const price = $(
    $(".seasoncontainer.liftcontainer.clsDaynight.clsNight li")[2]
  )
    .find("p")
    .text()
    .replace("$", "");

  return {
    price: parseInt(price, 10),
  };
}

async function main() {
  const place = {
    id: "cascademountain",
    website: "https://www.cascademountain.com",
    name: "Cascade Mountain",
    lat: 43.502728,
    lng: -89.515996,
    gmaps: "https://maps.app.goo.gl/YWdnQvZiJZwPhj79A",
    url: "https://www.cascademountain.com/lift-tickets/",
  };

  loggerInfo("etl start", { id: place.id });

  const html = await extract(place.url);
  const data = await transform(html);
  await load(place, data);

  loggerInfo("etl done", { id: place.id });
}

main().then(() => {});

CSS 선택자(.seasoncontainer.liftcontainer.clsDaynight.clsNight li)는 현재 사이트에 맞게 동작하지만, 클래스가 변경되면 스크래퍼가 깨집니다. 실제 운영 환경에서는 추출 실패에 대한 알림을 추가하거나, 가격 위치를 찾기 위해 AI 모델(예: Gemini)을 활용할 수 있습니다.

추출 함수

// common.js (excerpt)
async function extract(url) {
  loggerInfo("extracting", { url });

  const response = await fetch(url);
  const html = await response.text();
  return html;
}

이 함수의 변형은 Puppeteer를 사용하여 단순 fetch 요청을 차단하는 사이트에 대응합니다.

Load Function

// common.js (excerpt)
const fs = require("fs").promises;

async function load(place, extraData) {
  const data = { ...place, ...extraData };
  loggerInfo("load", data);

  const filename = `public/sites/${data.id}.json`;
  await fs.writeFile(filename, JSON.stringify(data, null, 2));

  loggerInfo("saved", { filename });
}

현재 데이터는 public/sites/에 JSON 파일로 저장됩니다. 프로덕션 환경에서는 DynamoDB와 같은 데이터베이스가 더 적합합니다.

로깅

// common.js (excerpt)
const loggerInfo = (...args) => {
  console.log(...args);
};

loggerInfoconsole.log의 얇은 래퍼입니다. 실제 애플리케이션에서는 New Relic, Datadog 또는 다른 로깅 서비스로 교체할 수 있습니다.

Next.js를 사용한 프런트엔드

ETL이 all-places.json을 채운 후, 다음 단계는 리조트를 지도에 표시하는 간단한 사이트를 구축하는 것입니다.

Copilot과 함께 사용한 프롬프트

On this Next.js project, build a page with the following requirements:
- Only one page.
- The page should show a map using Google Maps.
- The map should show a marker for each place found in `all-places.json`, displaying the price.
- The page should have Google Analytics.
- The page should let the user click a marker and open a small card with the place information: name, link to Google Maps, and link to the `url` found in the JSON.
- The UI should be simple and use inline styles.

Copilot은 시작 구현을 생성하며, 필요에 따라 조정할 수 있습니다.

결론

ETL은 야간 리프트 티켓 가격을 추출하여 JSON 파일에 저장하고, 데이터를 Google 지도에 시각화하는 최소한의 Next.js 프런트엔드에 전달합니다. 이 접근 방식은 소규모의 정적 스키장 목록에 적합합니다; 규모를 확대하려면 장소 목록을 자동화(예: Google Maps API 활용)하고 데이터 저장소를 적절한 데이터베이스로 이전해야 합니다.

사이트를 확인해 보시고 의견을 알려 주세요!

Back to Blog

관련 글

더 보기 »