diff --git a/.github/workflows/action-test.yml b/.github/workflows/action-test.yml index d5a19db..899bd44 100644 --- a/.github/workflows/action-test.yml +++ b/.github/workflows/action-test.yml @@ -46,9 +46,6 @@ jobs: mysql user: 'test' mysql password: ${{ secrets.DB_PASSWORD }} - - name: Set up Redis - uses: shogo82148/actions-setup-redis@v1.33.0 - # github action 에서 Gradle dependency 캐시 사용 - name: Cache Gradle packages uses: actions/cache@v3 diff --git a/build.gradle b/build.gradle index 9d49a33..de8f8cf 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/com/nainga/nainga/domain/store/application/StoreService.java b/src/main/java/com/nainga/nainga/domain/store/application/StoreService.java index 8b9a815..ed02554 100644 --- a/src/main/java/com/nainga/nainga/domain/store/application/StoreService.java +++ b/src/main/java/com/nainga/nainga/domain/store/application/StoreService.java @@ -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 autocorrect(String keyword) { //검색어 자동 완성 로직 + return storeRepository.findAllBySearchKeyword(keyword); } - public void saveAllSubstring(List 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 autocorrect(String keyword) { //검색어 자동 완성 기능 관련 로직 - Long index = redisSortedSetService.findFromSortedSet(keyword); //사용자가 입력한 검색어를 바탕으로 Redis에서 조회한 결과 매칭되는 index +// public void saveAllDisplayName(List 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 autocorrect(String keyword) { //검색어 자동 완성 로직 +// Set allValuesContainingSearchKeyword = redisHashService.findAllValuesContainingSearchKeyword(keyword); //case insensitive하게 serachKeyword를 포함하는 가게 이름 최대 10개 반환 +// if(allValuesContainingSearchKeyword.isEmpty()) +// return new ArrayList<>(); +// else +// return new ArrayList<>(allValuesContainingSearchKeyword); //자동 완성 결과가 존재하면 ArrayList로 변환하여 리턴 +// } - Set 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 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 autocorrect(String keyword) { //검색어 자동 완성 기능 관련 로직 +// Long index = redisSortedSetService.findFromSortedSet(keyword); //사용자가 입력한 검색어를 바탕으로 Redis에서 조회한 결과 매칭되는 index +// +// if (index == null) { +// return new ArrayList<>(); //만약 사용자 검색어 바탕으로 자동 완성 검색어를 만들 수 없으면 Empty Array 리턴 +// } +// +// Set 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개의 키워드들 +// } } diff --git a/src/main/java/com/nainga/nainga/domain/store/dao/StoreRepository.java b/src/main/java/com/nainga/nainga/domain/store/dao/StoreRepository.java index 90d16d5..b6ead00 100644 --- a/src/main/java/com/nainga/nainga/domain/store/dao/StoreRepository.java +++ b/src/main/java/com/nainga/nainga/domain/store/dao/StoreRepository.java @@ -48,4 +48,11 @@ public List findAllDisplayName() { return em.createQuery("select s.displayName from Store s", String.class) .getResultList(); } + + public List 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(); + } } diff --git a/src/main/java/com/nainga/nainga/global/application/RedisHashService.java b/src/main/java/com/nainga/nainga/global/application/RedisHashService.java new file mode 100644 index 0000000..61e322c --- /dev/null +++ b/src/main/java/com/nainga/nainga/global/application/RedisHashService.java @@ -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 hashOperations; +// private final RedisTemplate redisTemplate; +// +// public RedisHashService(RedisTemplate 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 findAllValuesContainingSearchKeyword(String searchKeyword) { +// //Redis에서는 case insensitive한 검색을 지원하는 내장 모듈이 없으므로 searchKeyword는 모두 소문자로 통일하여 검색하도록 구현 +// //당연히 초기 Redis에 field를 저장할 때도 모두 소문자로 변형하여 저장했고 원본 문자열은 value에 저장! +// Set result = new HashSet<>(); //searchKeyword를 포함하는 원래 가게 이름들의 리스트. 최대 maxSize개까지 저장. 중복 허용하지 않고, 자동 사전순 정렬하기 위해 사용 +// final int maxSize = 10; //최대 검색어 자동 완성 개수 +// +// ScanOptions scanOptions = ScanOptions.scanOptions().match("*" + searchKeyword + "*").build(); //searchKeyword를 포함하는지를 검사하기 위한 scanOption +// Cursor> cursor = hashOperations.scan(key, scanOptions); //기존 Redis Keys 로직의 성능 이슈를 해결하기 위해 10개 단위로 끊어서 조회하는 Scan 기능 사용 +// +// while (cursor.hasNext()) { //끊어서 조회하다보니 while loop로 조회 +// Map.Entry entry = cursor.next(); +// result.add(entry.getValue()); +// +// if(result.size() >= maxSize) //maxSize에 도달하면 scan 중단 +// break; +// } +// cursor.close(); +// return result; +// } +// +// public void removeAllOfHash() { +// redisTemplate.delete(key); +// } +//} diff --git a/src/main/java/com/nainga/nainga/global/application/RedisSortedSetService.java b/src/main/java/com/nainga/nainga/global/application/RedisSortedSetService.java index 374f7f7..52a575b 100644 --- a/src/main/java/com/nainga/nainga/global/application/RedisSortedSetService.java +++ b/src/main/java/com/nainga/nainga/global/application/RedisSortedSetService.java @@ -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 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 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 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 findAllValuesAfterIndexFromSortedSet(Long index) { +// return redisTemplate.opsForZSet().range(key, index, index + 200); //전체를 다 불러오기 보다는 200개 정도만 가져와도 자동 완성을 구현하는 데 무리가 없으므로 200개로 rough하게 설정 +// } +// +// public void removeAllOfSortedSet() { +// redisTemplate.opsForZSet().removeRange(key, 0, -1); +// } +//} diff --git a/src/main/java/com/nainga/nainga/global/config/RedisConfig.java b/src/main/java/com/nainga/nainga/global/config/RedisConfig.java deleted file mode 100644 index ff41fb6..0000000 --- a/src/main/java/com/nainga/nainga/global/config/RedisConfig.java +++ /dev/null @@ -1,28 +0,0 @@ -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; - - @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); - } -} \ No newline at end of file diff --git a/src/main/resources/backend-submodule b/src/main/resources/backend-submodule index bf26087..30d8002 160000 --- a/src/main/resources/backend-submodule +++ b/src/main/resources/backend-submodule @@ -1 +1 @@ -Subproject commit bf2608775a4bbddd2dbdd757a3c6de9113a32865 +Subproject commit 30d80024a646f6897fcbc59a3305910159bc4a54 diff --git a/src/test/java/com/nainga/nainga/domain/store/application/StoreServiceTest.java b/src/test/java/com/nainga/nainga/domain/store/application/StoreServiceTest.java index 9870906..cf87f9c 100644 --- a/src/test/java/com/nainga/nainga/domain/store/application/StoreServiceTest.java +++ b/src/test/java/com/nainga/nainga/domain/store/application/StoreServiceTest.java @@ -1,16 +1,15 @@ package com.nainga.nainga.domain.store.application; -import com.nainga.nainga.global.application.RedisSortedSetService; -import org.assertj.core.api.Assertions; +import com.nainga.nainga.domain.store.dao.StoreRepository; +import com.nainga.nainga.domain.store.domain.Store; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; import java.util.List; -import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest @@ -21,28 +20,30 @@ class StoreServiceTest { StoreService storeService; @Autowired - RedisSortedSetService redisSortedSetService; + StoreRepository storeRepository; @Test public void autocorrect() throws Exception { //검색어 자동 완성 기능에 대한 테스트 //given - //테스트를 실행시키는 환경에 따라 잘못된 결과가 나올 수 있으므로 테스트용 가게 이름 제일 앞에는 실제 Production DB에 존재하지 않는 이름인 *을 붙여 사용 - List allDisplayName = List.of("*김밥천국", "*김밥나라", "*김빱월드", "*김밥천지"); //List의 팩토리 메서드 사용 - storeService.saveAllSubstring(allDisplayName); //검색어 자동 완성 기능을 위해 필요한 Substring들을 뽑아 Redis에 저장 - Thread.sleep(2000); //직전에 실행시킨 saveAllSubstring이 멀티 스레드 기반 병렬 처리로 구현되어 있어서 바로 다음 검증 로직으로 넘어가버리면 아직 데이터가 전부 안들어가서 간헐적으로 실패하는 오류가 있음 + Store store1 = Store.builder() + .displayName("^") + .build(); + Store store2 = Store.builder() + .displayName("^^") + .build(); + Store store3 = Store.builder() + .displayName("^*^") + .build(); + storeRepository.save(store1); + storeRepository.save(store2); + storeRepository.save(store3); //when - List resultByKim = storeService.autocorrect("*김"); //Redis 상에 사전순 정렬되어 있으므로 *김밥나라, *김밥천국, *김밥천지, *김빱월드 순으로 나옴 - List resultByKimBap = storeService.autocorrect("*김밥"); //*김밥나라, *김밥천국, *김밥천지가 나와야 함 - List resultByKimBapCheon = storeService.autocorrect("*김밥천"); //*김밥천국, *김밥천지가 나와야 함 - List resultByKimBapCheonGuk = storeService.autocorrect("*김밥천국"); //*김밥천국이 나와야 함 + List result1 = storeService.autocorrect("^"); + List result2 = storeService.autocorrect("^^"); //then - assertThat(resultByKim).containsExactly("*김밥나라", "*김밥천국", "*김밥천지", "*김빱월드"); - assertThat(resultByKimBap).containsExactly("*김밥나라", "*김밥천국", "*김밥천지"); - assertThat(resultByKimBapCheon).containsExactly("*김밥천국", "*김밥천지"); - assertThat(resultByKimBapCheonGuk).containsExactly("*김밥천국"); - - redisSortedSetService.removeAllOfSortedSet(); //Test 이후 Redis가 Roll back 될 수 있도록 모든 데이터를 제거 + assertArrayEquals(result1.toArray(), List.of("^", "^^", "^*^").toArray()); + assertArrayEquals(result2.toArray(), List.of("^^").toArray()); } } \ No newline at end of file diff --git a/src/test/resources/backend-submodule b/src/test/resources/backend-submodule index bf26087..30d8002 160000 --- a/src/test/resources/backend-submodule +++ b/src/test/resources/backend-submodule @@ -1 +1 @@ -Subproject commit bf2608775a4bbddd2dbdd757a3c6de9113a32865 +Subproject commit 30d80024a646f6897fcbc59a3305910159bc4a54