스팀 게임과 리뷰 크롤링

Jaejun Kim·2023년 1월 5일
0

스팀의 게임을 검색할 수 있는 프로젝트를 진행했다.
https://github.com/SteamReviewSearch

게임 이름으로 게임 검색이 가능하고, 고른 게임의 리뷰를 볼 수 있는 간단한 기능을 탑재했는데, 이를 위해서 게임 데이터와 리뷰 데이터가 필요했다.

처음에는 데이터셋을 사용할 생각이었다. 검색 자체도 원래 생각은 게임 데이터 따로 없이 리뷰만 보여주는 식이었고. 데이터셋만 해도 수백만개가 넘었기 때문에 겉으로 보기에는 문제가 없어보였다.

하지만 얼마 지나지 않아 그 내부를 뜯어본 후에는 생각이 직접 크롤링을 하는 쪽으로 마음이 기울었다. 검색하는 종류는 게임인데 그 게임의 종류가 생각보다 많지 않았다는 점이 조금 걸렸다. 그리고 기왕이면 최신 게임도 검색결과에 나오도록 해주고 싶었다.

크롤링에는 스팀에서 제공하는 Web API를 사용했으며 NodeJS 환경에서 진행했다.

https://store.steampowered.com/robots.txt

Host: store.steampowered.com
User-Agent: *
Disallow: /share/
Disallow: /news/externalpost/
Disallow: /account/emailoptout/?*token=
Disallow: /login/?*guestpasskey=
Disallow: /join/?*redir=
Disallow: /account/ackgift/
Disallow: /email/
Disallow: /widget/

세가지 API를 활용했다.
1. 스팀에서 판매중인 모든 게임의 이름과 APPID를 제공함
https://api.steampowered.com/ISteamApps/GetAppList/v2
2. APPID를 받아 게임의 디테일 정보를 제공함. 언어 옵션 제공
https://store.steampowered.com/api/appdetails?appids=10&l=korean
3. APPID를 받아 게임의 리뷰 리스트를 제공함. 리턴받을 리뷰 갯수, 필터 정렬 옵션 제공
http://store.steampowered.com/appreviews/10?json=1&l=koreana&filter=recent&num_per_page=100

// 불러보자 1번
let res = await request(
    "Get", "https://api.steampowered.com/ISteamApps/GetAppList/v2"
  );
let name = res[0].apps.name
let appid = res[0].apps.appid


