어느 정도 개발이 진행됨에 따라 혹은 시행착오를 겪을 때마다 작성하려 했지만, 꾸준히 작성한다는 것이 쉽지는 않은듯 하다...
작성일 기준으로는 이미 프로젝트를 완료하여 배포하고 실사용자도 어느정도 확보된 상태이다.
그래서 코드나 기능 구현에 있어서 리뷰를 하며 돌아보고자한다.
#2 까지의 기준으로는 크롤링을 하여 상위권 유저의 닉네임을 얻고, 이를 로스트아크 api에 요청하여 실사용 보석 데이터를 수집했다.
우리는 이를 직업별로 가중치를 주고 추가적인 구분을 지어야하는데, 가중치를 주기 위해서는 각 직업별로 직업각인 채용률을 계산해야했다. 이는 클로아사이트의 서버측에 랭킹 정보를 요청하게되면 해당 직업 군의 100등까지의 정보를 얻을 수 있다는 점을 이용하여, 요청을 보내고, 카운팅하여 직업각인별 가중치를 계산했다.
매커니즘을 이해하기 위해서는 다음을 알아야한다.
0. 각 스킬에는 보석을 사용할 수 있다. (보석에는 겁화 혹은 작열 2가지가 존재한다)
1. 직업 각인은 반드시 A 와 B가 존재하며, A와 B는 약 20개의 스킬들을 공유하지만 채용하는 것은 8개뿐으로 둘이 채용하는 스킬이 다를 수 있다.
2. 앞선 #2 글의 과정을 통해서 우리는 각직업각인별로(A와B) 50명씩 조사하여 해당 유저가 사용하는 보석을 조사했으나, 보석이 빠져있는 경우도 존재하여, 각각의 실조사수를 카운팅했다.
3. A 직업 각인에 대해 ( 해당 스킬 사용자 수 / 조사 대상자 수 직업각인 가중치) + B직업 각인에 대해 ( 해당 스킬 사용자 수 / 조사 대상자 수 직업각인 가중치) 를 계산하면 해당 보석에 대한 채용률이 계산된다.
클로아 측에 요청을 보내기전 각각의 직업id 값을 찾았다.
해당 id값은 다음과같다.
private final String[] searchJobId = {
"11", "12", "13", "14", "91","21","22","31","32","33","34","41","42","43","44","51","61","62","63","64","71","72","73","74","81","82","83"
};
코드 내부에 카운팅한 값을 임시저장할 캐시를 구현한다.
public void reset1() {
engraveCountMap.clear();
engraveCountMap.put("분노의망치", 0);
engraveCountMap.put("중력수련", 0);
engraveCountMap.put("고독한기사", 0);
engraveCountMap.put("전투태세", 0);
engraveCountMap.put("광기", 0);
engraveCountMap.put("광전사의비기", 0);
engraveCountMap.put("심판자", 0);
.......
private void countEngraveRate(String jobId, String arkPassiveEffects) {
switch (jobId) {
case "11" -> {
if (arkPassiveEffects.equals("중력 갑옷")) {
engraveCountCache.updateEngraveCount("분노의망치", 1);
} else engraveCountCache.updateEngraveCount("중력수련", 1);
}
case "12" -> {
if (arkPassiveEffects.equals("창술 수련")) {
engraveCountCache.updateEngraveCount("고독한기사", 1);
} else engraveCountCache.updateEngraveCount("전투태세", 1);
}
case "13" -> {
.......
//클로아에서 직업별 상위 100명 조사해서 직업각인 비율 계산
public void engraveRate() {
engraveCountCache.reset1();
for (String jobId : searchJobId) {
String reqURL = "https://secapi.korlark.com/lostark/ranking/character?page=1&limit=3&job=" + jobId;
log.info("Calling API: {}", reqURL);
try {
URL url = new URL(reqURL);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
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 {
JSONArray jsonArray = new JSONArray(responseString);
// 각 항목에서 arkPassiveEffects 배열의 이름을 가져옴
for (int i = 0; i < jsonArray.length(); i++) {
JSONObject item = jsonArray.getJSONObject(i);
JSONArray arkPassiveEffects = item.getJSONArray("arkPassiveEffects");
for (int j = 0; j < arkPassiveEffects.length(); j++) {
JSONObject effect = arkPassiveEffects.getJSONObject(j);
String effectName = effect.getString("name");
log.info("effectName: {}", effectName);
countEngraveRate(jobId, effectName);
}
}
} catch (JSONException e) {
log.error("JSON 파싱 오류: {}", e.getMessage());
throw new GemDataException("직업각인 가중치를 위한 집계 중 오류가 발생했습니다");
}
} catch (Exception e) {
log.error("API 호출 중 오류 발생: {}", e.getMessage(), e);
throw new GemDataException("API 호출 또는 데이터 처리 중 문제가 발생했습니다.");
}
}
log.info("Engrave Rate: {}", engraveCountCache.getEngraveCountMap());
}
채용률의 경우 5프로 미만이라면 필터링 되도록한다
public void aggregateAndSaveGemData() {
List<GemApiEntity> gems = gemApiRepository.findAll();
// gems가 비었을 경우 예외 발생
if (gems.isEmpty()) {
throw new GemAggregationException("데이터베이스에 보석 정보가 없어서 집계를 내릴 수 없습니다");
}
// 데이터 집계 및 채용률 계산
Map<String, Map<String, Map<String, Double>>> aggregatedData = gems.stream()
.collect(Collectors.groupingBy(
GemApiEntity::getCharacterClassName,
Collectors.groupingBy(
GemApiEntity::getGemType,
Collectors.groupingBy(
GemApiEntity::getSkillName,
Collectors.collectingAndThen(
Collectors.summingDouble(
// 각 직업 각인에 대해 ( 해당 스킬 사용자 수 / 조사 대상자 수 ) 로 채용률을 계산
gem -> (double) gem.getCount() / engraveCountCache.getActualUserCountMap().getOrDefault(gem.getEngraveType(), 0)
* engraveCountCache.getEngraveCountMap().getOrDefault(gem.getEngraveType(), 0)),
sum -> {
// 둘을 더함
return BigDecimal.valueOf(sum)
.setScale(3, RoundingMode.HALF_UP)
.doubleValue(); // 소수점 셋째 자리에서 반올림
}
)
)
)
));
// 필터링 및 정렬
Map<String, Map<String, List<GemDto>>> filteredAndSortedData = new LinkedHashMap<>();
aggregatedData.forEach((className, gemTypeMap) -> {
Map<String, List<GemDto>> filteredGemTypes = new LinkedHashMap<>();
gemTypeMap.forEach((gemType, skillMap) -> {
List<GemDto> filteredAndSortedGems = skillMap.entrySet().stream()
.filter(entry -> entry.getValue() >= 5) // 채용률 >= 5% 필터링
.sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) // 채용률 기준 내림차순
.map(entry -> new GemDto(entry.getKey(), entry.getValue())) // GemDto 생성
.collect(Collectors.toList());
if (!filteredAndSortedGems.isEmpty()) {
filteredGemTypes.put(gemType, filteredAndSortedGems);
}
});
if (!filteredGemTypes.isEmpty()) {
filteredAndSortedData.put(className, filteredGemTypes);
}
});
log.info("집계된 데이터: {}", aggregatedData);
log.info("필터링 및 정렬된 데이터: {}", filteredAndSortedData);
try {
// JSON을 데이터베이스에 저장
String jsonData = objectMapper.writeValueAsString(filteredAndSortedData);
// 기존 데이터 삭제
gemDataRepository.deleteAll(); // 모든 데이터를 삭제
// 새로운 데이터 저장
GemDataEntity newGemData = new GemDataEntity();
newGemData.setJsonString(jsonData);
gemDataRepository.save(newGemData);
} catch (JsonProcessingException e) {
throw new GemAggregationException("JSON 파일로 변환 중 오류가 발생했습니다");
}
}
프론트의 요청이 오면 저장해둔 JSON파일을 전달하는 방식으로 진행한다. 매 요청마다 데이터베이스에 접근하여 가져오는 것보다 파일을 읽어 전달하는것이 더 빠르고 DB에 부하를 감소시키는데 좋을 것으로 판단하여 진행했다.
public String getJsonData() {
GemDataEntity gemData = gemDataRepository.findAll().stream().findFirst().orElse(null);
if(gemData == null){
return null;
}
String jsonString = gemData.getJsonString();
if (jsonString == null) {
return "저장된 JSON 데이터가 없습니다.";
}
try {
Files.write(Paths.get(filePath), jsonString.getBytes());
return "데이터베이스에서 파일로 JSON 파일이 성공적으로 저장되었습니다.";
} catch (IOException e) {
throw new GemAggregationException("JSON 파일 저장 중 오류가 발생했습니다");
}
}
@GetMapping("/readData")
public ResponseEntity<?> getGemData(
) {
String jsonData = gemDataService.readJsonFromFile(); // 파일에서 JSON 읽기
Object data = gemDataService.parseJsonToObject(jsonData); // JSON을 객체로 변환
return ResponseEntity.ok(data);
}
public String readJsonFromFile() {
try {
return Files.readString(Paths.get(filePath));
} catch (IOException e) {
throw new GemDataException("JSON 파일 읽기 중 오류가 발생했습니다");
}
}
public Object parseJsonToObject(String jsonData){
try {
return objectMapper.readValue(jsonData, Object.class);
} catch (JsonProcessingException e) {
throw new GemDataException("JSON 데이터를 객체로 변환 중 오류가 발생했습니다");
}
}
보석 검색과 관련해서는 이후에 현재 보석의 시세를 전달해주는 로직을 넣어주면 완성이다.