- 프로그래머스
- static
- nginx
- java
- DI
- select
- join
- @transactional
- Django
- spring security 6
- 1차원 배열
- ORM
- jpa
- string
- Docker
- 스프링부트
- 스프링
- mysql
- spring mvc
- springboot
- SSL
- hibernate
- sql
- spring boot
- AWS
- 문자열
- 자바
- spring
- 데이터베이스
- PYTHON
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
개발하는 자몽
[Spring Data JPA] 하이버네이트 Batch Size 본문
상황
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.yml`에 `batch_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` 대신 `SEQUENCE`나 `TABLE` 전략으로 변경하는 것이다. 하지만 이런 방법은 기존 데이터베이스 설계에 영향을 줄 수 있을 것 같아서 다른 방법을 찾았다. 바로 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` 생성에 관한 하이버네이트의 이해와 데이터베이스 상태 사이의 불일치 때문이었다.
- `jdbcTemplate.batchUpdate()`는 레코드를 바로 삽입하고 `AUTO_INCREMENT` 값을 증가시킨다.
- 하이버네이트의 `save()`는 JDBC가 이미 사용한 최신 `AUTO_INCREMENT` 값을 인지하지 못한다.
- 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);
}
이후 정상적으로 실행됐다!
🔖참고
'Java & Kotlin > Spring' 카테고리의 다른 글
[Spring Security] OAuth2 카카오 로그인 구현 with JWT (0) | 2024.12.29 |
---|---|
[Spring] @Value과 static 변수 (0) | 2024.12.16 |
[JPA Error] No EntityManager with actual transaction available for current thread - cannot reliably process 'flush' call (0) | 2024.10.23 |
[JPA] 임베디드 타입(@Embeddable, @Embedded)에 관하여 (0) | 2024.08.23 |
[Spring Boot / Error] Required request body is missing (0) | 2024.07.13 |