save, saveAll, bulk insert

2025. 3. 17. 11:35spring/jpa

save와 saveAll의 차이

  • save의 내부 로직

ave를 살펴보면, 하나의 트랜잭션에서 관리가 되고 있으며,

주어진 entity가 새 entity의 경우, persist를 통해 DB에 저장하며

기존에 존재하는 entity의 경우, merge를 통해 변경된 점을 DB에 병합한다.

  • saveAll의 내부로직

saveAll을 살펴보면, 이 메서드 역시 하나의 트랜잭션에서 관리된다.

내부적으로는 for문을 통해 save를 반복 호출하게된다.

그렇다면 save와의 차이점은 어떤것일까?

가장 큰 차이는 트랜잭션 관리이다. saveAll에서는 트랜잭션이 메서드 레벨에 위치하여

작업 수행동안 동일한 트랜잭션 내에서 처리된다.

따라서 for문을 통해 save를 직접 반복 호출하는 것보다 더 효율적일 수 있다.

즉, saveAll을 사용할 경우 트랜잭션이 한 번만 생성된다.

트랜잭션이란, DB의 상태를 변화시키기 위해 수행하는 작업의 단위를 뜻한다. DB의 상태를 변화시킨다는 것은 어떤것일까? 간단하게 말해서, 아래의 질의어를 이용하여 DB에 접근하는 것을 의미한다.

  • select
  • insert
  • delete
  • update

착각하지 말아야 할 것은, 작업의 단위는 질의어 한문장이 아니라는 점이다. 작업 단위는 많은 질의어 명령문을 사람이 정하는 기준에 따라 정하는 것을 의미한다.

게시판을 예로 들어보자. 게시판 사용자는 게시글을 작성하고 올리기 버튼을 누른다. 그 후에 다시 게시판에 돌아왔을 때, 게시판은 자신의 글이 포함된 업데이트 된 게시판을 보게 된다.

이러한 상황을 DB작업으로 옮기면, 사용자가 올리기 버튼을 눌렀을 시, insert문을 사용하여 사용자가 입력한 게시글의 데이터를 옮긴다. 그 후에, 게시판을 구성할 데이터를 다시 select하여 최신의 정보로 유지한다.

여기서 작업의 단위는 insert문과 select문을 둘 다 합친 것이다.
이러한 작업 단위를 하나의 트랜잭션이라고 한다.

save와 saveAll의 성능 비교

@SpringBootTest
public class SaveTest {

    @Autowired
    TestEntityRepository testEntityRepository;

    @Test
    @Transactional
    @Rollback(value = false)
    @DisplayName("save 테스트")
    void saveTest() {
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            TestEntity testEntity = TestEntity.builder().name("테스트" + i)
                .build();
            testEntityRepository.save(testEntity);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("execution time : " + (endTime - startTime) + "ms"); // 4268ms
    }

    @Test
    @Transactional
    @Rollback(value = false)
    @DisplayName("save all 테스트")
    void saveAllTest() {
        long startTime = System.currentTimeMillis();
        List<TestEntity> testlist = new ArrayList<>();

        for (int i = 0; i < 10000; i++) {
            TestEntity testEntity = TestEntity.builder().name("테스트" + i)
                .build();
            testlist.add(testEntity);
        }
        testEntityRepository.saveAll(testlist);
        long endTime = System.currentTimeMillis();
        System.out.println("execution time : " + (endTime - startTime) + "ms"); // 3664ms
    }
}

 

각각 1만 건의 TestEntity를 생성하는 반복문을 실행하면서, 생성된 엔티티를 저장하는 두 가지 방법을 비교했다. 첫 번째 방법은 save() 메서드를 사용하여 개별적으로 저장하는 것이고, 두 번째 방법은 List 타입의 testList에 엔티티를 담아 saveAll() 메서드를 통해 한 번에 저장하는 것이다.

실행 결과는 다음과 같다:

  • save() 메서드: 4268ms
  • saveAll() 메서드: 3664ms

결과적으로, saveAll() 메서드가 더 나은 성능을 보이는 것으로 확인되었다.

하지만 이 결과는 트랜잭션 관리의 차이에서 나오는것이지 결국 쿼리가 하나씩 나가는것은 동일했다. 그렇다면 JPA에서는 어떻게 대용량 데이터를 한번에 insert하는지 알아보겠다.

