Skip to content

Commit 53a45db

Browse files
committed
fix: 피드백 반영
- QueryDsl Repository 클래스명 변경 - ContentQueryRepositoryImpl: applyFilters 메서드 리펙토링 - ContentStats: averageRating 필드 제거 - ContentMapper: 중복 메서드 제거
1 parent 6488863 commit 53a45db

6 files changed

Lines changed: 46 additions & 76 deletions

File tree

src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,24 @@ public class ContentStats extends BaseUpdatableEntity {
2020
@Column(nullable = false)
2121
private double ratingSum;
2222

23-
@Column(nullable = false)
24-
private double averageRating;
25-
2623
@Column(nullable = false)
2724
private long watcherCount;
2825

2926
public static ContentStats create() {
3027
ContentStats stats = new ContentStats();
3128
stats.reviewCount = 0;
3229
stats.ratingSum = 0.0;
33-
stats.averageRating = 0.0;
3430
stats.watcherCount = 0;
3531
return stats;
3632
}
3733

34+
public double getAverageRating() {
35+
if (reviewCount == 0) return 0.0;
36+
return ratingSum / reviewCount;
37+
}
38+
3839
public void updateRating(double newRatingSum, int newReviewCount) {
3940
this.ratingSum = newRatingSum;
4041
this.reviewCount = newReviewCount;
41-
this.averageRating = newReviewCount == 0 ? 0.0 : newRatingSum / newReviewCount;
4242
}
4343
}

