Spring Data JPA Save(insert) 속도 최적화

Spring Data JPA Save(insert) 속도 최적화

대량의 데이터를 삽입하는 상황이 생겼습니다.

초창기에는 JPA Save함수를 반복문을 통해 호출해서 저장하게 구현을 했는데요.

처음에는 괜찮았으나, 삽입 할 데이터가 점점 많아지면서 시간이 굉장히 오래 걸리더라고요.

그래서 최적화에 신경 쓰게 되었습니다.

최적화하는 방법이 많이 있겠지만, 이번 글에서는 트랜젝션을 이용한 최적화 방법에 대해서 알아보겠습니다.

1. 잦은 save 함수 호출의 문제점

@Transactional @Override public S save(S entity) { if (entityInformation.isNew(entity)) { em.persist(entity); return entity; } else { return em.merge(entity); } }

save함수 소스코드입니다. 기본적으로 함수에 @Transactional이 선언되어있는 것을 볼 수 있습니다.

Controller 또는 Service 계층에서 별도의 @Transactional이 선언되어 있지 않다면, 이 save함수가 호출될 때 트랜잭션이 시작됩니다.

그리고, 데이터베이스에 데이터가 정상적으로 삽입이 되면 트랜잭션이 종료됩니다.

만약 10만 개의 데이터를 삽입한다고 가정한다면, 10만 번의 save함수가 호출이 되겠고,

이는 10만번의 트랜잭션이 시작됐다가 종료된다는 뜻입니다.

즉, save 함수를 호출할 때마다 트랜잭션이 실행되기 때문에, 이에 대한 오버헤드로 삽입 속도가 느려지게 됩니다.

2. @Transactional VS saveAll 함수

최적화 방법은 간단합니다. 여러 번 호출되는 트랜잭션을 하나로 묶는 것이죠.

Controller 혹은 Service 구현부에 @Transactional을 선언하거나, 삽입할 데이터들을 List로 묶어서 saveAll 함수를 호출하는 것입니다.

위 둘의 공통점은 save함수보다 더 상위에 @Transactional가 호출된다는 것이고,

@Transactional의 기본 전파 전략은 REQUIRED입니다. 이 전략은 트랜잭션이 없을 경우 새로 생성하고, 이미 시작된 트랜잭션이 있다면 기존 트랜잭션에 참여하는 전략입니다.

즉, save함수 상위에 @Transactional이 선언되어있지 않다면, 이미 시작된 트랜잭션이 없기 때문에 save 함수가 호출될 때마다 계속 트랜잭션이 시작되고 종료되는 것입니다.

차이점은 @Transactional의 위치입니다. 더 상위에 있냐, 하위에 있냐 그 차이입니다.

예를 들어, Controller에 1만 개의 데이터를 삽입하고, 특정 데이터는 다시 가공하여 업데이트를 하고, 특정 데이터의 일부를 지우는 API가 있다고 가정해봅니다.

만약 이 API에 @Transactional를 선언하게 되면, 일련의 모든 과정들이 하나의 트랜잭션으로 묶이게 됩니다.

하나의 트랜잭션으로 묶이게 되면 트랜잭션 시간이 길어지게 되며, 해당 시간 동안 다른 곳에서 데이터에 접근할 수 없기 때문에 딜레이가 발생합니다. 그리고 API 로직이 복잡해진다면 데이터 관리가 어려워집니다.

이러한 문제는 전파 전략(propagation)과 격리 수준(isolation) 설정을 통해 해결할 수 있습니다.

물론 시스템 구조상 하나의 트랜잭션으로 묶는 경우도 있습니다. 결제와 같은 중요한 시스템에서는요.

하지만 굳이 트랜잭션을 하나로 묶어도 되지 않는다면 이러한 경우는 피하는 게 좋습니다.

@Transactional @Override public List saveAll(Iterable entities) { Assert.notNull(entities, "Entities must not be null!"); List result = new ArrayList(); for (S entity : entities) { result.add(save(entity)); } return result; }

위는 saveAll 함수의 소스코드입니다. 루프를 돌면서 반복적으로 save함수를 호출하는 단순한 코드입니다.

위처럼 Controller에 @Transactional을 선언하는 대신 saveAll 함수를 호출한다면 아래와 같은 그림으로 동작합니다.

그럼 @Transactional와 saveAll 둘 중 어떤 것을 사용해야 할까요?

우선 성능이 같기 때문에 둘 중 더 좋은 건 없습니다.

트랜잭션에 대해 정확히 이해하고 API의 기능과 트랜잭션 설계에 따라 알맞게 사용하는 것이 가장 베스트입니다.

3. 데이터가 많아질수록 트랜잭션이 길어진다.

트랜잭션을 하나로 묶어서 대량의 데이터를 삽입하는데 속도가 굉장히 빨라졌습니다.

30만 개의 데이터를 삽입하는데 20분에서 10분으로 줄었습니다.

그러나 큰 문제는 30만 개의 데이터가 삽입이 끝날 때까지 하나의 트랜잭션이 끝나지 않고 긴 시간 동안 유지되는 겁니다.

데이터 삽입하는 동안에 이 데이터로 접근이 필요 없다면 트랜잭션이 길어져도 상관없겠지만, 보통은 그렇지 않기 때문에 트랜잭션이 길게 유지되어 다른 곳에서 접근이 못한다면 이 또한 문제가 되겠죠.

격리 수준을 낮추는 방법이 있겠으나, 이는 여기서 다루진 않겠습니다.

가장 간단한 방법은, 아래 코드와 같이 일정 단위로 묶어서 saveAll 함수를 호출하는 것입니다.

public void saveAllWithDivide(List list) { List tmp = new ArrayList(); list.forEach(i -> { tmp.add(i); if (tmp.size() == 100) { repo.saveAll(tmp); tmp.clear(); } }); }

30만 개의 데이터를 100개 단위로 트랜잭션을 구성하여 삽입을 한다면, 총 3,000번의 트랜잭션이 발생하여 약간 삽입 성능이 감소되겠지만, 트랜잭션이 계속 유지되고 있지 않기 때문에 다른 곳에서 데이터 접근이 가능합니다.

4. 성능 비교

10,000개의 데이터를 4개의 테스트 방식으로 나눠서 삽입했을 때 걸린 시간입니다.

Test 1: @Transactional 선언 없이 save 함수 호출

1차:217.496초

2차:224.056초

Test 2:@Transactional 선언 후 save 함수 호출

1차:21.370초

2차:21.574초

Test 3: saveAll 함수 호출

1차:20.457초

2차:21.006초

Test 4: 100개 단위로 나누어서 savaAll 함수

1차:24.559초

2차:24.251초

from http://2dongdong.tistory.com/29 by ccl(A) rewrite - 2020-03-13 00:54:27

댓글

이 블로그의 인기 게시물

2020 LCK 롤챔스 Spring 경기 재개 및 일정

데이터 바인딩 추상화 - propertyEditor

Spring Web Form