프로젝트/PlayUs

11. Elasticsearch 를 이용한 제목 기반 검색 기능 구현

han1693516 2025. 6. 16. 17:57

 

이번 프로젝트에서는 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);
    }