JpaRepository의 save, saveAll과 Batch Insert에 대해서 다뤘었다.
(기존 포스팅은 여기)
https://youngblue.tistory.com/41
JpaRepository save와 saveAll의 차이 그리고 Batch Insert
들어가기에 앞서...토스 Learner's High 서버 1기에 합격하게 되면서, 내가 개선할 수 있는 것들이 무엇이 있을까 고민 해봤다.내가 담당하고 있는 업무에서 그동안 미뤄왔던 부분을 개선하고자 마음
youngblue.tistory.com
오늘은...
실무에서 Batch Insert를 통해 처리성능을 개선시킨 내용을 다루고자 한다.
업무 내용을 노출할 순 없으니 자세하게 어떤 내용인지 소스코드를 공개할 순 없으니
대략적인 예시 코드와 로직의 흐름 정도로 설명 해보고자 한다.
우선 내가 근무중인 회사는 물류에 대한 도메인을 갖고있는 회사인데
그중에 택배사의 물류 이동 정보를 사내 서비스의 데이터에 업데이트 하고 이동 정보를 사용자에게 제공 해주는 서비스가 있다.
이 서비스에 대한 개선기를 이야기 하고자 한다.
꽤나 긴 내용이 되겠다.
어떤 문제가 있었나?
우선 이동 정보에 대한 상태 업데이트를 하고자 할때 크게 2가지의 로직을 타게된다.
내부 서비스를 통해서 callback 방식으로 받게되는 (정확히 말하면 우리 서비스의 api 호출) 데이터를
1. 사용자가 등록한 데이터에 대한 업데이트
2. 이 업데이트 정보를 사용자에게 전송
이부분을 내가 개발했기 때문에 이전부터 여유가 되면 개선해야지 하고 마음을 먹었으나
좀처럼 손이 떨어지지도 않았고 지난 한 해 동안 신규 기능들을 붙이느라 좀처럼 엄두가 나지 않았다.
어떻게 개선해야 할지도 막막하기도 했고.
그러나 최근 우리 서비스를 사용하는 회원사들이 많아지고 물량도 많아지면서 서비스에 적잖은 부담이 쌓이고 있었다.
상태 업데이트 로직 : 0.882s, 전송 데이터 생성(100개) : 10.340s >> Tot : 11.222s
상태 업데이트 로직 : 1.505s, 전송 데이터 생성(100개) : 10.281s >> Tot : 11.786s
상태 업데이트 로직 : 1.249s, 전송 데이터 생성(100개) : 10.662s >> Tot : 11.911s
상태 업데이트 로직 : 1.209s, 전송 데이터 생성(100개) : 10.115s >> Tot : 11.324s
(내부 소스를 공개 할 수 없어 임의로 이름을 변경했다)
처리 시간에 따른 데이터의 한계
보다시피 기존 로직에서는 100개의 전송 데이터를 생성하는데 10초대가 소요됐었다.
이러한 이유로 내부 서비스로부터 데이터를 전달 받을 때 한번에 최대 100개씩 분할로 받도록 처리되어 있었다.
Thread를 통해 병렬 처리도 했었으나 동시성과 데이터 불일치가 걱정되어 제거했다.
하나의 사용자 데이터에 최소 4개 ~ 6개 가량의 상태가 쌓이고 전송 데이터를 생성 하는데.
하루에 적을 때 1만건 ~ 많으면 2만건 정말 많으면 그 이상의 데이터가 등록된다.
여기에 5개 씩만 더해져도... 헤엑
그러다보니 점점 물량이 늘어나고 회원사가 늘어나려면 이부분에 대한 개선이 필요했다.
100개씩이 아니라 200개 300개씩 받아서 처리 할 수 있어야 했다.
save를 saveAll로 개선
먼저 개발한지 꽤나 시간이 지난... 이 메소드를 다시 까보기 시작했다.
어랍쇼?
Hibernate: insert into table (column_1, column_2, column_3, column_4, column_5, column_6, column_7, column_8) values (?, ?, ?, ?, ?, ?, ?, ?)
왜 이렇게 느린가 했더니 save로 하나씩 저장하고 있었다.
그당시에는 엄청나게 고민 할 정도로 많은 데이터가 아니어서 save로 처리하고 넘어갔었다.
그 후에 조금 늘어난 물량을 병렬로 어쩌고 했다가 다시 돌려놨지만...
이제는 save로 처리하기엔 버거운 양들이 들이닥치고 있었다.
1차 개선
1차 개선으로 시도해 본 것은 기존의 jpaRepository.save(entity) 였던 메소드를 jpaRepository.saveAll(list) 방식으로 변경했다.
이전 포스팅에도 설명 했지만 save 는 매번 커넥션을 생성하고 save (insert)를 하고 커넥션을 닫는 방식으로 처리하게 된다.
이렇게 되면 지금처럼 100개 당 10ms 로 잡아도 1000ms를 소비하게 된다.
반면, saveAll로 처리시 커넥션을 최초에 생성하고 save를 iterator 로 반복해서 호출하고 커넥션을 닫는다.
이렇게 되면 100개여도 커넥션은 한번만 맺게 되기 때문에 이에 따른 시간을 많이 줄일 수 있다.
놀 라 운 결 과 !
상태 업데이트 로직 : 0.172s, 전송 데이터 생성(100개) : 3.532s >> Tot : 3.704s
상태 업데이트 로직 : 0.160s, 전송 데이터 생성(100개) : 3.504s >> Tot : 3.664s
상태 업데이트 로직 : 0.166s, 전송 데이터 생성(100개) : 3.438s >> Tot : 3.604s
상태 업데이트 로직 : 0.154s, 전송 데이터 생성(100개) : 3.527s >> Tot : 3.681s
상태 업데이트 로직 : 0.167s, 전송 데이터 생성(100개) : 3.437s >> Tot : 3.604s
save를 saveAll로 변경하기만 했는데
10.349s (save 평균) => 3.487s (saveAll 평균) - 66.3% 라는 엄청난 개선 효과가 있었다.
확실한 개선에 신이난 주인장
하지만 나는 여기서 만족할 수 없었다.
1초대로 만들고 싶었다.
JdbcTemplate Batch Insert (부제: 2차 개선)
대규모 물량 업체 이야기가 오가는 와중에 난 이걸 더 개선하고 싶었다.
당장 눈앞에 급급한 눈가리고 아웅 식의 처리만 하고싶진 않았다.
때마침 바쁜 일정이 연말을 맞아 대부분 소강상태가 되면서 시간적 여유도 생긴 것도 한 몫 했다.
어쨌든. 난 이걸 반드시 1초대로 만들고 싶었다.
그래야 1000개씩 생성해도 이전 시간만큼이 나오는 10배 처리 성능 개선을 달성하고 싶었다.
이 전체적인 처리 시간에 전처리 작업도 있지만 이 전처리 작업이 왜 있냐! 하면
자체적으로 중복 데이터를 만들지 않게 하기 위해 전처리 작업과 DB 테이블에 여러 제약 조건들이 걸려 있었다.
여기에서 중복 데이터를 쌓지 않기 위해 쿼리로 조회 하는 부분이 들어가고,
DB 테이블 자체에도 제약 조건이 들어가있었기 때문에 만약에라도 스킵되고 exception이 발생하면
단순히 batch Insert로 했다가는 처리에 대한 보장을 할 수 없었다.
다음과 같은 처리를 진행했다.
1. 중복체크 전처리 과정 간소화
2. DB 테이블의 제약조건 해제
3. 해제한 제약조건을 트리거를 통해 중복 값이 있다면 return null로 exception 을 뱉지 않고 저장되지 않도록 처리.
뭐 물론 이 트리거로 인해 시간이 조금 소요될 순 있겠지만 내가 원하는 과정으로 진전 시키기 위해서는 다른 방도가 없었다.
이후 JdbcTemplate의 batchUpdate를 통해 진행했다.
상태 업데이트 처리 : 0.168s, 전송 데이터 생성(100개) : 1.626s >> Tot : 1.794s
상태 업데이트 처리 : 0.168s, 전송 데이터 생성(100개) : 1.615s >> Tot : 1.783s
상태 업데이트 처리 : 0.158s, 전송 데이터 생성(100개) : 1.538s >> Tot : 1.696s
상태 업데이트 처리 : 0.162s, 전송 데이터 생성(100개) : 1.750s >> Tot : 1.912s
상태 업데이트 처리 : 0.165s, 전송 데이터 생성(100개) : 1.527s >> Tot : 1.692s
아니 이럴수가?
평균 1.611s 대로 진입했다!!!
saveAll 대비 해서도 -53.7%
기존 소스 대비 -84.4% 라는 처리시간 단축을 일궈냈다.
만세!
3차 개선 (부제 : 끝나지 않은 개선)
슬슬 여기까지 오니까 오기가 생겨서 더 개선하고 싶었다.
위에서 말했던 전처리 과정을 조금만 개선하면 더 줄일 수 있지 않을까? 했다.
왜냐하면 저장 자체는 굉장히 빨라졌는데 전처리 과정을 무조건 거치기 때문에 여기를 개선하지 않으면 시간을 단축할 수 있는 한계가 있었다.
코드를 잘~ 살펴보다보니 어? 이거 줄일 수 있겠는데? 했다.
전처리 과정에서는 중복 체크, 하나의 서비스 건에 대해 내부적으로 시퀀스를 따라 순차적으로 순번 부여, 사용자마다 설정을 달리 할 수 있었기 때문에 해당 설정 정보를 가져오는 등 여러가지 과정이 있었다.
최소 3~5개 정도의 전처리 과정에서 쿼리가 발생했는데 하나당 3~5ms 정도가 발생했다.
여기를 줄여보고자 했다.
우선 중복체크와 시퀀스 부여하는 부분을 각각 따로 쿼리를 날리는게 아니라 entity 조회로 한번만 가져와서 필요에 따라 stream 메소드를 통해 필요한 데이터를 뽑아 썼다.
하나의 서비스 건에 대해 많은 데이터가 아닌 5개 전후로 존재하기 때문에 각각의 쿼리 발생으로 소요되는 시간보다
한번만 조회해서 가지고 있는 데이터를 뽑아 쓰는 것이 빠르다는 판단이었다.
이렇게 하니 개당 약 4~5ms 정도를 줄일 수 있었다.
전처리 개선 후
상태 업데이트 처리 : 0.166s, 전송 데이터 생성(100개) : 1.203s >> Tot : 1.369s
상태 업데이트 처리 : 0.157s, 전송 데이터 생성(100개) : 1.211s >> Tot : 1.368s
상태 업데이트 처리 : 0.184s, 전송 데이터 생성(100개) : 1.238s >> Tot : 1.422s
상태 업데이트 처리 : 0.212s, 전송 데이터 생성(100개) : 1.370s >> Tot : 1.582s
상태 업데이트 처리 : 0.167s, 전송 데이터 생성(100개) : 1.227s >> Tot : 1.394s
평균 1.249s 로 더 줄일 수 있었다!!!!
얏-호!
기존 (save) | 1차 (saveAll) | 2차 (batch Insert) | 3차 (전처리 개선) | |
처리시간 | 10.349s | 3.487s | 1.611s | 1.249s |
기존 대비 처리시간 | -66.3% | -84.4% | -87.9% |
마무리
여기까지 오면서 batch Insert의 쿼리가 제대로 날아가지 않거나,
Entity의 Id를 SEQUENCE 로 지정했는데 pk이다보니 중복 값은 들어갈 수 없고, Id가 max(id) 다음부터 +1 씩 되어서 들어가야 했는데
기간이 지나 삭제한 데이터 사이의 번호를 따서 들어가면서 중복값으로 exception이 나는 등 여러 시행착오를 겪었다.
그러나 해결 방법을 결국 찾고 간만에 이악물고 집요하게 물어 뜯은 결과가 나타나니까 기분이 좋았다.
아직도 개선해야 할 부분들은 시스템 내부에 많이 남았지만
그동안 어떻게, 어디를 개선해야 할지 막막하고 덮어두었던 부분을 이번 기회에 각잡고 해볼 수 있어서 좋았다.
앞으로 늘어나는 물량에 대비할 수 있게 되었다.
참고
https://www.baeldung.com/spring-jdbc-batch-inserts
'백엔드 > Spring' 카테고리의 다른 글
@Transacntional 의 옵션들 (0) | 2025.04.01 |
---|---|
JpaRepository save와 saveAll의 차이 그리고 Batch Insert (0) | 2025.01.05 |
[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 |