Repository: KEA-ChunSam/PlayUs-search-service
Pull request #19: CS-309 feat/검색, 인기 검색어 기능 구현
Author: login: han16935
Created: 2025-05-28T12:43:46Z | Merged: 2025-05-30T05:00:59Z

## ✅ PR 유형
어떤 변경 사항이 있었나요?

- [x] 새로운 기능 추가
- [ ] 버그 수정
- [ ] 리팩토링
- [ ] 코드에 영향을 주지 않는 변경사항(주석, 개행 등등..)
- [ ] 문서 수정
- [ ] 빌드 부분 혹은 패키지 매니저 수정
- [ ] 테스트 코드 추가

---

## ✏️ 작업 내용

타이틀 기반 검색, 인기 검색어 기능 구현했습니다. 
지금 Elasticsearch 에 nori 가 적용되어 잇지 않아서 그런지 한글이 섞인 문장 대해서는 특정 단어에 대한 검색은 안 되고 잇습니다. 코드는 변경될 거 같지 않아서 일단 pr 올립니다. db 쪽 해결되면 추후 다른 분들께도 review 요청하겟습니다

---

## 🔗 관련 이슈
- closes #18 

---

## 💡 추가 사항
dev, prod profile 동기화했습니다. 

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **신규 기능**
  - 커뮤니티 게시글 검색 API 및 인기 검색어 조회 API가 추가되었습니다.
  - 게시글 검색 시 Elasticsearch 기반의 검색 결과와 함께 작성자 정보가 제공됩니다.
  - 인기 검색어 상위 10개를 확인할 수 있습니다.
  - 검색어 입력값 검증 및 오류 응답이 표준화되었습니다.
  - Redis를 활용한 인기 검색어 관리 기능이 도입되었습니다.
  - Feign 클라이언트와 Resilience4j 회로 차단기 도입으로 외부 사용자 서비스 연동이 강화되었습니다.

- **버그 수정**
  - 검색어가 비어 있을 경우 400 Bad Request와 함께 명확한 에러 메시지가 반환됩니다.

- **문서화**
  - OpenAPI 명세에 검색 및 인기 검색어 관련 엔드포인트가 추가되었습니다.

- **테스트**
  - 검색 API, 인기 검색어, Redis 연동 등 주요 기능에 대한 단위 및 통합 테스트가 추가되었습니다.

- **환경설정**
  - Redis, Feign, Resilience4j, Testcontainers 등 외부 서비스 연동 및 테스트 환경 구성이 추가되었습니다.
  - Sentry, Swagger 등 일부 설정이 환경별로 개선되었습니다.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Linked issue #18: CS-309 Feat/검색 기능 구현
### 상위 작업 (Epic, 보라색 번개) Ticket Number

CS-308

### 이슈 내용 (Description)

제목 기반 (1차) 검색 기능을 구현합니다

### 담당자 이름

_No response_

### 상세 내용 (Details)

- About Details


### 체크리스트 (Tasks)

- [ ] Tasks1
- [ ] Tasks2


### 참조 (References)

- Reference1


