환경
DB: MySQL
Test: Junit5
Spring, JPA 사용
프로젝트를 진행하면서 단 건의 Client 엔티티가 아닌 다수의 Client를 한 번에 저장할 일이 생겼습니다. 이런 다수의 데이터를 저장하는 일은 JPA를 사용해도 되고 JDBC Template을 사용해도 됩니다.
이 글에서는 10,000건의 Client 데이터를 저장한다고 가정하겠습니다.
이런 다수의 엔티티를 저장하는 일은 JPA에서 2가지로 해결할 수 있습니다.
save()
를 활용하여 client를 루프문을 돌며 저장하기. saveAll()
을 활용하여 List형식의 client를 한 번에 저장하기먼저 save와 saveAll 메소드의 내부 로직을 확인해보겠습니다.
JpaRepository
의 구현체인 SimpleJpaRepository
로 확인해보겠습니다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
// 다른 코드 생략
...
@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null.");
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
@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;
}
}
코드를 보면 결국은 saveAll()
은 save()
를 호출하는 것으로 확인할 수 있습니다.
10,000건의 데이터를 저장하는 시간이 같지 않을까요?
JpaRepository
에서 제공하는 save()
를 활용해서 다음과 같이 10,000 건의 데이터를 저장해보겠습니다.
@Autowired
ClientRepository clientRepository;
@Test
@Transactional
void bulkInsertWithJpaSaveEachTest() {
for (int i = 0; i < 10000; i++) {
Client client = TestEntityCreator.createClient(i, group);
clientRepository.save(client);
}
}
insert
into
client
(created_at, updated_at, address, detail, client_image_id, group_id, latitude, longitude, name, phone_number)
values
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
insert
into
.... N번 발생
다음과 같은 insert 쿼리가 10,000번 발생했습니다.
해당 테스트는 4267.0589ms의 시간이 소요되었습니다.
이번에는 JpaRepository
에서 제공하는 saveAll()
을 활용해서 다음과 같이 10,000 건의 데이터를 저장해보겠습니다.
@Autowired
ClientRepository clientRepository;
@Test
@Transactional
void bulkInsertWithJpaSaveAllTest() {
List<Client> list = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
Client client = TestEntityCreator.createClient(i, group);
list.add(client);
}
clientRepository.saveAll(list);
}
이 테스트 또한 save
와 같은 insert 쿼리가 10,000번 발생했습니다.
하지만 해당 테스트는 2546.2081ms의 시간이 소요되었습니다.
어짜피 saveAll()
에서는 save()
를 사용하는데 이상하게 수행 시간은 더 빨라졌습니다.
save의 수행 시간: 4267.0589ms
saveAll의 수행 시간: 2546.2081ms
거의 2배 가까운 성능 차이가 났습니다.
수행 시간은 환경에 따라 다를 수 있기 때문에, 상대적인 값으로 봐주시길 바랍니다.
이유가 무엇일까요?
이유를 알려면 스프링 트랜잭션에 대해 이해해야합니다.
트랜잭션에 관한 설명은 너무 길기 때문에 단순하게 요약해보겠습니다.
/*
예제 단순화를 위해 코드를 단순화 했습니다.
그냥 수도 코드라고 생각하고 이해해주시면 감사하겠습니다.
*/
@Component
@Transactional
class A클래스 {
A-A메소드() {...}
A-B메소드(int cnt) {
...
for(cnt만큼 반복 수행) {A-A메소드()}
}
}
// 다른 패키지 위치에 저장되었다고 생각해주세요.
@Component
@RequiredArgsConstructor
class B클래스 {
private final A클래스 a객체;
B-A메소드() { a객체.A-A메소드() }
}
A클래스
는 컴포넌트 스캔이 되면서 Bean으로 등록되게 됩니다.
이때 @Transactional
이 있기 때문에 프록시 객체로 감싸서 Bean에 등록되게 됩니다.
프록시 객체로 감싸서 트랜잭션과 관련된 처리를 하는 것입니다.
Bean으로 등록되어 있는 다른 B클래스
를 봅시다.
B클래스
에 a객체
는 스프링 컨테이너를 통해 주입받기 때문에 프록시 객체로 감싸져서 들어오게 됩니다.
B-A메소드
를 보면 프록시 객체인 a객체
를 통해 A-A메소드
를 실행했습니다.
a객체
는 프록시 객체이기 때문에 A-A메소드
를 실행시키기 전에 프록시 객체의 로직들이 실행되는 것입니다.
-------B클래스-------
1. B-A메소드 실행!
-----A 클래스 프록시-----
| 2. 트랜잭션과 관련된 우리가 모르는 프록시 로직C
|
| ---실제객체---
| | 3. A-A메소드 실행!
| ------------
|
| 4. 트랜잭션과 관련된 우리가 모르는 프록시 로직D
---------------
5. 끝
--------------------
이렇게 스프링 컨테이너로부터 주입 받은 객체들은 프록시 객체이기 때문에
a객체의 메소드를 실행시킬 때 우리가 모르는 사이에 로직C
, 로직D
를 실행시킵니다.
이외에도 다른 로직들이 실행되겠지만 이 글의 목적에 집중하기 위해 이렇게 설명하고 넘어갑니다.
이제 B클래스
에서 B-B메소드
를 만들어서 A클래스
에 A-B메소드
를 10번 호출한다고 생각해봅시다.
다음과 B클래스
에 같은 코드가 추가된 것입니다.
@Component
@Transactional
class A클래스 {
A-A메소드() {...}
A-B메소드(cnt) {A-A메소드()를 cnt번 수행}
}
@Component
@RequiredArgsConstructor
class B클래스 {
private final A클래스 a객체;
B-A메소드() { a객체.A-A메소드() }
B-B메소드() { a객체.A-B메소드(10) } // 추가됐어요!
}
A클래스
의 A-B메소드
에서는 A-A메소드
를 호출하고 있습니다.
이제 흐름을 살펴볼까요?
B-A로 10번 실행 시킬 때
-------B클래스-------
1. B-A메소드 실행!
-----A 클래스 프록시-----
| 2. 트랜잭션과 관련된 우리가 모르는 프록시 로직C
|
| ---실제객체---
| | 3. A-A메소드 실행!
| ------------
|
| 4. 트랜잭션과 관련된 우리가 모르는 프록시 로직D
---------------
위 프록시의 실행을 9번 더 반복.
5. 끝
--------------------
B-B로 10번 실행 시킬 때
-------B클래스-------
1. B-B메소드(10) 실행!
-----A 클래스 프록시-----
| 2. 트랜잭션과 관련된 우리가 모르는 프록시 로직C
|
| ---실제객체---
| | 3. A-B메소드 실행!
| | 4. A-B에서 A-A메소드를 10번 호출해서 실행!
| ------------
|
| 5. 트랜잭션과 관련된 우리가 모르는 프록시 로직D
---------------
6. 끝
--------------------
a객체
의 A-A메소드
를 10번 호출하는 것과 a객체
의 A-B메소드
메소드를 통해 A-A메소드
를 10번 호출하는 것은 어떤 차이가 있을까요?
A-B메소드
를 사용하면 로직C
와 로직D
가 1번만 실행되기 때문에 A-A메소드
보다 9번 덜 실행되게 되는 것입니다.
그러면 다시 본론으로 돌아와서 save
와 saveAll
은 어떤 측면에서 성능의 차이가 발생했을까요?
B-A메소드
와 B-B메소드
를 생각해보면 답이 나옵니다.
이번 예시에서는 10번이 아닌 10,000번을 사용했기 때문에 프록시 로직이 9,999번 더 실행되었기 때문에 그 차이가 더 크게 발생했습니다.
save의 수행 시간: 4267.0589ms
saveAll의 수행 시간: 2546.2081ms
차이: 1720.8508ms
이 부분은 공부를 깊게 하지 않고 훑어 본 뒤에 작성하는 부분이기 때문에 정확하지 않을 수 있습니다. 그냥 save와 saveAll부분의 성능차이는 트랜잭션으로 인해 발생하는구나 정도로만 이해하셔도 충분합니다.
트랜잭션과의 관계를 알았는데 다시 save()
와 saveAll()
테스트 코드를 살펴보겠습니다.
@Autowired
ClientRepository clientRepository;
@Test
@Transactional
void bulkInsertWithJpaSaveEachTest() {
...
// save() 10000만 번 호출 테스트
}
@Test
@Transactional
void bulkInsertWithJpaSaveAllTest() {
...
// saveAll 한 번 호출 테스트
}
트랜잭션 설명부분에서 말했듯이 clientRepository
는 프록시 객체입니다.
즉 new ClientRepository();
와는 차이가 있다는 것입니다.
지금 테스트 코드는 @Transactional
을 메소드에 붙혀서 트랜잭션이 전파되도록 테스트 환경을 구성했습니다.
트랜잭션이 전파되어도 프록시 객체는 프록시 객체입니다.
하지만 트랜잭션이 전파되므로써 ClientRepository
의 프록시 객체에서 트랜잭션 관련 처리가 단순해 진 것 같습니다.
이 부분부터는 많은 상상이 들어가 있다.
다시 테스트 코드를 통해 save()
를 10,000번 실행한다고 하고
전체적인 실행 구조를 생각해보겠습니다.
-- 테스트 환경 save 테스트 메소드를 트랜잭션으로 묶음 --
1. save() 메소드 실행
-----clientRepository 프록시-----
| 2. 트랜잭션과 관련된 우리가 모르는 프록시 로직C - 트랜잭션이 전파되어 상위 트랜잭션 이용
|
| ---clientRepository 실제 객체---
| | 3. A-B메소드 실행!
| | 4. A-B에서 A-A메소드를 10번 호출해서 실행!
| --------------------------------
|
| 5. 트랜잭션과 관련된 우리가 모르는 프록시 로직D - 상위 트랜잭션이 관리할텐데...
--------------------------------
9,999번 더 반복...
6. 끝
--------------------------------
상위 트랜잭션을 이용하므로써 로직 C
, 로직 D
의 시간이 매우 단축되었을 것입니다.
이는 테스트 메소드에 @Transactional
을 제거하면 알 수 있습니다.
이번에는 테스트 메소드에 @Transactional
을 제거했을 때
전체적인 실행 구조를 생각해보겠습니다.
-- 테스트 환경 save 테스트 메소드를 트랜잭션을 사용하지 않음 --
1. save() 메소드 실행
-----clientRepository 프록시-----
| 2. 트랜잭션과 관련된 우리가 모르는 프록시 로직C - 트랜잭션 관련 처리를 해야함 - 바쁨
|
| ---clientRepository 실제 객체---
| | 3. A-B메소드 실행!
| | 4. A-B에서 A-A메소드를 10번 호출해서 실행!
| --------------------------------
|
| 5. 트랜잭션과 관련된 우리가 모르는 프록시 로직D - 트랜잭션 관련 처리를 해야함 - 바쁨
--------------------------------
9,999번 더 반복...
6. 끝
--------------------------------
실제로 @Transactional
의 유무에 따라 다음과 같은 성능의 차이가 났습니다.
save() 테스트 로직 수행시간
트랜잭션 전파 수행시간: 4267.0589ms
트랜잭션 전파X 수행시간: 31485.3486ms
성능은 개선이 되었지만 더 개선할 여지가 있습니다.
MySQL을 사용하는 서버에서 JPA는 기본키 생성 방식을 IDENTITY
를 사용하면 Batch Insert를 지원하지 않습니다.
IDENTITY
를 사용하면 Batch Insert를 지원하지 않는 자세한 이유는 다음 글들에서 다루도록 하겠습니다.
이 글에서는 JdbcTemplate을 썼을 때 성능이 얼마나 개선되는지를 다루겠습니다.
Batch Insert를 위해 JDBC를 직접 사용해서 쿼리를 날려보겠습니다.
가장 중요한 것은 rewriteBatchedStatements
설정을 true로 설정하는 것입니다.
이 설정을 켜지않으면 JdbcTemplate을 사용해도 단 건의 쿼리를 보내게 됩니다.
spring:
datasource:
...
url: jdbc:mysql://localhost:3306/test_c?rewriteBatchedStatements=true
만약 발생한 쿼리를 직접 확인하고, 비교하고 싶다면 다음 설정도 추가해줍시다.
spring:
datasource:
...
url: jdbc:mysql://localhost:3306/test_c?rewriteBatchedStatements=true&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=200
profileSQL
: Driver에서 전송하는 쿼리를 출력합니다.
logger
: Driver에서 쿼리 출력시 사용할 Logger를 설정합니다.
maxQuerySizeToLog
: 출력할 쿼리 길이 설정합니다.
JDBC Tempalte을 사용하기 위해 다음 의존성을 추가합니다.
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
그리고 다음 레포지토리를 구현했습니다.
// 다수의 Client를 저장하기 위한 레포지토리입니다.
@Repository
@Transactional
@RequiredArgsConstructor
public class ClientBulkRepository {
private final JdbcTemplate template;
// 글과 상관없는 세부 로직은 제거했습니다.
public void saveClientWithGroup(Group group, List<Client> newClient) {
final String insertSQL = "INSERT INTO " +
"client (name, phone_number, address, detail, latitude, longitude, group_id, created_at, updated_at) " +
"VALUES(?, ?, ?, ?, ?, ?, ?, NOW(), NOW())";
template.batchUpdate(insertSQL, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Client client = newClient.get(i);
ps.setString(1, client.getName());
ps.setString(2, client.getPhoneNumber());
...
}
private void setAddressOrSetNull(PreparedStatement ps, int index, String value) throws SQLException {
...
}
private void setLocationOrSetNull(PreparedStatement ps, int index, Double value) throws SQLException {
...
}
@Override
public int getBatchSize() {
return newClient.size();
}
});
}
}
@Autowired
ClientBulkRepository clientBulkRepository;
@Test
@Transactional
void bulkInsertWithJDBCTest() {
List<Client> list = new ArrayList<>();
for (int i = 0; i < loop; i++) {
Client client = TestEntityCreator.createClient(i, group);
list.add(client);
}
// group은 무시해도 상관없습니다.
clientBulkRepository.saveClientWithGroup(group, list);
}
해당 테스트는 1618.374ms의 시간이 소요되었습니다.
하나의 트렌잭션 내에서 1만 건의 데이터 insert
JDBC의 수행시간: 1618.374ms
saveAll의 수행시간: 2546.2081ms
save의 수행시간: 4267.0589ms
성능 비율
batchUpdate : saveAll : save = 1 : 1.573 : 2.637