spring batch를 통해 데이터를 가공하고 저장하는 업무가 있었다.
당시 나는 for문을 통해 단순히 한 건마다 save를 진행해서 코드를 작성했는데 생각보다 처리 시간이 너무나 오래 걸려 이를 최적화할 방법을 찾다 saveAll을 통해 어느정도 시간을 단축한 경험이 있다.
당시에는 그냥 saveAll을 쓰니 단축이 되네? 정도로만 하고 넘어갔지 실제 이유는 모른채 넘어갔기에 이참에 정리해보려한다.
@Transactional
@DisplayName("save 성능")
@Test
void saveTest(){
//Given
long start = System.currentTimeMillis();
//When
for(int i = 0; i < 1000000; i++) {
Member member = new Member("username" + i, i);
memberRepository.save(member);
}
//Then
long end = System.currentTimeMillis();
System.out.println("실행 시간 : " + (end - start));
}
@Transactional
@DisplayName("saveAll 성능")
@Test
void saveAllTest(){
//Given
long start = System.currentTimeMillis();
List<Member> members = new ArrayList<>();
//When
for(int i = 0; i < 1000000; i++) {
Member member = new Member("username" + i, i);
members.add(member);
}
memberRepository.saveAll(members);
//Then
long end = System.currentTimeMillis();
System.out.println("실행 시간 : " + (end - start));
}
100만건에 대하여, 각각 save와 saveAll의 시간을 비교해보았다.
결과는 다음과 같았다.
"실행 시간 : 3312
실행 시간 : 1647"
테스트한 컴퓨터의 성능이나 여러 요인에 따라 시간은 달라질 수 있겠지만, 분명한 건 saveAll이 유의미하게 실행 시간이 빠름을 알 수 있다.
그렇다면 왜 그럴까?
save와 saveAll의 코드를 뜯어보자.
@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (entityInformation.isNew(entity)) {
entityManager.persist(entity);
return entity;
} else {
return entityManager.merge(entity);
}
}
우선 이건 save의 코드이다.
@Transactional이 붙어있다.
전에 포스팅에서 @Transactional에 대해 간단히 포스팅을 한 내용을 가져와보자면,
public class TransactionProxy {
private MemberService target;
public void logic() {
TransactionStatus status = transactionManager.getTransaction(...);
try {
target.logic();
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw new IllegalStateException(e);
}
}
}
@Transactional은 프록시를 활용한다.
프록시에서 transaction을 가져와서, 프록시가 가리키는 실제 대상에 로직을 호출하고 commit하거나 rollback을 처리해준다.
그렇다면, 위의 save 코드는 @Transactional이 붙어있기 때문에,
프록시에서 getTransaction()을 호출해서 가져와야한다.
save() 호출을 했을 때 상위에 Transaction이 존재한다면, 해당 Transaction에 참여하고,
존재하지 않으면 새로 Transaction을 생성할 것이다.
결국은 프록시를 통해 거쳐서 진행해야 하기 때문에, 비용이 발생하고 그렇기 때문에 시간이 더 오래 발생한다.
이제 saveAll()을 봐보자.
@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;
}
result.add(save(entity)) 부분이 핵심인 듯 싶다.
해당 코드의 save는 위에 언급한 save코드이다.
그렇다면 로직은 똑같아보이는데? 싶은 의문이 든다.
하지만 잘 생각해보자.
저기의 save는 다시 말하면 this.save이다. 즉, 같은 인스턴스에서 내부 호출을 하고 있기 때문에 @Transactional이 적용되지 않는다.
정리해보자.
save()의 흐름
if 기존 트랜잭션이 존재하는 경우 :
- save() 호출 시 기존 트랜잭션에 참여
- @Transactional을 사용하기에, 프록시 로직을 사용함.
- 프록시 로직에 참여하는 resource가 들게 됨.
else 기존 트랜잭션이 없는 경우 :
- 트랜잭션을 새로 생성하고 종료한다.
- 그렇기에 자연스레 resource가 들게 됨.
saveAll의 흐름
if 기존 트랜잭션이 존재하는 경우 :
- saveAll() 호출 시 기존 트랜잭션에 참여
- saveAll()에서 save()를 호출하지만 이는 this.save(), 즉 프록시가 아닌 같은 인스턴스에서 내부 호출을 진행한다.
else 기존 트랜잭션이 없는 경우 :
- 트랜잭션을 하나 새로 생성하고, save()를 여러 번 호출하지만 마찬가지로 같은 인스턴스에서 내부 호출하기에 프록시 로직을 타지 않는다.
'스프링 정리' 카테고리의 다른 글
Path Variable vs Query Parameter (0) | 2023.11.19 |
---|---|
왜 생성자 주입이 @Autowired(필드 주입)보다 좋을까? (0) | 2023.11.07 |
테스트코드에서의 @Transactional (0) | 2023.10.30 |
Spring Batch - @PersistJobDataAfterExecution (0) | 2023.04.04 |
Spring Security & JWT(Json Web Token) 활용 예제 (0) | 2023.03.28 |