Skip to content

Commit 8de7c48

Browse files
committed
[Feat]: 검색어 검색 시 Like 기반 -> 풀텍스트 서치 적용
1 parent d0bf078 commit 8de7c48

File tree

3 files changed

+219
-29
lines changed

3 files changed

+219
-29
lines changed

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
columnList = "category, created_date DESC"),
3131
@Index(name = "idx_post_user_created",
3232
columnList = "user_id, created_date DESC"),
33-
@Index(name = "idx_post_title", columnList = "title")
33+
// @Index(name = "idx_post_title", columnList = "title")
3434
})
3535
@NoArgsConstructor(access = AccessLevel.PROTECTED)
3636
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@@ -76,6 +76,20 @@ public class Post extends BaseEntity {
7676
@JoinColumn(name = "scenario_id")
7777
private Scenario scenario;
7878

79+
/**
80+
* PostgreSQL tsvector 타입 컬럼
81+
* - 트리거에 의해 자동으로 업데이트됨
82+
* - 직접 값을 설정할 필요 없음
83+
* - GIN 인덱스로 빠른 검색 지원
84+
*/
85+
@Column(
86+
name = "search_vector",
87+
columnDefinition = "tsvector",
88+
insertable = false,
89+
updatable = false
90+
)
91+
private String searchVector;
92+
7993
public void updatePost(String title, String content, PostCategory category) {
8094
this.title = title;
8195
this.content = content;

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

Lines changed: 167 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,20 @@
77
import com.querydsl.core.types.Order;
88
import com.querydsl.core.types.OrderSpecifier;
99
import com.querydsl.core.types.dsl.BooleanExpression;
10-
import com.querydsl.core.types.dsl.PathBuilder;
1110
import com.querydsl.jpa.impl.JPAQuery;
1211
import com.querydsl.jpa.impl.JPAQueryFactory;
12+
import jakarta.persistence.EntityManager;
13+
import jakarta.persistence.Query;
1314
import lombok.RequiredArgsConstructor;
1415
import org.springframework.data.domain.Page;
16+
import org.springframework.data.domain.PageImpl;
1517
import org.springframework.data.domain.Pageable;
1618
import org.springframework.data.domain.Sort;
1719
import org.springframework.data.support.PageableExecutionUtils;
1820
import org.springframework.stereotype.Repository;
1921
import org.springframework.util.StringUtils;
2022

23+
import java.util.ArrayList;
2124
import java.util.List;
2225

2326
import static com.back.domain.post.entity.QPost.post;
@@ -26,59 +29,196 @@
2629
@RequiredArgsConstructor
2730
@Repository
2831
public 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+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
-- ============================================
2+
-- PostgreSQL Full-Text Search 마이그레이션
3+
-- ============================================
4+
5+
-- 1. 카테고리 인덱스, 최신순 정렬을 위한 인덱스 추가
6+
CREATE INDEX idx_post_category ON post(category);
7+
CREATE INDEX idx_post_created_desc ON post(created_date DESC);
8+
9+
-- 2. 기존 제목 인덱스 - 풀텍스트 서치 영향 미치지 않으므로 제거
10+
DROP INDEX IF EXISTS idx_post_title;
11+
12+
-- 3. 검색 컬럼 추가
13+
ALTER TABLE post ADD COLUMN search_vector tsvector;
14+
15+
-- 4. GIN 인덱스 생성
16+
CREATE INDEX idx_post_search_vector ON post USING GIN (search_vector);
17+
18+
-- 5. 자동 업데이트 함수 + 트리거
19+
CREATE OR REPLACE FUNCTION post_search_vector_update()
20+
RETURNS TRIGGER AS $$
21+
BEGIN
22+
NEW.search_vector :=
23+
setweight(to_tsvector('simple', COALESCE(NEW.title, '')), 'A') ||
24+
setweight(to_tsvector('simple', COALESCE(NEW.content, '')), 'B');
25+
RETURN NEW;
26+
END;
27+
$$ LANGUAGE plpgsql;
28+
29+
CREATE TRIGGER tsvector_update
30+
BEFORE INSERT OR UPDATE OF title, content ON post
31+
FOR EACH ROW EXECUTE FUNCTION post_search_vector_update();
32+
33+
-- 6. 기존 데이터 업데이트
34+
UPDATE post SET search_vector =
35+
setweight(to_tsvector('simple', COALESCE(title, '')), 'A') ||
36+
setweight(to_tsvector('simple', COALESCE(content, '')), 'B')
37+
WHERE search_vector IS NULL;

0 commit comments

Comments
 (0)