[moaloa] 미니프로젝트 개발일지 #5

msw-Hub·2025년 3월 10일
0

moaloa

목록 보기
6/7

> 영지 제작 계산 기능 개발

보석 검색 기능 다음으로 메인 기능인 영지 제작 계산 기능을 개발했다.
로스트아크 게임에는 게임내에서 얻는 생활재료로 제작품을 생산하여 판매를 할 수 있는 기능이 있는데,
제작을 할때 기본 재료값과 수수료 감소 계산 및 판매 수수료 계산, 그리고 생활재료간의 교환을 통해 레시피를 수정하는 등의 다양한 조건이 붙는다. 인게임에서는 해당 계산을 전부 수작업을 통해서 해야하기 때문에 이를 간편화 하여 보여주는 기능을 개발하고자 했다.

나는 재료랑 완성템의 시세를 1분마다 갱신하고 하루에 1번 전날 거래량을 갱신하면 된다.
이때, 프론트의 요구에 맞춰 객체를 구성하면된다.

기능 구현 순서는 다음과 같다.

  1. 로스트아크 내의 기본 제작 완성템과 재료템을 DB에 넣는다.
  2. 완성템의 레시피를 재료템의 ID값과 수량을 토대로 DB에 넣는다.
  3. 완성템과 재료템의 시세를 갱신하는 로직을 만든다.
  4. 완성템의 전날 거래량을 갱신하는 로직을 만든다.
  5. 프론트의 요구에 맞춰 객체를 구성하여 전달한다.
  6. 스케쥴링 로직을 구현한다.

> 코드 구현

1~2. 기본 제작 완성템과 재료템을 DB에 넣는다.

설계 했던 DB구조는 다음과 같다.
그런데 만들때는 몰랐지만, 만들고나니 cratf_recipe_entity는 필요 없었던것 같다....

우선 로스트아크 측에서 제공하는 해당 아이템들의 정보에는 무엇이 있는지 보자.
아이템을 검색하려면 카테고리에 해당되는 Subs 코드를 알아야한다.

해당 curl을 통해 확인이 가능하다.

curl -X 'GET' \
  'https://developer-lostark.game.onstove.com/markets/options' \
  -H 'accept: application/json' \
  -H 'authorization: bearer.....

결과는 다음과 같다.

"Subs" 항목 코드들
90200: 식물채집 전리품
90300: 벌목 전리품
90400: 채광 전리품
90500: 수렵 전리품
90600: 낚시 전리품
90700: 고고학 전리품
50010: 융화 재료
70000: 요리
60200: 회복형 배틀 아이템
60300: 공격형 배틀 아이템
60400: 기능형 배틀 아이템
60500: 버프형 배틀 아이템

해당 아이템들을 전부 사용하지는 않고, 게임 내에서 어느 정도 사용량이 있는 아이템에 한해서만 제작하기로 했다.
해당 품목은 글 작성기준 최근에 추가된 '특제 부패 중화제' 까지 총 56개의 아이템로 줄였다.
(융화재료의 경우, 생활종류에 따라 따로 계산된다)

해당 아이템 품목에 맞춰서 DB에 초기값들을 넣어준다.
※ 이때, 같은 Subs 코드여도, 인게임에서 구분하는 제작의 분야가 다를 수 있기에, 따로 카테고리를 구분하여주어야한다.
예를 들어, "각성 물약"과 "신속 로브" 아이템은 60500코드로 기능성배틀아이템-버프형- 에 속하지만, "각성 물약"은 제작에서 물약에 해당되지만, "신속 로브"는 제작에서 로브에 해당된다.

