본문 바로가기
프로그래밍/JPA, Database

[JPA] findAll, findAllBy, findAllByIn AOP로 성능 테스트해보기

by 카프카뮈 2021. 9. 2.

오늘 회사원 친구와 JPA 이야기를 하다가, 다음과 같은 질문을 받았다.

회사 코드를 보니까, Repository에서 findAll을 한 다음 stream으로 가공하는 코드가 있었다.
그런데 그 코드는, findAllByXXX로 바꾸면 해결되는 간단한 필터 연산 코드였다.
findAll vs findAllByXXX의 시간 효율 차이가 얼마나 날까?

듣고 보니 궁금하기도 하고, 이런 걸 테스트해보고 싶어서 직접 코드를 짜봤다.

 

본 글에서 수행하는 내용은 다음과 같다.

  • AOP로 타이머를 만들어서 서비스에 씌우고, SpringBootTest로 테스트를 실행한다.
  • findAll, findAllBy, findAllByIn 세 가지를 사용할 때 쿼리가 어떻게 나오나 비교해 본다.
  • 각각의 쿼리에 대한 서비스 차원의 수행 시간을 비교해 본다.
  • findAll + stream과 findAllBy의 수행시간 차이를 비교해 본다.

실제로 쿼리 성능 테스트를 한다면, 아무래도 SQL 관련 테스트 툴을 쓰지 AOP를 쓰지는 않을 것 같다.

다만 여기서는 findAll+stream vs findAllBy의 성능차가 궁금해서 부득이하게 AOP를 썼다.


프로젝트 구현

테스트 요약

본 글에서 수행한 테스트는 다음과 같다.

  • 데이터 10, 1000, 10000개에서 findAll에 걸리는 시간
  • 데이터 10, 1000, 10000개에서 findAllByEmail, 즉 단일 요소에 대한 조건-반환에 걸리는 시간
  • 데이터 10, 1000, 10000개에서 findAllByNameIn, 즉 다중 요소(리스트)에 대한 조건-반환에 걸리는 시간
  • 데이터 10000개에서 findAll 이후 stream으로 단일 요소에 대한 필터링을 수행하는 데에 걸리는 시간
  • 데이터 10000개에서 findAll 이후 stream으로 다중 요소에 대한 필터링을 수행하는 데에 걸리는 시간

테스트 결과는 다음과 같다.

  • findAll과 findAllByEmail은 성능차이가 미미했다.
  • findAllByNameIn의 경우에도 미미했으나, 인자로 전달되는 이름 리스트에 10000개를 넣었을 때엔 시간이 기하급수적으로 증가했다.
    • 즉, 인자가 적으면 딱히 findAllByEmail에 걸린 시간과 큰 차이가 없을듯하다.
  • findAll이후 stream을 수행한 경우, 커스텀 메소드보다 좀 더 빨랐다.
    • 그러나 데이터 크기가 지나치게 큰 경우를 테스트해보지 않아 안정성이 보장되지는 않는다.

기본 설정

일단 프로젝트를 만들어 준다.

기본 코드는 내용이 길어 아래처럼 가려두었다.

gradle 설정은 다음과 같다.

plugins {
    id 'org.springframework.boot' version '2.5.4'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-aop'
    implementation 'org.projectlombok:lombok:1.18.18'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    annotationProcessor 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
}

test {
    useJUnitPlatform()
}

application.yml은 다음과 같이 구성했다.

spring:
  h2:
    console:
      path: /h2-console
      enabled: true
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:testdb
    username: sa
  jpa:
    properties:
      hibernate:
        format_sql: true
    show-sql: true
    generate-ddl: true

User와 메인 로직

이제 도메인 코드! 간단한 User 코드를 만들어 준다.

package com.example.jpa_test.domain;

import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
@NoArgsConstructor
@Getter
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String email;

    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }
}

이제 이에 맞는 UserRepository를 만들고, 커스텀된 메소드를 추가한다.

Name을 목록으로 받는 NameIn 메소드와 Email로 검색하는 메소드를 추가했다.

둘 다 인덱싱된 속성은 아니라서, 큰 이슈없이 성능을 확인할 수 있을 것으로 생각했다.

package com.example.jpa_test.domain;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface UserRepository extends JpaRepository<User, Long> {
    List<User> findAllByNameIn(List<String> names);
    List<User> findAllByEmail(String email);
}

