Skip to content

Commit 72ff037

Browse files
authored
Merge pull request #73 from BackEndSchoolPlus3th/SMS-83-feature-keywordnotES
feat: 해쉬태그 자동완성 개선
2 parents 11394e4 + a8b0618 commit 72ff037

7 files changed

Lines changed: 103 additions & 46 deletions

File tree

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,81 @@
11
package org.com.stocknote.domain.hashtagAutocomplete;
22

33
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.data.redis.core.RedisCallback;
46
import org.springframework.data.redis.core.StringRedisTemplate;
57
import org.springframework.stereotype.Service;
8+
import org.springframework.data.domain.Range;
69

7-
import java.util.Set;
10+
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.*;
813

914
@Service
1015
@RequiredArgsConstructor
11-
public class RedisSortedSetService { //검색어 자동 완성을 구현할 때 사용하는 Redis의 SortedSet 관련 서비스 레이어
16+
@Slf4j
17+
public class RedisSortedSetService {
1218
private final StringRedisTemplate redisTemplate;
13-
private String key = "autocorrect"; //검색어 자동 완성을 위한 Redis 데이터
14-
private int score = 0; //Score는 딱히 필요 없으므로 하나로 통일
19+
private static final String KEY = "autocorrect";
20+
private static final int MAX_RESULTS = 10;
21+
private static final String SUFFIX = "*"; // 완전한 단어 구분
22+
23+
public void addAllToSortedSet(List<String> values) {
24+
try {
25+
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
26+
byte[] keyBytes = KEY.getBytes(StandardCharsets.UTF_8);
1527

16-
public void addToSortedSet(String value) { //Redis SortedSet에 추가
17-
redisTemplate.opsForZSet().add(key, value, score);
28+
for (String value : values) {
29+
byte[] fullValueBytes = (value + SUFFIX).getBytes(StandardCharsets.UTF_8);
30+
connection.zAdd(keyBytes, 0, fullValueBytes);
31+
32+
// 모든 부분 문자열 저장
33+
for (int i = 1; i <= value.length(); i++) {
34+
byte[] substringBytes = value.substring(0, i).getBytes(StandardCharsets.UTF_8);
35+
connection.zAdd(keyBytes, 0, substringBytes);
36+
}
37+
}
38+
return null;
39+
});
40+
} catch (Exception e) {
41+
log.error("Error in batch add: {}", e.getMessage());
42+
throw new RuntimeException("Failed to add values to Redis", e);
43+
}
1844
}
45+
public List<String> autocomplete(String keyword) {
46+
if (keyword == null || keyword.trim().isEmpty()) {
47+
return Collections.emptyList();
48+
}
49+
50+
String searchKeyword = keyword.trim();
51+
String min = searchKeyword;
52+
String max = searchKeyword + "\uffff";
1953

20-
public Long findFromSortedSet(String value) { //Redis SortedSet에서 Value를 찾아 인덱스를 반환
21-
return redisTemplate.opsForZSet().rank(key, value);
54+
try {
55+
List<String> results = redisTemplate.opsForZSet()
56+
.rangeByLex(KEY, Range.rightOpen(min, max))
57+
.stream()
58+
.filter(value -> value.endsWith(SUFFIX)) // 완전한 단어만 필터링
59+
.map(value -> value.replace(SUFFIX, ""))
60+
.limit(MAX_RESULTS)
61+
.toList();
62+
63+
return results;
64+
} catch (Exception e) {
65+
log.error("Error in autocomplete: {}", e.getMessage());
66+
return Collections.emptyList();
67+
}
2268
}
2369

24-
public Set<String> findAllValuesAfterIndexFromSortedSet(Long index) {
25-
return redisTemplate.opsForZSet().range(key, index, index + 10); //전체를 다 불러오기 보다는 200개 정도만 가져와도 자동 완성을 구현하는 데 무리가 없으므로 200개로 rough하게 설정
70+
public void clearAll() {
71+
try {
72+
redisTemplate.delete(KEY);
73+
} catch (Exception e) {
74+
log.error("Error clearing Redis: {}", e.getMessage());
75+
throw new RuntimeException("Failed to clear Redis", e);
76+
}
2677
}
2778
}
79+
80+
81+

src/main/java/org/com/stocknote/domain/hashtagAutocomplete/controller/HashtagAutocompleteController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@ public class HashtagAutocompleteController {
1717

1818
@GetMapping("/search/{q}")
1919
public List<String> query(@PathVariable("q") String query) {
20-
return hashtagAutocompleteService.autocorrect(query);
20+
return hashtagAutocompleteService.autocomplete(query);
2121
}
2222
}

src/main/java/org/com/stocknote/domain/hashtagAutocomplete/service/HashtagAutocompleteService.java

Lines changed: 14 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,56 +4,37 @@
44
import org.com.stocknote.domain.stock.repository.StockRepository;
55
import org.springframework.transaction.annotation.Transactional;
66
import lombok.RequiredArgsConstructor;
7-
import org.apache.commons.lang3.StringUtils;
87
import org.com.stocknote.domain.hashtagAutocomplete.RedisSortedSetService;
98
import org.springframework.stereotype.Service;
109

11-
import java.util.ArrayList;
12-
import java.util.List;
13-
import java.util.Set;
10+
import java.util.*;
11+
1412

1513
@Service
1614
@Transactional(readOnly = true)
1715
@RequiredArgsConstructor
1816
public class HashtagAutocompleteService {
1917
private final StockRepository stockRepository;
2018
private final RedisSortedSetService redisSortedSetService;
21-
private String suffix = "*"; //검색어 자동 완성 기능에서 실제 노출될 수 있는 완벽한 형태의 단어를 구분하기 위한 접미사
22-
private int maxSize = 10; //검색어 자동 완성 기능 최대 개수
19+
private static final int MAX_SIZE = 10;
2320

2421
@PostConstruct
25-
public void init() { //이 Service Bean이 생성된 이후에 검색어 자동 완성 기능을 위한 데이터들을 Redis에 저장 (Redis는 인메모리 DB라 휘발성을 띄기 때문)
26-
saveAllSubstring(stockRepository.findAllName()); //MySQL DB에 저장된 모든 가게명을 음절 단위로 잘라 모든 Substring을 Redis에 저장해주는 로직
27-
}
22+
public void init() {
23+
redisSortedSetService.clearAll();
2824

29-
private void saveAllSubstring(List<String> allDisplayName) { //MySQL DB에 저장된 모든 가게명을 음절 단위로 잘라 모든 Substring을 Redis에 저장해주는 로직
30-
// long start1 = System.currentTimeMillis(); //뒤에서 성능 비교를 위해 시간을 재는 용도
31-
for (String displayName : allDisplayName) {
32-
redisSortedSetService.addToSortedSet(displayName + suffix); //완벽한 형태의 단어일 경우에는 *을 붙여 구분
33-
34-
for (int i = displayName.length(); i > 0; --i) { //음절 단위로 잘라서 모든 Substring 구하기
35-
redisSortedSetService.addToSortedSet(displayName.substring(0, i)); //곧바로 redis에 저장
36-
}
25+
List<String> allNames = stockRepository.findAllName();
26+
if (allNames != null && !allNames.isEmpty()) {
27+
redisSortedSetService.addAllToSortedSet(allNames);
3728
}
38-
// long end1 = System.currentTimeMillis(); //뒤에서 성능 비교를 위해 시간을 재는 용도
39-
// long elapsed1 = end1 - start1; //뒤에서 성능 비교를 위해 시간을 재는 용도
4029
}
4130

42-
public List<String> autocorrect(String keyword) { //검색어 자동 완성 기능 관련 로직
43-
Long index = redisSortedSetService.findFromSortedSet(keyword); //사용자가 입력한 검색어를 바탕으로 Redis에서 조회한 결과 매칭되는 index
44-
45-
if (index == null) {
46-
return new ArrayList<>(); //만약 사용자 검색어 바탕으로 자동 완성 검색어를 만들 수 없으면 Empty Array 리턴
31+
public List<String> autocomplete(String keyword) {
32+
if (keyword == null || keyword.trim().isEmpty()) {
33+
return Collections.emptyList();
4734
}
4835

49-
Set<String> allValuesAfterIndexFromSortedSet = redisSortedSetService.findAllValuesAfterIndexFromSortedSet(index); //사용자 검색어 이후로 정렬된 Redis 데이터들 가져오기
50-
51-
List<String> autocorrectKeywords = allValuesAfterIndexFromSortedSet.stream()
52-
.filter(value -> value.endsWith(suffix) && value.startsWith(keyword))
53-
.map(value -> StringUtils.removeEnd(value, suffix))
54-
.limit(maxSize)
55-
.toList(); //자동 완성을 통해 만들어진 최대 maxSize개의 키워드들
56-
57-
return autocorrectKeywords;
36+
return redisSortedSetService.autocomplete(keyword.trim());
5837
}
5938
}
39+
40+

src/main/java/org/com/stocknote/domain/post/controller/PostController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public GlobalResponse<Long> createPost(
4646
) {
4747
Member member = principalDetails.user();
4848
Post post = postService.createPost(postResponseDto, member);
49-
PostDoc postDoc= searchDocService.savePostDoc(post);
49+
PostDoc postDoc= searchDocService.transformPostDoc(post);
5050
keywordNotificationElasticService.createKeywordNotification(postDoc);
5151
return GlobalResponse.success(post.getId());
5252
}

src/main/java/org/com/stocknote/domain/searchDoc/service/SearchDocService.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,24 @@ public Page<PostDoc> searchPosts(PostSearchConditionDto condition, Pageable page
120120
return Page.empty(pageable);
121121
}
122122
}
123+
124+
public PostDoc transformPostDoc(Post post) {
125+
List<Hashtag> hashtags = hashtagRepository.findByPostId(post.getId());
126+
127+
List<String> hashtagList = hashtags.stream()
128+
.map(Hashtag::getName)
129+
.collect(Collectors.toList());
130+
131+
PostDoc postDoc = PostDoc.builder()
132+
.id(post.getId().toString())
133+
.createdAt(post.getCreatedAt().toString())
134+
.modifiedAt(post.getModifiedAt().toString())
135+
.title(post.getTitle())
136+
.body(post.getBody())
137+
.category(post.getCategory())
138+
.hashtags(hashtagList) // 본문에서 해시태그 추출하는 메소드 필요
139+
.memberDoc(convertToMemberDoc(post.getMember())) // Member를 MemberDoc으로 변환하는 메소드 필요
140+
.build();
141+
return postDoc;
142+
}
123143
}

src/main/java/org/com/stocknote/domain/stock/repository/StockRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,6 @@ public interface StockRepository extends JpaRepository<Stock, String> {
2222

2323
@Query("SELECT s.name FROM Stock s")
2424
List<String> findAllName();
25+
26+
List<Stock> findByNameStartingWithIgnoreCase(String searchKeyword);
2527
}

src/main/java/org/com/stocknote/domain/stock/service/StockService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public List<Stock> searchStocks(String keyword) {
5151

5252
String searchKeyword = keyword.toLowerCase();
5353
return stockRepository.findByNameContainingIgnoreCaseOrCodeContainingIgnoreCase(searchKeyword,
54-
searchKeyword);
54+
searchKeyword);
5555
}
5656

5757
@Transactional

0 commit comments

Comments
 (0)