본문 바로가기

카테고리 없음

JPA로 10만 건 넣다가 JDBC로 도망친 이야기 (feat. 10.2배 개선)

반응형

들어가면서

예전에 회사에서 엑셀로 받은 데이터를 DB에 한 번에 적재하는 기능을 만든 적이 있습니다. 건수가 적을 때는 아무 문제가 없었는데, 어느 순간 10만 건이 넘는 파일이 들어오면서 적재가 미친듯이 느려지더라구요. 그때 원인을 파보니 JPA saveAll의 동작 방식과 PK 생성 전략 때문이었고, 결국 해당 기능을 JDBC batchUpdate로 변경해서 처리 시간을 크게 줄였습니다.

 

이미 회사에서 해결하고 넘어간 이슈긴 한데, 당시엔 일정에 쫓겨서 머릿속으로만 정리하고 글로 남겨두질 못했습니다. 이번 기회에 그때 상황을 작은 테스트 프로젝트로 다시 만들어 직접 측정하고 제대로 정리해두려고 합니다.

 

이 글은 "JDBC가 빠르다 끝!" 이 아니라, 왜 JPA가 그렇게 느렸는지 그리고 왜 JDBC로 바꿨는지를 같이 들여다보는 글입니다. 혹시 비슷한 상황 겪으신 분이 있다면 도움이 됐으면 좋겟습니다.

배경

상황은 이랬습니다.

  • 사용자가 엑셀 파일(.xlsx)을 업로드하면
  • 서버가 파일을 파싱해서 엔티티 리스트로 만들고
  • 그걸 DB에 한 번에 INSERT 하는 기능

처음엔 그냥 자연스럽게 Spring Data JPA의 saveAll()을 썼습니다. 평소에 CRUD 짤 때 쓰던 그 방식 그대로요. 데이터가 수백~수천 건일 때는 체감상 전혀 느리지 않았거든요. 문제는 데이터가 커지면서 시작됐습니다.

문제 상황

  1. 10만 건이 넘어가니까 적재에 82초가 걸렸습니다. 사용자가 업로드 버튼 누르고 1분 넘게 기다려야 하는 상황이었죠.
  2. 건수가 늘어날수록 시간이 선형보다 더 가파르게 늘어나는 느낌이었습니다.
  3. batch_size를 설정해도 체감상 별로 빨라지지 않았습니다. => 이 부분이 제일 이상했습니다. 분명 배치 설정을 했는데 왜 안 먹지?

원인을 추적해보니 결국 PK 생성 전략과 JPA의 동작 방식이 맞물린 문제였고, 그래서 해당 기능을 JDBC batchUpdate로 변경해 82초 → 8초로 줄였습니다. 아래부터는 그때 회사에서 분석했던 내용을, 이번에 테스트 프로젝트로 다시 재현하면서 정리한 것입니다.

테스트 환경

회고용으로 만든 프로젝트 구성은 이렇습니다. (Java 21 / Spring Boot 3.4.3 / MySQL 8.0)

  • ExcelGenerator 로 테스트용 엑셀 데이터를 생성하고
  • JDBC 방식(JdbcTemplate.batchUpdate)과 JPA 방식(saveAll)으로 각각 같은 데이터를 적재
  • 소요 시간을 비교

엔티티는 대충 이런 모양입니다.

@Entity
@Table(name = "excel_data")
public class ExcelData {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)  // ← 사실 여기가 핵심 범인이었습니다
    private Long id;

    private String name;
    private String email;
    private Integer age;
    private BigDecimal salary;
    // ...
}

그리고 application.yml에는 배치 설정을 분명히 줬습니다.

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 1000      # 1000건씩 묶어서 보내라!
        order_inserts: true
        order_updates: true
  datasource:
    url: jdbc:mysql://localhost:3306/jpa_test?rewriteBatchedStatements=true

batch_size: 1000 도 줬고, MySQL 드라이버에 rewriteBatchedStatements=true 도 줬습니다. "이 정도면 JPA도 알아서 배치로 묶어서 빠르게 넣어주겠지?" 라고 생각했지요. 근데 아니었습니다.


원인 분석: JPA는 왜 느렸을까

1. saveAll은 만능이 아니었다

처음엔 saveAll()이라는 이름 때문에 "리스트를 통째로 한 방에 넣어주는 메서드"라고 막연하게 생각했습니다. 근데 내부를 까보면 그냥 for문 돌면서 save()를 하나씩 호출하는 게 전부였습니다.

