Spring/Spring Boot

[Spring] oneToMany, 일 대 다 관계 조회 성능 테스트 (Jpa, QueryDsl, Java Stream, 단일 DB 조회 쿼리 성능 비교)

민돌v 2023. 6. 30. 16:51

👏🏻 오랜만에 포스팅!

오늘은 Spring 에서 다대다, 일대다 관계를 가지는 RDB 테이블에서의 데이터를 가져올 수 있는 방법과, 어떤 방법이 제일 빨랐는지 기록해볼려 합니다.

 

보통 Spring 에서 JPA를 사용하면 oneToMany, manyToMany 의 관계에서 터지는 N+1 문제를 해결하기 위한 방법을 많이 고민하고는 합니다.
저 또한 취준생때 겪었던 문제 중 가장 고민을 많이 고생했던 문제로 포스팅을 남겼던 기억이 있습니다.

👉 [Spring] JPA N+1 문제 해결방법(지연로딩 N+1, 2개 이상 ToMany 관계, fetch join, 페이지네이션)

 

[목차]

✔️ 이번 포스팅에서는 JPA 의 @OneToMany 기능의 사용을 전혀 고려하지 않고 아래의 방법들에 대해서만 비교를 해보았습니다.

  1. 연관관계를 가지는 테이블마다 조회쿼리 날리기 (4개의 테이블 4개의 쿼리)
  2. java Stream grouping 후 중복 제거
  3. queryDsl transform

 


 JPA를 사용하지 않은 이유는 지극히 개인적인 생각인데

  1. JPA N+1 문제를 신경써줘야한다.
    1. 이전 포스팅과 같이 fetch Join 과 batch size를 조정하여 할 수 있지만, 2개 이상의 연관관걔를 가지는 테이블은 fetch join이 불가능하다.
    2. 데이터 조회를 위해 어플리케이션 단의 설정에서 batch size 제한을 두고 싶지 않았다. (네 한계를 스스로 정하지마....
    3. Entity 연관관계 lazy 로딩으로 설정하기
      → eager 와 lazy가 혼합되고 이거에 의존적이게 되면 추후에 어디서 에러가 터졌는지 파악하기 힘들더라..
  2. JPA의 연관관계를 사용하다 보면 신경써줘야할게 많다.
    1. 불필요한 쿼리나, 최적화 되지 쿼리가 날라가는 경우가 종종 있었다.

 

[결론]

간단한 단일 테이블에 CRUD 쿼리에서는 JPA 가 정말편하고, ORM 의 생산적인 기능을 너무 똑똑하게 해내지만

복잡한 관계를 가지는 테이블간의 쿼리는 queryDsl 로 직접 제어하는게 → (지금의 제 생각에는) 조금 더 지속가능한 코드를 만들어 갈 수 있는 방법이 아닌가라는 생각입니다.

 

 

잡설이 길었지만
이제 Spring + Java 에서 복수개의 일대다 관계를 가지는 테이블을 조회하는 방법에대해서 정리해보겠습니다..!

 


개발 환경

  • spring boot 3.0.+
  • java 17
  • hibernate 6.1.7
  • Mysql

 

데이터 

  • 100,000 User
  • 각 유저당 3개의 이상형
  • 3개의 관심사
  • 3개의 프로필 사진을 가짐 → 단순 계산 : 10만 | 30만 | 30만 | 30만 

 

제 개인적인 프로젝트지만 세상이 흉흉하기에(?) 내부 컬럼은 숨기겠습니다..ㅋㅋㅋㅋㅋㅋㅋ

 

→ 불러오고자 하는 결과는, 각 유저당 매핑되는 이상형리스트, 관심사리스트, 프로필 리스트 입니다. response 만 보자면 아래와 같게됩니다.

public record Response(
    String username,
    String userUuid,
	
   //.. 생략..

    List<IdealTypeResponse> idealTypeResponseList,
    List<InterestResponse> interestResponses,
    List<UserProfilePhotoResponse> userProfilePhotos,

) {}


//이상형
public record IdealTypeResponse(
    Integer idx,
    String name
) {}


// 관심사
public record InterestResponse(
    Integer idx,
    String name
) {}


//프로필 사진
public record UserProfilePhotoResponse(
    String url
) {}

 


1. Simple Query (N번의 select 문)

가장 단순한 방법이고, 회사코드나 주변 지인분들에게 여쭤봤을 때
복잡하지않은 관계를(1~2개의 연관 테이블) 가지고 데이터 수가 많지 않은 Entity 들은 단순하게 n 개의 select 문을 날리고 있더군요

 

저도 솔직히 귀찮아서 이렇게 할려했으나..

데이터베이스에 연결하고 해지하는, " 1 번의 select 비용이 굉장히 비싸다"는 이야기를 많이 접해 테스트를 해보고자 했습니다.

 

시나리오는 아래와 같았고, 5번은 테스트하고자 하는 부분이 아니기에 생략했습니다. 

  1. 10만 유저 중 100 명의 유저를 조회
  2. 100명의 유저의 uuid로 In 조건 30만 이상형 테이블 중 300건 조회
  3. 100명의 유저의 uuid로 In 조건 30만 관심사 테이블 중 300건 조회
  4. 100명의 유저의 uuid로 In 조건 30만 프로필 사진 테이블 중 300건 조회
  5. 4개의 select 로 불러온 4개의 List 를 각 유저에 맞는 데이터로 매칭

 

각각의 메소드로 나눌까 하다가, (귀찮아서) 심플하게 하나의 querydsl로 몰았습니다 ㅎㅎ 테스트니까여~

@Override
public List<MainScreenUserInfoTransformMapper> find_simple( ) {

    // 1번. select user
    List<User> userList = 대충 100명의 유저

    // 2번. select 프로필 사진
    List<UserProfilePhoto> userProfilePhotos = queryFactory.selectFrom(userProfilePhoto)
        .where(userProfilePhoto.userUuid.in(
            userList.stream().map(User::getUserUuid).toList())
        )
        .fetch();

    // 3번. select 이상형 리스트
    List<UserIdealType> userIdealTypeList = queryFactory.selectFrom(userIdealType)
        .where(userIdealType.userUuid.in(
            userList.stream().map(User::getUserUuid).toList())
        )
        .fetch();

    // 4번. select 관심사 사진
    List<UserInterests> userInterestsList = queryFactory.selectFrom(userInterests)
        .where(userInterests.userUuid.in(
            userList.stream().map(User::getUserUuid).toList())
        )
        .fetch();

    return null;
}

 

📌 결과

결과는 진짜 놀라웠습니다.

  • 테스트 코드로 돌려보았을 때, 4개의 쿼리가 날라가는데만 30초 가까이 걸렸습니다.
  • 10만 데이터를 테스트하기전에 1만으로도 한번 돌려봤는데 3초가 걸렸습니다. 

 

 

물론  프러덕션 코드상 실제 날아가는 쿼리 로직은 아래와 같은 상황도 고려해야하지만, 그럼에도 불구하고 굉장히 많은 시간이 걸린게 사실입니다.

  1. 코드상 많은 코드를 생략함
  2. 페이징을 위해 커버링 인덱스도 사용함
  3. user_uuid 가 외래키 이지만 in 조건절에 String 값 100건을 넣어 조회

여기에 java 매칭 코드까지 생각한다면 끔찍하네요

 


 

2. Java Stream grouping 후 중복 제거

이 방법은 제가 가장 처음에 시도했던 방법입니다.

 

시나리오

  1. 연관되는 테이블들을 inner join 합니다.
    • 유저 컬럼 1당 - 이상형 3, 관심사 3, 프로필 3 의 행이 매칭되어 27개의 컬럼 결과값이 생성됩니다.
    • 해당 값들을 dto 객체에 담습니다.
  2. 유저 고유 값으로 Stream GroupingBy 을 통해 데이터를 추출하고자 하는 데이터별로 추려냅니다.
  3. 27개의 컬럼 중 중복되는 이상형, 관심사, 프로필 중 중복되는 데이터를 제거합니다.

 

시나리오대로 해봅니다.

 

1) 테이블 inner join

  • 계산대로 라면 100개의 유저를 조회했으니 2700개의 데이터가 리스트안에 들어갈 것 입니다.
