프로젝트

데이터 100만 건 insert 하기

yoney 2025. 3. 18. 15:48

[ 목차 ]


    요구사항

    대용량 데이터 처리 실습을 위해, 테스트 코드로 유저 데이터를 100만 건 생성해주세요.
    데이터 생성 시 닉네임은 랜덤으로 지정해주세요.
    가급적 동일한 닉네임이 들어가지 않도록 방법을 생각해보세요.

    닉네임을 조건으로 유저 목록을 검색하는 API를 만들어주세요.
    닉네임은 정확히 일치해야 검색이 가능해요.

    여러가지 아이디어로 유저 검색 속도를 줄여주세요.
    조회 속도를 개선할 수 있는 여러 방법을 고민하고, 각각의 방법들을 실행해보세요.

    데이터 100만 건을 어떻게 넣지?

     

    유저 데이터를 100만건 생성해야 하는 것부터 난관이었다.

    하나하나 생성해서 repository에 save하면 시간이 오래 걸릴 것이고..

    애초에 테스트 코든데 db에 실제로 값을 넣어도 되는 것인가.. 내가 요구사항 파악을 잘못하는 것인가,,

    테스트 코드로 작성하라는 게 이 테스트 코드가 아닌가...

    100만 개를 다 넣으면 시간이 오래 걸릴 것 같은데 내 똥컴이 버텨줄 수 있을가...

    이 상태로 고민과 검색만 하다 세시간이 홀라당 날아갓다 ㅎ


    1차 시도

     

    리스트를 통해서 1000개씩 리스트에 담고 saveAll하는 방식으로 작성해봤다.

    sql 디버깅을 활성화해놨더니 무수히 돌아가는 insert 문.... 너무 오래 걸려서 중간에 멈췄는데도 2분이 훌쩍 넘어갔다

    saveAll을 해도 insert 될 때는 하나씩 들어가서 100만개의 insert가 각각 실행되고 있었다.


    2차 시도

     

    properties에 batch 설정 및 바로 db에 반영되도록 flush()도 했다.

    결과는 그래도 insert가 한개씩 들어갔다.

     

    찾아보니 jpa에서 id를 identity로 설정해놓으면 batch insert가 안된다고 한다..

     

    👇🏻JPA에서 @GeneratedValue(strategy = GenerationType.IDENTITY)를 사용하면 Batch Insert가 동작하지 않는 이유

    더보기

     

    • IDENTITY 전략을 사용하면, 데이터베이스가 자동 증가(AUTO_INCREMENT) 를 통해 ID를 생성한다.
    • 즉, INSERT 문을 실행할 때 각 행이 데이터베이스에 즉시 반영되어야 ID 값을 얻을 수 있다.
    • 배치 삽입(Batch Insert) 는 여러 개의 INSERT 문을 하나의 배치로 모아 한꺼번에 실행하는 방식이다.
    • 하지만, IDENTITY 전략은 개별 INSERT 실행 후 즉시 생성된 키 값을 조회해야 하므로, 배치로 묶을 수 없다.
    • 따라서 JPA는 IDENTITY 전략을 사용할 때 배치 처리를 비활성화하고, 개별 INSERT 문을 실행하게 된다.

     


    3차 시도(최종 시도)

    package org.example.expert.domain.user.repository;
    
    import lombok.RequiredArgsConstructor;
    import org.example.expert.domain.user.entity.User;
    import org.springframework.jdbc.core.BatchPreparedStatementSetter;
    import org.springframework.jdbc.core.JdbcTemplate;
    import org.springframework.stereotype.Repository;
    
    import java.sql.PreparedStatement;
    import java.sql.SQLException;
    import java.util.List;
    
    @Repository
    @RequiredArgsConstructor
    public class UserJdbcRepository {
    
        private final JdbcTemplate jdbcTemplate;
    
        public void saveAll(List<User> userList) {
            jdbcTemplate.batchUpdate("insert into users (email, password, nickname, user_role, created_at, modified_at) " +
                            "VALUES (?, ?, ?, ?, ?, ?)",
                    new BatchPreparedStatementSetter() {
                        @Override
                        public void setValues(PreparedStatement ps, int i) throws SQLException {
                            User user = userList.get(i);
                            ps.setString(1, user.getEmail());
                            ps.setString(2, user.getPassword());
                            ps.setString(3, user.getNickname());
                            ps.setString(4, user.getUserRole().name());
                            ps.setTimestamp(5, java.sql.Timestamp.valueOf(java.time.LocalDateTime.now()));
                            ps.setTimestamp(6, java.sql.Timestamp.valueOf(java.time.LocalDateTime.now()));
                        }
    
                        @Override
                        public int getBatchSize() {
                            return userList.size();
                        }
                    });
        }
    }
    package org.example.expert.domain.user;
    
    import org.example.expert.domain.user.entity.User;
    import org.example.expert.domain.user.enums.UserRole;
    import org.example.expert.domain.user.repository.UserJdbcRepository;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    
    import java.util.ArrayList;
    import java.util.List;
    import java.util.UUID;
    
    @SpringBootTest
    public class UserSaveTest {
    
        @Autowired
        private UserJdbcRepository userJdbcRepository;
    
        @Test
        void 유저_데이터_백만건_생성_JDBC() {
            int batchSize = 1000;
            List<User> userList = new ArrayList<>();
    
            for (int i = 1; i <= 1_000_000; i++) {
                String randomNickname = UUID.randomUUID().toString().substring(0, 10) + i;
                userList.add(new User("test" + i + "@em.com", "pw", randomNickname, UserRole.ROLE_USER));
                if (i % batchSize == 0) {
                    userJdbcRepository.saveAll(userList);
                    userList.clear();
                }
            }
        }
    }

     

    jpa 방식으로만 생각하다가 이번에는 jdbc batchUpdate를 사용했다.

    (백만 건이 아닌 10000건으로 유저 데이터 생성 테스트를 먼저 해본 결과)

    로그를 살펴보면 10000건의 유저 데이터 생성을 위해 1000개씩 10번 insert가 정상적으로 된 것을 확인할 수 있었다.

    시간도 1초밖에 안 걸렸다.

     

    10만개 데이터 생성에도 3초가 걸렸다. 와! 이제 시간은 얼추 해결이 된 듯 하다.

     

    근데 백만 개의 데이터를 넣을 때는 또 다른 문제가 생겼다..

     


    OutOfMemoryError


    org.gradle.api.internal.tasks.testing.TestSuiteExecutionException: Could not complete execution for Gradle Test Executor 15. at org.gradle.api.internal.tasks.testing.SuiteTestClassProcessor.stop(SuiteTestClassProcessor.java:64) at java.base@17.0.7/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at java.base@17.0.7/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) at java.base@17.0.7/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.base@17.0.7/java.lang.reflect.Method.invoke(Method.java:568) at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:36) at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:24) at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:94) at jdk.proxy1/jdk.proxy1.$Proxy2.stop(Unknown Source) at org.gradle.api.internal.tasks.testing.worker.TestWorker$3.run(TestWorker.java:193) at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:129) at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:100) at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:60) at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:113) at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:65) at app//worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at app//worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) Caused by: java.lang.OutOfMemoryError: Java heap space

     

    시간 문제 해결했더니 이제 힙 메모리 공간이 부족하다고?

     

    org.gradle.jvmargs=-Xmx2048m

    최대 힙 메모리 크기를 2048MB로 설정해봤지만 결과는 그대로였다.

     

    test {
        jvmArgs '-Xmx2048m'
    }

    gradle 파일에 따로 설정해봤다.

     

    꺅 성공

    10초가 걸렸다. 시간을 더 줄이고 싶은데 더 좋은 방법은 이후에 시간이 되면 찾아봐야겠다.


    BatchSize 조절

    배치 사이즈를 1000->10000으로 늘려봤다.

    아주 찔끔 속도가 줄어들었다.

     

     

    1000->100으로 줄여봤다.

    배치 사이즈가 1000일때보다 4초 가량 더 걸렸다.

     

    제일 빠른 batchSize를 찾아봤지만 10000일 때가 그나마 빠른 것 같아서 10000으로 바꿨다.


    또 다른 이슈

    이제 해결 된 줄 알았는데,

    테스트 코드는 실행이 되지만 db에는 저장이 안되고 있었어서 또 엄청 삽질했다.

    원래 h2를 썼는데 h2가 문젠가 싶어 mysql로 바꾸고 url 뒤에 rewriteBatchStatements=true 조건을 붙여줬더니 저장이 잘 되었다.


    결론

    대용량 데이터를 생성하기 위해 jpa에서 id를 identity로 설정한 경우 batch insert가 안되기 때문에 jdbc를 사용해서 batch insert를 해준다.

    batch size를 최적화하면 데이터 생성 속도를 높일 수 있다.


    참고한 블로그

    https://passionfruit200.tistory.com/750

    https://kobumddaring.tistory.com/58