👏🏻 오랜만에 포스팅!
오늘은 Spring 에서 다대다, 일대다 관계를 가지는 RDB 테이블에서의 데이터를 가져올 수 있는 방법과, 어떤 방법이 제일 빨랐는지 기록해볼려 합니다.
보통 Spring 에서 JPA를 사용하면 oneToMany, manyToMany 의 관계에서 터지는 N+1 문제를 해결하기 위한 방법을 많이 고민하고는 합니다.
저 또한 취준생때 겪었던 문제 중 가장 고민을 많이 고생했던 문제로 포스팅을 남겼던 기억이 있습니다.
👉 [Spring] JPA N+1 문제 해결방법(지연로딩 N+1, 2개 이상 ToMany 관계, fetch join, 페이지네이션)
[목차]
✔️ 이번 포스팅에서는 JPA 의 @OneToMany 기능의 사용을 전혀 고려하지 않고 아래의 방법들에 대해서만 비교를 해보았습니다.
- 연관관계를 가지는 테이블마다 조회쿼리 날리기 (4개의 테이블 4개의 쿼리)
- java Stream grouping 후 중복 제거
- queryDsl transform
JPA를 사용하지 않은 이유는 지극히 개인적인 생각인데
- JPA N+1 문제를 신경써줘야한다.
- 이전 포스팅과 같이 fetch Join 과 batch size를 조정하여 할 수 있지만, 2개 이상의 연관관걔를 가지는 테이블은 fetch join이 불가능하다.
- 데이터 조회를 위해 어플리케이션 단의 설정에서 batch size 제한을 두고 싶지 않았다. (
네 한계를 스스로 정하지마....) - Entity 연관관계 lazy 로딩으로 설정하기
→ eager 와 lazy가 혼합되고 이거에 의존적이게 되면 추후에 어디서 에러가 터졌는지 파악하기 힘들더라..
- JPA의 연관관계를 사용하다 보면 신경써줘야할게 많다.
- 불필요한 쿼리나, 최적화 되지 쿼리가 날라가는 경우가 종종 있었다.
[결론]
간단한 단일 테이블에 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번은 테스트하고자 하는 부분이 아니기에 생략했습니다.
- 10만 유저 중 100 명의 유저를 조회
- 100명의 유저의 uuid로 In 조건 30만 이상형 테이블 중 300건 조회
- 100명의 유저의 uuid로 In 조건 30만 관심사 테이블 중 300건 조회
- 100명의 유저의 uuid로 In 조건 30만 프로필 사진 테이블 중 300건 조회
- 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초가 걸렸습니다.
물론 프러덕션 코드상 실제 날아가는 쿼리 로직은 아래와 같은 상황도 고려해야하지만, 그럼에도 불구하고 굉장히 많은 시간이 걸린게 사실입니다.
- 코드상 많은 코드를 생략함
- 페이징을 위해 커버링 인덱스도 사용함
- user_uuid 가 외래키 이지만 in 조건절에 String 값 100건을 넣어 조회
여기에 java 매칭 코드까지 생각한다면 끔찍하네요
2. Java Stream grouping 후 중복 제거
이 방법은 제가 가장 처음에 시도했던 방법입니다.
시나리오
- 연관되는 테이블들을 inner join 합니다.
- 유저 컬럼 1당 - 이상형 3, 관심사 3, 프로필 3 의 행이 매칭되어 27개의 컬럼 결과값이 생성됩니다.
- 해당 값들을 dto 객체에 담습니다.
- 유저 고유 값으로 Stream GroupingBy 을 통해 데이터를 추출하고자 하는 데이터별로 추려냅니다.
- 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개의 컬럼으로 항상 제한되어있는 상황이기 때문에,
상황에 따라 결과는 달라질 수 있다고 생각합니다.
그럼 끝..!
참고
'Spring > Spring Boot' 카테고리의 다른 글
[Spring Security] 존재하지 않는 API 호출 시 404 대신 401 or 403 을 반환할 때 (2) | 2023.07.15 |
---|---|
Spring Security Exception Handling - Filter 단 예외 처리하기 (5) | 2023.07.06 |
Spring Data mongoDB + mysql 사용하기 (with. queries) (4) | 2023.06.14 |
Spring WebSocket STOMP 채팅 서버 구현하기 (with. JWT, Exception Handling) (2) | 2023.06.14 |
Spring WebSocket 공식문서 가이드 살펴보기 (4) | 2023.06.01 |