//queryDsl

@Override
public List<MainScreenUserInfoMapper> find( ) {

	//.. 생략

    return queryFactory.select(
            new QMainScreenUserInfoMapper(
                user.username,
                user.userUuid,
                new QIdealTypeMapper(
                    idealType.idx,
                    idealType.name
                ),
                new QInterestMapper(
                    interest.idx,
                    interest.name
                ),
                new QUserProfilePhotoMapper(
                    userProfilePhoto.idx,
                    userProfilePhoto.userUuid,
                    userProfilePhoto.url
                )
            )
        )
        .from(user)
        .innerJoin(userProfilePhoto)
        .on(user.userUuid.eq(userProfilePhoto.userUuid))
        .innerJoin(userInterests)
        .on(user.userUuid.eq(userInterests.userUuid))
        .innerJoin(interest)
        .on(userInterests.interestIdx.eq(interest.idx))
        .innerJoin(userIdealType)
        .on(user.userUuid.eq(userIdealType.userUuid))
        .innerJoin(idealType)
        .on(userIdealType.idealTypeIdx.eq(idealType.idx))
        .where(
            //100개 추출 커버링 인덱스
            user.idx.in(userDailyFallingIdxList)
        )
        .fetch();
}

//데이터를 담을 Projection DTO
public record MainScreenUserInfoMapper(
    String userUuid,
    IdealTypeMapper idealTypeMapper,
    InterestMapper interestMapper,
    UserProfilePhotoMapper userProfilePhotoMapper
) {

    @QueryProjection
    public MainScreenUserInfoMapper{}
}

 

