[EDA 프로젝트] 게임 스트리밍과 게임 판매량의 연관성 분석 1: 데이터 수집 및 전처리
주제를 게임 스트리밍과 게임 판매량의 연관석 분석이라 설정했지만, 정확히 내가 하려는 분석의 방향성은
게임의 특성별(장르 등) 게임 스트리밍과 게임 판매량의 상관관계를 알아보는 것이었다.
예를 들어, RPG 장르가 비주얼 노벨 장르보다 게임 판매량에 스트리밍의 영향이 더 크다(홍보 효과가 크다)라는 것 등의 분석 결과가 나올 수 있도록 접근하는 것을 목표로 했다.
주제를 정하고 나서 가장 품이 많이 드는 파트라 해도 과언이 아닌 데이터 수집 파트에 돌입했다.
주제 정하기나 분석은 이것저것 생각하느라 머리가 아파서 힘든 거지만 데이터 수집은 여러 사이트를 돌아다녀야 하다 보니 시간과 노력이 꽤 든다.
데이터 수집 및 전처리
주제를 기반으로 필요한 데이터는 다음과 같이 정리했다.
게임 정보 데이터 + 스트리밍 데이터 + 게임 판매량 데이터
1. 게임 데이터
1-1) 데이터 수집 준비(API)
게임의 특성에 따른 비교를 위해 게임 정보 데이터가 반드시 필요했다.
나는 대표적인 글로벌 게임 스트리밍 플랫폼인 트위치와 트위치가 운영하는 게임 데이터베이스 사이트인 IGDB의 API를 활용했다.
IGDB API: https://api-docs.igdb.com/#getting-started
Twitch API: https://dev.twitch.tv/docs/api/
Twitch API
Twitch API
dev.twitch.tv
트위치 API를 사용하기 위해서는 사용자 토큰이 필요하다.
토큰 등록을 위해서는 트위치 아이디를 만든 후 API 홈페이지 오른쪽 위의 Your Console에서 응용 프로그램을 등록해야 한다.
이름은 원하는 대로 짓고 OAuth 리디렉션 URL에 http://localhost를 넣어주고, 범주를 Other로 선택, 클라이언트 유형을 기밀로 설정한 후 등록하면 클라이언트 ID가 생성된다.
생성된 응용 프로그램에서 클라이언트 시크릿을 준비해 주면 된다.
파이썬으로 넘어와서 준비된 정보를 기반으로 토큰을 조회하면 된다.+ 클라이언트 아이디와 시크릿은 변하지 않으니 코드를 써 놓고 필요할 때마다 실행하면 편하다.
import requests
req = requests.post(f'https://id.twitch.tv/oauth2/token?client_id={client_id}&client_secret={client_secret}&grant_type=client_credentials')
print(req.text)
그러면 이러한 형식으로 출력이 된다.
{"access_token":"유저 토큰","expires_in":만료 기간,"token_type":"bearer"}
액세스 토큰은 출력할 때마다 변한다.
API 요청 시 필요한 헤더를 이렇게 등록해 놓으면 트위치 API 요청을 위한 준비는 끝났다.
headers = {"Client-Id" : client_id,
"Authorization" : f"Bearer {access_token}"}
1-2) GetTopGames(Twitch)
https://dev.twitch.tv/docs/api/reference/#get-top-games
Reference
Twitch Developer tools and services to integrate Twitch into your development or create interactive experience on twitch.tv.
dev.twitch.tv
분석을 위해 어느 정도 스트리밍이 활성화된 게임이 좋겠다고 생각해서 기준이 될 게임 목록을 GetTopGames에서 가져왔다.
참고로 트위치와 같은 외국 사이트 API에서는 리퀘스트 예시를 다음과 같이 CURL 형식으로 주는 경우가 있다.
curl -X GET 'https://api.twitch.tv/helix/games/top' \
-H 'Authorization: Bearer cfabdegwdoklmawdzdo98xt2fo512y' \
-H 'Client-Id: uo6dggojyb8d6soh92zknwmi5ej1q2'
이를 CURL Converter 사이트에 입력하면 원하는 프로그래밍 언어로 리퀘스트 예시를 들어준다.
나는 다음과 같이 최대 만 개 정도의 게임을 뽑으려고 했고, 돌려보니 약 3000개를 넘겼을 때 출력이 중단되었다.
import requests
import time
top_games = []
params = {
'first':'100',
'after':''
}
while len(top_games) <= 9900:
req = requests.get('https://api.twitch.tv/helix/games/top', params=params, headers=headers)
games = req.json()
top_games.extend(games['data'])
print(f"{len(games['data'])}개의 게임을 수집했습니다. 현재까지 총 {len(top_games)}개의 게임을 수집했습니다.")
params['after'] = games['pagination']['cursor']
time.sleep(1)
이런 식으로 데이터프레임을 생성했다. IGDB API와의 연계를 위해 igdb_id가 필요하다.
id | name | igdb_id |
32982 | Grand Theft Auto V | 1020 |
516575 | VALORANT | 126459 |
21779 | League of Legends | 115 |
770229477 | 60 Seconds! Reatomized | 120560 |
29452 | Virtual Casino | 45517 |
... | ... | ... |
697266534 | Rock Life: The Rock Simulator | 228299 |
516747 | Weed Farmer Simulator | 128479 |
19343 | Dissidia Final Fantasy | 391 |
188824012 | Internet Cafe Simulator 2 | 160171 |
495563 | Project CARS 2 | 26709 |
3047 rows × 3 columns
1-3) Games(IGDB)
https://api-docs.igdb.com/#game
장르를 비롯한 게임의 특성이 필요했기에 IGDB API의 Games를 활용했다. Games API에는 여러 필드들이 존재해서 문서를 충분히 읽고 필요한 데이터를 가져오는 과정이 필요했다. 여러 시행착오를 겪은 후 내가 가져온 필드는 다음과 같다.
- genres: 장르. 장르 API와 연계(추후 업데이트).
- created_at: IGDB 데이터 생성일자. 사실 first_release_date가 발매일 필드지만 오류가 많은 것 같아 대체했다. Unix Time Stamp 형태로 데이터가 생성되는데, 마찬가지로 Unix Time Stamp를 변환해 주는 사이트에서 변환해도 되고, 파이썬 라이브러리를 활용해도 된다.
- multiplayer_modes: 멀티플레이 모드에 관한 데이터. 멀티플레이 API와 연계 가능하지만 나는 이진수로 바꿔 멀티플레이 모드를 지원하느냐 마느냐의 여부만 보기로 했다.
- player_perspectives: 플레이어 시점. 플레이어 시점 API와 연계(추후 업데이트).
이 데이터들을 아까 수집한 트위치 데이터프레임에 IGDB_ID를 기준으로 이어 주기로 했다.
raw['genres'] = ''
raw['created_at'] = ''
raw['multiplayer_modes'] = ''
raw['player_perspectives'] = ''
raw_copy = raw.copy()
url = 'https://api.igdb.com/v4/games'
for i in range(len(raw_copy)):
id = raw_copy.iloc[i]['igdb_id']
query = f'''fields id, name, genres, created_at, multiplayer_modes, player_perspectives;
where id = {id};'''
req = requests.post(url, headers=headers, data=query)
temp = req.json()
if 'genres' in temp[0].keys():
raw_copy.iloc[i]['genres'] = temp[0]['genres']
if 'created_at' in temp[0].keys():
raw_copy.iloc[i]['created_at'] = temp[0]['created_at']
if 'multiplayer_modes' in temp[0].keys():
raw_copy.iloc[i]['multiplayer_modes'] = temp[0]['multiplayer_modes']
if 'player_perspectives' in temp[0].keys():
raw_copy.iloc[i]['player_perspectives'] = temp[0]['player_perspectives']
id | name | igdb_id | genres | created_at | multiplayer_modes | player_perspectives |
32982 | Grand Theft Auto V | 1020 | [5, 10, 31] | 1326127365 | 1 | [1, 2] |
516575 | VALORANT | 126459 | [5, 24] | 1575122198 | 1 | [1] |
21779 | League of Legends | 115 | [12, 15, 36] | 1300101163 | 1 | [3] |
770229477 | 60 Seconds! Reatomized | 120560 | [12, 13, 15, 31, 32] | 1562778088 | 1 | [2] |
29452 | Virtual Casino | 45517 | [35] | 1499509233 | 0 | |
... | ... | ... | ... | ... | ... | ... |
697266534 | Rock Life: The Rock Simulator | 228299 | [13, 32] | 1670030423 | 0 | |
516747 | Weed Farmer Simulator | 128479 | [13, 32] | 1579284459 | 0 | |
19343 | Dissidia Final Fantasy | 391 | [4, 12, 25, 31] | 1300443348 | 0 | [2] |
188824012 | Internet Cafe Simulator 2 | 160171 | [13, 15, 31, 32] | 1627906912 | 0 | [1] |
495563 | Project CARS 2 | 26709 | [10, 13] | 1483751393 | 1 | [1, 2, 7] |
3047 rows × 7 columns
1-4) External Game(IGDB)
https://api-docs.igdb.com/#external-game
스팀 판매량과의 연계를 위해 External Game API에서 스팀 아이디를 조회하기로 했다.
External Game API에서 카테고리가 1인 부분이 스팀 아이디이다. 스팀 아이디가 존재하지 않는 데이터는 수집하지 않기로 했다.
raw_copy['steam_id'] = ''
raw_copy2 = raw_copy.copy()
url = 'https://api.igdb.com/v4/external_games'
for i in range(len(raw_copy2)):
id = raw_copy2.iloc[i]['igdb_id']
query = f'fields *; where game = {id} & category = 1;'
req = requests.post(url, headers=headers, data=query)
temp = req.json()
if len(temp) != 0:
raw_copy2.loc[raw_copy2['igdb_id']== id, 'steam_id'] = temp[0]['uid']
else:
raw_copy2.loc[raw_copy2['igdb_id']== id, 'steam_id'] = ''
id | name | igdb_id | genres | created_at | multiplayer_modes | player_perspectives | steam_id |
32982 | Grand Theft Auto V | 1020 | [5, 10, 31] | 1326127365 | 1 | [1, 2] | 271590 |
770229477 | 60 Seconds! Reatomized | 120560 | [12, 13, 15, 31, 32] | 1562778088 | 1 | [2] | 1012880 |
1808565595 | The Casting of Frank Stone | 279635 | [31] | 1702000534 | 0 | [2] | 2223840 |
2068583461 | NBA 2K25 | 308034 | [13, 14] | 1720626296 | 0 | [2] | 2878980 |
491487 | Dead by Daylight | 18866 | [15] | 1461403829 | 1 | [1, 2] | 381210 |
1-5) 전처리
이후 진행한 전처리는 다음과 같다.
- 중복 제거
- 플레이어 시점 결측치 채우기
- 장르 없는 행 드롭
- Genre API를 통해 장르 이름 붙이기
- 플레이어 시점 API를 통해 플레이어 시점 붙이기
- Unix Time Stamp 변환
그리하여 다음과 같은 게임 정보 데이터가 최종으로 완성되었다!
id | igdb_id | steam_id | name | genres | created_at | multiplayer_modes | player_perspectives |
32982 | 1020 | 271590 | Grand Theft Auto V | ['Shooter', 'Racing', 'Adventure'] | 2012-01-09 | 1 | ['First person', 'Third person'] |
770229477 | 120560 | 1012880 | 60 Seconds! Reatomized | ['Role-playing (RPG)', 'Simulator', 'Strategy'... | 2019-07-10 | 1 | ['Third person'] |
1808565595 | 279635 | 2223840 | The Casting of Frank Stone | ['Adventure'] | 2023-12-08 | 0 | ['Third person'] |
2068583461 | 308034 | 2878980 | NBA 2K25 | ['Simulator', 'Sport'] | 2024-07-10 | 0 | ['Third person'] |
491487 | 18866 | 381210 | Dead by Daylight | ['Strategy'] | 2016-04-23 | 1 | ['First person', 'Third person'] |
... | ... | ... | ... | ... | ... | ... | ... |
510204 | 61616 | 704850 | Thief Simulator | ['Simulator', 'Strategy', 'Adventure', 'Indie'] | 2017-09-08 | 0 | ['First person'] |
68028 | 1593 | 291650 | Pillars of Eternity | ['Role-playing (RPG)', 'Strategy', 'Adventure'... | 2012-10-14 | 0 | ['Bird view / Isometric'] |
517515 | 131760 | 1256670 | Library of Ruina | ['Role-playing (RPG)', 'Simulator', 'Strategy'... | 2020-03-05 | 0 | ['Side view'] |
1465660616 | 277337 | 2565260 | NOTICE | ['Role-playing (RPG)', 'Indie'] | 2023-11-20 | 0 | [] |
188824012 | 160171 | 1563180 | Internet Cafe Simulator 2 | ['Simulator', 'Strategy', 'Adventure', 'Indie'] | 2021-08-02 | 0 | ['First person'] |
1611 rows × 8 columns
다른 전처리는 데이터 분석 과정에서 추가로 진행할 예정이다.
++ DLC 데이터 수집도 진행했지만, 데이터가 존재하는 게임보다 존재하지 않는 게임이 훨씬 많아 아예 DLC 출시 및 업데이트 영향을 배제하는 방향으로 분석하기로 했다.
2. 스트리밍 데이터
내가 필요한 데이터는 게임의 트위치 기간별 누적 스트림 정보였고, 처음엔 일주일 단위가 적합하다고 생각했다.
그러나 데이터 구하기가 여의치 않아서... 한 달 단위로 보기로 결정하였고, 데이터 수집은 셀레니움을 활용했다.
혹시 모를 잡음을 피하기 위해 데이터 수집 과정은 공개하지 않겠다.
3. 게임 판매량(리뷰) 데이터
데이터 수집 과정에 앞서 하나의 장벽에 마주했다.
바로 게임 판매량 데이터를 수집하기 어렵다는 점...!
게임 판매량 데이터를 제공하는 여러 사설 사이트가 존재하긴 했지만, 유료일뿐더러 판매량의 '추정치'만 제공했다.
아무래도 업계에서 예민한 데이터인 만큼 대중에게 공개를 꺼려하는 것처럼 보였다.
결국 나는 게임 판매량을 간접적으로 확인할 수 있는 데이터를 찾아야 했다.
스팀에서도 다음과 같이 주간 차트를 제공하고 있긴 하지만, 저 '판매 수익'이라는 부분이 신경 쓰였다. 할인에 지나치게 영향을 많이 받을 것이라는 생각이 들었다. 그리고, 주간 100개의 게임밖에 제공하지 않았는데, 말이 100개지 소위 '고여 있는' 차트이기 때문에 이런저런 전처리를 거치면 유의미한 데이터를 찾기 힘들 것 같았다.
그래서 활용하고자 한 것이 바로 리뷰 데이터이다.
게임 발매 후 기간에 따라 스팀에서는 최대 한 달 단위로 리뷰 데이터를 제공하고 있다.
아무래도 직접 게임을 플레이한 후 리뷰를 작성하기 때문에 할인을 할 때 일단 사놓고 보는 경우도 어느 정도 배제가 가능하고, 게임이 많이 팔릴수록 리뷰의 개수도 늘어날 것이라고 판단했다. 최종적으로 게임 판매량을 알아보기 유의미한 정보라고 생각되어서 선택했다.
리뷰 데이터 수집은 웹페이지 크롤링을 통해 진행했다.
게임 데이터 수집 때 찾아놓은 스팀 아이디를 활용하여 한 달 단위 총 리뷰 수 데이터를 수집했다.
date | recommendations_up | recommendations_down | steam_id |
1427846400 | 18075 | 4672 | 271590 |
1430438400 | 6553 | 2126 | 271590 |
1433116800 | 5086 | 1619 | 271590 |
1435708800 | 3243 | 808 | 271590 |
1438387200 | 2576 | 565 | 271590 |
역시 Unix Time Stamp 형식으로 날짜 데이터가 제공되어 이를 변환하고, 추천과 비추천 데이터를 합쳐 총 리뷰수 데이터를 만들었다.
steam_id | date | recommendations_up | recommendations_down | recommendations_tot |
20 | 2010-10-01 | 1 | 0 | 1 |
20 | 2010-11-01 | 10 | 0 | 10 |
20 | 2010-12-01 | 6 | 0 | 6 |
20 | 2011-01-01 | 6 | 0 | 6 |
20 | 2011-02-01 | 2 | 0 | 2 |
... | ... | ... | ... | ... |
3010230 | 2024-09-01 | 157 | 112 | 269 |
3018910 | 2024-08-01 | 57 | 24 | 81 |
3018910 | 2024-09-01 | 3 | 0 | 3 |
3062970 | 2024-08-01 | 58 | 2 | 60 |
3076400 | 2024-08-01 | 27 | 8 | 35 |
90452 rows × 5 columns
야호! 데이터 수집이 모두 완료되었다.
다음은 본격적인 데이터 분석으로 돌아오겠다.