SpringBoot

다양한 상황에서의 DB에 저장하는 시간을 알아보자

Terror123 2025. 2. 26. 22:04

개요

  • 각각의 상황에서의 저장시간을 비교해보며, 효율적인 방법을 찾아봅시다.

상황

  • 1000개의 데이터를 데이터베이스에 넣는 상황으로 간주

Helper

  • application.yml
server:
  port: 8081

spring:
  jpa:
    hibernate:
      ddl-auto: update
    // 여기밑에 설정해줘야, Flush, Insert 갯수 카운트 가능
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.MySQL8Dialect
        generate_statistics: true
    show-sql: true
  • 실제 DB와 상호작용하는 Flush를 네트워크 I/O 비용으로 간주한다.

테스트

  • 하기와같이 진행된다
    1. 트랜잭션 (X), saveAll(X), 배치 (X), 벌크 (X)
    2. 트랜잭션 (O), saveAll(X), 배치 (X), 벌크 (X)
    3. 트랜잭션 (O), saveAll(O), 배치 (X), 벌크 (X)
    4. 트랜잭션 (O), saveAll(O), 배치 (O), 벌크 (X)
    5. 트랜잭션 (O), saveAll(O), 배치 (X), 벌크 (O)

1번 상황 [ 트랜잭션 (X), saveAll(X), 배치 (X), 벌크 (X) ]

    @Test
    @DisplayName(value = "트랜잭션 (X), saveAll(X), 배치 (X), 벌크 (X)")
    public void test1() {
        Long start = System.currentTimeMillis();
        for (int i = 0; i < 1000; i++) {
            Users user = new Users(
                    "username" + i,
                    "password" + i,
                    "email" + i + "@gmail.com"
            );
            userRepository.save(user);
        }
        Long end = System.currentTimeMillis();
        log.info("          ---         ");
        log.info("test1 실행시간: {}", (end - start));
        log.info("          ---         ");
        jpaQueryCount();
    }
  • 2.2초가 소요된 모습이다.

  • 트랜잭션환경으로 묶여있지않기 떄문에, 영속성 컨텍스트에 데이터가 쌓이지않고, 개별적으로 처리되어 1000개의 FLUSH가 호출 되는 것을 알 수 있다

  • Flush가 총 1000번 일어난 모습이다.

2번 상황 [ 트랜잭션 (O), saveAll(X), 배치 (X), 벌크 (X) ]

    @Test
    @DisplayName(value = "트랜잭션 (O), saveAll(X), 배치 (X), 벌크 (X)")
    @Transactional
    @Commit
    public void test2() {
        Long start = System.currentTimeMillis();
        for (int i = 0; i < 1000; i++) {
            Users user = new Users(
                    "username" + i,
                    "password" + i,
                    "email" + i + "@gmail.com"
            );
            userRepository.save(user);
        }
        Long end = System.currentTimeMillis();
        log.info("          ---         ");
        log.info("test1 실행시간: {}ms", (end - start));
        log.info("          ---         ");
        jpaQueryCount();
    }
  • 0.6초가 소요된 모습이다.

  • 1000번의 쿼리가 하나의 트랜잭션으로 묶여있기떄문에, 커밋되는 시점에 한번에 DB에 저장되면서 네트워크 I/O비용이 1번만 발생한다.

3번 상황 [ 트랜잭션 (O), saveAll(O), 배치 (X), 벌크 (X) ]

    @Test
    @DisplayName(value = "트랜잭션 (O), saveAll(O), 배치 (X), 벌크 (X)")
    @Transactional
    @Commit
    public void test3() {
        Long start = System.currentTimeMillis();
        List<Users> users = new ArrayList<>();
        for (int i = 0; i < 1000; i++) {
            Users user = new Users(
                    "username" + i,
                    "password" + i,
                    "email" + i + "@gmail.com"
            );
            users.add(user);
        }
        userRepository.saveAll(users);
        Long end = System.currentTimeMillis();
        log.info("          ---         ");
        log.info("test1 실행시간: {}ms", (end - start));
        log.info("          ---         ");
    }
  • 0.6초 소모로 2번 테스트케이스와 유사한 시간 속도를 보여준다

  • saveall역시 트랜잭션환경 안에서 호출되니 1번의 FLUSH만 호출하는 모습을 확인 할 수 있다

