Skip to content

Commit 68d0aa9

Browse files
authored
Merge pull request #97 from SEMOSAN/feat/#84-free-post-search
[Feat] 자유게시판 키워드 검색 API 및 목록 응답 개선
2 parents 4e05246 + 0d3e2a5 commit 68d0aa9

9 files changed

Lines changed: 75 additions & 13 deletions

File tree

src/main/java/com/semosan/api/common/status/SuccessStatus.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ public enum SuccessStatus implements BaseStatus {
109109
FREE_POST_LIST_SUCCESS(HttpStatus.OK, "FPOST_200_1", "자유게시판 게시글 목록 조회에 성공했습니다."),
110110
FREE_POST_MY_LIST_SUCCESS(HttpStatus.OK, "FPOST_200_2", "내 자유게시판 게시글 목록 조회에 성공했습니다."),
111111
FREE_POST_DETAIL_SUCCESS(HttpStatus.OK, "FPOST_200_3", "자유게시판 게시글 상세 조회에 성공했습니다."),
112-
FREE_POST_DELETE_SUCCESS(HttpStatus.OK, "FPOST_200_4", "자유게시판 게시글이 삭제되었습니다.");
112+
FREE_POST_DELETE_SUCCESS(HttpStatus.OK, "FPOST_200_4", "자유게시판 게시글이 삭제되었습니다."),
113+
FREE_POST_SEARCH_SUCCESS(HttpStatus.OK, "FPOST_200_5", "자유게시판 게시글 검색에 성공했습니다.");
113114

114115
private final HttpStatus httpStatus;
115116
private final String code;

src/main/java/com/semosan/api/domain/community/post/controller/FreePostController.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ public ResponseEntity<ApiResponse<PageResponse<FreePostListResponse>>> getList(
4949
);
5050
}
5151

52+
@GetMapping("/search")
53+
public ResponseEntity<ApiResponse<PageResponse<FreePostListResponse>>> search(
54+
@RequestParam String keyword,
55+
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
56+
) {
57+
return ApiResponse.success(
58+
SuccessStatus.FREE_POST_SEARCH_SUCCESS,
59+
PageResponse.from(freePostService.search(keyword, pageable))
60+
);
61+
}
62+
5263
@GetMapping("/me")
5364
public ResponseEntity<ApiResponse<PageResponse<FreePostListResponse>>> getMyList(
5465
@AuthenticationPrincipal Long userId,

src/main/java/com/semosan/api/domain/community/post/controller/docs/FreePostControllerDocs.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ ResponseEntity<ApiResponse<FreePostDetailResponse>> create(
3535
})
3636
ResponseEntity<ApiResponse<PageResponse<FreePostListResponse>>> getList(Pageable pageable);
3737

