diff --git a/src/main/java/com/jisungin/api/search/SearchController.java b/src/main/java/com/jisungin/api/search/SearchController.java new file mode 100644 index 0000000..608fb02 --- /dev/null +++ b/src/main/java/com/jisungin/api/search/SearchController.java @@ -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 addScoreSearchKeyword(@ModelAttribute @Valid SearchKeywordRequest request) { + log.info("키워드 = {}", request.getKeyword()); + searchService.searchKeyword(request.getKeyword()); + return ApiResponse.ok(null); + } + + @GetMapping("/rank") + public ApiResponse> getSearchRanking() { + return ApiResponse.ok(searchService.getRankKeywords()); + } + +} diff --git a/src/main/java/com/jisungin/api/search/request/SearchKeywordRequest.java b/src/main/java/com/jisungin/api/search/request/SearchKeywordRequest.java new file mode 100644 index 0000000..783722f --- /dev/null +++ b/src/main/java/com/jisungin/api/search/request/SearchKeywordRequest.java @@ -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; + +} diff --git a/src/main/java/com/jisungin/application/search/SearchService.java b/src/main/java/com/jisungin/application/search/SearchService.java new file mode 100644 index 0000000..fa516ec --- /dev/null +++ b/src/main/java/com/jisungin/application/search/SearchService.java @@ -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 redisTemplate; + + public SearchService(@Qualifier("redisTemplateSecond") RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public void searchKeyword(String keyword) { + ZSetOperations zset = redisTemplate.opsForZSet(); + zset.incrementScore("ranking", keyword, 1); // 점수 증가 + } + + public List getRankKeywords() { + ZSetOperations zset = redisTemplate.opsForZSet(); + Set typedTuples = zset.reverseRange("ranking", 0, 9); + return List.copyOf(typedTuples); + } + +} diff --git a/src/main/java/com/jisungin/config/RedisConfig.java b/src/main/java/com/jisungin/config/RedisConfig.java index c15a72d..5b232ec 100644 --- a/src/main/java/com/jisungin/config/RedisConfig.java +++ b/src/main/java/com/jisungin/config/RedisConfig.java @@ -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 @@ -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 redisTemplateFirst( + @Qualifier("redisConnectionFactoryFirst") LettuceConnectionFactory connectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + + redisTemplate.setConnectionFactory(connectionFactory); redisTemplate.setEnableTransactionSupport(true); redisTemplate.setKeySerializer(new StringRedisSerializer()); @@ -53,11 +76,34 @@ public RedisConnectionFactory redisConnectionFactory() { return redisTemplate; } + @Bean(name = "redisTemplateSecond") + public RedisTemplate redisTemplateSecond( + @Qualifier("redisConnectionFactorySecond") LettuceConnectionFactory connectionFactory) { + RedisTemplate 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(); + } } + diff --git a/src/test/java/com/jisungin/ControllerTestSupport.java b/src/test/java/com/jisungin/ControllerTestSupport.java index 3090214..1ea8f4d 100644 --- a/src/test/java/com/jisungin/ControllerTestSupport.java +++ b/src/test/java/com/jisungin/ControllerTestSupport.java @@ -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; @@ -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; @@ -35,7 +37,8 @@ UserController.class, BookController.class, ReviewLikeController.class, - ImageController.class + ImageController.class, + SearchController.class }) public abstract class ControllerTestSupport { @@ -78,4 +81,7 @@ public abstract class ControllerTestSupport { @MockBean protected ImageService imageService; + @MockBean + protected SearchService searchService; + } diff --git a/src/test/java/com/jisungin/api/search/SearchControllerTest.java b/src/test/java/com/jisungin/api/search/SearchControllerTest.java new file mode 100644 index 0000000..56aa023 --- /dev/null +++ b/src/test/java/com/jisungin/api/search/SearchControllerTest.java @@ -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()); + } + +} \ No newline at end of file diff --git a/src/test/java/com/jisungin/application/search/SearchServiceTest.java b/src/test/java/com/jisungin/application/search/SearchServiceTest.java new file mode 100644 index 0000000..fb318e8 --- /dev/null +++ b/src/test/java/com/jisungin/application/search/SearchServiceTest.java @@ -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 redisTemplate; + + @MockBean + private S3FileManager s3FileManager; + + @DisplayName("사용자가 검색한 키워드의 점수가 1 증가한다.") + @org.junit.jupiter.api.Test + void searchSaveRanking() { + //given + String keyword = "testKeyword"; + + //when + searchService.searchKeyword(keyword); + + //then + ZSetOperations 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 rankKeywords = searchService.getRankKeywords(); + Assertions.assertThat(rankKeywords).contains(keyword); // 랭킹에 추가된 검색어가 있는지 확인 + } + +} \ No newline at end of file