1+ package com .back .domain .post .repository ;
2+
3+ import com .back .domain .post .dto .PostSearchCondition ;
4+ import com .back .domain .post .entity .Post ;
5+ import com .back .domain .post .enums .SearchType ;
6+ import jakarta .persistence .EntityManager ;
7+ import jakarta .persistence .Query ;
8+ import lombok .RequiredArgsConstructor ;
9+ import org .springframework .context .annotation .Profile ;
10+ import org .springframework .data .domain .Page ;
11+ import org .springframework .data .domain .PageImpl ;
12+ import org .springframework .data .domain .Pageable ;
13+ import org .springframework .data .domain .Sort ;
14+ import org .springframework .stereotype .Repository ;
15+ import org .springframework .util .StringUtils ;
16+
17+ import java .util .ArrayList ;
18+ import java .util .List ;
19+
20+ @ Profile ("prod" )
21+ @ RequiredArgsConstructor
22+ @ Repository
23+ public class PostRepositoryCustomFullTextImpl implements PostRepositoryCustom {
24+
25+ private final EntityManager em ;
26+
27+ @ Override
28+ public Page <Post > searchPosts (PostSearchCondition condition , Pageable pageable ) {
29+ // Full-Text Search가 필요한 경우
30+ if (isFullTextSearchRequired (condition )) {
31+ return searchPostsWithFullText (condition , pageable );
32+ }
33+
34+ // 일반 검색 (카테고리만 있거나, AUTHOR 검색)
35+ return searchPostsWithNativeQuery (condition , pageable );
36+ }
37+
38+ private Page <Post > searchPostsWithNativeQuery (PostSearchCondition condition , Pageable pageable ) {
39+ StringBuilder sql = new StringBuilder ();
40+ sql .append ("SELECT p.* FROM post p LEFT JOIN users u ON p.user_id = u.id " );
41+
42+ if (condition .category () != null ) {
43+ sql .append ("WHERE p.category = :category " );
44+ }
45+
46+ // 정렬 반영
47+ if (!pageable .getSort ().isEmpty ()) {
48+ sql .append ("ORDER BY " ).append (buildOrderByColumns (pageable ));
49+ } else {
50+ sql .append ("ORDER BY p.created_date DESC" );
51+ }
52+
53+ sql .append (" LIMIT :limit OFFSET :offset" );
54+
55+ Query query = em .createNativeQuery (sql .toString (), Post .class );
56+
57+ if (condition .category () != null ) {
58+ query .setParameter ("category" , condition .category ().name ());
59+ }
60+ query .setParameter ("limit" , pageable .getPageSize ());
61+ query .setParameter ("offset" , pageable .getOffset ());
62+
63+ List <Post > posts = query .getResultList ();
64+ long total = countPosts (condition );
65+
66+ return new PageImpl <>(posts , pageable , total );
67+ }
68+
69+ private long countPosts (PostSearchCondition condition ) {
70+ String sql = "SELECT COUNT(*) FROM post p" ;
71+ if (condition .category () != null ) {
72+ sql += " WHERE p.category = :category" ;
73+ }
74+
75+ Query query = em .createNativeQuery (sql );
76+
77+ if (condition .category () != null ) {
78+ query .setParameter ("category" , condition .category ().name ());
79+ }
80+
81+ return ((Number ) query .getSingleResult ()).longValue ();
82+ }
83+
84+
85+ /**
86+ * 제목, 제목+내용 + 검색어 검색 시 Full-Text Search 적용
87+ */
88+ private boolean isFullTextSearchRequired (PostSearchCondition condition ) {
89+ return StringUtils .hasText (condition .keyword ())
90+ && (condition .searchType () == SearchType .TITLE
91+ || condition .searchType () == SearchType .TITLE_CONTENT );
92+ }
93+
94+ /**
95+ * Native Query로 Full-Text Search 수행
96+ */
97+ private Page <Post > searchPostsWithFullText (PostSearchCondition condition , Pageable pageable ) {
98+ String tsQuery = buildTsQuery (condition .keyword ());
99+
100+ // 데이터 조회
101+ String dataSql = buildDataQuery (condition , pageable );
102+ Query dataQuery = em .createNativeQuery (dataSql , Post .class );
103+ setQueryParameters (dataQuery , condition , tsQuery , pageable );
104+
105+ List <Post > posts = dataQuery .getResultList ();
106+
107+ // 전체 카운트 조회
108+ long total = countWithFullText (condition , tsQuery );
109+
110+ return new PageImpl <>(posts , pageable , total );
111+ }
112+
113+ /**
114+ * 데이터 조회 쿼리 생성
115+ */
116+ private String buildDataQuery (PostSearchCondition condition , Pageable pageable ) {
117+ StringBuilder sql = new StringBuilder ();
118+ sql .append ("SELECT p.*, " );
119+ sql .append ("ts_rank(p.search_vector, plainto_tsquery('simple', :tsQuery)) as rank " );
120+ sql .append ("FROM post p " );
121+ sql .append ("LEFT JOIN users u ON p.user_id = u.id " );
122+ sql .append ("WHERE " );
123+
124+ // Full-Text Search 조건
125+ sql .append (getFullTextCondition (condition .searchType ())).append (" " );
126+
127+ // 카테고리 조건
128+ if (condition .category () != null ) {
129+ sql .append ("AND p.category = :category " );
130+ }
131+
132+ // 정렬: rank 우선, 그 다음 사용자 지정 정렬
133+ sql .append ("ORDER BY rank DESC" );
134+
135+ if (!pageable .getSort ().isEmpty ()) {
136+ sql .append (", " );
137+ sql .append (buildOrderByColumns (pageable ));
138+ sql .append (" " ); // 공백 추가
139+ } else {
140+ sql .append (", p.created_date DESC " );
141+ }
142+
143+ // 페이징
144+ sql .append ("LIMIT :limit OFFSET :offset" );
145+
146+ return sql .toString ();
147+ }
148+
149+ private String buildOrderByColumns (Pageable pageable ) {
150+ List <String > orders = new ArrayList <>();
151+
152+ for (Sort .Order order : pageable .getSort ()) {
153+ String column = switch (order .getProperty ()) {
154+ case "createdDate" -> "p.created_date" ;
155+ case "likeCount" -> "p.like_count" ;
156+ default -> "p.created_date" ;
157+ };
158+ String direction = order .isAscending () ? "ASC" : "DESC" ;
159+ orders .add (column + " " + direction );
160+ }
161+
162+ return String .join (", " , orders );
163+ }
164+
165+ /**
166+ * Full-Text Search 조건 생성
167+ */
168+ private String getFullTextCondition (SearchType searchType ) {
169+ return switch (searchType ) {
170+ case TITLE , TITLE_CONTENT -> "p.search_vector @@ plainto_tsquery('simple', :tsQuery)" ;
171+ default -> "1=1" ;
172+ };
173+ }
174+
175+
176+ /**
177+ * ts_query 문자열 생성 (띄어쓰기 = AND 연산)
178+ */
179+ private String buildTsQuery (String keyword ) {
180+ return keyword .trim ();
181+ }
182+
183+ /**
184+ * 쿼리 파라미터 설정
185+ */
186+ private void setQueryParameters (Query query , PostSearchCondition condition , String tsQuery , Pageable pageable ) {
187+ if (condition .category () != null ) {
188+ query .setParameter ("category" , condition .category ().name ());
189+ }
190+ query .setParameter ("tsQuery" , tsQuery );
191+ query .setParameter ("limit" , pageable .getPageSize ());
192+ query .setParameter ("offset" , pageable .getOffset ());
193+ }
194+
195+ /**
196+ * 전체 개수 조회
197+ */
198+ private long countWithFullText (PostSearchCondition condition , String tsQuery ) {
199+ // 10,000건 이상이면 "10,000+" 표시
200+ long maxCount = 10000 ;
201+
202+ StringBuilder sql = new StringBuilder ();
203+ sql .append ("SELECT COUNT(*) FROM (" );
204+ sql .append (" SELECT 1 FROM post p " );
205+ sql .append (" WHERE 1=1 " );
206+
207+ if (condition .category () != null ) {
208+ sql .append (" AND p.category = :category " );
209+ }
210+
211+ sql .append (" AND " ).append (getFullTextCondition (condition .searchType ()));
212+ sql .append (" LIMIT :maxCount" );
213+ sql .append (") subquery" );
214+
215+ Query countQuery = em .createNativeQuery (sql .toString ());
216+
217+ if (condition .category () != null ) {
218+ countQuery .setParameter ("category" , condition .category ().name ());
219+ }
220+ countQuery .setParameter ("tsQuery" , tsQuery );
221+ countQuery .setParameter ("maxCount" , maxCount );
222+
223+ return ((Number ) countQuery .getSingleResult ()).longValue ();
224+ }
225+
226+ }
0 commit comments