본문 바로가기
🚀 데브 노트 (IT 지식 & 팁)

saveAll() 썼다가 서버가 뻗었다고요? JPA 벌크 인서트(Bulk Insert)의 배신과 JdbcTemplate 100배 최적화

by 데브프리 2026. 8. 10.

안녕하세요, 여러분의 든든한 IT 길잡이 데브프리입니다.

 

백엔드 실무를 하다 보면 외부 API에서 대량의 데이터를 긁어오거나, 운영팀이 넘겨준 수만 건의 엑셀(Excel) 데이터를 DB에 한 번에 밀어 넣어야 하는 상황(데이터 마이그레이션, 로그 적재 등)이 반드시 찾아옵니다.

 

스프링 부트(Spring Boot) 개발자라면 당연히 리스트에 데이터를 차곡차곡 담은 뒤, userRepository.saveAll(users)를 호출하며 쿨하게 퇴근을 준비할 것입니다. 하지만 실서버에서 이 코드가 실행되는 순간, 서버 CPU가 미친 듯이 치솟고 30초가 지나도 응답이 오지 않는 기현상을 마주하게 됩니다. 데이터가 10만 건을 넘어간다면 무시무시한 OutOfMemoryError와 함께 서버가 그대로 기절해 버리죠.

 

"분명히 한 번에 저장하라고 saveAll()을 썼는데 왜 이렇게 느린 걸까요?"

 

오늘은 실무에서 대용량 데이터를 다룰 때 JPA가 우리를 배신하는 이유(GenerationType.IDENTITY의 함정)를 객관적으로 분석하고, JdbcTemplate과 MySQL 옵션을 활용해 벌크 인서트(Bulk Insert) 성능을 100배 이상 끌어올리는 완벽한 트러블슈팅 가이드를 짚어드리겠습니다.

 

1. 실전 트러블슈팅: saveAll()은 벌크 인서트가 아니다?

대부분의 주니어 개발자들은 saveAll()을 호출하면 DB에 아래와 같이 쿼리가 날아갈 것이라고 기대합니다.

SQL

-- 우리가 기대한 이상적인 벌크 인서트 (단 1번의 네트워크 통신)
INSERT INTO users (name, age) VALUES ('Alice', 20), ('Bob', 25), ('Charlie', 30) ... (1만 개);

하지만 application.yml에서 쿼리 로그 옵션(spring.jpa.properties.hibernate.show_sql=true)을 켜고 콘솔 창을 유심히 살펴보면, 충격적인 진실을 마주하게 됩니다.

Plaintext

Hibernate: insert into users (name, age) values (?, ?)
Hibernate: insert into users (name, age) values (?, ?)
Hibernate: insert into users (name, age) values (?, ?)
-- (이 짓을 정확히 1만 번 반복합니다...)

네, 그렇습니다. Spring Data JPA의 saveAll()은 내부적으로 for 문을 돌면서 save()를 1만 번 호출할 뿐입니다. 1만 번의 네트워크 통신(I/O)이 발생하니 서버가 뻗어버리는 것은 당연한 결과입니다.

도대체 왜 한 번에 묶어서 보내지 않을까요?

문제의 원인은 바로 엔티티(Entity) 클래스에 적어둔 @GeneratedValue(strategy = GenerationType.IDENTITY) 때문입니다. MySQL을 사용할 때 백엔드 개발자의 99%가 이 전략을 사용해 DB의 AUTO_INCREMENT에 PK(기본키) 생성을 위임합니다.

 

그런데 JPA(Hibernate)는 영속성 컨텍스트(1차 캐시)에서 객체를 관리하기 위해 반드시 해당 객체의 PK 값을 알고 있어야 합니다. 하지만 IDENTITY 전략은 DB에 INSERT 쿼리를 날려봐야만 그 PK 값을 알 수 있죠.

결국 Hibernate는 PK를 얻기 위해 데이터를 하나씩 DB에 던져서 PK를 받아오는 과정을 반복할 수밖에 없습니다. Hibernate 공식 문서에도 "IDENTITY 전략을 사용하면 JDBC Batch Insert를 비활성화한다"라고 명확히 명시되어 있습니다.

단건 인서트 1만 번 호출과 다중 벌크 인서트의 네트워크 통신 효율성 비교 비유
1만 개의 데이터를 1만 번 쪼개서 네트워크로 보내는 것과, 거대한 컨테이너 하나에 담아 한 번에 보내는 것은 시스템 부하와 속도 면에서 비교할 수 없는 차이를 만듭니다.

 

2. 해결 베스트 프랙티스: JdbcTemplate과 rewriteBatchedStatements

그렇다고 벌크 인서트 하나 하자고 잘 쓰던 IDENTITY 전략을 포기하고 UUID나 SEQUENCE로 바꿀 수는 없습니다. 이 딜레마를 해결하는 가장 깔끔하고 객관적인 실무 프랙티스는, 대용량 INSERT를 할 때만 과감하게 JPA를 내려놓고 JdbcTemplate을 사용하는 것입니다.

 

이 작업을 위해 두 가지 필수 세팅이 필요합니다.

① MySQL JDBC URL 옵션 추가

아무리 JdbcTemplate으로 코드를 잘 짜도, DB 드라이버 단에서 허락해주지 않으면 말짱 도루묵입니다. application.yml의 데이터소스 URL 끝에 반드시 rewriteBatchedStatements=true 옵션을 추가해야 합니다. 이 옵션이 있어야 JDBC 드라이버가 여러 개의 INSERT 쿼리를 가로채서 하나의 거대한 벌크 쿼리(VALUES (), (), ()...)로 재조립(Rewrite)해 줍니다.

