트러블 슈팅

[Springboot] 힙 메모리 누수에 관한 이야기

개발만파볼까 2024. 4. 18. 00:28
728x90
반응형
SMALL

배경

내가 운영하고 있는 서비스가 실서버에 배포 및 운영을 하는 상태인데, 회사 내부 상품 정보를 많이 가져오면서도 처리속도를 빠르게 하기 위해 API 내부에는 ExecutorService 라는 객체를 쓰면서 멀티쓰레드를 활용해 여러번 API 호출하는 부분이 있었는데,  그걸로 인해 힙 메모리가 일정비율로 채워졌음에도 불구하고 GC가 제대로 일어나지 않은 것처럼 보이고 힙 메모리 사용량 70% 이상임에도 계속 늘어나는 메모리 누수가 일어나는 현상이 일어나고 있었다. 

 

현재 다른 서비스 개발에 집중을 하고 있어서 왜 그런지에 대해서 명확히 찾지를 못했채, 힙 메모리 사용량 일정 비율에 의해 모니터링 알람 울리면 강제로 GC 명령어를 입력해서 임시방편으로 대응하고 있다.  

 

 

원인 파악

내가 생각한 메모리 누수 원인은 다음과 같다.

 

1. 사용하는 객체 크기가 엄청 큼

  • 상품에 대한 정보가 정말 많기 때문에 이 부분에 대한 필드 갯수가 많음 (대략적으로 100개 정도)

2. 어떤 특정 필드 중 데이터 크기가 굉장히 큰 부분이 있음

  • 모니터링으로 응답 크기를 체크한 결과 최대 3-4MB (3,000,000 byte 이상) 인 크기가 많았었고, 이로 인해 메모리 누수가 발생

 

 

분석 파악

회사에서 만든 코드를 최대한 재현해서 예제형식으로 바꿔본 코드이다. 

 

public List<Product> executeTask2() {
        List<String> productIdList = new ArrayList<>();
        for (int i = 1; i <= 100; i++) {
            productIdList.add("Product" + i);
        }

        ExecutorService executor = Executors.newFixedThreadPool(20);
        CompletionService<List<Product>> completionService = new ExecutorCompletionService<>(executor);

        List<Product> allResults = new ArrayList<>();
        List<List<String>> partitions = partition(productIdList);
        log.info(partitions.size());

        for (List<String> partition : partitions) {
            completionService.submit(() -> {
                List<Product> products = new ArrayList<>();
                for (String productId : partition) {
                    try {
                        List<Product> apiResult = apiService.callApi();

                        // 데이터 크기 계산 및 로그 출력
                        if (apiResult.size() > 0) {
                            ByteArrayOutputStream baos = new ByteArrayOutputStream();
                            ObjectOutputStream oos = new ObjectOutputStream(baos);
                            oos.writeObject(apiResult);
                            oos.close();
                            double sizeInKB = baos.size() / 1024.0; // 바이트 단위의 크기를 KB 단위로 변환
                            log.info("API result size for productId: " + productId + " is " + sizeInKB + " KB");

                            if (!apiResult.isEmpty()) {
                                products.addAll(apiResult); // 모든 결과를 추가
                            }
                        }

                        for (Product product : products) {
                            try {
                                long size = estimateObjectSize(product); // Product 객체의 크기 추정
                                System.out.println("Product size: " + size + " bytes (" + (size / 1024.0) + " KB)");
                            } catch (IOException e) {
                                System.err.println("Failed to estimate object size for product: " + e.getMessage());
                            }
                        }

                    } catch (Exception e) {
                        log.error("API call failed for productId: " + productId, e);
                    }
                }
                return products; // List<Product> 반환
            });
        }


        for (int i = 0; i < partitions.size(); i++) {
            try {
                Future<List<Product>> future = completionService.take(); // 완료된 작업의 결과를 가져옴
                synchronized (allResults) {
                    allResults.addAll(future.get()); // Blocking call
                }
                // products 리스트를 처리
            } catch (InterruptedException | ExecutionException e) {
                log.error("Error while fetching products", e);
            }
        }

        executor.shutdown();
        log.info(String.format("Total processed products: %d", allResults.size()));
        return allResults.stream().limit(300).toList();
    }

 

 

