쿼리에 대한 견해
**기본**
일반적인 쿼리 실행 순서
1. 조인, WHERE
2. GROUP BY
3. DISTINCT
4. HAVING 조건 적용
5. ORDER BY
6. LIMIT
특수한 경우 쿼리 실행 순서
1. WHERE 적용
2. ORDER BY
3. 조인
4. LIMIT
(위의 두 실행 순서로 판단하겠습니다.)
조인에 대해
Store/User와 같이 복잡하지 않고, 하나의 레코드에 한명의 유저만 접근하는 도메인에는 굳이 쿼리 최적화/동시성 보장을 할 우선순위가 현저하게 낮은 부분은 제외하겠습니다.
1. Feed 쿼리 최적화
public Long countAllByFiltering(boolean isAvailable, Category category, String keyword) {
return jpaQueryFactory
.select(feed.count())
.from(feed)
.leftJoin(feed.store, store)
.where(
isOpen(isAvailable),
categoryEq(category),
keywordContains(keyword)
)
.fetchOne();
}
BooleanExpression은 WHERE절에서 isOpen()과 같은 조건이 NULL을 반환하면, 쿼리에서 조건을 빼버린다.
그러니, where절의 3개의 조건을 조합하여 아래의 8개의 조건이 생긴다.
(isAvailable=null, category=null, keyword=null)
(isAvailable≠null, category=null, keyword=null)
(isAvailable=null, category≠null, keyword=null)
(isAvailable=null, category=null, keyword≠null)
(isAvailable≠null, category≠null, keyword=null)
(isAvailable≠null, category=null, keyword≠null)
(isAvailable=null, category≠null, keyword≠null)
(isAvailable≠null, category≠null, keyword≠null)
(젠장, 너무 어렵다.)
1. 들어가기 전에
이 쿼리에서 핵심적으로 강조할 부분은 LEFT OUTER JOIN에 대하여이다.
기본적으로 OUTER JOIN은 매우 비효율적인 조인 옵션이다.
MySQL에서 기본적으로 사용되는 조인인 Nested Loop Join에서 옵티마이저는 드라이빙 테이블과 드리븐 테이블을 정한다.
Nested Loop Join이란, 100개의 레코드를 가진 테이블1과 10개의 레코드를 가진 테이블을
for(int i = 0; i < 10; i++) {
for(int j = 0; j < 100; j++) {
//조인로직
}
}
이런 식으로 루프를 돌려 조인하는 방식이다. MySQL에서는 웬만하면 이런 조인을 수행한다.(Hash Join도 있긴하다.)
만약, 100개의 레코드를 조인할 때, 인덱스가 있다면, 어떻게 될까?
for(int i = 0; i < 10; i++) {
drivenTable.Index_Seek(i.컬럼 = tableJ.컬럼); //이 로직의 시간복잡도는 1이라 가정한다.
}
위에서는 10 * 100이라는 시간복잡도가 10으로 확 줄여진 것을 볼 수 있다.
이러한 과정으로 우리는 생각할 수 있다.
드라이빙 테이블(1중 for문)은 되도록 작은 테이블을 설정하는게 좋겠고, (카디널리티가 작은)
드리븐 테이블(2중 for문)은 인덱스를 효율적으로 탈 수 있는 테이블로 설정하는게 좋겠구나! (인덱스를 탈 수 있는)
옵티마이저는 조인 절이 주어졌을 때, 위처럼 효율적으로 조인을 수행할 수 있도록, 드라이빙 테이블과 드리븐 테이블을 동적으로 정한다.
하지만!, LEFT OUTER JOIN을 사용하면,
SELECT *
FROM table1
LEFT OUTER JOIN table2
이 쿼리에서 table2가 무조건 드라이빙 테이블이 되도록 강제되게 된다.
결국, table2가 드리븐 테이블이 될 때 더 효율적인 쿼리이어도, 드라이빙 테이블로 선택되게되고, 이는 비효율적인 쿼리로 변하게 된다.
2. 옵티마이저에게 JOIN의 드라이빙/드리븐 테이블 선택권 제공(OUTER -> INNER)
LEFT JOIN = LEFT OUTER JOIN
SELECT count(*)
FROM feed f
LEFT OUTER JOIN store s ON f.store_id = s.id
WHERE f.status IN {Status.OPEN, Status.UPCOMING}
AND f.category = {category}
AND (f.title LIKE %?% OR s.name LIKE %?%)
이러한 쿼리가 날라간다고 가정한다.
하지만, OUTER JOIN을 활용하게 되면, FROM 테이블이 드라이빙 테이블로 강제된다.
=> feed 테이블이 카디널리티가 더 작아도 드라이빙 테이블로 선택되는 것이다.
때문에, LEFT JOIN을 (INNER) JOIN으로 바꾸어, 옵티마이저에게 드라이빙/드리븐 테이블 선택권을 줘야한다.
3. 불필요한 조인
위에서, BooleanExpression은 Null이 반환값일 시 해당 조건을 무시한다고 했다.
즉, 사용자가 status와 category만을 선택했다고 가정한다.
SELECT count(*)
FROM feed f
LEFT OUTER JOIN store s ON f.store_id = s.id
WHERE f.status IN {Status.OPEN, Status.UPCOMING}
AND f.category = {category}
이때, 조인이 필요할까?ㄹ
feed와 store는 N:1이다.
=> 현재는 JOIN이 필요 없다고 보인다.
즉, 조인을 keywordContains(keyword)가 NULL을 반환하지 않아 적용될 때만, 적용하는 것이 좋아보인다.
4. 풀 텍스트 인덱스
=> 이것은 좀 찾아봐야겠다. 좀 어려운 것 같다.
5. 인덱스 설정
동적 쿼리에서 인덱스 설정은 어렵다.
현재는 하나의 동적 쿼리에서 8개의 쿼리가 발생한다.
그렇다면, 어떻게 인덱스를 고려할 수 있을까?
내가 생각한 동적 쿼리에서 인덱스를 설정하는 방법이다.
1. 각각의 쿼리들을 생각해보기
2. 자주 사용될 쿼리들을 우선순위를 둬서 인덱스를 설정하기
ㅋㅋ 아주 귀찮을 수 있지만, 어쩌겠는가? 해야지...
SELECT count(*)
FROM feed f
-- 일반적인 COUNT(*)은 뭐 어쩔 수 없다. 풀 테이블 스캔을 해야된다.
SELECT count(*)
FROM feed f
WHERE f.status IN {Status.OPEN, Status.UPCOMING}
-- (status) 단일인덱스가 가장 좋아보인다.
SELECT count(*)
FROM feed f
AND f.category = {category}
-- (category) 단일인덱스
SELECT count(*)
FROM feed f
WHERE f.status IN {Status.OPEN, Status.UPCOMING}
AND f.category = {category}
/* IN은 OR 절이다. 즉, 아래와 같이 옵티마이저가 쿼리를 최적화할 것이다.
* SELECT count(*)
* FROM feed f
* WHERE f.status = Status.OPEN OR f.status = Status.UPCOMING
* AND f.category = {category}
*
* OR 절이 쓰인 것을 볼 수 있다.
* 인덱스가 없을 때, OR절은 매우 위험하다. 무조건 풀 테이블 스캔이라고 보면 된다.
* 때문에 인덱스를 두어, 인덱스 레인지 스캔을 할 수 있도록 하자.
* (status, category) 복합 인덱스
* status가 OPEN, UPCOMING인 것들만 필터링하고, f.category를 필터링
*/
SELECT count(*)
FROM feed f
JOIN store s ON f.store_id = s.id
AND (f.title LIKE %?% OR s.name LIKE %?%)
/*
* OUTER JOIN을 INNER JOIN으로 바꿔줌으로 최적화 해주자.
* 이제 옵티마이저는 드리븐 드라이빙 테이블을 알아서 선택할 수 있다.
*
* 두번째로, 가장 큰 문제이다. %?%를 어떻게 처리할 것인가?
* 이건 저도 잘...(풀 텍스트 인덱스를 적용하면 될 것 같긴 합니다만..)
*/
SELECT count(*)
FROM feed f
JOIN store s ON f.store_id = s.id
AND f.category = {category}
AND (f.title LIKE %?% OR s.name LIKE %?%)
/*
* 이것도 잘...
*/
SELECT count(*)
FROM feed f
JOIN store s ON f.store_id = s.id
WHERE f.status IN {Status.OPEN, Status.UPCOMING}
AND (f.title LIKE %?% OR s.name LIKE %?%)
/*
* 이 또한 그러하다
*/
SELECT count(*)
FROM feed f
JOIN store s ON f.store_id = s.id
WHERE f.status IN {Status.OPEN, Status.UPCOMING}
AND f.category = {category}
AND (f.title LIKE %?% OR s.name LIKE %?%)
/*
* 만약, 풀 텍스트 인덱스를 사용하지 않는다면,
* 커버링 인덱스를 위해, (status, category)에 title 추가
* (status, category, title)
*/
6. 최종 인덱스
(status)
(category)
(status, category, title)
7. 최종 쿼리 DSL 코드
public Long countAllByFiltering(boolean isAvailable,
Category category,
String keyword) {
// 쿼리 시작
JPAQuery<Long> query = jpaQueryFactory
.select(feed.count())
.from(feed);
// 기본적인 WHERE 조건 추가
BooleanBuilder builder = new BooleanBuilder();
builder.and(isOpen(isAvailable))
.and(categoryEq(category));
// 키워드 있을 시 조인, 추가 WHERE 조건 추가
if (StringUtils.hasText(keyword)) {
query.join(feed.store, store);
builder.and(keywordContains(keyword));
}
// WHERE 에 빌더 적용 후 실행
return query
.where(builder)
.fetchOne();
}
@Override
public List<Feed> findPageByFiltering(Pageable pageable, boolean isAvailable, SortType sortType, Category category, String keyword) {
// 서브쿼리로 ID 먼저 추출
List<Long> feedIds = jpaQueryFactory
.select(feed.id)
.from(feed)
.where(
isOpen(isAvailable),
categoryEq(category),
keywordContains(keyword)
)
.orderBy(getSortTypeSpecifier(sortType))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// feedId 기반 전체 데이터 조회
return jpaQueryFactory
.selectFrom(feed)
.leftJoin(feed.store, store).fetchJoin()
.where(feed.id.in(feedIds))
.fetch();
}
-- 서브 쿼리
SELECT f.id
FROM feed f
LEFT JOIN store s ON f.store_id = s.id
WHERE f.status IN ?...
AND f.category = ?
AND (f.title LIKE %?% OR s.name LIKE %?%)
ORDER BY {아래 두개 정렬 조건}
OFFSET ?
LIMIT ?
-- 조건 1 : PRICE_ASC
ORDER BY f.price ASC, f.id DESC
-- 조건 2 : RECENT
ORDER BY f.id DESC
SELECT f.*, s.*
FROM feed f
LEFT JOIN store s ON f.store_id = s.id
WHERE f.id IN {위의 서브 쿼리로 뽑아온 feedId들};
우선, 서브쿼리부터 살펴보자.
1. 불필요한 JOIN
위에서 설명햇듯, if 조건분기로 like가 없을때, 조인을 빼주는 것이 좋겠다.
@Override
public List<Feed> findAllByUserOrderByFeedIdDesc(Pageable pageable, User user, Long lastFeedId) {
return jpaQueryFactory
.select(feed)
.from(reservation)
.join(reservation.feed, feed)
.orderBy(feed.id.desc())
.where(
reservation.user.eq(user),
ltFeedId(lastFeedId))
.limit(pageable.getPageSize() + 1) // 다음 페이지 유무 판단을 위해
.fetch();
}
select
f.id,
f.store_id,
f.user_id,
f.title,
f.content,
f.status,
f.category,
f.created_at,
f.updated_at
from reservation r
inner join feed f on r.feed_id = f.id
where r.user_id = ?
order by f.id desc
limit ?
-- 위는 첫번째 기본 페이지, 아래는 두번째 페이지부터
select
f.id,
f.store_id,
f.user_id,
f.title,
f.content,
f.status,
f.category,
f.created_at,
f.updated_at
from reservation r
inner join feed f on r.feed_id = f.id
where r.user_id = ?
and f.id < ?
order by f.id desc
limit ?
1. 쿼리부터 분석해봅시당
feed는 f.id < ? 로, 필터링을 수행할 수 있다.
reservation은 r.feed_id = f.id로 필터링을 수행할 수 있다.
필터링 수행 후 조인을 하겠다.
이때, 드라이빙 테이블은 feed가 되어야 한다.(문제점)
feed가 드라이빙 테이블이 되어 ORDER BY와 LIMIT을 index로 수행한다.
즉, feed가 드라이빙 테이블이 되어, 정렬된 인덱스의 이점을 뽑을 수 있는 상황을 만들어야한다.
(드라이빙 테이블이 주 테이블이라, ORDER BY 등의 인덱스를 조인에서 이용하려면, 드라이빙 테이블의 인덱스가 적절해야한다.)
만약, reservation이 드라이빙 테이블이 된다면, ORDER BY와 LIMIT은 인덱스를 이용하지 못하고, file sort로 할 것이다.
위에서 뭐라 그랬는가? 옵티마이저가 드라이빙 테이블을 알아서 정해준다고 하지 않았는가?
이 쿼리에서는 feed가 드라이빙 테이블이 되어야만 한다.
하지만, JOIN으로 어떤 테이블이 드라이빙 테이블이 되어야할 지 모른다.
이때, 우리는 2개의 대안을 쓸 수 있다.
2. HINT로 옵티마이저에게 feed가 되도록 강제한다.
spring.jpa.properties.hibernate.use_sql_comments=true //옵티마이저에게 힌트를 넘겨주는 설정.yml
JPAQuery<Feed> query = jpaQueryFactory
.select(feed)
.from(feed)
.where(feed.id.lt(lastFeedId))
.orderBy(feed.id.desc())
.limit(pageSize);
query.setHint("org.hibernate.comment", "+ STRAIGHT_JOIN(f r) FORCE INDEX(idx_reservation_user_feed)");
List<Feed> results = query.fetch();
=> 솔직히 이 방법은 잘 모르겠다.
3. 서브쿼리(인 라인 뷰) 사용으로 드라이빙 테이블 강제하기
쿼리를 보면, 자신이 예약한 모든 feed를 페이지네이션해서 return하는 쿼리로 보인다.
SELECT는 feed *를 하면서 reservation은 정작 조인만하고 쓰지를 않는다.
이를 어떻게 최적화 할 수 있을까?
예약은 우리의 프로젝트에서 가장 많은 레코드를 갖는 테이블이다.
때문에, 서브쿼리를 통한 조인으로 최적화할 수 있다.("인 라인 뷰" 라고 한다.)
SELECT
f.id, f.store_id, f.user_id,
f.title, f.content,
f.status, f.category,
f.created_at, f.updated_at
FROM (
SELECT feed_id
FROM reservation
WHERE user_id = ?
ORDER BY feed_id DESC
LIMIT ?
) r
JOIN feed f
ON f.id = r.feed_id
ORDER BY f.id DESC;
SELECT
f.id, f.store_id, f.user_id,
f.title, f.content,
f.status, f.category,
f.created_at, f.updated_at
FROM (
SELECT feed_id
FROM reservation
WHERE user_id = ?
AND feed_id < ?
ORDER BY feed_id DESC
LIMIT ?
) r
JOIN feed f
ON f.id = r.feed_id
ORDER BY f.id DESC;
인라인 뷰 서브쿼리는 일종의 파이프라인이된다.
이것이 뭔 뜻이냐면, feed 테이블에 형성되어있는, id(PK) 인덱스를 DESC로 타며, f.id = r.feed_id(서브쿼리) 를 실행한다.
즉, 서브쿼리로 찾아온 값들을, 메모리에 저장해놓고, 밖의 본 쿼리를 실행한다.
이게 왜 대안인가?
feed_id는 시간 순을 대신해서 사용한 것이다. 서브쿼리 안, 밖에 ORDER BY가 있으므로, 정렬은 문제없다.
여기서는 LIMIT이 막 1만건 이렇게 되지 않는다. 많아야 20건이다. 때문에, 어떤 테이블이 드라이빙 테이블이 되어도, 조인 비용이 그렇게 크지 않다.
4. 최종 인덱스
reservation
(user_id, feed_id)
feed
필요 없음
5. 최종 쿼리 DSL 코드
//서브 쿼리
JPAQuery<Long> idQuery = jpaQueryFactory
.select(r.feed.id)
.from(r)
.where(r.user.id.eq(userId));
if (lastFeedId != null) {
idQuery.where(r.feed.id.lt(lastFeedId));
}
List<Long> feedIds = idQuery
.orderBy(r.feed.id.desc())
.limit(pageSize)
.fetch();
if (feedIds.isEmpty()) {
return Collections.emptyList();
}
//본 쿼리
return jpaQueryFactory
.selectFrom(f)
.where(f.id.in(feedIds))
.orderBy(f.id.desc())
.fetch();
@Transactional(readOnly = true)
public GetMypageInfoRes getMypageInfo(Long userId, Pageable pageable, Long lastFeedId) {
User user = findUser(userId);
if(user.getRole() == UserRole.ROLE_TESTER) { // 일반 사용자
// Reservation 기반 조회
List<GetFeedRes> feedResList = new ArrayList<>(feedRepository.findAllByUserOrderByFeedIdDesc(pageable, user, lastFeedId).stream()
.map(GetFeedRes::new).toList());
// 다음 페이지 확인 및 반환값 조정
boolean hasNextPage = (feedResList.size() > pageable.getPageSize());
if(hasNextPage) feedResList.remove(feedResList.size() - 1);
return new GetMypageInfoRes(user.getId(), user.getNickname(), user.getRole(), hasNextPage, feedResList);
} else if(user.getRole() == UserRole.ROLE_OWNER) { // 사장님
Store store = findStoreByUserId(user.getId());
// store 기반 조회
List<GetFeedRes> feedResList = new ArrayList<>(feedRepository.findAllByStoreOrderByFeedIdDesc(pageable, store, lastFeedId).stream()
.map(GetFeedRes::new).toList());
// 다음 페이지 확인 및 반환값 조정
boolean hasNextPage = (feedResList.size() > pageable.getPageSize());
if(hasNextPage) feedResList.remove(feedResList.size() - 1);
return new GetMypageInfoRes(store.getId(), store.getName(), user.getRole(), hasNextPage, feedResList);
}
// 이외의 경우에는 myPage 접근 불가
throw new GlobalException(FORBIDDEN);
}
@Override
public List<Feed> findAllByUserOrderByFeedIdDesc(Pageable pageable, User user, Long lastFeedId) {
return jpaQueryFactory
.select(feed)
.from(reservation)
.join(reservation.feed, feed)
.orderBy(feed.id.desc())
.where(
reservation.user.eq(user),
ltFeedId(lastFeedId))
.limit(pageable.getPageSize() + 1) // 다음 페이지 유무 판단을 위해
.fetch();
}
SELECT feed f
FROM reservation r
JOIN r.feed_id = f.id
WHERE r.user_id = ? AND r.feed_id < ?
ORDER BY f.id DESC
LIMIT (? + 1);
1. 들어가기 전에
reservation 테이블은 일단 redis에 저장 후 DB에 동기화를 시켜주기 때문에, 그 사이 얼마 동안은 안보일 수 있다.
2. JWT 토큰 활용하여 findUser(userId) 생략하기
현재 getMypageInfo를 보면 userId를 통해 user를 찾아오는 경우를 볼 수 있다.
JWT 토큰에 user의 Nickname을 넣으면 이를 생략할 수 있겠다.
JWT 토큰에 user의 닉네임, 권한, id 정도는 넣어도 된다고 생각한다.(그렇게 오버헤드가 크지도 않다.)
@Transactional(readOnly = true)
public GetMypageInfoRes getMypageInfo(
Long userId,
String nickname,
UserRole userRole,
Pageable pageable,
Long lastFeedId) {
...
}
이렇게 바꿀 수 있겠다.(JWT에 nickname과 role을 추가한다면...)
3. 이 쿼리 또한 서브쿼리로 분리 가능
현재 쿼리는 예약한 피드들의 feed_id의 정렬을 통한 페이지 네이션으로 보인다.
한 번의 쿼리 좋다... 하지만, 만약 테이블이 커진다고 가정했을 때, JOIN은 그만큼 더 큰 오버헤드를 가져온다.
때문에, 서브쿼리(쿼리 dsl에서는 제약이 있음)나 두 번의 쿼리를 날리는 방법이 좋다.
일단, 예약 테이블에서 자신이 예약한, 지금까지 본 피드보다 오래된 피드 id를 찾아온다.
SELECT r.feed_id
FROM reservation r
WHERE r.user_id = ?
AND r.feed_id < ?
ORDER BY r.feed_id DESC
LIMIT (?+1)
그 후 찾아온 피드 id들로 SELECT 해올 수 있다.
SELECT *
FROM feed f
WHERE f.id IN (?.....)
ORDER BY f.id DESC;
조인이 들어가지 않고, 깔끔해졌다.
@Override
public List<Feed> findAllByStoreOrderByFeedIdDesc(Pageable pageable, Store store, Long lastFeedId) {
return jpaQueryFactory
.selectFrom(feed)
.orderBy(feed.id.desc())
.where(
feed.store.eq(store),
ltFeedId(lastFeedId))
.limit(pageable.getPageSize() + 1) // 다음 페이지 유무 판단을 위해
.fetch();
}
=> 이 쿼리는 좋다고 생각한다.
2. review 쿼리 최적화
public GetReviewsRes getReviews(Long userId, UserRole userRole, Long feedId) {
// 리뷰에 해당하는 파일들 모두 조회
List<Review> reviews = List.of();
if(userRole.equals(UserRole.ROLE_OWNER)){
reviews = reviewRepository.findAllByFeedId(feedId);
}
if(userRole.equals(UserRole.ROLE_TESTER)){
reviews = reviewRepository.findByFeedIdAndUserId(feedId, userId)
.map(List::of)
.orElse(List.of());
}
// 리뷰 id 받아오기
List<Long> reviewIds = reviews.stream()
.map(Review::getId)
.toList();
// 리뷰에 연결된 파일 전부 조회
List<ReviewFile> reviewFiles = reviewFileRepository.findAllByReview_IdIn(reviewIds);
// 리뷰 ID 기준으로 파일 리뷰-파일 연결
Map<Long, List<ReviewFile>> fileMap = reviewFiles.stream()
.collect(Collectors.groupingBy(rf -> rf.getReview().getId()));
// DTO 변환
List<ReviewWithFiles> reviewWithFiles = reviews.stream()
.map(review -> ReviewWithFiles.from(
review, fileMap.getOrDefault(review.getId(), List.of())))
.toList();
return new GetReviewsRes(reviewWithFiles);
}
// ROLE_OWNER
@Query("SELECT r FROM Review r JOIN FETCH r.user WHERE r.feed.id = :feedId ")
List<Review> findAllByFeedId(@Param("feedId") Long feedId);
// ROLE_TESTER
@Query("SELECT r FROM Review r JOIN FETCH r.user WHERE r.feed.id = :feedId AND r.user.id = :userId")
Optional<Review> findByFeedIdAndUserId(@Param("feedId") Long feedId, @Param("userId") Long userId);
List<ReviewFile> findAllByReview_IdIn(List<Long> reviewIds);
동적 쿼리를 쓰지 않은 아주 기특한 쿼리이다.(동적 쿼리가 나쁘다는게 아니고, 정적 쿼리가 분석하기 쉽다.)
이 쿼리는 여러 곳에서 사용되는 것으로 보이는데, 때문에, 쿼리를 바꾸기 보단, 인덱스를 잡아주자.
user 테이블에 (user_id, feed_id)로 인덱스를 잡아주면 더 빨라질 것이다.
@Transactional
public ModifyReviewRes updateReview(Long userId, Long feedId, ReviewReq req) {
// review 정보 갱신, user가 작성한 리뷰 찾기
Review review = reviewRepository.findByFeedIdAndUserId(feedId, userId)
.orElseThrow(() -> new GlobalException(REVIEW_NOT_FOUND));
review.update(req);
// 기존 review 파일 S3에서 제거
List<ReviewFile> files = reviewFileRepository.findAllByReviewId(review.getId());
s3Service.deleteMultiFiles(files.stream().map(ReviewFile::getUrl).toList(), FilePath.REVIEW);
reviewFileRepository.deleteAll(files);
// 새로운 review 파일 정보 추가
saveReviewFiles(req.getImages(), req.getVideo(), review);
return new ModifyReviewRes();
}
/**
* review 가져오기
* - Role에 따라 하나 or 다수
* - review와 reviewfile을 가져와 하나의 dto로 반환
*/
public GetReviewsRes getReviews(Long userId, UserRole userRole, Long feedId) {
// 리뷰에 해당하는 파일들 모두 조회
List<Review> reviews = List.of();
if(userRole.equals(UserRole.ROLE_OWNER)){
reviews = reviewRepository.findAllByFeedId(feedId);
}
if(userRole.equals(UserRole.ROLE_TESTER)){
reviews = reviewRepository.findByFeedIdAndUserId(feedId, userId)
.map(List::of)
.orElse(List.of());
}
// 리뷰 id 받아오기
List<Long> reviewIds = reviews.stream()
.map(Review::getId)
.toList();
// 리뷰에 연결된 파일 전부 조회
List<ReviewFile> reviewFiles = reviewFileRepository.findAllByReview_IdIn(reviewIds);
// 리뷰 ID 기준으로 파일 리뷰-파일 연결
Map<Long, List<ReviewFile>> fileMap = reviewFiles.stream()
.collect(Collectors.groupingBy(rf -> rf.getReview().getId()));
// DTO 변환
List<ReviewWithFiles> reviewWithFiles = reviews.stream()
.map(review -> ReviewWithFiles.from(
review, fileMap.getOrDefault(review.getId(), List.of())))
.toList();
return new GetReviewsRes(reviewWithFiles);
}
쿼리 합치기
@Transactional
public void saveReviewFiles(List<MultipartFile> images, MultipartFile video, Review review) {
List<ReviewFile> files = new ArrayList<>();
// IMAGE가 존재하는 경우
if(images != null && !images.isEmpty()){
addImageFiles(images, review, files);
}
// VIDEO가 존재하는 경우
if(video != null && !video.isEmpty()){
addVideoFile(video, review, files);
}
reviewFileRepository.saveAll(files);
}
saveAll