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