Diff:
diff --git a/build.gradle b/build.gradle
index 5ac78eb..1af8eb6 100644
--- a/build.gradle
+++ b/build.gradle
@@ -34,8 +34,11 @@ dependencies {
 
 	// DB
 	implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
+	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
 
 	// MSA
+	implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
+	implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
 
 	// Monitoring
 	implementation 'org.springframework.boot:spring-boot-starter-actuator'
@@ -50,6 +53,10 @@ dependencies {
 	testImplementation 'io.projectreactor:reactor-test'
 	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
 
+	testImplementation 'org.springframework.boot:spring-boot-testcontainers'
+	testImplementation("org.testcontainers:elasticsearch:1.21.0")
+	testImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock'
+
 	// Swagger
 	implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
 
diff --git a/src/main/java/com/playus/searchservice/domain/common/adapter/TrendingKeywordAdapter.java b/src/main/java/com/playus/searchservice/domain/common/adapter/TrendingKeywordAdapter.java
new file mode 100644
index 0000000..309ce29
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/domain/common/adapter/TrendingKeywordAdapter.java
@@ -0,0 +1,33 @@
+package com.playus.searchservice.domain.common.adapter;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Component;
+
+import java.util.Set;
+
+@Component
+@RequiredArgsConstructor
+public class TrendingKeywordAdapter {
+
+    private static final String KEY_NAME = "TRENDING-KEYWORD";
+    private final RedisTemplate<String, String> trendingKeywordRedisTemplate;
+
+    public Boolean addKeyword(String keyword) {
+        return trendingKeywordRedisTemplate.opsForZSet().add(KEY_NAME, keyword, 1);
+    }
+
+    public Double scoreKeyword (String keyword) {
+        return trendingKeywordRedisTemplate.opsForZSet().incrementScore(KEY_NAME, keyword, 1);
+    }
+
+    public Boolean existsKeyword(String keyword) {
+        Double score = trendingKeywordRedisTemplate.opsForZSet().score(KEY_NAME, keyword);
+        return score != null;
+    }
+
+    public Set<ZSetOperations.TypedTuple<String>> getTop10Keywords() {
+        return trendingKeywordRedisTemplate.opsForZSet().reverseRangeWithScores(KEY_NAME, 0, 9);
+    }
+}
diff --git a/src/main/java/com/playus/searchservice/domain/common/feign/client/UserFeignClient.java b/src/main/java/com/playus/searchservice/domain/common/feign/client/UserFeignClient.java
new file mode 100644
index 0000000..b06a933
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/domain/common/feign/client/UserFeignClient.java
@@ -0,0 +1,18 @@
+package com.playus.searchservice.domain.common.feign.client;
+
+import com.playus.searchservice.domain.common.feign.fallback.UserFeignFallback;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+
+import java.util.List;
+
+@FeignClient(name = "userFeignClient", url = "${feign.user.url}", path = "/user/api", fallback = UserFeignFallback.class)
+@CircuitBreaker(name = "circuit")
+public interface UserFeignClient {
+
+    @PostMapping("/writers")
+    List<PartyWriterInfoFeignResponse> getWriterInfo(@RequestBody List<Long> writerIdList);
+}
diff --git a/src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java b/src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java
new file mode 100644
index 0000000..721cec6
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java
@@ -0,0 +1,19 @@
+package com.playus.searchservice.domain.common.feign.fallback;
+
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Slf4j
+@Component
+public class UserFeignFallback implements UserFeignClient {
+
+    @Override
+    public List<PartyWriterInfoFeignResponse> getWriterInfo(List<Long> writerIdList) {
+        log.error("503 happened in UserFeignClient at fetching writer data!!!");
+        return List.of(PartyWriterInfoFeignResponse.withServiceUnavailable());
+    }
+}
diff --git a/src/main/java/com/playus/searchservice/domain/common/feign/response/PartyWriterInfoFeignResponse.java b/src/main/java/com/playus/searchservice/domain/common/feign/response/PartyWriterInfoFeignResponse.java
new file mode 100644
index 0000000..f0e2289
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/domain/common/feign/response/PartyWriterInfoFeignResponse.java
@@ -0,0 +1,34 @@
+package com.playus.searchservice.domain.common.feign.response;
+
+import lombok.Builder;
+
+@Builder
+public record PartyWriterInfoFeignResponse(
+        Long id,
+        String writerName,
+        String writerGender,
+        int writerAge,
+        String writerThumbnailUrl
+) {
+
+    public static PartyWriterInfoFeignResponse of (Long id, String writerName,
+                                                   String writerGender, int writerAge, String writerThumbnailUrl) {
+        return PartyWriterInfoFeignResponse.builder()
+                .id(id)
+                .writerName(writerName)
+                .writerGender(writerGender)
+                .writerAge(writerAge)
+                .writerThumbnailUrl(writerThumbnailUrl)
+                .build();
+    }
+
+    public static PartyWriterInfoFeignResponse withServiceUnavailable() {
+        return PartyWriterInfoFeignResponse.builder()
+                .id(null)
+                .writerName(null)
+                .writerGender(null)
+                .writerAge(10)
+                .writerThumbnailUrl(null)
+                .build();
+    }
+}
diff --git a/src/main/java/com/playus/searchservice/domain/post/controller/PostSearchController.java b/src/main/java/com/playus/searchservice/domain/post/controller/PostSearchController.java
new file mode 100644
index 0000000..10d95d1
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/domain/post/controller/PostSearchController.java
@@ -0,0 +1,37 @@
+package com.playus.searchservice.domain.post.controller;
+
+import com.playus.searchservice.domain.post.dto.search.SearchRequest;
+import com.playus.searchservice.domain.post.dto.search.SearchResponse;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import com.playus.searchservice.domain.post.service.PostSearchService;
+import com.playus.searchservice.domain.post.specification.PostSearchControllerSpecification;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/search")
+public class PostSearchController implements PostSearchControllerSpecification {
+
+    private final PostSearchService postSearchService;
+
+    @Override
+    @GetMapping("/posts")
+    public ResponseEntity<SearchResponse> search(@Valid SearchRequest request) {
+        SearchResponse response = postSearchService.search(request);
+        return ResponseEntity.ok().body(response);
+    }
+
+    @Override
+    @GetMapping("/trending")
+    public ResponseEntity<List<TrendingKeywordResponse>> getTrendingKeyword() {
+        List<TrendingKeywordResponse> response = postSearchService.getTrendingKeyword();
+        return ResponseEntity.ok().body(response);
+    }
+}
diff --git a/src/main/java/com/playus/searchservice/domain/post/document/PostDocument.java b/src/main/java/com/playus/searchservice/domain/post/document/PostDocument.java
new file mode 100644
index 0000000..55d378e
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/domain/post/document/PostDocument.java
@@ -0,0 +1,82 @@
+package com.playus.searchservice.domain.post.document;
+
+import com.playus.searchservice.domain.post.enums.TeamTag;
+import com.playus.searchservice.domain.post.vo.PostSearchResult;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.elasticsearch.annotations.Document;
+import org.springframework.data.elasticsearch.annotations.Field;
+import org.springframework.data.elasticsearch.annotations.FieldType;
+import org.springframework.data.elasticsearch.annotations.Setting;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor
+//@Setting(settingPath = "/elasticsearch/post-settings.json")
+@Document(indexName = "mysql-server.community_dev.post")
+public class PostDocument {
+
+    @Id
+    private Long id;
+
+    @Field(type = FieldType.Text)
+    private String title;
+
+    private String description;
+
+    @Field(name = "image_url")
+    private String imageUrl;
+
+    @Field(type = FieldType.Keyword, name = "tag")
+    private TeamTag tag;
+
+    private int view;
+
+    @Field(type = FieldType.Boolean)
+    private boolean activated;
+
+    @Field(name = "is_secret")
+    private boolean isSecret;
+
+    @Field(type = FieldType.Long, name = "writer_id")
+    private Long writerId;
+
+    @Field(name = "twp_date")
+    private LocalDate twpDate;
+
+    @Field(name = "created_at")
+    private LocalDateTime createdAt;
+
+    @Field(name = "updated_at")
+    private LocalDateTime updatedAt;
+
+    @Builder
+    private PostDocument(Long id, String title, String description, String imageUrl, TeamTag tag, int view, boolean activated, boolean isSecret, Long writerId, LocalDate twpDate, LocalDateTime createdAt, LocalDateTime updatedAt) {
+        this.id = id;
+        this.title = title;
+        this.description = description;
+        this.imageUrl = imageUrl;
+        this.tag = tag;
+        this.view = view;
+        this.activated = activated;
+        this.isSecret = isSecret;
+        this.writerId = writerId;
+        this.twpDate = twpDate;
+        this.createdAt = createdAt;
+        this.updatedAt = updatedAt;
+    }
+
+    public PostSearchResult toPostSearchResult() {
+        return PostSearchResult.builder()
+                .postId(id)
+                .writerId(writerId)
+                .title(title)
+                .thumbnailUrl(imageUrl)
+                .createdAt(createdAt)
+                .build();
+    }
+}
diff --git a/src/main/java/com/playus/searchservice/domain/post/dto/search/SearchRequest.java b/src/main/java/com/playus/searchservice/domain/post/dto/search/SearchRequest.java
new file mode 100644
index 0000000..bdf1839
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/domain/post/dto/search/SearchRequest.java
@@ -0,0 +1,12 @@
+package com.playus.searchservice.domain.post.dto.search;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Builder;
+
+@Builder
+public record SearchRequest(
+
+        @NotBlank(message = "검색어는 필수입니다!")
+        String query
+) {
+}
diff --git a/src/main/java/com/playus/searchservice/domain/post/dto/search/SearchResponse.java b/src/main/java/com/playus/searchservice/domain/post/dto/search/SearchResponse.java
new file mode 100644
index 0000000..1cd3bda
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/domain/post/dto/search/SearchResponse.java
@@ -0,0 +1,20 @@
+package com.playus.searchservice.domain.post.dto.search;
+
+import com.playus.searchservice.domain.post.vo.PostSearchResult;
+import lombok.Builder;
+
+import java.util.List;
+
+@Builder
+public record SearchResponse(
+        int resultSize,
+        List<PostSearchResult> searchResultList
+) {
+
+    public static SearchResponse of (int resultSize, List<PostSearchResult> searchResultList) {
+        return SearchResponse.builder()
+                .resultSize(resultSize)
+                .searchResultList(searchResultList)
+                .build();
+    }
+}
diff --git a/src/main/java/com/playus/searchservice/domain/post/dto/trending/TrendingKeywordResponse.java b/src/main/java/com/playus/searchservice/domain/post/dto/trending/TrendingKeywordResponse.java
new file mode 100644
index 0000000..80e0e49
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/domain/post/dto/trending/TrendingKeywordResponse.java
@@ -0,0 +1,16 @@
+package com.playus.searchservice.domain.post.dto.trending;
+
+import lombok.Builder;
+
+@Builder
+public record TrendingKeywordResponse(
+      int rank,
+      String keyword
+) {
+    public static TrendingKeywordResponse of (int rank, String keyword) {
+        return TrendingKeywordResponse.builder()
+                .rank(rank)
+                .keyword(keyword)
+                .build();
+    }
+}
diff --git a/src/main/java/com/playus/searchservice/domain/post/enums/TeamTag.java b/src/main/java/com/playus/searchservice/domain/post/enums/TeamTag.java
new file mode 100644
index 0000000..5e30b9d
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/domain/post/enums/TeamTag.java
@@ -0,0 +1,30 @@
+package com.playus.searchservice.domain.post.enums;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum TeamTag {
+    DOOSAN_BEARS("두산 베어스"),
+    HANWHA_EAGLES("한화 이글스"),
+    KIA_TIGERS("KIA 타이거즈"),
+    KIWOOM_HEROES("키움 히어로즈"),
+    LG_TWINS("LG 트윈스"),
+    LOTTE_GIANTS("롯데 자이언츠"),
+    NC_DINOS("NC 다이노스"),
+    SSG_LANDERS("SSG 랜더스"),
+    SAMSUNG_LIONS("삼성 라이온즈"),
+    KT_WIZ("KT 위즈"),;
+
+    private final String teamName;
+
+    public static TeamTag fromString(String name){
+        for (TeamTag teamTag : TeamTag.values()) {
+            if (teamTag.teamName.equals(name)) {
+                return teamTag;
+            }
+        }
+        throw new IllegalArgumentException("No enum constant " + name);
+    }
+}
diff --git a/src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java b/src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
new file mode 100644
index 0000000..7137a64
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
@@ -0,0 +1,129 @@
+package com.playus.searchservice.domain.post.service;
+
+import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import com.playus.searchservice.domain.post.document.PostDocument;
+import com.playus.searchservice.domain.post.dto.search.SearchRequest;
+import com.playus.searchservice.domain.post.dto.search.SearchResponse;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import com.playus.searchservice.domain.post.vo.PostSearchResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.elasticsearch.client.elc.NativeQuery;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.springframework.data.elasticsearch.core.SearchHits;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class PostSearchService {
+
+    private final ElasticsearchOperations elasticsearchOperations;
+    private final TrendingKeywordAdapter trendingKeywordAdapter;
+    private final UserFeignClient userFeignClient;
+
+    public SearchResponse search(SearchRequest request) {
+
+        String query = request.query();
+
+        Query titleMatchQuery = MatchQuery.of(m -> m
+                        .query(request.query())
+                        .field("title")
+                        .fuzziness("AUTO"))
+                ._toQuery();
+
+        List<Query> filters = new ArrayList<>();
+
+        createSoftAndSecretFilter(filters);
+
+        Query boolQuery = BoolQuery.of(b -> b
+                        .must(titleMatchQuery)
+                        .filter(filters))
+                ._toQuery();
+
+
+        NativeQuery nativeQuery = NativeQuery.builder()
+                .withQuery(boolQuery)
+                .build();
+
+        SearchHits<PostDocument> searchResult = elasticsearchOperations.search(nativeQuery, PostDocument.class);
+
+        increaseTrendingKeyword(query);
+
+        List<PostSearchResult> resultList = searchResult.getSearchHits().stream()
+                .map(result -> result.getContent().toPostSearchResult())
+                .toList();
+
+        updateWriterInfo(resultList);
+
+        return SearchResponse.of(resultList.size(), resultList);
+    }
+
+    public List<TrendingKeywordResponse> getTrendingKeyword() {
+        Set<ZSetOperations.TypedTuple<String>> topKeywords =
+                trendingKeywordAdapter.getTop10Keywords();
+
+        if (topKeywords == null) {
+            return Collections.emptyList();
+        }
+
+        AtomicInteger rank = new AtomicInteger(1);
+        return topKeywords.stream()
+                .map(ZSetOperations.TypedTuple::getValue)
+                .filter(Objects::nonNull)
+                .map(keyword -> TrendingKeywordResponse.of(rank.getAndIncrement(), keyword))
+                .toList();
+    }
+
+    private void updateWriterInfo(List<PostSearchResult> resultList) {
+        List<Long> writerIdList = resultList.stream()
+                .map(PostSearchResult::getWriterId)
+                .toList();
+
+        List<PartyWriterInfoFeignResponse> writerInfoList = userFeignClient.getWriterInfo(writerIdList);
+
+        Map<Long, PartyWriterInfoFeignResponse> writerInfoMap = writerInfoList.stream()
+                .collect(Collectors.toMap(PartyWriterInfoFeignResponse::id, Function.identity(), (a, b) -> a)); // 중복 ID 있을 경우 첫 번째 유지
+
+        for (PostSearchResult post : resultList) {
+            PartyWriterInfoFeignResponse writerInfo = writerInfoMap.get(post.getWriterId());
+            if (writerInfo != null) {
+                post.updateWriterName(writerInfo.writerName());
+            }
+        }
+    }
+
+    private static void createSoftAndSecretFilter(List<Query> filters) {
+        Query notDeletedPostFilter = TermQuery.of(t -> t
+                .field("activated")
+                .value(true)
+        )._toQuery();
+        filters.add(notDeletedPostFilter);
+
+        Query notSecretPostFilter = TermQuery.of(t -> t
+                .field("is_secret")
+                .value(false)
+        )._toQuery();
+        filters.add(notSecretPostFilter);
+    }
+
+    private void increaseTrendingKeyword(String query) {
+        if (trendingKeywordAdapter.existsKeyword(query)) {
+            trendingKeywordAdapter.scoreKeyword(query);
+        } else {
+            trendingKeywordAdapter.addKeyword(query);
+        }
+    }
+}
diff --git a/src/main/java/com/playus/searchservice/domain/post/specification/PostSearchControllerSpecification.java b/src/main/java/com/playus/searchservice/domain/post/specification/PostSearchControllerSpecification.java
new file mode 100644
index 0000000..d04f60b
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/domain/post/specification/PostSearchControllerSpecification.java
@@ -0,0 +1,44 @@
+package com.playus.searchservice.domain.post.specification;
+
+import com.playus.searchservice.domain.post.dto.search.SearchRequest;
+import com.playus.searchservice.domain.post.dto.search.SearchResponse;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+
+import java.util.List;
+
+
+public interface PostSearchControllerSpecification {
+
+
+    @Tag(name = "Get", description = "커뮤니티 글 검색 API")
+    @Operation(
+            summary = "커뮤니티 글 검색",
+            description = "커뮤니티 글을 검색합니다.",
+//            security = @SecurityRequirement(name = "Access"),
+            parameters = {
+                    @Parameter(
+                            name = "query",
+                            description = "검색어",
+                            in = ParameterIn.QUERY,
+                            required = true
+                    )
+            }
+    )
+    ResponseEntity<SearchResponse> search(@Valid @Parameter(hidden = true, description = "검색어")
+                                          SearchRequest request);
+
+
+    @Tag(name = "Get", description = "커뮤니티 인기 검색어 조회 API")
+    @Operation(
+            summary = "커뮤니티 인기 검색어 조회",
+            description = "커뮤니티 인기 검색어를 조회합니다."
+//            security = @SecurityRequirement(name = "Access"),
+    )
+    ResponseEntity<List<TrendingKeywordResponse>> getTrendingKeyword();
+}
diff --git a/src/main/java/com/playus/searchservice/domain/post/vo/PostSearchResult.java b/src/main/java/com/playus/searchservice/domain/post/vo/PostSearchResult.java
new file mode 100644
index 0000000..d7cfb5d
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/domain/post/vo/PostSearchResult.java
@@ -0,0 +1,45 @@
+package com.playus.searchservice.domain.post.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+
+@Getter
+public class PostSearchResult {
+
+    private Long postId;
+    private Long writerId;
+    private String writerName;
+    private String title;
+    private String thumbnailUrl;
+
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
+    private LocalDateTime createdAt;
+
+    @Builder
+    private PostSearchResult(Long postId, Long writerId, String writerName, String title, String thumbnailUrl, LocalDateTime createdAt) {
+        this.postId = postId;
+        this.writerId = writerId;
+        this.writerName = writerName;
+        this.title = title;
+        this.thumbnailUrl = thumbnailUrl;
+        this.createdAt = createdAt;
+    }
+
+    public static PostSearchResult of(Long postId, String writerName, String title, String thumbnailUrl, LocalDateTime createdAt) {
+        return PostSearchResult.builder()
+                .postId(postId)
+                .writerName(writerName)
+                .title(title)
+                .thumbnailUrl(thumbnailUrl)
+                .createdAt(createdAt)
+                .build();
+    }
+
+    public void updateWriterName(String writerName) {
+        this.writerName = writerName;
+    }
+}
diff --git a/src/main/java/com/playus/searchservice/global/sentry/SentryConfig.java b/src/main/java/com/playus/searchservice/global/config/sentry/SentryConfig.java
similarity index 81%
rename from src/main/java/com/playus/searchservice/global/sentry/SentryConfig.java
rename to src/main/java/com/playus/searchservice/global/config/sentry/SentryConfig.java
index 839cf4a..d4686fa 100644
--- a/src/main/java/com/playus/searchservice/global/sentry/SentryConfig.java
+++ b/src/main/java/com/playus/searchservice/global/config/sentry/SentryConfig.java
@@ -1,12 +1,14 @@
-package com.playus.searchservice.global.sentry;
+package com.playus.searchservice.global.config.sentry;
 
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.context.annotation.Configuration;
 
 import io.sentry.Sentry;
 import jakarta.annotation.PostConstruct;
+import org.springframework.context.annotation.Profile;
 
 @Configuration
+@Profile({"prod", "dev"})
 public class SentryConfig {
 
 	@Value("${sentry.dsn}")
diff --git a/src/main/java/com/playus/searchservice/global/swagger/SwaggerConfig.java b/src/main/java/com/playus/searchservice/global/config/swagger/SwaggerConfig.java
similarity index 95%
rename from src/main/java/com/playus/searchservice/global/swagger/SwaggerConfig.java
rename to src/main/java/com/playus/searchservice/global/config/swagger/SwaggerConfig.java
index 3340465..51f0258 100644
--- a/src/main/java/com/playus/searchservice/global/swagger/SwaggerConfig.java
+++ b/src/main/java/com/playus/searchservice/global/config/swagger/SwaggerConfig.java
@@ -1,4 +1,4 @@
-package com.playus.searchservice.global.swagger;
+package com.playus.searchservice.global.config.swagger;
 
 import java.util.List;
 
diff --git a/src/main/java/com/playus/searchservice/global/data/RedisConfig.java b/src/main/java/com/playus/searchservice/global/data/RedisConfig.java
new file mode 100644
index 0000000..3adc2e9
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/global/data/RedisConfig.java
@@ -0,0 +1,36 @@
+package com.playus.searchservice.global.data;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+@Configuration
+public class RedisConfig {
+
+    @Value("${spring.data.redis.host}")
+    private String host;
+
+    @Value("${spring.data.redis.port}")
+    private int port;
+
+    @Bean
+    public RedisConnectionFactory redisConnectionFactory() {
+        return new LettuceConnectionFactory(host, port);
+    }
+
+    @Bean(name = "trendingKeywordRedisTemplate")
+    public RedisTemplate<String, String> trendingKeywordRedisTemplate() {
+
+        RedisTemplate<String, String> trendingKeywordRedisTemplate = new RedisTemplate<>();
+        trendingKeywordRedisTemplate.setConnectionFactory(redisConnectionFactory());
+
+        trendingKeywordRedisTemplate.setKeySerializer(new StringRedisSerializer());
+        trendingKeywordRedisTemplate.setValueSerializer(new StringRedisSerializer());
+
+        return trendingKeywordRedisTemplate;
+    }
+}
diff --git a/src/main/java/com/playus/searchservice/global/exception/ExceptionAdvice.java b/src/main/java/com/playus/searchservice/global/exception/ExceptionAdvice.java
new file mode 100644
index 0000000..27e8c80
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/global/exception/ExceptionAdvice.java
@@ -0,0 +1,26 @@
+package com.playus.searchservice.global.exception;
+
+import com.playus.searchservice.domain.post.controller.PostSearchController;
+import com.playus.searchservice.global.response.ErrorResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.validation.BindException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+
+@Slf4j
+@RestControllerAdvice(assignableTypes = {
+        PostSearchController.class
+})
+public class ExceptionAdvice {
+
+    @ResponseStatus(BAD_REQUEST)
+    @ExceptionHandler(BindException.class)
+    public ErrorResponse handleBindException(BindException e) {
+        String errorMessage = e.getAllErrors().get(0).getDefaultMessage();
+        log.warn("Validation Error: {}", errorMessage);
+        return ErrorResponse.badRequestError(errorMessage);
+    }
+}
diff --git a/src/main/java/com/playus/searchservice/global/response/ErrorResponse.java b/src/main/java/com/playus/searchservice/global/response/ErrorResponse.java
new file mode 100644
index 0000000..596249c
--- /dev/null
+++ b/src/main/java/com/playus/searchservice/global/response/ErrorResponse.java
@@ -0,0 +1,44 @@
+package com.playus.searchservice.global.response;
+
+import lombok.Builder;
+import org.springframework.http.HttpStatus;
+
+@Builder
+public record ErrorResponse(
+        int code,
+        HttpStatus status,
+        String message
+) {
+
+    public static ErrorResponse badRequestError (String errorMessage) {
+        return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage);
+    }
+
+    public static ErrorResponse unauthorizedError (String errorMessage) {
+        return createErrorResponse(HttpStatus.UNAUTHORIZED, errorMessage);
+    }
+
+    public static ErrorResponse forbiddenError (String errorMessage) {
+        return createErrorResponse(HttpStatus.FORBIDDEN, errorMessage);
+    }
+
+    public static ErrorResponse notFoundError(String errorMessage) {
+        return createErrorResponse(HttpStatus.NOT_FOUND, errorMessage);
+    }
+
+    public static ErrorResponse conflictError(String errorMessage) {
+        return createErrorResponse(HttpStatus.CONFLICT, errorMessage);
+    }
+
+    public static ErrorResponse internalServerError (String errorMessage) {
+        return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage);
+    }
+
+    private static ErrorResponse createErrorResponse(HttpStatus errorStatus, String errorMessage) {
+        return ErrorResponse.builder()
+                .code(errorStatus.value())
+                .status(errorStatus)
+                .message(errorMessage)
+                .build();
+    }
+}
diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml
index 72c225f..0ad9d45 100644
--- a/src/main/resources/application-dev.yaml
+++ b/src/main/resources/application-dev.yaml
@@ -6,6 +6,11 @@ spring:
     elasticsearch:
       repositories:
         enabled: true
+
+    redis:
+      host: ${REDIS_HOST}
+      port: ${REDIS_PORT}
+
   elasticsearch:
     uris: ${SPRING_ELASTIC_SEARCH_URL}
 
@@ -18,6 +23,27 @@ sentry:
 logging:
   config: classpath:logback-spring.xml
 
+feign:
+  user:
+    url: ${USER_SERVICE_URL}
+
+resilience4j:
+  circuitbreaker:
+    configs:
+      default:
+        failure-rate-threshold: 50
+        slow-call-rate-threshold: 80
+        slow-call-duration-threshold: 10s
+        permitted-number-of-calls-in-half-open-state: 3
+        max-wait-duration-in-half-open-state: 0s
+        sliding-window-type: COUNT_BASED
+        sliding-window-size: 10
+        minimum-number-of-calls: 10
+        wait-duration-in-open-state: 10s
+    instances:
+      circuit:
+        base-config: default
+
 slack:
   webhook-url: ${SLACK_WEBHOOK_URL}
 
@@ -27,8 +53,11 @@ management:
       exposure:
         include:
           - health           # 애플리케이션 상태 확인
+          - info             # 애플리케이션 정보
           - prometheus       # 메트릭 데이터 노출
 
   endpoint:
     health:
-      show-details: never
+      show-details: always
+
+
diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml
index 1451e46..3ccd6fb 100644
--- a/src/main/resources/application-local.yaml
+++ b/src/main/resources/application-local.yaml
@@ -6,6 +6,11 @@ spring:
     elasticsearch:
       repositories:
         enabled: true
+
+    redis:
+      host: localhost
+      port: 6379
+
   elasticsearch:
     uris: ${SPRING_ELASTIC_SEARCH_URL}
 
@@ -18,6 +23,27 @@ sentry:
 logging:
   config: classpath:logback-spring.xml
 
+feign:
+  user:
+    url: http://localhost:8080
+
+resilience4j:
+  circuitbreaker:
+    configs:
+      default:
+        failure-rate-threshold: 50
+        slow-call-rate-threshold: 80
+        slow-call-duration-threshold: 10s
+        permitted-number-of-calls-in-half-open-state: 3
+        max-wait-duration-in-half-open-state: 0s
+        sliding-window-type: COUNT_BASED
+        sliding-window-size: 10
+        minimum-number-of-calls: 10
+        wait-duration-in-open-state: 10s
+    instances:
+      circuit:
+        base-config: default
+
 slack:
   webhook-url: ${SLACK_WEBHOOK_URL}
 
diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml
index 6884a94..2aea653 100644
--- a/src/main/resources/application-prod.yaml
+++ b/src/main/resources/application-prod.yaml
@@ -6,6 +6,11 @@ spring:
     elasticsearch:
       repositories:
         enabled: true
+
+    redis:
+      host: ${REDIS_HOST}
+      port: ${REDIS_PORT}
+
   elasticsearch:
     uris: ${SPRING_ELASTIC_SEARCH_URL}
 
@@ -18,5 +23,39 @@ sentry:
 logging:
   config: classpath:logback-spring.xml
 
+feign:
+  user:
+    url: ${USER_SERVICE_URL}
+
+resilience4j:
+  circuitbreaker:
+    configs:
+      default:
+        failure-rate-threshold: 50
+        slow-call-rate-threshold: 80
+        slow-call-duration-threshold: 10s
+        permitted-number-of-calls-in-half-open-state: 3
+        max-wait-duration-in-half-open-state: 0s
+        sliding-window-type: COUNT_BASED
+        sliding-window-size: 10
+        minimum-number-of-calls: 10
+        wait-duration-in-open-state: 10s
+    instances:
+      circuit:
+        base-config: default
+
 slack:
   webhook-url: ${SLACK_WEBHOOK_URL}
+
+management:
+  endpoints:
+    web:
+      exposure:
+        include:
+          - health           # 애플리케이션 상태 확인
+          - info             # 애플리케이션 정보
+          - prometheus       # 메트릭 데이터 노출
+
+  endpoint:
+    health:
+      show-details: always
diff --git a/src/main/resources/application-test.yaml b/src/main/resources/application-test.yaml
new file mode 100644
index 0000000..c8fe3ae
--- /dev/null
+++ b/src/main/resources/application-test.yaml
@@ -0,0 +1,9 @@
+spring:
+  data:
+    elasticsearch:
+      repositories:
+        enabled: true
+
+logging:
+  level:
+    org.elasticsearch.client: TRACE
diff --git a/src/test/java/com/playus/searchservice/ControllerTestSupport.java b/src/test/java/com/playus/searchservice/ControllerTestSupport.java
new file mode 100644
index 0000000..f341159
--- /dev/null
+++ b/src/test/java/com/playus/searchservice/ControllerTestSupport.java
@@ -0,0 +1,26 @@
+package com.playus.searchservice;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.playus.searchservice.domain.post.controller.PostSearchController;
+import com.playus.searchservice.domain.post.service.PostSearchService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+@ActiveProfiles("test")
+@WebMvcTest(controllers = {
+        PostSearchController.class
+})
+public abstract class ControllerTestSupport {
+
+    @Autowired
+    protected MockMvc mockMvc;
+
+    @Autowired
+    protected ObjectMapper objectMapper;
+
+    @MockitoBean
+    protected PostSearchService postSearchService;
+}
diff --git a/src/test/java/com/playus/searchservice/IntegrationTestSupport.java b/src/test/java/com/playus/searchservice/IntegrationTestSupport.java
new file mode 100644
index 0000000..f8298fd
--- /dev/null
+++ b/src/test/java/com/playus/searchservice/IntegrationTestSupport.java
@@ -0,0 +1,45 @@
+package com.playus.searchservice;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.elasticsearch.ElasticsearchContainer;
+import org.testcontainers.utility.DockerImageName;
+
+@ActiveProfiles("test")
+@SpringBootTest
+public abstract class IntegrationTestSupport {
+
+    private static final GenericContainer redis;
+    private static final ElasticsearchContainer elasticsearch;
+
+    private static final String REDIS_VERSION = "redis:7.0.12";
+    private static final String ELASTIC_VERSION = "elasticsearch:9.0.1";
+
+    private static final int REDIS_PORT = 6379;
+    private static final int ELASTIC_PORT = 9200;
+
+    static {
+        redis = new GenericContainer(DockerImageName.parse(REDIS_VERSION))
+                .withExposedPorts(REDIS_PORT)
+                .withReuse(true);
+
+        elasticsearch = new ElasticsearchContainer(DockerImageName.parse(ELASTIC_VERSION))
+                .withExposedPorts(ELASTIC_PORT)
+                .withReuse(true);
+
+        redis.start();
+        elasticsearch.start();
+    }
+
+    @DynamicPropertySource
+    public static void dynamicConfiguration(DynamicPropertyRegistry registry) {
+        registry.add("spring.data.redis.host", redis::getHost);
+        registry.add("spring.data.redis.port", () -> String.valueOf(redis.getMappedPort(REDIS_PORT)));
+
+        registry.add("spring.elasticsearch.uris", () -> String.format("http://%s:%d/", elasticsearch.getHost(), elasticsearch.getMappedPort(ELASTIC_PORT)));
+    }
+}
+
diff --git a/src/test/java/com/playus/searchservice/domain/common/adapter/TrendingKeywordAdapterTest.java b/src/test/java/com/playus/searchservice/domain/common/adapter/TrendingKeywordAdapterTest.java
new file mode 100644
index 0000000..dcac8b9
--- /dev/null
+++ b/src/test/java/com/playus/searchservice/domain/common/adapter/TrendingKeywordAdapterTest.java
@@ -0,0 +1,113 @@
+package com.playus.searchservice.domain.common.adapter;
+
+import com.playus.searchservice.IntegrationTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ZSetOperations;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class TrendingKeywordAdapterTest extends IntegrationTestSupport {
+
+    private static final String KEY_NAME = "TRENDING-KEYWORD";
+
+    @Autowired
+    private TrendingKeywordAdapter trendingKeywordAdapter;
+
+    @Autowired
+    private RedisTemplate<String, String> trendingKeywordRedisTemplate;
+
+    @AfterEach
+    void tearDown() {
+        trendingKeywordRedisTemplate.delete(KEY_NAME);
+    }
+
+    @Test
+    @DisplayName("새로운 키워드를 추가할 수 있다")
+    void addKeyword_success() {
+        // given
+        String keyword = "springboot";
+
+        // when
+        Boolean result = trendingKeywordAdapter.addKeyword(keyword);
+
+        // then
+        assertThat(result).isTrue();
+    }
+
+    @Test
+    @DisplayName("기존 키워드의 점수를 증가시킬 수 있다")
+    void scoreKeyword_success() {
+        // given
+        String keyword = "springboot";
+        trendingKeywordAdapter.addKeyword(keyword);
+
+        // when
+        Double score = trendingKeywordAdapter.scoreKeyword(keyword);
+
+        // then
+        assertThat(score).isEqualTo(2.0);
+    }
+
+    @DisplayName("특정 키워드가 검색된 적 있는지 확인할 수 있다.")
+    @Test
+    void existsKeyword() {
+        // given
+        trendingKeywordAdapter.addKeyword("spring");
+
+        // when
+        Boolean result = trendingKeywordAdapter.existsKeyword("spring");
+
+        // then
+        assertThat(result).isTrue();
+    }
+
+    @DisplayName("특정 키워드가 검색된 적 없을 수 있다.")
+    @Test
+    void existsKeyword_NOT_EXISTS() {
+      // given
+
+        // when
+        Boolean result = trendingKeywordAdapter.existsKeyword("spring");
+
+        // then
+        assertThat(result).isFalse();
+    }
+
+    @Test
+    @DisplayName("상위 10개의 키워드를 순위와 함께 조회할 수 있다")
+    void getTop10Keywords_success() {
+        // given
+        trendingKeywordAdapter.addKeyword("spring"); // 1
+        trendingKeywordAdapter.addKeyword("java");   // 1
+        trendingKeywordAdapter.addKeyword("redis");  // 1
+
+        trendingKeywordAdapter.scoreKeyword("spring"); // 2
+        trendingKeywordAdapter.scoreKeyword("spring"); // 3
+
+        // when
+        Set<ZSetOperations.TypedTuple<String>> topKeywords = trendingKeywordAdapter.getTop10Keywords();
+
+        // then
+        assertThat(topKeywords).hasSize(3);
+
+        List<ZSetOperations.TypedTuple<String>> resultList = new ArrayList<>(topKeywords);
+
+        assertThat(resultList.get(0).getValue()).isEqualTo("spring");
+        assertThat(resultList.get(0).getScore()).isEqualTo(3.0);
+
+        // 나머지 키워드들도 존재하는지만 확인
+        List<String> keywords = resultList.stream()
+                .map(ZSetOperations.TypedTuple::getValue)
+                .toList();
+
+        assertThat(keywords).contains("java", "redis");
+    }
+}
diff --git a/src/test/java/com/playus/searchservice/domain/post/controller/PostSearchControllerTest.java b/src/test/java/com/playus/searchservice/domain/post/controller/PostSearchControllerTest.java
new file mode 100644
index 0000000..7687e5a
--- /dev/null
+++ b/src/test/java/com/playus/searchservice/domain/post/controller/PostSearchControllerTest.java
@@ -0,0 +1,68 @@
+package com.playus.searchservice.domain.post.controller;
+
+import com.playus.searchservice.ControllerTestSupport;
+import com.playus.searchservice.domain.post.dto.search.SearchRequest;
+import com.playus.searchservice.domain.post.dto.search.SearchResponse;
+import com.playus.searchservice.domain.post.vo.PostSearchResult;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.*;
+import static org.springframework.http.MediaType.APPLICATION_JSON;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class PostSearchControllerTest extends ControllerTestSupport {
+
+    @DisplayName("커뮤니티 게시글을 검색할 수 있다.")
+    @Test
+    void search() throws Exception {
+        // given
+        String query = "테스트";
+        List<PostSearchResult> searchResultList = List.of(
+                PostSearchResult.of(1L, "kim", "title", "http://thumbnailUrl", LocalDateTime.of(2025, 5, 25, 10, 0, 0))
+        );
+        SearchResponse response = SearchResponse.of(1, searchResultList);
+        given(postSearchService.search(any(SearchRequest.class))).willReturn(response);
+
+        // when // then
+        mockMvc.perform(get("/search/posts")
+                        .param("query", query)
+                        .contentType(APPLICATION_JSON))
+                .andDo(print())
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.resultSize").value(1))
+                .andExpect(jsonPath("$.searchResultList[0].postId").value(1L))
+                .andExpect(jsonPath("$.searchResultList[0].writerName").value("kim"))
+                .andExpect(jsonPath("$.searchResultList[0].title").value("title"))
+                .andExpect(jsonPath("$.searchResultList[0].thumbnailUrl").value("http://thumbnailUrl"))
+                .andExpect(jsonPath("$.searchResultList[0].createdAt").value("10:00"));
+    }
+
+    @DisplayName("게시글 검색 시 검색어는 필수이다.")
+    @NullAndEmptySource
+    @ParameterizedTest
+    void search_EMPTY_QUERY(String emptyQuery) throws Exception {
+        // given
+
+        // when // then
+        mockMvc.perform(get("/search/posts")
+                        .param("query", emptyQuery)
+                        .contentType(APPLICATION_JSON))
+                .andDo(print())
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value("400"))
+                .andExpect(jsonPath("$.status").value("BAD_REQUEST"))
+                .andExpect(jsonPath("$.message").value("검색어는 필수입니다!"));
+    }
+}
+
diff --git a/src/test/java/com/playus/searchservice/domain/post/service/PostSearchServiceTest.java b/src/test/java/com/playus/searchservice/domain/post/service/PostSearchServiceTest.java
new file mode 100644
index 0000000..9af6f67
--- /dev/null
+++ b/src/test/java/com/playus/searchservice/domain/post/service/PostSearchServiceTest.java
@@ -0,0 +1,74 @@
+package com.playus.searchservice.domain.post.service;
+
+import com.playus.searchservice.IntegrationTestSupport;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+
+class PostSearchServiceTest extends IntegrationTestSupport {
+
+    private static final String KEY_NAME = "TRENDING-KEYWORD";
+
+    @Autowired
+    private PostSearchService postSearchService;
+
+    @Autowired
+    private RedisTemplate<String, String> trendingKeywordRedisTemplate;
+
+    @Autowired
+    private TrendingKeywordAdapter trendingKeywordAdapter;
+
+    @AfterEach
+    void tearDown() {
+        trendingKeywordRedisTemplate.delete(KEY_NAME);
+    }
+
+    @DisplayName("10등까지의 실시간 검색어를 가져올 수 있다.")
+    @Test
+    void getTrendingKeyword() {
+
+        // given
+        trendingKeywordAdapter.addKeyword("spring"); // 1
+        trendingKeywordAdapter.scoreKeyword("spring");
+        trendingKeywordAdapter.scoreKeyword("spring");
+
+        trendingKeywordAdapter.addKeyword("java");   // 2
+        trendingKeywordAdapter.scoreKeyword("java");
+
+        trendingKeywordAdapter.addKeyword("redis");  // 3
+
+        // when
+        List<TrendingKeywordResponse> result = postSearchService.getTrendingKeyword();
+
+        // then
+        assertThat(result).hasSize(3)
+                .extracting("rank", "keyword")
+                .containsExactlyInAnyOrder(
+                        tuple(1, "spring"),
+                        tuple(2, "java"),
+                        tuple(3, "redis")
+                );
+    }
+
+    @DisplayName("검색 기록이 없을 수 있다.")
+    @Test
+    void getTrendingKeyword_EMPTY() {
+        // given
+
+        // when
+        List<TrendingKeywordResponse> result = postSearchService.getTrendingKeyword();
+
+        // then
+        assertThat(result).isEmpty();
+    }
+
+}


