-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
검색어 자동 완성 API 개발 및 성능 개선 (#95) #102
Changes from 14 commits
d192bbe
fa5fefe
1719402
d6b5b4c
20bdac9
7bd03ee
43eaafe
db3d86c
ecadff3
7dc1942
0f176ee
319fc20
94ab06f
d1f8177
a350f14
ec20ba9
84f016c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
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라 휘발성을 띄기 때문) | ||
saveAllSubstring(storeRepository.findAllDisplayName()); //MySQL DB에 저장된 모든 가게명을 음절 단위로 잘라 모든 Substring을 Redis에 저장해주는 로직 | ||
} | ||
|
||
private 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 |
---|---|---|
@@ -0,0 +1,30 @@ | ||
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하게 설정 | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
package com.nainga.nainga.global.config; | ||
|
||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.data.redis.connection.RedisConnectionFactory; | ||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration; | ||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; | ||
|
||
@Configuration | ||
public class RedisConfig { //Redis 연결을 위한 기본 설정 | ||
|
||
@Value("${spring.data.redis.host}") | ||
private String host; | ||
|
||
@Value("${spring.data.redis.port}") | ||
private int port; | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. dev, prod 는 보안상 접속시에 비밀번호를 사용하고 있는데 비밀번호 설정이 필요해보입니다! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. dev, prod 환경에서 비밀번호를 사용하고 있으니 Spring 프로젝트 내 Redis관련한 Config에서 password 사용하도록 변경해달라는 말씀이신거죠? ㅎㅎ 일단 수정 반영해놓았으니 확인 부탁드립니다! |
||
@Bean | ||
public RedisConnectionFactory redisConnectionFactory() { | ||
return new LettuceConnectionFactory( | ||
new RedisStandaloneConfiguration(host, port) | ||
); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
availableProcessors()
를 통해 현재 가용가능한 스레드풀을 가져오는걸로 알고있습니다. 이 작업이 맨처음에 실행되는걸로 알고있는데, 그러면 10개가 가용가능하다면 10개를 모두 사용할거고, 이 시간동안에는 다른 작업이 불가능한건가요? 현재는 이 작업이 매우 짧은시간내에 이루어져 큰 영향은 없어보이지만, 호기심 삼아 물어봅니다!There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
우선 굉장히 좋은 고민 거리를 주셔서 감사합니다. 제가 처음에 구현했을 때는 스레드풀을 만든다고 해서 모든 스레드를 점유해서 사용한다기보다는 공유해서 사용할 것이라고 너무 당연하게만 생각했었는데 이에 대한 근거가 부족한 것 같아서 많이 찾아봤습니다.
일단 nexFixedThreadPool() 메서드를 뜯어보면 뭔가 답이 나오지않을까해서 들어가봤는데 저기 인자로 넘겨준 availableProcessors()이 corePoolSize, maximumPoolSize의 값으로 들어가더군요. corePoolSize는 초기 Pool 내 thread 값이고 maximumPoolSize는 Pool에 예약된 task가 많은 경우 최대로 늘어날 수 있는 thread 수 였습니다.
이것만으로는 ThreadPool이 모든 Thread를 점유하는 것인지 아닌지에 대한 근거를 찾을 수 없어서 제가 JVM 관련된 debug 로그를 모두 켜놓고 아래와 같이 직접 실행시켜봤습니다.
하이라이트 되어있는 StoreCertification 관련된 부분은 완전히 다른 서비스 단에서 PostConstruct 애너테이션을 활용한 로직 내 실행되는 내용입니다. 그리고 lettuce.core에서 처리하고 있는 ZADD와 같은 로직들은 이번에 개발한 검색어 자동 완성 기능을 위해 Redis에 쿼리를 실행시키는 로그입니다.
이를 보고나니 JVM 자체가 멀티 스레드 기반이고 기대한 것처럼 ThreadPool을 사용한다해서 그 스레드를 모두 점유해서 사용하는 것이 아닌 공유해서 사용하는 것으로 확인되었습니다~
저도 다시 한번 고민해볼 수 있는 좋은 피드백 주셔서 감사합니다 ㅎㅎ