Skip to content

Commit b2e6b8c

Browse files
committed
[Refactor]: 로컬, 운영 레포지토리 분리
1 parent 9c45ca0 commit b2e6b8c

File tree

5 files changed

+260
-178
lines changed

5 files changed

+260
-178
lines changed

back/src/main/java/com/back/domain/post/entity/Post.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import org.hibernate.annotations.JdbcTypeCode;
1616
import org.hibernate.service.spi.ServiceException;
1717
import org.hibernate.type.SqlTypes;
18+
import org.springframework.context.annotation.Profile;
1819
import org.springframework.data.annotation.LastModifiedDate;
1920

2021
import java.time.LocalDateTime;
@@ -30,7 +31,6 @@
3031
columnList = "category, created_date DESC"),
3132
@Index(name = "idx_post_user_created",
3233
columnList = "user_id, created_date DESC"),
33-
// @Index(name = "idx_post_title", columnList = "title")
3434
})
3535
@NoArgsConstructor(access = AccessLevel.PROTECTED)
3636
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@@ -77,14 +77,13 @@ public class Post extends BaseEntity {
7777
private Scenario scenario;
7878

7979
/**
80-
* PostgreSQL tsvector 타입 컬럼
80+
* 마이그레이션 alter문으로 postgreSQL tsvector 타입 컬럼 지정
8181
* - 트리거에 의해 자동으로 업데이트됨
8282
* - 직접 값을 설정할 필요 없음
8383
* - GIN 인덱스로 빠른 검색 지원
8484
*/
8585
@Column(
8686
name = "search_vector",
87-
columnDefinition = "tsvector",
8887
insertable = false,
8988
updatable = false
9089
)

back/src/main/java/com/back/domain/post/repository/PostRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* 게시글 엔티티에 대한 데이터베이스 접근을 담당하는 JpaRepository.
1717
*/
1818
@Repository
19-
public interface PostRepository extends JpaRepository<Post, Long>, PostRepositoryCustom {
19+
public interface PostRepository extends JpaRepository<Post, Long> {
2020
// @Lock(LockModeType.PESSIMISTIC_WRITE)
2121
// @Query("SELECT p FROM Post p WHERE p.id = :postId")
2222
// Optional<Post> findByIdWithLock(@Param("postId") Long postId);
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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

Comments
 (0)