Reviews:
user: login: yechan-kim
body: LGTM👍
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T12:46:46Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 8**

<details>
<summary>🔭 Outside diff range comments (1)</summary><blockquote>

<details>
<summary>src/main/resources/application-local.yaml (1)</summary><blockquote>

`1-62`: _🛠️ Refactor suggestion_

**다른 환경 설정 파일과의 일관성 문제가 있습니다.**

dev/prod 환경에는 있지만 local 환경에 누락된 management endpoints 설정이 있습니다.


다음 설정을 추가하여 일관성을 맞춰주세요:

```diff
+management:
+  endpoints:
+    web:
+      exposure:
+        include:
+          - health           # 애플리케이션 상태 확인
+          - info             # 애플리케이션 정보
+          - prometheus       # 메트릭 데이터 노출
+
+  endpoint:
+    health:
+      show-details: always
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (24)</summary><blockquote>

<details>
<summary>src/test/java/com/playus/searchservice/IntegrationTestSupport.java (1)</summary><blockquote>

`24-35`: **컨테이너 초기화 로직이 효율적으로 구현되었습니다.**

정적 초기화 블록과 `withReuse(true)` 설정을 통해 테스트 성능을 최적화한 것이 좋습니다. 그러나 더 나은 리소스 관리를 위해 몇 가지 개선을 고려해보세요.


다음과 같은 개선사항을 제안합니다:

```diff
    static {
        redis = new GenericContainer(DockerImageName.parse(REDIS_VERSION))
                .withExposedPorts(REDIS_PORT)
-               .withReuse(true);
+               .withReuse(true)
+               .withStartupTimeout(Duration.ofSeconds(60));

        elasticsearch = new ElasticsearchContainer(DockerImageName.parse(ELASTIC_VERSION))
                .withExposedPorts(ELASTIC_PORT)
-               .withReuse(true);
+               .withReuse(true)
+               .withStartupTimeout(Duration.ofSeconds(120));

        redis.start();
        elasticsearch.start();
+       
+       Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+           redis.stop();
+           elasticsearch.stop();
+       }));
    }
```

개선 사항:
1. **시작 타임아웃 설정**: 컨테이너 시작 시간 제한으로 무한 대기 방지
2. **명시적 정리 로직**: JVM 종료 시 컨테이너 정리 보장

</blockquote></details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/post/dto/trending/TrendingKeywordResponse.java (1)</summary><blockquote>

`10-15`: **불필요한 정적 팩토리 메서드 제거를 고려해보세요.**

Record 클래스는 이미 생성자를 제공하고 `@Builder` 어노테이션도 있어서, `of()` 메서드가 중복 기능을 제공하고 있습니다. 코드 단순화를 위해 제거를 고려해보세요.


```diff
-    public static TrendingKeywordResponse of (int rank, String keyword) {
-        return TrendingKeywordResponse.builder()
-                .rank(rank)
-                .keyword(keyword)
-                .build();
-    }
```

대신 다음과 같이 사용할 수 있습니다:
- `new TrendingKeywordResponse(rank, keyword)` 또는
- `TrendingKeywordResponse.builder().rank(rank).keyword(keyword).build()`

</blockquote></details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/post/dto/search/SearchResponse.java (1)</summary><blockquote>

`14-19`: **불필요한 정적 팩토리 메서드 제거를 고려해보세요.**

`TrendingKeywordResponse`와 마찬가지로, Record 클래스와 `@Builder` 어노테이션이 이미 객체 생성 방법을 제공하므로 `of()` 메서드가 중복됩니다. 일관성을 위해 제거를 고려해보세요.


```diff
-    public static SearchResponse of (int resultSize, List<PostSearchResult> searchResultList) {
-        return SearchResponse.builder()
-                .resultSize(resultSize)
-                .searchResultList(searchResultList)
-                .build();
-    }
```

Record 생성자나 Builder 패턴을 직접 사용하는 것이 더 명확합니다.

</blockquote></details>
<details>
<summary>src/main/resources/application-local.yaml (1)</summary><blockquote>

`30-45`: <details>
<summary>❓ Verification inconclusive</summary>

**Circuit Breaker 설정 값들을 검증해주세요.**

설정된 값들이 적절한지 확인이 필요합니다:
- `failure-rate-threshold: 50`: 50% 실패율에서 Circuit Breaker 동작
- `slow-call-duration-threshold: 10s`: 10초는 다소 긴 편일 수 있습니다
- `wait-duration-in-open-state: 10s`: 복구 대기 시간




다음 스크립트로 다른 마이크로서비스의 Circuit Breaker 설정과 비교해보세요:

---

🏁 Script executed:

```shell
#!/bin/bash
# 다른 서비스의 Circuit Breaker 설정 패턴 확인
rg -A 10 -B 2 "circuitbreaker:" --type yaml
```

Length of output: 2815

---



</details>

**환경별 Circuit Breaker 설정 일관성 확인**

다음 환경(application-local.yaml, application-dev.yaml, application-prod.yaml)에 아래와 같이 동일한 Circuit Breaker 기본 설정이 적용된 것을 확인했습니다.  
- failure-rate-threshold: 50  
- slow-call-duration-threshold: 10s  
- wait-duration-in-open-state: 10s  

위 값들이 서비스별 SLO, 호출 패턴, 장애 허용 범위에 적합한지 검증해 주세요.  
특정 서비스에 따라 더 빠른 복구나 민감도 조정이 필요하다면, 해당 환경의 설정을 별도로 커스터마이징하시기 바랍니다.

</blockquote></details>
<details>
<summary>src/main/resources/application-dev.yaml (1)</summary><blockquote>

`63-64`: **파일 끝의 불필요한 빈 줄을 제거해주세요.**

코드 포맷팅 일관성을 위해 파일 끝의 추가 빈 줄을 제거하는 것이 좋습니다.


```diff
-
-
+
```

</blockquote></details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/post/enums/TeamTag.java (2)</summary><blockquote>

`18-18`: **불필요한 trailing comma를 제거하세요.**

enum 상수 선언 마지막에 있는 쉼표는 불필요합니다.



```diff
-    KT_WIZ("KT 위즈"),;
+    KT_WIZ("KT 위즈");
```

---

`22-29`: **fromString 메서드를 Stream API로 리팩토링하는 것을 고려해보세요.**

현재 구현도 올바르게 작동하지만, Stream API를 사용하면 더 간결하고 함수형 스타일로 개선할 수 있습니다.



```diff
 public static TeamTag fromString(String name){
-    for (TeamTag teamTag : TeamTag.values()) {
-        if (teamTag.teamName.equals(name)) {
-            return teamTag;
-        }
-    }
-    throw new IllegalArgumentException("No enum constant " + name);
+    return Arrays.stream(TeamTag.values())
+            .filter(teamTag -> teamTag.teamName.equals(name))
+            .findFirst()
+            .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 팀명입니다: " + name));
 }
