JPA를 사용하면서 다수의 데이터를 저장 하기위해 spring data jpa의 saveAll()을 이용 하였지만 bulk insert로 처리되지 않는 것을 발견하여, 성능 문제를 개선한 내용입니다.
use case는 다음과 같습니다.
관리자가 공연을 등록할 때 공연장 좌석 정보 데이터도 함께 등록이 되는 상황입니다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PerformanceService {
private final PerformanceRepository performanceRepository;
private final PlaceRepository placeRepository;
private final SeatRepository seatRepository;
@Transactional
public CreatePerformanceResult create(CreatePerformanceValue createPerformanceValue) {
//... 생략
Performance newPerformance = createPerformanceValue.toEntity(place);
performanceRepository.save(newPerformance);
seatRepository.saveAll(
performanceSeats.getSeats(newPerformance.getId())
);
return new CreatePerformanceResult(
newPerformance,
new PerformancePlace(place)
);
}
}
위 코드를 보면 jpa에서 제공해주는 saveAll()
을 활용하여, 좌석 정보를 저장 하는 로직을 작성하였는데 확인 결과가 insert가 생성하려는 좌석 수 만큼 발생하는 것을 확인 하였습니다.(100건 등록시 insert 100번 발생)
spring data jpa 구현체인 SimpleJpaRepository
를 확인해 보면 saveAll() 내부에서 for문을 통해 save()를 호출 하는 것을 확인할 수 있습니다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
...
@Transactional
@Override
public <S extends T> List<S> saveAll(Iterable<S> entities) {
Assert.notNull(entities, "Entities must not be null");
List<S> result = new ArrayList<>();
for (S entity : entities) {
result.add(save(entity));
}
return result;
}
}
Hibernate
에서 batch insert를 제공하지만 기본키 생성 전략중 IDENTITY
전략에서는 Hibernate가 batch insert를 비활성화 하기 때문에 사용이 불가능 합니다.
비활성화를 하는 이유는 영속성 컨텍스트 내부에 엔티티를 식별할때 엔티티 타입과 PK값으로 식별하지만, IDENTITY
전략의 경우 DB에 Insert한 후 PK 확인이 가능(jpa - entityManager.persist 시 insert 쿼리 발생)하기 때문입니다.
(IDENTITY - 기본키 생성을 DB에 위임하는 전략)
해당 서비스는 기본키 생성 전략을 IDENTITY
를 활용 하기때문에 해당 성능 문제를 해결하기 위해 JdbcTemplate를 활용해 Bulk Insert
를 하였습니다.
Mysql을 사용 하고 있다면 rewriteBatchedStatements=true
를 작성 해주셔야 합니다.
MySQL Connector/J 8.1 Developer Guide
Stops checking if every INSERT statement contains the "ON DUPLICATE KEY UPDATE" clause. As a side effect, obtaining the statement's generated keys information will return a list where normally it would not. Also be aware that, in this case, the list of generated keys returned may not be accurate. The effect of this property is canceled if set simultaneously with "rewriteBatchedStatements=true".
application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/reservation?rewriteBatchedStatements=true
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: 1234
JdbcRepository 구현 코드
@Repository
@RequiredArgsConstructor
public class PerformanceJdbcRepository implements JdbcRepository {
private final JdbcTemplate jdbcTemplate;
@Override
public void saveAll(List<Seat> seats) {
String sql = "INSERT INTO reservation.seat (created_at, updated_at, is_reserved, location, number, performance_id)"
+ "VALUES (now(), now(), ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Seat seat = seats.get(i);
ps.setBoolean(1, seat.getIsReserved());
ps.setString(2, seat.getLocation());
ps.setInt(3, seat.getNumber());
ps.setLong(4, seat.getPerformanceId());
}
@Override
public int getBatchSize() {
return 100;
}
});
}
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PerformanceService {
private final PerformanceRepository performanceRepository;
private final PlaceRepository placeRepository;
private final JdbcRepository jdbcRepository;
@Transactional
public CreatePerformanceResult create(CreatePerformanceValue createPerformanceValue) {
... 생략코드
Performance newPerformance = createPerformanceValue.toEntity(place);
performanceRepository.save(newPerformance);
jdbcRepository.saveAll(
performanceSeats.getSeats(newPerformance.getId())
);
return new CreatePerformanceResult(
newPerformance,
new PerformancePlace(place)
);
}
}
JMeter를 활용하여 테스트로 등록할 좌석정보 데이터는 30,000건 이며, load time(응답시간)을 비교한 결과입니다.
JPA saveAll() 결과 - 40127ms
JdbcTemplate batchUpdate() 결과 - 565ms