JPA : 대용량 데이터 Insert 최적화

건 초이·2025년 1월 24일

JPA

목록 보기
3/3
post-thumbnail

개요 :

JPA가 대용량 데이터 insert 측면에서, 다른 jdbc 나 Mybatis에 비해 느리다는 점을 알게되어 최적화를 해보았고, jdbc를 이용하면 얼마나 빠른지 테스트 해보았습니다.

테스트 데이터 : user 데이터 10만 개 insert


목차

0단계 최적화(jpa/identity 전략)
0단계 : 10만개를 최적화 요소 없이 개별 insert
0-1단계 : 0단계에서 메서드 외부에 @Transactional을 붙임
0-2단계 : 0-1단계에서 save() → saveAll()로 변경

1단계 최적화(jpa/sequence 전략 + batch)
identity 전략에서 sequence 전략으로 변경+ batch size 조절(1 ~ 1000)

2단계 최적화 (jdbc + batch)
jpa가 아닌 jdbc로 batch 작업 처리

2.5단계 최적화 (jdbc + multi-value insert = bulk insert)
jdbc로 multi-value insert(bulk) 연산으로 처리


성능 테스트 결과 :

0단계 : jpa / identity 전략/ 개별 insert / 소요 시간 : 10초 ~10.5초
0-1단계 : 0단계에서 @Transactional 추가/ 소요 시간 : 6.0초~6.2초
0-2단계 : 0-1단계에서 save() → saveAll()로 변경/ 소요 시간 : 4.5초 ~ 4.8초
1단계 : jpa/ identity → sequence 변경 / batch insert
-batch size 1 : 소요 시간 : 1.7초~1.8초
-batch size 1000 : 소요 시간 : 1.08초~1.15초 (평균 1.1초)
2단계: (jpa → jdbc/batch size 1000) / 소요 시간 : 0.91초~0.98초 (평균 0.94초)
2.5단계: (jpa → jdbc/multi-value insert) / 소요 시간 : 0.80초~0.88초 (평균 0.83초)

아직..
1단계, 2단계, 2.5단계 간 성능 격차가 10만개로 비교하기에는 부족해서
조금 데이터 개수를 100만개까지 늘려서 비교했습니다.



최적화 0 단계 :

10만개를 개별 쿼리로 insert한다.
identity 전략 + save() 개별 호출
= 아무런 최적화 요소를 넣지 않음..

유저를 저장하는 createUser 메서드

`save()에 쓰이는 createUser 메서드`
private User createUser(int i) {
	User user = User.create("user" + i,
                "email" + i + "@email.com",
                "password" + i,
                "nickName" + i
	);
	return userRepository.save(user);
  
  `여기서 save() 메서드가 호출된다!`
}

0단계 코드

//V1 
@Override
public void run(ApplicationArguments args) throws Exception {
	long startTime = System.currentTimeMillis();
	for (int i = 0; i < 100000; i++) {
		User user = createUser(i);
  	}
	long endTime = System.currentTimeMillis();
	System.out.println("Normal Insert V1: " + (endTime - startTime) + " ms");
}
                               
          

10만 개 insert 소요 시간

: 10초 ~ 10.5초

고찰 :

0단계 최적화는 identity 전략 + save()를 10만 번 호출한다..
비효율적인 부분은 save() 메서드는 내부적으로 @Transactional이 있다.

@Transactional
public <S extends T> S save(S entity) {
	Assert.notNull(entity, "Entity must not be null");
    if (this.entityInformation.isNew(entity)) {
    	this.entityManager.persist(entity);
        return entity;
    } else {
    	return this.entityManager.merge(entity);
   	}
}  

기본 트랜잭션 전파 속성은 REQUIRED로 외부의 트랜잭션이 없다면 생성하고,
있다면 외부 트랜잭션을 이어 받는데..

외부에 트랜잭션이 없다보니 save()마다 개별적으로 트랜잭션을 생성한다.
= 10만개의 save() 메서드 호출 시, 총 10만 개의 트랜잭션을 생성한다..

개선점 : 그렇다면 트랜잭션을 외부에 걸어보자.
트랜잭션 전파 속성에 의해서 전체 트랜잭션은 외부에 걸린 트랜잭션 1개만 존재하게 될 것이다. 이렇게 하면 트랜잭션의 개수를 1개로 줄일 수 있다.
트랜잭션을 10만개에서 1개로 줄이기!