38+
@Operation(summary = "자유게시판 검색", description = "제목/내용 기반 키워드 검색. 최신순 페이징.")
39+
@ApiResponses({
40+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공")
41+
})
42+
ResponseEntity<ApiResponse<PageResponse<FreePostListResponse>>> search(
43+
String keyword,
44+
Pageable pageable
45+
);
46+
3847
@Operation(summary = "내 자유게시판 목록", description = "내가 쓴 자유게시판 게시글 목록.")
3948
@ApiResponses({
4049
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공")

src/main/java/com/semosan/api/domain/community/post/dto/AuthorResponse.java

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,19 @@
44

55
public record AuthorResponse(
66
Long id,
7-
String name,
7+
String nickname,
88
String profileUrl,
99
boolean isDeleted
1010
) {
1111

12-
//TODO: 앱 정책에 따라서 탈퇴한 회원 이름 어떻게 보내줄지 결정되면 통일하기
13-
private static final String DELETED_USER_NAME = "탈퇴한 사용자";
14-
private static final String UNKNOWN_USER_NAME = "이름없음";
12+
private static final String DELETED_USER_NICKNAME = "탈퇴한 사용자";
13+
private static final String UNKNOWN_USER_NICKNAME = "이름없음";
1514

1615
public static AuthorResponse from(User user) {
1716
if (user.isDeleted()) {
18-
return new AuthorResponse(user.getId(), DELETED_USER_NAME, null, true);
17+
return new AuthorResponse(user.getId(), DELETED_USER_NICKNAME, null, true);
1918
}
20-
String name = user.getName() != null ? user.getName() : UNKNOWN_USER_NAME;
21-
return new AuthorResponse(user.getId(), name, user.getProfileUrl(), false);
19+
String nickname = user.getNickname() != null ? user.getNickname() : UNKNOWN_USER_NICKNAME;
20+
return new AuthorResponse(user.getId(), nickname, user.getProfileUrl(), false);
2221
}
2322
}

src/main/java/com/semosan/api/domain/community/post/dto/FreePostListResponse.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ public record FreePostListResponse(
99
AuthorResponse author,
1010
String title,
1111
String contentPreview,
12-
String representativeImageUrl,
13-
Integer viewCount,
12+
String thumbnailUrl,
13+
Integer extraImageCount,
1414
Long likeCount,
1515
Long commentCount,
1616
LocalDateTime createdAt
@@ -19,7 +19,8 @@ public record FreePostListResponse(
1919

2020
public static FreePostListResponse from(
2121
FreePost post,
22-
String representativeImageUrl,
22+
String thumbnailUrl,
23+
Integer extraImageCount,
2324
long likeCount,
2425
long commentCount
2526
) {
@@ -28,8 +29,8 @@ public static FreePostListResponse from(
2829
AuthorResponse.from(post.getAuthor()),
2930
post.getTitle(),
3031
preview(post.getContent()),
31-
representativeImageUrl,
32-
post.getViewCount(),
32+
thumbnailUrl,
33+
extraImageCount != null && extraImageCount > 1 ? extraImageCount - 1 : null,
3334
likeCount,
3435
commentCount,
3536
post.getCreatedAt()

src/main/java/com/semosan/api/domain/community/post/repository/FreePostRepository.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@
55
import org.springframework.data.domain.Page;
66
import org.springframework.data.domain.Pageable;
77
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
810

911
public interface FreePostRepository extends JpaRepository<FreePost, Long> {
1012

1113
Page<FreePost> findAllByDeletedFalse(Pageable pageable);
1214

1315
Page<FreePost> findByAuthorAndDeletedFalse(User author, Pageable pageable);
16+
17+
@Query("SELECT fp FROM FreePost fp " +
18+
"WHERE fp.deleted = false " +
19+
"AND (LOWER(fp.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " +
20+
"OR LOWER(fp.content) LIKE LOWER(CONCAT('%', :keyword, '%'))) " +
21+
"ORDER BY fp.createdAt DESC")
22+
Page<FreePost> searchByKeyword(@Param("keyword") String keyword, Pageable pageable);
1423
}

src/main/java/com/semosan/api/domain/community/post/repository/PostImageRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,7 @@ public interface PostImageRepository extends JpaRepository<PostImage, Long> {
1414

1515
@Query("SELECT pi FROM PostImage pi WHERE pi.post.id IN :postIds AND pi.main = true")
1616
List<PostImage> findMainImagesByPostIds(@Param("postIds") List<Long> postIds);
17+
18+
@Query("SELECT pi.post.id, COUNT(pi) FROM PostImage pi WHERE pi.post.id IN :postIds GROUP BY pi.post.id")
19+
List<Object[]> countByPostIdsGrouped(@Param("postIds") List<Long> postIds);
1720
}

src/main/java/com/semosan/api/domain/community/post/service/FreePostService.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import com.semosan.api.domain.user.repository.UserRepository;
1515
import lombok.RequiredArgsConstructor;
1616
import org.springframework.data.domain.Page;
17+
import org.springframework.data.domain.PageRequest;
1718
import org.springframework.data.domain.Pageable;
1819
import org.springframework.stereotype.Service;
1920
import org.springframework.transaction.annotation.Transactional;
@@ -59,6 +60,14 @@ public Page<FreePostListResponse> getList(Pageable pageable) {
5960
return enrichWithCounts(freePostRepository.findAllByDeletedFalse(pageable));
6061
}
6162

63+
public Page<FreePostListResponse> search(String keyword, Pageable pageable) {
64+
if (keyword == null || keyword.isBlank()) {
65+
return Page.empty(pageable);
66+
}
67+
Pageable unsorted = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize());
68+
return enrichWithCounts(freePostRepository.searchByKeyword(keyword.strip(), unsorted));
69+
}
70+
6271
public Page<FreePostListResponse> getMyList(Long authorId, Pageable pageable) {
6372
User author = userRepository.findById(authorId)
6473
.orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND));
@@ -113,10 +122,13 @@ private Page<FreePostListResponse> enrichWithCounts(Page<FreePost> posts) {
113122
.collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1]));
114123
Map<Long, String> mainImageMap = postImageRepository.findMainImagesByPostIds(postIds).stream()
115124
.collect(Collectors.toMap(img -> img.getPost().getId(), PostImage::getImageUrl));
125+
Map<Long, Long> imageCountMap = postImageRepository.countByPostIdsGrouped(postIds).stream()
126+
.collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1]));
116127

117128
return posts.map(post -> FreePostListResponse.from(
118129
post,
119130
mainImageMap.get(post.getId()),
131+
imageCountMap.getOrDefault(post.getId(), 0L).intValue(),
120132
likeCountMap.getOrDefault(post.getId(), 0L),
121133
commentCountMap.getOrDefault(post.getId(), 0L)
122134
));
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- =====================================================================
2+
-- V13__add_free_post_search_index.sql
3+
-- 목적: 자유게시판 제목/내용 검색 성능을 위한 pg_trgm GIN 인덱스
4+
-- =====================================================================
5+
6+
-- 1) pg_trgm extension 활성화
7+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
8+
9+
-- 2) 제목 검색용 GIN 인덱스
10+
CREATE INDEX IF NOT EXISTS idx_free_post_title_trgm
11+
ON free_posts
12+
USING GIN (title gin_trgm_ops);
13+
14+
-- 3) 본문 검색용 GIN 인덱스
15+
CREATE INDEX IF NOT EXISTS idx_post_content_trgm
16+
ON posts
17+
USING GIN (content gin_trgm_ops);

0 commit comments

Comments
 (0)