Skip to content

Commit

Permalink
Merge pull request #70 from jisung-in/feature/67-real-time-search-ter…
Browse files Browse the repository at this point in the history
…m-api

[Feature] 실시간 검색어 API 구현
  • Loading branch information
pdohyung authored Apr 9, 2024
2 parents 80ac987 + fb4611f commit ffdb0ce
Show file tree
Hide file tree
Showing 7 changed files with 277 additions and 8 deletions.
33 changes: 33 additions & 0 deletions src/main/java/com/jisungin/api/search/SearchController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.jisungin.api.search;

import com.jisungin.api.ApiResponse;
import com.jisungin.api.search.request.SearchKeywordRequest;
import com.jisungin.application.search.SearchService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Slf4j
@RequestMapping("/v1/search")
@RequiredArgsConstructor
@RestController
public class SearchController {

private final SearchService searchService;

@PostMapping("/rank")
public ApiResponse<Void> addScoreSearchKeyword(@ModelAttribute @Valid SearchKeywordRequest request) {
log.info("키워드 = {}", request.getKeyword());
searchService.searchKeyword(request.getKeyword());
return ApiResponse.ok(null);
}

@GetMapping("/rank")
public ApiResponse<List<String>> getSearchRanking() {
return ApiResponse.ok(searchService.getRankKeywords());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.jisungin.api.search.request;

import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
public class SearchKeywordRequest {

@NotBlank(message = "키워드 값은 필수 입니다.")
private String keyword;

}
33 changes: 33 additions & 0 deletions src/main/java/com/jisungin/application/search/SearchService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.jisungin.application.search;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
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;

@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);
}

}
60 changes: 53 additions & 7 deletions src/main/java/com/jisungin/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
package com.jisungin.config;

import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
Expand All @@ -26,22 +33,38 @@ public class RedisConfig {
@Value("${spring.data.redis.password}")
private String password;

@Bean
public RedisConnectionFactory redisConnectionFactory() {
@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
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
@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);
}

redisTemplate.setConnectionFactory(redisConnectionFactory());
@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());
Expand All @@ -53,11 +76,34 @@ public RedisConnectionFactory redisConnectionFactory() {
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(RedisConnectionFactory connectionFactory) {
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();
}
}

8 changes: 7 additions & 1 deletion src/test/java/com/jisungin/ControllerTestSupport.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.jisungin.api.oauth.AuthContext;
import com.jisungin.api.review.ReviewController;
import com.jisungin.api.reviewlike.ReviewLikeController;
import com.jisungin.api.search.SearchController;
import com.jisungin.api.talkroom.TalkRoomController;
import com.jisungin.api.talkroomlike.TalkRoomLikeController;
import com.jisungin.api.user.UserController;
Expand All @@ -18,6 +19,7 @@
import com.jisungin.application.image.ImageService;
import com.jisungin.application.review.ReviewService;
import com.jisungin.application.reviewlike.ReviewLikeService;
import com.jisungin.application.search.SearchService;
import com.jisungin.application.talkroom.TalkRoomService;
import com.jisungin.application.talkroomlike.TalkRoomLikeService;
import com.jisungin.application.user.UserService;
Expand All @@ -35,7 +37,8 @@
UserController.class,
BookController.class,
ReviewLikeController.class,
ImageController.class
ImageController.class,
SearchController.class
})
public abstract class ControllerTestSupport {

Expand Down Expand Up @@ -78,4 +81,7 @@ public abstract class ControllerTestSupport {
@MockBean
protected ImageService imageService;

@MockBean
protected SearchService searchService;

}
72 changes: 72 additions & 0 deletions src/test/java/com/jisungin/api/search/SearchControllerTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.jisungin.api.search;

import com.jisungin.ControllerTestSupport;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

class SearchControllerTest extends ControllerTestSupport {

@DisplayName("검색한 키워드의 점수를 증가시킨다.")
@Test
void addScoreSearchKeyword() throws Exception {
//given
//when //then
mockMvc.perform(
post("/v1/search/rank?keyword=정의")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.status").value("OK"))
.andExpect(jsonPath("$.message").value("OK"))
.andDo(print());
}

@DisplayName("검색한 키워드가 null이면 예외가 발생한다.")
@Test
void addScoreSearchKeywordWithNull() throws Exception {
//given
//when //then
mockMvc.perform(
post("/v1/search/rank")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("400"))
.andExpect(jsonPath("$.message").value("키워드 값은 필수 입니다."))
.andDo(print());
}

@DisplayName("검색한 키워드가 공백이면 예외가 발생한다.")
@Test
void addScoreSearchKeywordWithEmpty() throws Exception {
//given
//when //then
mockMvc.perform(
post("/v1/search/rank?keyword=")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("400"))
.andExpect(jsonPath("$.message").value("키워드 값은 필수 입니다."))
.andDo(print());
}

@DisplayName("인기 검색어 랭킹을 조회한다.")
@Test
void getSearchRanking() throws Exception {
//given
//when //then
mockMvc.perform(get("/v1/search/rank")
.contentType(MediaType.APPLICATION_JSON)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.status").value("OK"))
.andExpect(jsonPath("$.message").value("OK"))
.andDo(print());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.jisungin.application.search;

import com.jisungin.RedisTestContainer;
import com.jisungin.infra.s3.S3FileManager;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.test.context.event.RecordApplicationEvents;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@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); // 랭킹에 추가된 검색어가 있는지 확인
}

}

0 comments on commit ffdb0ce

Please sign in to comment.