YAML

spring:
  datasource:
    # 💡 끝에 반드시 rewriteBatchedStatements=true 를 추가하세요!
    url: jdbc:mysql://localhost:3306/mydb?rewriteBatchedStatements=true&characterEncoding=UTF-8

② JdbcTemplate.batchUpdate() 구현

이제 JPA Repository 대신, 별도의 Repository 클래스를 만들어 JdbcTemplate의 batchUpdate 메서드를 활용합니다.

Java

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import java.sql.PreparedStatement;
import java.util.List;

@Repository
public class UserBulkRepository {

    private final JdbcTemplate jdbcTemplate;
    private final int BATCH_SIZE = 1000; // 💡 1,000건씩 쪼개서 전송

    public UserBulkRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public void saveAllBulk(List<UserDto> users) {
        String sql = "INSERT INTO users (name, age) VALUES (?, ?)";

        // 1만 건의 데이터를 1,000건 단위로 나누어 메모리 부하를 방지합니다.
        for (int i = 0; i < users.size(); i += BATCH_SIZE) {
            List<UserDto> batchList = users.subList(i, Math.min(i + BATCH_SIZE, users.size()));
            
            jdbcTemplate.batchUpdate(sql, batchList, batchList.size(),
                (PreparedStatement ps, UserDto user) -> {
                    ps.setString(1, user.getName());
                    ps.setInt(2, user.getAge());
                }
            );
        }
    }
}

이렇게 코드를 수정하고 1만 건의 데이터를 밀어 넣어 보세요.

기존에 saveAll()로 30초 넘게 걸리던 작업이, 단 0.5초 만에 완료되는 압도적인 성능 향상을 눈으로 확인할 수 있습니다. 데이터베이스의 CPU 사용량과 애플리케이션의 메모리 튀는 현상도 완벽하게 잡힙니다.

 

💡 [데브프리의 심층 면접 꿀팁: JdbcTemplate 없이 JPA만으로 벌크 인서트를 하는 방법은 없을까?]

면접관이 "어떻게든 JPA의 saveAll()을 유지하면서 벌크 인서트를 할 수 있는 방법은 없을까요?"라고 묻는다면 100점짜리 압박 질문입니다!

문제의 핵심은 DB가 PK를 생성하는 IDENTITY 전략에 있었습니다. 그렇다면 애플리케이션(스프링 부트) 단에서 미리 PK를 생성해서 엔티티에 세팅한 뒤에 넘겨주면 어떨까요?

최근 실무에서는 MySQL의 AUTO_INCREMENT 대신, 정렬이 가능하면서도 충돌이 없는 TSID (Time-Sorted Unique Identifier)나 UUID를 PK로 많이 사용합니다. 이처럼 엔티티를 생성할 때 애플리케이션에서 미리 ID 값을 할당해 주면, Hibernate는 굳이 DB에 물어볼 필요 없이 곧바로 JDBC Batch Insert를 활성화해 줍니다!

(단, 이 경우에도 application.yml에 spring.jpa.properties.hibernate.jdbc.batch_size=1000 설정은 반드시 켜주어야 한다는 점을 잊지 마세요!)

 

3. 전문가의 확고한 결론 (Verdict)

JPA saveAll() vs JdbcTemplate 벌크 인서트, 실무에 언제 도입하고 언제 피해야 할까요?

  • 👍 JdbcTemplate 벌크 인서트 도입 시점: 수천~수만 건의 로그 데이터 적재, 대규모 엑셀 업로드 처리, 타 시스템으로부터의 일일 데이터 마이그레이션 등 '대량의 데이터를 한 번에 밀어 넣는 작업'에서는 선택이 아닌 필수입니다. 영속성 컨텍스트를 거치지 않으므로 메모리 OOM(Out of Memory)을 방지하고 DB 네트워크 I/O 병목을 드라마틱하게 해결할 수 있습니다.
  • 🛑 JdbcTemplate 도입을 피하고 JPA saveAll()을 써야 할 때: 사용자가 장바구니에 담은 3~5개의 상품을 결제하는 등 저장할 데이터의 개수가 10~50개 미만의 소량일 때, 그리고 저장 직후 발생한 PK(ID) 값을 가지고 추가적인 연관 관계 매핑(자식 엔티티 저장 등)을 맺어야 하거나 엔티티 생명주기 이벤트(@EntityListeners, @CreatedDate 등)가 반드시 동작해야 할 때는 기존처럼 JPA의 saveAll()을 사용하는 것이 맞습니다. JdbcTemplate은 속도를 얻는 대신 JPA의 모든 객체지향적 편의 기능을 포기하는 우회로이기 때문입니다.

JPA는 마법의 도구가 아닙니다. 내부적으로 쿼리가 어떻게 만들어지고 데이터베이스로 어떻게 날아가는지 원리를 이해해야만, 프레임워크가 만든 병목을 피해 유연한 아키텍처를 설계할 수 있습니다. 오늘 다룬 벌크 인서트 최적화 기법을 여러분의 실무 프로젝트에 무기로 장착해 보시길 바랍니다.

 

항상 팩트에 기반한 객관적인 실무 가이드로 여러분의 삽질 시간을 줄여드리겠습니다. 다음 포스팅에서도 백엔드 개발자를 위한 깊이 있는 성능 최적화 분석으로 찾아뵙겠습니다!

 

👉 실서버 뻗기 전에 꼭 확인하세요! 내 서버 스펙에 맞는 최적의 DB 커넥션 풀 개수 계산기 [링크]