Skip to content

Commit 5828dda

Browse files
committed
feat: Elasticsearch 검색 기능 개선
1 parent 3505f69 commit 5828dda

7 files changed

Lines changed: 374 additions & 14 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package swyp.dodream.domain.search.config;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.boot.CommandLineRunner;
6+
import org.springframework.context.annotation.Profile;
7+
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
8+
import org.springframework.data.elasticsearch.core.IndexOperations;
9+
import org.springframework.stereotype.Component;
10+
import swyp.dodream.domain.search.document.PostDocument;
11+
12+
/**
13+
* Elasticsearch 인덱스 초기화
14+
* 애플리케이션 시작 시 인덱스를 재생성합니다.
15+
*
16+
* 주의: 운영 환경에서는 사용하지 마세요! (데이터 손실)
17+
* @Profile("dev") 또는 @Profile("local")로 제한하는 것을 권장
18+
*/
19+
@Slf4j
20+
@Component
21+
@RequiredArgsConstructor
22+
@Profile("dev") // 로컬 환경에서만 실행
23+
public class ElasticsearchIndexInitializer implements CommandLineRunner {
24+
25+
private final ElasticsearchOperations elasticsearchOperations;
26+
27+
@Override
28+
public void run(String... args) {
29+
log.info("Elasticsearch 인덱스 초기화 시작");
30+
31+
try {
32+
IndexOperations indexOps = elasticsearchOperations.indexOps(PostDocument.class);
33+
34+
// 기존 인덱스가 있으면 삭제
35+
if (indexOps.exists()) {
36+
log.info("기존 'posts' 인덱스 삭제");
37+
indexOps.delete();
38+
}
39+
40+
// 새 인덱스 생성
41+
log.info("새 'posts' 인덱스 생성");
42+
indexOps.create();
43+
44+
// 매핑 설정
45+
indexOps.putMapping(indexOps.createMapping(PostDocument.class));
46+
47+
log.info("Elasticsearch 인덱스 초기화 완료");
48+
} catch (Exception e) {
49+
log.error("Elasticsearch 인덱스 초기화 실패", e);
50+
}
51+
}
52+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package swyp.dodream.domain.search.controller;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.tags.Tag;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.http.ResponseEntity;
7+
import org.springframework.web.bind.annotation.PostMapping;
8+
import org.springframework.web.bind.annotation.RequestMapping;
9+
import org.springframework.web.bind.annotation.RestController;
10+
import swyp.dodream.domain.search.service.PostIndexService;
11+
12+
/**
13+
* Elasticsearch 인덱스 관리 API
14+
* 개발/테스트 용도로 사용
15+
*/
16+
@Tag(name = "Search Admin", description = "검색 인덱스 관리 API")
17+
@RestController
18+
@RequiredArgsConstructor
19+
@RequestMapping("/api/admin/search")
20+
public class SearchAdminController {
21+
22+
private final PostIndexService postIndexService;
23+
24+
@PostMapping("/reindex")
25+
@Operation(
26+
summary = "전체 게시글 재인덱싱",
27+
description = "DB의 모든 게시글을 Elasticsearch에 다시 인덱싱합니다. 검색이 안 될 때 사용하세요."
28+
)
29+
public ResponseEntity<String> reindexAllPosts() {
30+
postIndexService.reindexAllPosts();
31+
return ResponseEntity.ok("재인덱싱이 완료되었습니다.");
32+
}
33+
}

src/main/java/swyp/dodream/domain/search/document/PostDocument.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
import lombok.*;
44
import org.springframework.data.annotation.Id;
55
import org.springframework.data.elasticsearch.annotations.Document;
6+
import org.springframework.data.elasticsearch.annotations.Field;
7+
import org.springframework.data.elasticsearch.annotations.FieldType;
8+
import org.springframework.data.elasticsearch.annotations.Setting;
69

710
@Document(indexName = "posts")
11+
@Setting(settingPath = "elasticsearch/post-settings.json")
812
@Getter
913
@NoArgsConstructor
1014
@AllArgsConstructor
@@ -14,6 +18,19 @@ public class PostDocument {
1418
@Id
1519
private Long id;
1620

21+
/**
22+
* 제목: post_analyzer 사용
23+
* - nori_tokenizer로 한글 형태소 분석
24+
* - lowercase 필터로 대소문자 통일
25+
* - my_synonyms 필터로 동의어 처리
26+
*/
27+
@Field(type = FieldType.Text, analyzer = "post_analyzer")
1728
private String title;
29+
30+
/**
31+
* 내용: post_analyzer 사용
32+
* 제목과 동일한 분석기 적용
33+
*/
34+
@Field(type = FieldType.Text, analyzer = "post_analyzer")
1835
private String description;
19-
}
36+
}