save vs saveAll

  • save의 경우 for문으로 하나씩 넣어준다고 가정
  • saveall의 경우 List<'Entity'>의 형태의 데이터를 넣어준다고 가정
  • save
    • 1번의 save가 호출될때마다, 상위 클래스의 트랜잭션환경이 있다면 전파받고, 없다면 새로 만들고, 저장을 한다
      • 이러한 이유로 성능의 차이가 있는거군

  • saveall
    • saveall을 호출한 클래스의 트랜잭션 환경이 있다면 전파받고, 내부에서 for문으로 자신의 클래스 내부에 있는 save메서드 호출(덕분에 새로운 프록시를 만들지않아도 되어 성능적으로 이득을 볼 수 있다)

4번 상황 [ 트랜잭션 (O), saveAll(O), 배치 (O), 벌크 (X) ]

  • 배치할때는 auto_increment전략으로 IDENTITY가 되지않아, 잠시 시퀸스 전략으로 진행하였다.
    @Test
    @DisplayName(value = "트랜잭션 (O), saveAll(O), 배치 (O), 벌크 (X)")
    @Transactional
    @Commit
    public void test4() {
        Long start = System.currentTimeMillis();
        List<Users> users = new ArrayList<>();
        for (int i = 0; i < 1000; i++) {
            Users user = new Users(
                    "username" + i,
                    "password" + i,
                    "email" + i + "@gmail.com"
            );
            users.add(user);
        }
        userRepository.saveAll(users);
        Long end = System.currentTimeMillis();
        log.info("          ---         ");
        log.info("test 실행시간: {}ms", (end - start));
        log.info("          ---         ");
    }
  • yml 파일 설정 추가
server:
  port: 8081

spring:
  datasource:
    # url에 설정 추가
    url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8&rewriteBatchedStatements=true&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999

    username: user
    password: password
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.MySQL8Dialect
        jdbc:
          # 여기 밑에 배치 설정 추가
          batch_size: 100
        generate_statistics: true
    show-sql: true

  data:
    redis:
      host: redis
      port: 6379
  • 0.1초 걸린 모습을 확인 할 수 있다

-

  • 배치 Insert가 정상적으로 적용된 모습이다

  • 1 FLUSH가 호출 되었다

  • 배치사이즈 100에서, 1000개로 바꾸었을때의 속도는 하기와 같다.

5번 상황 [ 트랜잭션 (O), saveAll(X), 배치 (X), 벌크 (O) ]

    @Test
    @DisplayName(value = "트랜잭션 (O), saveAll(X), 배치 (X), 벌크 (O)")
    @Transactional
    @Commit
    public void test5() {
        Long start = System.currentTimeMillis();

        // 벌크 Insert 쿼리 준비
        StringBuilder bulkInsertQuery = new StringBuilder("INSERT INTO users (username, password, email, id) VALUES ");

        List<Users> users = new ArrayList<>();
        for (int i = 0; i < 1000; i++) {
            users.add(new Users("username" + i, "password" + i, "email" + i + "@gmail.com"));
        }

        for (int i = 0; i < users.size(); i++) {
            Users user = users.get(i);
            bulkInsertQuery.append(String.format("('%s', '%s', '%s', DEFAULT)", user.getUsername(), user.getPassword(), user.getEmail()));
            if (i < users.size() - 1) {
                bulkInsertQuery.append(", ");
            }
        }

        // 네이티브 쿼리 실행
        em.createNativeQuery(bulkInsertQuery.toString()).executeUpdate();

        Long end = System.currentTimeMillis();
        log.info("          ---         ");
        log.info("벌크 Insert 실행시간: {}ms", (end - start));
        log.info("          ---         ");
    }
  • 0.06초가 소요된 모습이다.

  • 실행된 쿼리문

  • 네이티브 쿼리 기반이다보니, flush count는 되지 않는 모습이다.

일단 오늘은 여기까지

  • 벌크 연산에 대해서는 다음시간에 알아보자

참조 문헌

https://dev-coco.tistory.com/169
https://dkswnkk.tistory.com/682

TMI

  • 테스트코드에서 트랜잭션환경이여야 롤백된다.
  • JPA의 쿼리 로깅은 영속성 컨텍스트에 영속화될때 발생한다.