22
33import com .codeit .team5 .mopl .content .dto .request .ContentCursorRequest ;
44import com .codeit .team5 .mopl .content .entity .Content ;
5+ import com .codeit .team5 .mopl .content .entity .ContentType ;
56import com .codeit .team5 .mopl .content .entity .QContent ;
67import com .codeit .team5 .mopl .content .entity .QContentStats ;
78import com .codeit .team5 .mopl .content .entity .QContentTag ;
89import com .codeit .team5 .mopl .tag .entity .QTag ;
910import com .querydsl .core .BooleanBuilder ;
1011import com .querydsl .core .types .OrderSpecifier ;
1112import com .querydsl .core .types .dsl .BooleanExpression ;
13+ import com .querydsl .core .types .dsl .NumberExpression ;
1214import com .querydsl .jpa .impl .JPAQueryFactory ;
1315import java .time .Instant ;
1416import java .util .List ;
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 };
0 commit comments