이번 프로젝트에서는 Elasticsearch 내 역인덱스를 이용해 검색 기능을 구현해 보려 한다. 이를 위해 title field 에 대해 nori analyzer를 적용시켰다! 현재 Debezium 을 통해 MySQL 내 데이터가 Elasticsearch 로 동기화되고 있어서, 할 때에는 기존 index 를 DELETE 문으로 지우고, 아래를 실행시켰다.

위 index 에 접근하기 위해 다음과 같은 Document 클래스를 작성했다
상단 @Document 를 보면 indexName 에 SPEL 을 적용해 각 프로파일별로 다른 index 를 사용할 수 있도록 구성했다!
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;
@Getter
@NoArgsConstructor
@Document(indexName = "#{@indexNameProvider.indexName()}")
public class PostDocument {
@Id
private Long id;
@Field(type = FieldType.Text) // Text 를 적용할 경우, 내부 문자가 tokenize 되어서 쪼개진다!
private String title;
private String description;
@Field(name = "image_url")
private String imageUrl;
@Field(type = FieldType.Keyword, name = "tag") // Keyword 를 적용할 경우, 문자열에 tokenizer 가 적용되지 않는다!
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)
.teamTag(tag)
.createdAt(createdAt)
.build();
}
}
@Profile("!test")
@Component
public class IndexNameProvider {
@Value("${spring.elasticsearch.index}")
private String indexName;
public String indexName() {
return indexName;
}
}
검색은 다음과 같이 구현했다.
MatchQuery 를 통해 title 의 토큰 중 하나가 request 내 검색어와 일치하는 지 확인하고, fuziness 를 통해 오타를 보정한다.
@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(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);
}
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 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());
}
}
}
}
createSoftAndSecretFilter() 의 경우 activated 와 is_secret 을 통해 soft delete 되지 않은 글과 커뮤니티 글 (is_secret 이 false 면 커뮤니티, true 면 직관일지) 를 가져올 수 있도록 하는 필터를 적용한다
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);
}'프로젝트 > PlayUs' 카테고리의 다른 글
| 선착순 직관팟 신청 기능 대한 성능 개선 (0) | 2025.12.24 |
|---|---|
| 12. SentryConfig, SwaggerConfig 적용 (0) | 2025.06.16 |
| 10. env 파일 적용 (0) | 2025.06.16 |
| 9. Redisson 을 이용한 분산 락 구현 (0) | 2025.06.16 |
| 8. Spring Data MongoDB에서의 Soft Delete + @Query 사용하면서 나너무많은일이잇엇어 (0) | 2025.05.17 |