public List<Product> callApi() {

        return IntStream.rangeClosed(1, 100).mapToObj(i -> {
            Product product = new Product();
            product.setShopNo(1L);
            product.setProductNo((long) i);
            product.setProductCode("P000000" + i);
            product.setCustomProductCode("CUST" + i);
            product.setProductName("Product " + i);
            product.setEngProductName("Product English Name " + i);
            product.setSupplyProductName("Supply Product " + i);
            product.setInternalProductName("Internal Name " + i);
            product.setModelName("Model " + i);
            product.setPriceExcludingTax("100.00");
            product.setPrice(new Product.Price(1000L + i, "1000"));
            product.setRetailPrice(new Product.Price(1200L + i, "1200"));
            product.setSupplyPrice(new Product.Price(800L + i, "800"));
            product.setDisplay("Y");
            product.setSelling("Y");
            product.setProductCondition("New");
            product.setProductUsedMonth(0L);
            product.setSummaryDescription("This is a summary for product " + i);
            product.setMarginRate("10%");
            product.setTaxCalculation("Included");
            product.setTaxType("VAT");
            product.setTaxRate(10L);
            product.setBuyLimitByProduct("N");
            product.setBuyLimitType("None");
            product.setBuyGroupList(Arrays.asList(1L, 2L));
            product.setBuyMemberIDList(Arrays.asList("user1", "user2"));
            product.setRepurchaseRestriction("N");
            product.setSinglePurchaseRestriction("N");
            product.setBuyUnitType("Unit");
            product.setBuyUnit(1L);
            product.setOrderQuantityLimitType("Limit");
            product.setMinimumQuantity(1L);
            product.setMaximumQuantity(10L);
            product.setPointsByProduct("Y");
            product.setPointsSettingByPayment("Fixed");
            product.setPointsAmount(Arrays.asList(new Product.PointsAmount("Cash", "5%"), new Product.PointsAmount("Card", "3%")));
            product.setExceptMemberPoints("N");
            product.setProductVolume(new Product.ProductVolume("Y", "10cm", "20cm", "30cm"));
            product.setAdultCertification("N");
            product.setDetailImage("http://example.com/detail.jpg");
            product.setListImage("http://example.com/list.jpg");
            product.setTinyImage("http://example.com/tiny.jpg");
            product.setSmallImage("http://example.com/small.jpg");
            product.setUseNaverpay("Y");
            product.setNaverpayType("Instant");
            product.setUseKakaopay("Y");
            product.setManufacturerCode("MANU" + i);
            product.setTrendCode("TREND" + i);
            product.setBrandCode("BRAND" + i);
            product.setSupplierCode("SUPP" + i);
            product.setMadeDate("2023-01-01");
            product.setReleaseDate("2023-01-10");
            Product.ExpirationDate expirationDate = new Product.ExpirationDate();
            expirationDate.setStartDate(new Product.Date(LocalDate.of(2023, 1, 1), OffsetDateTime.now()));
            expirationDate.setEndDate(new Product.Date(LocalDate.of(2023, 12, 31), OffsetDateTime.now()));
            product.setExpirationDate(expirationDate);
            product.setOriginClassification("Domestic");
            product.setOriginPlaceNo(100L);
            product.setOriginPlaceValue("Origin Place");
            product.setMadeInCode("KR");
            Product.ExpirationDate iconShowPeriod = new Product.ExpirationDate();
            iconShowPeriod.setStartDate(new Product.Date(LocalDate.of(2023, 1, 1), OffsetDateTime.now()));
            iconShowPeriod.setEndDate(new Product.Date(LocalDate.of(2023, 12, 31), OffsetDateTime.now()));
            product.setIconShowPeriod(iconShowPeriod);
            product.setIcon(Arrays.asList("icon1", "icon2"));
            product.setHscode("123456");
            product.setProductWeight("1kg");
            product.setProductMaterial("Material");
            product.setCreatedDate(OffsetDateTime.now());
            product.setUpdatedDate(OffsetDateTime.now());
            Product.ListIcon listIcon = new Product.ListIcon();
            listIcon.setSoldoutIcon(true);
            listIcon.setRecommendIcon(false);
            listIcon.setNewIcon(true);
            product.setListIcon(listIcon);
            product.setApproveStatus("Approved");
            product.setClassificationCode("CLASS" + i);
            product.setSoldOut("N");
            product.setAdditionalPrice("0.00");
            product.setClearanceCategoryEng("Category ENG");
            product.setClearanceCategoryKor("Category KOR");
            product.setClearanceCategoryCode("CAT" + i);
            product.setExposureLimitType("Limit");
            product.setExposureGroupList(Arrays.asList(1L, 2L));
            product.setShippingFeeByProduct("N");
            product.setShippingFeeType("Free");
            product.setMain(Arrays.asList(1L, 2L));
            product.setMarketSync("N");
            product.setSummaryDescription("Description for product " + generateLargeString());
            return product;
        }).collect(Collectors.toList());
    }

    public String generateLargeString() {
        double size = 400 * 1024;
        char[] chars = new char[(int) size];
        Arrays.fill(chars, 'A'); // 단순화를 위해 모든 문자를 'A'로 채움
        return new String(chars);
    }

 