UserService도 만들어 준다. 여기에 커스텀 어노테이션을 달아서, AOP를 적용할 계획이다.

package com.example.jpa_test.service;

import com.example.jpa_test.annotation.Timer;
import com.example.jpa_test.domain.User;
import com.example.jpa_test.domain.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;

    @Timer
    @Transactional(readOnly = true)
    public List<User> getUsers() {
        return userRepository.findAll();
    }

    @Timer
    @Transactional(readOnly = true)
    public List<User> getUsersByNames(List<String> names) {
        return userRepository.findAllByNameIn(names);
    }

    @Timer
    @Transactional(readOnly = true)
    public List<User> getUsersByEmail(String email) {
        return userRepository.findAllByEmail(email);
    }
}

AOP

이제 AOP 코드를 추가해 보자. 먼저 AOP에 활용될 커스텀 어노테이션인 @Timer다.

package com.example.jpa_test.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Timer {
}

그러면 AOP 코드를 보자. TimerAop라는 이름으로 구현하였다.

package com.example.jpa_test.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;

@Aspect
@Component
public class TimerAop {
    @Pointcut("@annotation(com.example.jpa_test.annotation.Timer)")
    private void enableTimer() {}

    @Around("enableTimer()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        var stopWatch = new StopWatch();
        stopWatch.start();

        Object result = joinPoint.proceed();

        stopWatch.stop();

        System.out.println("total time is : " + stopWatch.getTotalTimeMillis());

        return result;
    }
}

테스트 시작하기

이제 테스트 코드를 작성해 보자.

UserServiceTest라는 이름으로 구현하였고, @SpringBootTest로 구현하여 컴포넌트 스캔 및 DI가 수행되도록 했다.

package com.example.jpa_test.service;

import com.example.jpa_test.domain.User;
import com.example.jpa_test.domain.UserRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.LongStream;

@SpringBootTest
@Transactional
class UserServiceTest {
    @Autowired
    private UserService userService;

    @Autowired
    private UserRepository userRepository;

    private int count = 1000;
    List<User> userDatas;
    List<String> names;

    @BeforeEach
    void setUp() {
        userDatas = LongStream.range(1, count + 1)
                .mapToObj(number -> new User("이름" + number, "이메일"))
                .collect(Collectors.toList());
        names = userDatas.stream()
                .map(User::getName)
                .collect(Collectors.toList());

        System.out.println("---------------insert---------------");
        userRepository.saveAll(userDatas);
    }

    @Test
    void findAllTest() {
        System.out.println("---------------findAll---------------");
        List<User> users = userService.getUsers();
        Assertions.assertThat(users.size()).isEqualTo(count);
    }

    @Test
    void findAllByNamesTest() {
        System.out.println("---------------findAllByNames---------------");
        List<User> usersByNames = userService.getUsersByNames(names);
        Assertions.assertThat(usersByNames.size()).isEqualTo(count);
    }

    @Test
    void findAllByEmailTest() {
        System.out.println("---------------findAllByEmail---------------");
        List<User> usersByEmail = userService.getUsersByEmail("이메일");
        Assertions.assertThat(usersByEmail.size()).isEqualTo(count);
    }
}

위 코드에서는 데이터 개수를 정해서 각 테스트 전에 그 개수만큼 User를 삽입했다.

테스트 코드를 짜면서 몇몇 이슈가 있었다.

  • ParameterizedTest로 수행하려고 ValueSource에 10, 100, 1000을 넣고 테스트해 본 결과, 실행할수록 쿼리 시간이 단축됨
  • 세 개의 테스트를 동시에 수행한 결과, 뒤에 있는 테스트가 무조건 짧은 시간에 수행됨. 또한 순서를 바꿔도 마찬가지로 뒤쪽 쿼리가 짧은 시간 내에 수행됨.
  • UserServiceTest의 모든 테스트 메소드를 동시 수행한 결과, 나중에 실행된 테스트 로직이 더 짧은 시간 내에 수행됨.
  • 이를 바탕으로, 특정 findAll이 먼저 수행될 경우 persistence context에서의 영속화 로직으로 인해 시간 측정이 방해된다고 결론내림(확실한 건 아닙니다. 아시는 분은 조언 부탁드립니다.)

