# Event
프로젝트의 요구사항 중 검색칸을 눌렀을 때, 인기 검색어 보여줘야 했다.
처음에는 ElasticSearch를 이용해서 구현하고 싶었지만 알아야 할 내용이 너무 많았다.
그래서 일단 MVP 구현을 위해 차선책으로 Redis의 Sorted Sets을 사용했다.
왜 차선책인가 ?
DB보다는 Redis가 더 좋을 것이라는 것은 자명하다.
만약 검색어를 DB에서 쿼리로 가져온다면 하드디스크 기반의 저장 공간에 접근해야 한다.
하지만 Redis는 In-Memory DB이기 때문에 더 빠를 수밖에 없다.
또한 Sorted Sets을 통해 요청 검색어의 점수를 1씩 증가시킨다.
조회할 때는 점수 Top-10을 반환한다.
# Implementation
@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Value("${spring.data.redis.password}")
private String password;
@Primary
@Bean(name = "redisConnectionFactoryFirst")
public LettuceConnectionFactory redisConnectionFactoryFirst() {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(port);
redisStandaloneConfiguration.setPassword(password);
redisStandaloneConfiguration.setDatabase(0);
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
@Bean(name = "redisConnectionFactorySecond")
public LettuceConnectionFactory redisConnectionFactorySecond() {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(port);
redisStandaloneConfiguration.setPassword(password);
redisStandaloneConfiguration.setDatabase(1);
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
@Primary
@Bean(name = "redisTemplateFirst")
public RedisTemplate<String, String> redisTemplateFirst(
@Qualifier("redisConnectionFactoryFirst") LettuceConnectionFactory connectionFactory) {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setEnableTransactionSupport(true);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
@Bean(name = "redisTemplateSecond")
public RedisTemplate<String, String> redisTemplateSecond(
@Qualifier("redisConnectionFactorySecond") LettuceConnectionFactory connectionFactory) {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));
return redisTemplate;
}
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
@Qualifier("redisConnectionFactoryFirst") RedisConnectionFactory connectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
return container;
}
@Bean
public RedisCacheManager redisCacheManager(
@Qualifier("redisConnectionFactorySecond") RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(connectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
일단 구현을 위주로 코드를 작성했기 때문에 리팩토링이 필요하다.
간단하게 설명하자면 기존에 이미 Redis를 사용해서 베스트셀러 책을 저장하고 있었다.
Multi Redis를 사용하려면 코드와 같이 @Qualifier로 각 레디스를 구분해야 한다.
또한 각 레디스마다 다른 인덱스를 사용해야 한다.
나는 0번과 1번을 사용했다. (0 ~ 15까지 16개 사용 가능)
Spring에서 Redis를 연결할 때, 하나의 연결에 하나의 Redis DB를 사용할 수 있다.
이부분을 구현하는데 시간을 많이 썼다.. ㅎㅎ
API 로직은 생각보다 간단하다.
아래 코드를 살펴보자.
@Getter
@Setter
@NoArgsConstructor
public class SearchKeywordRequest {
private String keyword;
}
@Slf4j
@Service
public class SearchService {
private final RedisTemplate<String, String> redisTemplate;
public SearchService(@Qualifier("redisTemplateSecond") RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void searchKeyword(String keyword) {
ZSetOperations<String, String> zset = redisTemplate.opsForZSet();
zset.incrementScore("ranking", keyword, 1); // 점수 증가
}
public List<String> getRankKeywords() {
ZSetOperations<String, String> zset = redisTemplate.opsForZSet();
Set<String> typedTuples = zset.reverseRange("ranking", 0, 9);
return List.copyOf(typedTuples);
}
}
@Slf4j
@RequestMapping("/v1/search")
@RequiredArgsConstructor
@RestController
public class SearchController {
private final SearchService searchService;
@PostMapping("/rank")
public ApiResponse<Void> search(@ModelAttribute SearchKeywordRequest request) {
log.info("키워드 = {}", request.getKeyword());
searchService.searchKeyword(request.getKeyword());
return ApiResponse.ok(null);
}
@GetMapping("/rank")
public ApiResponse<List<String>> search() {
return ApiResponse.ok(searchService.getRankKeywords());
}
}
POST요청에는 검색어 점수를 증가시키고, GET요청은 검색어 TOP-10을 조회한다.
현재는 List<String>으로 결과를 반환하지만 나중에는 검색어와 점수 변수를 함께 리턴하는 DTO를 추가할 것이다.
# Test
@SpringBootTest
@RecordApplicationEvents
public class SearchServiceTest extends RedisTestContainer {
@Autowired
private SearchService searchService;
@Autowired
private @Qualifier("redisTemplateSecond") RedisTemplate<String, String> redisTemplate;
@MockBean
private S3FileManager s3FileManager;
@DisplayName("사용자가 검색한 키워드의 점수가 1 증가한다.")
@org.junit.jupiter.api.Test
void searchSaveRanking() {
//given
String keyword = "testKeyword";
//when
searchService.searchKeyword(keyword);
//then
ZSetOperations<String, String> zset = redisTemplate.opsForZSet();
Double score = zset.score("ranking", keyword);
assertThat(score).isEqualTo(1.0); // 검색어의 점수가 1.0인지 확인
}
@DisplayName("키워드 검색 횟수 상위 10개를 가져온다.")
@Test
void getRankKeywords() {
//given
String keyword = "testKeyword";
//when
searchService.searchKeyword(keyword);
//then
List<String> rankKeywords = searchService.getRankKeywords();
Assertions.assertThat(rankKeywords).contains(keyword); // 랭킹에 추가된 검색어가 있는지 확인
}
}
RedisTestContainer를 통해 테스트를 진행했고 성공했다.
굿 🔥
'Spring' 카테고리의 다른 글
STOMP로 소켓 방식 채팅 구현 + Rate Limiter, Token Bucket으로 API 처리율 제한하기 (0) | 2024.06.21 |
---|---|
좋아요 조회 쿼리 N + 1 문제 해결하기 (0) | 2024.04.05 |
[Spring] could not initialize proxy [...] - no Session 오류 (0) | 2024.04.02 |
QueryDSL 동적 정렬 쿼리 OrderSpecifier 구현하기 (0) | 2024.03.28 |
Spring Security 없이 소셜 로그인 구현하기 (0) | 2024.03.17 |