해당 로직은 스레드 풀로 쓰레드 20개 세팅하고 해당 쓰레드 기반으로 해서 데이터 임의대로 만든 후, 해당 데이터들을 응답값을 리턴하는 로직을 만들었다. 내부에서는 API가 총 8천번 정도 호출하는 거랑 동일하게 데이터를 생성했다. 

 

자바 버젼 및 힙메모리 사양은 다음과 같다. 

Java: version 17.0.9 2023-10-17 LTS, vendor Amazon.com Inc.
Heap Size : 4GB

 

 

 

그 뒤에 한 번 호출을 했는데, visual GC에서 본 결과는 다음과 같다. 

 

한 번 호출한 결과, 다음과 같은 스샷 결과가 나왔다. 

스샷을 보시면 알다시피, API 호출이 끝났음에도 불구하고 여전히 Old 영역은 약 90% 가 되고 있는 상태고 호출하고 나서 30분이 지나도 여전히 힙 메모리 사용량이 줄지 않고 있다.  회사에서도 거의 비슷한 상황에 놓여져 있다.
현재 시간이 지나면 지날수록 메모리 사용량은 점점 늘고 있으며, 추후 힙 메모리 사용량이 가득차게 되어 프로세스가 죽을 거 같다. 

 

 

 

 

해결 방안

원인이 어떤지는 어느정도 파악이 된 상태이고, 내가 해결 방안을 생각했는데 다음과 같다. 

 

1. 필요한 필드만 제외이 부분은 한 번 체크를 해봐야겠지만 어떤 필드가 가장 크기를 많이 차지하는지를 체크가 필요하고, 그게 파악이 되었으면 API 호출했을 때 그 필드를 제외해도 되는지 여부를 체크해야한다. 하지만 해당 필드가 클라이언트 단에서 정말 필요한 필드일 수도 있기 떄문에 이 부분은 참고해야 한다. 

 

2. 프론트에서 설명 부분이 앞부분만 필요하다면 10줄 이상은 '...' 으로 처리하고 나머지는 지우는 걸로 처리하면 되지 않을까 생각한다.

 

 

3. 처음부터 "설명"이라는 필드는 1000 자 이상을 못 넘게 정책을 정하면 되지 않을까 생각을 하고 있다. 

 

 

4. 3가지 이외의 방법은 없을까? 

728x90
반응형
LIST