77import com .querydsl .core .types .Order ;
88import com .querydsl .core .types .OrderSpecifier ;
99import com .querydsl .core .types .dsl .BooleanExpression ;
10- import com .querydsl .core .types .dsl .PathBuilder ;
1110import com .querydsl .jpa .impl .JPAQuery ;
1211import com .querydsl .jpa .impl .JPAQueryFactory ;
12+ import jakarta .persistence .EntityManager ;
13+ import jakarta .persistence .Query ;
1314import lombok .RequiredArgsConstructor ;
1415import org .springframework .data .domain .Page ;
16+ import org .springframework .data .domain .PageImpl ;
1517import org .springframework .data .domain .Pageable ;
1618import org .springframework .data .domain .Sort ;
1719import org .springframework .data .support .PageableExecutionUtils ;
1820import org .springframework .stereotype .Repository ;
1921import org .springframework .util .StringUtils ;
2022
23+ import java .util .ArrayList ;
2124import java .util .List ;
2225
2326import static com .back .domain .post .entity .QPost .post ;
2629@ RequiredArgsConstructor
2730@ Repository
2831public class PostRepositoryCustomImpl implements PostRepositoryCustom {
32+
2933 private final JPAQueryFactory queryFactory ;
34+ private final EntityManager em ;
3035
3136 @ Override
3237 public Page <Post > searchPosts (PostSearchCondition condition , Pageable pageable ) {
38+ // Full-Text Search가 필요한 경우
39+ if (isFullTextSearchRequired (condition )) {
40+ return searchPostsWithFullText (condition , pageable );
41+ }
42+
43+ // 일반 검색 (카테고리만 있거나, AUTHOR 검색)
44+ return searchPostsWithQueryDSL (condition , pageable );
45+ }
46+
47+ /**
48+ * Full-Text Search가 필요한지 판단
49+ */
50+ private boolean isFullTextSearchRequired (PostSearchCondition condition ) {
51+ return StringUtils .hasText (condition .keyword ())
52+ && (condition .searchType () == SearchType .TITLE
53+ || condition .searchType () == SearchType .TITLE_CONTENT );
54+ }
55+
56+ /**
57+ * Native Query로 Full-Text Search 수행
58+ */
59+ private Page <Post > searchPostsWithFullText (PostSearchCondition condition , Pageable pageable ) {
60+ String tsQuery = buildTsQuery (condition .keyword ());
61+
62+ // 데이터 조회
63+ String dataSql = buildDataQuery (condition , pageable );
64+ Query dataQuery = em .createNativeQuery (dataSql , Post .class );
65+ setQueryParameters (dataQuery , condition , tsQuery , pageable );
66+
67+ @ SuppressWarnings ("unchecked" )
68+ List <Post > posts = dataQuery .getResultList ();
69+
70+ // 전체 카운트 조회
71+ long total = countWithFullText (condition , tsQuery );
72+
73+ return new PageImpl <>(posts , pageable , total );
74+ }
75+
76+ /**
77+ * 데이터 조회 쿼리 생성
78+ */
79+ private String buildDataQuery (PostSearchCondition condition , Pageable pageable ) {
80+ StringBuilder sql = new StringBuilder ();
81+ sql .append ("SELECT p.* FROM post p " );
82+ sql .append ("LEFT JOIN users u ON p.user_id = u.id " );
83+ sql .append ("WHERE 1=1 " );
84+
85+ // 카테고리 조건
86+ if (condition .category () != null ) {
87+ sql .append ("AND p.category = :category " );
88+ }
89+
90+ // Full-Text Search 조건
91+ sql .append ("AND " ).append (getFullTextCondition (condition .searchType ())).append (" " );
92+
93+ // 정렬
94+ sql .append (buildOrderBy (pageable ));
95+
96+ // 페이징
97+ sql .append ("LIMIT :limit OFFSET :offset" );
98+
99+ return sql .toString ();
100+ }
101+
102+ /**
103+ * Full-Text Search 조건 생성
104+ */
105+ private String getFullTextCondition (SearchType searchType ) {
106+ return switch (searchType ) {
107+ case TITLE -> "p.search_vector @@ to_tsquery('simple', :tsQuery)" ;
108+ case TITLE_CONTENT -> "p.search_vector @@ to_tsquery('simple', :tsQuery)" ;
109+ default -> "1=1" ;
110+ };
111+ }
112+
113+ /**
114+ * ts_query 문자열 생성 (띄어쓰기 = AND 연산)
115+ */
116+ private String buildTsQuery (String keyword ) {
117+ return keyword .trim ().replaceAll ("\\ s+" , " & " );
118+ }
119+
120+ /**
121+ * 정렬 조건 생성
122+ */
123+ private String buildOrderBy (Pageable pageable ) {
124+ if (pageable .getSort ().isEmpty ()) {
125+ return "ORDER BY p.created_date DESC " ;
126+ }
127+
128+ StringBuilder orderBy = new StringBuilder ("ORDER BY " );
129+ List <String > orders = new ArrayList <>();
130+
131+ for (Sort .Order order : pageable .getSort ()) {
132+ String column = switch (order .getProperty ()) {
133+ case "createdDate" -> "p.created_date" ;
134+ case "likeCount" -> "p.like_count" ;
135+ default -> "p.created_date" ;
136+ };
137+ String direction = order .isAscending () ? "ASC" : "DESC" ;
138+ orders .add (column + " " + direction );
139+ }
140+
141+ orderBy .append (String .join (", " , orders )).append (" " );
142+ return orderBy .toString ();
143+ }
144+
145+ /**
146+ * 쿼리 파라미터 설정
147+ */
148+ private void setQueryParameters (Query query , PostSearchCondition condition , String tsQuery , Pageable pageable ) {
149+ if (condition .category () != null ) {
150+ query .setParameter ("category" , condition .category ().name ());
151+ }
152+ query .setParameter ("tsQuery" , tsQuery );
153+ query .setParameter ("limit" , pageable .getPageSize ());
154+ query .setParameter ("offset" , pageable .getOffset ());
155+ }
156+
157+ /**
158+ * 전체 개수 조회
159+ */
160+ private long countWithFullText (PostSearchCondition condition , String tsQuery ) {
161+ StringBuilder sql = new StringBuilder ();
162+ sql .append ("SELECT COUNT(*) FROM post p " );
163+ sql .append ("WHERE 1=1 " );
164+
165+ if (condition .category () != null ) {
166+ sql .append ("AND p.category = :category " );
167+ }
168+
169+ sql .append ("AND " ).append (getFullTextCondition (condition .searchType ()));
170+
171+ Query countQuery = em .createNativeQuery (sql .toString ());
172+
173+ if (condition .category () != null ) {
174+ countQuery .setParameter ("category" , condition .category ().name ());
175+ }
176+ countQuery .setParameter ("tsQuery" , tsQuery );
177+
178+ return ((Number ) countQuery .getSingleResult ()).longValue ();
179+ }
180+
181+ /**
182+ * QueryDSL을 사용한 일반 검색 (카테고리만 있거나 AUTHOR 검색)
183+ */
184+ private Page <Post > searchPostsWithQueryDSL (PostSearchCondition condition , Pageable pageable ) {
33185 List <Post > posts = queryFactory
34186 .selectFrom (post )
35187 .leftJoin (post .user , user ).fetchJoin ()
36- .where (getCategoryCondition (condition .category ()),
37- getSearchCondition (condition .keyword (), condition .searchType ()),
38- excludeHiddenIfSearch (condition .keyword (), condition .searchType ()))
188+ .where (
189+ getCategoryCondition (condition .category ()),
190+ getAuthorSearchCondition (condition .keyword (), condition .searchType ()),
191+ excludeHiddenIfAuthorSearch (condition .keyword (), condition .searchType ())
192+ )
39193 .orderBy (toOrderSpecifier (pageable ))
40194 .offset (pageable .getOffset ())
41195 .limit (pageable .getPageSize ())
42196 .fetch ();
43197
44- JPAQuery <Long > count = queryFactory
198+ JPAQuery <Long > countQuery = queryFactory
45199 .select (post .count ())
46200 .from (post )
47201 .where (
48202 getCategoryCondition (condition .category ()),
49- getSearchCondition (condition .keyword (), condition .searchType ()),
50- excludeHiddenIfSearch (condition .keyword (), condition .searchType ())
203+ getAuthorSearchCondition (condition .keyword (), condition .searchType ()),
204+ excludeHiddenIfAuthorSearch (condition .keyword (), condition .searchType ())
51205 );
52206
53- return PageableExecutionUtils .getPage (posts , pageable , count ::fetchOne );
207+ return PageableExecutionUtils .getPage (posts , pageable , countQuery ::fetchOne );
54208 }
55209
56- /**
57- * 1차 필터링 (CHAT, SCENARIO, POLL)
58- * category 조건이 null이 아니면 필터링 조건 추가
59- */
60210 private BooleanExpression getCategoryCondition (PostCategory category ) {
61211 return category != null ? post .category .eq (category ) : null ;
62212 }
63213
64- /**
65- * 2차 필터링 (TITLE, TITLE_CONTENT, AUTHOR)
66- * fixme 현재 like 기반 검색 - 성능 최적화를 위해 추후에 수정 예정
67- */
68- private BooleanExpression getSearchCondition (String searchKeyword , SearchType searchType ) {
69- if (!StringUtils .hasText (searchKeyword ) || searchType == null ) {
214+ private BooleanExpression getAuthorSearchCondition (String searchKeyword , SearchType searchType ) {
215+ if (!StringUtils .hasText (searchKeyword ) || searchType != SearchType .AUTHOR ) {
70216 return null ;
71217 }
72-
73- return switch (searchType ) {
74- case TITLE -> post .title .containsIgnoreCase (searchKeyword );
75- case TITLE_CONTENT -> post .title .containsIgnoreCase (searchKeyword )
76- .or (post .content .containsIgnoreCase (searchKeyword ));
77- case AUTHOR -> post .user .nickname .containsIgnoreCase (searchKeyword );
78- };
218+ return post .user .nickname .containsIgnoreCase (searchKeyword );
79219 }
80220
81- private BooleanExpression excludeHiddenIfSearch (String searchKeyword , SearchType searchType ) {
221+ private BooleanExpression excludeHiddenIfAuthorSearch (String searchKeyword , SearchType searchType ) {
82222 if (!StringUtils .hasText (searchKeyword ) || searchType != SearchType .AUTHOR ) {
83223 return null ;
84224 }
@@ -94,7 +234,6 @@ private OrderSpecifier<?>[] toOrderSpecifier(Pageable pageable) {
94234 .map (order -> {
95235 Order direction = order .isAscending () ? Order .ASC : Order .DESC ;
96236 String property = order .getProperty ();
97-
98237 return switch (property ) {
99238 case "createdDate" -> new OrderSpecifier <>(direction , post .createdDate );
100239 case "likeCount" -> new OrderSpecifier <>(direction , post .likeCount );
@@ -103,4 +242,4 @@ private OrderSpecifier<?>[] toOrderSpecifier(Pageable pageable) {
103242 })
104243 .toArray (OrderSpecifier []::new );
105244 }
106- }
245+ }
0 commit comments