
크롤링을 통해 직업 각인 별 상위 50명의 닉네임을 가져왔다. (원래는 20명씩이였으나, 절대적인 수가 부족하기도 했고, 이후 과정에서도 나오지만, 보석을 장착하지 않거나, 타 직업의 보석을 장착한 경우도 있어서 쓸모없는 데이터가 발생하기에 집계수가 더욱 줄어들어 볼륨을 조금 키웠다.)
추가로, 화면이 로딩 되기까지의 충분한 로딩 시간이 필요하여, 코드 내에 딜레이를 걸었다.
크롤링 이후에,
1. 해당 유저들의 닉네임을 로스트아크 api에 요청하여, 해당 유저들이 사용하는 보석 데이터를 가져온다.
2. 받아온 보석 데이터를 종류에 따라 분류한다 ( 겁화 or 작열 )
3. 직업별로 직업각인의 채용률도 다르기 때문에, 직업 각인에 따른 스킬 채용률을 보다 정확히 집계하기 위해, 추가적으로 각 직업별 상위 랭커 100명의 직업각인 채용률을 집계하여 스킬 채용률을 계산한다.
4. 스킬 채용률을 계산하여 JSON파일로 리소스 파일에 저장하여, 프론트 요청이 오면 데이터베이스가 아닌 집계된 JSON파일을 전달한다.
5. 스케쥴링을 활용해 해당 과정을 매주 알아서 동작하게 한다. 해당 부분은 이후에 스케쥴링 관련된 내용을 모아서 다시할 예정이다.
이번 포스팅에서는 (2)번 까지의 과정을 담았다.

이때, 주황색 AUTHIRIZE 버튼을 눌러 아까 발급 받은 키를 입력한다.
꼭 bearer 뒤에 붙여넣기해야한다.
완료되었다면 Try it out을 눌러보자.

나는 현 랭커 1위인 "버서커" 닉네임을 입력하고 실행버튼을 눌렀다.

application/json 형식으로 응답이 왔으며, 응답이 잘 온 것을 볼 수 있다.
우리는 Curl에 포커스를 두고 잘 보도록하자.
Curl은 client URL의 약자로, 명령줄에서 HTTP(S) 요청을 보내고 응답을 처리하는 데 사용되는 도구다.
쉽게 말해서, 어떤 형식으로 데이터를 보냈는지, 혹은 보내야하는지를 확인한다고 생각하면 된다.