// 불러보자 2번
let getAppDetail = async (appid) => await axios
	.get(
    `https://store.steampowered.com/api/appdetails?appids=${appid}&l=korean`,
    {
      contentType: "utf-8",
    },
    {
      baseURL: `https://store.steampowered.com/`,
      timeout: 60000, //아웃바운드 문제 - 포트복제등이 timeout으로 쌓이다보니까 나중에 요청을 보내지를 못함.
      httpsAgent: new https.Agent({ keepAlive: true }),
      headers: { "Content-Type": "application/xml" },
    }
  )
	.then(async (response) => {~~~}).catch(async (error) => {console.log(error})
    
    
// 불러보자 3번
let getAppReviews = async (appid) => await axios
	.get(
        `http://store.steampowered.com/appreviews/${appid}?json=1&l=english&filter=recent&num_per_page=100`,
        {
          contentType: "utf-8",
        },
        {
          baseURL: `http://store.steampowered.com/appreviews/${appid}?json=1&l=english&filter=recent&num_per_page=100`,
          timeout: 60000, //optional
          httpsAgent: new https.Agent({ keepAlive: true }),
          headers: { "Content-Type": "application/xml" },
        }
      )
	.then(async (response, next) => {~~~}).catch(async (error) => {console.log(error})

request나 axios등을 사용해 반환값을 받아내었고, 이를 가공하여 데이터베이스에 저장하였다.

처음에는 MySQL을 사용했으나 이후 엘라스틱서치를 사용함에 따라 가공한 정보를 바로 엘라스틱서치에 집어넣게 되었다.

수집하는 데이터는 두 종류로 게임과 리뷰였다. 게임이든 리뷰든 '초기에' 집어넣는 것은 큰 문제가 아니었다! 어떤 저장소를 사용하든 그냥 insert 쿼리를 보내면 넣어질테고 그 속도는 WEB API 요청 속도보다 훨씬 빠르다.

그보다 우선 해결해야 하는 문제는 입맛 까탈스러운 Steam Web Api의 요청거부 조건을 아는 것이었다.

나는 이번 크롤링을 하기 전까지 크롤링의 ㅋ자도 모르는 사람이었다. 끽해봐야 영화사이트에서 영화 몇개 긁어만든 웹사이트가 내 크롤링 경험의 전부였다.

그래서 첫 시도에는 NodeJS가 처리하는 속도로 요청을 보냈다. 리스트를 가져오고, for문을 돌려 반복적으로 api 요청을 보냈다. 못해도 초에 수천, 수만건의 요청이 갔을 것이다. 결과는? 당연히 몇개 가지 않아 거부당했다.

원인은 비동기 처리에 있었다. WEB API 요청은 비동기로 동시다발적으로 이루어졌다. 하나의 요청이 끝나기 전에 다음 반복이 시작되는 상황을 제어하지 못했고 결국 엉망진창인 결과가 나온 것이다. 비동기를 끝까지 잡아 처리할 수 있으려면 반복문도 비동기 처리가 되도록 비동기 함수 안에 가둬야 했다.


const https = require("https");
const axios = require("axios");


let work = async (appid) => await axios.get(
  `https://store.steampowered.com/api/appdetails?appids=${appid}&l=korean`,
  {
    contentType: "utf-8",
  },
  {
    baseURL: `https://store.steampowered.com/`,
    timeout: 60000, //아웃바운드 문제 - 포트복제등이 timeout으로 쌓이다보니까 나중에 요청을 보내지를 못함.
    httpsAgent: new https.Agent({ keepAlive: true }),
    headers: { "Content-Type": "application/xml" },
  }
)
  .then(async (response) => {
    return true
  })
  .catch(async (error) => {
    return false
  });


let test = async () => {
  // 500번 요청 보내기 
  let success = 0;
  let fail = 0;
  for (let i = 1; i < 500; i++) {
    let result = await work(i)
    if (result) {
      success++
      console.log("success: ", success)
    } else {
      fail++
      console.log("fail: ", fail)
    }
  }
  console.log("success: ", success, " failure: ", fail)
}

test()

// success:  200  failure:  299

하나씩 차례차례 실행되니 반환속도가 느려서인지 0.5초 정도에 하나씩 처리가 되는 모습을 보였다. 또한 요청 속도도 자연스레 줄어들어 요청을 거부당하는 타이밍도 조금 늦춰지게 되었다.

이제 요청을 거부당하지 않는 텀을 알고 텀을 늦춰서 크롤링을 한다면 적어도 요청을 거부당하지는 않을 것이다.

let setTimeoutPromise = (ms) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => ressolve(), ms);
  });
}

let test = async () => {
  // 5000번 요청 보내기 
  let success = 0;
  let fail = 0;
  for (let i = 1; i < 500; i++) {

    let result = await work(i)
    if (result) {
      success++
      console.log("success: ", success)
    } else {
      fail++
      console.log("fail: ", fail)
    }

    await setTimeoutPromise(7000)
  }
  console.log("success: ", success, " failure: ", fail)
}

일반 setTimeout을 떠올렸고 넣어 보았지만 제어되지 않았다. 때문에 비동기 지연을 위한 함수를 따로 만들어서 await을 거니, 내부 함수가 끝날 때까지 프로세스가 정상적으로(?) 정지되었다.
https://www.daleseo.com/js-sleep/

실험끝에 게임 디테일 요청은 텀을 7초는 주어야 거부당하지 않았다. 만약 이 데이터가 절실히 필요한 상황이 아니라면 머뭇거릴 수준의 빡센 제약이다.

싱글스레드로는 이를 타파할 방법이 보이지 않았다. 이대로 15만건을 크롤링 하려면 최소 일주일에서 한달의 시간이 필요하기 때문에 우회할 방법을 찾게 되었다.