최적화 0-1 단계:

개선점 : 0단계에서 추가로 외부에 @Transactional 붙인다.
identity 전략 + save() 개별 호출 + @Transactional 추가
트랜잭션 전파 속성(REQUIRED)에 의해서..save()마다 트랜잭션을 열지 않는다!

//V2 
@Override
@Transactional ← `이것을 추가 함`
public void run(ApplicationArguments args) throws Exception {
	long startTime = System.currentTimeMillis();
	for (int i = 0; i < 100000; i++) {
		User user = createUser(i);
  	}
	long endTime = System.currentTimeMillis();
	System.out.println("Normal InsertV2: " + (endTime - startTime) + " ms");
}

10만 개 insert 소요 시간

: 6.0초~6.2초

고찰 :

0단계 + save() 메서드 외부에 @Transactional을 붙였다.
각 save() 메서드 호출마다 트랜잭션을 새로 열지 않게 돼, 성능이 향상 됐다.
= 외부의 트랜잭션에 참여하면서 전체의 트랜잭션은 1개만 열리게 된다.

개선할 요소 : save()가 10만 번 호출될 때마다 외부 트랜잭션에 참여하는 작업과
프록시 오버헤드가 반복적으로 발생하여 성능 저하가 있다!

= saveAll() 메서드를 통해 외부 트랜잭션에 참여할지/새로 생성할지 확인하는 작업을
1번으로 줄일 수 있지 않을까?

아래 코드 참조해주세요!

외부 트랜잭션에 참여할지/새로 생성할지를 확인하는 작업이 필요하다라...
= 프록시 오버헤드

프록시 동작 관련..
@Transactional
public void saveUsers() {
    // Case 1: 외부 빈의 save() 호출 (프록시 동작 O)
    userRepository.save(user1); // 외부 트랜잭션 참여! = 프록시 오버헤드
    userRepository.save(user2); // 외부 트랜잭션 참여! = 프록시 오버헤드
    
    // Case 2: 내부의 saveAll() 호출 (내부 save()의 프록시 동작 X)
    userRepository.saveAll(users); // 트랜잭션 참여 1번!
  
   `2번의 save가 아니고 10만 번이었다면? 10만 번의 save()마다 
    외부 트랜잭션에 참여하는 프록시 오버헤드가 존재하여 되게 비효율적이다!!`
    
}
   

최적화 0-2단계 :

save() → saveAll()로 변경한다.
saveAll() 사용시에는 프록시 오버헤드가 한 번.. 반복적인 프록시 오버헤드가 없다.

프록시 오버헤드
= 프록시 체크 + AOP 체인 탐색 + 리플렉션 메서드 호출 + 트랜잭션 동기화 관리 등
save()는 10만번.. saveAll()은 1번

saveAll에 있는 내부 save() 메서드 호출에서는 프록시 체크가 일어나지 않는다..!
save-all

0-2단계 코드

//V3 (id 생성 전략 <identity> 기준)
@Override
@Transactional
public void run(ApplicationArguments args) throws Exception {
	List<User> users = new ArrayList<>();
	long startTime = System.currentTimeMillis();

	for (int i = 0; i < 100000; i++) {
    	User user = createUserV2(i);
    	users.add(user);
	}
	userRepository.saveAll(users);
  
	long endTime = System.currentTimeMillis();
	System.out.println("Normal InsertV3 Time: " + (endTime - startTime) + " ms");
}
                                             

10만 개 insert 소요 시간

: 4.5초 ~ 4.8초

고찰

identity 전략 자체의 한계가 있다.
identity 전략은 ID를 미리 할당할 수 없고, ID를 알기 위해서는
즉시 DB를 갔다 와야 한다는 한계가 존재한다.
→ 이로 인해 Batch Insert 작업이 되지 않는다. 또, JPA의 중요 기능인
**쓰기 지연**도 되지 않는다.

개선점 :
전략을 identity → sequence로 변경하여, ID를 DB에서 미리 batch size만큼 가져오면 되지 않을까? 그렇게 되면 batch insert가 가능해져 성능 개선이 될 것으로 보인다.


최적화 1단계 : 드디어 batch insert

ID 생성 전략 identity → sequence로 변경
= ID를 미리 할당받을 수 없어 한계가 많던 identity에서 sequence로 변경

변경된 부분

