Skip to content

Commit

Permalink
Merge pull request #108 from Korea-Certified-Store/feature/fix-search…
Browse files Browse the repository at this point in the history
…Keyword-autocorrect(#107)

검색어 자동 완성 API 로직 변경 (#107)
  • Loading branch information
sungjindev authored Feb 19, 2024
2 parents 2ca264c + 46157d7 commit 7fd7d89
Show file tree
Hide file tree
Showing 10 changed files with 194 additions and 128 deletions.
3 changes: 0 additions & 3 deletions .github/workflows/action-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,6 @@ jobs:
mysql user: 'test'
mysql password: ${{ secrets.DB_PASSWORD }}

- name: Set up Redis
uses: shogo82148/[email protected]

# github action 에서 Gradle dependency 캐시 사용
- name: Cache Gradle packages
uses: actions/cache@v3
Expand Down
3 changes: 0 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,6 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
}

//Redis를 사용하기 위해 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

//Validation을 위해 추가! Spring Boot 2.3버전 이후부터는 web 의존성안에 있던 validation 관련 package가 아예 모듈로 빠짐
implementation 'org.springframework.boot:spring-boot-starter-validation'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,62 +1,100 @@
package com.nainga.nainga.domain.store.application;

import com.nainga.nainga.domain.store.dao.StoreRepository;
import com.nainga.nainga.global.application.RedisSortedSetService;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class StoreService {
private final StoreRepository storeRepository;
private final RedisSortedSetService redisSortedSetService;
private String suffix = "*"; //검색어 자동 완성 기능에서 실제 노출될 수 있는 완벽한 형태의 단어를 구분하기 위한 접미사
private int maxSize = 10; //검색어 자동 완성 기능 최대 개수

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

public List<String> autocorrect(String keyword) { //검색어 자동 완성 로직
return storeRepository.findAllBySearchKeyword(keyword);
}

public void saveAllSubstring(List<String> allDisplayName) {
ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); //병렬 처리를 위한 스레드풀을 생성하는 과정
// private final RedisSortedSetService redisSortedSetService;
// private final RedisHashService redisHashService;

for (String displayName : allDisplayName) {
executorService.submit(() -> { //submit 메서드를 사용해서 병렬 처리할 작업 추가
redisSortedSetService.addToSortedSet(displayName + suffix);
/*
Redis Hash 자료 구조를 활용한 새로운 검색어 자동 완성 로직입니다.
아래 로직은 Case insensitive하게 검색될 수 있도록 구현하였습니다.
지금은 MySQL이 성능이 더 좋아서 사용하지 않습니다.
*/

for (int i = displayName.length(); i > 0; --i) {
redisSortedSetService.addToSortedSet(displayName.substring(0, i));
}
});
}
executorService.shutdown(); //작업이 모두 완료되면 스레드풀을 종료
}
// @PostConstruct
// public void init() { //이 Service Bean이 생성된 이후에 검색어 자동 완성 기능을 위한 데이터들을 Redis에 저장 (Redis는 인메모리 DB라 휘발성을 띄기 때문)
// redisHashService.removeAllOfHash();
// saveAllDisplayName(storeRepository.findAllDisplayName()); //모든 가게명을 소문자로 변환한 것을 field, 원래 가게 이름을 value로 매핑시켜서 Redis Hash에 저장
// }

public List<String> autocorrect(String keyword) { //검색어 자동 완성 기능 관련 로직
Long index = redisSortedSetService.findFromSortedSet(keyword); //사용자가 입력한 검색어를 바탕으로 Redis에서 조회한 결과 매칭되는 index
// public void saveAllDisplayName(List<String> allDisplayName) {
// ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); //병렬 처리를 위한 스레드풀을 생성하는 과정
//
// for (String displayName : allDisplayName) {
// executorService.submit(() -> { //submit 메서드를 사용해서 병렬 처리할 작업 추가
// //Redis 내장 기능이 아닌 case insensitive 조회를 구현하기 위해 소문자로 변환한 field값과 원래 string인 value의 쌍을 Hash에 저장
// redisHashService.addToHash(displayName.toLowerCase(), displayName); //case insensitive하게 검색어 자동 완성 기능을 직접 구현하기 위해 소문자로 통일해서 저장
// });
// }
// executorService.shutdown(); //작업이 모두 완료되면 스레드풀을 종료
// }

if (index == null) {
return new ArrayList<>(); //만약 사용자 검색어 바탕으로 자동 완성 검색어를 만들 수 없으면 Empty Array 리턴
}
// public List<String> autocorrect(String keyword) { //검색어 자동 완성 로직
// Set<String> allValuesContainingSearchKeyword = redisHashService.findAllValuesContainingSearchKeyword(keyword); //case insensitive하게 serachKeyword를 포함하는 가게 이름 최대 10개 반환
// if(allValuesContainingSearchKeyword.isEmpty())
// return new ArrayList<>();
// else
// return new ArrayList<>(allValuesContainingSearchKeyword); //자동 완성 결과가 존재하면 ArrayList로 변환하여 리턴
// }

Set<String> allValuesAfterIndexFromSortedSet = redisSortedSetService.findAllValuesAfterIndexFromSortedSet(index); //사용자 검색어 이후로 정렬된 Redis 데이터들 가져오기
/*
아래 주석처리 된 코드는 초기 검색어 자동 완성 로직입니다.
검색어 자동 완성에 대한 요구 사항이 앞에서부터 매칭되는 글자가 아닌 Contains 개념으로 바뀌어서 주석 처리하여 임시로 남겨놓았습니다.
지금은 MySQL이 성능이 더 좋기 때문에 사용하지 않는 코드입니다.
*/

return allValuesAfterIndexFromSortedSet.stream()
.filter(value -> value.endsWith(suffix) && value.startsWith(keyword))
.map(value -> StringUtils.removeEnd(value, suffix))
.limit(maxSize)
.toList(); //자동 완성을 통해 만들어진 최대 maxSize개의 키워드들
}
// private String suffix = "*"; //검색어 자동 완성 기능에서 실제 노출될 수 있는 완벽한 형태의 단어를 구분하기 위한 접미사
// private int maxSize = 10; //검색어 자동 완성 기능 최대 개수
//
// @PostConstruct
// public void init() { //이 Service Bean이 생성된 이후에 검색어 자동 완성 기능을 위한 데이터들을 Redis에 저장 (Redis는 인메모리 DB라 휘발성을 띄기 때문)
// redisSortedSetService.removeAllOfSortedSet();
// saveAllSubstring(storeRepository.findAllDisplayName()); //MySQL DB에 저장된 모든 가게명을 음절 단위로 잘라 모든 Substring을 Redis에 저장해주는 로직
// }
//
// public void saveAllSubstring(List<String> allDisplayName) {
// ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); //병렬 처리를 위한 스레드풀을 생성하는 과정
//
// for (String displayName : allDisplayName) {
// executorService.submit(() -> { //submit 메서드를 사용해서 병렬 처리할 작업 추가
// redisSortedSetService.addToSortedSet(displayName + suffix);
//
// for (int i = displayName.length(); i > 0; --i) {
// redisSortedSetService.addToSortedSet(displayName.substring(0, i));
// }
// });
// }
// executorService.shutdown(); //작업이 모두 완료되면 스레드풀을 종료
// }
//
// public List<String> autocorrect(String keyword) { //검색어 자동 완성 기능 관련 로직
// Long index = redisSortedSetService.findFromSortedSet(keyword); //사용자가 입력한 검색어를 바탕으로 Redis에서 조회한 결과 매칭되는 index
//
// if (index == null) {
// return new ArrayList<>(); //만약 사용자 검색어 바탕으로 자동 완성 검색어를 만들 수 없으면 Empty Array 리턴
// }
//
// Set<String> allValuesAfterIndexFromSortedSet = redisSortedSetService.findAllValuesAfterIndexFromSortedSet(index); //사용자 검색어 이후로 정렬된 Redis 데이터들 가져오기
//
// return allValuesAfterIndexFromSortedSet.stream()
// .filter(value -> value.endsWith(suffix) && value.startsWith(keyword))
// .map(value -> StringUtils.removeEnd(value, suffix))
// .limit(maxSize)
// .toList(); //자동 완성을 통해 만들어진 최대 maxSize개의 키워드들
// }
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,11 @@ public List<String> findAllDisplayName() {
return em.createQuery("select s.displayName from Store s", String.class)
.getResultList();
}

public List<String> findAllBySearchKeyword(String searchKeyword) {
return em.createQuery("select s.displayName from Store s where s.displayName like concat('%', :searchKeyword, '%')", String.class)
.setParameter("searchKeyword", searchKeyword)
.setMaxResults(10)
.getResultList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//package com.nainga.nainga.global.application;
//
//import org.springframework.data.redis.core.Cursor;
//import org.springframework.data.redis.core.HashOperations;
//import org.springframework.data.redis.core.RedisTemplate;
//import org.springframework.data.redis.core.ScanOptions;
//import org.springframework.stereotype.Service;
//
//import java.util.HashSet;
//import java.util.Map;
//import java.util.Set;
//import java.util.TreeSet;
//
//@Service
//public class RedisHashService {
// private final HashOperations<String, String, String> hashOperations;
// private final RedisTemplate<String, String> redisTemplate;
//
// public RedisHashService(RedisTemplate<String, String> redisTemplate) {
// this.hashOperations = redisTemplate.opsForHash();
// this.redisTemplate = redisTemplate;
// }
//
// private String key = "autocorrect"; //검색어 자동 완성을 위한 Redis 데이터
//
// //Hash에 field-value 쌍을 추가하는 메서드
// public void addToHash(String field, String value) {
// hashOperations.put(key, field, value);
// }
//
// public Set<String> findAllValuesContainingSearchKeyword(String searchKeyword) {
// //Redis에서는 case insensitive한 검색을 지원하는 내장 모듈이 없으므로 searchKeyword는 모두 소문자로 통일하여 검색하도록 구현
// //당연히 초기 Redis에 field를 저장할 때도 모두 소문자로 변형하여 저장했고 원본 문자열은 value에 저장!
// Set<String> result = new HashSet<>(); //searchKeyword를 포함하는 원래 가게 이름들의 리스트. 최대 maxSize개까지 저장. 중복 허용하지 않고, 자동 사전순 정렬하기 위해 사용
// final int maxSize = 10; //최대 검색어 자동 완성 개수
//
// ScanOptions scanOptions = ScanOptions.scanOptions().match("*" + searchKeyword + "*").build(); //searchKeyword를 포함하는지를 검사하기 위한 scanOption
// Cursor<Map.Entry<String, String>> cursor = hashOperations.scan(key, scanOptions); //기존 Redis Keys 로직의 성능 이슈를 해결하기 위해 10개 단위로 끊어서 조회하는 Scan 기능 사용
//
// while (cursor.hasNext()) { //끊어서 조회하다보니 while loop로 조회
// Map.Entry<String, String> entry = cursor.next();
// result.add(entry.getValue());
//
// if(result.size() >= maxSize) //maxSize에 도달하면 scan 중단
// break;
// }
// cursor.close();
// return result;
// }
//
// public void removeAllOfHash() {
// redisTemplate.delete(key);
// }
//}
Original file line number Diff line number Diff line change
@@ -1,34 +1,34 @@
package com.nainga.nainga.global.application;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Set;

@Service
@RequiredArgsConstructor
public class RedisSortedSetService { //검색어 자동 완성을 구현할 때 사용하는 Redis의 SortedSet 관련 서비스 레이어
private final RedisTemplate<String, String> redisTemplate;
private String key = "autocorrect"; //검색어 자동 완성을 위한 Redis 데이터
private int score = 0; //Score는 딱히 필요 없으므로 하나로 통일

public void addToSortedSet(String value) { //Redis SortedSet에 추가
redisTemplate.opsForZSet().add(key, value, score);
}

public Long findFromSortedSet(String value) { //Redis SortedSet에서 Value를 찾아 인덱스를 반환
return redisTemplate.opsForZSet().rank(key, value);
}

public Set<String> findAllValuesAfterIndexFromSortedSet(Long index) {
return redisTemplate.opsForZSet().range(key, index, index + 200); //전체를 다 불러오기 보다는 200개 정도만 가져와도 자동 완성을 구현하는 데 무리가 없으므로 200개로 rough하게 설정
}

public void removeAllOfSortedSet() {
redisTemplate.opsForZSet().removeRange(key, 0, -1);
}
}
//package com.nainga.nainga.global.application;
//
//import lombok.RequiredArgsConstructor;
//import org.springframework.data.redis.core.RedisCallback;
//import org.springframework.data.redis.core.RedisTemplate;
//import org.springframework.data.redis.core.ZSetOperations;
//import org.springframework.stereotype.Service;
//
//import java.util.List;
//import java.util.Set;
//
//@Service
//@RequiredArgsConstructor
//public class RedisSortedSetService { //검색어 자동 완성을 구현할 때 사용하는 Redis의 SortedSet 관련 서비스 레이어
// private final RedisTemplate<String, String> redisTemplate;
// private String key = "autocorrect"; //검색어 자동 완성을 위한 Redis 데이터
// private int score = 0; //Score는 딱히 필요 없으므로 하나로 통일
//
// public void addToSortedSet(String value) { //Redis SortedSet에 추가
// redisTemplate.opsForZSet().add(key, value, score);
// }
//
// public Long findFromSortedSet(String value) { //Redis SortedSet에서 Value를 찾아 인덱스를 반환
// return redisTemplate.opsForZSet().rank(key, value);
// }
//
// public Set<String> findAllValuesAfterIndexFromSortedSet(Long index) {
// return redisTemplate.opsForZSet().range(key, index, index + 200); //전체를 다 불러오기 보다는 200개 정도만 가져와도 자동 완성을 구현하는 데 무리가 없으므로 200개로 rough하게 설정
// }
//
// public void removeAllOfSortedSet() {
// redisTemplate.opsForZSet().removeRange(key, 0, -1);
// }
//}
28 changes: 0 additions & 28 deletions src/main/java/com/nainga/nainga/global/config/RedisConfig.java

This file was deleted.

2 changes: 1 addition & 1 deletion src/main/resources/backend-submodule
Loading

0 comments on commit 7fd7d89

Please sign in to comment.