배경
내가 운영하고 있는 서비스가 실서버에 배포 및 운영을 하는 상태인데, 회사 내부 상품 정보를 많이 가져오면서도 처리속도를 빠르게 하기 위해 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가지 이외의 방법은 없을까?