AI가 추천한 장소 데이터를 한 번에 여러 개 저장해야 했습니다. 처음에는 saveAll()과 Hibernate batch 설정이면 충분할 것처럼 보였습니다.

하지만 실제 로그는 달랐습니다. INSERT는 배치로 묶이지 않았고, 저장 시간도 기대만큼 줄지 않았습니다. 문제는 Hibernate 설정이 아니라, IDENTITY 전략과 영속성 컨텍스트가 동작하는 방식에 있었습니다.

yaml
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 20
        order_inserts: true
        order_updates: true

📸 JPA saveAll() 성능 테스트 로그

문제는 Hibernate 설정이 아니라 ID 전략이었습니다

문제가 된 엔티티는 Course였고, PK 전략은 IDENTITY였습니다.

java
@Entity
public class Course extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    private String address;
    private Double latitude;
    private Double longitude;
}

MySQL에서 IDENTITYAUTO_INCREMENT 기반입니다. 즉, INSERT가 실제로 실행된 뒤에야 ID를 알 수 있습니다.

Hibernate는 엔티티를 영속성 컨텍스트에 넣으려면 식별자가 필요합니다. 그런데 IDENTITY 전략에서는 INSERT 전까지 ID가 없습니다. 결국 Hibernate는 쓰기 지연 저장소에 모아두지 못하고, persist() 시점에 바로 INSERT를 실행해 ID를 받아와야 합니다.

즉, 이 문제는 “batch 설정이 적용되지 않는다”가 아니라, IDENTITY 전략에서는 batch를 적용할 전제가 성립하지 않는다는 데 있었습니다.

그래서 먼저 로그로 확인했습니다

가설을 세운 뒤에는 실제로 어떤 SQL이 실행되는지 확인해야 했습니다. 10개 데이터만 저장하면서 로그를 봤습니다.

java
@Test
void testSmallBatchForLogAnalysis() {
    List courses = generateTestCourses(10);
    courseRepository.saveAll(courses);
}

결과는 예상과 달랐습니다.

  • batch_size: 20으로 설정했는데도 INSERT가 묶이지 않았습니다.
  • saveAll() 호출 시점부터 INSERT가 바로 실행됐습니다.
  • 트랜잭션 커밋 직전까지 모였다가 한 번에 나가는 흐름이 아니었습니다.

📸 10개 데이터 INSERT 로그

이 시점부터 문제는 명확해졌습니다. JPA가 느리다는 뜻이 아니라, 지금 선택한 ID 전략에서는 Hibernate batch가 동작할 수 없는 구조라는 뜻이었습니다.

수치로 보면 더 분명했습니다

그다음에는 실제로 저장 시간이 얼마나 차이 나는지 확인했습니다. 100개, 500개 데이터를 각각 저장하면서 시간을 측정했습니다.

java
@Test
void testLargeBatchPerformance() {
    List courses = generateTestCourses(500);

    long startTime = System.currentTimeMillis();
    courseRepository.saveAll(courses);
    long endTime = System.currentTimeMillis();

    System.out.println("총 소요 시간: " + (endTime - startTime) + "ms");
}

당시 측정 결과는 다음과 같았습니다.

  • 100개 저장: 약 423ms
  • 500개 저장: 약 2702ms

📸 500개 저장 테스트 결과

중요한 건 절대값보다 패턴입니다. 데이터가 늘어날수록 INSERT가 여전히 한 건씩 나가고 있었고, 따라서 애플리케이션이 기대한 배치 처리 이점을 얻지 못하고 있었습니다.

그래서 JPA를 우회했습니다

이 문제를 해결하려면 Hibernate가 해결해 주길 기대할 수 없었습니다. IDENTITY 전략을 유지해야 하는 MySQL 환경에서는, 대량 INSERT 구간만 영속성 컨텍스트 밖으로 빼는 편이 더 현실적이었습니다.

그래서 선택한 방식이 JdbcTemplate.batchUpdate()였습니다.

java
@Repository
@RequiredArgsConstructor
public class CourseBatchRepository {

    private final JdbcTemplate jdbcTemplate;

    public void batchInsert(List courses) {
        String sql = """
            INSERT INTO course (
                name, address, description, latitude, longitude,
                is_hidden, category, popularity_score, data_source,
                created_at, updated_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """;

        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                Course course = courses.get(i);
                Timestamp now = new Timestamp(System.currentTimeMillis());

                ps.setString(1, course.getName());
                ps.setString(2, course.getAddress());
                ps.setString(3, course.getDescription());
                ps.setDouble(4, course.getLatitude() != null ? course.getLatitude() : 0.0);
                ps.setDouble(5, course.getLongitude() != null ? course.getLongitude() : 0.0);
                ps.setBoolean(6, course.getIsHidden() != null ? course.getIsHidden() : false);
                ps.setString(7, course.getCategory());
                ps.setInt(8, course.getPopularityScore() != null ? course.getPopularityScore() : 50);
                ps.setString(9, course.getDataSource() != null ? course.getDataSource() : "manual");
                ps.setTimestamp(10, now);
                ps.setTimestamp(11, now);
            }

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

이 방식의 의미는 단순합니다.

