Skip to content
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

Merged
merged 17 commits into from
Feb 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d192bbe
feat : Redis를 사용하기 위해 build.gradle 수정 (#95)
sungjindev Feb 13, 2024
fa5fefe
feat : Redis 연결을 위한 기본 Config 클래스 추가 (#95)
sungjindev Feb 14, 2024
1719402
feat : 검색어 자동 완성 기능을 위해 RedisSortedSetService 구현 (#95)
sungjindev Feb 14, 2024
d6b5b4c
feat : findAllDisplayName 로직 구현 (#95)
sungjindev Feb 14, 2024
20bdac9
feat : 검색어 자동 완성을 위해 모든 가게명에 대해 음절 단위로 잘라 Redis에 저장하는 로직 구현 (#95)
sungjindev Feb 14, 2024
7bd03ee
feat : Redis에서 특정 index 이후의 값들을 가져오는 로직 구현 (#95)
sungjindev Feb 14, 2024
43eaafe
feat : 검색어 자동 완성 기능과 관련된 서비스 로직 구현 (#95)
sungjindev Feb 14, 2024
db3d86c
Merge branch 'develop' of https://github.com/Korea-Certified-Store/ba…
sungjindev Feb 14, 2024
ecadff3
feat : 검색어 자동 완성을 위한 API 구현 (#95)
sungjindev Feb 14, 2024
7dc1942
fix : Merge commit
sungjindev Feb 14, 2024
0f176ee
fix : 검색어 자동 완성 최대 개수를 5개에서 10개로 변경 (#95)
sungjindev Feb 14, 2024
319fc20
refactor : findAllDisplayName() 쿼리 개선 (#95)
sungjindev Feb 14, 2024
94ab06f
refactor : 병목 현상이 걸리던 로직을 멀티 스레드 병렬 처리로 변경하여 175초에서 4초로 성능 개선 (#95)
sungjindev Feb 16, 2024
d1f8177
refactor : 병목 현상이 걸리던 로직을 158초에서 0.009초로 개선 (#95)
sungjindev Feb 16, 2024
a350f14
fix : Redis에 비밀번호를 사용하여 접근하도록 변경 (#95)
sungjindev Feb 17, 2024
ec20ba9
Merge branch 'develop' into feature/add-searchKeyword-autocorrect(#95)
sungjindev Feb 17, 2024
84f016c
fix : backend-submodule 수정 반영 (#95)
sungjindev Feb 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'
}

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

}

test {
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/com/nainga/nainga/domain/store/api/StoreApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import com.nainga.nainga.domain.store.application.GoodPriceGoogleMapStoreService;
import com.nainga.nainga.domain.store.application.MobeomGoogleMapStoreService;
import com.nainga.nainga.domain.store.application.SafeGoogleMapStoreService;
import com.nainga.nainga.domain.store.application.StoreService;
import com.nainga.nainga.domain.store.dto.CreateDividedGoodPriceStoresResponse;
import com.nainga.nainga.domain.store.dto.CreateDividedMobeomStoresResponse;
import com.nainga.nainga.domain.store.dto.CreateDividedSafeStoresResponse;
import com.nainga.nainga.domain.storecertification.dto.StoreCertificationsByLocationResponse;
import com.nainga.nainga.global.util.Result;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -15,12 +17,15 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class StoreApi {
private final MobeomGoogleMapStoreService mobeomGoogleMapStoreService;
private final SafeGoogleMapStoreService safeGoogleMapStoreService;
private final GoodPriceGoogleMapStoreService goodPriceGoogleMapStoreService;
private final StoreService storeService;

@Hidden
@Tag(name = "초기 Data 생성")
Expand Down Expand Up @@ -78,5 +83,18 @@ public Result<CreateDividedGoodPriceStoresResponse> createDividedGoodPriceStores
System.out.println("response = " + response); //편하게 콘솔 로그에서 확인하기 위한 용도
return new Result<>(Result.CODE_SUCCESS, Result.MESSAGE_OK, response);
}

//검색어를 이용해 가게 이름에 대해 검색하여 나온 검색 결과를 바탕으로 검색어를 자동 완성해서 최대 10개의 자동 완성된 검색어를 리턴
@Tag(name = "[New] 검색어 자동 완성")
@Operation(summary = "사용자의 검색 키워드를 바탕으로 검색어 자동 완성", description = "사용자의 검색 키워드를 바탕으로 DB에서 매칭되는 가게 이름을 조회하여 최대 10개까지 검색어를 자동으로 완성하여 반환해줍니다.<br><br>" +
"[Request Body]<br>" +
"searchKeyword: 사용자의 검색 키워드<br>" +
"[Response Body]<br>" +
"자동으로 완성된 최대 10개의 검색어<br>")
@GetMapping("api/store/autocorrect/v1")
public Result<List<String>> autocorrect(@RequestParam(value = "searchKeyword") String searchKeyword) {
List<String> autocorrectResult = storeService.autocorrect(searchKeyword);
return new Result<>(Result.CODE_SUCCESS, Result.MESSAGE_OK, autocorrectResult);
}
}

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()); //병렬 처리를 위한 스레드풀을 생성하는 과정
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

availableProcessors() 를 통해 현재 가용가능한 스레드풀을 가져오는걸로 알고있습니다. 이 작업이 맨처음에 실행되는걸로 알고있는데, 그러면 10개가 가용가능하다면 10개를 모두 사용할거고, 이 시간동안에는 다른 작업이 불가능한건가요? 현재는 이 작업이 매우 짧은시간내에 이루어져 큰 영향은 없어보이지만, 호기심 삼아 물어봅니다!

Copy link
Contributor Author

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 로그를 모두 켜놓고 아래와 같이 직접 실행시켜봤습니다.

7

하이라이트 되어있는 StoreCertification 관련된 부분은 완전히 다른 서비스 단에서 PostConstruct 애너테이션을 활용한 로직 내 실행되는 내용입니다. 그리고 lettuce.core에서 처리하고 있는 ZADD와 같은 로직들은 이번에 개발한 검색어 자동 완성 기능을 위해 Redis에 쿼리를 실행시키는 로그입니다.

이를 보고나니 JVM 자체가 멀티 스레드 기반이고 기대한 것처럼 ThreadPool을 사용한다해서 그 스레드를 모두 점유해서 사용하는 것이 아닌 공유해서 사용하는 것으로 확인되었습니다~

저도 다시 한번 고민해볼 수 있는 좋은 피드백 주셔서 감사합니다 ㅎㅎ


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 @@ -43,4 +43,9 @@ public List<Store> findAll() {
return em.createQuery("select s from Store s", Store.class)
.getResultList();
}

public List<String> findAllDisplayName() {
return em.createQuery("select s.displayName from Store s", String.class)
.getResultList();
}
}
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하게 설정
}
}
28 changes: 28 additions & 0 deletions src/main/java/com/nainga/nainga/global/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dev, prod 는 보안상 접속시에 비밀번호를 사용하고 있는데 비밀번호 설정이 필요해보입니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dev, prod 환경에서 비밀번호를 사용하고 있으니 Spring 프로젝트 내 Redis관련한 Config에서 password 사용하도록 변경해달라는 말씀이신거죠? ㅎㅎ

일단 수정 반영해놓았으니 확인 부탁드립니다!

@Value("${spring.data.redis.password}")
private String password;

@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
config.setPassword(password);
return new LettuceConnectionFactory(config);
}
}
2 changes: 1 addition & 1 deletion src/main/resources/backend-submodule
2 changes: 1 addition & 1 deletion src/test/resources/backend-submodule
Loading