// SimpleJpaRepository.saveAll() 내부 (개념상)
public <S> List<S> saveAll(Iterable<S> entities) {
    List<S> result = new ArrayList<>();
    for (S entity : entities) {
        result.add(save(entity));   // 결국 한 건씩...
    }
    return result;
}

saveAll을 호출한다고 해서 자동으로 INSERT ... VALUES (...), (...), (...) 같은 다중 INSERT가 되는 게 아니었습니다. 배치로 묶어주는 건 saveAll이 아니라 그 밑단의 Hibernate JDBC 배치 기능이지요. 그래서 batch_size 설정이 중요한 거였고요.

2. 진짜 원인: GenerationType.IDENTITY

여기가 제일 충격이었습니다. 아까 batch_size: 1000을 줬는데도 안 빨라졌다고 말씀드렸습니다? 그 이유가 @GeneratedValue(strategy = IDENTITY) 때문이었습니다. IDENTITY 전략은 PK를 DB의 auto_increment에 위임하는 방식입니다. 즉, INSERT가 실제로 실행되기 전까지는 그 엔티티의 ID를 알 수가 없습니다.

 

그런데 JPA(Hibernate)는 영속성 컨텍스트에서 엔티티를 식별자(ID)로 관리합니다. 새 엔티티를 영속 상태로 만들려면 ID가 반드시 필요하지요. IDENTITY 전략에서는 그 ID를 얻으려면 지금 당장 INSERT를 날려서 DB가 만들어준 값을 받아와야 합니다.

=> 결과적으로 Hibernate는 IDENTITY 전략을 쓰면 JDBC 배치를 아예 비활성화해버립니다. 모아서 한 번에 보내고 싶어도, ID를 즉시 알아야 하니까 한 건 넣고 ID 받고, 또 한 건 넣고 ID 받고... 이걸 반복할 수밖에 없는 거죠.

 

그래서 batch_size: 1000을 줘도 IDENTITY 전략이면 무시됩니다. "배치 설정 했는데 왜 안 빨라지냐"의 답이 바로 이것이었습니다. 이건 Hibernate 공식 문서에도 명시돼 있습니다. (Hibernate User Guide의 batch insert 항목을 보면, IDENTITY 생성 전략에서는 batch insert가 비활성화된다고 나와 있습니다.) 우아한형제들 기술블로그의 Bulk Insert 관련 글에서도 비슷한 맥락이 잘 정리돼 있더라구요.

3. 영속성 컨텍스트(1차 캐시)의 부담

배치가 안 되는 것도 문제지만, 한 가지가 더 있습니다. saveAll로 10만 건을 넣는 동안 영속성 컨텍스트(1차 캐시)에 10만 개의 엔티티가 계속 쌓입니다.

  • 엔티티가 쌓일수록 메모리 사용량이 올라가고
  • flush 시점마다 변경 감지(dirty checking) 대상도 같이 늘어납니다
  • 운이 나쁘면 GC도 더 자주 돌게 되지요

그래서 보통은 일정 건수마다 flush() + clear() 로 영속성 컨텍스트를 비워주는 패턴을 쓰는데, 이렇게 해도 IDENTITY 전략의 배치 비활성화 문제 자체는 해결되지 않습니다. => 결국 INSERT 쿼리는 여전히 건건이 나가니까요.

4. 정리하면

JPA saveAll이 느렸던 이유는 결국 이 조합이었습니다.

  • INSERT가 한 건씩 N번 실행됨 (IDENTITY 때문에 배치 묶기 불가)
  • 영속성 컨텍스트에 엔티티가 누적되면서 메모리/dirty checking 부담 증가

10만 건이면 INSERT 쿼리가 10만 번 왕복했다는 얘기입니다. 느릴 수밖에 없었네요.


해결: JDBC batchUpdate

JDBC로 바꾼 핵심은 단순합니다. 영속성 컨텍스트를 안 쓰고, INSERT를 진짜 배치로 묶어서 보낸다.

private static final String INSERT_SQL =
        "INSERT INTO excel_data (name, email, age, salary, department, hire_date, address) " +
        "VALUES (?, ?, ?, ?, ?, ?, ?)";

public void bulkInsert(List<ExcelData> dataList) {
    int batchSize = 1000;
    for (int i = 0; i < dataList.size(); i += batchSize) {
        int end = Math.min(i + batchSize, dataList.size());
        List<ExcelData> batch = dataList.subList(i, end);

        jdbcTemplate.batchUpdate(INSERT_SQL, batch, batchSize,
            (ps, data) -> {
                ps.setString(1, data.getName());
                ps.setString(2, data.getEmail());
                ps.setInt(3, data.getAge());
                ps.setBigDecimal(4, data.getSalary());
                ps.setString(5, data.getDepartment());
                ps.setDate(6, Date.valueOf(data.getHireDate()));
                ps.setString(7, data.getAddress());
            });
    }
}