2) Stream GroupingBy

  • Stream 으로 userUuid 가 같은 데이터들을 추출합니다.
private MainScreenResponse getMainScreenResponse(//생략) {
    
    final Map<String, List<MainScreenUserInfoMapper>> listMap = findService.find()
        .stream()
        .collect(Collectors.groupingBy(MainScreenUserInfoMapper::userUuid));
}

 

3) 중복 데이터 제거

  • 클래스의 역할을 나누기 위해 Response List 를 가진 일급 컬렉션을 만들어 주었습니다.
  • 이 일급 컬렉션의 역할은 중복데이터가 제거된 response List 를 가지는 것 입니다.

 

public record MainScreenUserInfoResponseGroup(
    List<MainScreenUserInfoResponse> responses
) {

    public static MainScreenUserInfoResponseGroup of(
        final Map<String, List<MainScreenUserInfoMapper>> listMap) {

        final List<MainScreenUserInfoResponse> responses = new ArrayList<>();

        for (Map.Entry<String, List<MainScreenUserInfoMapper>> entry : listMap.entrySet()) {
            List<MainScreenUserInfoMapper> mapperList = entry.getValue();

            responses.add(MainScreenUserInfoResponse.of(mapperList));
        }

        return new MainScreenUserInfoResponseGroup(
            responses.stream()
                .sorted(Comparator.comparing(MainScreenUserInfoResponse::userDailyFallingCourserIdx))
                .toList()
        );
    }
}

 

중복된 데이터를 제거하기 위해, 하나의 user_uuid 를 가지는 27개의 컬럼을 받아 HashSet으로 중복데이터를 제거해 주었습니다.

public record MainScreenUserInfoResponse(
    String userUuid,
    HashSet<IdealTypeResponse> idealTypeResponseList,
    HashSet<InterestResponse> interestResponses,
    HashSet<UserProfilePhotoResponse> userProfilePhotos
) {

    public static MainScreenUserInfoResponse of(final List<MainScreenUserInfoMapper> mapperList) {

        if (mapperList.isEmpty()) {
            return null;
        }

        MainScreenUserInfoMapper base = mapperList.get(0);

        return new MainScreenUserInfoResponse(
            base.userUuid(),
            new HashSet<>(mapperList.stream().map(MainScreenUserInfoMapper::idealTypeMapper)
                .map(IdealTypeResponse::of).toList()),
            new HashSet<>(mapperList.stream().map(MainScreenUserInfoMapper::interestMapper)
                .map(InterestResponse::of).toList()),
            new HashSet<>(mapperList.stream().map(MainScreenUserInfoMapper::userProfilePhotoMapper)
                .map(UserProfilePhotoResponse::of).toList())
        );
    }

 

📌 결과

결과는 나쁘지 않았습니다.

  • 1만 유저 일 때는 1012ms 정도
  • 10만 유저 일 때는 1209 ms 정도로 큰차이를 보이지는 않았습니다.

 


 

3. queryDsl transForm 사용하기

✨ 결론부터 말하자면 이 방법이 가장 빨랐습니다.

  • transForm 은 QueryDsl 에서 결과처리를 커스터마이징 하기위해 제공하는 기능 중 하나입니다.
  • .transForm() 집합 함수는 메모리에서 쿼리 결과에 대한 집한 연산을 수행하는 com.mysema.query.group.GroupBy 클래스에서 제공합니다.

 

config

  • 먼저, Spring boot 3.0.+ (Hibernate 6.1.+) 버전에서는 평범하게 transForm을 사용할려고하니 아래와 같은 에러가 발생해서 추가적인 설정을 해줘야 했습니다.
  • org.hibernate.ScrollableResults.get(int)  → 이게 JPA 와 하이버네이트의 버전이 맞지않을때 나는 에러라고 하는데,,,
    아래처럼 JPQLTemplates.DEFAULT 설정값을 넣으니 해결되었습니다 ... 솔직히 잘모르겠습니다..!
@Configuration
@RequiredArgsConstructor
public class QueryDslConfig {

    private final EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(JPQLTemplates.DEFAULT, entityManager);
    }
}

 

