Spring JPA save vs saveAll 그리고 JDBC Template - 성능비교 + JPA 트랜잭션 위주

Jang990·2023년 7월 29일
2

Insert 성능 개선

목록 보기
1/3
환경
DB: MySQL
Test: Junit5
Spring, JPA 사용

프로젝트를 진행하면서 단 건의 Client 엔티티가 아닌 다수의 Client를 한 번에 저장할 일이 생겼습니다. 이런 다수의 데이터를 저장하는 일은 JPA를 사용해도 되고 JDBC Template을 사용해도 됩니다.

이 글에서는 10,000건의 Client 데이터를 저장한다고 가정하겠습니다.

JPA를 활용하기

이런 다수의 엔티티를 저장하는 일은 JPA에서 2가지로 해결할 수 있습니다.

  1. save()를 활용하여 client를 루프문을 돌며 저장하기.
  2. 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건의 데이터를 저장하는 시간이 같지 않을까요?

save() 사용

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의 시간이 소요되었습니다.

saveAll() 사용

이번에는 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번 덜 실행되게 되는 것입니다.



그러면 다시 본론으로 돌아와서 savesaveAll은 어떤 측면에서 성능의 차이가 발생했을까요?
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


JDBC Template을 활용하기

성능은 개선이 되었지만 더 개선할 여지가 있습니다.
MySQL을 사용하는 서버에서 JPA는 기본키 생성 방식을 IDENTITY를 사용하면 Batch Insert를 지원하지 않습니다.

IDENTITY를 사용하면 Batch Insert를 지원하지 않는 자세한 이유는 다음 글들에서 다루도록 하겠습니다.
이 글에서는 JdbcTemplate을 썼을 때 성능이 얼마나 개선되는지를 다루겠습니다.

Batch Insert를 위해 JDBC를 직접 사용해서 쿼리를 날려보겠습니다.

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 : 출력할 쿼리 길이 설정합니다.

insert 쿼리 만들기

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();
            }
        });
    }
}

JDBC Template 사용

	@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

참고

인프런 - 스프링 DB 2편 - 데이터 접근 활용 기술

profile
공부한 내용을 적지 말고 이해한 내용을 설명하자

0개의 댓글