여기서 하나 더 중요한 게 rewriteBatchedStatements=true 옵션입니다. MySQL 드라이버는 이 옵션이 있어야 여러 개의 INSERT를 진짜로

INSERT INTO excel_data (...) VALUES (...), (...), (...), ...

이런 하나의 multi-value INSERT 문으로 재작성해서 네트워크 왕복을 확 줄여줍니다. => 이게 없으면 batchUpdate를 써도 효과가 반감되더라구요. (JPA 쪽 application.yml에는 줬었지만, 정작 IDENTITY 때문에 JPA에선 무용지물이었던 거죠.)

  • Apple Silicon Mac / Docker MySQL 8.0.46
  • 엔티티는 위와 동일하게 @GeneratedValue(IDENTITY), batch_size: 1000, rewriteBatchedStatements=true
  • 데이터는 50만 건 (회사 때보다 좀 더 키워서 차이를 확실하게 봤습니다)
  • 같은 데이터를 JDBC → JPA 순서로 적재, 사이에 테이블 truncate

테스트 결과는 이렇게 나왔습니다.

방식 소요 시간 저장 건수
JDBC batchUpdate 7.6초 (7,586ms) 500,000
JPA saveAll 81.1초 (81,116ms) 500,000
차이 JPA가 JDBC 대비 약 10.7배 느림 -

그리고 JPA 쪽 진행 로그를 보면 더 명확합니다.

[JPA saveAll] 100000건 처리 완료   (+16.4초)
[JPA saveAll] 200000건 처리 완료   (+16.1초)
[JPA saveAll] 300000건 처리 완료   (+16.0초)
[JPA saveAll] 400000건 처리 완료   (+16.0초)
[JPA saveAll] 500000건 처리 완료   (+16.3초)

그럼 JPA는 쓰면 안 되는 걸까?

그건 아니라고 생각합니다. 이번에 정리하면서 느낀 건, JPA가 느린 게 아니라 "대량 INSERT"라는 작업이 JPA의 설계 의도와 안 맞는 것에 가깝다는 거였어요.

 

JPA는 객체-영속성 매핑, 변경 감지, 연관관계 관리처럼 도메인 로직을 편하게 다루기 위한 도구지요. 근데 대량 적재는 그런 기능이 전부 오버헤드로만 작용하는 작업입니다. 도메인 객체로 다룰 일도 없고, 그냥 데이터를 빠르게 밀어넣는 게 목적이니까요.

 

JPA를 꼭 쓰고 싶다면 이런 선택지도 있긴 합니다.

  1. PK 생성 전략을 SEQUENCE로 바꾸기 → ID를 미리 채번할 수 있어서 배치 INSERT가 가능해집니다. (다만 MySQL은 시퀀스를 네이티브로 지원하지 않아서 TABLE 전략 등을 써야 하는데, 이건 또 별도 오버헤드가...)
  2. 일정 건수마다 flush() + clear() 로 영속성 컨텍스트 비우기 → 메모리 부담은 줄지만 IDENTITY면 배치 문제는 그대로

=> 근데 이렇게 우회하느니, 순수하게 "대량 적재"가 목적이라면 그냥 JDBC batchUpdate를 쓰는 게 가장 단순하고 빠르다고 판단했습니다. 도구를 목적에 맞게 쓰는 거죠.

마치며

머릿속에만 있던 걸 코드로 재현하고 숫자로 확인하니, batch_size를 줬는데도 안 먹혔던 이유가 IDENTITY 전략 때문이라는 게 훨씬 또렷하게 정리됐습니다. 역시 글로 남겨야 진짜 내 것이 되는 것 같네요.

 

정리하면 이렇습니다.

  • saveAll은 내부적으로 save()를 반복 호출할 뿐, 알아서 배치로 묶어주지 않는다
  • @GeneratedValue(IDENTITY)를 쓰면 Hibernate가 JDBC 배치를 비활성화한다 → batch_size 무용지물
  • 영속성 컨텍스트에 엔티티가 쌓이는 부담도 있다
  • 순수 대량 적재라면 JDBC batchUpdate + rewriteBatchedStatements=true 조합이 깔끔하다

혹시 "엑셀 대량 적재가 느려요" 하시는 분들은 한 번 PK 전략부터 확인해보시는 걸 추천드립니다. 의외로 여기서 막혀 있는 경우가 많지 않을까 싶네요.

반응형