JPA에서의 bulk insert

bulk insert란 inset쿼리를 한번에 처리하는 것을 의미한다.

INSERT INTO post (title) VALUES
('title1'),
('title2'),
('title3'),
('title4');

 

  • Bulk Insert를 사용하려면 application.yml에 정의한 JdbcUrl 옵션에 rewirteBatchedStatements=true를 필수로 설정해야 한다.

MySQL8버전 미만

jdbc:mysql://localhost:3306/{db}rewriteBatchedStatements=true


 

MySQL8 이상부터는 rewriteBatchedStatements=true 옵션의 default = true로 설정되어 별도의 설정이 없어도 Bulk Insert이 적용된다.

하지만 auto_increment가 IDENTITY인 경우, JPA에서의 bulk insert가 적용되지 않는다.

MySQL의 auto_increment 옵션은 기본키를 연속적으로 생성해주는 옵션이다. 일반적으로 JPA를 사용할 때 엔터티의 기본키 생성 전략을 GenerationType.IDENTITY로 가져간다.

@Id로 지정된 필드가 비어있는데 어떻게 영속화가 가능할까? Hibernates에서는 IDENTITY 전략을 사용할 경우, save할 때 DB에 일단 insert한 뒤 생성된 기본키를 가져온다. 그러다 보니, saveAll할 때 N번 째 데이터의 기본키를 획득하기 위해 N-1 데이터까지 insert가 되어있는 상태에서 N번째 데이터를 insert하고 기본키를 가져오게 되어 실질적으로 N번의 쿼리가 발생하게 된다.

Hibernates에서는 기본적으로 IDENTITY 기본키 생성 전략을 가져갈 때 batch insert를 허용하지 않는다는 것을 알 수 있다.

 

이러한 상황에서의 대처 방법은 ID 채번 전략 수정 또는 JPA가 아닌 JDBCTemplate과 같은 네이티브 쿼리를 작성하는 방법, QueryDSL, JOOQ등이 있다.

테이블 전략을 수정하는 방법은 테이블 설계 단계에서부터 정한 것이 아니라면, 적용이 어렵다. (이미 DB에 데이터가 자리잡은 상황에서 id채번을 변경하는 것은 부담이라는 말임.)

JdbcTemplate(+ Mybatis)와 같이 문자열 기반의 SQL 프레임워크는 IDE 자동 지원이 제한적이다. 테이블 컬럼에 추가/수정 발생 시, 연관된 쿼리 문자열을 모두 찾아서 반영하는 것이 필요하다.

JDBC Bulk Insert를 사용할 때 에러를 확인할 수 있는 시점은 컴파일이 아닌 런타임이다.

 

@Repository
@RequiredArgsConstructor
public class TestEntityJdbcRepository {

    private final JdbcTemplate jdbcTemplate;

    public void bulkInsert(List<TestEntity> entities) {

        jdbcTemplate.batchUpdate("INSERT INTO test_entity(name) VALUES (?)",
            new BatchPreparedStatementSetter() {
                @Override
                public void setValues(PreparedStatement ps, int i) throws SQLException {
                    ps.setString(1, entities.get(i).getName());
                }

                @Override
                public int getBatchSize() {
                    return entities.size();
                }

            });
    }
}

 

@Test
    @Transactional
    @Rollback(value = false)
    @DisplayName("bulk insert 테스트")
    void bulkInsertTest() {
        long startTime = System.currentTimeMillis();
        List<TestEntity> testlist = new ArrayList<>();

        for (int i = 0; i < 10000; i++) {
            TestEntity testEntity = TestEntity.builder().name("테스트" + i)
                .build();
            testlist.add(testEntity);
        }
        jdbcTestEntityRepository.bulkInsert(testlist);
        long endTime = System.currentTimeMillis();
        System.out.println("execution time : " + (endTime - startTime) + "ms"); //93ms
    }
}

 

10,000개의 데이터를 넣는 테스트 환경에서의 결과이다.

  • save() : 4268ms
  • saveAll() : 3664ms
  • jdbc batchUpdate : 93ms

save()와 batchUpdate()사이에 약 46배의 성능차이가 발생하는것이 확인되었다.