ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • OOE: Java heap 분석기 (feat 20만건 벌크 인서트)
    Project/TravelFeelDog 2023. 9. 22. 23:46

     

    Gradle Task Verification test 를 돌리는중에 다음과 같은 문제가 생겼습니다.

     

    Caused by: java.lang.OutOfMemoryError: Java heap space

     

     

     

    메모리 초과에 예상되는 시점은 Bulk Insert test 를 구현한  부분으로 가장 많은 객체가 생기는 부분으로 봤습니다.

    -> Heap Area 는 JVM data area 의 객체가 생성할시 할당되는 가상 메모리 주소입니다.

     

    에러 로그를 확인해 보기 위하여 한번 더 진행합니다.

     ./gradlew test --stacktrace

     

    BatchInsertSQL 에서의 에러를 확인했습니다.

    Caused by: java.lang.OutOfMemoryError: Java heap space
    	at com.mysql.cj.jdbc.ClientPreparedStatement$$Lambda/0x0000000800afdd08.apply(Unknown Source)
    	at com.mysql.cj.NativeQueryBindings.<init>(NativeQueryBindings.java:97)
    	at com.mysql.cj.jdbc.ClientPreparedStatement.initializeFromQueryInfo(ClientPreparedStatement.java:1251)
    	at com.mysql.cj.jdbc.ClientPreparedStatement.<init>(ClientPreparedStatement.java:223)
    	at com.mysql.cj.jdbc.ClientPreparedStatement.prepareBatchedInsertSQL(ClientPreparedStatement.java:1105)
    	at com.mysql.cj.jdbc.ClientPreparedStatement.executeBatchWithMultiValuesClause(ClientPreparedStatement.java:653)
    	at com.mysql.cj.jdbc.ClientPreparedStatement.executeBatchInternal(ClientPreparedStatement.java:409)
    	at com.mysql.cj.jdbc.StatementImpl.executeBatch(StatementImpl.java:795)

     

    약 20 만건의 데이터를 데이터 베이스에 넣는 테스트로 개별 테스트에서는 이상이 없었지만 통합 테스트시 문제가 발생하였습니다.

    코드는 다음과 같습니다.

    @Test
    public void bulkInsert() {
            var easyRandom = FeedFixtureFactory.get(
                    1L,
                    LocalDate.of(1970,12,4),
                    LocalDate.of(2024,2,6)
    
            );
    
            var stopWatch = new StopWatch();
            stopWatch.start();
    
            int _10만 = 100000;
            List<Feed> posts = IntStream.range(0, _10만*2)
                    .parallel()
                    .mapToObj(i -> easyRandom.nextObject(Feed.class))
                    .toList();
    
            posts.forEach(i->{
                i.getMember().setWriterId(2L);
                i.syncUpdateTimeToCreatedTime();
            });
    
            stopWatch.stop();
            logger.info("객체 생성 시간 : {}" ,stopWatch.getTotalTimeSeconds());
    
            var queryStopWatch = new StopWatch();
            queryStopWatch.start();
    
            feedRepository.bulkInsert(posts);
    
            queryStopWatch.stop();
            logger.info("DB 인서트 시간 : {} " , queryStopWatch.getTotalTimeSeconds());
        }
    }

     

     

     

    - 20 만건이 아닌 2만건의 테스트의 경우는 문제가 없었습니다.

     

    다음 순서로 파악을 해보겠습니다.

     

    1. JVM 의 Heap 사이즈를 보겠습니다.

    노트북은 16GB Ram 을 사용하고 있습니다.

    java -XX:+PrintFlagsFinal -version | grep -iE 'heapsize|permsize|threadstacksize'

     

       intx CompilerThreadStackSize                  = 2048                                   {pd product} {default}
       size_t ErgoHeapSizeLimit                        = 0                                         {product} {default}
       size_t HeapSizePerGCThread                      = 43620760                                  {product} {default}
       size_t InitialHeapSize                          = 268435456                                 {product} {ergonomic}
       size_t LargePageHeapSizeThreshold               = 134217728                                 {product} {default}
       size_t MaxHeapSize                              = 4294967296                                {product} {ergonomic}
       size_t MinHeapSize                              = 8388608                                   {product} {ergonomic}
        uintx NonNMethodCodeHeapSize                   = 5839564                                {pd product} {ergonomic}
        uintx NonProfiledCodeHeapSize                  = 122909338                              {pd product} {ergonomic}
        uintx ProfiledCodeHeapSize                     = 122909338                              {pd product} {ergonomic}
       size_t SoftMaxHeapSize                          = 4294967296                             {manageable} {ergonomic}
         intx ThreadStackSize                          = 2048                                   {pd product} {default}
         intx VMThreadStackSize                        = 2048                                   {pd product} {default}
    openjdk version "21" 2023-09-19
    OpenJDK Runtime Environment JBR-21+9-126.4-nomod (build 21+9-b126.4)
    OpenJDK 64-Bit Server VM JBR-21+9-126.4-nomod (build 21+9-b126.4, mixed mode)

    계산 합시다. (계산기 씁시다  " 1MB = 2^20 바이트" , ( 1바이트 ->2^10 -> 1 KB ->2^10 -> 1MB)

     

    JVM 초기 힙 크기 

     

    InitialHeapSize = 268435456(바이트)

    InitialHeapSize -> 256MB

     

    JVM  힙 최대 크기 

    MaxHeapSize = 4294967296

    MaxHeapSize  -> 4096MB = 4GB

     

     

    참고로 배포 환경의 RAM은 2GB 입니다.

      intx CompilerThreadStackSize                  = 2048                                   {pd product} {default}
       size_t ErgoHeapSizeLimit                        = 0                                         {product} {default}
       size_t HeapSizePerGCThread                      = 43620760                                  {product} {default}
       size_t InitialHeapSize                          = 31457280                                  {product} {ergonomic}
       size_t LargePageHeapSizeThreshold               = 134217728                                 {product} {default}
       size_t MaxHeapSize                              = 484442112                                 {product} {ergonomic}
       size_t MinHeapSize                              = 8388608                                   {product} {ergonomic}
        uintx NonNMethodCodeHeapSize                   = 5826252                                {pd product} {ergonomic}
        uintx NonProfiledCodeHeapSize                  = 122915994                              {pd product} {ergonomic}
        uintx ProfiledCodeHeapSize                     = 122915994                              {pd product} {ergonomic}
       size_t SoftMaxHeapSize                          = 484442112                              {manageable} {ergonomic}
         intx ThreadStackSize                          = 2048                                   {pd product} {default}
         intx VMThreadStackSize                        = 2048                                   {pd product} {default}
    openjdk version "17.0.8.1" 2023-08-24
    OpenJDK Runtime Environment (build 17.0.8.1+1-Ubuntu-0ubuntu122.04)
    OpenJDK 64-Bit Server VM (build 17.0.8.1+1-Ubuntu-0ubuntu122.04, mixed mode, sharing)

     

    배포 환경보다 큰 MAX heap size 에서 에러가 나는걸 확인했으니 다음작업을 해봅니다.

     

    대략적인  데이터 사이즈를 파악하자

    인텔리 제이에서 자바 프로파일러를 사용했습니다.

    쿼리를 보내는 로직 전 객체를 생성하는 시점에서의 메모리를 먼저 확인합니다.

     

    531MB

    List<Feed> Posts 의 20 만건의 데이터가 생기는 시점에서의 힙 메모리 크기입니다.

     

    단일 테스트의 결과이니 통합 테스트로 확인해보겠습니다.

     

    List Posts 의 20 만건의 데이터가 생기는 시점에서의 힙 메모리 크기가 약 342mb 입니다.

     

     

    테스트 설정을 gradle 로 설정후 해보겠습니다.

    객체 생성 시점에서 큰차이가 없습니다. 

     

    종료된 모습 512MB 의 근접한 모습입니다.

     

     

    다음 사진은 인텔리제이 테스 환경에서의 종료후 힙메모리의 크기입니다.

     

    [정리를 해보겠습니다.]

     

    1.  Intellij 을 이용한 테스트시 힙메모리 최대 크기를 4GB 로 잡는다 

    로컬 컴퓨터의  JVM  힙 최대 크기 만큼 잡습니다.

     

    2.  Gradle 을 이용한 테스트시 힙메모리 최대 크기를 512MB 로 잡는다 

     

    3.  전체를 테스트할시 약  최소 1600MB 의 메모리 사이즈가 필요하다.

     

    WAS 의 메모리가 2GB 이니 배포시 테스트 진행시 OOE 가 날 수 있다.

     

     

     

    1. 메모리 스왑을 통하여 배포되어 있는 어플리케이션이 메모리를 4GB늘려줍니다. 

    https://chosunghyun18.tistory.com/180

     

    AWS 스왑 파일을 이용한 메모리 늘리기

    프로젝트 빌드와 배포중 빌드시간이 코드가 늘어감에 따라 증가하여 메모리를 늘리기로 하였다. 기존) $ free total used free shared buff/cache available Mem: 1892324 1097460 530728 1448 264136 638868 Swap: 0 0 0 $ df -h F

    chosunghyun18.tistory.com

     

     

    2. build.gradle 에서  다음과 같이 테스트시 수행할 메모리의 크기를 늘려주겠습니다.

    tasks.named('test') {
        useJUnitPlatform()
        maxHeapSize = "1650m"
    }

     

     

     

     

     

    실제 배포 환경에서 실행을 해보면 다음과 같은 테스트를 통과 하는것을 확인할 수 있습니다.

     

    penJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
    ...
    BUILD SUCCESSFUL in 2m
    7 actionable tasks: 1 executed, 6 up-to-date
    // Gradle 이 이전 빌드의 대한 테스트 수행에서 변경사항이 없으면 테스트를 수행하지 않습니다

     

     

    // 모든 테스트 통과 후 다시 빌드할 경우
    BUILD SUCCESSFUL in 8s
    7 actionable tasks: 7 up-to-date
    TravelFeelDog kill
    
    
    // 테스트를 강제로 다시 시작할 경우 
    ./gradlew cleanTest test

     

     

    <div class="counter">1m14.33s</div>

     

    그러나 , 빌드를 하기전에 test 를 통하여 안정성을 검사하는 단계에서 1분이 넘는 시간동안 테스트를 하는 상황입니다.

    매번 코드 수정후 빌드시마다 이러한 불필요한 오래걸리는 작업이 있으면 개발의 대한 효율은 물론 불필요한 네트워킹을 통하여 자원을 낭비하게 됩니다.

     

     

    테스트 코드를 수정하지 않고 일반적인 빌드시에 건너뛰도록 설정을 추가해줍니다.

     

    import java.lang.annotation.ElementType;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.transaction.annotation.Transactional;
    
    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @SpringBootTest
    @Transactional
    public @interface IntegrationTest {
    
    }

     

    자바에서 커스텀 어노테이션 은 상속이라는 개념이 없기 때문에 기존의 작성했던 어노테이션을 활요하지 못하고 명시적으로 작성을 해줘야합니다. @Trancactional 과 같은 어노테이션은 메타-어노테이션이라 불리며 새로 만드는 어노테이션에 기능을 제공할 수 있습니다.

     

    추가 : 

    package travelfeeldog.domain.feed;
    
    import java.lang.annotation.ElementType;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;
    import org.junit.jupiter.api.Tag;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.transaction.annotation.Transactional;
    
    @Tag("BulkDataTest") // Skip 할 테스트명
    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @SpringBootTest
    @Transactional
    public @interface BulkDataTest {
    }

     

     

    tasks.named('test') {
        useJUnitPlatform {
            excludeTags 'BulkDataTest'
        }
        maxHeapSize = "1650m"
    
        testLogging {
            events 'passed', 'skipped', 'failed'
            showStandardStreams = true
            exceptionFormat = 'full'
            outputs.upToDateWhen { false }
        }
    }

     

     

    무려 1분을 줄인 빌드 시간

     

Designed by Tistory.