src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@
33
import com.codeit.team5.mopl.content.dto.response.ContentResponse;
44
import com.codeit.team5.mopl.content.entity.Content;
55
import com.codeit.team5.mopl.content.entity.ContentSortByType;
6-
import com.codeit.team5.mopl.content.entity.ContentStats;
7-
import com.codeit.team5.mopl.content.entity.ContentTag;
6+
import com.codeit.team5.mopl.content.mapper.util.ContentUtilsMapper;
87
import com.codeit.team5.mopl.global.dto.CursorResponse;
9-
import java.util.Collections;
108
import java.util.List;
11-
import java.util.Set;
129
import org.mapstruct.Mapper;
1310
import org.mapstruct.Mapping;
1411
import org.springframework.data.domain.Sort.Direction;
@@ -24,19 +21,6 @@ public interface ContentMapper {
2421
@Mapping(target = "thumbnailUploadStatus", source = "thumbnail.uploadStatus")
2522
ContentResponse toDto(Content content);
2623

27-
default List<String> toTagNames(Set<ContentTag> contentTags) {
28-
if (contentTags == null) return Collections.emptyList();
29-
return contentTags.stream()
30-
.map(ct -> ct.getTag().getName())
31-
.sorted()
32-
.toList();
33-
}
34-
35-
default double toAverageRating(ContentStats stats) {
36-
if (stats == null) return 0.0;
37-
return stats.getAverageRating();
38-
}
39-
4024
default CursorResponse<ContentResponse> toCursor(List<Content> page, boolean hasNext,
4125
long totalCount, ContentSortByType sortBy, Direction sortDirection) {
4226
String nextCursor = null;

src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
package com.codeit.team5.mopl.content.repository;
22

33
import com.codeit.team5.mopl.content.entity.Content;
4-
import com.codeit.team5.mopl.content.repository.querydsl.ContentRepositoryCustom;
4+
import com.codeit.team5.mopl.content.repository.querydsl.ContentQueryRepository;
55
import java.util.Optional;
66
import java.util.UUID;
77
import org.springframework.data.jpa.repository.EntityGraph;
88
import org.springframework.data.jpa.repository.JpaRepository;
99

10-
public interface ContentRepository extends JpaRepository<Content, UUID>, ContentRepositoryCustom {
10+
public interface ContentRepository extends JpaRepository<Content, UUID>, ContentQueryRepository {
1111

1212
@EntityGraph(attributePaths = {"thumbnail", "stats", "contentTags", "contentTags.tag"})
1313
Optional<Content> findWithStatsAndTagsById(UUID id);

src/main/java/com/codeit/team5/mopl/content/repository/querydsl/ContentRepositoryCustom.java renamed to src/main/java/com/codeit/team5/mopl/content/repository/querydsl/ContentQueryRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import com.codeit.team5.mopl.content.entity.Content;
55
import java.util.List;
66

7-
public interface ContentRepositoryCustom {
7+
public interface ContentQueryRepository {
88

99
List<Content> findContents(ContentCursorRequest request, int fetchLimit);
1010

src/main/java/com/codeit/team5/mopl/content/repository/querydsl/ContentRepositoryImpl.java renamed to src/main/java/com/codeit/team5/mopl/content/repository/querydsl/ContentQueryRepositoryImpl.java

Lines changed: 37 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest;
44
import com.codeit.team5.mopl.content.entity.Content;
5+
import com.codeit.team5.mopl.content.entity.ContentType;
56
import com.codeit.team5.mopl.content.entity.QContent;
67
import com.codeit.team5.mopl.content.entity.QContentStats;
78
import com.codeit.team5.mopl.content.entity.QContentTag;
89
import com.codeit.team5.mopl.tag.entity.QTag;
910
import com.querydsl.core.BooleanBuilder;
1011
import com.querydsl.core.types.OrderSpecifier;
1112
import com.querydsl.core.types.dsl.BooleanExpression;
13+
import com.querydsl.core.types.dsl.NumberExpression;
1214
import com.querydsl.jpa.impl.JPAQueryFactory;
1315
import java.time.Instant;
1416
import java.util.List;
@@ -21,7 +23,7 @@
2123
// hasNext 판별을 Testcontainers(PostgreSQL) 기반 @DataJpaTest로 검증 필요
2224
@Repository
2325
@RequiredArgsConstructor
24-
public class ContentRepositoryImpl implements ContentRepositoryCustom {
26+
public class ContentQueryRepositoryImpl implements ContentQueryRepository {
2527

2628
private final JPAQueryFactory queryFactory;
2729

@@ -30,11 +32,6 @@ public class ContentRepositoryImpl implements ContentRepositoryCustom {
3032
private static final QContentTag contentTag = QContentTag.contentTag;
3133
private static final QTag tag = QTag.tag;
3234

33-
/**
34-
* 클라이언트 요청 조건에 맞는 Content 목록을 조회합니다.
35-
* To-One 관계인 stats는 fetchJoin으로 한 번에 가져와 N+1을 방지하고,
36-
* Limit을 적용하여 다음 페이지 확인을 위한 데이터(fetchLimit)까지 안전하게 조회합니다.
37-
*/
3835
@Override
3936
public List<Content> findContents(ContentCursorRequest request, int fetchLimit) {
4037
return queryFactory
@@ -46,10 +43,6 @@ public List<Content> findContents(ContentCursorRequest request, int fetchLimit)
4643
.fetch();
4744
}
4845

49-
/**
50-
* 전체 검색 결과의 총 개수를 반환합니다.
51-
* 페이징 기준점(커서)은 개수 산정에 영향을 주지 않으므로 커서 조건은 제외하고 검색(Filter) 조건만 적용합니다.
52-
*/
5346
@Override
5447
public long countContents(ContentCursorRequest request) {
5548
BooleanBuilder where = new BooleanBuilder();
@@ -62,47 +55,43 @@ public long countContents(ContentCursorRequest request) {
6255
return count != null ? count : 0L;
6356
}
6457

65-
/**
66-
* 동적 쿼리의 핵심 조립기입니다.
67-
* 빈 BooleanBuilder 상자에 검색 조건(Filters)과 커서 조건(Cursor)을 차례대로 안전하게 담아 반환합니다.
68-
*/
6958
private BooleanBuilder buildWhere(ContentCursorRequest request) {
7059
BooleanBuilder where = new BooleanBuilder();
7160
applyFilters(where, request);
7261
applyCursor(where, request);
7362
return where;
7463
}
7564

76-
/**
77-
* 사용자가 입력한 검색 조건(타입, 키워드, 태그)을 쿼리에 추가합니다.
78-
* 특히 태그(To-Many) 검색 시 서브쿼리를 사용하여, 메인 쿼리의 Row 뻥튀기 및 페이징 데이터 유실을 방지합니다.
79-
*/
8065
private void applyFilters(BooleanBuilder where, ContentCursorRequest request) {
81-
if (request.typeEqual() != null) {
82-
where.and(content.type.eq(request.typeEqual()));
83-
}
84-
if (request.keywordLike() != null && !request.keywordLike().isBlank()) {
85-
where.and(content.title.containsIgnoreCase(request.keywordLike())
86-
.or(content.description.containsIgnoreCase(request.keywordLike())));
87-
}
88-
if (request.tagsIn() != null && !request.tagsIn().isEmpty()) {
89-
List<String> normalizedTags = request.tagsIn().stream()
90-
.map(String::trim)
91-
.map(String::toLowerCase)
92-
.toList();
93-
where.and(content.id.in(
94-
queryFactory.select(contentTag.content.id)
95-
.from(contentTag)
96-
.join(contentTag.tag, tag)
97-
.where(tag.name.in(normalizedTags))
98-
));
99-
}
66+
where.and(typeFilter(request.typeEqual()));
67+
where.and(keywordFilter(request.keywordLike()));
68+
where.and(tagsFilter(request.tagsIn()));
69+
}
70+
71+
private BooleanExpression typeFilter(ContentType typeEqual) {
72+
return typeEqual != null ? content.type.eq(typeEqual) : null;
73+
}
74+
75+
private BooleanExpression keywordFilter(String keyword) {
76+
if (keyword == null || keyword.isBlank()) return null;
77+
return content.title.containsIgnoreCase(keyword)
78+
.or(content.description.containsIgnoreCase(keyword));
79+
}
80+
81+
private BooleanExpression tagsFilter(List<String> tagsIn) {
82+
if (tagsIn == null || tagsIn.isEmpty()) return null;
83+
List<String> normalizedTags = tagsIn.stream()
84+
.map(String::trim)
85+
.map(String::toLowerCase)
86+
.toList();
87+
return content.id.in(
88+
queryFactory.select(contentTag.content.id)
89+
.from(contentTag)
90+
.join(contentTag.tag, tag)
91+
.where(tag.name.in(normalizedTags))
92+
);
10093
}
10194

102-
/**
103-
* 무한 스크롤 시 중복 조회를 방지하기 위한 다음 페이지 기준점(Keyset) 조건을 설정합니다.
104-
* 정렬 기준값과 고유 ID를 조합하고, 엄격한 부등호(<, >)를 사용하여 방금 본 마지막 데이터가 쿼리에서 완벽히 제외되도록 처리합니다.
105-
*/
10695
private void applyCursor(BooleanBuilder where, ContentCursorRequest request) {
10796
String cursor = request.cursor();
10897
String idAfter = request.idAfter();
@@ -132,26 +121,24 @@ private void applyCursor(BooleanBuilder where, ContentCursorRequest request) {
132121
}
133122
case RATE -> {
134123
double cursorVal = Double.parseDouble(cursor);
124+
NumberExpression<Double> avgRating = stats.ratingSum.divide(stats.reviewCount.doubleValue());
135125
yield isAsc
136-
? stats.averageRating.gt(cursorVal)
137-
.or(stats.averageRating.eq(cursorVal).and(content.id.gt(id)))
138-
: stats.averageRating.lt(cursorVal)
139-
.or(stats.averageRating.eq(cursorVal).and(content.id.lt(id)));
126+
? avgRating.gt(cursorVal)
127+
.or(avgRating.eq(cursorVal).and(content.id.gt(id)))
128+
: avgRating.lt(cursorVal)
129+
.or(avgRating.eq(cursorVal).and(content.id.lt(id)));
140130
}
141131
};
142132
where.and(cursorCondition);
143133
}
144134

145-
/**
146-
* 클라이언트의 정렬 요청(Enum)을 안전한 Q-Class 필드와 매핑합니다.
147-
* 오타로 인한 런타임 에러를 방지(타입 안정성)하며, 값이 같을 경우 고유 ID로 2차 정렬하여 페이징 순서가 꼬이는 것을 막습니다.
148-
*/
149135
private OrderSpecifier<?>[] buildOrder(ContentCursorRequest request) {
150136
boolean isAsc = request.sortDirection() == Direction.ASC;
137+
NumberExpression<Double> avgRating = stats.ratingSum.divide(stats.reviewCount.doubleValue());
151138
OrderSpecifier<?> primary = switch (request.sortBy()) {
152139
case CREATED_AT -> isAsc ? content.createdAt.asc() : content.createdAt.desc();
153140
case WATCHER_COUNT -> isAsc ? stats.watcherCount.asc() : stats.watcherCount.desc();
154-
case RATE -> isAsc ? stats.averageRating.asc() : stats.averageRating.desc();
141+
case RATE -> isAsc ? avgRating.asc() : avgRating.desc();
155142
};
156143
OrderSpecifier<?> secondary = isAsc ? content.id.asc() : content.id.desc();
157144
return new OrderSpecifier<?>[]{ primary, secondary };

src/main/resources/db/migration/V12__add_average_rating_to_content_stats.sql

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)