src/main/java/swyp/dodream/domain/search/repository/PostDocumentRepository.java

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,84 @@
88

99
public interface PostDocumentRepository extends ElasticsearchRepository<PostDocument, Long> {
1010

11+
/**
12+
* 기본 검색
13+
* - 대소문자 구분 없음 (lowercase 필터)
14+
* - 단어 순서 무관 (multi_match)
15+
* - 오타 허용 (fuzziness AUTO)
16+
* - 동의어 자동 적용 (my_synonyms 필터)
17+
* - 한글/영어 모두 지원 (nori_tokenizer)
18+
*
19+
* 예시:
20+
* - "spring" 검색 → "스프링", "Spring" 모두 검색됨
21+
* - "프론트" 검색 → "frontend", "front-end" 모두 검색됨
22+
* - "자바" 검색 → "java", "Java" 모두 검색됨
23+
*/
1124
@Query("""
1225
{
13-
"bool": {
14-
"should": [
15-
{ "match": { "title": "?0" } },
16-
{ "match": { "description": "?0" } }
17-
]
26+
"multi_match": {
27+
"query": "?0",
28+
"fields": ["title^2", "description"],
29+
"type": "best_fields",
30+
"operator": "or",
31+
"fuzziness": "AUTO",
32+
"prefix_length": 0,
33+
"max_expansions": 50,
34+
"fuzzy_transpositions": true
1835
}
1936
}
2037
""")
21-
List<PostDocument> searchByTitleOrDescription(String keyword);
38+
List<PostDocument> searchByKeyword(String keyword);
2239

40+
/**
41+
* 엄격한 검색: 모든 단어가 포함되어야 함
42+
* 동의어는 여전히 적용됨
43+
*/
2344
@Query("""
2445
{
2546
"multi_match": {
2647
"query": "?0",
27-
"fields": ["title", "description"],
28-
"operator": "or",
48+
"fields": ["title^2", "description"],
49+
"type": "cross_fields",
50+
"operator": "and",
2951
"fuzziness": "AUTO"
3052
}
3153
}
3254
""")
33-
List<PostDocument> searchWithFuzzy(String keyword);
55+
List<PostDocument> searchByKeywordStrict(String keyword);
56+
57+
/**
58+
* 퍼지 검색: 오타에 더 관대
59+
* fuzziness를 2로 설정하여 최대 2글자까지 오타 허용
60+
*/
61+
@Query("""
62+
{
63+
"multi_match": {
64+
"query": "?0",
65+
"fields": ["title^2", "description"],
66+
"type": "best_fields",
67+
"operator": "or",
68+
"fuzziness": "2",
69+
"prefix_length": 0,
70+
"max_expansions": 100
71+
}
72+
}
73+
""")
74+
List<PostDocument> searchWithHighFuzziness(String keyword);
75+
76+
/**
77+
* 제목만 검색 (제목에서만 찾고 싶을 때)
78+
*/
79+
@Query("""
80+
{
81+
"match": {
82+
"title": {
83+
"query": "?0",
84+
"operator": "or",
85+
"fuzziness": "AUTO"
86+
}
87+
}
88+
}
89+
""")
90+
List<PostDocument> searchByTitle(String keyword);
3491
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package swyp.dodream.domain.search.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.stereotype.Service;
6+
import org.springframework.transaction.annotation.Transactional;
7+
import swyp.dodream.domain.post.domain.Post;
8+
import swyp.dodream.domain.post.repository.PostRepository;
9+
import swyp.dodream.domain.search.document.PostDocument;
10+
import swyp.dodream.domain.search.repository.PostDocumentRepository;
11+
12+
import java.util.List;
13+
import java.util.stream.Collectors;
14+
15+
/**
16+
* DB의 게시글을 Elasticsearch에 동기화하는 서비스
17+
*/
18+
@Slf4j
19+
@Service
20+
@RequiredArgsConstructor
21+
public class PostIndexService {
22+
23+
private final PostRepository postRepository;
24+
private final PostDocumentRepository postDocumentRepository;
25+
26+
/**
27+
* 단일 게시글을 Elasticsearch에 인덱싱
28+
*/
29+
public void indexPost(Post post) {
30+
PostDocument document = PostDocument.builder()
31+
.id(post.getId())
32+
.title(post.getTitle())
33+
.description(post.getContent())
34+
.build();
35+
36+
postDocumentRepository.save(document);
37+
log.debug("게시글 인덱싱 완료: ID={}, 제목={}", post.getId(), post.getTitle());
38+
}
39+
40+
/**
41+
* 모든 게시글을 Elasticsearch에 재인덱싱
42+
* 초기 데이터 동기화 또는 인덱스 재구성 시 사용
43+
*/
44+
@Transactional(readOnly = true)
45+
public void reindexAllPosts() {
46+
log.info("전체 게시글 재인덱싱 시작");
47+
48+
// 기존 인덱스 전체 삭제
49+
postDocumentRepository.deleteAll();
50+
51+
// DB의 모든 게시글 조회
52+
List<Post> allPosts = postRepository.findAll();
53+
54+
// PostDocument로 변환
55+
List<PostDocument> documents = allPosts.stream()
56+
.map(post -> PostDocument.builder()
57+
.id(post.getId())
58+
.title(post.getTitle())
59+
.description(post.getContent())
60+
.build())
61+
.collect(Collectors.toList());
62+
63+
// 일괄 저장
64+
postDocumentRepository.saveAll(documents);
65+
66+
log.info("전체 게시글 재인덱싱 완료: {}건", documents.size());
67+
}
68+
69+
/**
70+
* 게시글 삭제 시 Elasticsearch에서도 삭제
71+
*/
72+
public void deletePost(Long postId) {
73+
postDocumentRepository.deleteById(postId);
74+
log.debug("게시글 인덱스 삭제: ID={}", postId);
75+
}
76+
}

src/main/java/swyp/dodream/domain/search/service/SearchService.java

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package swyp.dodream.domain.search.service;
22

33
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
45
import org.springframework.stereotype.Service;
56
import swyp.dodream.domain.bookmark.repository.BookmarkRepository;
67
import swyp.dodream.domain.post.domain.Post;
@@ -12,7 +13,9 @@
1213
import swyp.dodream.domain.search.repository.PostDocumentRepository;
1314

1415
import java.util.List;
16+
import java.util.stream.Collectors;
1517

18+
@Slf4j
1619
@Service
1720
@RequiredArgsConstructor
1821
public class SearchService {
@@ -22,16 +25,56 @@ public class SearchService {
2225
private final ProfileRepository profileRepository;
2326
private final BookmarkRepository bookmarkRepository;
2427

28+
/**
29+
* 게시글 검색
30+
* - 대소문자 구분 없음
31+
* - 단어 순서 무관
32+
* - 오타 허용 (AUTO fuzziness)
33+
* - 한글/영어 모두 지원
34+
*/
2535
public List<PostResponse> searchPosts(String keyword, Long userId) {
36+
log.debug("검색 키워드: {}, 사용자 ID: {}", keyword, userId);
2637

27-
List<PostDocument> docs = postDocumentRepository.searchByTitleOrDescription(keyword);
38+
// 1. Elasticsearch에서 검색
39+
List<PostDocument> docs = postDocumentRepository.searchByKeyword(keyword);
40+
41+
log.debug("Elasticsearch 검색 결과: {}건", docs.size());
42+
43+
// 2. DB에서 전체 정보 조회 및 응답 변환
44+
return docs.stream()
45+
.map(doc -> postRepository.findById(doc.getId())
46+
.orElse(null))
47+
.filter(post -> post != null) // null 필터링
48+
.map(post -> toPostResponse(post, userId))
49+
.collect(Collectors.toList());
50+
}
51+
52+
/**
53+
* 엄격한 검색 (모든 키워드가 포함되어야 함)
54+
*/
55+
public List<PostResponse> searchPostsStrict(String keyword, Long userId) {
56+
List<PostDocument> docs = postDocumentRepository.searchByKeywordStrict(keyword);
57+
58+
return docs.stream()
59+
.map(doc -> postRepository.findById(doc.getId())
60+
.orElse(null))
61+
.filter(post -> post != null)
62+
.map(post -> toPostResponse(post, userId))
63+
.collect(Collectors.toList());
64+
}
65+
66+
/**
67+
* 오타에 더 관대한 검색
68+
*/
69+
public List<PostResponse> searchPostsWithHighFuzziness(String keyword, Long userId) {
70+
List<PostDocument> docs = postDocumentRepository.searchWithHighFuzziness(keyword);
2871

2972
return docs.stream()
3073
.map(doc -> postRepository.findById(doc.getId())
31-
.orElseThrow(() -> new RuntimeException("POST not found in DB: " + doc.getId()))
32-
)
74+
.orElse(null))
75+
.filter(post -> post != null)
3376
.map(post -> toPostResponse(post, userId))
34-
.toList();
77+
.collect(Collectors.toList());
3578
}
3679

3780
private PostResponse toPostResponse(Post post, Long userId) {

0 commit comments

Comments
 (0)