즐겨 하던 게임 중 하나인 로스트아크의 경매장 알리미를 만들면서 경험한 기록입니다~!
코드는 웹 프론트엔드 저장소 백엔드 서버 저장소 디스코드 봇 저장소에서 확인할 수 있습니다. 혹시 궁금한 점이 있다면 편하게 질문해주세요!
프로젝트 개발 동기
게임하면서 원하는 아이템을 적당한 가격에 사기 위해 계속 검색하다보니 불편하더라고요. 그래서 경매장 API를 사용해서 알리미를 만들기로 했습니다. 처음에는 혼자 사용하려고 했는데, 같이 게임하는 사람들도 사용하면 좋겠다는 생각이 들어서 서비스로 개발하기로 했습니다. 그러나 API 요청 제한이 있기 때문에 각자의 API 키를 등록하고, 검색 주기는 1분, 최대 등록 가능한 검색 아이템의 수는 10개로 결정했습니다. 각인을 맞추는데 필요한 장신구 수가 5개이니 충분하다고 생각했어요!
작은 프로젝트지만 여러가지 생각하면서 만들었던 재밌는 경험이었어요. 특히 프론트엔드, 백엔드를 모두 개발하며 API를 사용해서 통신하는 과정에서 요청과 응답에 관한 것들을 직접 설정하고 만든 경험, 데이터베이스를 구성할 때 어떤 구조로 만드는게 좋을지 모르겠어서 조금씩 공부하면서 만드는 과정에서 효율적으로 만들기 위해서 노력한 경험이 기억에 남네요. 문제는 공부할수록 배울게 더 늘어났어요 ㅋㅋㅋ
프로젝트 설명 및 기능 조건
간단히 요약하면 사용자가 원하는 옵션의 아이템을 등록하고, 조건에 맞는 아이템이 경매장에서 검색되면 디스코드 봇이 유저를 멘션해요. 디스코드 봇이 유저의 디스코드 id를 알아야 하기 때문에, 디스코드 봇에 id를 등록하는 과정이 필요해요.
기능 조건
- 사용자가 웹에서 원하는 옵션을 API 키와 함께 등록한다.전투 특성(특화, 치명, 신속, 제압, 인내, 숙련)감소 각인(공격력 감소 등)아이템 품질(최소치)거래가능횟수(최소치)
- 즉시 구매가격(최대치)
- 아이템 등급(유물, 고대 등)
- 각인 종류(원한 등)
- 장신구 종류(전체, 목걸이, 귀걸이, 반지)
- 사용자는 봇의 유저 등록 커맨드를 사용해서 디스코드 id와 로스트아크 API를 DB에 등록한다.
- 서버에서 검색 주기마다 로스트아크 API를 사용해서 경매장에서 검색한다.
- 조건에 맞는 아이템을 찾으면 유저에게 봇이 DM을 보낸다.
- 단, 한번 알림한 아이템을 다시 알림하지 않는다.
개발환경
- 디스코드 봇 - discord.py
- 웹서비스 - Vue3+Vuetify / Spring Boot
- 데이터베이스 - MySQL
- 배포 - 오라클 클라우드 프리티어 인스턴스
웹 프론트 개발(vue3, vuetify, vue-router)
프론트를 Vue3과 Vuetify로 만들면서 배우고 있기 때문에 개발이 간단하고, 사용자가 사용하기 쉬운 구조로 만들고 싶었어요! 헤더-메인-푸터 구조로 각각에 맞는 컴포넌트를 만들었어요.
각 컴포넌트의 스크립트는 포함하면 너무 길어져서 포함하지 않았어요.
헤더 컴포넌트
검색 옵션 등록 컴포넌트와 검색 목록 관리 컴포넌트로 라우팅해주는 컴포넌트예요. vuex와 vue-router를 사용해서 만들었어요.
<template>
<v-app-bar app>
<v-toolbar-title>
<v-btn to="/" class="no-bg-on-hover btn-logo">
<v-img src="/cocomo_logo.png" alt="Logo" :style="{ width: '80px', height: '40px'}"></v-img>
</v-btn>
</v-toolbar-title>
<v-btn :to="alarmAddBtnLink" v-if="store.getters.isAuthenticated">
{{ alarmAddBtnText }}
</v-btn>
<v-btn :to="alarmListBtnLink" v-if="store.getters.isAuthenticated">
{{ alarmListBtnText }}
</v-btn>
<v-btn @click="logout" v-if="store.getters.isAuthenticated">
로그아웃
</v-btn>
</v-app-bar>
</template>
메인 컴포넌트
검색 옵션 등록 컴포넌트
단순한 등록 폼이에요. 입력값은 reactive로 래핑한 오브젝트에 키:값으로 저장해서 사용했어요.
<template>
<v-container class="rounded-xl form-select">
<v-form class="mt-5">
<v-row class="mb-n10">
<v-col class="d-flex justify-center">
<v-select :items="categoryOptions" v-model="selectedOptions.category"
label="장신구 종류" class="mr-4">
</v-select>
<v-select :items="statOptions" v-model="selectedOptions.battleAbility1"
label="전투 특성1" class="mr-4">
</v-select>
<v-select :items="statOptions" v-model="selectedOptions.battleAbility2"
v-if="selectedOptions.category === '목걸이'"
class="mr-4"
label="전투 특성2">
</v-select>
</v-col>
</v-row>
<v-row class="mb-n10">
<v-col class="d-flex justify-center">
<v-autocomplete :items="skillNames" class="mr-3"
label="각인1" v-model="selectedOptions.skill1"
>
</v-autocomplete>
<v-text-field class="mr-3"
label="각인1 최소치" v-model="selectedOptions.skill1MinValue"
>
</v-text-field>
<v-autocomplete :items="skillNames" class="mr-3"
label="각인2" v-model="selectedOptions.skill2">
</v-autocomplete>
<v-text-field class="mr-3"
label="각인2 최소치" v-model="selectedOptions.skill2MinValue">
</v-text-field>
</v-col>
</v-row>
<v-row class="mb-n10">
<v-col class="d-flex justify-center">
<v-autocomplete :items="penaltyOptions" class="mr-3"
label="패널티" v-model="selectedOptions.penalty">
</v-autocomplete>
<v-text-field class="mr-3"
label="패널티 최대치" v-model="selectedOptions.penaltyMaxValue">
</v-text-field>
</v-col>
</v-row>
<v-row class="mb-n10">
<v-col class="d-flex justify-center">
<v-text-field class="mr-3"
label="최대 구매 가격" v-model="selectedOptions.buyPrice">
</v-text-field>
<v-text-field class="mr-3"
label="거래 가능 최소치" v-model="selectedOptions.tradeCount">
</v-text-field>
<v-select label="품질(최소치)" class="mr-3"
:items="qualityOptions"
v-model="selectedOptions.quality">
</v-select>
</v-col>
</v-row>
<v-row>
<v-col class="d-flex justify-center">
<v-btn type="button" clas style="width: 300px;"
@click="submitAlarm">
등록
</v-btn>
</v-col>
</v-row>
</v-form>
</v-container>
</template>
검색 목록 관리 컴포넌트
검색 목록을 관리하기 위한 컴포넌트에요. vuetify의 v-data-table로 쉽게 만들 수 있었어요.
<template>
<v-container style="width: 1200px;"> <!-- 고정 너비 설정 -->
<v-data-table
v-model:items-per-page="itemsPerPage"
:headers="headers"
:items="alarms"
class="elevation-1 text-left"
>
<template v-slot:top>
<v-toolbar
flat
>
<v-toolbar-title>알람 등록 현황</v-toolbar-title>
<v-divider
class="mx-4"
inset
vertical
></v-divider>
<v-spacer></v-spacer>
<v-dialog v-model="dialogDelete" max-width="500px">
<v-card>
<v-card-title class="text-h5">알람을 삭제하시겠습니까?</v-card-title>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="closeDelete">취소</v-btn>
<v-btn color="blue-darken-1" variant="text" @click="deleteItemConfirm">확인</v-btn>
<v-spacer></v-spacer>
</v-card-actions>
</v-card>
</v-dialog>
</v-toolbar>
</template>
<template v-slot:item.actions="{ item }">
<v-icon
size="small"
@click="deleteItem(item)"
>
mdi-delete
</v-icon>
</template>
</v-data-table>
</v-container>
</template>
로그인 기능 컴포넌트
로그인을 위한 카드 컴포넌트예요. 디스코드 유저 등록 기능에서 발급한 로그인용 API Key로 로그인 할 수 있어요.
<template>
<v-container>
<v-row>
<v-col class="notice-card d-flex justify-center align-center">
<v-card title="사용법" :text="notice" variant="outlined">
</v-card>
</v-col>
</v-row>
<v-row class="d-flex">
<v-col>
<v-card>
<v-card-title>코코모 로그인</v-card-title>
<v-card-text>
<v-form>
<v-text-field label="인증 키" outlined v-model="key"></v-text-field>
</v-form>
</v-card-text>
<v-card-actions class="justify-center">
<v-btn color="primary" @click="login">로그인</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-dialog v-model="dialog" max-width="400px">
<v-card>
<v-card-title>로그인 실패</v-card-title>
<v-card-text>유효하지 않은 인증 키입니다.</v-card-text>
<v-card-actions>
<v-btn color="primary" @click="dialog = false">닫기</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
</v-container>
</template>
푸터 컴포넌트
사이트 정보를 담은 간단한 컴포넌트예요. 딱히 넣을게 없어서 없앨지 고민도 했었는데, 나중에 사용할 일이 있을지도 모르고 레이아웃을 변경할 때도 푸터는 건드리지 않을 것 같아서 넣기로 했어요!
<template>
<footer>
<v-footer app>
<v-container>
<v-row>
<v-col class="text-center">
<v-btn :href="blogBtnLink">{{ blogBtnText }}</v-btn>
<v-btn :href="discordBtnLink">{{ discordBtnText }}</v-btn>
<v-spacer></v-spacer>
Copyright © 2023 Cocomo. All rights reserved.
</v-col>
</v-row>
</v-container>
</v-footer>
</footer>
</template>
디스코드 봇 개발(discord.py)
유저 등록 기능
웹 서버에 디스코드 유저 id를 저장하기 위해서 사용자는 디스코드 봇으로 유저 등록을 해야해요. 입력받은 정보로 회원가입 API를 사용해요. 첫 요청은 CSRF 토큰을 얻기 위해서 보내요.
서버는 민감한 정보인 API 키를 DB에 암호화해서 저장하고, 사용자에게는 UUID로 생성한 로그인 키를 알려줍니다.
async def join(self, user_id: int, api_key: str):
async with httpx.AsyncClient() as client:
response = await client.get(get_config()['csrf_url'])
csrf = response.headers['set-cookie']
start = csrf.find("=") + 1
end = csrf.find(";")
headers = {
'X-XSRF-TOKEN': csrf[start:end],
'Content-Type': 'application/json'
}
data = {
"discordId": user_id,
"apiKey": api_key
}
response = await client.post(get_config()['join_url'],
json=data, headers=headers, cookies=client.cookies)
if response.status_code == 200:
print(response.json())
cocomo_key = response.json()['data']['cocomoKey']
return cocomo_key
멘션 기능
조건에 맞는 아이템을 찾으면 웹 서버가 디스코드 봇 서버의 멘션 API를 호출해서 봇이 유저를 멘션해요.
async def alarm(self, user_id: int, items: list):
user = self.get_user(user_id)
if user:
for item in items:
embed = item_to_embed(item)
await user.send(embed=embed)
API 서버(spring boot, jpa)
알람 조건 생성, 조회, 삭제 기능
적다보니 코드가 너무 길어져서 서버 코드는 첨부하지 않기로 했습니다.
문제 - 경매장 검색 기능
데이터베이스에 저장된 조건들을 가져와서 API로 검색합니다. 처음엔 쉽게 생각했는데 사용자가 많아질수록 많아지는 조건들을 1분마다 전부 DB에서 불러오는건 비효율적이라는 생각이 들더라고요. 조건들을 로딩할 때 어떻게 하는게 효율적일지 고민했어요.
해결
- 서버 첫 실행시, 서버의 메모리에 userid: 조건 리스트 구조의 HashMap을 저장해요.
- 조건 추가, 수정, 삭제 API가 호출될 때, 해당 유저의 조건 리스트를 갱신해요.
서버의 메모리를 사용한다는 단점이 있지만, 유저 1000명 정도까지는 괜찮을테니 우선 이 방법으로 해결하려고 해요.
문제 - API 키 관리
사용자의 API 키를 데이터베이스에 암호화해서 관리하는데, 문제는 경매장 API를 사용할 때마다 복호화할지, 아니면 복호화한 결과를 메모리에 저장하고 사용할지 고민됐어요.
해결
찾아보니 AES256의 암호화-복호화 시간은 상당히 빠르다고 해서 메모리에 캐싱은 하지 않기로 했어요.
회원가입 기능
유저의 API 키를 검증해서 문제가 없다면 암호화해서 등록하는 방식으로 만들었어요.
문제 - RequestBody 객체 맵핑
스프링은 요청으로 받은 JSON 데이터를 자동으로 객체에 맵핑하는데, @RequestBody 애너테이션을 적용해도 빈 객체가 생성됐어요.
해결
직접 ObjectMapper로 맵핑해서 해결했어요.
로그인 기능
AbstractAuthenticationProcessingFilter, AbstractAuthenticationToken, AuthenticationProvider 세 가지 추상 클래스와 인터페이스를 상속받아서 UUID로 생성한 키로 인증하는 커스텀 인증 시스템을 만들었어요. 인증을 통과하면 세션에 인증 정보를 저장하고, 연결한 DB와 연동해서 스프링 세션이 자동으로 세션을 관리해줘요.
번외) 로스트아크 경매장 API 간단 설명
처음에 이해하기 조금 어려웠던건 저만 그런걸까요... ㅋㅋ 혹시 저와 비슷한 분이 계시면 조금이나마 도움이 될지 몰라서 제가 이해한 것들을 공유합니다!
우선 https://developer-lostark.game.onstove.com/auctions/options API를 본인의 API 키와 함께 호출하면, 경매장 검색 API를 호출할 때 필요한 모든 Search options가 있는 json 파일을 받을 수 있어요.
'https://developer-lostark.game.onstove.com/auctions/items' API가 경매장 검색 API인데, 샘플로 주어진 파라미터는 아래와 같아요. Model 설명만으로 이해하기 힘들었던 것들을 주석으로 정리했어요. 세부 코드는 json 파일에서 찾아서 사용했어요.
{
"ItemLevelMin": 0,
"ItemLevelMax": 0,
"ItemGradeQuality": null, // 아이템 품질 최소치, null 또는 [10, 90]. 10 단위로만 가능
"SkillOptions": [ // 트라이포드 조건
{
"FirstOption": null, // 스킬 세부 코드
"SecondOption": null, // 트라이포드 세부 코드
"MinValue": null,
"MaxValue": null
}
],
"EtcOptions": [ // 전투 특성, 각인, 패널티
{
"FirstOption": null, // 2는 전투 특성, 3은 각인과 패널티 효과
"SecondOption": null, // (전투 특성, 각인, 패널티 세부 코드)
"MinValue": null,
"MaxValue": null
}
],
"Sort": "BIDSTART_PRICE", // 정렬 방식[ BIDSTART_PRICE, BUY_PRICE, EXPIREDATE, ITEM_GRADE, ITEM_LEVEL, ITEM_QUALITY ]
"CategoryCode": 0, // 아이템 카테고리 코드
"CharacterClass": "string", // 캐릭터 직업
"ItemTier": null, // 아이템 티어
"ItemGrade": "string", // 아이템 등급 "" 가능
"ItemName": "string", // 아이템 이름 "" 가능
"PageNo": 0, // 검색 페이지
"SortCondition": "ASC" // 오름차순, 내림차순
}
'Project' 카테고리의 다른 글
내일배움캠프 뉴스피드 프로젝트 KPT (1) | 2023.11.27 |
---|---|
[내일배움캠프] 미니 프로젝트 KPT (0) | 2023.10.12 |