July 27, 2020
위코드 부트캠프에서 2차 프로젝트를 2주간 진행하였고 저는 백엔드 부분을 담당했습니다.
국내 최대 게임회사인 넥슨의 오랜기간 사랑받는 캐쥬얼 레이싱 장르인 카트라이더의 전적 정보를 제공하는 Kartrider TMI를 클론하는 프로젝트입니다. 공식 홈페이지와는 다르게 소셜 로그인 기능이 없어 클론하지 않고 직접 개발하였으며 추가적으로 소셜계정과 실제 카트라이더 유저의 계정을 연동하는 기능도 추가하였습니다. 개발 인원은 총 5명이며 백엔드 3명 프론트엔드 2명으로 구성되었으며 여담으로 게임회사 출신이 저 포함 3명이나 되는 팀이었습니다.
## Abstract Factory
class NXApiStat(metaclass = ABCMeta):
def __init__(self, access_id, match_types, start_date, end_date):
self.access_id = access_id
self.match_types = match_types
self.start_date = start_date
self.end_date = end_date
self.authorization_key = '-.-.-' #보안상 API Key는 비공개 처리합니다.
self.current_URL = f"https://api.nexon.co.kr/kart/v1.0/users/{self.access_id}/matches?start_date={self.start_date}&end_date={self.end_date}&offset={offset}&limit={limit}&match_types={self.match_types}"
self.limit500_URL = f"https://api.nexon.co.kr/kart/v1.0/users/{self.access_id}/matches?start_date={self.start_date}&end_date={self.end_date}&offset={offset}&limit={limit}&match_types={self.match_types}"
def _api_to_df(self, url):
# API Call
req_data = requests.get(url, headers={'authorization': self.authorization_key}).text
data = json.loads(req_data)
# Get dfs
matches_df = pd.DataFrame(data['matches'][0]['matches'])
player_df = pd.json_normalize(matches_df['player'])
# preprocess 1) join, replace empty value, convert (numeric/date)type
df = pd.merge(matches_df, player_df, how='left', left_index=True, right_index=True)
df = df.replace('', np.nan, regex=True)
df['startTime'] = pd.to_datetime(df['startTime']) # format='%Y-%m-%d'
to_num_cols = ['matchTime', 'matchWin', 'matchRetired', 'matchRank']
df[to_num_cols] = df[to_num_cols].apply(pd.to_numeric, errors='coerce', axis=1)
# preprocess 2) convert retired log: if matchRank == 99 then matchTime set to 0
mask = (df['matchRank'] == 99)
df.loc[mask, 'matchTime'] = 0
# preprocess 3) drop na columns
df = df.dropna(subset=['kart', 'matchTime', 'matchWin', 'matchRetired', 'matchRank'])
return df
@abstractmethod
def summary_stat(self):
pass
## RankMain(first_day_of_month to limit 500)
class RankMainRecord_Cumul(NXApiStat):
def __init__(self, access_id, match_types, start_date = first_day_of_month, end_date = today):
super().__init__(access_id, match_types, start_date, end_date)
print(self.access_id, self.limit500_URL)
def summary_stat(self):
df = self._api_to_df(url = self.limit500_URL) # Get Cumul Records
# [메인순위] (승률 / 리타이어율)
# [종합전적] (전 / 승 / 패 / 승률 / 완주율 / 리타이어율)
play_cnt = df['matchRank'].count()
rank_df = df['matchRank'].value_counts().reset_index()
win_mask = (rank_df['index'] == 1)
retire_mask = (rank_df['index'] == 99)
win_cnt = rank_df[win_mask].matchRank.item()
win_ratio = round(win_cnt/play_cnt, 4)
if len(rank_df[retire_mask].matchRank) != 0:
retire_cnt = rank_df[retire_mask].matchRank.item()
else:
retire_cnt = 0
retire_ratio = round(retire_cnt/play_cnt, 4)
# [종합전적] 캐릭터이름
character = df['character_x'].head(1).item()
# [메인순위] (누적포인트)
score_df = pd.DataFrame(
{
'score' : [10,7,5,4,3,1,0,-1,-5]
}, index = [1, 2, 3, 4, 5, 6, 7, 8, 99]
)
user_rankcount = df['matchRank'].value_counts()
result = pd.merge(user_rankcount, score_df, how='inner', left_index=True, right_index=True)
result['Rank_Mul_score'] = result['matchRank'] * result['score']
point_cumul = result['Rank_Mul_score'].sum()
return play_cnt, win_cnt, win_ratio, retire_ratio, point_cumul, character
## RankMain(yeseterday)
class RankMainRecord_Recent(NXApiStat):
def __init__(self, access_id, match_types, start_date = bf_yesterday, end_date = today):
super().__init__(access_id, match_types, start_date, end_date)
print(self.access_id, self.limit500_URL)
def summary_stat(self):
df = self._api_to_df(url = self.current_URL) # Get Recent Records
# [메인순위] (주행 횟수) / 승률 / 리타이어율 / 누적포인트 / 포인트 변화 / {랭크 변화 / 평균 순위}
play_count_day1 = df['matchRank'].count()
# [메인순위] 주행 횟수 / 승률 / 리타이어율 / 누적포인트 / (포인트변화) / {랭크 변화 / 평균 순위}
score_df = pd.DataFrame(
{
'score' : [10,7,5,4,3,1,0,-1,-5]
}, index = [1, 2, 3, 4, 5, 6, 7, 8, 99]
)
user_rankcount = df['matchRank'].value_counts()
result = pd.merge(user_rankcount, score_df, how='inner', left_index=True, right_index=True)
result['Rank_Mul_score'] = result['matchRank'] * result['score']
point_new_day1 = result['Rank_Mul_score'].sum()
return play_count_day1, point_new_day1
판교 N사에서 제공하는 API에서 Raw Data를 가져와 Pandas를 통해 통계 데이터로 가공하는 과정입니다. Design Pattern은 Abstract Factory 방식을 적용하였습니다. Pandas를 이용한 통계 데이터 가공은 제가 작성하였으나 디자인 패턴을 적용한 리팩토링은 제가 하지 않아 관련하여 더 공부 하고 싶습니다.
# Views.py
class SocialLoginView(View):
def post(self, request):
try:
kakao_token = request.headers['Authorization']
req_kakao_profile = requests.get(KAKAO_API,headers = {
'Authorization' : f'Bearer {kakao_token}'
})
profile = req_kakao_profile.json()
kakao_email = profile['kakao_account']['email']
kakao_id = profile['properties']['nickname']
kakao_picture = profile['properties']['profile_image']
if User.objects.filter(email = kakao_email).exists():
token = jwt.encode({'email' : kakao_email}, SECRET_KEY, algorithm=ALGORITHM).decode('utf-8')
return JsonResponse({
'access_token' : token,
'nickname' : kakao_id,
'profile_image' : kakao_picture,
'email' : kakao_email
}, status=200)
User(
email = kakao_email,
kakao_id = kakao_id,
picture = kakao_picture,
).save()
token = jwt.encode({'email' : kakao_email}, SECRET_KEY, algorithm=ALGORITHM)
token = token.decode('utf-8')
return JsonResponse({
'access_token' : token,
'nickname' : kakao_id,
'profile_image' : kakao_picture,
'email' : kakao_email
}, status=200)
except KeyError:
return JsonResponse({'Message' : 'INVALID_KEYS'}, status=400)
# tests.py
class SocialLoginTest(TestCase):
def setUp(self):
User.objects.create(
email = 'test@test.com',
kakao_id = 'test',
picture = 'http://k.kakaocdn.net/dn/5w8QS/btqCrOP9kx6/XcCLekEj4OrEbCncK7CLiK/img_640x640.jpg'
)
def tearDown(self):
User.objects.filter(email='test@test.com').delete()
@patch('user.views.requests')
def test_login_pass(self, mocked_request):
class MockedResponse:
def json(self):
return {
'kakao_account' : {
'email' : 'test@test.com'
},
'properties' : {
'nickname' : 'test',
'profile_image' : 'http://k.kakaocdn.net/dn/5w8QS/btqCrOP9kx6/XcCLekEj4OrEbCncK7CLiK/img_640x640.jpg'
}
}
mocked_request.get = MagicMock(return_value = MockedResponse())
kakao_id = MockedResponse().json()['properties']['nickname']
kakao_picture = MockedResponse().json()['properties']['profile_image']
kakao_email = MockedResponse().json()['kakao_account']['email']
client = Client()
headers = {
'HTTP_Authorization' : '',
'content_type' : 'application/json'
}
response = client.post('/user/login', **headers)
token = response.json()['access_token']
self.assertEqual(response.json(), {
'access_token' : token,
'nickname' : kakao_id,
'profile_image' : kakao_picture,
'email' : kakao_email
})
self.assertEqual(response.status_code, 200)
@patch('user.views.requests')
def test_login_fail(self, mocked_request):
class MockedResponse:
def json(self):
return {
'account' : {
'email' : 'test@test.com'
},
'properties' : {
'nickname' : 'test',
'image' : 'http://k.kakaocdn.net/dn/5w8QS/btqCrOP9kx6/XcCLekEj4OrEbCncK7CLiK/img_640x640.jpg'
}
}
mocked_request.get = MagicMock(return_value = MockedResponse())
client = Client()
headers = {
'HTTP_Authorization' : '',
'content_type' : 'application/json'
}
response = client.post('/user/login', **headers)
self.assertEqual(response.json(),{
'Message' : 'INVALID_KEYS'
})
self.assertEqual(response.status_code, 400)
Kakao를 통한 Social Login 기능을하는 View와 Unit Test입니다.
소셜로그인의 경우 현재 다양한 서비스에서 사용되는 기술이므로 더 공부해볼만한 가치가 있다고 생각하며 더 효율적인 코드설계는 없는지 공부해봐야겠습니다.
Unit Test의 경우 일단 이 소셜로그인 과정의 Unit Test를 수행하는 것 자체가 너무 큰 허들이어서 선배님들 붙잡고 한줄한줄 print 찍어가며 공부했던 기억이 너무 강렬하여 반드시 기억하지 않으면 선배님들에게 죄짓는거 같고 그럼에도 불구하고 Pass case와 Fail case만 고려하여 예외처리는 생각하지 않아 소셜 로그인 관련 예외처리 케이스에 대해 더 깊게 고민해보고자 합니다.
전설로 기억될 저희 백엔드 팀의 프레젠테이션을 소개하지 않는것은 도리가 아니라고 생각됩니다 여기까지 읽어주신 분들은 이 영상도 꼭 봐주셨으면 좋겠습니다. 동영상 보기