Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.*;
Expand All @@ -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;
Expand Down Expand Up @@ -40,7 +41,7 @@
public class PlaceController {

private final PlaceService placeService;
private final DataMigrationService dataMigrationService;
private final PlaceAutoCompleteService placeAutoCompleteService;

@GetMapping("/main")
public ApiResponse<MainRes> getMain(
Expand Down Expand Up @@ -136,17 +137,17 @@ public ApiResponse<List<PlaceRes>> searchPlaceList(
return ApiResponse.success(Success.SEARCH_PLACE_LIST_SUCCESS, placeService.searchPlaceMap(category,disabilityType,detailFilter,arrange,minX,maxX,minY,maxY));
}

@GetMapping("/search/autocomplete")
public ApiResponse<List<Map<String,Object>>> 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<List<Suggestion>> searchPlaceComplete(
@RequestParam String query
) {
return ApiResponse.success(Success.SEARCH_COMPLETE_SUCCESS, placeAutoCompleteService.suggest(query));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package Journey.Together.domain.place.dto;

public record Suggestion(String word, double score, Long placeId) {}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface PlaceRepository extends JpaRepository<Place, Long>, PlaceRepositoryCustom {
@Query(value = "SELECT * FROM place p " +
Expand All @@ -27,7 +28,7 @@ List<Place> findAroundProducts(@Param("areacode") String areacode,

Place findPlaceById(Long id);

Place findPlaceByName(String name);
Optional<Place> findPlaceByName(String name);

/*
검색어 기반 목록 조회 최신순
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<String> names = placeRepository.findAll(PageRequest.of(page, pageSize)).map(Place::getName);
if (names.isEmpty()) break;

List<String> batch = names.getContent().stream()
.map(String::trim)
.filter(s -> !s.isEmpty())
.distinct()
.toList();

if (!batch.isEmpty()) {
redis.executePipelined((RedisCallback<Object>) 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<Suggestion> 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<String> cand = redis.opsForZSet().rangeByLex(LEX_KEY, range.toRange(), Limit.limit().count(take));

if (cand == null || cand.isEmpty()) return List.of();

// 점수 일괄 조회 파이프라인
List<String> list = new ArrayList<>(cand); // 사전순 유지
List<Object> scores = redis.executePipelined((RedisCallback<Object>) c -> {
for (String w : list) {
c.zScore(SCORE_KEY.getBytes(StandardCharsets.UTF_8),
w.getBytes(StandardCharsets.UTF_8));
}
return null;
});

// (단어, 점수, 장소id) 구성
List<Suggestion> 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.<Suggestion>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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -388,39 +388,5 @@ public void updateMyPlaceReview(Member member, UpdateReviewDto updateReviewDto,

}

public List<Map<String, Object>> searchPlaceComplete(String query) throws IOException {
List<Map<String, Object>> 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<String> 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<String, Object> map = new HashMap<>();
map.put("keyword", keyword);
map.put("placeId", placeId);

list.add(map);
});

return list;
}



}
31 changes: 31 additions & 0 deletions src/main/java/Journey/Together/global/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -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;
}
}