--- --- --- --- --- --- --- --- 
`saveAll을 위한 createUser 메서드`
batch, bulk 처리를 위해 List에 user 객체를 모았다가 insert하기 위해서..
private User createUserV2(int i) {
	return User.create("user" + i,
		"email" + i + "@email.com",
        "password" + i,
        "nickName" + i
	);
}

1단계 코드

@Override
@Transactional
public void run(ApplicationArguments args) throws Exception {
	List<User> users = new ArrayList<>();
	long startTime = System.currentTimeMillis();

	for (int i = 0; i < 100000; i++) {
    	User user = createUserV2(i);
    	users.add(user);
	}
	userRepository.saveAll(users);
  
	long endTime = System.currentTimeMillis();
	System.out.println("Batch Insert(batchSize=?) Time: " + (endTime - startTime) + " ms");
}

identity → sequence로 변경

@SequenceGenerator(
    name = "USERS_SEQ_GENERATOR",         // Hibernate가 참조할 시퀀스 이름
    sequenceName = "USER_SEQ",           // DB에 실제 생성된 시퀀스 이름
    initialValue = 1,                    // 시퀀스 초기 값
    allocationSize = 1000     // Hibernate가 한 번에 가져올 시퀀스 크기
)
public class User extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "USERS_SEQ_GENERATOR")
    private Long id;       
                                             
	`ID 생성 전략을 sequence로 바꾼다.`                                             

10만개 1.7초~1.8초 (batch size 1일 때)
batch size가 1
10만개 1.08초 ~ 1.15초 (batch size가 1000일 때)
batch size가 1000

batch query 확인

중요!
아래 사진과 같이 insert 쿼리는 개별로 나간다
= multi-value insert가 아니다!
= 1000개 씩 모아서 batch 처리한 것이다.

batch insert는..

쉽게 설명하면 1000명의 유저 데이터를 저장할 때, 1명의 유저당 insert 쿼리가 1개이다.
1000명의 유저면 1000개의 insert 쿼리가 나간다. 하지만 1000개의 쿼리를 1번에 모아서(batch)
DB로 전송할 뿐이다!

0단계의 최악의 쿼리는 1개의 insert 쿼리마다 DB로 전송해서 
네트워크 왕복이 1000번 2000번... 일어난다

아래 쿼리 로그를 보면 1개씩 쿼리가 나가는 것을 확인할 수 있다.


최적화 2단계 :

jpa batch insert → jdbc batch insert로 변경
= JPA의 오버헤드를 줄여, 로우 레벨의 JDBC로 약간의 성능 향상을 시켜보기 위함
schema.sql(sequence 정의)

CREATE SEQUENCE IF NOT EXISTS USER_SEQ_JDBC
START WITH 1              -- 초기값
INCREMENT BY 1  -- 시퀀스 증가 크기
CACHE 1000;  -- 메모리에 ID 1000개를 캐싱 (1~1000) / (1001~2000) ...

-- 한 번에 ID 1000개씩 DB에서 가져온다! --

batch 코드

@Transactional
public void batchInsertWithJdbc(List<User> users) {

     // 시퀀스에서 ID 일괄 조회 (H2 전용 쿼리)
	List<Long> ids = jdbcTemplate.queryForList(
                "SELECT NEXT VALUE FOR USER_SEQ_JDBC FROM SYSTEM_RANGE(1, ?)",
                Long.class,
                users.size()
     );


    String sql = "INSERT INTO users (id, name, email, password, nick_name) VALUES (?, ?, ?, ?, ?)";

	jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
    	@Override
    	public void setValues(PreparedStatement ps, int i) throws SQLException {
  			User user = users.get(i);
  			ps.setLong(1, ids.get(i));
  			ps.setString(2, user.getName());
  			ps.setString(3, user.getEmail());
  			ps.setString(4, user.getPassword());
  			ps.setString(5, user.getNickName());
    	}

    	@Override
  		public int getBatchSize() {
        	return users.size();
  		}
  	});
}

쿼리 사진
넣기

10만 개 insert 소요 시간 :

: 0.91초 ~ 0.98초 평균 0.94초

bulk-batch? v1


최적화 2.5 단계 :

jdbc batch insert → jdbc multi-value insert로 변경!

jdbc + bulk insert(multi-value insert)
= bulk insert는 multi-value insert를 말하는 것.
= 쿼리가 1개로 나간다!!

