저번 전문가를 매칭시켜주는 숨고 서비스를 클론 코딩하는 팀 프로젝트를 진행하던 중 인기 검색어, 최근 검색어를 구현하는 파트를 맡게 되었다!
오늘은 그와 관련해서 포스팅을 해보려고 한다.
최근 검색어, 인기 검색어... 왜 Redis를?
우선 RDB가 아닌 NoSQL의 Redis를 선택하게된 이유에 대해서 간략하게 생각해보자.
우리가 일반적인 사이트의 검색창에 마우스를 클릭했을 때 최근 검색한 내용, 혹은 그 서비스에서 제공하는 인기 검색어의 리스트등이 나오는 것을 볼 수 있다.
여기서 '검색어를 입력하지 않았을 때도 데이터를 제공해주고, 실제로 잘못 클릭했을 경우에도 저 데이터를 제공해주네..?' 라는 생각을 해서 의도했던, 하지 않았던 해당 서비스에서 많은 사용이 이루어질 것이라고 판단했다.
그말은 인기 검색어, 최근 검색어등을 RDB로 구현했을 때에는 사람들이 실수로 검색창을 누르던 어쩌던 간에 계속 DB에 select 문이 나가게 될 것이라고 생각했다.
그래서 나는 이 점이 비효율적이라고 생각했고 NoSQL을 접목시켜봐야 겠다고 판단했다!
그 중 Redis를 사용한 이유와 그 장점을 보자면
1. 빠른 응답 시간
Redis는 메모리 기반의 키-값 저장소로, 뛰어난 속도를 자랑한다. 검색어와 같은 실시간으로 변하는 데이터를 처리할 때, 높은 응답 속도는 사용자 경험을 향상시키는 데 핵심적일 수 있을 것이다! 또 빠른 응답 시간은 웹 서비스의 성능을 향상시키는 데 큰 역할을 한다!
2. 간편한 데이터 구조 지원
Redis는 다양한 데이터 구조를 지원한다. 이는 검색어 목록, 횟수 등을 효과적으로 다룰 수 있도록 도와준다. 복잡한 데이터 모델링 없이도 간단하게 필요한 정보를 저장하고 검색할 수 있다.
3. 분산 환경에서의 안정성
Redis는 데이터를 메모리에 저장하지만 필요에 따라 디스크에도 저장할 수 있어, 시스템 장애 시에도 데이터를 보존할 수 있다. 또한 Redis는 마스터-슬레이브 복제와 같은 기능을 통해 데이터의 안정성을 보장할 수 있다.
아마 3번은 당장 내가 사용할 것 같진 않지만, 잘 사용했을 때 많은 장점이 있다라고 생각했다!
코드로 보자!
우선 Redis 이미지는 Docker Hub를 통해 받았고 기본 포트로 스프링부트와 연결하였다.
1. Redis 설정 클래스 (RedisConfig)
@Configuration
@EnableRedisRepositories(redisTemplateRef = "searchRedisTemplate")
@RequiredArgsConstructor
public class RedisConfig {
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
lettuceConnectionFactory.setDatabase(2);
return lettuceConnectionFactory;
}
@Bean
public RedisTemplate<String, String> searchRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, String> searchRedisTemplate = new RedisTemplate<>();
searchRedisTemplate.setConnectionFactory(redisConnectionFactory);
searchRedisTemplate.setKeySerializer(new StringRedisSerializer());
searchRedisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));
return searchRedisTemplate;
}
}
- LettuceConnectionFactory를 사용하여 Redis에 연결한다.
- redisProperties 객체를 통해 호스트 및 포트를 설정하고, setDatabase를 통해 데이터베이스를 선택한다.
- 나의 경우 Redis의 다른 1번 DB에 다른 팀원이 휴대폰 인증 관련해서 사용하고 있으므로 2번으로 설정해주었다.
- searchRedisTemplate 빈을 생성하여 Redis와의 상호작용을 위한 기본 구성을 정의한다.
- Key는 문자열, Value는 문자열로 설정하며, Jackson2JsonRedisSerializer를 사용하여 객체 직렬화를 수행한다.
2. 검색 서비스 클래스 (SearchService)
@Service
@RequiredArgsConstructor
@Transactional
public class SearchService {
private static final int MAXIMUM_SAVED_VALUE = 5;
private static final String SEARCH_KEY = "search::";
private static final String SEARCH_COUNT_KEY = "search_count::";
private static final String POPULAR_KEYWORDS_KEY = "popular_keywords";
@Qualifier("searchRedisTemplate")
private final RedisTemplate<String, String> redisTemplate;
private final SubItemRepository subItemRepository;
public SubItemsResponse searchKeyword(Long memberId, String keyword) {
// 검색어 유효성 검사
if (isInvalidKeyword(keyword)) return null;
String key = SEARCH_KEY + memberId;
// 검색어에 해당하는 서브 아이템 검색
List<SubItem> subItems = subItemRepository.findByNameContains(keyword);
// 검색 결과가 있으면 Redis에 검색어 저장 및 관련 통계 업데이트
if (!subItems.isEmpty()) {
addKeywordInRedis(keyword, key);
incrementSearchCount(keyword);
updatePopularKeywordsList(keyword);
}
return SubItemsResponse.from(subItems);
}
@Transactional(readOnly = true)
public SearchListResponse getResentSearchList(Long memberId) {
String key = SEARCH_KEY + memberId;
ListOperations<String, String> listOperations = redisTemplate.opsForList();
List<String> range = listOperations.range(key, 0, listOperations.size(key));
Collections.reverse(range);
return SearchListResponse.from(range);
}
@Transactional(readOnly = true)
public SearchRankingListResponse getPopularKeywords() {
Map<String, Long> keywordCounts = getKeywordCounts();
List<String> popularKeywords = getTopFivePopularKeywords(keywordCounts);
return SearchRankingListResponse.from(popularKeywords);
}
private boolean isInvalidKeyword(String keyword) {
return keyword == null || keyword.isBlank() || keyword.isEmpty();
}
private void addKeywordInRedis(String keyword, String key) {
// Redis List에 최근 검색어 추가
ListOperations<String, String> listOperations = redisTemplate.opsForList();
boolean isKeywordInRedis = false;
for (String pastKeyword : listOperations.range(key, 0, listOperations.size(key))) {
if (pastKeyword.equals(keyword)) {
isKeywordInRedis = true;
break;
}
}
// 중복된 검색어가 없으면 추가하고, 최대 저장 개수를 초과하면 가장 오래된 항목 제거
if (!isKeywordInRedis) {
if (listOperations.size(key) == MAXIMUM_SAVED_VALUE) {
listOperations.leftPop(key);
}
listOperations.rightPush(key, keyword);
}
}
private void incrementSearchCount(String keyword) {
// Redis에서 해당 검색어의 검색 횟수 증가
String countKey = SEARCH_COUNT_KEY + keyword;
ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
valueOperations.increment(countKey);
}
private Map<String, Long> getKeywordCounts() {
// 인기 검색어 목록 업데이트
Map<String, Long> keywordCounts = new HashMap<>();
Set<String> keys = redisTemplate.keys(SEARCH_COUNT_KEY + "*");
for (String key : keys) {
String keyword = key.replace(SEARCH_COUNT_KEY, "");
long count = Long.parseLong(redisTemplate.opsForValue().get(key));
keywordCounts.put(keyword, count);
}
return keywordCounts;
}
private List<String> getTopFivePopularKeywords(Map<String, Long> keywordCounts) {
List<String> popularKeywords = keywordCounts.entrySet()
.stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(MAXIMUM_SAVED_VALUE)
.map(Map.Entry::getKey)
.toList();
return popularKeywords;
}
private void updatePopularKeywordsList(String keyword) {
Long currentCount = redisTemplate.opsForList().size(POPULAR_KEYWORDS_KEY);
if (currentCount >= MAXIMUM_SAVED_VALUE) {
redisTemplate.opsForList().trim(POPULAR_KEYWORDS_KEY, 0, MAXIMUM_SAVED_VALUE - 1);
}
ListOperations<String, String> listOperations = redisTemplate.opsForList();
listOperations.rightPush(POPULAR_KEYWORDS_KEY, keyword);
}
}
- @Qualifier 어노테이션 : 여러 Redis 인스턴스를 사용하기 위함
- searchKeyword 메서드에서는 사용자의 검색어를 처리하고 검색 결과에 따라 Redis에 검색어를 저장하고 관련 통계를 업데이트한다.
- 중복된 검색어는 저장되지 않고, 최근 검색어 목록의 최대 저장 개수를 초과하면 가장 오래된 항목을 제거한다.
- 검색어의 검색 횟수를 증가시키고, 인기 검색어 목록을 업데이트한다.
3. 검색 컨트롤러 클래스 (SearchController)
@RestController
@RequestMapping("/api/v1/search")
@RequiredArgsConstructor
public class SearchController {
private final SearchService searchService;
@PostMapping
@CurrentMemberId
public ResponseEntity<SubItemsResponse> search(@RequestParam String keyword, Long memberId) {
// 검색어 서비스 호출
SubItemsResponse subItemsResponse = searchService.searchKeyword(memberId, keyword);
return ResponseEntity.ok(subItemsResponse);
}
@GetMapping
@CurrentMemberId
public ResponseEntity<SearchListResponse> getRecentSearchList(Long memberId) {
// 최근 검색어 조회 서비스 호출
SearchListResponse searchList = searchService.getResentSearchList(memberId);
return ResponseEntity.ok(searchList);
}
@GetMapping("/popularity")
public ResponseEntity<SearchRankingListResponse> getPopularSearchList() {
// 인기 검색어 조회 서비스 호출
SearchRankingListResponse popularKeywords = searchService.getPopularKeywords();
return ResponseEntity.ok(popularKeywords);
}
}
결과는!!!?
검색한 데이터도 Redis에도 잘 담기고, 역시 빠르게 바로바로 구현한 검색어 리스트들이 나와줬다!
종합적인 설명:
위의 코드들은 Redis를 활용하여 사용자의 최근 검색어와 인기 검색어를 효과적으로 관리하는 서비스를 구현한 예시이다.
Redis는 뛰어난 속도와 다양한 데이터 구조 지원으로 검색어 관리 시스템에 적합한 도구인 것을 더욱 느꼈다...
그 중 하나의 이유는, Redis로 구현을 할 때 사용되는 자료구조 형태들이 자바의 자료구조 형태와 비슷했기에 약간의 구글링 검색을 통해서 빠르게 서비스에 접목시킬 수 있을 정도로 만들어 낼 수 있었다! (물론 처음에 도커 세팅하고.. 연동하는데 애를 먹긴 했지만...)
무튼!!
이를 통해 실시간으로 변하는 검색어 통계를 빠르게 처리하고, 사용자에게 최적화된 검색 경험을 제공할 수 있었다.
끝으로..
처음 사용해보는 기술이었지만 도전을 했고, 성공적으로 구현을 해내서 정말 희열을 많이 느꼈던 것 같다.
빠른 응답 속도와 관련하여 캐싱등을 고려해봐야할 땐 Redis가 아마 먼저 떠오를 것 같다! (효자여 ~)
다음 프로젝트를 진행하게 될 때 또 연관된 부분이 있다면 다른 기능에서 Redis를 붙여 성능을 개선 시켜보고싶다!
'Spring' 카테고리의 다른 글
Spring Boot에서 RestController 테스트시 @WebMvcTest 사용하기 (feat. 실패 테스트까지!) (0) | 2023.07.13 |
---|---|
스프링 빈(Bean)의 생명주기와 스코프 (0) | 2023.03.14 |
싱글톤 vs 스프링 싱글톤 (0) | 2023.03.13 |
스프링 어노테이션 (Spring Annotation) (1) | 2023.03.13 |
AOP의 특징과 개념 (feat. Filter, Interceptor) (0) | 2023.03.07 |