  • JPA의 영속성 컨텍스트를 거치지 않습니다.
  • INSERT를 JDBC 레벨에서 실제 배치로 보냅니다.
  • 대신 엔티티 관리, 변경 감지, 연관관계 처리 같은 JPA의 편의 기능은 포기합니다.

즉, 모든 쓰기를 JDBC로 바꾸는 것이 아니라, “성능이 중요한 대량 INSERT 구간만 분리한다”는 선택에 가깝습니다.

결과는 명확했습니다

같은 환경에서 saveAll()JdbcTemplate.batchUpdate()를 비교했습니다.

java
@Test
void testPerformanceComparison() {
    List courses100 = generateTestCourses(100);

    long jpaStart = System.currentTimeMillis();
    courseRepository.saveAll(courses100);
    long jpaEnd = System.currentTimeMillis();

    long jdbcStart = System.currentTimeMillis();
    courseBatchRepository.batchInsert(courses100);
    long jdbcEnd = System.currentTimeMillis();

    System.out.println("JPA saveAll(): " + (jpaEnd - jpaStart) + "ms");
    System.out.println("JdbcTemplate batchUpdate(): " + (jdbcEnd - jdbcStart) + "ms");
}

500개 저장 기준으로는 다음 차이가 났습니다.

  • JPA saveAll(): 2702ms
  • JdbcTemplate batchUpdate(): 120ms
  • 성능 향상: 약 95% 이상

📸 JdbcTemplate 성능 비교 결과

데이터 개수JPA saveAll()JdbcTemplate batchUpdate()비고
500개2.7초 수준0.1초 수준IDENTITY 전략 우회 시 큰 차이

📸 최종 비교 결과

그래서 실제 서비스에서는 둘을 같이 썼습니다

JPA를 완전히 버린 것은 아닙니다. 읽기와 비즈니스 로직 조합에는 여전히 JPA가 편리했습니다. 대신 대량 INSERT가 필요한 구간만 JDBC로 분리했습니다.

java
@Service
@RequiredArgsConstructor
public class CourseService {

    private final CourseRepository courseRepository;
    private final CourseBatchRepository courseBatchRepository;

    @Transactional
    public List saveAllCourses(List courses) {
        List coursesToSave = new ArrayList<>();
        List finalCourses = new ArrayList<>();

        for (Course course : courses) {
            List existing = courseRepository
                .findByNameContainingAndIsHiddenFalse(course.getName());

            boolean isDuplicate = existing.stream()
                .anyMatch(e -> e.getAddress() != null && e.getAddress().equals(course.getAddress()));

            if (isDuplicate) {
                finalCourses.add(existing.get(0));
            } else {
                coursesToSave.add(course);
            }
        }

        if (!coursesToSave.isEmpty()) {
            courseBatchRepository.batchInsert(coursesToSave);
            finalCourses.addAll(coursesToSave);
        }

        return finalCourses;
    }
}

이 구조에서는 역할이 분명합니다.

  • 읽기와 중복 검사에는 JPA를 사용했습니다.
  • 대량 INSERT에는 JdbcTemplate을 사용했습니다.

즉, JPA와 JDBC를 대체 관계로 보지 않고, 각자가 잘하는 구간으로 나눠 쓴 것입니다.

정리

이 문제의 핵심은 JPA가 느리다는 데 있지 않았습니다. IDENTITY 전략을 쓰는 환경에서는 Hibernate batch가 구조적으로 동작하기 어렵다는 데 있었습니다.

그래서 결론도 분명합니다.

  • MySQL + IDENTITY 전략에서는 saveAll()만으로 batch insert를 기대하면 안 됩니다.
  • hibernate.jdbc.batch_size 설정만으로는 해결되지 않습니다.
  • 대량 INSERT가 중요한 구간은 JDBC batch로 분리하는 편이 현실적입니다.
  • JPA와 JDBC는 둘 중 하나만 고르는 문제가 아니라, 어떤 구간에서 무엇을 쓸지 나누는 문제에 가깝습니다.

결국 중요한 건 프레임워크 설정이 아니라, 실제 로그와 실행 시간을 보고 “지금 이 경로에서 무엇이 병목인지”를 먼저 확인하는 일입니다.