개발하는 자몽

[Spring Data JPA] 하이버네이트 Batch Size 본문

Java & Kotlin/Spring

[Spring Data JPA] 하이버네이트 Batch Size

jaamong 2024. 11. 25. 16:42

상황

1:N 관계가 얽혀있는 엔티티를 대상으로 Spring Data JPA를 이용해서 Pagination 조회를 하니 말로만 듣던 N+1 상황이 발생했다. 

 

처음에는 일반적인 해결 방법인 Fetch Join을 사용하려고 했다. 더불어 중복을 방지하기 위해  DISTINCT 추가하려고 했다. 그러나 1:N 관계의 컬렉션을 Fetch Join 하면서 동시에 Pagination을 사용하는 것은 OutOfMemoryError가 발생할 수 있고, 이로 인해 DISTINCT를 적용할 수 없다고 한다. 그래서 찾은 다른 방법 중 가장 빠르고 쉬운 방법은 Batch Size를 설정하는 것이었다.

 

하이버네이트 Batch Size

하이버네이트에서 제공하는 @BatchSize 애노테이션이나 batch_size을 적용하여 Batch Size을 설정할 수 있다.

 

엔티티를 가져오는 것은 데이터베이스에서 데이터를 가져와 애플리케이션에서 객체(Object)로 변환하는 것을 의미한다. 하이버네이트는 데이터를 그룹(batches)으로 가져와 이런 동작을 최적화할 수 있다. 이는 데이터베이스 호출 수를 줄이고, 요청 처리 효율성을 증가시킨다. 

 

클래스 레벨에서 적용하기

엔티티 클래스에 @BatchSize(size=n)을 적용하면 하이버네이트는 지연 로딩 동안 해당 엔티티의 인스턴스를 한 번에(한 요청) 최대 n개까지 가져오도록 지시한다. 이는 많은 연관된 엔티티들을 가져올 때 매우 효과적으로 데이터베이스 쿼리의 수를 줄일 수 있다.

@Entity
@BatchSize(size=100)
public class MyEntity {
    ...
}

 

컬렉션 레벨에서 적용하기

엔티티의 컬렉션 필드에 @BatchSize를 설정하면 해당 컬렉션의 요소를 배치 사이즈만큼 일괄적으로 가져와 지연 로딩 처리가 최적화된다. 이 또한 데이터베이스 쿼리 수를 줄이고 관련 컬렉션에 접근할 때 성능을 향상한다. 

@OneToMany
@BatchSize(size=50)
private Set<MyCollection> myCollections = new HashSet<>();

 

스프링 부트에서 batch_size 설정하기

batch_size 파라미터 설정을 통해 insert, update, delete 작업에도 batch size를 적용할 수 있다. 해당 파라미터는 application.properties 또는 application.yml에서 쉽게 설정할 수 있다.

spring.jpa.properties.hibernate.jdbc.batch_size=50

하이버네이트에서 이러한 쓰기 작업을 최적화하는 것은 오직 전역 batch_size 설정을 통해서만 할 수 있다. @BatchSize 애노테이션은 오로지 읽기 작업에만 영향을 미친다.

 

해결

나는 읽기 작업 외에 쓰기(save/saveAll) 작업에도 적용되길 원했기 때문에 application.ymlbatch_size 설정을 하여 해결했다. (보통 전역적으로 설정할 때 size는 100 이상으로 하는 것 같다)

 

 

 

번외. Batch Insert와 MySQL

Notice  여기서 부터는 1:N 관계 Pagination의 N+1 문제와는 관련이 없다.

 

참고로 Batch Insert를 원하는데, MySQL을 사용하고 있고 PK 생성 전략을 IDENTITY로 하고 있다면 위 설정 외에도 추가적으로 해야 할 것이 있다. 그렇지 않으면 Spring Data JPA의 saveAll()을 쓰든 batch_size를 사용하든 데이터 건 수 별로 단건 insert 호출이 발생한다. 이유는 PK 생성 전략을 IDENTITY로 설정한 경우 하이버네이트에서 Batch Insert 기능을 지원하지 않기 때문이다.

 

MySQL에서 IDENTITY 전략은 AUTO_INCREMENT로 PK 값을 자동 증가시켜 생성하는 것이다. 이는 insert 작업을 실제로 실행하기 전까지는 ID에 할당된 값을 알 수 없음을 의미하며, Transaction Write-Behind를 할 수 없어 Batch Insert 작업을 실행할 수 없는 결과를 초래한다. 

 

