Skip to content

Commit 97f9d5f

Browse files
authored
[FEAT]: 게시글 검색 전문 검색 적용 (#138)
* [Feat]: 검색어 검색 시 Like 기반 -> 풀텍스트 서치 적용 * [Fix]: 댓글 생성 시 transactional readonly false * [Refactor]: 검색 시 데이터 최대 1만개 조회되도록 변경 * [Refactor]: 로컬, 운영 레포지토리 분리 * [Refactor]: 이름 변경 * [Refactor]: 정렬 조건 sql 인젝션 방지용 화이트리스트 추가 * [Fix]: 버전 변경
1 parent c4dbe6c commit 97f9d5f

File tree

7 files changed

+297
-6
lines changed

7 files changed

+297
-6
lines changed

back/src/main/java/com/back/domain/comment/service/CommentService.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public class CommentService {
3535
private final CommentRepository commentRepository;
3636
private final CommentLikeRepository commentLikeRepository;
3737

38+
@Transactional
3839
public CommentResponse createComment(User user, Long postId, CommentRequest request) {
3940
Post post = postRepository.findById(postId)
4041
.orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND));

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

Lines changed: 14 additions & 1 deletion
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)
@@ -76,6 +76,19 @@ public class Post extends BaseEntity {
7676
@JoinColumn(name = "scenario_id")
7777
private Scenario scenario;
7878

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

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: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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+
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,12 @@
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;
1312
import lombok.RequiredArgsConstructor;
13+
import org.springframework.context.annotation.Profile;
1414
import org.springframework.data.domain.Page;
1515
import org.springframework.data.domain.Pageable;
16-
import org.springframework.data.domain.Sort;
1716
import org.springframework.data.support.PageableExecutionUtils;
1817
import org.springframework.stereotype.Repository;
1918
import org.springframework.util.StringUtils;
@@ -23,6 +22,7 @@
2322
import static com.back.domain.post.entity.QPost.post;
2423
import static com.back.domain.user.entity.QUser.user;
2524

25+
@Profile("!prod")
2626
@RequiredArgsConstructor
2727
@Repository
2828
public class PostRepositoryCustomImpl implements PostRepositoryCustom {
@@ -103,4 +103,4 @@ private OrderSpecifier<?>[] toOrderSpecifier(Pageable pageable) {
103103
})
104104
.toArray(OrderSpecifier[]::new);
105105
}
106-
}
106+
}

back/src/main/java/com/back/domain/post/service/PostService.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import com.back.domain.post.enums.PostCategory;
1313
import com.back.domain.post.mapper.PostMappers;
1414
import com.back.domain.post.repository.PostRepository;
15+
import com.back.domain.post.repository.PostRepositoryCustom;
1516
import com.back.domain.scenario.dto.ScenarioDetailResponse;
1617
import com.back.domain.scenario.entity.Scenario;
1718
import com.back.domain.scenario.entity.SceneType;
@@ -43,6 +44,7 @@
4344
public class PostService {
4445

4546
private final PostRepository postRepository;
47+
private final PostRepositoryCustom postRepositoryCustom;
4648
private final PostLikeRepository postLikeRepository;
4749
private final PollVoteRepository pollVoteRepository;
4850
private final ScenarioRepository scenarioRepository;
@@ -111,7 +113,7 @@ private PollOptionResponse getPollInfo(User user, Long postId, Post post) {
111113
}
112114

113115
public Page<PostSummaryResponse> getPosts(User user, PostSearchCondition condition, Pageable pageable) {
114-
Page<Post> posts = postRepository.searchPosts(condition, pageable);
116+
Page<Post> posts = postRepositoryCustom.searchPosts(condition, pageable);
115117

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

0 commit comments

Comments
 (0)