첫째로는 여러개의 컴퓨터 자원을 활용하였다. 컴퓨터 한개로 크롤링 하는 것보다는 두개로 하는게 속도가 빠른건 당연지사다. EC2와 동료 컴퓨터를 골고루 활용해서 범위를 나눠 크롤링을 진행했다.

둘째로는 Node.js 의 내장기능인 Worker_thereded 를 사용하여 동시적으로 요청을 보내도록 하였다. Web API의 속도 제한 기준을 알지 못했을 때에는 요청이 거부되는 주기를 일일이 테스트해가며 가장 안정적인 타이밍을 알아냈고 그것이 7초에 한번의 요청이었다.

하지만 지금와서 안 사실로는 이 요청 제한이 규칙을 갖고 있으며 그 정체가
IP 주소당 초당 10개 요청으로 제한
IP 주소당 분당 150개 요청으로 제한
API 키당 분당 300개의 요청으로 제한
라고 한다.

이게 사실일지는 확실하지 않으나 이게 만약 맞다면 멀티스레드를 사용하여 요청을 보냈을 때 요청이 왜 막히지 않았는지는 설명이 가능하다. 싱글스레드만을 사용했을 때는 왜 7초에 한번이 최선이었는지는 여전히 수수께끼긴 하지만 말이다.

//node --experimental-worker <file>
async function index_game() {
  const { Worker } = require("worker_threads");
  let startTime = process.uptime(); // 프로세스 시작 시간
  let jobSize = 10;
  let myWorker1, myWorker2, myWorker3

  myWorker1 = new Worker(__dirname + "/all_game_update.js");
  myWorker2 = new Worker(__dirname + "/all_game_update.js");
  myWorker3 = new Worker(__dirname + "/all_game_update.js");
  let endTime = process.uptime();
  console.log("main thread time: " + (endTime - startTime)); // 스레드 생성 시간 + doSomething 처리하는 데 걸린 시간.
}
module.exports = index_game;
index_game();

3개의 스레드를 생성하여 각 스레드는 지정된 파일의 로직을 실행한다.

const Worker = require("worker_threads");

let requestGames = async () => {
  let num = Worker.threadId;
  // 스레드 3개, 만약 요청할 갯수가 9999개라면 start를 조절하여 스레드당 1111개씩 3개의 컴퓨터가 분할하여 요청.
  let start = 0;
  // GetAppList/v2 에서 얻어온 게임 appid 리스트를 스레드에 분배
  let { list, start_point } = await finAllList(num, start);
  
  if (!list) {
    console.log(num, "번 스레드 필요없음")
    return;
  }
  // 각 스레드 요청 실시
  await updateAll(list, start_point)
};


let finAllList = async (offset, start) => {
  //게임 리스트 확보
  await detail.setTimeoutPromise((offset - 1) * 30000) // 30초에 하나씩 시작
  let res = await request(
    "Get",
    "https://api.steampowered.com/ISteamApps/GetAppList/v2"
  );
  if (res.getBody("utf8") !== undefined) {
    const response = JSON.parse(res.getBody("utf8"));
    if (res.getBody("utf8").slice(0, 6) !== "<HTML>") {
      let apps = response.applist.apps;
      
      // 각 스레드 시작지점 설정
      let start_point = ((offset - 1) * 1111) + start
      const log = `
      ===================================================================
        ${offset}-Worker START!! | 시작: ${start_point} | ${offset < 3 ? "30초 뒤 다음 worker 시작" : "Worker threads 시작 완료"} 
      ===================================================================
            `

      // 마지막 스레드 분기처리
      if (offset === 8) {
        if (start_point < apps.length) {
          if (start_point + 1111 > apps.length) {
            console.log(log)
            return { list: apps.slice(start_point, -1), start_point };
          } else {
            console.log(log)
            return { list: apps.slice(start_point, start_point + 1111), start_point };
          }
        }
        return { list: false, start_point };
      }

      const list = apps.slice(start_point, start_point + 1111)

      console.log(log)
      return { list, start_point };
    } else {
      console.log(res.body.slice(0, 6) + i);
    }
  }
};

0개의 댓글