그래서 테스트 방법은 다음과 같다: 각각의 테스트 메소드를 5회 수행하고, 해당되는 값의 평균을 내는 식으로 계산

테스트에서 count의 크기는 10인 경우와 1000인 경우, 10000인 경우로 수행해 보았다.

요런 느낌으로 단순무식하게 테스트...

결과는 다음과 같다. (단위: MilliSecond)

데이터 개수 10 1000 10000
findAll 100.6 126.8 164.4
findAllByEmail 112.6 138.8 172
findAllByNameIn 109.4 224.2 1066.4
  • findAll의 경우, 데이터의 크기와는 별개로 평이하게 값이 상승함을 볼 수 있었다.
  • findAllByEmail의 경우, 데이터의 크기와 별개로 평이하게 값이 상승했다. 그러나 findAll보다는 미세하게 시간이 더 들어갔다.
  • findAllByNameIn의 경우, 데이터의 크기가 커짐에 따라 시간 폭이 급격히 상승했다.
    • 다만 데이터를 단순무식하게 10000개씩 넣은 결과인지라, 만약 인자의 수가 적었다면 findAllByEmail과 큰 차이가 없었을 것으로 추정된다.

왜 이런 결과가 나올까? 일단 쿼리를 확인해 보자.

쿼리

findAll

Hibernate: 
    select
        user0_.id as id1_0_,
        user0_.email as email2_0_,
        user0_.name as name3_0_ 
    from
        user user0_

정말 단순한 select 쿼리이다.

findAllByEmail

Hibernate: 
    select
        user0_.id as id1_0_,
        user0_.email as email2_0_,
        user0_.name as name3_0_ 
    from
        user user0_ 
    where
        user0_.email=?

친구가 걱정했던 부분은 where 절이 추가되면서 시간복잡도가 늘거나, 혹은 여러 개의 쿼리가 나가는게 아니었냐였다.

그러나 실제 수행 결과, where절이 추가되는 것만으로는 큰 부하가 발생하지 않았다.

여기서는 인덱싱된 값이 아닌 것을 탐색했으므로, 인덱싱이 추가로 수행되었다면 결과는 달라질 수 있다.

findAllByNameIn

Hibernate: 
    select
        user0_.id as id1_0_,
        user0_.email as email2_0_,
        user0_.name as name3_0_ 
    from
        user user0_ 
    where
        user0_.name in (
            ? , ? , ? , ? , ? , ? , ? , ? , ? , ?
        )

findAllByNameIn에서는, 일부러 데이터의 개수만큼 where절에 추가하는 방식으로 진행해 보았다.

당연하게도, 10000개의 데이터를 추가하고 where~in 연산을 수행하며 엄청난 시간이 소요되었다.

다만 작은 데이터면 크게 상관은 없는듯.

여기서도 인덱싱된 값이 아닌 것을 탐색했으므로, 인덱싱이 추가로 수행되었다면 결과는 달라질 수 있다.

Stream vs Query

이제 이것도 한번 볼 차례. 

findAll로 다 가져와서 Stream으로 연산하기 vs 그냥 findAllBy로 연산하기 이다.

일단 UserService에, stream을 사용하는 코드를 추가해 보자.

@Timer
@Transactional(readOnly = true)
public List<User> findAllByEmailWithStream(String email) {
    return userRepository.findAll()
            .stream()
            .filter(user -> user.getEmail().equals(email))
            .collect(Collectors.toList());
}

@Timer
@Transactional(readOnly = true)
public List<User> findAllByNamesWithStream(List<String> names) {
    return userRepository.findAll()
            .stream()
            .filter(user -> names.contains(user.getName()))
            .collect(Collectors.toList());
}

이렇게 추가한 뒤, 테스트 코드에 아래 두 테스트를 추가해 시간을 재본다.

@Test
void findAllByEmailWithStreamTest() {
    System.out.println("---------------findAllByEmailWithStream---------------");
    List<User> usersByEmail = userService.findAllByEmailWithStream("이메일");
    Assertions.assertThat(usersByEmail.size()).isEqualTo(count);
}

@Test
void findAllByNamesWithStreamTest() {
    System.out.println("---------------findAllByNamesWithStream---------------");
    List<User> usersByEmail = userService.findAllByNamesWithStream(names);
    Assertions.assertThat(usersByEmail.size()).isEqualTo(count);
}

