들어가기에 앞서...
토스 Learner's High 서버 1기에 합격하게 되면서, 내가 개선할 수 있는 것들이 무엇이 있을까 고민 해봤다.
내가 담당하고 있는 업무에서 그동안 미뤄왔던 부분을 개선하고자 마음 먹었다.
서비스가 점점 확장 되어가면서 처리량은 늘고 있는데 내부 서비스에서 우리팀 서비스로 넘어오는 데이터에
병목현상이 생겨 강제로 데이터 갯수 제한을 걸어서 받고 있었다.
아직까지는 괜찮지만 점점 이용자수가 늘고 처리 해야하는 데이터가 많아지고 있으니 개선하지 않으면 추후 서비스 확장에 있어서 큰 걸림돌이 될 것 같았다.
그래서 이부분을 개선해보기로 마음 먹었다.
기존 JpaRepository 를 사용해서 save 하던 부분을 batch Insert 를 통해 처리 시간을 줄여보고자 하는 목적이었다.
결론부터 말하자면, 비즈니스 로직이 존재하기 때문에 그 부분은 더이상 처리하기 어려웠으나 그 부분을 제외하면 원래 목적이었던 save -> batch insert 를 통해서는 상당한 개선을 얻을 수 있었다.
다음은 내가 직접 단순한 entity를 saveAll과 Batch Insert를 통해 해본 결과다.
saveAll | batch Insert | |
80개 | 422ms | 37ms |
300개 | 1,578ms | 47ms |
1,000개 | 5,504ms | 72ms |
직접 테스트 해보고 기존의 소스코드를 개선 시킬 수 있다는 확신이 들었기에 진행할 수 있었다.
자 그렇다면 들어가보자.
Batch Insert란 무엇인가?
Batch Insert 를 사용하게 되면 많은 데이터를 저장할 때 단건 Insert에 비해 한번에 쿼리를 일으키면서
Connection을 맺고 쿼리를 처리하고 다시 Connection을 종료하는 반복문에 비해 처리성능과 소요시간에 대한 이점을 가져가게 된다.
즉, 한번에 모아서 처리할 수 있도록 해준다.
단건 insert
다음과 같은 간단한 JPA Entity가 있다고 가정하자.
...
@Entity
@Table(name = "memo")
@Builder
public class Memo {
@Id
private String id;
private String title;
private String content;
}
...
Memo 라는 Entity는 id와 title, content 필드를 갖는다.
이때 해당 Entity를 DB에 저장하게 될 경우 JpaRepository interface에서 제공하는 save 메소드를 사용하게 되면,
단건 insert를 실행 하게 된다.
public interface MemoRepository extends JpaRepository<Memo, String> {}
...
Memo newMemo = Memo.builder()
.title("save테스트")
.content("jpaRepository를 통해 save")
.build();
memoRepository.save(newMemo);
## 단건 insert
insert into memo (id, title, content) valuse (?, ?, ?);
만약 저장하려는 데이터가 1개가 아니라 10개, 100개, 1만개, 10만개가 된다고 가정해보자.
이러면 어떠한 문제가 발생할까?
반복문을 통해서 10만개의 Memo 를 저장한다면, 단건 insert의 경우 한건을 저장할 때마다 db connection을 맺고,
저장하고, connection을 끊고 를 10만번 반복하게 될 것이다.
이를 개선하기 위한 방법으로는 JpaRepository에서 제공하는 SaveAll() 메소드가 있다.
JpaRepository의 saveAll()
save와 saveAll은 어떤 차이가 있을까?
Memo 를 10만개 저장하는 다음과 같은 코드가 있다고 하자.
(유효성 검증은 잠시 생략)
// save
int max = 100000;
for(int i = 0; i < max; i++) {
memoRepository.save(Memo.builder()
.title("save" + i)
.content("content" + i)
.build());
}
// saveAll
int max = 100000;
List<Memo> list = new ArrayList<>();
for(int i = 0; i < max; i++) {
list.add(Memo.builder()
.title("saveAll" + i)
.content("content" + i)
.build());
}
memoRepository.saveAll(list);
save의 경우 10만개를 반복해서 저장하고
saveAll의 경우 10만개를 List에 담아 넘긴다.
save가 매번 호출하는데에 반해
saveAll의 경우 List를 돌며 save를 호출한다.
Spring 문서에서는 다음과 같이 기재되어있다.
Iterable 에서 알 수 있듯 인자로 넘긴 List를 돌며 save를 호출한다.
즉 하나의 connection 에서 save를 반복 호출 하는 방식이다.
아래는 실제 자바 프로젝트에서 따라가보면 확인 할 수 있다.
JpaRepository는 CrudRepository를 상속 받고 있기 때문에 그 안으로 들어가면 다음과 같은 내용이 기재되어 있다.
save에 비해 connection을 맺고 끊는 시간이 10ms라고 쳤을 때 10만 * 10ms = 1,000,000ms = 1,000s 를 절약할 수 있는 셈이다.
물론, 데이터가 10개 정도로 적다면 100ms로 크게 차이가 없을 수 있다.
그러나 데이터가 많아질수록 그 차이는 기하급수적으로 늘어난다.
데이터 저장 소요시간은 동일하고 connection에 대한 소요시간도 고정이라고 하자.
데이터 갯수 | 저장 소요시간 | 소요시간 | save | saveAll | 차이 |
10개 | 5ms | 10ms | 50ms + 100ms = 150ms |
50ms + 10ms = 60ms |
90ms |
100개 | 5ms | 10ms | 1,500ms | 510ms | 990ms |
1,000개 | 5ms | 10ms | 15,000ms | 5,010ms | 9,990ms |
10,000개 | 5ms | 10ms | 150,000ms | 50,010ms | 99,990ms |
100,000개 | 5ms | 10ms | 1,500,000ms | 500,010ms | 999,990ms |
1,000,000개 | 5ms | 10ms | 15,000,000ms | 5,000,010ms | 9,999,990ms = 약 10,000s = 약 166.6시간 (-66.6%) |
그렇다면 Batch Insert를 쓰면 얼마나 차이가 날까?
Batch Insert의 사용
Batch Insert와 옵션을 사용하면 멀티 Insert를 사용할 수 있다.
## 멀티 Insert
insert into memo (id, title, content)
values (?, ?, ?,),
(?, ?, ?,),
(?, ?, ?,),
(?, ?, ?,),
(?, ?, ?,);
멀티 Insert를 사용하면 하나의 쿼리문에 여러건의 insert를 처리할 수 있는 것인데.
하나의 트랜잭션으로 묶여 여러개의 동일구문 쿼리를 하나로 만들어준다.
반복해서 쿼리를 실행하는 것보다 역시나 데이터가 많을 경우에 많은 시간 절약이 될 수 있다.
다만 직접 쿼리문을 작성해야 하기 때문에 쿼리가 복잡할수록 유지보수성이 낮아지며 JPA의 장점인 변경감지나 prepersist 같은 어노테이션에 의한 전처리, 또는 후처리 작업 같은 것들이 적용되지 않기 때문에 꼼꼼한 확인이 필요하다.
예를 들자면 Entity의 필드가 추가, 삭제 되는 경우에 쿼리문도 같이 직접 수정 해주어야 한다.
Batch Insert를 사용하기 위한 유의점
1. Entity를 생성할 때 @Id 어노테이션을 쓰면 pk로 지정되는데
이때 Generator 전략을 IDENTITY로 가져가면 JPA의 쓰기지연 특성 때문에 Id 값을 알 수 없어 사용할 수 없다고 한다.
다행히도 내가 적용하고자 하는 실무에서는 SEQUENCE 전략을 사용하고 있었기 때문에 사용하기엔 문제가 없었다.
그런데 이상하게 적용이 안됐을뿐
또한 시퀀스에 대한 별도 로직이 있지 않는한 시퀀스로 동작하려면 Long 형태의 id를 갖게 될 것이다.
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
2. 몇가지 옵션
서버 설정을 위한 어플리케이션 파일에 몇가지 옵션을 주어야 제대로 쿼리가 동작한다.
나의 경우 yml 다음과 같은 옵션을 주었다.
spring:
datasource:
url: jdbc:postgresql://db주소/db명?rewriteBatchedInserts=true
...
jpa:
properties:
hibernate:
order_insert: true
order_updates: true
PostgreSQL 을 사용중이어서 rewriteBatchedInsert=true 로 주었지만
MySQL인 경우 rewriteBatchedStatements=true 로 설정해야 한다고 한다.
order_insert는 말 그대로 쿼리문을 정렬 해주는 기능이다.
이기능과 rewriteBatchedInsert가 합쳐지면
동일구문 쿼리를 하나의 멀티 구문으로 변경해서 실행해준다.
위에서 예시로 들었던 insert 의 경우 동일구문의 쿼리가 반복되기 때문에 위의 옵션을 통해서 최적화가 가능한 것이다.
주요 차이점 비교
특징 | JdbcTemplate | JpaRepository |
SQL 작성 | 직접 작성 | Hibernate가 자동 생성 |
객체 지향성 | 없음(데이터베이스 중심) | 있음 (Entity 중심) |
성능 | 더 빠름 (추가 오버헤드 없음) | 느릴 수 있음 (Hibernate 관리 비용) |
유지보수성 | SQL 의존성 높음 | Entity 기반으로 유지보수 쉬움 |
배치 처리 지원 | 기본적으로 지원 | Hibernate 설정 필요 (batch_size) |
추상화 수준 | 낮음 | 높음 |
대량 데이터 삽입 | 적합 | 배치 설정이 필요하며 다소 비효율적 |
즉, 유지보수성을 낮춰 가면서까지 성능이 중요한 대량 작업에는 JdbcTemplate 을 통한 batch Insert
데이터가 많지 않고 변경 감지 또는 JPA의 장점 or 추가 옵션들을 이용할 경우에는 JpaRepository를 사용하면 되겠다.
실제 어떠한 개선이 되었는지 수치와 함께 다음 포스팅에서 알아보자.
'백엔드 > Spring' 카테고리의 다른 글
@Transacntional 의 옵션들 (0) | 2025.04.01 |
---|---|
Batch Insert를 통한 속도 개선기 (0) | 2025.01.08 |
[JPA] queryDsl cannot find symbol Q class 오류 (0) | 2023.05.11 |
[JPA] but parameter 'Optional[xx]' not found in annotated query. (2) | 2023.05.11 |