위에서부터 하나씩 분석해보자.
이제 해당 부분을 스프링으로 구현하게 되면 앞선 예시처럼 같은 JSON 데이터를 받게 될 것이며, JSON 데이터를 파싱해서 필요한 부분을 추출해서 사용할 것이다.
나는 코드를 git hub에 올리기에 API Key의 노출을 막고자 yml에 키를 저장해두고, git ignore에 yml 파일을 넣었다.
이후에, config에 @Bean을 통해 API Key를 주입하였다.
@Value를 사용하여 yml의 값을 가져올 수 있다.
@Bean
public String[] api(
@Value("${loa.api.key1}") String apiKey1,
@Value("${loa.api.key2}") String apiKey2,
@Value("${loa.api.key3}") String apiKey3,
@Value("${loa.api.key4}") String apiKey4,
@Value("${loa.api.key5}") String apiKey5
) {
return new String[]{apiKey1, apiKey2, apiKey3, apiKey4, apiKey5};
}
추가적으로 json파일을 src/main/resources 파일 밑에 생성되게 경로를 설정해두었다.
또한 아래 서비스 로직에서 나올 직업 카운팅 부분을 위해 Cache 클래스를 준비해두었다.
캐시로 사용한 이유는 어짜피 [ 크롤링 > 로아api요청 > 집계 및 json 저장 ] 해당 과정은 한번에 동작할 것이기 때문이다.
먼저 로아api요청 과정에서 actualUserCount로 보석 11개가 온전히 장착되어 있는 실 유저를 카운트를 하여 해당 스킬 수 / 측정 유저 수를 계산할 것이고 이후 집계 과정에서 engraveCount로 직업각인 가중치로 실제 스킬채용률에 근접하게 값을 구할 것이다.
@Getter
@Component
public class EngraveCountCache {
private final Map<String, Integer> engraveCountMap = new HashMap<>(); //상위 100명의 각인 채용 횟수
private final Map<String, Integer> actualUserCountMap = new HashMap<>(); //각 직업각인별 상위 50명 중 집계 가능 인원 수
@PostConstruct
public void init() {
reset1();
reset2();
}
public void reset1() {
engraveCountMap.clear();
engraveCountMap.put("분노의망치", 0);
engraveCountMap.put("중력수련", 0);
engraveCountMap.put("고독한기사", 0);
engraveCountMap.put("전투태세", 0);
...
}
public void reset2(){
actualUserCountMap.clear();
actualUserCountMap.put("분노의망치", 0);
actualUserCountMap.put("중력수련", 0);
actualUserCountMap.put("고독한기사", 0);
actualUserCountMap.put("전투태세", 0);
actualUserCountMap.put("광기", 0);
actualUserCountMap.put("광전사의비기", 0);
...
}
public void updateEngraveCount(String className, int count) {
engraveCountMap.put(className, engraveCountMap.getOrDefault(className, 0) + count);
}
public void updateActualUserCount(String className, int count) {
actualUserCountMap.put(className, actualUserCountMap.getOrDefault(className, 0) + count);
}
}
@GetMapping("/test")
public ResponseEntity<?> test(
) {
gemApiService.loaAPI();
return ResponseEntity.status(HttpStatus.OK).body("보석 API 호출에 성공하였습니다");
}
로직의 흐름은 다음과 같다.
1. 크롤링한 데이터를 저장한 테이블을 List로 가져온다.
2. 작업을 한 직업을 카운팅하는데, 보석을 11개 모두 착용하고 있지 않은 경우는 카운팅하지 않는다.
3. 앞에서 분석한 Curl에 맞춰 로스트아크 API에 요청을 보낸다.
4. 요청을 보낼때, 한 API Key로는 1분에 100개가 최대이기에, 5개를 모두 사용 후에 1분의 대기시간을 넣는다.
5. JSON구조는 다음과 같기에, 받은 데이터를 파싱하여, Effects 객체의 Skills 배열 안에 Description 속성의 문자열을 보고 "재사용"이라는 표현을 사용하면 작열 보석으로 분류하고, "피해" 혹은 "지원"이라는 표현이 포함된다면 겁화 보석으로 분류할 것이다.
{
"Gems": [
{
"Slot": 0,
"Name": "string",
"Icon": "string",
"Level": 0,
"Grade": "string",
"Tooltip": "string"
}
],
"Effects": {
"Description": "string",
"Skills": [
{
"GemSlot": 0,
"Name": "string",
"Description": [
"string"
],
"Option": "string",
"Icon": "string",
"Tooltip": "string"
}
]
}
}
@Transactional
public void loaAPI() {
try {
List<CrawlingEntity> users = crawlingRepository.findAll(); // 모든 사용자 가져오기
// users가 비었을 경우 예외 발생
if (users.isEmpty()) {
throw new UserNotFoundException("db에 크롤링 정보가 없습니다.");
}
int apiIndex = 0;
int userCount = 0;
// 카운트 데이터 초기화
engraveCountCache.reset2();
gemApiRepository.deleteAll();
for (CrawlingEntity user : users) {
if (userCount >= 80) { // 80명 검색 후 다음 API로 전환
apiIndex++;
userCount = 0;
if (apiIndex >= api.length) { // 모든 API 키를 사용했다면 1분 대기 후 다시 첫 번째 API로
apiIndex = 0;
log.info("모든 API 키를 사용했습니다. 60초 동안 대기 중...");
Thread.sleep(60000); // 60초 대기
}
}
String apiKey = api[apiIndex]; // 현재 API 키 선택
String userNickName = user.getUserNickName();
String characterClassName = user.getCharacterClassName();
String engraveName = user.getEngraveName();
log.info("=================================================================================");
log.info("userCount: {}", userCount);
log.info("User NickName: {}", userNickName);
log.info("Character Class Name: {}", characterClassName);
log.info("Engrave Name: {}", engraveName);
String encodedUserNickName = URLEncoder.encode(userNickName, "UTF-8");
String reqURL = "https://developer-lostark.game.onstove.com/armories/characters/" + encodedUserNickName + "/gems";
log.info("Calling API: {}", reqURL);
URL url = new URL(reqURL);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "bearer " + apiKey);
conn.setRequestProperty("Accept", "application/json");
int responseCode = conn.getResponseCode();
InputStreamReader streamReader;
if (responseCode == 200) {
streamReader = new InputStreamReader(conn.getInputStream());
} else {
streamReader = new InputStreamReader(conn.getErrorStream());
}
BufferedReader br = new BufferedReader(streamReader);
String line;
StringBuilder result = new StringBuilder();
while ((line = br.readLine()) != null) {
result.append(line);
}
br.close();
log.info("Response Code: {}", responseCode);
log.info("Response: {}", result);
String responseString = result.toString();
try {
JSONObject gemJson = new JSONObject(responseString);
if (gemJson.has("Effects")) {
JSONObject effectsJson = gemJson.getJSONObject("Effects");
if (effectsJson.has("Skills") && !effectsJson.getJSONArray("Skills").isEmpty()) {
JSONArray skillsArray = effectsJson.getJSONArray("Skills");
if(skillsArray.length()!=11) continue; // 보석이 11개가 아닌 경우 건너뜀
for (int j = 0; j < skillsArray.length(); j++) {
JSONObject skill = skillsArray.getJSONObject(j);
String skillName = skill.getString("Name");
JSONArray descriptionArray = skill.getJSONArray("Description");
String description = !descriptionArray.isEmpty() ? descriptionArray.getString(0) : "";
String gemType = null;
if (description.contains("재사용")) {
gemType = "작";
} else if (description.contains("피해")||description.contains("지원")) {
gemType = "겁";
} else {
log.error("Unknown gem type: {}", description);
throw new GemApiGetException("알 수 없는 종류의 보석입니다" + description);
}
GemApiEntity gemApiEntity = gemApiRepository.findByCharacterClassNameAndSkillNameAndGemTypeAndEngraveType(characterClassName, skillName, gemType, engraveName);
if (gemApiEntity != null) {
gemApiEntity.setCount(gemApiEntity.getCount() + 1);
gemApiRepository.save(gemApiEntity);
} else {
GemApiEntity newGemApiEntity = new GemApiEntity();
newGemApiEntity.setCharacterClassName(characterClassName);
newGemApiEntity.setSkillName(skillName);
newGemApiEntity.setGemType(gemType);
newGemApiEntity.setEngraveType(engraveName);
newGemApiEntity.setCount(1);
gemApiRepository.save(newGemApiEntity);
}
}
// 직업각인 카운트 증가
engraveCountCache.updateActualUserCount(engraveName, 1);
} else {
log.error("Skills 데이터가 없습니다.");
}
} else {
log.error("Effects 데이터가 없습니다.");
}
} catch (JSONException e) {
log.error("JSON 파싱 오류: {}", e.getMessage());
throw new GemApiGetException("JSON 파싱 오류: " + e.getMessage());
}
userCount++; // 요청 후 사용자 카운트 증가
}
log.info("집계 결과: {}", engraveCountCache.getActualUserCountMap());
} catch (Exception e) {
throw new GemApiGetException("로아 API 요청 중 오류 발생: " + e.getMessage());
}
}
코드 실행시, 데이터 베이스에 잘 들어간 것을 볼 수 있다.