```

추가로 필요한 import:
```diff
+import java.util.Arrays;
```

</blockquote></details>
<details>
<summary>src/test/java/com/playus/searchservice/domain/post/service/PostSearchServiceTest.java (1)</summary><blockquote>

`39-48`: **테스트 데이터 설정을 더 체계적으로 구성할 수 있습니다.**

현재 테스트 데이터 설정은 명확하지만, 테스트 메서드로 추출하면 가독성과 재사용성을 높일 수 있습니다.



```diff
+    private void setUpTrendingKeywords() {
+        // spring: score 3 (rank 1)
+        trendingKeywordAdapter.addKeyword("spring");
+        trendingKeywordAdapter.scoreKeyword("spring");
+        trendingKeywordAdapter.scoreKeyword("spring");
+        
+        // java: score 2 (rank 2)  
+        trendingKeywordAdapter.addKeyword("java");
+        trendingKeywordAdapter.scoreKeyword("java");
+        
+        // redis: score 1 (rank 3)
+        trendingKeywordAdapter.addKeyword("redis");
+    }
```

그리고 테스트에서 사용:
```diff
     @Test
     void getTrendingKeyword() {
         // given
-        trendingKeywordAdapter.addKeyword("spring"); // 1
-        trendingKeywordAdapter.scoreKeyword("spring");
-        trendingKeywordAdapter.scoreKeyword("spring");
-
-        trendingKeywordAdapter.addKeyword("java");   // 2
-        trendingKeywordAdapter.scoreKeyword("java");
-
-        trendingKeywordAdapter.addKeyword("redis");  // 3
+        setUpTrendingKeywords();
```

</blockquote></details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/post/document/PostDocument.java (1)</summary><blockquote>

`73-81`: **null safety를 고려한 변환 메서드 개선을 권장합니다.**

현재 변환 메서드는 잘 구현되었지만, null 값에 대한 안전성을 높일 수 있습니다.



```diff
 public PostSearchResult toPostSearchResult() {
     return PostSearchResult.builder()
             .postId(id)
             .writerId(writerId)
-            .title(title)
-            .thumbnailUrl(imageUrl)
+            .title(title != null ? title : "")
+            .thumbnailUrl(imageUrl != null ? imageUrl : "")
             .createdAt(createdAt)
             .build();
 }
```

또는 Optional을 활용한 더 안전한 방식:
```diff
 public PostSearchResult toPostSearchResult() {
     return PostSearchResult.builder()
             .postId(id)
             .writerId(writerId)
+            .title(Optional.ofNullable(title).orElse(""))
+            .thumbnailUrl(Optional.ofNullable(imageUrl).orElse(""))
             .createdAt(createdAt)
             .build();
 }
```

</blockquote></details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/common/adapter/TrendingKeywordAdapter.java (3)</summary><blockquote>

`17-19`: **입력 유효성 검증을 추가하는 것을 고려해보세요.**

키워드가 null이거나 빈 문자열인 경우에 대한 검증이 없습니다.



```diff
 public Boolean addKeyword(String keyword) {
+    if (keyword == null || keyword.trim().isEmpty()) {
+        throw new IllegalArgumentException("키워드는 비어있을 수 없습니다.");
+    }
     return trendingKeywordRedisTemplate.opsForZSet().add(KEY_NAME, keyword, 1);
 }
```

---

`21-23`: **scoreKeyword 메서드에도 동일한 유효성 검증을 적용하세요.**

addKeyword와 마찬가지로 null/빈 문자열 검증이 필요합니다.



```diff
 public Double scoreKeyword (String keyword) {
+    if (keyword == null || keyword.trim().isEmpty()) {
+        throw new IllegalArgumentException("키워드는 비어있을 수 없습니다.");
+    }
     return trendingKeywordRedisTemplate.opsForZSet().incrementScore(KEY_NAME, keyword, 1);
 }
```

---

`12-33`: **로깅 추가를 고려해보세요.**

Redis 작업에 대한 로깅을 추가하면 디버깅과 모니터링에 도움이 됩니다.



클래스 상단에 로거 추가:
```diff
+import lombok.extern.slf4j.Slf4j;

+@Slf4j
 @Component
 @RequiredArgsConstructor
 public class TrendingKeywordAdapter {
```

그리고 주요 메서드에 로깅 추가:
```diff
 public Boolean addKeyword(String keyword) {
+    log.debug("키워드 추가: {}", keyword);
     return trendingKeywordRedisTemplate.opsForZSet().add(KEY_NAME, keyword, 1);
 }

 public Set<ZSetOperations.TypedTuple<String>> getTop10Keywords() {
+    log.debug("상위 10개 트렌딩 키워드 조회");
     return trendingKeywordRedisTemplate.opsForZSet().reverseRangeWithScores(KEY_NAME, 0, 9);
 }
```

</blockquote></details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/post/vo/PostSearchResult.java (2)</summary><blockquote>

`19-20`: **날짜 형식 검토 필요**

`createdAt` 필드에 대해 "HH:mm" 형식만 표시하도록 설정되어 있습니다. 검색 결과에서 시간 정보만 제공하는 것이 사용자에게 충분한 정보를 제공하는지 검토가 필요합니다. 날짜 정보 없이는 언제 작성된 글인지 명확하지 않을 수 있습니다.



더 명확한 정보 제공을 위해 다음과 같은 형식을 고려해보세요:

```diff
-@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
+@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "MM-dd HH:mm")
```

---

`32-40`: **정적 팩토리 메서드에서 writerId 누락**

`of` 메서드에서 `writerId` 파라미터가 누락되어 있습니다. 이로 인해 생성된 객체의 `writerId`가 null이 됩니다. 만약 의도적인 설계라면 메서드명을 더 명확하게 하거나 주석으로 설명을 추가하는 것이 좋겠습니다.



메서드명을 더 명확하게 변경하거나 writerId를 포함하는 버전을 고려해보세요:

```diff
-public static PostSearchResult of(Long postId, String writerName, String title, String thumbnailUrl, LocalDateTime createdAt) {
+public static PostSearchResult ofWithoutWriterId(Long postId, String writerName, String title, String thumbnailUrl, LocalDateTime createdAt) {
     return PostSearchResult.builder()
             .postId(postId)
             .writerName(writerName)
             .title(title)
             .thumbnailUrl(thumbnailUrl)
             .createdAt(createdAt)
             .build();
 }
```

</blockquote></details>
<details>
<summary>src/test/java/com/playus/searchservice/domain/common/adapter/TrendingKeywordAdapterTest.java (1)</summary><blockquote>

`75-76`: **들여쓰기 일관성 문제**

74번째 줄에서 들여쓰기가 일관되지 않습니다. 다른 테스트 메서드들과 동일한 들여쓰기 스타일을 유지해야 합니다.



```diff
-      // given
+        // given
```

</blockquote></details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/post/specification/PostSearchControllerSpecification.java (1)</summary><blockquote>

`41-41`: **주석 처리된 보안 설정 일관성**

인기 검색어 조회 API에서도 보안 요구사항이 주석 처리되어 있습니다. 두 API 모두 동일한 보안 정책을 적용해야 하므로 일관성 있게 처리해야 합니다.

</blockquote></details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/common/feign/response/PartyWriterInfoFeignResponse.java (3)</summary><blockquote>

`5-5`: **Record 클래스에서 @Builder 사용 검토**

Record 클래스는 이미 모든 필드를 받는 생성자를 자동으로 제공합니다. @Builder 어노테이션이 추가적인 가치를 제공하는지 검토가 필요합니다. 단순한 경우라면 record의 기본 생성자만으로도 충분할 수 있습니다.



만약 빌더 패턴이 꼭 필요하지 않다면 제거를 고려해보세요:

```diff
-@Builder
 public record PartyWriterInfoFeignResponse(
         Long id,
         String writerName,
         String writerGender,
         int writerAge,
         String writerThumbnailUrl
 ) {
```

---

`30-30`: **센티널 값으로 -1 사용 검토**

`writerAge`에 -1을 사용하는 것은 유효하지만, 더 명확한 의미를 전달하기 위해 상수로 정의하는 것이 좋겠습니다.



```diff
+    private static final int UNAVAILABLE_AGE = -1;
+    
     public static PartyWriterInfoFeignResponse withServiceUnavailable() {
         return PartyWriterInfoFeignResponse.builder()
                 .id(null)
                 .writerName(null)
                 .writerGender(null)
-                .writerAge(-1)
+                .writerAge(UNAVAILABLE_AGE)
                 .writerThumbnailUrl(null)
                 .build();
     }
```

---

`14-23`: **불필요한 정적 팩토리 메서드**

`of` 메서드가 단순히 빌더를 호출하는 것 외에 추가적인 로직이 없습니다. Record 클래스의 기본 생성자를 직접 사용하는 것이 더 간단할 수 있습니다.



이 메서드를 제거하고 직접 생성자를 사용하는 것을 고려해보세요:

```diff
-    public static PartyWriterInfoFeignResponse of (Long id, String writerName,
-                                                   String writerGender, int writerAge, String writerThumbnailUrl) {
-        return PartyWriterInfoFeignResponse.builder()
-                .id(id)
-                .writerName(writerName)
-                .writerGender(writerGender)
-                .writerAge(writerAge)
-                .writerThumbnailUrl(writerThumbnailUrl)
-                .build();
-    }
```

사용하는 곳에서는:
```java
// Before
PartyWriterInfoFeignResponse.of(id, name, gender, age, url);

// After  
new PartyWriterInfoFeignResponse(id, name, gender, age, url);
```

</blockquote></details>
<details>
<summary>src/main/java/com/playus/searchservice/global/response/ErrorResponse.java (2)</summary><blockquote>

`7-11`: **HttpStatus와 code 필드의 중복성을 고려해보세요.**

현재 `HttpStatus` enum과 `int code` 필드가 같은 정보를 중복으로 담고 있습니다. `code` 필드는 `status.value()`로 대체 가능하므로 제거를 고려해보세요.



```diff
public record ErrorResponse(
-       int code,
        HttpStatus status,
        String message
) {
```

그리고 팩토리 메서드에서:

```diff
private static ErrorResponse createErrorResponse(HttpStatus errorStatus, String errorMessage) {
    return ErrorResponse.builder()
-           .code(errorStatus.value())
            .status(errorStatus)
            .message(errorMessage)
            .build();
}
```

---

`13-35`: **자주 사용되는 HTTP 상태 코드 추가를 고려해보세요.**

현재 구현은 잘 되어있지만, 일반적으로 사용되는 몇 가지 상태 코드가 누락되어 있습니다.



```diff
+   public static ErrorResponse methodNotAllowedError(String errorMessage) {
+       return createErrorResponse(HttpStatus.METHOD_NOT_ALLOWED, errorMessage);
+   }
+
+   public static ErrorResponse unprocessableEntityError(String errorMessage) {
+       return createErrorResponse(HttpStatus.UNPROCESSABLE_ENTITY, errorMessage);
+   }
```

</blockquote></details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java (1)</summary><blockquote>

`28-31`: **서비스 클래스의 단일 책임 원칙을 고려해보세요.**

현재 서비스가 검색과 인기 검색어 관리 두 가지 책임을 가지고 있습니다. 향후 확장성을 위해 분리를 고려해보세요.



예를 들어, `TrendingKeywordService`를 별도로 분리하거나, 최소한 인기 검색어 관련 로직을 별도 메서드로 명확히 분리하는 것을 권장합니다.

</blockquote></details>
<details>
<summary>src/test/java/com/playus/searchservice/domain/post/controller/PostSearchControllerTest.java (2)</summary><blockquote>

`26-49`: **시간 검증 방식을 개선해보세요.**

현재 `createdAt` 필드를 "10:00"으로만 검증하고 있는데, 이는 날짜 정보가 누락되어 취약할 수 있습니다.



더 명확한 날짜/시간 검증을 권장합니다:

```diff
-   .andExpect(jsonPath("$.searchResultList[0].createdAt").value("10:00"));
+   .andExpect(jsonPath("$.searchResultList[0].createdAt").value("2025-05-25T10:00:00"));
```

또는 JsonPath 매처를 사용하여 더 유연한 검증을 할 수 있습니다.

---

`24-67`: **인기 검색어 엔드포인트에 대한 테스트를 추가해보세요.**

현재 `/search/posts` 엔드포인트만 테스트하고 있습니다. `/search/trending` 엔드포인트에 대한 테스트도 추가하는 것을 권장합니다.



```java
@DisplayName("인기 검색어를 조회할 수 있다.")
@Test
void getTrendingKeyword() throws Exception {
    // given
    List<TrendingKeywordResponse> trendingKeywords = List.of(
            TrendingKeywordResponse.of(1, "인기검색어1"),
            TrendingKeywordResponse.of(2, "인기검색어2")
    );
    given(postSearchService.getTrendingKeyword()).willReturn(trendingKeywords);

    // when // then
    mockMvc.perform(get("/search/trending")
                    .contentType(APPLICATION_JSON))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpected(jsonPath("$[0].rank").value(1))
            .andExpected(jsonPath("$[0].keyword").value("인기검색어1"));
}
```

이런 테스트를 추가하시겠습니까?

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between a21c7398005e99123a31054f423f56dcde9f28f3 and b7133fad86b72204aa1dfd654ebb5d5650d0c237.

</details>

<details>
<summary>📒 Files selected for processing (28)</summary>

* `build.gradle` (2 hunks)
* `src/main/java/com/playus/searchservice/domain/common/adapter/TrendingKeywordAdapter.java` (1 hunks)
* `src/main/java/com/playus/searchservice/domain/common/feign/client/UserFeignClient.java` (1 hunks)
* `src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java` (1 hunks)
* `src/main/java/com/playus/searchservice/domain/common/feign/response/PartyWriterInfoFeignResponse.java` (1 hunks)
* `src/main/java/com/playus/searchservice/domain/post/controller/PostSearchController.java` (1 hunks)
* `src/main/java/com/playus/searchservice/domain/post/document/PostDocument.java` (1 hunks)
* `src/main/java/com/playus/searchservice/domain/post/dto/search/SearchRequest.java` (1 hunks)
* `src/main/java/com/playus/searchservice/domain/post/dto/search/SearchResponse.java` (1 hunks)
* `src/main/java/com/playus/searchservice/domain/post/dto/trending/TrendingKeywordResponse.java` (1 hunks)
* `src/main/java/com/playus/searchservice/domain/post/enums/TeamTag.java` (1 hunks)
* `src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java` (1 hunks)
* `src/main/java/com/playus/searchservice/domain/post/specification/PostSearchControllerSpecification.java` (1 hunks)
* `src/main/java/com/playus/searchservice/domain/post/vo/PostSearchResult.java` (1 hunks)
* `src/main/java/com/playus/searchservice/global/config/sentry/SentryConfig.java` (1 hunks)
* `src/main/java/com/playus/searchservice/global/config/swagger/SwaggerConfig.java` (1 hunks)
* `src/main/java/com/playus/searchservice/global/data/RedisConfig.java` (1 hunks)
* `src/main/java/com/playus/searchservice/global/exception/ExceptionAdvice.java` (1 hunks)
* `src/main/java/com/playus/searchservice/global/response/ErrorResponse.java` (1 hunks)
* `src/main/resources/application-dev.yaml` (3 hunks)
* `src/main/resources/application-local.yaml` (2 hunks)
* `src/main/resources/application-prod.yaml` (2 hunks)
* `src/main/resources/application-test.yaml` (1 hunks)
* `src/test/java/com/playus/searchservice/ControllerTestSupport.java` (1 hunks)
* `src/test/java/com/playus/searchservice/IntegrationTestSupport.java` (1 hunks)
* `src/test/java/com/playus/searchservice/domain/common/adapter/TrendingKeywordAdapterTest.java` (1 hunks)
* `src/test/java/com/playus/searchservice/domain/post/controller/PostSearchControllerTest.java` (1 hunks)
* `src/test/java/com/playus/searchservice/domain/post/service/PostSearchServiceTest.java` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧠 Learnings (1)</summary>

<details>
<summary>src/main/java/com/playus/searchservice/global/config/sentry/SentryConfig.java (1)</summary>

```
Learnt from: yechan-kim
PR: KEA-ChunSam/PlayUs-search-service#15
File: src/main/java/com/playus/searchservice/global/sentry/SentryConfig.java:9-20
Timestamp: 2025-05-11T18:33:01.164Z
Learning: PlayUs-search-service 프로젝트에서는 환경 변수가 모든 환경(local, dev, prod)에 제공되며, Sentry 관련 설정은 프로파일 제한 없이 글로벌하게 적용되어 있다. 이는 logback과의 일관성 및 테스트 목적으로 의도적으로 설계된 것이다.
```

</details>

</details><details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>src/test/java/com/playus/searchservice/IntegrationTestSupport.java (1)</summary><blockquote>

<details>
<summary>src/test/java/com/playus/searchservice/SearchServiceApplicationTests.java (1)</summary>

* `SpringBootTest` (6-13)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (25)</summary><blockquote>

<details>
<summary>src/main/resources/application-test.yaml (1)</summary>

`1-10`: **테스트 환경 설정이 적절하게 구성되었습니다.**

테스트 환경에서 Elasticsearch 저장소를 활성화하고 TRACE 레벨 로깅을 설정한 것이 통합 테스트 실행에 적합합니다.

</details>
<details>
<summary>src/main/java/com/playus/searchservice/global/config/sentry/SentryConfig.java (1)</summary>

`11-11`: **프로파일 제한 변경에 대한 확인이 필요합니다.**

이전 학습 내용에 따르면 Sentry 설정은 모든 환경(local, dev, prod)에서 글로벌하게 적용되도록 의도적으로 설계되었다고 되어 있습니다. 현재 `@Profile({"prod", "dev"})`를 추가하여 로컬 환경을 제외한 것이 의도된 변경인지 확인이 필요합니다.

로컬 개발 환경에서 Sentry 연동이 불필요하다면 이 변경이 적절하지만, 테스트 목적이나 일관성을 위해 모든 환경에서 적용해야 한다면 재검토가 필요할 수 있습니다.

</details>
<details>
<summary>build.gradle (2)</summary>

`37-41`: **새로운 기능 지원을 위한 의존성이 적절하게 추가되었습니다.**

Redis 기반 인기 검색어 관리와 MSA 통신을 위한 의존성들이 올바르게 구성되었습니다:
- `spring-boot-starter-data-redis`: Redis 연동
- `spring-cloud-starter-openfeign`: 외부 서비스 통신
- `spring-cloud-starter-circuitbreaker-resilience4j`: 장애 복구 패턴

---

`56-58`: **통합 테스트를 위한 Testcontainers 의존성이 적절하게 추가되었습니다.**

Redis와 Elasticsearch 통합 테스트를 위한 Testcontainers 의존성들이 올바르게 구성되었습니다. 이는 실제 환경과 유사한 테스트 환경을 제공할 것입니다.

</details>
<details>
<summary>src/test/java/com/playus/searchservice/IntegrationTestSupport.java (2)</summary>

`11-13`: **통합 테스트 지원 클래스가 적절하게 구현되었습니다.**

`@ActiveProfiles("test")`와 `@SpringBootTest`를 사용하여 테스트 환경을 올바르게 구성했습니다. 추상 클래스로 설계하여 재사용성을 높인 것도 좋은 접근입니다.

---

`37-43`: **동적 속성 설정이 올바르게 구현되었습니다.**

Spring의 `@DynamicPropertySource`를 활용하여 컨테이너 정보를 동적으로 주입하는 방식이 적절합니다. Redis와 Elasticsearch 연결 정보가 정확하게 설정되었습니다.

</details>
<details>
<summary>src/main/java/com/playus/searchservice/global/config/swagger/SwaggerConfig.java (1)</summary>

`1-1`: **패키지 구조 개선이 적절합니다.**

패키지 구조를 `global.swagger`에서 `global.config.swagger`로 변경한 것은 설정 관련 클래스들을 더 명확하게 구조화한 좋은 개선사항입니다.

</details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/post/dto/search/SearchRequest.java (1)</summary>

`6-12`: **검색 요청 DTO 구현이 적절합니다.**

Record 클래스 사용과 `@NotBlank` 검증, 그리고 한국어 오류 메시지가 잘 구현되었습니다. 간단하면서도 명확한 설계입니다.

</details>
<details>
<summary>src/test/java/com/playus/searchservice/ControllerTestSupport.java (1)</summary>

`1-26`: **테스트 지원 클래스 구현이 우수합니다.**

컨트롤러 테스트를 위한 공통 설정을 잘 추상화했습니다. `@WebMvcTest`를 특정 컨트롤러로 제한하고, `test` 프로파일을 활성화하여 테스트 환경을 명확히 분리한 점이 좋습니다. MockMvc와 ObjectMapper를 protected로 제공하여 상속받는 테스트 클래스에서 사용할 수 있도록 한 설계도 적절합니다.

</details>
<details>
<summary>src/main/java/com/playus/searchservice/global/data/RedisConfig.java (1)</summary>

`1-36`: **Redis 설정이 잘 구성되었습니다.**

Lettuce 연결 팩토리를 사용하고, 트렌딩 키워드 전용 RedisTemplate을 별도로 구성한 것이 좋습니다. String 키와 값에 대해 StringRedisSerializer를 사용하여 직렬화를 명확히 설정했고, 빈 네이밍도 목적에 맞게 명확하게 했습니다.

</details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/common/feign/client/UserFeignClient.java (1)</summary>

`12-18`: **Feign 클라이언트 구성이 훌륭합니다.**

Circuit Breaker와 fallback을 적절히 설정하여 장애 허용성을 확보했습니다. 외부 서비스 호출에 대한 안정성을 고려한 좋은 설계입니다. 사용자 서비스와의 통신을 위한 인터페이스도 명확하게 정의되었습니다.

</details>
<details>
<summary>src/main/resources/application-local.yaml (2)</summary>

`10-12`: **Redis 설정이 올바르게 구성되었습니다.**

로컬 개발 환경에서 하드코딩된 값 사용이 적절합니다.

---

`26-28`: **Feign 클라이언트 설정이 적절합니다.**

로컬 개발 환경에서 localhost URL 사용이 합리적입니다.

</details>
<details>
<summary>src/main/resources/application-prod.yaml (3)</summary>

`10-12`: **프로덕션 환경에서 환경변수 사용이 적절합니다.**

Redis 연결 정보를 환경변수로 관리하는 것은 보안 모범 사례입니다.

---

`26-28`: **Feign 클라이언트 URL 설정이 안전합니다.**

프로덕션에서 환경변수를 통한 URL 관리가 올바릅니다.

---

`50-61`: **Management endpoints 설정이 잘 구성되었습니다.**

프로덕션 모니터링을 위한 적절한 엔드포인트들이 노출되어 있습니다.

</details>
<details>
<summary>src/main/resources/application-dev.yaml (2)</summary>

`10-12`: **개발 환경에서 환경변수 사용이 적절합니다.**

개발 환경에서도 환경변수를 사용하여 설정 관리의 일관성을 유지하고 있습니다.

---

`26-28`: **Feign 클라이언트 설정이 올바릅니다.**

개발 환경에서의 환경변수 사용이 적절합니다.

</details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java (1)</summary>

`10-12`: **클래스 선언과 어노테이션이 적절합니다.**

`@Slf4j`와 `@Component` 어노테이션 사용이 올바르고, 인터페이스 구현이 정확합니다.

</details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/post/document/PostDocument.java (1)</summary>

`19-19`: **주석처리된 @Setting 어노테이션의 용도를 명확히 하세요.**

주석처리된 코드가 있습니다. 이것이 향후 사용 예정인지, 아니면 제거해야 할 코드인지 명확히 해주세요.



만약 향후 사용 예정이라면 TODO 주석을 추가하고, 그렇지 않다면 제거하는 것이 좋겠습니다.

```diff
-//@Setting(settingPath = "/elasticsearch/post-settings.json")
+// TODO: nori analyzer 설정 후 활성화 예정
+//@Setting(settingPath = "/elasticsearch/post-settings.json")
```

또는 사용하지 않는다면:
```diff
-//@Setting(settingPath = "/elasticsearch/post-settings.json")
```

</details>
<details>
<summary>src/test/java/com/playus/searchservice/domain/common/adapter/TrendingKeywordAdapterTest.java (1)</summary>

`17-113`: **우수한 테스트 구조와 커버리지**

테스트 클래스가 다음과 같은 좋은 특성들을 보여줍니다:
- 명확한 given-when-then 구조
- 적절한 테스트 격리 (@AfterEach)
- 의미 있는 테스트 케이스들 (새 키워드 추가, 점수 증가, 존재 확인, 상위 키워드 조회)
- AssertJ를 활용한 명확한 검증

특히 `getTop10Keywords_success` 테스트에서 순위 확인과 포함 관계 확인을 모두 검증하는 것이 좋습니다.

</details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/post/specification/PostSearchControllerSpecification.java (2)</summary>

`16-44`: **잘 구성된 API 명세 인터페이스**

OpenAPI 어노테이션이 적절히 사용되어 있고, 메서드 시그니처가 명확합니다. 특히 다음 사항들이 좋습니다:
- 의미 있는 operation summary와 description
- 적절한 parameter 정의
- validation 어노테이션 적용

---

`23-24`: <details>
<summary>❓ Verification inconclusive</summary>

**주석 처리된 보안 설정 확인 필요**

보안 요구사항(`SecurityRequirement`)이 주석 처리되어 있습니다. 검색 API에 인증이 필요한지 확인하고, 필요하다면 주석을 해제하거나 불필요하다면 완전히 제거하는 것이 좋겠습니다.



현재 검색 API가 인증을 요구하는지 확인해보세요:

---

🏁 Script executed:

```shell
#!/bin/bash
# 프로젝트에서 보안 설정 관련 파일들을 확인
rg -A 5 -B 5 "SecurityRequirement\|WebSecurity\|HttpSecurity" --type java
```

Length of output: 75

---



</details>

**보안 요구사항 주석 처리 검토 필요**

검색 API의 `@SecurityRequirement` 어노테이션이 주석 처리되어 있습니다. 이 엔드포인트에 인증이 필요한지 확인 후, 필요하다면 주석을 해제하고 불필요하다면 완전히 제거해주세요.

- 파일: `src/main/java/com/playus/searchservice/domain/post/specification/PostSearchControllerSpecification.java` (23–24행)  
- 현재 코드베이스에서 WebSecurity나 HttpSecurity를 통한 보안 설정 구현이 검색되지 않았습니다.  
  → `SecurityConfig` 클래스, `application.yml`/`application.properties` 보안 설정 등을 직접 확인해 주세요.

</details>
<details>
<summary>src/main/java/com/playus/searchservice/domain/post/controller/PostSearchController.java (1)</summary>

`17-37`: **깔끔하게 구현된 REST 컨트롤러입니다.**

Spring Boot 모범 사례를 잘 따르고 있습니다:
- 인터페이스 구현으로 테스트 가능성 향상
- 생성자 주입을 통한 의존성 관리
- `@Valid` 어노테이션을 통한 입력 검증
- 적절한 HTTP 응답 코드 반환

</details>
<details>
<summary>src/test/java/com/playus/searchservice/domain/post/controller/PostSearchControllerTest.java (1)</summary>

`51-66`: **검증 테스트가 잘 구현되어 있습니다.**

매개변수화 테스트를 사용하여 null과 빈 문자열 케이스를 효율적으로 검증하고 있습니다. 에러 응답 구조도 정확히 검증하고 있어 좋습니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-05-28T12:50:53Z
user: login: han16935
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T04:38:06Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-30T04:38:35Z
user: login: han16935
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T04:40:52Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-30T04:41:19Z
user: login: han16935
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T04:44:26Z
user: login: han16935
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T04:44:59Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-30T04:45:03Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-30T04:45:23Z
user: login: han16935
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T04:50:44Z
user: login: han16935
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T04:51:34Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-30T04:51:36Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-30T04:51:59Z
user: login: han16935
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T04:54:43Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-30T04:55:24Z
user: login: han16935
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T04:55:42Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-30T04:56:05Z
user: login: coderabbitai[bot]
state: APPROVED
author_association: NONE
submitted_at: 2025-05-30T05:00:20Z

Review comments:
diff_hunk: @@ -0,0 +1,26 @@
+package com.playus.searchservice.global.exception;
+
+import com.playus.searchservice.domain.post.controller.PostSearchController;
+import com.playus.searchservice.global.response.ErrorResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.validation.BindException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+
+@Slf4j
+@RestControllerAdvice(assignableTypes = {
+        PostSearchController.class
+})
+public class ExceptionAdvice {
+
+    @ResponseStatus(BAD_REQUEST)
+    @ExceptionHandler(BindException.class)
+    public ErrorResponse handleBindException(BindException e) {
+        String errorMessage = e.getAllErrors().get(0).getDefaultMessage();
+        log.warn("Validation Error: {}", errorMessage);
+        return ErrorResponse.badRequestError(errorMessage);
+    }
path: src/main/java/com/playus/searchservice/global/exception/ExceptionAdvice.java
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**검증 오류 리스트가 비어있을 경우 IndexOutOfBoundsException 발생 가능성이 있습니다.**

`e.getAllErrors().get(0)`에서 에러 리스트가 비어있을 경우 예외가 발생할 수 있습니다. 안전성을 위해 리스트 크기를 먼저 확인하는 것이 좋겠습니다.


다음과 같이 수정을 제안합니다:

```diff
 @ResponseStatus(BAD_REQUEST)
 @ExceptionHandler(BindException.class)
 public ErrorResponse handleBindException(BindException e) {
-    String errorMessage = e.getAllErrors().get(0).getDefaultMessage();
+    String errorMessage = e.getAllErrors().isEmpty() 
+        ? "검증 오류가 발생했습니다."
+        : e.getAllErrors().get(0).getDefaultMessage();
     log.warn("Validation Error: {}", errorMessage);
     return ErrorResponse.badRequestError(errorMessage);
 }
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    @ResponseStatus(BAD_REQUEST)
    @ExceptionHandler(BindException.class)
    public ErrorResponse handleBindException(BindException e) {
        String errorMessage = e.getAllErrors().isEmpty() 
            ? "검증 오류가 발생했습니다."
            : e.getAllErrors().get(0).getDefaultMessage();
        log.warn("Validation Error: {}", errorMessage);
        return ErrorResponse.badRequestError(errorMessage);
    }
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/main/java/com/playus/searchservice/global/exception/ExceptionAdvice.java
around lines 21 to 25, the method handleBindException accesses the first error
from the list without checking if the list is empty, which can cause an
IndexOutOfBoundsException. To fix this, add a check to verify that
e.getAllErrors() is not empty before accessing the first element. If the list is
empty, handle that case appropriately, such as returning a default error message
or a generic bad request error response.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-28T12:50:51Z
author_association: NONE
diff_hunk: @@ -0,0 +1,19 @@
+package com.playus.searchservice.domain.common.feign.fallback;
+
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Slf4j
+@Component
+public class UserFeignFallback implements UserFeignClient {
+
+    @Override
+    public List<PartyWriterInfoFeignResponse> getWriterInfo(List<Long> writerIdList) {
+        log.error("503 happened in UserFeignClient at fetching writer data!!!");
path: src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**로그 메시지에 더 많은 컨텍스트 정보를 추가해주세요.**

현재 로그는 오류 발생만 알려주지만, 요청된 작성자 수나 기타 컨텍스트 정보가 없어 디버깅이 어려울 수 있습니다.


위의 수정안에서 이미 개선된 로그 메시지를 제안했습니다.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In
src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java
at line 16, the log message only states that a 503 error occurred without any
context. Enhance the log message by including additional context such as the
number of requested writers or relevant request details available at this point
to improve debugging. Modify the log.error call to incorporate these details
clearly.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-28T12:50:51Z
author_association: NONE
diff_hunk: @@ -0,0 +1,19 @@
+package com.playus.searchservice.domain.common.feign.fallback;
+
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Slf4j
+@Component
+public class UserFeignFallback implements UserFeignClient {
+
+    @Override
+    public List<PartyWriterInfoFeignResponse> getWriterInfo(List<Long> writerIdList) {
+        log.error("503 happened in UserFeignClient at fetching writer data!!!");
+        return List.of(PartyWriterInfoFeignResponse.withServiceUnavailable());
+    }
path: src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**응답 수와 입력 수의 불일치 문제가 있습니다.**

입력으로 여러 작성자 ID가 들어올 수 있지만, 항상 하나의 응답만 반환하고 있습니다. 이는 호출자가 예상하는 응답 수와 맞지 않을 수 있습니다.


다음과 같이 수정하여 입력 크기에 맞는 응답을 반환해주세요:

```diff
 @Override
 public List<PartyWriterInfoFeignResponse> getWriterInfo(List<Long> writerIdList) {
-    log.error("503 happened in UserFeignClient at fetching writer data!!!");
-    return List.of(PartyWriterInfoFeignResponse.withServiceUnavailable());
+    log.error("503 happened in UserFeignClient at fetching writer data for {} writers!!!", writerIdList.size());
+    return writerIdList.stream()
+        .map(id -> PartyWriterInfoFeignResponse.withServiceUnavailable())
+        .toList();
 }
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    @Override
    public List<PartyWriterInfoFeignResponse> getWriterInfo(List<Long> writerIdList) {
        log.error("503 happened in UserFeignClient at fetching writer data for {} writers!!!", writerIdList.size());
        return writerIdList.stream()
            .map(id -> PartyWriterInfoFeignResponse.withServiceUnavailable())
            .toList();
    }
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In
src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java
around lines 14 to 18, the getWriterInfo method returns a single response
regardless of the input list size, causing a mismatch between input and output
counts. Modify the method to return a list of PartyWriterInfoFeignResponse
objects matching the size of the input writerIdList, each indicating service
unavailability, so the response count aligns with the input count.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-28T12:50:51Z
author_association: NONE
diff_hunk: @@ -0,0 +1,74 @@
+package com.playus.searchservice.domain.post.service;
+
+import com.playus.searchservice.IntegrationTestSupport;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+
+class PostSearchServiceTest extends IntegrationTestSupport {
+
+    private static final String KEY_NAME = "TRENDING-KEYWORD";
+
+    @Autowired
+    private PostSearchService postSearchService;
+
+    @Autowired
+    private RedisTemplate<String, String> trendingKeywordRedisTemplate;
+
+    @Autowired
+    private TrendingKeywordAdapter trendingKeywordAdapter;
+
+    @AfterEach
+    void tearDown() {
+        trendingKeywordRedisTemplate.delete(KEY_NAME);
+    }
+
+    @DisplayName("10등까지의 실시간 검색어를 가져올 수 있다.")
+    @Test
+    void getTrendingKeyword() {
+
+        // given
+        trendingKeywordAdapter.addKeyword("spring"); // 1
+        trendingKeywordAdapter.scoreKeyword("spring");
+        trendingKeywordAdapter.scoreKeyword("spring");
+
+        trendingKeywordAdapter.addKeyword("java");   // 2
+        trendingKeywordAdapter.scoreKeyword("java");
+
+        trendingKeywordAdapter.addKeyword("redis");  // 3
+
+        // when
+        List<TrendingKeywordResponse> result = postSearchService.getTrendingKeyword();
+
+        // then
+        assertThat(result).hasSize(3)
+                .extracting("rank", "keyword")
+                .containsExactlyInAnyOrder(
+                        tuple(1, "spring"),
+                        tuple(2, "java"),
+                        tuple(3, "redis")
+                );
+    }
+
+    @DisplayName("검색 기록이 없을 수 있다.")
+    @Test
+    void getTrendingKeyword_EMPYT() {
path: src/test/java/com/playus/searchservice/domain/post/service/PostSearchServiceTest.java
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**메서드명 오타를 수정하세요.**

메서드명에 오타가 있습니다.



```diff
-    void getTrendingKeyword_EMPYT() {
+    void getTrendingKeyword_EMPTY() {
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    void getTrendingKeyword_EMPTY() {
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In
src/test/java/com/playus/searchservice/domain/post/service/PostSearchServiceTest.java
at line 64, the method name getTrendingKeyword_EMPYT contains a typo. Rename the
method to getTrendingKeyword_EMPTY to correct the spelling mistake.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->

✅ Addressed in commits 836c5bd to e011804

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-28T12:50:51Z
author_association: NONE
diff_hunk: @@ -0,0 +1,45 @@
+package com.playus.searchservice.domain.post.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+
+@Getter
+public class PostSearchResult {
+
+    private Long postId;
+    private Long writerId;
+    private String writerName;
+    private String title;
+    private String thumbnailUrl;
+
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
+    private LocalDateTime createdAt;
+
+    @Builder
+    private PostSearchResult(Long postId, Long writerId, String writerName, String title, String thumbnailUrl, LocalDateTime createdAt) {
+        this.postId = postId;
+        this.writerId = writerId;
+        this.writerName = writerName;
+        this.title = title;
+        this.thumbnailUrl = thumbnailUrl;
+        this.createdAt = createdAt;
+    }
+
+    public static PostSearchResult of(Long postId, String writerName, String title, String thumbnailUrl, LocalDateTime createdAt) {
+        return PostSearchResult.builder()
+                .postId(postId)
+                .writerName(writerName)
+                .title(title)
+                .thumbnailUrl(thumbnailUrl)
+                .createdAt(createdAt)
+                .build();
+    }
+
+    public void updateWriterName(String writerName) {
+        this.writerName = writerName;
+    }
path: src/main/java/com/playus/searchservice/domain/post/vo/PostSearchResult.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Value Object의 불변성 검토**

Value Object는 일반적으로 불변 객체로 설계되는 것이 권장됩니다. `updateWriterName` 메서드는 객체의 상태를 변경하므로 이 패턴이 적절한지 검토가 필요합니다.



불변성을 유지하면서 새로운 객체를 반환하는 방식을 고려해보세요:

```diff
-public void updateWriterName(String writerName) {
-    this.writerName = writerName;
-}
+public PostSearchResult withWriterName(String writerName) {
+    return PostSearchResult.builder()
+            .postId(this.postId)
+            .writerId(this.writerId)
+            .writerName(writerName)
+            .title(this.title)
+            .thumbnailUrl(this.thumbnailUrl)
+            .createdAt(this.createdAt)
+            .build();
+}
```

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/main/java/com/playus/searchservice/domain/post/vo/PostSearchResult.java
around lines 42 to 44, the updateWriterName method mutates the state of the
value object, violating its recommended immutability. Refactor this method to
remove state mutation and instead return a new PostSearchResult instance with
the updated writerName, preserving the original object's immutability.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-28T12:50:52Z
author_association: NONE
diff_hunk: @@ -0,0 +1,129 @@
+package com.playus.searchservice.domain.post.service;
+
+import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import com.playus.searchservice.domain.post.document.PostDocument;
+import com.playus.searchservice.domain.post.dto.search.SearchRequest;
+import com.playus.searchservice.domain.post.dto.search.SearchResponse;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import com.playus.searchservice.domain.post.vo.PostSearchResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.elasticsearch.client.elc.NativeQuery;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.springframework.data.elasticsearch.core.SearchHits;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class PostSearchService {
+
+    private final ElasticsearchOperations elasticsearchOperations;
+    private final TrendingKeywordAdapter trendingKeywordAdapter;
+    private final UserFeignClient userFeignClient;
+
+    public SearchResponse search(SearchRequest request) {
+
+        String query = request.query();
+
+        Query titleMatchQuery = MatchQuery.of(m -> m
+                        .query(request.query())
+                        .field("title")
+                        .fuzziness("AUTO"))
+                ._toQuery();
+
+        List<Query> filters = new ArrayList<>();
+
+        createSoftAndSecretFilter(filters);
+
+        Query boolQuery = BoolQuery.of(b -> b
+                        .must(titleMatchQuery)
+                        .filter(filters))
+                ._toQuery();
+
+
+        NativeQuery nativeQuery = NativeQuery.builder()
+                .withQuery(boolQuery)
+                .build();
+
+        SearchHits<PostDocument> searchResult = elasticsearchOperations.search(nativeQuery, PostDocument.class);
+
+        increaeTrendingKeyword(query);
+
+        List<PostSearchResult> resultList = searchResult.getSearchHits().stream()
+                .map(result -> result.getContent().toPostSearchResult())
+                .toList();
+
+        updateWriterInfo(resultList);
+
+        return SearchResponse.of(resultList.size(), resultList);
+    }
path: src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**검색 결과에 페이징 처리를 추가하는 것을 고려해보세요.**

현재 검색 결과를 모든 매치 결과를 반환하고 있는데, 이는 대량의 데이터가 있을 때 성능 문제를 일으킬 수 있습니다.



Elasticsearch NativeQuery에 페이징을 추가하는 것을 권장합니다:

```diff
NativeQuery nativeQuery = NativeQuery.builder()
        .withQuery(boolQuery)
+       .withPageable(PageRequest.of(0, 20)) // 기본 20개 결과
        .build();
```

그리고 `SearchRequest`에 페이징 파라미터를 추가해보세요.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In
src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
between lines 37 and 72, the search method currently returns all matching
results without pagination, which can cause performance issues with large
datasets. To fix this, add pagination parameters (such as page number and size)
to the SearchRequest class, then modify the NativeQuery builder to include these
pagination parameters (from and size) when constructing the query. This will
limit the number of results returned per request and improve performance.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-28T12:50:52Z
author_association: NONE
diff_hunk: @@ -0,0 +1,129 @@
+package com.playus.searchservice.domain.post.service;
+
+import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import com.playus.searchservice.domain.post.document.PostDocument;
+import com.playus.searchservice.domain.post.dto.search.SearchRequest;
+import com.playus.searchservice.domain.post.dto.search.SearchResponse;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import com.playus.searchservice.domain.post.vo.PostSearchResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.elasticsearch.client.elc.NativeQuery;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.springframework.data.elasticsearch.core.SearchHits;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class PostSearchService {
+
+    private final ElasticsearchOperations elasticsearchOperations;
+    private final TrendingKeywordAdapter trendingKeywordAdapter;
+    private final UserFeignClient userFeignClient;
+
+    public SearchResponse search(SearchRequest request) {
+
+        String query = request.query();
+
+        Query titleMatchQuery = MatchQuery.of(m -> m
+                        .query(request.query())
+                        .field("title")
+                        .fuzziness("AUTO"))
+                ._toQuery();
+
+        List<Query> filters = new ArrayList<>();
+
+        createSoftAndSecretFilter(filters);
+
+        Query boolQuery = BoolQuery.of(b -> b
+                        .must(titleMatchQuery)
+                        .filter(filters))
+                ._toQuery();
+
+
+        NativeQuery nativeQuery = NativeQuery.builder()
+                .withQuery(boolQuery)
+                .build();
+
+        SearchHits<PostDocument> searchResult = elasticsearchOperations.search(nativeQuery, PostDocument.class);
+
+        increaeTrendingKeyword(query);
+
+        List<PostSearchResult> resultList = searchResult.getSearchHits().stream()
+                .map(result -> result.getContent().toPostSearchResult())
+                .toList();
+
+        updateWriterInfo(resultList);
+
+        return SearchResponse.of(resultList.size(), resultList);
+    }
+
+    public List<TrendingKeywordResponse> getTrendingKeyword() {
+        Set<ZSetOperations.TypedTuple<String>> topKeywords =
+                trendingKeywordAdapter.getTop10Keywords();
+
+        if (topKeywords == null) {
+            return Collections.emptyList();
+        }
+
+        AtomicInteger rank = new AtomicInteger(1);
+        return topKeywords.stream()
+                .map(ZSetOperations.TypedTuple::getValue)
+                .filter(Objects::nonNull)
+                .map(keyword -> TrendingKeywordResponse.of(rank.getAndIncrement(), keyword))
+                .toList();
+    }
+
+    private void updateWriterInfo(List<PostSearchResult> resultList) {
+        List<Long> writerIdList = resultList.stream()
+                .map(PostSearchResult::getWriterId)
+                .toList();
+
+        List<PartyWriterInfoFeignResponse> writerInfoList = userFeignClient.getWriterInfo(writerIdList);
+
+        Map<Long, PartyWriterInfoFeignResponse> writerInfoMap = writerInfoList.stream()
+                .collect(Collectors.toMap(PartyWriterInfoFeignResponse::id, Function.identity(), (a, b) -> a)); // 중복 ID 있을 경우 첫 번째 유지
+
+        for (PostSearchResult post : resultList) {
+            PartyWriterInfoFeignResponse writerInfo = writerInfoMap.get(post.getWriterId());
+            if (writerInfo != null) {
+                post.updateWriterName(writerInfo.writerName());
+            }
+        }
+    }
path: src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**외부 서비스 호출에 대한 오류 처리를 강화해보세요.**

현재 Feign 클라이언트 호출에서 예외가 발생할 경우에 대한 처리가 부족합니다. 네트워크 장애나 외부 서비스 장애 시에도 검색 결과는 반환되어야 합니다.



```diff
private void updateWriterInfo(List<PostSearchResult> resultList) {
    List<Long> writerIdList = resultList.stream()
            .map(PostSearchResult::getWriterId)
            .toList();

+   try {
        List<PartyWriterInfoFeignResponse> writerInfoList = userFeignClient.getWriterInfo(writerIdList);
        
        Map<Long, PartyWriterInfoFeignResponse> writerInfoMap = writerInfoList.stream()
                .collect(Collectors.toMap(PartyWriterInfoFeignResponse::id, Function.identity(), (a, b) -> a));

        for (PostSearchResult post : resultList) {
            PartyWriterInfoFeignResponse writerInfo = writerInfoMap.get(post.getWriterId());
            if (writerInfo != null) {
                post.updateWriterName(writerInfo.writerName());
            }
        }
+   } catch (Exception e) {
+       // 외부 서비스 장애 시에도 검색은 계속 진행
+       log.warn("Failed to fetch writer info: {}", e.getMessage());
+   }
}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    private void updateWriterInfo(List<PostSearchResult> resultList) {
        List<Long> writerIdList = resultList.stream()
                .map(PostSearchResult::getWriterId)
                .toList();

        try {
            List<PartyWriterInfoFeignResponse> writerInfoList = userFeignClient.getWriterInfo(writerIdList);

            Map<Long, PartyWriterInfoFeignResponse> writerInfoMap = writerInfoList.stream()
                    .collect(Collectors.toMap(PartyWriterInfoFeignResponse::id, Function.identity(), (a, b) -> a)); // 중복 ID 있을 경우 첫 번째 유지

            for (PostSearchResult post : resultList) {
                PartyWriterInfoFeignResponse writerInfo = writerInfoMap.get(post.getWriterId());
                if (writerInfo != null) {
                    post.updateWriterName(writerInfo.writerName());
                }
            }
        } catch (Exception e) {
            // 외부 서비스 장애 시에도 검색은 계속 진행
            log.warn("Failed to fetch writer info: {}", e.getMessage());
        }
    }
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In
src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
around lines 90 to 106, the call to userFeignClient.getWriterInfo lacks error
handling for exceptions such as network or external service failures. Wrap the
Feign client call in a try-catch block to catch exceptions, log the error
appropriately, and ensure the method continues to update writer info for
available data or skips updating without failing, so that search results are
still returned even if the external call fails.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-28T12:50:52Z
author_association: NONE
diff_hunk: @@ -0,0 +1,129 @@
+package com.playus.searchservice.domain.post.service;
+
+import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import com.playus.searchservice.domain.post.document.PostDocument;
+import com.playus.searchservice.domain.post.dto.search.SearchRequest;
+import com.playus.searchservice.domain.post.dto.search.SearchResponse;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import com.playus.searchservice.domain.post.vo.PostSearchResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.elasticsearch.client.elc.NativeQuery;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.springframework.data.elasticsearch.core.SearchHits;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class PostSearchService {
+
+    private final ElasticsearchOperations elasticsearchOperations;
+    private final TrendingKeywordAdapter trendingKeywordAdapter;
+    private final UserFeignClient userFeignClient;
+
+    public SearchResponse search(SearchRequest request) {
+
+        String query = request.query();
+
+        Query titleMatchQuery = MatchQuery.of(m -> m
+                        .query(request.query())
+                        .field("title")
+                        .fuzziness("AUTO"))
+                ._toQuery();
+
+        List<Query> filters = new ArrayList<>();
+
+        createSoftAndSecretFilter(filters);
+
+        Query boolQuery = BoolQuery.of(b -> b
+                        .must(titleMatchQuery)
+                        .filter(filters))
+                ._toQuery();
+
+
+        NativeQuery nativeQuery = NativeQuery.builder()
+                .withQuery(boolQuery)
+                .build();
+
+        SearchHits<PostDocument> searchResult = elasticsearchOperations.search(nativeQuery, PostDocument.class);
+
+        increaeTrendingKeyword(query);
+
+        List<PostSearchResult> resultList = searchResult.getSearchHits().stream()
+                .map(result -> result.getContent().toPostSearchResult())
+                .toList();
+
+        updateWriterInfo(resultList);
+
+        return SearchResponse.of(resultList.size(), resultList);
+    }
+
+    public List<TrendingKeywordResponse> getTrendingKeyword() {
+        Set<ZSetOperations.TypedTuple<String>> topKeywords =
+                trendingKeywordAdapter.getTop10Keywords();
+
+        if (topKeywords == null) {
+            return Collections.emptyList();
+        }
+
+        AtomicInteger rank = new AtomicInteger(1);
+        return topKeywords.stream()
+                .map(ZSetOperations.TypedTuple::getValue)
+                .filter(Objects::nonNull)
+                .map(keyword -> TrendingKeywordResponse.of(rank.getAndIncrement(), keyword))
+                .toList();
+    }
+
+    private void updateWriterInfo(List<PostSearchResult> resultList) {
+        List<Long> writerIdList = resultList.stream()
+                .map(PostSearchResult::getWriterId)
+                .toList();
+
+        List<PartyWriterInfoFeignResponse> writerInfoList = userFeignClient.getWriterInfo(writerIdList);
+
+        Map<Long, PartyWriterInfoFeignResponse> writerInfoMap = writerInfoList.stream()
+                .collect(Collectors.toMap(PartyWriterInfoFeignResponse::id, Function.identity(), (a, b) -> a)); // 중복 ID 있을 경우 첫 번째 유지
+
+        for (PostSearchResult post : resultList) {
+            PartyWriterInfoFeignResponse writerInfo = writerInfoMap.get(post.getWriterId());
+            if (writerInfo != null) {
+                post.updateWriterName(writerInfo.writerName());
+            }
+        }
+    }
+
+    private static void createSoftAndSecretFilter(List<Query> filters) {
+        Query notDeletedPostFilter = TermQuery.of(t -> t
+                .field("activated")
+                .value(true)
+        )._toQuery();
+        filters.add(notDeletedPostFilter);
+
+        Query notSecretPostFilter = TermQuery.of(t -> t
+                .field("is_secret")
+                .value(false)
+        )._toQuery();
+        filters.add(notSecretPostFilter);
+    }
+
+    private void increaeTrendingKeyword(String query) {
path: src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**메서드명 오타를 수정해주세요.**

메서드명에 오타가 있습니다: `increaeTrendingKeyword` → `increaseTrendingKeyword`



```diff
-   private void increaeTrendingKeyword(String query) {
+   private void increaseTrendingKeyword(String query) {
```

그리고 호출 부분도 수정해야 합니다:

```diff
-       increaeTrendingKeyword(query);
+       increaseTrendingKeyword(query);
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
// Rename the method definition
private void increaseTrendingKeyword(String query) {
    // existing implementation
}

// …later, update the call site
increaseTrendingKeyword(query);
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In
src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
at line 122, there is a typo in the method name 'increaeTrendingKeyword'. Rename
the method to 'increaseTrendingKeyword' and update all calls to this method
accordingly to maintain consistency and avoid compilation errors.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->

✅ Addressed in commits 836c5bd to e011804

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-28T12:50:52Z
author_association: NONE
diff_hunk: @@ -0,0 +1,26 @@
+package com.playus.searchservice.global.exception;
+
+import com.playus.searchservice.domain.post.controller.PostSearchController;
+import com.playus.searchservice.global.response.ErrorResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.validation.BindException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+
+@Slf4j
+@RestControllerAdvice(assignableTypes = {
+        PostSearchController.class
+})
+public class ExceptionAdvice {
+
+    @ResponseStatus(BAD_REQUEST)
+    @ExceptionHandler(BindException.class)
+    public ErrorResponse handleBindException(BindException e) {
+        String errorMessage = e.getAllErrors().get(0).getDefaultMessage();
+        log.warn("Validation Error: {}", errorMessage);
+        return ErrorResponse.badRequestError(errorMessage);
+    }
path: src/main/java/com/playus/searchservice/global/exception/ExceptionAdvice.java
user: login: han16935
body: `@ExceptionHandler` 를 통해 최소 하나의 exception 은 리스트에 반드시 들어오도록 했습니다. 추후 관련해서 문제 발생 시 추가해보겟습니다~
created_at: 2025-05-30T04:38:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,26 @@
+package com.playus.searchservice.global.exception;
+
+import com.playus.searchservice.domain.post.controller.PostSearchController;
+import com.playus.searchservice.global.response.ErrorResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.validation.BindException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+
+@Slf4j
+@RestControllerAdvice(assignableTypes = {
+        PostSearchController.class
+})
+public class ExceptionAdvice {
+
+    @ResponseStatus(BAD_REQUEST)
+    @ExceptionHandler(BindException.class)
+    public ErrorResponse handleBindException(BindException e) {
+        String errorMessage = e.getAllErrors().get(0).getDefaultMessage();
+        log.warn("Validation Error: {}", errorMessage);
+        return ErrorResponse.badRequestError(errorMessage);
+    }
path: src/main/java/com/playus/searchservice/global/exception/ExceptionAdvice.java
user: login: coderabbitai[bot]
body: @han16935 네, 맞습니다! `@ExceptionHandler`로 `BindException`을 처리할 때는 보통 검증 오류가 최소 하나는 포함되어 있죠. 실제 문제가 발생했을 때 대응하는 방식도 합리적인 접근법입니다. 

현재 구현으로도 충분히 동작할 것 같고, 혹시 나중에 예상치 못한 케이스가 발생하면 그때 추가 안전장치를 고려해보시면 될 것 같습니다! 👍



🐰 *귀를 쫑긋 세우며 호기심 어린 표정*

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/global/exception/ExceptionAdvice.java:21-25
Timestamp: 2025-05-30T04:38:33.845Z
Learning: 한국어 프로젝트에서 사용자는 방어적 프로그래밍을 반응적으로 접근하는 것을 선호함. @ExceptionHandler에서 BindException 처리 시 검증 오류 리스트가 비어있을 가능성에 대한 사전 체크보다는 실제 문제 발생 시 대응하는 방식을 선택함.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@han16935 네, 맞습니다! `@ExceptionHandler`로 `BindException`을 처리할 때는 보통 검증 오류가 최소 하나는 포함되어 있죠. 실제 문제가 발생했을 때 대응하는 방식도 합리적인 접근법입니다. 

현재 구현으로도 충분히 동작할 것 같고, 혹시 나중에 예상치 못한 케이스가 발생하면 그때 추가 안전장치를 고려해보시면 될 것 같습니다! 👍



🐰 *귀를 쫑긋 세우며 호기심 어린 표정*

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/global/exception/ExceptionAdvice.java:21-25
Timestamp: 2025-05-30T04:38:33.845Z
Learning: 한국어 프로젝트에서 사용자는 방어적 프로그래밍을 반응적으로 접근하는 것을 선호함. @ExceptionHandler에서 BindException 처리 시 검증 오류 리스트가 비어있을 가능성에 대한 사전 체크보다는 실제 문제 발생 시 대응하는 방식을 선택함.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-30T04:38:35Z
author_association: NONE
diff_hunk: @@ -0,0 +1,19 @@
+package com.playus.searchservice.domain.common.feign.fallback;
+
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Slf4j
+@Component
+public class UserFeignFallback implements UserFeignClient {
+
+    @Override
+    public List<PartyWriterInfoFeignResponse> getWriterInfo(List<Long> writerIdList) {
+        log.error("503 happened in UserFeignClient at fetching writer data!!!");
path: src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java
user: login: han16935
body: 위 에러는 타 msa 모듈에서 응답이 느리거나 오지 않을 경우 circuit breaker 가 열릴 때 발생합니다. 에러 원인 등 상세한 정보는 타 모듈에서 처리중입니다~
created_at: 2025-05-30T04:40:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,19 @@
+package com.playus.searchservice.domain.common.feign.fallback;
+
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Slf4j
+@Component
+public class UserFeignFallback implements UserFeignClient {
+
+    @Override
+    public List<PartyWriterInfoFeignResponse> getWriterInfo(List<Long> writerIdList) {
+        log.error("503 happened in UserFeignClient at fetching writer data!!!");
path: src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java
user: login: coderabbitai[bot]
body: @han16935 이해했습니다! Circuit breaker 패턴의 특성상 fallback은 빠른 실패를 위한 것이고, 상세한 에러 분석은 실제 원인이 되는 모듈에서 처리하는 것이 맞습니다. 현재 구현이 의도적으로 간단하게 유지되고 있군요. 감사합니다!

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java:16-16
Timestamp: 2025-05-30T04:41:18.490Z
Learning: PlayUs 검색 서비스에서 Circuit breaker fallback (UserFeignFallback)의 로그 메시지는 의도적으로 간단하게 유지됩니다. 상세한 에러 원인과 분석은 호출되는 다른 MSA 모듈에서 처리하며, fallback은 빠른 실패를 위한 용도입니다.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@han16935 이해했습니다! Circuit breaker 패턴의 특성상 fallback은 빠른 실패를 위한 것이고, 상세한 에러 분석은 실제 원인이 되는 모듈에서 처리하는 것이 맞습니다. 현재 구현이 의도적으로 간단하게 유지되고 있군요. 감사합니다!

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java:16-16
Timestamp: 2025-05-30T04:41:18.490Z
Learning: PlayUs 검색 서비스에서 Circuit breaker fallback (UserFeignFallback)의 로그 메시지는 의도적으로 간단하게 유지됩니다. 상세한 에러 원인과 분석은 호출되는 다른 MSA 모듈에서 처리하며, fallback은 빠른 실패를 위한 용도입니다.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-30T04:41:19Z
author_association: NONE
diff_hunk: @@ -0,0 +1,19 @@
+package com.playus.searchservice.domain.common.feign.fallback;
+
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Slf4j
+@Component
+public class UserFeignFallback implements UserFeignClient {
+
+    @Override
+    public List<PartyWriterInfoFeignResponse> getWriterInfo(List<Long> writerIdList) {
+        log.error("503 happened in UserFeignClient at fetching writer data!!!");
+        return List.of(PartyWriterInfoFeignResponse.withServiceUnavailable());
+    }
path: src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java
user: login: han16935
body: `writerIdList` 내부 수와 관계없이, `withServiceUnavailble()` 의 결과는 같기에 503 발생 메시지는 하나만 남겨도 될 거 같다 생각해서 따로 입력 크기에 맞게 작성하지 않앗습니다~
created_at: 2025-05-30T04:44:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,74 @@
+package com.playus.searchservice.domain.post.service;
+
+import com.playus.searchservice.IntegrationTestSupport;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+
+class PostSearchServiceTest extends IntegrationTestSupport {
+
+    private static final String KEY_NAME = "TRENDING-KEYWORD";
+
+    @Autowired
+    private PostSearchService postSearchService;
+
+    @Autowired
+    private RedisTemplate<String, String> trendingKeywordRedisTemplate;
+
+    @Autowired
+    private TrendingKeywordAdapter trendingKeywordAdapter;
+
+    @AfterEach
+    void tearDown() {
+        trendingKeywordRedisTemplate.delete(KEY_NAME);
+    }
+
+    @DisplayName("10등까지의 실시간 검색어를 가져올 수 있다.")
+    @Test
+    void getTrendingKeyword() {
+
+        // given
+        trendingKeywordAdapter.addKeyword("spring"); // 1
+        trendingKeywordAdapter.scoreKeyword("spring");
+        trendingKeywordAdapter.scoreKeyword("spring");
+
+        trendingKeywordAdapter.addKeyword("java");   // 2
+        trendingKeywordAdapter.scoreKeyword("java");
+
+        trendingKeywordAdapter.addKeyword("redis");  // 3
+
+        // when
+        List<TrendingKeywordResponse> result = postSearchService.getTrendingKeyword();
+
+        // then
+        assertThat(result).hasSize(3)
+                .extracting("rank", "keyword")
+                .containsExactlyInAnyOrder(
+                        tuple(1, "spring"),
+                        tuple(2, "java"),
+                        tuple(3, "redis")
+                );
+    }
+
+    @DisplayName("검색 기록이 없을 수 있다.")
+    @Test
+    void getTrendingKeyword_EMPYT() {
path: src/test/java/com/playus/searchservice/domain/post/service/PostSearchServiceTest.java
user: login: han16935
body: 수정햇고 추후 푸시하겟습니다!
created_at: 2025-05-30T04:44:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,19 @@
+package com.playus.searchservice.domain.common.feign.fallback;
+
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Slf4j
+@Component
+public class UserFeignFallback implements UserFeignClient {
+
+    @Override
+    public List<PartyWriterInfoFeignResponse> getWriterInfo(List<Long> writerIdList) {
+        log.error("503 happened in UserFeignClient at fetching writer data!!!");
+        return List.of(PartyWriterInfoFeignResponse.withServiceUnavailable());
+    }
path: src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java
user: login: coderabbitai[bot]
body: @han16935 말씀하신 관점도 충분히 이해됩니다! 로그 메시지 관점에서는 동일한 에러 내용을 반복할 필요가 없다는 점이 맞네요.

다만 API 계약 관점에서는 호출자가 입력한 writer ID 수만큼 응답을 받을 것으로 예상할 수 있어서, 1:1 대응이 명확할 수 있다는 점만 참고해주시면 될 것 같습니다. 하지만 현재 구조에서는 사용자가 의도하신 대로 구현하는 것도 합리적인 선택이라고 생각합니다.

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java:14-18
Timestamp: 2025-05-30T04:45:02.816Z
Learning: UserFeignFallback에서 서비스 장애 시 writerIdList 크기와 관계없이 단일 응답을 반환하는 것을 선호하는 설계 방식. 동일한 withServiceUnavailable() 결과이므로 중복된 응답보다는 단일 응답으로 충분하다고 판단.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@han16935 말씀하신 관점도 충분히 이해됩니다! 로그 메시지 관점에서는 동일한 에러 내용을 반복할 필요가 없다는 점이 맞네요.

다만 API 계약 관점에서는 호출자가 입력한 writer ID 수만큼 응답을 받을 것으로 예상할 수 있어서, 1:1 대응이 명확할 수 있다는 점만 참고해주시면 될 것 같습니다. 하지만 현재 구조에서는 사용자가 의도하신 대로 구현하는 것도 합리적인 선택이라고 생각합니다.

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java:14-18
Timestamp: 2025-05-30T04:45:02.816Z
Learning: UserFeignFallback에서 서비스 장애 시 writerIdList 크기와 관계없이 단일 응답을 반환하는 것을 선호하는 설계 방식. 동일한 withServiceUnavailable() 결과이므로 중복된 응답보다는 단일 응답으로 충분하다고 판단.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-30T04:45:03Z
author_association: NONE
diff_hunk: @@ -0,0 +1,74 @@
+package com.playus.searchservice.domain.post.service;
+
+import com.playus.searchservice.IntegrationTestSupport;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+
+class PostSearchServiceTest extends IntegrationTestSupport {
+
+    private static final String KEY_NAME = "TRENDING-KEYWORD";
+
+    @Autowired
+    private PostSearchService postSearchService;
+
+    @Autowired
+    private RedisTemplate<String, String> trendingKeywordRedisTemplate;
+
+    @Autowired
+    private TrendingKeywordAdapter trendingKeywordAdapter;
+
+    @AfterEach
+    void tearDown() {
+        trendingKeywordRedisTemplate.delete(KEY_NAME);
+    }
+
+    @DisplayName("10등까지의 실시간 검색어를 가져올 수 있다.")
+    @Test
+    void getTrendingKeyword() {
+
+        // given
+        trendingKeywordAdapter.addKeyword("spring"); // 1
+        trendingKeywordAdapter.scoreKeyword("spring");
+        trendingKeywordAdapter.scoreKeyword("spring");
+
+        trendingKeywordAdapter.addKeyword("java");   // 2
+        trendingKeywordAdapter.scoreKeyword("java");
+
+        trendingKeywordAdapter.addKeyword("redis");  // 3
+
+        // when
+        List<TrendingKeywordResponse> result = postSearchService.getTrendingKeyword();
+
+        // then
+        assertThat(result).hasSize(3)
+                .extracting("rank", "keyword")
+                .containsExactlyInAnyOrder(
+                        tuple(1, "spring"),
+                        tuple(2, "java"),
+                        tuple(3, "redis")
+                );
+    }
+
+    @DisplayName("검색 기록이 없을 수 있다.")
+    @Test
+    void getTrendingKeyword_EMPYT() {
path: src/test/java/com/playus/searchservice/domain/post/service/PostSearchServiceTest.java
user: login: coderabbitai[bot]
body: @han16935 감사합니다! 오타 수정 확인했습니다. 푸시하시면 코드가 더 깔끔해질 것 같네요. 👍

---

<!-- This is an auto-generated comment: tweet message by coderabbit.ai -->

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=%40coderabbitai%20%EB%8D%95%EB%B6%84%EC%97%90%20%EB%A9%94%EC%84%9C%EB%93%9C%EB%AA%85%20%EC%98%A4%ED%83%80%EB%A5%BC%20%EB%B0%94%EB%A1%9C%EC%9E%A1%EC%95%84%20%EC%BD%94%EB%93%9C%20%ED%92%88%EC%A7%88%20UP!%20%EC%9E%91%EC%9D%80%20%EC%8B%A4%EC%88%98%EB%8F%84%20%EB%86%93%EC%B9%98%EC%A7%80%20%EC%95%8A%EB%8A%94%20%EA%BC%BC%EA%BC%BC%ED%95%9C%20%EB%A6%AC%EB%B7%B0%2C%20%EA%BC%AD%20%EA%B2%BD%ED%97%98%ED%95%B4%EB%B3%B4%EC%84%B8%EC%9A%94!%20%F0%9F%90%B0%E2%9C%A8%20%23%EC%BD%94%EB%93%9C%EB%A6%AC%EB%B7%B0&url=https%3A%2F%2Fgithub.com%2FKEA-ChunSam%2FPlayUs-search-service%2Fpull%2F19%23discussion_r2111776834)?

Thank you for using CodeRabbit!

<!-- end of auto-generated comment: tweet message by coderabbit.ai -->


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@han16935 감사합니다! 오타 수정 확인했습니다. 푸시하시면 코드가 더 깔끔해질 것 같네요. 👍

---

&lt;!-- This is an auto-generated comment: tweet message by coderabbit.ai --&gt;

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=%40coderabbitai%20%EB%8D%95%EB%B6%84%EC%97%90%20%EB%A9%94%EC%84%9C%EB%93%9C%EB%AA%85%20%EC%98%A4%ED%83%80%EB%A5%BC%20%EB%B0%94%EB%A1%9C%EC%9E%A1%EC%95%84%20%EC%BD%94%EB%93%9C%20%ED%92%88%EC%A7%88%20UP!%20%EC%9E%91%EC%9D%80%20%EC%8B%A4%EC%88%98%EB%8F%84%20%EB%86%93%EC%B9%98%EC%A7%80%20%EC%95%8A%EB%8A%94%20%EA%BC%BC%EA%BC%BC%ED%95%9C%20%EB%A6%AC%EB%B7%B0%2C%20%EA%BC%AD%20%EA%B2%BD%ED%97%98%ED%95%B4%EB%B3%B4%EC%84%B8%EC%9A%94!%20%F0%9F%90%B0%E2%9C%A8%20%23%EC%BD%94%EB%93%9C%EB%A6%AC%EB%B7%B0&url=https%3A%2F%2Fgithub.com%2FKEA-ChunSam%2FPlayUs-search-service%2Fpull%2F19%23discussion_r2111776834)?

Thank you for using CodeRabbit!

&lt;!-- end of auto-generated comment: tweet message by coderabbit.ai --&gt;

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-30T04:45:23Z
author_association: NONE
diff_hunk: @@ -0,0 +1,45 @@
+package com.playus.searchservice.domain.post.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+
+@Getter
+public class PostSearchResult {
+
+    private Long postId;
+    private Long writerId;
+    private String writerName;
+    private String title;
+    private String thumbnailUrl;
+
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
+    private LocalDateTime createdAt;
+
+    @Builder
+    private PostSearchResult(Long postId, Long writerId, String writerName, String title, String thumbnailUrl, LocalDateTime createdAt) {
+        this.postId = postId;
+        this.writerId = writerId;
+        this.writerName = writerName;
+        this.title = title;
+        this.thumbnailUrl = thumbnailUrl;
+        this.createdAt = createdAt;
+    }
+
+    public static PostSearchResult of(Long postId, String writerName, String title, String thumbnailUrl, LocalDateTime createdAt) {
+        return PostSearchResult.builder()
+                .postId(postId)
+                .writerName(writerName)
+                .title(title)
+                .thumbnailUrl(thumbnailUrl)
+                .createdAt(createdAt)
+                .build();
+    }
+
+    public void updateWriterName(String writerName) {
+        this.writerName = writerName;
+    }
path: src/main/java/com/playus/searchservice/domain/post/vo/PostSearchResult.java
user: login: han16935
body: 하지만 현재 `updateWriterName()` 가 사용되는 곳을 보면 update 된 객체를 받아서 저장하는 변수는 존재하지 않으며, 매번 새로운 객체를 만드는 것보다는 기존 field 를 변경하는 것이 비용이 더 적게 들 것 같습니다. 어떻게 생각하시나요?
created_at: 2025-05-30T04:50:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,129 @@
+package com.playus.searchservice.domain.post.service;
+
+import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import com.playus.searchservice.domain.post.document.PostDocument;
+import com.playus.searchservice.domain.post.dto.search.SearchRequest;
+import com.playus.searchservice.domain.post.dto.search.SearchResponse;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import com.playus.searchservice.domain.post.vo.PostSearchResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.elasticsearch.client.elc.NativeQuery;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.springframework.data.elasticsearch.core.SearchHits;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class PostSearchService {
+
+    private final ElasticsearchOperations elasticsearchOperations;
+    private final TrendingKeywordAdapter trendingKeywordAdapter;
+    private final UserFeignClient userFeignClient;
+
+    public SearchResponse search(SearchRequest request) {
+
+        String query = request.query();
+
+        Query titleMatchQuery = MatchQuery.of(m -> m
+                        .query(request.query())
+                        .field("title")
+                        .fuzziness("AUTO"))
+                ._toQuery();
+
+        List<Query> filters = new ArrayList<>();
+
+        createSoftAndSecretFilter(filters);
+
+        Query boolQuery = BoolQuery.of(b -> b
+                        .must(titleMatchQuery)
+                        .filter(filters))
+                ._toQuery();
+
+
+        NativeQuery nativeQuery = NativeQuery.builder()
+                .withQuery(boolQuery)
+                .build();
+
+        SearchHits<PostDocument> searchResult = elasticsearchOperations.search(nativeQuery, PostDocument.class);
+
+        increaeTrendingKeyword(query);
+
+        List<PostSearchResult> resultList = searchResult.getSearchHits().stream()
+                .map(result -> result.getContent().toPostSearchResult())
+                .toList();
+
+        updateWriterInfo(resultList);
+
+        return SearchResponse.of(resultList.size(), resultList);
+    }
path: src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
user: login: han16935
body: 현재 디자인상 따로 페이징 관련해서 없기도 하고, 검색 기능이 MVP는 아니여서 많은 결과가 안 나올 것으로 예측해 따로 넣지 않앗습니다. 만약 페이징 요소가 들어가면 넣도록 하겠습니다!
created_at: 2025-05-30T04:51:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,45 @@
+package com.playus.searchservice.domain.post.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+
+@Getter
+public class PostSearchResult {
+
+    private Long postId;
+    private Long writerId;
+    private String writerName;
+    private String title;
+    private String thumbnailUrl;
+
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
+    private LocalDateTime createdAt;
+
+    @Builder
+    private PostSearchResult(Long postId, Long writerId, String writerName, String title, String thumbnailUrl, LocalDateTime createdAt) {
+        this.postId = postId;
+        this.writerId = writerId;
+        this.writerName = writerName;
+        this.title = title;
+        this.thumbnailUrl = thumbnailUrl;
+        this.createdAt = createdAt;
+    }
+
+    public static PostSearchResult of(Long postId, String writerName, String title, String thumbnailUrl, LocalDateTime createdAt) {
+        return PostSearchResult.builder()
+                .postId(postId)
+                .writerName(writerName)
+                .title(title)
+                .thumbnailUrl(thumbnailUrl)
+                .createdAt(createdAt)
+                .build();
+    }
+
+    public void updateWriterName(String writerName) {
+        this.writerName = writerName;
+    }
path: src/main/java/com/playus/searchservice/domain/post/vo/PostSearchResult.java
user: login: coderabbitai[bot]
body: <details>
<summary>🧩 Analysis chain</summary>

@han16935 좋은 지적입니다! 실제 사용 패턴을 확인해보겠습니다.

---

🏁 Script executed:
```
#!/bin/bash
# updateWriterName 메서드가 어떻게 사용되는지 확인
rg "updateWriterName" -A 3 -B 3
```

Length of output: 1549

---



</details>

맞습니다! 실제 사용 패턴을 확인해보니 당신의 분석이 정확합니다.

`PostSearchService`에서 `updateWriterName()`이 사용되는 방식을 보면:

```java
for (PostSearchResult post : resultList) {
    PartyWriterInfoFeignResponse writerInfo = writerInfoMap.get(post.getWriterId());
    if (writerInfo != null) {
        post.updateWriterName(writerInfo.writerName());
    }
}
```

이 경우에는 뮤테이션이 더 적절한 선택입니다:

1. **성능상 이점**: 리스트 내의 각 객체를 새로 생성하고 다시 할당하는 것보다 기존 필드를 업데이트하는 것이 효율적
2. **사용 패턴**: 초기 생성 후 한 번의 enrichment 단계에서만 사용되므로 불변성 위반의 위험이 낮음
3. **코드 복잡성**: 불변 방식으로 구현하면 리스트 재구성이 필요해 코드가 복잡해짐

Value Object의 불변성은 일반적인 권장사항이지만, 이처럼 제어된 환경에서 성능상 이점이 명확한 경우에는 실용적인 접근이 더 나을 수 있습니다. 특히 외부 서비스에서 가져온 데이터로 객체를 enrichment하는 패턴에서는 흔히 사용되는 방식입니다.

좋은 분석이었습니다! 👍

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/domain/post/vo/PostSearchResult.java:42-44
Timestamp: 2025-05-30T04:51:34.778Z
Learning: PostSearchResult.updateWriterName() 메서드는 PostSearchService에서 외부 서비스로부터 가져온 writer 정보로 검색 결과를 enrichment하는 용도로 사용됩니다. 이 경우 새 객체 생성보다는 필드 변경이 성능상 더 효율적이며, 제어된 환경에서의 mutation은 실용적인 선택입니다.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer><details>
<summary>🧩 Analysis chain</summary>

@han16935 좋은 지적입니다! 실제 사용 패턴을 확인해보겠습니다.

---

🏁 Script executed:
```
#!/bin/bash
# updateWriterName 메서드가 어떻게 사용되는지 확인
rg "updateWriterName" -A 3 -B 3
```

Length of output: 1549

---



</details>

맞습니다! 실제 사용 패턴을 확인해보니 당신의 분석이 정확합니다.

`PostSearchService`에서 `updateWriterName()`이 사용되는 방식을 보면:

```java
for (PostSearchResult post : resultList) {
    PartyWriterInfoFeignResponse writerInfo = writerInfoMap.get(post.getWriterId());
    if (writerInfo != null) {
        post.updateWriterName(writerInfo.writerName());
    }
}
```

이 경우에는 뮤테이션이 더 적절한 선택입니다:

1. **성능상 이점**: 리스트 내의 각 객체를 새로 생성하고 다시 할당하는 것보다 기존 필드를 업데이트하는 것이 효율적
2. **사용 패턴**: 초기 생성 후 한 번의 enrichment 단계에서만 사용되므로 불변성 위반의 위험이 낮음
3. **코드 복잡성**: 불변 방식으로 구현하면 리스트 재구성이 필요해 코드가 복잡해짐

Value Object의 불변성은 일반적인 권장사항이지만, 이처럼 제어된 환경에서 성능상 이점이 명확한 경우에는 실용적인 접근이 더 나을 수 있습니다. 특히 외부 서비스에서 가져온 데이터로 객체를 enrichment하는 패턴에서는 흔히 사용되는 방식입니다.

좋은 분석이었습니다! 👍

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/domain/post/vo/PostSearchResult.java:42-44
Timestamp: 2025-05-30T04:51:34.778Z
Learning: PostSearchResult.updateWriterName() 메서드는 PostSearchService에서 외부 서비스로부터 가져온 writer 정보로 검색 결과를 enrichment하는 용도로 사용됩니다. 이 경우 새 객체 생성보다는 필드 변경이 성능상 더 효율적이며, 제어된 환경에서의 mutation은 실용적인 선택입니다.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-30T04:51:36Z
author_association: NONE
diff_hunk: @@ -0,0 +1,129 @@
+package com.playus.searchservice.domain.post.service;
+
+import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import com.playus.searchservice.domain.post.document.PostDocument;
+import com.playus.searchservice.domain.post.dto.search.SearchRequest;
+import com.playus.searchservice.domain.post.dto.search.SearchResponse;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import com.playus.searchservice.domain.post.vo.PostSearchResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.elasticsearch.client.elc.NativeQuery;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.springframework.data.elasticsearch.core.SearchHits;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class PostSearchService {
+
+    private final ElasticsearchOperations elasticsearchOperations;
+    private final TrendingKeywordAdapter trendingKeywordAdapter;
+    private final UserFeignClient userFeignClient;
+
+    public SearchResponse search(SearchRequest request) {
+
+        String query = request.query();
+
+        Query titleMatchQuery = MatchQuery.of(m -> m
+                        .query(request.query())
+                        .field("title")
+                        .fuzziness("AUTO"))
+                ._toQuery();
+
+        List<Query> filters = new ArrayList<>();
+
+        createSoftAndSecretFilter(filters);
+
+        Query boolQuery = BoolQuery.of(b -> b
+                        .must(titleMatchQuery)
+                        .filter(filters))
+                ._toQuery();
+
+
+        NativeQuery nativeQuery = NativeQuery.builder()
+                .withQuery(boolQuery)
+                .build();
+
+        SearchHits<PostDocument> searchResult = elasticsearchOperations.search(nativeQuery, PostDocument.class);
+
+        increaeTrendingKeyword(query);
+
+        List<PostSearchResult> resultList = searchResult.getSearchHits().stream()
+                .map(result -> result.getContent().toPostSearchResult())
+                .toList();
+
+        updateWriterInfo(resultList);
+
+        return SearchResponse.of(resultList.size(), resultList);
+    }
path: src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
user: login: coderabbitai[bot]
body: @han16935 MVP 단계에서의 합리적인 판단이네요! 현재 설계 범위와 예상 데이터량을 고려한 접근법이 맞다고 생각합니다. 나중에 사용자가 늘어나거나 검색 결과가 많아질 때 페이징을 추가하시면 됩니다. 👍

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java:37-72
Timestamp: 2025-05-30T04:51:58.852Z
Learning: han16935의 프로젝트에서 검색 기능은 MVP 범위가 아니며, 현재 디자인상 페이징이 없고 많은 검색 결과가 예상되지 않아 성능 최적화보다는 기본 기능 구현에 집중하고 있다.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@han16935 MVP 단계에서의 합리적인 판단이네요! 현재 설계 범위와 예상 데이터량을 고려한 접근법이 맞다고 생각합니다. 나중에 사용자가 늘어나거나 검색 결과가 많아질 때 페이징을 추가하시면 됩니다. 👍

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java:37-72
Timestamp: 2025-05-30T04:51:58.852Z
Learning: han16935의 프로젝트에서 검색 기능은 MVP 범위가 아니며, 현재 디자인상 페이징이 없고 많은 검색 결과가 예상되지 않아 성능 최적화보다는 기본 기능 구현에 집중하고 있다.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-30T04:51:59Z
author_association: NONE
diff_hunk: @@ -0,0 +1,129 @@
+package com.playus.searchservice.domain.post.service;
+
+import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import com.playus.searchservice.domain.post.document.PostDocument;
+import com.playus.searchservice.domain.post.dto.search.SearchRequest;
+import com.playus.searchservice.domain.post.dto.search.SearchResponse;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import com.playus.searchservice.domain.post.vo.PostSearchResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.elasticsearch.client.elc.NativeQuery;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.springframework.data.elasticsearch.core.SearchHits;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class PostSearchService {
+
+    private final ElasticsearchOperations elasticsearchOperations;
+    private final TrendingKeywordAdapter trendingKeywordAdapter;
+    private final UserFeignClient userFeignClient;
+
+    public SearchResponse search(SearchRequest request) {
+
+        String query = request.query();
+
+        Query titleMatchQuery = MatchQuery.of(m -> m
+                        .query(request.query())
+                        .field("title")
+                        .fuzziness("AUTO"))
+                ._toQuery();
+
+        List<Query> filters = new ArrayList<>();
+
+        createSoftAndSecretFilter(filters);
+
+        Query boolQuery = BoolQuery.of(b -> b
+                        .must(titleMatchQuery)
+                        .filter(filters))
+                ._toQuery();
+
+
+        NativeQuery nativeQuery = NativeQuery.builder()
+                .withQuery(boolQuery)
+                .build();
+
+        SearchHits<PostDocument> searchResult = elasticsearchOperations.search(nativeQuery, PostDocument.class);
+
+        increaeTrendingKeyword(query);
+
+        List<PostSearchResult> resultList = searchResult.getSearchHits().stream()
+                .map(result -> result.getContent().toPostSearchResult())
+                .toList();
+
+        updateWriterInfo(resultList);
+
+        return SearchResponse.of(resultList.size(), resultList);
+    }
+
+    public List<TrendingKeywordResponse> getTrendingKeyword() {
+        Set<ZSetOperations.TypedTuple<String>> topKeywords =
+                trendingKeywordAdapter.getTop10Keywords();
+
+        if (topKeywords == null) {
+            return Collections.emptyList();
+        }
+
+        AtomicInteger rank = new AtomicInteger(1);
+        return topKeywords.stream()
+                .map(ZSetOperations.TypedTuple::getValue)
+                .filter(Objects::nonNull)
+                .map(keyword -> TrendingKeywordResponse.of(rank.getAndIncrement(), keyword))
+                .toList();
+    }
+
+    private void updateWriterInfo(List<PostSearchResult> resultList) {
+        List<Long> writerIdList = resultList.stream()
+                .map(PostSearchResult::getWriterId)
+                .toList();
+
+        List<PartyWriterInfoFeignResponse> writerInfoList = userFeignClient.getWriterInfo(writerIdList);
+
+        Map<Long, PartyWriterInfoFeignResponse> writerInfoMap = writerInfoList.stream()
+                .collect(Collectors.toMap(PartyWriterInfoFeignResponse::id, Function.identity(), (a, b) -> a)); // 중복 ID 있을 경우 첫 번째 유지
+
+        for (PostSearchResult post : resultList) {
+            PartyWriterInfoFeignResponse writerInfo = writerInfoMap.get(post.getWriterId());
+            if (writerInfo != null) {
+                post.updateWriterName(writerInfo.writerName());
+            }
+        }
+    }
path: src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
user: login: han16935
body: 외부 서비스에서 에러가 발생해도 아래처럼 fallback 클래스를 만들어서 대응하고 잇습니다!

```
public static PartyWriterInfoFeignResponse withServiceUnavailable() {
        return PartyWriterInfoFeignResponse.builder()
                .id(null)
                .writerName(null)
                .writerGender(null)
                .writerAge(10) 
                .writerThumbnailUrl(null)
                .build();
    }
```
created_at: 2025-05-30T04:54:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,129 @@
+package com.playus.searchservice.domain.post.service;
+
+import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import com.playus.searchservice.domain.post.document.PostDocument;
+import com.playus.searchservice.domain.post.dto.search.SearchRequest;
+import com.playus.searchservice.domain.post.dto.search.SearchResponse;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import com.playus.searchservice.domain.post.vo.PostSearchResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.elasticsearch.client.elc.NativeQuery;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.springframework.data.elasticsearch.core.SearchHits;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class PostSearchService {
+
+    private final ElasticsearchOperations elasticsearchOperations;
+    private final TrendingKeywordAdapter trendingKeywordAdapter;
+    private final UserFeignClient userFeignClient;
+
+    public SearchResponse search(SearchRequest request) {
+
+        String query = request.query();
+
+        Query titleMatchQuery = MatchQuery.of(m -> m
+                        .query(request.query())
+                        .field("title")
+                        .fuzziness("AUTO"))
+                ._toQuery();
+
+        List<Query> filters = new ArrayList<>();
+
+        createSoftAndSecretFilter(filters);
+
+        Query boolQuery = BoolQuery.of(b -> b
+                        .must(titleMatchQuery)
+                        .filter(filters))
+                ._toQuery();
+
+
+        NativeQuery nativeQuery = NativeQuery.builder()
+                .withQuery(boolQuery)
+                .build();
+
+        SearchHits<PostDocument> searchResult = elasticsearchOperations.search(nativeQuery, PostDocument.class);
+
+        increaeTrendingKeyword(query);
+
+        List<PostSearchResult> resultList = searchResult.getSearchHits().stream()
+                .map(result -> result.getContent().toPostSearchResult())
+                .toList();
+
+        updateWriterInfo(resultList);
+
+        return SearchResponse.of(resultList.size(), resultList);
+    }
+
+    public List<TrendingKeywordResponse> getTrendingKeyword() {
+        Set<ZSetOperations.TypedTuple<String>> topKeywords =
+                trendingKeywordAdapter.getTop10Keywords();
+
+        if (topKeywords == null) {
+            return Collections.emptyList();
+        }
+
+        AtomicInteger rank = new AtomicInteger(1);
+        return topKeywords.stream()
+                .map(ZSetOperations.TypedTuple::getValue)
+                .filter(Objects::nonNull)
+                .map(keyword -> TrendingKeywordResponse.of(rank.getAndIncrement(), keyword))
+                .toList();
+    }
+
+    private void updateWriterInfo(List<PostSearchResult> resultList) {
+        List<Long> writerIdList = resultList.stream()
+                .map(PostSearchResult::getWriterId)
+                .toList();
+
+        List<PartyWriterInfoFeignResponse> writerInfoList = userFeignClient.getWriterInfo(writerIdList);
+
+        Map<Long, PartyWriterInfoFeignResponse> writerInfoMap = writerInfoList.stream()
+                .collect(Collectors.toMap(PartyWriterInfoFeignResponse::id, Function.identity(), (a, b) -> a)); // 중복 ID 있을 경우 첫 번째 유지
+
+        for (PostSearchResult post : resultList) {
+            PartyWriterInfoFeignResponse writerInfo = writerInfoMap.get(post.getWriterId());
+            if (writerInfo != null) {
+                post.updateWriterName(writerInfo.writerName());
+            }
+        }
+    }
path: src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
user: login: coderabbitai[bot]
body: @han16935 맞습니다! Circuit breaker와 fallback 클래스를 통한 방식이 훨씬 더 좋은 접근법이네요. 

`UserFeignFallback`을 통해 외부 서비스 장애 시 graceful degradation을 제공하고 있고, `withServiceUnavailable()` 메서드로 기본값을 반환하여 검색 기능이 중단되지 않도록 처리하고 있습니다. 이는 제가 제안한 단순한 try-catch보다 훨씬 체계적이고 마이크로서비스 아키텍처에 적합한 방식입니다.

Resilience4j의 circuit breaker 설정과 함께 안정적인 외부 서비스 연동을 구현하셨네요! 👍



🐰✨
   ╭─────╮
   │ 😊  │  학습 완료!
   ╰─────╯

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java:90-106
Timestamp: 2025-05-30T04:55:23.568Z
Learning: PostSearchService에서 외부 서비스 오류 처리를 위해 UserFeignFallback 클래스와 withServiceUnavailable() 메서드를 사용하여 circuit breaker 패턴으로 fallback 처리를 구현. 외부 서비스 장애 시에도 검색 서비스가 중단되지 않도록 graceful degradation을 제공하는 설계 방식을 선호.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>



---

<details>
<summary>🧠 Learnings used</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java:14-18
Timestamp: 2025-05-30T04:45:02.816Z
Learning: UserFeignFallback에서 서비스 장애 시 writerIdList 크기와 관계없이 단일 응답을 반환하는 것을 선호하는 설계 방식. 동일한 withServiceUnavailable() 결과이므로 중복된 응답보다는 단일 응답으로 충분하다고 판단.
```

</details>


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@han16935 맞습니다! Circuit breaker와 fallback 클래스를 통한 방식이 훨씬 더 좋은 접근법이네요. 

`UserFeignFallback`을 통해 외부 서비스 장애 시 graceful degradation을 제공하고 있고, `withServiceUnavailable()` 메서드로 기본값을 반환하여 검색 기능이 중단되지 않도록 처리하고 있습니다. 이는 제가 제안한 단순한 try-catch보다 훨씬 체계적이고 마이크로서비스 아키텍처에 적합한 방식입니다.

Resilience4j의 circuit breaker 설정과 함께 안정적인 외부 서비스 연동을 구현하셨네요! 👍



🐰✨
   ╭─────╮
   │ 😊  │  학습 완료!
   ╰─────╯

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java:90-106
Timestamp: 2025-05-30T04:55:23.568Z
Learning: PostSearchService에서 외부 서비스 오류 처리를 위해 UserFeignFallback 클래스와 withServiceUnavailable() 메서드를 사용하여 circuit breaker 패턴으로 fallback 처리를 구현. 외부 서비스 장애 시에도 검색 서비스가 중단되지 않도록 graceful degradation을 제공하는 설계 방식을 선호.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>



---

<details>
<summary>🧠 Learnings used</summary>

```
Learnt from: han16935
PR: KEA-ChunSam/PlayUs-search-service#19
File: src/main/java/com/playus/searchservice/domain/common/feign/fallback/UserFeignFallback.java:14-18
Timestamp: 2025-05-30T04:45:02.816Z
Learning: UserFeignFallback에서 서비스 장애 시 writerIdList 크기와 관계없이 단일 응답을 반환하는 것을 선호하는 설계 방식. 동일한 withServiceUnavailable() 결과이므로 중복된 응답보다는 단일 응답으로 충분하다고 판단.
```

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-30T04:55:24Z
author_association: NONE
diff_hunk: @@ -0,0 +1,129 @@
+package com.playus.searchservice.domain.post.service;
+
+import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import com.playus.searchservice.domain.post.document.PostDocument;
+import com.playus.searchservice.domain.post.dto.search.SearchRequest;
+import com.playus.searchservice.domain.post.dto.search.SearchResponse;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import com.playus.searchservice.domain.post.vo.PostSearchResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.elasticsearch.client.elc.NativeQuery;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.springframework.data.elasticsearch.core.SearchHits;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class PostSearchService {
+
+    private final ElasticsearchOperations elasticsearchOperations;
+    private final TrendingKeywordAdapter trendingKeywordAdapter;
+    private final UserFeignClient userFeignClient;
+
+    public SearchResponse search(SearchRequest request) {
+
+        String query = request.query();
+
+        Query titleMatchQuery = MatchQuery.of(m -> m
+                        .query(request.query())
+                        .field("title")
+                        .fuzziness("AUTO"))
+                ._toQuery();
+
+        List<Query> filters = new ArrayList<>();
+
+        createSoftAndSecretFilter(filters);
+
+        Query boolQuery = BoolQuery.of(b -> b
+                        .must(titleMatchQuery)
+                        .filter(filters))
+                ._toQuery();
+
+
+        NativeQuery nativeQuery = NativeQuery.builder()
+                .withQuery(boolQuery)
+                .build();
+
+        SearchHits<PostDocument> searchResult = elasticsearchOperations.search(nativeQuery, PostDocument.class);
+
+        increaeTrendingKeyword(query);
+
+        List<PostSearchResult> resultList = searchResult.getSearchHits().stream()
+                .map(result -> result.getContent().toPostSearchResult())
+                .toList();
+
+        updateWriterInfo(resultList);
+
+        return SearchResponse.of(resultList.size(), resultList);
+    }
+
+    public List<TrendingKeywordResponse> getTrendingKeyword() {
+        Set<ZSetOperations.TypedTuple<String>> topKeywords =
+                trendingKeywordAdapter.getTop10Keywords();
+
+        if (topKeywords == null) {
+            return Collections.emptyList();
+        }
+
+        AtomicInteger rank = new AtomicInteger(1);
+        return topKeywords.stream()
+                .map(ZSetOperations.TypedTuple::getValue)
+                .filter(Objects::nonNull)
+                .map(keyword -> TrendingKeywordResponse.of(rank.getAndIncrement(), keyword))
+                .toList();
+    }
+
+    private void updateWriterInfo(List<PostSearchResult> resultList) {
+        List<Long> writerIdList = resultList.stream()
+                .map(PostSearchResult::getWriterId)
+                .toList();
+
+        List<PartyWriterInfoFeignResponse> writerInfoList = userFeignClient.getWriterInfo(writerIdList);
+
+        Map<Long, PartyWriterInfoFeignResponse> writerInfoMap = writerInfoList.stream()
+                .collect(Collectors.toMap(PartyWriterInfoFeignResponse::id, Function.identity(), (a, b) -> a)); // 중복 ID 있을 경우 첫 번째 유지
+
+        for (PostSearchResult post : resultList) {
+            PartyWriterInfoFeignResponse writerInfo = writerInfoMap.get(post.getWriterId());
+            if (writerInfo != null) {
+                post.updateWriterName(writerInfo.writerName());
+            }
+        }
+    }
+
+    private static void createSoftAndSecretFilter(List<Query> filters) {
+        Query notDeletedPostFilter = TermQuery.of(t -> t
+                .field("activated")
+                .value(true)
+        )._toQuery();
+        filters.add(notDeletedPostFilter);
+
+        Query notSecretPostFilter = TermQuery.of(t -> t
+                .field("is_secret")
+                .value(false)
+        )._toQuery();
+        filters.add(notSecretPostFilter);
+    }
+
+    private void increaeTrendingKeyword(String query) {
path: src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
user: login: han16935
body: 수정햇고 추후 푸시하겟습니다!
created_at: 2025-05-30T04:55:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,129 @@
+package com.playus.searchservice.domain.post.service;
+
+import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery;
+import com.playus.searchservice.domain.common.adapter.TrendingKeywordAdapter;
+import com.playus.searchservice.domain.common.feign.client.UserFeignClient;
+import com.playus.searchservice.domain.common.feign.response.PartyWriterInfoFeignResponse;
+import com.playus.searchservice.domain.post.document.PostDocument;
+import com.playus.searchservice.domain.post.dto.search.SearchRequest;
+import com.playus.searchservice.domain.post.dto.search.SearchResponse;
+import com.playus.searchservice.domain.post.dto.trending.TrendingKeywordResponse;
+import com.playus.searchservice.domain.post.vo.PostSearchResult;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.elasticsearch.client.elc.NativeQuery;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.springframework.data.elasticsearch.core.SearchHits;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class PostSearchService {
+
+    private final ElasticsearchOperations elasticsearchOperations;
+    private final TrendingKeywordAdapter trendingKeywordAdapter;
+    private final UserFeignClient userFeignClient;
+
+    public SearchResponse search(SearchRequest request) {
+
+        String query = request.query();
+
+        Query titleMatchQuery = MatchQuery.of(m -> m
+                        .query(request.query())
+                        .field("title")
+                        .fuzziness("AUTO"))
+                ._toQuery();
+
+        List<Query> filters = new ArrayList<>();
+
+        createSoftAndSecretFilter(filters);
+
+        Query boolQuery = BoolQuery.of(b -> b
+                        .must(titleMatchQuery)
+                        .filter(filters))
+                ._toQuery();
+
+
+        NativeQuery nativeQuery = NativeQuery.builder()
+                .withQuery(boolQuery)
+                .build();
+
+        SearchHits<PostDocument> searchResult = elasticsearchOperations.search(nativeQuery, PostDocument.class);
+
+        increaeTrendingKeyword(query);
+
+        List<PostSearchResult> resultList = searchResult.getSearchHits().stream()
+                .map(result -> result.getContent().toPostSearchResult())
+                .toList();
+
+        updateWriterInfo(resultList);
+
+        return SearchResponse.of(resultList.size(), resultList);
+    }
+
+    public List<TrendingKeywordResponse> getTrendingKeyword() {
+        Set<ZSetOperations.TypedTuple<String>> topKeywords =
+                trendingKeywordAdapter.getTop10Keywords();
+
+        if (topKeywords == null) {
+            return Collections.emptyList();
+        }
+
+        AtomicInteger rank = new AtomicInteger(1);
+        return topKeywords.stream()
+                .map(ZSetOperations.TypedTuple::getValue)
+                .filter(Objects::nonNull)
+                .map(keyword -> TrendingKeywordResponse.of(rank.getAndIncrement(), keyword))
+                .toList();
+    }
+
+    private void updateWriterInfo(List<PostSearchResult> resultList) {
+        List<Long> writerIdList = resultList.stream()
+                .map(PostSearchResult::getWriterId)
+                .toList();
+
+        List<PartyWriterInfoFeignResponse> writerInfoList = userFeignClient.getWriterInfo(writerIdList);
+
+        Map<Long, PartyWriterInfoFeignResponse> writerInfoMap = writerInfoList.stream()
+                .collect(Collectors.toMap(PartyWriterInfoFeignResponse::id, Function.identity(), (a, b) -> a)); // 중복 ID 있을 경우 첫 번째 유지
+
+        for (PostSearchResult post : resultList) {
+            PartyWriterInfoFeignResponse writerInfo = writerInfoMap.get(post.getWriterId());
+            if (writerInfo != null) {
+                post.updateWriterName(writerInfo.writerName());
+            }
+        }
+    }
+
+    private static void createSoftAndSecretFilter(List<Query> filters) {
+        Query notDeletedPostFilter = TermQuery.of(t -> t
+                .field("activated")
+                .value(true)
+        )._toQuery();
+        filters.add(notDeletedPostFilter);
+
+        Query notSecretPostFilter = TermQuery.of(t -> t
+                .field("is_secret")
+                .value(false)
+        )._toQuery();
+        filters.add(notSecretPostFilter);
+    }
+
+    private void increaeTrendingKeyword(String query) {
path: src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java
user: login: coderabbitai[bot]
body: @han16935 감사합니다! 오타 수정해주셔서 좋습니다. 푸시하시면 코드가 더 깔끔해질 것 같네요! 👍



ᕱ⑅ᕱ

---

<!-- This is an auto-generated comment: tweet message by coderabbit.ai -->

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=%40coderabbitai%20%EB%8D%95%EB%B6%84%EC%97%90%20%EB%A9%94%EC%84%9C%EB%93%9C%EB%AA%85%20%EC%98%A4%ED%83%80%20%EC%9E%A1%EA%B3%A0%20%EC%BD%94%EB%93%9C%20%EC%95%88%EC%A0%95%EC%84%B1%20UP!%20%EC%9E%91%EC%9D%80%20%EC%8B%A4%EC%88%98%EB%8F%84%20%EB%86%93%EC%B9%98%EC%A7%80%20%EC%95%8A%EB%8A%94%20AI%20%EB%A6%AC%EB%B7%B0%2C%20%EA%B0%9C%EB%B0%9C%20%EC%83%9D%EC%82%B0%EC%84%B1%20%ED%99%95%EC%8B%A4%ED%9E%88%20%EB%86%92%EC%97%AC%EC%A4%8D%EB%8B%88%EB%8B%A4%20%F0%9F%9A%80%F0%9F%90%87%20%EA%BC%AD%20%EC%8D%A8%EB%B3%B4%EC%84%B8%EC%9A%94!&url=https%3A%2F%2Fgithub.com%2FKEA-ChunSam%2FPlayUs-search-service%2Fpull%2F19%23discussion_r2111776863)?

Thank you for using CodeRabbit!

<!-- end of auto-generated comment: tweet message by coderabbit.ai -->


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@han16935 감사합니다! 오타 수정해주셔서 좋습니다. 푸시하시면 코드가 더 깔끔해질 것 같네요! 👍



ᕱ⑅ᕱ

---

&lt;!-- This is an auto-generated comment: tweet message by coderabbit.ai --&gt;

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=%40coderabbitai%20%EB%8D%95%EB%B6%84%EC%97%90%20%EB%A9%94%EC%84%9C%EB%93%9C%EB%AA%85%20%EC%98%A4%ED%83%80%20%EC%9E%A1%EA%B3%A0%20%EC%BD%94%EB%93%9C%20%EC%95%88%EC%A0%95%EC%84%B1%20UP!%20%EC%9E%91%EC%9D%80%20%EC%8B%A4%EC%88%98%EB%8F%84%20%EB%86%93%EC%B9%98%EC%A7%80%20%EC%95%8A%EB%8A%94%20AI%20%EB%A6%AC%EB%B7%B0%2C%20%EA%B0%9C%EB%B0%9C%20%EC%83%9D%EC%82%B0%EC%84%B1%20%ED%99%95%EC%8B%A4%ED%9E%88%20%EB%86%92%EC%97%AC%EC%A4%8D%EB%8B%88%EB%8B%A4%20%F0%9F%9A%80%F0%9F%90%87%20%EA%BC%AD%20%EC%8D%A8%EB%B3%B4%EC%84%B8%EC%9A%94!&url=https%3A%2F%2Fgithub.com%2FKEA-ChunSam%2FPlayUs-search-service%2Fpull%2F19%23discussion_r2111776863)?

Thank you for using CodeRabbit!

&lt;!-- end of auto-generated comment: tweet message by coderabbit.ai --&gt;

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-30T04:56:05Z
author_association: NONE

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-05-28T12:43:53Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

"""
## Walkthrough

검색 서비스에 제목 기반 검색 기능이 새롭게 구현되었습니다. Elasticsearch를 활용한 게시글 검색, Redis 기반 인기 검색어 관리, 외부 사용자 서비스 연동(Feign + Resilience4j), 예외 및 오류 응답 표준화, 환경별 설정 추가, 그리고 통합 및 단위 테스트 코드가 대거 도입되었습니다.

## Changes

| 파일/경로 그룹                                                               | 변경 요약                                                                                                   |
|------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------|
| `build.gradle`                                                               | Redis, OpenFeign, Resilience4j, Testcontainers, WireMock 등 관련 의존성 추가                                 |
| `src/main/resources/application-*.yaml`                                      | Redis/Feign/Resilience4j/관리 엔드포인트 등 환경설정 추가 및 수정                                           |
| `src/main/java/com/playus/searchservice/domain/common/adapter/TrendingKeywordAdapter.java`<br>`.../TrendingKeywordAdapterTest.java` | Redis 기반 인기 검색어 관리 어댑터 및 통합 테스트 추가                                                      |
| `src/main/java/com/playus/searchservice/domain/common/feign/client/UserFeignClient.java`<br>`.../fallback/UserFeignFallback.java`<br>`.../response/PartyWriterInfoFeignResponse.java` | 외부 사용자 서비스 연동 Feign 클라이언트, 장애시 Fallback, 응답 DTO 추가                                     |
| `src/main/java/com/playus/searchservice/domain/post/controller/PostSearchController.java`<br>`.../specification/PostSearchControllerSpecification.java`<br>`.../dto/search/SearchRequest.java`<br>`.../dto/search/SearchResponse.java`<br>`.../dto/trending/TrendingKeywordResponse.java`<br>`.../vo/PostSearchResult.java`<br>`.../enums/TeamTag.java` | 게시글 검색/인기 검색어 API 컨트롤러, DTO, VO, enum 등 신규 도입                                            |
| `src/main/java/com/playus/searchservice/domain/post/document/PostDocument.java` | Elasticsearch 게시글 도큐먼트 매핑 클래스 추가                                                              |
| `src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java`<br>`.../service/PostSearchServiceTest.java` | 게시글 검색 서비스 구현, 인기 검색어 조회 서비스 및 통합 테스트 추가                                         |
| `src/main/java/com/playus/searchservice/global/data/RedisConfig.java`         | Redis 연결 및 템플릿 설정 추가                                                                              |
| `src/main/java/com/playus/searchservice/global/config/sentry/SentryConfig.java`<br>`.../swagger/SwaggerConfig.java` | 패키지 이동 및 SentryConfig에 프로파일 제한 추가                                                            |
| `src/main/java/com/playus/searchservice/global/exception/ExceptionAdvice.java`<br>`.../response/ErrorResponse.java` | 예외 처리 어드바이스, 표준 오류 응답 레코드 및 팩토리 메서드 추가                                           |
| `src/test/java/com/playus/searchservice/ControllerTestSupport.java`<br>`.../IntegrationTestSupport.java` | 컨트롤러/통합 테스트용 추상 지원 클래스 추가                                                                |
| `src/test/java/com/playus/searchservice/domain/post/controller/PostSearchControllerTest.java` | 게시글 검색 컨트롤러 단위 테스트 추가                                                                       |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant Client
    participant PostSearchController
    participant PostSearchService
    participant Elasticsearch
    participant TrendingKeywordAdapter
    participant UserFeignClient
    participant Redis

    Client->>PostSearchController: GET /search/posts?query=...
    PostSearchController->>PostSearchService: search(request)
    PostSearchService->>Elasticsearch: search(query, filters)
    Elasticsearch-->>PostSearchService: PostDocument[]
    PostSearchService->>TrendingKeywordAdapter: existsKeyword(query)
    alt exists
        PostSearchService->>TrendingKeywordAdapter: scoreKeyword(query)
    else not exists
        PostSearchService->>TrendingKeywordAdapter: addKeyword(query)
    end
    PostSearchService->>UserFeignClient: getWriterInfo(writerIdList)
    UserFeignClient-->>PostSearchService: PartyWriterInfoFeignResponse[]
    PostSearchService-->>PostSearchController: SearchResponse
    PostSearchController-->>Client: 200 OK + SearchResponse

    Client->>PostSearchController: GET /search/trending
    PostSearchController->>PostSearchService: getTrendingKeyword()
    PostSearchService->>TrendingKeywordAdapter: getTop10Keywords()
    TrendingKeywordAdapter->>Redis: ZREVRANGE "TRENDING-KEYWORD"
    Redis-->>TrendingKeywordAdapter: keyword/score set
    TrendingKeywordAdapter-->>PostSearchService: Set<TypedTuple>
    PostSearchService-->>PostSearchController: List<TrendingKeywordResponse>
    PostSearchController-->>Client: 200 OK + List<TrendingKeywordResponse>
```

## Assessment against linked issues

| Objective                                                      | Addressed | Explanation |
|---------------------------------------------------------------|:---------:|-------------|
| 제목 기반 게시글 검색 기능 구현 (#18)                          | ✅        |             |
| 인기 검색어(트렌딩 키워드) 관리 및 조회 기능 구현 (#18)         | ✅        |             |
| 외부 사용자 서비스 연동 및 장애 대응(Fallback) 구현 (#18)       | ✅        |             |
| 예외 처리 및 표준 오류 응답 구조 적용 (#18)                    | ✅        |             |

## Poem

> 🐇  
> 새싹처럼 돋아난 검색의 힘,  
> Redis와 ES가 손을 잡고,  
> 인기 키워드도 척척 기록!  
> 외부 서비스도 똑똑하게 연결,  
> 예외엔 우아하게 응답하니,  
> 토끼 개발자, 귀 쫑긋 세우고  
> 오늘도 코드를 춤추게 하네!  
> 🌱✨
"""

</details>

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between b7133fad86b72204aa1dfd654ebb5d5650d0c237 and e011804e9056562ad22c2abad624b3d00d529645.

</details>

<details>
<summary>📒 Files selected for processing (3)</summary>

* `src/main/java/com/playus/searchservice/domain/common/feign/response/PartyWriterInfoFeignResponse.java` (1 hunks)
* `src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java` (1 hunks)
* `src/test/java/com/playus/searchservice/domain/post/service/PostSearchServiceTest.java` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (3)</summary>

* src/test/java/com/playus/searchservice/domain/post/service/PostSearchServiceTest.java
* src/main/java/com/playus/searchservice/domain/common/feign/response/PartyWriterInfoFeignResponse.java
* src/main/java/com/playus/searchservice/domain/post/service/PostSearchService.java

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (1)</summary>

* GitHub Check: test-result

</details>

</details>
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=KEA-ChunSam/PlayUs-search-service&utm_content=19):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-28T12:46:30Z
body: ## Test Results
11 tests   11 ✅  1s ⏱️
 4 suites   0 💤
 4 files     0 ❌

Results for commit e011804e.

[test-results]:data:application/gzip;base64,H4sIAH47OWgC/12Myw7CIBBFf6Vh7WIYgVh/xlCgycS2GB4r4787rQZbd/ecm5ynGGkKWVw7depErlQa+JpsobgwSkY+ynrJBrdcnfszd3qwgSZGS9NBhJRi+ppUl1Zc9yH4Eb/exrvcxvuai/NMhUEEkPICKvSgjTZoPaJDO1hvUA1nD+A19kZp8XoDH8uytP8AAAA=

:recycle: This comment has been updated with latest results.
author_association: NONE