queryDsl

  • 참고 : http://querydsl.com/static/querydsl/3.7.2/reference/ko-KR/html/ch03s02.html
  • transForm을 사용하면 groupBy로 묶은 key를 기준으로 Map 을 반홥니다. 여기까지는 stream groupinby랑 결과값이 똑같지만
  • list 함수를 통해 결과를 모을 수 있습니다.
  • 조인을 하게되면 결과값이 카다시안 곱으로 중복 생성되기 때문에, 이에 대한 결과값들을 중복제거 해주기 위해 Group.set 으로 묶어 반환해줍니다.
import com.querydsl.core.group.GroupBy;

//...

@Override
public List<MainScreenUserInfoTransformMapper> find_atTransform() {

    return queryFactory.selectFrom(user)
        .innerJoin(userProfilePhoto)
        .on(user.userUuid.eq(userProfilePhoto.userUuid))
        .innerJoin(userInterests)
        .on(user.userUuid.eq(userInterests.userUuid))
        .innerJoin(interest)
        .on(userInterests.interestIdx.eq(interest.idx))
        .innerJoin(userIdealType)
        .on(user.userUuid.eq(userIdealType.userUuid))
        .innerJoin(idealType)
        .on(userIdealType.idealTypeIdx.eq(idealType.idx))
        .where(
            user.idx.in(//커버링 인덱스 리스트)
        )
        .transform(GroupBy.groupBy(user.userUuid).list(
            new QMainScreenUserInfoTransformMapper(
                user.username,
                user.userUuid,
                GroupBy.set(
                    new QIdealTypeMapper(
                        idealType.idx,
                        idealType.name
                    )
                ),
                GroupBy.set(
                    new QInterestMapper(
                        interest.idx,
                        interest.name
                    )
                ),
                GroupBy.set(
                    new QUserProfilePhotoMapper(
                        userProfilePhoto.idx,
                        userProfilePhoto.userUuid,
                        userProfilePhoto.url
                    )
                )
            )));
}

 

Query Projection Dto

  • 당연히 쿼리를 담을 프로젝션 클래스에서 Set으로 설정해야겠죠?
  • 저는 jdk 17 을 사용해서 record 를 사용했지만 일반 pojo class 를 사용하신다면 @EqualsAndHashCode를 설정해야합니다. (중복 체크를 위해)
public record MainScreenUserInfoMapper(
    String username,
    String userUuid,
    Set<IdealTypeMapper> idealTypeMapper,
    Set<InterestMapper> interestMapper,
    Set<UserProfilePhotoMapper> userProfilePhotoMapper
) {

    @QueryProjection
    public MainScreenUserInfoMapper {}
}

 

 

📌 결과

결과입니다. 2번 방법과 로직의 흐름은 같다 하더라도, 하이버네이트가 메모리에서 처리하는게 최적화가 더 잘되어있나 봅니다.

  • 10만 유저 일 때는 989 ms 라는 결과 나왔고 → JAVA Stream GroupBy 보다 단순하게 계산해보면 약 22%  빨랐습니다.
  • 또한 결과를 제하더라도, 가독성과 의존성 측면에서 데이터를 담아오는 Projection Class 만 List 대신 Set 을 사용하기 때문에 유지보수에 유리하다고 생각됩니다.

 

 


 

 

재밌는 실험이 끝나네요
이렇게 데이터를 넣어가면서 각 상황에 맞춰 시간을 테스트해본건 처음인데 유의미한결과 도출되어서 다행입니다.

JPA 를 사용하지는 않았지만 Spring 에서 1 대 다의 관계를 가지는 데이터를 QueryDsl을 이용해 조회할 떄 매번 고민하지 않아도 될거같아서 좋은 정리가 된것 같습니다.

 

하지만 이 방법은, 카디널리티 곱에의해 생성되는 데이터가 최대 27개의 컬럼으로 항상 제한되어있는 상황이기 때문에,
상황에 따라 결과는 달라질 수 있다고 생각합니다.

 

그럼 끝..! 

 

 

 

 

 

 

 

 

 


참고