데이터가 10000개일 때 기준으로만 재봤다. 그것보다 적으면 큰 영향이 없을 듯해서.

10000개일 때의 수행 시간은 다음과 같다.

  • findAllByEmailWithStream: 평균 160.6 ms (Repository 메소드: 172)
  • findAllByNamesWithStream: 평균 437.4 ms (Repository  메소드: 1066.4)

일단 이를 바탕으로, stream을 사용하는 것이 Repository  메소드를 커스텀하는 것보다 조금 더 빠를 수 있다고 생각할 수 있다. 그러나, 내가 생각하기엔 이 역시 위험성을 가지고 있다.

  • 데이터의 양이 예를 들어 100만개, 1억개라면 어떨까? 아무리 stream이라고 해도, 서버 메모리 운영에 무리가 없을까? 이런 부분은 실제로 겪기 전엔 테스트도 어렵기 때문에 위험하다고 생각한다.
  • Repository의 메소드를 커스텀하게 되면, 데이터의 검색/가공/정렬 등은 Repository에 맡기고 서비스 레이어에서는 트랜잭션 유지와 데이터 전달, 비즈니스 로직 수행에 집중할 수 있다. 그러나 위처럼 짜게 되면, 서비스 레이어에서 데이터를 가공하는 내용이 추가로 들어가게 된다.
    • 이렇게 될 경우 지나치게 Repository에 의존적인 코드가 될 수 있다고 생각한다. 예를 들어 Entity의 필드가 조금만 바뀌더라도, 서비스 코드까지 불필요하게 변경해야 할 수 있다!

결론

본 글에서 수행한 테스트는 다음과 같다.

  • 데이터 10, 1000, 10000개에서 findAll에 걸리는 시간
  • 데이터 10, 1000, 10000개에서 findAllByEmail, 즉 단일 요소에 대한 조건-반환에 걸리는 시간
  • 데이터 10, 1000, 10000개에서 findAllByNameIn, 즉 다중 요소(리스트)에 대한 조건-반환에 걸리는 시간
  • 데이터 10000개에서 findAll 이후 stream으로 단일 요소에 대한 필터링을 수행하는 데에 걸리는 시간
  • 데이터 10000개에서 findAll 이후 stream으로 다중 요소에 대한 필터링을 수행하는 데에 걸리는 시간

테스트 결과는 다음과 같다.

  • findAll과 findAllByEmail은 성능차이가 미미했다.
  • findAllByNameIn의 경우에도 미미했으나, 인자로 전달되는 이름 리스트에 10000개를 넣었을 때엔 시간이 기하급수적으로 증가했다.
    • 즉, 인자가 적으면 딱히 findAllByEmail에 걸린 시간과 큰 차이가 없을듯하다.
  • findAll이후 stream을 수행한 경우, 커스텀 메소드보다 좀 더 빨랐다.
    • 그러나 데이터 크기가 지나치게 큰 경우를 테스트해보지 않아 안정성이 보장되지는 않는다.

이를 바탕으로, 다음 사실들을 알 수 있었다.

  • Repository에 커스텀된 메소드를 추가하더라도, 최적화가 엄청 잘되어 있다.
  • 데이터의 크기가 늘더라도 어느정도의 성능이 보장된다.
  • 그러니 편하게 쓰는게 낫다! 오히려 서비스에서 연산을 해버릴 때 생기는 문제가 더 많다.

이 글을 쓰면서 궁금점이 해소되어 나름 마음이 편해졌다. 게다 AOP 공부한 걸 바로 응용해서 재밌었고.

물론 아쉬운 점도 있다. 일단 SQL의 성능 테스트 방법을 몰라서 세상 제일 무식하게 테스트한게 아쉽고...

querydsl을 사용한 커스텀 쿼리와 비교해 보거나, 최적화된 SQL을 직접 짜서 비교해보고 싶었는데 아쉽게 성사되지 못했다. 혹은 인덱싱을 적용하거나, 기타 최적화 방법을 적용했을 때의 성능 개선도 보고 싶었는데 이번엔 해보지 못했다. 다음에는 꼭 해보자는 마음이다.

 

부족한 글인만큼, 혹시나 개선점이나 오류를 찾으신다면 댓글 부탁드립니다😂

반응형

댓글