# 제작 아이템 테이블
INSERT INTO craft_item_entity (id, market_id, craft_name, market_name, category, craft_quantity, bundle_count, craft_price, activity_price, exp, craft_time, icon_link, grade, current_min_price, recent_price, y_day_avg_price, trade_count) VALUES
(1, 101042, '신호탄', '신호탄', 6, 3, 1, 0, 2, 0, 10, 'https://cdn-lostark.game.onstove.com/efui_iconatlas/battle_item/battle_item_01_4.png', '고급', 4, 4, 4.5,0),
(2, 101935, '빛나는 신호탄', '빛나는 신호탄', 6, 2, 1, 5, 72, 144, 900, 'https://cdn-lostark.game.onstove.com/efui_iconatlas/battle_item/battle_item_01_98.png', '고급', 18, 17, 0,0),
(3, 101015, '만능 물약', '만능 물약', 2, 3, 1, 15, 144, 288, 1800, 'https://cdn-lostark.game.onstove.com/efui_iconatlas/battle_item/battle_item_01_17.png', '희귀', 13, 13, 12.9,0),
(4, 101929, '빛나는 만능 물약', '빛나는 만능 물약', 2, 2, 1, 15, 216, 432, 2700, 'https://cdn-lostark.game.onstove.com/efui_iconatlas/battle_item/battle_item_01_92.png', '희귀', 30, 30, 30.4,0),
(5, 101916, '페로몬 정수', '페로몬 정수', 6, 3, 1, 15, 144, 288, 1800, 'https://cdn-
.....

# 제작 재료 테이블
INSERT INTO craft_material_entity (id, market_id, market_name, sub_code, bundle_count, icon_link, grade, current_min_price, recent_price, y_day_avg_price) VALUES
(1, 6882101, '들꽃', 90200, 100, 'https://cdn-lostark.game.onstove.com/efui_iconatlas/use/use_8_46.png', '일반', 64, 64, 60),
(2, 6882104, '수줍은 들꽃', 90200, 100, 'https://cdn-lostark.game.onstove.com/efui_iconatlas/use/use_4_14.png', '고급', 167, 167, 162.9),
(3, 6882107, '화사한 들꽃', 90200, 100, 'https://cdn-lostark.game.onstove.com/efui_iconatlas/use/use_8_47.png', '희귀', 85, 85, 85.3),
(4, 6882109, '아비도스 들꽃', 90200, 100, 'https://cdn-lostark.game.onstove.com/efui_iconatlas/lifelevel/lifelevel_02_47.png', '희귀', 634, 634, 639.7),
(5, 6882301, '목재', 90300, 100, 'https://cdn-lostark.game.onstove.com/efui_iconatlas/use/use_3_252.png', '일반', 51, 51, 52)
.....

# 제작 레시피 재료 테이블
INSERT INTO craft_recipe_material_entity (id, craft_recipe_id,craft_material_id,quantity) VALUES
(1, 1, 6, 20), (2, 1, 9, 36), (3, 2, 10, 20), (4, 2, 39, 3), .....

3. 완성템과 재료템의 시세를 갱신하는 로직을 만든다.

각 Subs코드에 맞춰 검색할 아이템을 Map한다.

private Map<Integer, List<String>> subsCodeMap() {
        Map<Integer, List<String>> map = new HashMap<>();

        map.put(90200, List.of("null"));
        map.put(90300, List.of("null"));
        map.put(90400, List.of("null"));
        map.put(90500, List.of("null"));
        map.put(90600, List.of("null"));
        map.put(90700, List.of("null"));
        map.put(50010, List.of("융화 재료"));
        map.put(70000, List.of("거장의 채끝 스테이크 정식", "대가의 안심 스테이크 정식", "명인의 허브 스테이크 정식", "거장의 특제 스튜", "명인의 쫄깃한 꼬치구이"));
        map.put(60200, List.of("null"));
        map.put(60300, List.of("점토","화염","암흑","회오리","폭탄"));
        map.put(60400, List.of("신호탄","만능","페로몬","시간","성스러운","불꽃 마법","특제 부패 중화제"));
        map.put(60500, List.of("신속 로브", "진군", "각성","아드로핀"));
        return map;
    }

이후 검색된 아이템은 3가지로 나뉜다. 제작아이템(완성템), 제작재료아이템, 제작아이템이면서 제작재료아이템.
Subs코드가 90000 이 넘는다면, 이는 생활재료아이템으로 해당 아이템들은 전부 제작재료아이템에 해당한다.
그렇기에 제작재료아이템인지 구분하여 제작재료테이블을 거치고, 해당 아이템이 90000 이 안넘는다면 제작아이템(완성템)테이블에도 넣어주면된다.

@Transactional
    public void getLoaApi() {
        String reqURL = "https://developer-lostark.game.onstove.com/markets/items";
        Map<Integer, List<String>> codeToItemsMap = subsCodeMap();
        try {
            for (Map.Entry<Integer, List<String>> entry : codeToItemsMap.entrySet()) {
                int code = entry.getKey();
                List<String> itemNames = entry.getValue();

                for (String itemName : itemNames) {
                    HttpURLConnection conn = (HttpURLConnection) new URL(reqURL).openConnection();
                    conn.setRequestMethod("POST");
                    conn.setRequestProperty("Authorization", "bearer " + craftApi[0]);
                    conn.setRequestProperty("Accept", "application/json");
                    conn.setRequestProperty("Content-Type", "application/json");
                    conn.setDoOutput(true);

                    String jsonInputString = createJsonInputString(code, itemName);

                    // JSON 데이터 전송
                    try (OutputStream os = conn.getOutputStream()) {
                        byte[] input = jsonInputString.getBytes("utf-8");
                        os.write(input, 0, input.length);
                    }

                    int responseCode = conn.getResponseCode();
                    InputStreamReader streamReader = (responseCode == 200) ?
                            new InputStreamReader(conn.getInputStream()) : new InputStreamReader(conn.getErrorStream());

                    BufferedReader br = new BufferedReader(streamReader);
                    StringBuilder result = new StringBuilder();
                    String line;
                    while ((line = br.readLine()) != null) {
                        result.append(line);
                    }
                    br.close();

                    String responseString = result.toString();
                    // 시세 데이터 갱신

                    updateCraftPrice(responseString, code);
                    // 연결을 명시적으로 닫음
                    conn.disconnect();
                }
            }
            //시세 갱신된 데이터를 json 파일로 저장. 이때, json 구조를 변경해야함
            saveJsonToFile();
        } catch (Exception e) {
            log.error("Craft API 호출 중 오류가 발생했습니다", e);
            throw new CraftApiGetException("로스트아크 API 호출 중 오류가 발생했습니다.");

        }
    }

    @Transactional
    protected void updateCraftPrice(String responseString, int code) {  //에러 핸들러 바꿔야함
        try {
            JSONObject jsonObject = new JSONObject(responseString);
            if(jsonObject.has("Items")) {
                JSONArray jsonArray = jsonObject.getJSONArray("Items");
                if(jsonArray.isEmpty()) {
                    log.warn("해당 code로 검색한 데이터의 Items가 비어있습니다: {}", code);
                }

                for(int i = 0; i < jsonArray.length(); i++) {
                    JSONObject item = jsonArray.getJSONObject(i);
                    int marketId = item.getInt("Id");
                    String marketName = item.getString("Name");
                    double currentMinPrice = item.getDouble("CurrentMinPrice");
                    double recentPrice = item.getDouble("RecentPrice");
                    double yDayAvgPrice = item.getDouble("YDayAvgPrice");


                    //제작 재료 아이템에 해당되는 아이템만 업데이트
                    if ((code > 90000 && !marketName.contains("결정"))
                            || (code == 60300 && !marketName.contains("빛나는"))
                            || (code == 60200 && marketId == 101063)
                            || (code == 60400 && (marketName.equals("신호탄") || marketName.equals("만능 물약") || marketName.equals("성스러운 부적")) || marketName.equals("페로몬 정수")&& !marketName.contains("빛나는"))
                            || (code == 60500 && (marketName.equals("신속 로브") || marketName.equals("진군의 깃발")) && !marketName.contains("빛나는"))
                    ) {
                        CraftMaterialEntity craftMaterialEntity = craftMaterialRepository.findByMarketId(marketId);
                        if (craftMaterialEntity == null) {
                            log.warn("재료 아이템을 찾는 과정에서 DB에 존재하지 않는 아이템입니다 해당 아이템을 확인해 주시기 바랍니다. : {}", item);
                            continue;
                        }
                        craftMaterialEntity.setCurrentMinPrice(currentMinPrice);
                        craftMaterialEntity.setRecentPrice(recentPrice);
                        craftMaterialEntity.setYDayAvgPrice(yDayAvgPrice);
                        craftMaterialRepository.save(craftMaterialEntity);
                    }
                    // 제작 아이템에 해당 되는 아이템만 업데이트
                    if (code < 90000) {
                        List<CraftItemEntity> craftItemEntities = craftItemRepository.findAllByMarketId(marketId);
                        if (craftItemEntities.isEmpty()) {
                            log.warn("제작 아이템을 찾는 과정에서 DB에 존재하지 않는 아이템입니다 해당 아이템을 확인해 주시기 바랍니다. : {}", item);
                            continue;
                        } //같은 이름의 아이템이 여러개일 수 있음 - 한번에 다 바꾸기
                        for (CraftItemEntity craftItemEntity : craftItemEntities) {
                            craftItemEntity.setCurrentMinPrice(currentMinPrice);
                            craftItemEntity.setRecentPrice(recentPrice);
                            craftItemEntity.setYDayAvgPrice(yDayAvgPrice);
                            craftItemRepository.save(craftItemEntity);
                        }
                    }
                }
            } else {
                log.warn("해당 code에 대한 데이터가 존재하지 않습니다: {}", code);
            }
        } catch (JSONException e) {
            log.error("JSON 파싱 중 오류가 발생했습니다: {}", responseString, e);
            throw new CraftDataException("JSON 데이터를 객체로 변환 중 오류가 발생했습니다. api 키 횟수의 문제가 있을 수 있습니다.");
        } catch (Exception e) {
            log.error("데이터 베이스 업데이트 중 오류가 발생했습니다", e);
            throw new CraftDataException("데이터 베이스 업데이트 중 오류가 발생했습니다");
        }
    }

4. 완성템의 전날 거래량을 갱신하는 로직을 만든다.

해당 부분은 처음 설계했던 부분에 시간이 조금 지난 뒤 추가된 부분이다.
제작에 있어서 전날의 판매량을 보고 참고할 수 있도록 만든 부분이다. 아무래도 판매량이 낮은 제작품을 만들게되면 오랬동안 해당 매물을 들고 있을 수도 있으며, 가격이 점차 떨어질 수도 있기 때문이다.

로스트아크api에 다른 요청들과 다를바가 없다. 해당 curl 형식에 맞춰 작성한다.

curl -X 'GET' \
 'https://developer-lostark.game.onstove.com/markets/items/{해당 아이템 id값}' \
 -H 'accept: application/json' \
 -H 'authorization: bearer ...

검색 결과의 첫번째 객체의 "Stats"값의 두번째 항목 값이 전날 거래량 값이다.

[
  {
    "Name": "오레하 융화 재료",
    "TradeRemainCount": null,
    "BundleCount": 1,
    "Stats": [
      {
        "Date": "2025-03-11",
        "AvgPrice": 11.7,
        "TradeCount": 64027
      },
      {
        "Date": "2025-03-10",
        "AvgPrice": 11.3,
        "TradeCount": 591596
      },
      ......

해당 값의 "TradeCount" 값을 가져와 DB에 저장하는 로직을 구현하면된다.

5. 프론트의 요구에 맞춰 객체를 구성하여 전달한다.

3번 매서드 부분의 아래의 saveJsonToFile()에 해당되는 부분이다.
이전 보석 검색 부분의 로직과 흡사하다, JSON파일을 저장해두는 방식을 사용했다.
갱신시간, 제작아이템리스트, 제작재료시세에 맞춰 객체를 구성한다. 이에 맞춰 맵핑해준다.

public void saveJsonToFile() throws IOException {
        // 레시피를 가져옴
        List<CraftRecipeEntity> craftRecipeEntities = craftRecipeRepository.findAll();

        // 현재 날짜와 시간을 얻음
        String currentDateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));

        // CraftRecipeEntity를 CraftRecipeDto로 변환
        List<CraftRecipeDto> craftRecipeDtos = craftRecipeEntities.stream()
                .map(craftRecipeEntity -> new CraftRecipeDto(craftRecipeEntity, craftRecipeEntity.getCraftItem())) // CraftRecipeDto 생성 시 필요한 Entity 추가
                .collect(Collectors.toList());



        // JSON 객체 생성
        Map<String, Object> jsonMap = new HashMap<>();
        jsonMap.put("갱신시간", currentDateTime);
        jsonMap.put("craftItemList", craftRecipeDtos);
        jsonMap.put("제작재료시세", materialAllMap());

        // ObjectMapper를 사용하여 Map을 JSON으로 변환
        ObjectMapper objectMapper = new ObjectMapper();
        String jsonString = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonMap);

        // JSON 파일로 저장
        Files.write(Paths.get(filePath), jsonString.getBytes());

        //생활 재료만 추가적으로 저장해야함    >>>> 새로 추가된 내용 24.12.31
        Map<String, Object> lifeMap = new HashMap<>();
        lifeMap.put("갱신시간", currentDateTime);
        lifeMap.put("생활재료 시세", lifeMeterialMap());
        // ObjectMapper를 사용하여 Map을 JSON으로 변환
        ObjectMapper objectMapper2 = new ObjectMapper();
        String jsonString2 = objectMapper2.writerWithDefaultPrettyPrinter().writeValueAsString(lifeMap);
        // JSON 파일로 저장
        Files.write(Paths.get(lifeFilePath), jsonString2.getBytes());

    }

    private Map<String, Object> materialAllMap(){
        Map<String, Object> lifeMap = new HashMap<>();
        int[] subCodes = {60200,60300,60400,60500,90200, 90300, 90400, 90500, 90600, 90700};
        for (int subCode : subCodes) {
            List<CraftMaterialLifeDto> craftMaterialDtos = craftMaterialRepository.findBySubCode(subCode).stream()
                    .map(CraftMaterialLifeDto::new)
                    .toList();
            lifeMap.put(String.valueOf(subCode), craftMaterialDtos);
        }
        return lifeMap;
    }
    private Map<String, Object> lifeMeterialMap(){
        Map<String, Object> lifeMap = new HashMap<>();
        int[] subCodes = {90200, 90300, 90400, 90500, 90600, 90700};
        for (int subCode : subCodes) {
            List<CraftMaterialLifeDto> craftMaterialDtos = craftMaterialRepository.findBySubCode(subCode).stream()
                    .map(CraftMaterialLifeDto::new)
                    .toList();
            lifeMap.put(String.valueOf(subCode), craftMaterialDtos);
        }
        return lifeMap;
    }

결과는 다음과 같다.

{
   "제작재료시세": {
        "90200": [
            {
                "marketId": 6882101,
                "marketName": "들꽃",
                "currentMinPrice": 68.0,
                "grade": "일반"
            },
            .....
    },
    "craftItemList": [
        {
            "id": 1,
            "marketId": 101042,
            "craftName": "신호탄",
            "marketName": "신호탄",
            "category": 6,
            "craftQuantity": 3,
            "bundleCount": 1,
            "craftPrice": 0,
            "activityPrice": 2,
            "exp": 0,
            ......
    ],
    "갱신시간": "2025-03-11 02:18:15"
}
             

해당 info를 통해 프론트에서 계산 로직을 만들어 효율을 계산한다.

전달하는 로직도 구현한다.
메인페이지에 접근하게 된다면, 방금 전달한 객체가 전부지만, 특정 아이템에 대한 정보를 검색 받았을 때는 특정 아이템만을 전달해야한다.
원하는 아이템의 id값을 전달받아, 기존 데이터 파일에서 갱신시간과 특정 데이터를 추출하여 데이터를 재구성하여 전달한다.

@GetMapping("/readData")
    public ResponseEntity<?> getCraftData(
            @RequestParam("craftItemId") int craftItemId
    ) {
        String jsonData = craftService.readJsonFromFile(0); // 파일에서 JSON 읽기
        // JSON에서 갱신시간과 생활재료시세 데이터만 추출
        Map<String,Object> data = craftService.reParseJsonToObject(jsonData, craftItemId);
        return ResponseEntity.ok(data);
    }
public Map<String, Object> reParseJsonToObject(String jsonData, int craftItemId) {
        try {
            // JSON 데이터를 파싱
            JSONObject jsonObject = new JSONObject(jsonData);

            // 갱신시간 추출
            String date = jsonObject.getString("갱신시간");

            // 특정 craftItemId의 데이터를 객체로 추출
            JSONArray jsonArray = jsonObject.getJSONArray("craftItemList");
            JSONObject matchedItem = null;
            for (int i = 0; i < jsonArray.length(); i++) {
                JSONObject item = jsonArray.getJSONObject(i);
                if (item.getInt("id") == craftItemId) {
                    matchedItem = item;
                    break;
                }
            }
            // 만약 해당 아이템이 없다면 예외 처리
            if (matchedItem == null) {
                throw new CraftDataException("해당 아이템 ID를 찾을 수 없습니다: " + craftItemId);
            }

            // 제작 재료 배열에서 subCode 추출 및 중복 제거
            List<Integer> subCodes = new ArrayList<>();
            if (matchedItem.has("craftMaterials")) {
                JSONArray craftMaterials = matchedItem.getJSONArray("craftMaterials");
                for (int i = 0; i < craftMaterials.length(); i++) {
                    JSONObject material = craftMaterials.getJSONObject(i);
                    if (material.has("subCode")) {
                        Integer subCode = material.getInt("subCode");
                        if (!subCodes.contains(subCode)) { // 중복 확인
                            subCodes.add(subCode);
                        }
                    }
                }
            }

            // 결과 데이터 구성
            Map<String, Object> craftItemData = new HashMap<>();
            craftItemData.put("갱신시간", date);
            craftItemData.put("제작아이템", matchedItem.toMap()); // JSONObject를 Map으로 변환
            craftItemData.put("제작재료시세", materialMap(subCodes));

            return craftItemData;
        } catch (JSONException e) {
            throw new CraftDataException("JSON 데이터를 객체로 변환 중 오류가 발생했습니다");
        }
    }

6. 스케쥴링 로직을 구현한다.

spring으로 서버를 구현할때 REST API 방식만을 생각했다보니 이해가 되지않았다.
REST API는 클라이언트가 요청(request)을 보내야 서버가 응답(response)을 반환하는 구조이다.
나는 일정시간마다 아이템의 시세를 갱신해야하는데, 이는 REST API방식으로는 구현이 불가능하기 때문이다.
찾아보니 스케쥴링 방식을 사용하면 된다는 것을 찾아냈다.
@Scheduled 어노테이션을 작성하고,
원하는 시간마다 혹은 고정된 날짜나 시간마다 반복하도록 구성하면된다.

// 생활재료 데이터 갱신
    @Async
    @Scheduled(fixedDelay = 1000 * 60) // 1분
    public void renewCraftData() {
        if (isWithinBlockedTime()) {
            log.warn("renewCraftData is disabled on Wednesday between 6:00 and 10:00. Skipping this execution.");
            return;
        }

        if (isRunning) {
            log.warn("renewCraftData is already running. Skipping this execution.");
            return;
        }

        isRunning = true;
        try {
            log.info("======================CraftData Start======================");
            craftService.getLoaApi();
            log.info("======================CraftData End======================");
        } finally {
            isRunning = false;
        }
    }

이런식으로 반복적으로 동작하도록하면된다.


> 다음 과정

아이디어를 토대로 기능을 구현하는 것은 여기까지이며, 이후는 배포와 관련된 정보이다.
추가적으로 스케쥴링에 관해 포스팅하도록 하겠다.

아래 링크로 이동하면 해당 페이지를 볼 수 있다.

moaloa 링크 : https://moaloa.org/craft

profile
천천히 시작하는 개발자

0개의 댓글