들어가기 전

업무를 보다가 대용량 데이터를 저장해야 되는 상황에 맞닥뜨렸습니다.

그래서 평소처럼 JPA를 이용하여 데이터를 넣으려고 했는데 저장속도가 너무 느려 JdbcTemplate을 이용하여 bulkInsert를 하였습니다. 

해당 포스팅에서는 JdbcTemplate과 JPA를 이용하여 예시로 50만 건의 데이터가 저장되는 속도를 비교하고 JdbcTemplate보다 Jpa가 데이터 저장속도가 느린 이유에 대해 알아보겠습니다.

 

 

JPA를 이용하여 데이터 저장

JPA에서는  save, saveAll을 이용하여 데이터를 저장할 수 있습니다. 그래서 save와 saveAll 저장속도를 먼저 비교해 보겠습니다.

 

1. Save

 

1) 코드

@SpringBootTest
public class BlogContents {

    @Autowired
    private JpaDataInsertRepository jpaDataInsertRepository;


    @Test
    @DisplayName("JPA Save를 이용하여 50만 건 데이터 속도")
    @Rollback(value = false)
    void useJpaSaveDataInsert() {
        long startTime = System.currentTimeMillis();
        for (int i = 1; i <= 500000; i++) {
            JpaDataInsert jpaDataInsert = new JpaDataInsert();
            jpaDataInsert.setName("save" + i);
            jpaDataInsertRepository.save(jpaDataInsert);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("저장 속도 = " + (endTime - startTime));
    }

 

 

 

 

  • 3068791 ms를 1000으로 나누면 3068s입니다 즉 저장속도는 약 50분 정도 걸린 것을 확인할 수 있습니다.

 

2. SaveAll

 

@SpringBootTest
public class BlogContents {

    @Autowired
    private JpaDataInsertRepository jpaDataInsertRepository;

    @Test
    @DisplayName("JPA SaveAll을 이용하여 50만 건 데이터 속도")
    @Rollback(value = false)
    void useJpaSaveAllDataInsert() {
        List<JpaDataInsert> saveAll = new ArrayList<>();
        for (int i = 1; i <= 500000; i++) {
            JpaDataInsert jpaDataInsert = new JpaDataInsert();
            jpaDataInsert.setName("save" + i);
            saveAll.add(jpaDataInsert);
        }
        long startTime = System.currentTimeMillis();
        jpaDataInsertRepository.saveAll(saveAll);

        long endTime = System.currentTimeMillis();

        System.out.println("저장 속도 = " + (endTime - startTime) / 1000);

    }
}

 

 

 

 

  • 저장속도 / 1000을 하여 580s 즉 9분 40초 정도 걸린 것을 확인할 수 있습니다.

 

SaveAll이 Save보다 저장속도가 빠른 이유

 

위의 저장시간 로그가 찍힌 사진을 보면 saveAll과 save 차이가 많이 나는 것을 확인할 수 있습니다.

이렇게 속도가 많이 차이 나는 이유는 @Transactional 때문입니다.

@Transactional은  클래스나 메서드 단위에 작성을 하여 트랜잭션을 제공해 주는 기능입니다. 해당 어노테이션은 AOP 프록시 기반으로 동작을 하고 외부 Bean 객체가 있어 해당 객체의 함수를 호출해야 Intercept가 되어 트랜잭션으로 묶이게 됩니다.

그래서 Bean 객체 내부에서 내부 함수를 호출하게 되는 경우에는 해당 어노테이션이 적용되지 않습니다.

 

save 같은 경우에는 @Transactional이 존재하는 경우 해당 트랜잭션에 참여하게 되고 그렇지 않을 경우 새로운 트랜잭션을 생성하여 동작합니다.

이유는 @Transactional의 기본 전파과정은 REQUIRED이기 때문입니다.

REQUIRED는 진행 중인 트랜잭션이 있으면 합류를 하고 그렇지 않으면 새로운 트랜잭션을 생성합니다.

(해당 포스팅은 전파과정을 설명하는 게 아니기 때문에 자세한 설명 생략)

기존 트랜잭션에 참여하게 되더라도 외부 Bean 객체의 내부 함수를 호출하는 것이기 때문에 위에서 설명한 "@Transactional이 존재하는 경우 해당 트랜잭션에 참여하게 되고 그렇지 않을 경우 새로운 트랜잭션을 생성"  행위를 반복적으로 수행하여 비용이 발생합니다. 개수가 적을 때는 성능 차이가 없지만 개수가 많을수록 비용이 점점 커져 시간이 오래 걸리게 됩니다.

 

그러나 saveAll 같은 경우에는 Bean 객체의 내부함수를 호출하기 때문에 save 메서드와 같이 트랜잭션이 생성되거나 참여하는 프록시 동작을 타지 않고 메서드 호출만 하기 때문에 save와 같은 비용이 발생하지 않습니다.

 

 

JdbcTemplate을 이용하여 데이터 저장

 

@Repository
@RequiredArgsConstructor
@Transactional
public class JdbcDataInsertRepository {
    private final JdbcTemplate jdbcTemplate;

    private final int batchSize = 10000; // 한번에 저장할 데이터 크기

    public void jdbcTemplateSaveAll(List<JdbcDataInsert> jdbcDataInserts) {
        int batchCount = 0;
        List<JdbcDataInsert> sub = new ArrayList<>();
        for (int i = 0; i < jdbcDataInserts.size(); i++) {
            sub.add(jdbcDataInserts.get(i));
            if ((i + 1) % batchSize == 0) {
                batchCount = dataInsert(sub, batchCount, batchSize);
            }
        }
    }

    public int dataInsert(List<JdbcDataInsert> request, int batchCount, int batchSize) {
        jdbcTemplate.batchUpdate("INSERT INTO jdbc_data_insert(name) VALUES(?)",
                request, batchSize, (ps, argument) -> ps.setString(1, argument.getName())
        );
        request.clear();
        batchCount++;
        return batchCount;
    }
}

 

@Test
@DisplayName("JdbcTemplate을 이용하여 50만 건 데이터 속도")
void useJdbcTemplateDataInsert() {
    List<JdbcDataInsert> list = new ArrayList<>();
    for (int i = 1; i <= 500000; i++) {
        list.add(new JdbcDataInsert("JDBC" + i));
    }

    long startTime = System.currentTimeMillis();
    jdbcDataInsertRepository.jdbcTemplateSaveAll(list);

    long endTime = System.currentTimeMillis();

    System.out.println("저장 속도 = " + (endTime - startTime) / 1000);
    }

 

 

 

  • 저장속도는 저장속도 / 1000 해서 428s 즉 7분 8초 정도 걸린 것을 확인할 수 있습니다.

 

JPA보다 JdbcTemplate이 저장속도가 빠른 이유

 

JPA에서 식별자 생성 방식을 IDENTITY 방식을 사용하면 Hibernate가 JDBC 수준에서 batch Insert를 비활성화가 됩니다. 이유는 새로 할당된 Key값을 미리 알 수 없는 IDENTITY 방식을 사용할 때 Batch Support를 지원하게 되면 Hibernate가 채택한 flush 방식인 쓰기 지연(Transactional Write Behind) 충돌이 발생하여 Batch Insert는 동작하지 않습니다.

그래서 MySQL이나 PostgreSQL을 사용하게 되면 식별자 생성 방식을 IDENTITY 사용하기 때문에 Batch Insert가 동작하지 않습니다.

 

JDbcTemplate에서는 Hibernate가 비활성환 Batch Insert를 지원하는 batchUpdate 메서드를 지원합니다. 해당 메서드를 사용해서 배치 크기를 지정하고  전체 데이터를 지정한 배치 크기로 나눠서 Batch Insert를 사용합니다.

 

 

 

깨달은 점 

JPA를 사용하면서 편리함과 JPA가 제공하는 많은 기능을 사용하게 되면서 JPA의 장점만 인지한 상태로  개발을 해왔습니다.

하지만 위에서 설명했던 내용과 같이 많은 양의 데이터를 저장하는 상황에서 JPA를 이용하면 저장 속도가 느려진다는 단점을 알게 되었고 편리하고 많은 기능을 제공해 주는 기술이라도 상황에 맞지 않는 기술이라면 상황에 맞는 효율적인 기술을 탐색하여 사용해야 된다라는 것을 깨닫게 되었습니다

'JPA' 카테고리의 다른 글

[JPA] Fetch Join의 양면성  (1) 2024.05.28
[JPA] 2차 캐시란?  (0) 2024.01.16
[JPA] Cascade 옵션 종류 및 예제  (0) 2023.04.12
[JPA] Kotlin을 이용하여 Soft Delete 구현  (0) 2023.03.04
[JPA] N+1 원인 및 해결방법  (0) 2023.01.18