이를 해결하는 방법 중 하나는 PK 생성 전략을 IDENTITY 대신 SEQUENCETABLE 전략으로 변경하는 것이다. 하지만 이런 방법은 기존 데이터베이스 설계에 영향을 줄 수 있을 것 같아서 다른 방법을 찾았다. 바로 JDBC Template를 사용하는 것이다. 

 

Batch Insert와 JDBC Template 사용하기

JDBC Template을 사용하여 Batch Insert를 하려면 MySQL JDBC에 rewriteBatchedStatements 옵션을 추가하여 true로 설정해야 한다. JDBC Template 의존성은 Spring Data JPA를 사용중이라면 바로 import 할 수 있다.

spring:
  datasource:
    url: jdbc:mysql://{MYSQL_DB_URL}:{PORT}/{SCHEMA}?rewriteBatchedStatements=true

 

 

이제 JdbcTemplate을 사용하여 Batch Insert를 해보자!

@Repository
@RequiredArgsConstructor
public class MyEntityJdbcRepository {

    private final JdbcTemplate jdbcTemplate;

    public void saveAll(List<MyEntity> myEntities, Long myEntityLastId) {
        String sql = "INSERT INTO my_entity (my_entity_id, ..., created_at, modified_at) VALUES (?, ..., now(), now())";

        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                MyEntity myEntity = myEntities.get(i);

                // 파라미터 설정
                ps.setLong(1, myEntityLastId + i); // pk 설정
                ps.setString(2, myEntity.getXxx());
               	...
                ps.setString(n, myEntity.getXxx());
            }

            @Override
            public int getBatchSize() {  
                return myEntities.size();  // 삽입할 컬렉션 크기 만큼 배치 사이즈 설정
            }
        });
    }
}

 

이제 insert/save 로직을 수행하는 곳에서 해당 메소드를 호출하여 사용하면 된다. 

 

잘 적용이 되었는지 콘솔창에서 확인하고 싶다면 아래 설정을 추가하자. 

spring:
  datasource:
    url: jdbc:mysql://{MYSQL_DB_URL}:{PORT}/{SCHEMA}?rewriteBatchedStatements=true&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=500
  • profileSQL=true :  Driver에서 전송하는 쿼리를 출력
  • logger=Slf4JLogger :  Driver에서 쿼리 출력 시 사용할 Logger 설정
  • maxQuerySizeToLog=... :  출력할 쿼리 길이 설정

 

Batch Insert 이후 또다른 문제...

Batch Insert를 사용한 saveAll() 이후 JPA를 이용한 단건 insert/save 작업에서 문제가 발생했다(둘은 별도의 트랜잭션에서 실행된다). 에러를 보니 PK 위반 문제였는데, 원인은 SEQUENCE/IDENTITY 생성에 관한 하이버네이트의 이해와 데이터베이스 상태 사이의 불일치 때문이었다. 

  1. jdbcTemplate.batchUpdate()는 레코드를 바로 삽입하고 AUTO_INCREMENT 값을 증가시킨다.
  2. 하이버네이트의 save()는 JDBC가 이미 사용한 최신 AUTO_INCREMENT 값을 인지하지 못한다. 
  3. MySQL은 내부적으로 AUTO_INCREMENT 카운터를 유지하려고 한다.

 

그래서 JDBC를 사용한 Batch Insert 이후에 MySQL의 AUTO_INCREMENT 값을 데이터베이스에 마지막으로 저장된 값보다 더 큰 값으로 업데이트하도록 로직을 수정했다. 

@Repository
@RequiredArgsConstructor
public class MyEntityJdbcRepository {

    private final JdbcTemplate jdbcTemplate;

    public void saveAll(List<MyEntity> myEntities, Long myEntityLastId) {
        ...
    }
    
    // 데이터베이스 id의 가장 마지막 값을 조회
    public Long getMaxId() {
        String sql = "SELECT COALESCE(MAX(my_entity_id), 0) FROM my_entity";
        return jdbcTemplate.queryForObject(sql, Long.class);
    }

    // 다음 ID를 `value`로 시작하도록 테이블 변경
    public void resetAutoIncrement(Long value) {
        String sql = "ALTER TABLE my_entity AUTO_INCREMENT = ?";
        jdbcTemplate.update(sql, value);
    }
}

 

실제 사용 코드이다. Batch Insert 이후 ID 값을 업데이트 한다(단일 insert/save 하는 코드 쪽은 건드리지 않았다). 

@Transactional
public void initData() {
    ...
    myEntityJdbcRepository.saveAll(myEntities, 1L);

    Long maxId = myEntityJdbcRepository.getMaxId();
    myEntityJdbcRepository.resetAutoIncrement(maxId + 1);
}

 

이후 정상적으로 실행됐다!

 

 

 

 

 

🔖참고

Comments