@Transactional
public void bulkInsertWithJdbcV2(List<User> users) {

	List<Long> ids = jdbcTemplate.queryForList(
            "SELECT NEXT VALUE FOR USER_SEQ FROM SYSTEM_RANGE(1, ?)",
            Long.class,
            users.size()
    );

    // 배치 크기 설정
    int batchSize = 1000;

    // 배치 크기만큼 나누어 처리
    for (int i = 0; i < users.size(); i += batchSize) {
        int end = Math.min(i + batchSize, users.size());
        List<User> batchUsers = users.subList(i, end);
        List<Long> batchIds = ids.subList(i, end);

        // 각 배치 실행
        StringBuilder sql = new StringBuilder("INSERT INTO users (id, name, email, password, nick_name) VALUES ");

        // SQL 생성
        for (int j = 0; j < batchUsers.size(); j++) {
            sql.append("(?, ?, ?, ?, ?)");
            if (j < batchUsers.size() - 1) {
                sql.append(", ");
            }
        }

        // PreparedStatement 실행
        jdbcTemplate.update(connection -> {
            PreparedStatement ps = connection.prepareStatement(sql.toString());
            int index = 1;
            for (int j = 0; j < batchUsers.size(); j++) {
                User user = batchUsers.get(j);
                ps.setLong(index++, batchIds.get(j));
                ps.setString(index++, user.getName());
                ps.setString(index++, user.getEmail());
                ps.setString(index++, user.getPassword());
                ps.setString(index++, user.getNickName());
            }
            return ps;
        });
    }

}

10만 개 insert 소요 시간 :

:0.80초~0.88초 평균 0.83초

bulk-multi-value-insert v2

multi-value insert 쿼리 확인

multi-value insert의 쿼리는 아래와 같다.
1개의 쿼리에 여러 개의 value가 들어있는 모습이고, batch insert와는 다르다.
multi-value-insert-query


고찰

1단계 jpa batch insert에 비해, 2.5단계 bulk(multi-value insert)가 30% 성능 향상

더 빨라질 수 있나?
= Batch Insert <-> Bulk Insert 간 차이는 MySQL로 바꾼다해도 큰 차이 없을 것.
= 왜냐하면 네트워크 왕복 횟수는 같음..
단지 쿼리를 1개씩 1000개를 보내나, 쿼리 1개에 1000개의 데이터를 보내냐의 차이..

쿼리 쪽에서 성능 최적화 요소를 찾아봐야 할 것 같다.

그리고 MySqlDriver 설정에는 자동으로 batch를 multi-value insert로 최적화를 해준다고 하니 생각보다 성능 차이는 크게 없다고 한다.


의문점...?

첫 번째 :
jpa batch insert와 jdbc multi-value insert 간 성능 차이가 드라마틱하지 않은 이유?
= 내가 잘못된 쿼리를 작성을 한 것인지?
= 원래 그런 건지?
= H2 특성 때문인지?

두 번째 :
sequence 일 시 allocationSize가 1인데도 identity 보다 훨씬 빠른 이유가 뭔지?
= batch size가 1인 것이라, save()마다 insert 쿼리가 나갈텐데..
= 왜냐하면 동일하게 네트워크 왕복을, 10만번 20만번을 할텐데..?


마치며...

단계별로 성능 최적화가 되는 걸 확인했습니다.

하지만, 예상했던 것보다는 성능 최적화가 드라마틱하게 된 느낌은 약간 부족했습니다. 
In-Memory DBH2를 써서 그런 것으로 추측하고 있습니다.

따라서 MySQL로 마이그레이션 한 후에, 다시 이에 대한 분석과 
추가적인 성능 최적화에 대한 고민은 2편에 이어 작성할 계획입니다.

추가로 H2 -> MySQL로 마이그레이션하면 네트워크 왕복 비용이 생기면서 
네트워크 왕복 횟수 차이에 비례해서 성능 차이가 더욱 심해질 것 같습니다.

`0단계의 잦은 네트워크 왕복은 치명적으로 성능 저하가 있으니 대규모 데이터 insert시에는
무조건 batch 처리를 해야 한다고 생각이 들었습니다!`

JPABATCH INSERTJDBC의 Multi-Value insert 간 성능 차이가 생각보다 많이 나지 않았다. 
H2를 사용했기 때문일 수도 있으나, 실제 프로덕션에서도 그렇다고 한다면

**JPABATCH INSERT를 써도 충분할 것 같다는 생각입니다.** 
profile
하면 된다

0개의 댓글