diff --git a/build.gradle b/build.gradle index 54b2f83..8d65270 100644 --- a/build.gradle +++ b/build.gradle @@ -96,6 +96,10 @@ dependencies { //Promethus runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + //Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' // Lettuce 기본 + implementation 'org.springframework.boot:spring-boot-starter' + } tasks.named('test') { diff --git a/src/main/java/Journey/Together/domain/place/controller/PlaceController.java b/src/main/java/Journey/Together/domain/place/controller/PlaceController.java index e83c55c..676ed62 100644 --- a/src/main/java/Journey/Together/domain/place/controller/PlaceController.java +++ b/src/main/java/Journey/Together/domain/place/controller/PlaceController.java @@ -1,5 +1,6 @@ package Journey.Together.domain.place.controller; +import Journey.Together.domain.place.dto.Suggestion; import Journey.Together.domain.place.dto.request.PlaceReviewReq; import Journey.Together.domain.place.dto.request.UpdateReviewDto; import Journey.Together.domain.place.dto.response.*; @@ -8,7 +9,7 @@ import Journey.Together.domain.place.dto.response.PlaceDetailRes; import Journey.Together.domain.place.dto.response.PlaceRes; import Journey.Together.domain.place.dto.response.SearchPlaceRes; -import Journey.Together.domain.place.service.DataMigrationService; +import Journey.Together.domain.place.service.PlaceAutoCompleteService; import Journey.Together.domain.place.service.PlaceService; import Journey.Together.domain.place.service.PublicDataService; import Journey.Together.global.common.ApiResponse; @@ -40,7 +41,7 @@ public class PlaceController { private final PlaceService placeService; - private final DataMigrationService dataMigrationService; + private final PlaceAutoCompleteService placeAutoCompleteService; @GetMapping("/main") public ApiResponse getMain( @@ -136,17 +137,17 @@ public ApiResponse> searchPlaceList( return ApiResponse.success(Success.SEARCH_PLACE_LIST_SUCCESS, placeService.searchPlaceMap(category,disabilityType,detailFilter,arrange,minX,maxX,minY,maxY)); } - @GetMapping("/search/autocomplete") - public ApiResponse>> searchPlaceComplete( - @RequestParam String query - ) throws IOException { - return ApiResponse.success(Success.SEARCH_COMPLETE_SUCCESS, placeService.searchPlaceComplete(query)); + @PostMapping("/search/autocomplete/migration/redis") + public ApiResponse migrationPlaceName( + ) { + placeAutoCompleteService.syncPlaceNamesWithRedis(1000); + return ApiResponse.success(Success.SEARCH_COMPLETE_SUCCESS); } - @GetMapping("/search/autocomplete/migration") - public ApiResponse migrationData( - ) throws IOException { - dataMigrationService.migrateData(); - return ApiResponse.success(Success.SEARCH_COMPLETE_SUCCESS); + @GetMapping("/search/autocomplete") + public ApiResponse> searchPlaceComplete( + @RequestParam String query + ) { + return ApiResponse.success(Success.SEARCH_COMPLETE_SUCCESS, placeAutoCompleteService.suggest(query)); } } diff --git a/src/main/java/Journey/Together/domain/place/dto/Suggestion.java b/src/main/java/Journey/Together/domain/place/dto/Suggestion.java new file mode 100644 index 0000000..49105dc --- /dev/null +++ b/src/main/java/Journey/Together/domain/place/dto/Suggestion.java @@ -0,0 +1,3 @@ +package Journey.Together.domain.place.dto; + +public record Suggestion(String word, double score, Long placeId) {} \ No newline at end of file diff --git a/src/main/java/Journey/Together/domain/place/repository/PlaceRepository.java b/src/main/java/Journey/Together/domain/place/repository/PlaceRepository.java index 00b0865..b1a9339 100644 --- a/src/main/java/Journey/Together/domain/place/repository/PlaceRepository.java +++ b/src/main/java/Journey/Together/domain/place/repository/PlaceRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface PlaceRepository extends JpaRepository, PlaceRepositoryCustom { @Query(value = "SELECT * FROM place p " + @@ -27,7 +28,7 @@ List findAroundProducts(@Param("areacode") String areacode, Place findPlaceById(Long id); - Place findPlaceByName(String name); + Optional findPlaceByName(String name); /* 검색어 기반 목록 조회 최신순 diff --git a/src/main/java/Journey/Together/domain/place/service/DataMigrationService.java b/src/main/java/Journey/Together/domain/place/service/DataMigrationService.java deleted file mode 100644 index 50cf507..0000000 --- a/src/main/java/Journey/Together/domain/place/service/DataMigrationService.java +++ /dev/null @@ -1,46 +0,0 @@ -package Journey.Together.domain.place.service; - -import Journey.Together.domain.place.entity.Place; -import Journey.Together.domain.place.repository.PlaceRepository; -import lombok.RequiredArgsConstructor; -import org.apache.http.HttpHost; -import org.elasticsearch.action.index.IndexRequest; -import org.elasticsearch.client.RequestOptions; -import org.elasticsearch.client.RestClient; -import org.elasticsearch.client.RestClientBuilder; -import org.elasticsearch.client.RestHighLevelClient; -import org.elasticsearch.common.xcontent.XContentType; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.io.IOException; -import java.util.List; -import java.util.Map; - -@Service -@RequiredArgsConstructor -public class DataMigrationService { - - private final PlaceRepository placeRepository; - - private final RestHighLevelClient client; - - @Transactional - public void migrateData() { - List places = placeRepository.findAll(); - - places.forEach(place -> { - IndexRequest indexRequest = new IndexRequest("places") - .id(place.getId().toString()) - .source(Map.of("name", place.getName()), XContentType.JSON); - - try { - client.index(indexRequest, RequestOptions.DEFAULT); - } catch (IOException e) { - throw new RuntimeException(e); - } - - }); - - } -} diff --git a/src/main/java/Journey/Together/domain/place/service/PlaceAutoCompleteService.java b/src/main/java/Journey/Together/domain/place/service/PlaceAutoCompleteService.java new file mode 100644 index 0000000..c119123 --- /dev/null +++ b/src/main/java/Journey/Together/domain/place/service/PlaceAutoCompleteService.java @@ -0,0 +1,124 @@ +package Journey.Together.domain.place.service; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.connection.Limit; +import org.springframework.data.redis.connection.RedisZSetCommands; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.connection.Limit; +import org.springframework.data.redis.connection.RedisZSetCommands.Range; +import org.springframework.data.redis.core.RedisCallback; + +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import Journey.Together.domain.place.dto.Suggestion; +import Journey.Together.domain.place.entity.Place; +import Journey.Together.domain.place.repository.PlaceRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PlaceAutoCompleteService { + + private final StringRedisTemplate redis; + private final PlaceRepository placeRepository; + + private static final Integer LIMIT = 5; + private static final Integer OVER_SAMPLE = 5; + private static final String LEX_KEY = "autocomplete:lex"; + private static final String SCORE_KEY = "autocomplete:score"; + + public void syncPlaceNamesWithRedis(int pageSize) { + int page = 0; + while (true) { + Page names = placeRepository.findAll(PageRequest.of(page, pageSize)).map(Place::getName); + if (names.isEmpty()) break; + + List batch = names.getContent().stream() + .map(String::trim) + .filter(s -> !s.isEmpty()) + .distinct() + .toList(); + + if (!batch.isEmpty()) { + redis.executePipelined((RedisCallback) conn -> { + for (String w : batch) { + conn.zAdd(LEX_KEY.getBytes(StandardCharsets.UTF_8), 0.0, w.getBytes(StandardCharsets.UTF_8)); // 사전순 + conn.zAdd(SCORE_KEY.getBytes(StandardCharsets.UTF_8), 0.0, w.getBytes(StandardCharsets.UTF_8)); // 인기순 + } + return null; + }); + } + if (!names.hasNext()) break; + page++; + } + } + + public List suggest(String prefix) { + if (prefix.isBlank() || LIMIT <= 0) return List.of(); + + // [prefix, prefix + \u00ff] 범위 (접두어 매칭을 위한 상한 트릭) + Range range = Range.range().gte(prefix).lte(prefix + "\u00ff"); + + // oversample: lex로 넉넉히 가져와서 인기점수로 재정렬 + int take = Math.max(LIMIT * Math.max(OVER_SAMPLE, 1), LIMIT); + + Set cand = redis.opsForZSet().rangeByLex(LEX_KEY, range.toRange(), Limit.limit().count(take)); + + if (cand == null || cand.isEmpty()) return List.of(); + + // 점수 일괄 조회 파이프라인 + List list = new ArrayList<>(cand); // 사전순 유지 + List scores = redis.executePipelined((RedisCallback) c -> { + for (String w : list) { + c.zScore(SCORE_KEY.getBytes(StandardCharsets.UTF_8), + w.getBytes(StandardCharsets.UTF_8)); + } + return null; + }); + + // (단어, 점수, 장소id) 구성 + List paired = new ArrayList<>(list.size()); + for (int i = 0; i < list.size(); i++) { + String w = list.get(i); + Object s = scores.get(i); + double score = (s instanceof Double d) ? d : 0.0; + Long placeId = placeRepository.findPlaceByName(w) + .map(Place::getId) + .orElse(null); + paired.add(new Suggestion(w, score, placeId)); + } + + // 인기 점수 내림차순 → 동점 시 사전순 + paired.sort(Comparator.comparingDouble(Suggestion::score).reversed() + .thenComparing(Suggestion::word)); + + return paired.size() > LIMIT ? paired.subList(0, LIMIT) : paired; + } + + /** 사용자가 항목을 선택했을 때 인기 점수 증가 */ + public double recordSelection(String word, double inc) { + if (word == null || word.isBlank()) return 0.0; + Double v = redis.opsForZSet().incrementScore(SCORE_KEY, word, inc); + return v == null ? 0.0 : v; + } +} \ No newline at end of file diff --git a/src/main/java/Journey/Together/domain/place/service/PlaceService.java b/src/main/java/Journey/Together/domain/place/service/PlaceService.java index 323c328..471d179 100644 --- a/src/main/java/Journey/Together/domain/place/service/PlaceService.java +++ b/src/main/java/Journey/Together/domain/place/service/PlaceService.java @@ -388,39 +388,5 @@ public void updateMyPlaceReview(Member member, UpdateReviewDto updateReviewDto, } - public List> searchPlaceComplete(String query) throws IOException { - List> list = new ArrayList<>(); - SearchRequest searchRequest = new SearchRequest("places"); - SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); - - searchSourceBuilder.query(QueryBuilders.matchQuery("name", query)); - searchSourceBuilder.size(autocompleteNum); // 상위 10개의 결과만 반환 - - searchRequest.source(searchSourceBuilder); - - SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT); - - List results = Arrays.stream(searchResponse.getHits().getHits()) - .map(hit -> hit.getSourceAsMap().get("name").toString()) - .toList(); - - results.forEach(keyword -> { - Place place = placeRepository.findPlaceByName(keyword); - Long placeId = null; - if (place != null) { - placeId = place.getId(); - } - - Map map = new HashMap<>(); - map.put("keyword", keyword); - map.put("placeId", placeId); - - list.add(map); - }); - - return list; - } - - } diff --git a/src/main/java/Journey/Together/global/config/RedisConfig.java b/src/main/java/Journey/Together/global/config/RedisConfig.java new file mode 100644 index 0000000..d25f122 --- /dev/null +++ b/src/main/java/Journey/Together/global/config/RedisConfig.java @@ -0,0 +1,31 @@ +package Journey.Together.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.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } +} \ No newline at end of file