diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e10b48c..3cdef783 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,16 @@ jobs: --health-timeout 5s --health-retries 10 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + kafka: image: confluentinc/cp-kafka:7.6.1 ports: @@ -92,12 +102,13 @@ jobs: - name: ๐Ÿ• Wait for Kafka and Elasticsearch run: | - echo "Waiting for Elasticsearch and Kafka to be ready..." + echo "Waiting for Elasticsearch, Kafka, and Redis to be ready..." for i in {1..25}; do es_ready=$(curl -fsS http://localhost:9200/_cluster/health > /dev/null && echo "yes" || echo "no") kafka_ready=$(nc -z localhost 9092 && echo "yes" || echo "no") + redis_ready=$(nc -z localhost 6379 && echo "yes" || echo "no") - if [ "$es_ready" = "yes" ] && [ "$kafka_ready" = "yes" ]; then + if [ "$es_ready" = "yes" ] && [ "$kafka_ready" = "yes" ] && [ "$redis_ready" = "yes" ]; then echo "โœ… All services are ready!" exit 0 fi diff --git a/README.md b/README.md index 4e546bf1..4f14e606 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - **์นดํ…Œ๊ณ ๋ฆฌ ๋ถ„๋ฅ˜**: TECH, LIFE, TRAVEL, FOOD, HOBBY ์นดํ…Œ๊ณ ๋ฆฌ - **์‹ค์‹œ๊ฐ„ ํ†ต๊ณ„**: ์กฐํšŒ์ˆ˜, ์ข‹์•„์š” ์ˆ˜, ๋Œ“๊ธ€ ์ˆ˜ ์ถ”์  - **๋„๋ฉ”์ธ ์ด๋ฒคํŠธ**: ๋น„์ฆˆ๋‹ˆ์Šค ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ ์ฒ˜๋ฆฌ +- **๊ฒ€์ƒ‰ ์บ์‹œ**: Elasticsearch ํ‚ค์›Œ๋“œ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋ฅผ Redis์— 3๋ถ„ TTL๋กœ ์บ์‹ฑํ•˜์—ฌ ์‘๋‹ต ์ง€์—ฐ ์ตœ์†Œํ™” ### ๋Œ“๊ธ€ ์‹œ์Šคํ…œ - **๊ณ„์ธตํ˜• ๋Œ“๊ธ€**: ๋Œ€๋Œ“๊ธ€ ์ง€์›์œผ๋กœ ๊นŠ์ด ์žˆ๋Š” ํ† ๋ก  diff --git a/build.gradle.kts b/build.gradle.kts index e9d94363..cf9a2316 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -83,6 +83,10 @@ jmh { resultsFile.set(layout.buildDirectory.file("reports/jmh/post-search.json")) } +tasks.named("jmhJar") { + isZip64 = true +} + tasks.named("test") { diff --git a/src/gatling/scala/dooya/see/search/PostSearchSimulation.scala b/src/gatling/scala/dooya/see/search/PostSearchSimulation.scala index f78232d1..48cb4bc3 100644 --- a/src/gatling/scala/dooya/see/search/PostSearchSimulation.scala +++ b/src/gatling/scala/dooya/see/search/PostSearchSimulation.scala @@ -17,21 +17,40 @@ class PostSearchSimulation extends Simulation { .acceptHeader("application/json") .contentTypeHeader("application/json") - private val feeder = Iterator.continually(Map( - "keyword" -> keywords(Random.nextInt(keywords.size)) - )) + private val feeder = Iterator.continually { + val baseKeyword = keywords(Random.nextInt(keywords.size)) + Map( + "hotKeyword" -> baseKeyword, + "coldKeyword" -> s"$baseKeyword-${System.nanoTime()}" + ) + } private val dbSearch = exec( http("db-search") .get("/api/posts/search") - .queryParam("keyword", "${keyword}") + .queryParam("keyword", "${hotKeyword}") .check(status.is(200)) ) private val esSearch = exec( - http("es-search") + http("es-cold-search") + .get("/api/v1/posts/elasticsearch") + .queryParam("keyword", "${coldKeyword}") + .check(status.is(200)) + ) + + private val esCacheWarm = exec( + http("es-cache-warm") + .get("/api/v1/posts/elasticsearch") + .queryParam("keyword", "${hotKeyword}") + .check(status.is(200)) + .silent + ) + + private val esCacheHit = exec( + http("es-cache-hit") .get("/api/v1/posts/elasticsearch") - .queryParam("keyword", "${keyword}") + .queryParam("keyword", "${hotKeyword}") .check(status.is(200)) ) @@ -44,6 +63,10 @@ class PostSearchSimulation extends Simulation { .exec(dbSearch) .pause(500.milliseconds, 2.seconds) .exec(esSearch) + .pause(200.milliseconds, 500.milliseconds) + .exec(esCacheWarm) + .pause(100.milliseconds) + .exec(esCacheHit) setUp( scenarioBuilder.inject( diff --git a/src/jmh/java/dooya/see/benchmark/PostSearchBenchmark.java b/src/jmh/java/dooya/see/benchmark/PostSearchBenchmark.java index 2a4cd3ce..4d051fab 100644 --- a/src/jmh/java/dooya/see/benchmark/PostSearchBenchmark.java +++ b/src/jmh/java/dooya/see/benchmark/PostSearchBenchmark.java @@ -1,12 +1,25 @@ package dooya.see.benchmark; import dooya.see.SeeApplication; -import dooya.see.application.post.provided.PostFinder; import dooya.see.application.post.dto.PostSearchResult; +import dooya.see.application.post.provided.PostFinder; +import dooya.see.application.post.required.PostSearchCacheRepository; import dooya.see.domain.post.dto.PostSearchRequest; -import org.openjdk.jmh.annotations.*; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; import org.openjdk.jmh.infra.Blackhole; import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.WebApplicationType; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.data.domain.Page; @@ -19,27 +32,28 @@ @Fork(1) public class PostSearchBenchmark { - @State(Scope.Benchmark) - public static class BenchmarkState { - private ConfigurableApplicationContext context; - private PostFinder postFinder; - private SearchBenchmarkScenario scenario; + private abstract static class AbstractBenchmarkState { + protected ConfigurableApplicationContext context; + protected PostFinder postFinder; + protected PostSearchCacheRepository cacheRepository; + protected SearchBenchmarkScenario scenario; + protected SearchBenchmarkScenario.SearchQuery cacheHitQuery; @Setup(Level.Trial) public void setUp() { context = new SpringApplicationBuilder(SeeApplication.class) - .profiles("benchmark") + .profiles("benchmark", "benchmark-data") + .web(WebApplicationType.NONE) .logStartupInfo(false) .run(); postFinder = context.getBean(PostFinder.class); + cacheRepository = context.getBean(PostSearchCacheRepository.class); scenario = context.getBean(SearchBenchmarkScenarioProvider.class).createScenario(); - // warm-up search to prime caches - for (int i = 0; i < 20; i++) { - SearchBenchmarkScenario.SearchQuery query = scenario.next(); - postFinder.search(buildRequest(query.keyword()), query.pageable()); - postFinder.searchPosts(query.keyword(), query.pageable()); - } + cacheHitQuery = scenario.next(); + // warm up DB and cache flows with the hot keyword + postFinder.search(buildRequest(cacheHitQuery.keyword()), cacheHitQuery.pageable()); + postFinder.searchPosts(cacheHitQuery.keyword(), cacheHitQuery.pageable()); } @TearDown(Level.Trial) @@ -48,26 +62,62 @@ public void tearDown() { context.close(); } } + + protected SearchBenchmarkScenario.SearchQuery nextQuery() { + return scenario.next(); + } + + protected SearchBenchmarkScenario.SearchQuery cacheHitQuery() { + return cacheHitQuery; + } + + protected static PostSearchRequest buildRequest(String keyword) { + return PostSearchRequest.builder() + .keyword(keyword) + .status(null) + .build(); + } + } + + @State(Scope.Benchmark) + public static class DatabaseSearchState extends AbstractBenchmarkState { + // inherits base set-up + } + + @State(Scope.Benchmark) + public static class ColdSearchState extends AbstractBenchmarkState { + @Setup(Level.Invocation) + public void clearCache() { + cacheRepository.evictAll(); + } + } + + @State(Scope.Benchmark) + public static class CachedSearchState extends AbstractBenchmarkState { + // inherits warm-up behaviour } @Benchmark - public void databaseSearch(BenchmarkState state, Blackhole blackhole) { - SearchBenchmarkScenario.SearchQuery query = state.scenario.next(); - Page result = state.postFinder.search(buildRequest(query.keyword()), query.pageable()); + public void elasticsearchCold(ColdSearchState state, Blackhole blackhole) { + SearchBenchmarkScenario.SearchQuery query = state.nextQuery(); + Page result = state.postFinder.searchPosts(query.keyword(), query.pageable()); blackhole.consume(result.getTotalElements()); } @Benchmark - public void elasticsearchSearch(BenchmarkState state, Blackhole blackhole) { - SearchBenchmarkScenario.SearchQuery query = state.scenario.next(); + public void elasticsearchCached(CachedSearchState state, Blackhole blackhole) { + SearchBenchmarkScenario.SearchQuery query = state.cacheHitQuery(); Page result = state.postFinder.searchPosts(query.keyword(), query.pageable()); blackhole.consume(result.getTotalElements()); } - private static PostSearchRequest buildRequest(String keyword) { - return PostSearchRequest.builder() - .keyword(keyword) - .status(null) - .build(); + @Benchmark + public void databaseSearch(DatabaseSearchState state, Blackhole blackhole) { + SearchBenchmarkScenario.SearchQuery query = state.nextQuery(); + Page result = state.postFinder.search( + AbstractBenchmarkState.buildRequest(query.keyword()), + query.pageable() + ); + blackhole.consume(result.getTotalElements()); } } diff --git a/src/main/java/dooya/see/adapter/integration/cache/RedisPostSearchCacheRepository.java b/src/main/java/dooya/see/adapter/integration/cache/RedisPostSearchCacheRepository.java new file mode 100644 index 00000000..3256666c --- /dev/null +++ b/src/main/java/dooya/see/adapter/integration/cache/RedisPostSearchCacheRepository.java @@ -0,0 +1,103 @@ +package dooya.see.adapter.integration.cache; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import dooya.see.adapter.integration.cache.config.PostSearchCacheProperties; +import dooya.see.adapter.integration.cache.key.PostCacheKey; +import dooya.see.application.post.dto.PostSearchResult; +import dooya.see.application.post.required.PostSearchCacheRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisPostSearchCacheRepository implements PostSearchCacheRepository { + private static final String SEARCH_KEY_PATTERN = "post:search:*"; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final PostSearchCacheProperties properties; + + @Override + public Optional> find(String keyword, Pageable pageable) { + String key = buildKey(keyword, pageable); + String cached = redisTemplate.opsForValue().get(key); + + if (cached == null) { + return Optional.empty(); + } + + try { + CachedPage cachedPage = objectMapper.readValue(cached, CachedPage.class); + return Optional.of(cachedPage.toPage(pageable)); + } catch (JsonProcessingException e) { + log.warn("๊ฒ€์ƒ‰ ์บ์‹œ ์—ญ์ง๋ ฌํ™” ์‹คํŒจ: key={}", key, e); + redisTemplate.delete(key); + return Optional.empty(); + } + } + + @Override + public void save(String keyword, Pageable pageable, Page page) { + String key = buildKey(keyword, pageable); + CachedPage cachedPage = CachedPage.from(page); + + try { + String serialized = objectMapper.writeValueAsString(cachedPage); + Duration ttl = properties.getTtl(); + if (ttl == null || ttl.isZero() || ttl.isNegative()) { + redisTemplate.opsForValue().set(key, serialized); + } else { + redisTemplate.opsForValue().set(key, serialized, ttl); + } + } catch (JsonProcessingException e) { + log.warn("๊ฒ€์ƒ‰ ์บ์‹œ ์ง๋ ฌํ™” ์‹คํŒจ: key={}", key, e); + } + } + + @Override + public void evictAll() { + Set keys = redisTemplate.keys(SEARCH_KEY_PATTERN); + if (keys == null || keys.isEmpty()) { + return; + } + redisTemplate.delete(keys); + } + + private String buildKey(String keyword, Pageable pageable) { + String normalized = normalize(keyword); + return PostCacheKey.search(normalized, pageable.getPageNumber(), pageable.getPageSize()); + } + + private String normalize(String keyword) { + if (keyword == null) { + return ""; + } + return keyword.trim().toLowerCase(Locale.ROOT); + } + + private record CachedPage( + List content, + long totalElements + ) { + private Page toPage(Pageable pageable) { + return new PageImpl<>(content, pageable, totalElements); + } + + private static CachedPage from(Page page) { + return new CachedPage(page.getContent(), page.getTotalElements()); + } + } +} diff --git a/src/main/java/dooya/see/adapter/integration/cache/config/PostSearchCacheProperties.java b/src/main/java/dooya/see/adapter/integration/cache/config/PostSearchCacheProperties.java new file mode 100644 index 00000000..299dc0a2 --- /dev/null +++ b/src/main/java/dooya/see/adapter/integration/cache/config/PostSearchCacheProperties.java @@ -0,0 +1,21 @@ +package dooya.see.adapter.integration.cache.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "see.cache.post-search") +public class PostSearchCacheProperties { + /** + * ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์บ์‹œ TTL. + */ + private Duration ttl = Duration.ofMinutes(3); + + public Duration getTtl() { + return ttl; + } + + public void setTtl(Duration ttl) { + this.ttl = ttl; + } +} diff --git a/src/main/java/dooya/see/adapter/integration/cache/config/RedisConfig.java b/src/main/java/dooya/see/adapter/integration/cache/config/RedisConfig.java index 5893e3e0..0314fafc 100644 --- a/src/main/java/dooya/see/adapter/integration/cache/config/RedisConfig.java +++ b/src/main/java/dooya/see/adapter/integration/cache/config/RedisConfig.java @@ -1,5 +1,6 @@ package dooya.see.adapter.integration.cache.config; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; @@ -7,6 +8,7 @@ import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration +@EnableConfigurationProperties(PostSearchCacheProperties.class) public class RedisConfig { @Bean RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { diff --git a/src/main/java/dooya/see/application/post/PostQueryService.java b/src/main/java/dooya/see/application/post/PostQueryService.java index 851c7a2c..6a5c211a 100644 --- a/src/main/java/dooya/see/application/post/PostQueryService.java +++ b/src/main/java/dooya/see/application/post/PostQueryService.java @@ -2,12 +2,14 @@ import dooya.see.application.post.provided.PostFinder; import dooya.see.application.post.required.PostRepository; +import dooya.see.application.post.required.PostSearchCacheRepository; import dooya.see.application.post.required.PostSearchReader; import dooya.see.application.post.dto.PostSearchResult; import dooya.see.domain.post.*; import dooya.see.domain.post.dto.PostSearchRequest; import dooya.see.domain.post.exception.PostNotFoundException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -16,6 +18,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @Transactional @RequiredArgsConstructor @@ -23,6 +26,7 @@ public class PostQueryService implements PostFinder { private final PostRepository postRepository; private final ApplicationEventPublisher eventPublisher; private final PostSearchReader postSearchReader; + private final PostSearchCacheRepository postSearchCacheRepository; @Override public Post find(Long postId) { @@ -70,7 +74,8 @@ public Page findPosts(PostSearchRequest searchRequest, Pageable pageable) @Override public Page searchPosts(String keyword, Pageable pageable) { - return postSearchReader.searchByKeyword(keyword, pageable); + return postSearchCacheRepository.find(keyword, pageable) + .orElseGet(() -> fetchAndCacheSearchResults(keyword, pageable)); } @Override @@ -91,6 +96,17 @@ private void publishDomainEvents(Post post) { post.clearDomainEvents(); } + private Page fetchAndCacheSearchResults(String keyword, Pageable pageable) { + Page results = postSearchReader.searchByKeyword(keyword, pageable); + try { + postSearchCacheRepository.save(keyword, pageable, results); + } catch (Exception e) { + // ์บ์‹œ ์‹คํŒจ๋Š” ๊ฒ€์ƒ‰ ํ๋ฆ„์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š๋„๋ก ๊ธฐ๋ก๋งŒ ๋‚จ๊ธด๋‹ค. + log.warn("๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์บ์‹œ ์ €์žฅ ์‹คํŒจ: keyword={}, page={}, size={}", keyword, pageable.getPageNumber(), pageable.getPageSize(), e); + } + return results; + } + private boolean isEmptySearch(PostSearchRequest searchRequest) { if (searchRequest == null) { return true; diff --git a/src/main/java/dooya/see/application/post/PostSearchCacheEventHandler.java b/src/main/java/dooya/see/application/post/PostSearchCacheEventHandler.java new file mode 100644 index 00000000..7f20d3f0 --- /dev/null +++ b/src/main/java/dooya/see/application/post/PostSearchCacheEventHandler.java @@ -0,0 +1,70 @@ +package dooya.see.application.post; + +import dooya.see.application.post.required.PostSearchCacheRepository; +import dooya.see.domain.post.event.PostCreated; +import dooya.see.domain.post.event.PostDeleted; +import dooya.see.domain.post.event.PostHidden; +import dooya.see.domain.post.event.PostPublished; +import dooya.see.domain.post.event.PostUpdated; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PostSearchCacheEventHandler { + private final PostSearchCacheRepository cacheRepository; + + @Async("applicationTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePostCreated(PostCreated event) { + evict("PostCreated", event.postId()); + } + + @Async("applicationTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePostUpdated(PostUpdated event) { + evict("PostUpdated", event.postId()); + } + + @Async("applicationTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePostPublished(PostPublished event) { + evict("PostPublished", event.postId()); + } + + @Async("applicationTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePostHidden(PostHidden event) { + evict("PostHidden", event.postId()); + } + + @Async("applicationTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePostDeleted(PostDeleted event) { + evict("PostDeleted", event.postId()); + } + + @Async("applicationTaskExecutor") + @EventListener + public void handleCacheEvictRequest(PostSearchCacheEvictCommand command) { + evict(command.source(), command.postId()); + } + + private void evict(String source, Long postId) { + log.debug("๊ฒ€์ƒ‰ ์บ์‹œ ๋ฌดํšจํ™” ์š”์ฒญ ์ฒ˜๋ฆฌ: source={}, postId={}", source, postId); + try { + cacheRepository.evictAll(); + } catch (Exception e) { + log.error("๊ฒ€์ƒ‰ ์บ์‹œ ๋ฌดํšจํ™” ์‹คํŒจ: source={}, postId={}", source, postId, e); + } + } + + public record PostSearchCacheEvictCommand(String source, Long postId) { + } +} diff --git a/src/main/java/dooya/see/application/post/required/PostSearchCacheRepository.java b/src/main/java/dooya/see/application/post/required/PostSearchCacheRepository.java new file mode 100644 index 00000000..b5844877 --- /dev/null +++ b/src/main/java/dooya/see/application/post/required/PostSearchCacheRepository.java @@ -0,0 +1,35 @@ +package dooya.see.application.post.required; + +import dooya.see.application.post.dto.PostSearchResult; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +/** + * ๊ฒŒ์‹œ๊ธ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์บ์‹ฑ์„ ์œ„ํ•œ ํฌํŠธ์ž…๋‹ˆ๋‹ค. + */ +public interface PostSearchCacheRepository { + /** + * ์บ์‹œ์— ์ €์žฅ๋œ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + * + * @param keyword ๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ + * @param pageable ํŽ˜์ด์ง• ์ •๋ณด + * @return ์บ์‹œ๋œ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ํŽ˜์ด์ง€ + */ + Optional> find(String keyword, Pageable pageable); + + /** + * ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋ฅผ ์บ์‹œ์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + * + * @param keyword ๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ + * @param pageable ํŽ˜์ด์ง• ์ •๋ณด + * @param page ์ €์žฅํ•  ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ํŽ˜์ด์ง€ + */ + void save(String keyword, Pageable pageable, Page page); + + /** + * ๊ฒ€์ƒ‰ ์บ์‹œ ์ „์ฒด๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + */ + void evictAll(); +} diff --git a/src/test/java/dooya/see/application/post/PostSearchCacheEventHandlerTest.java b/src/test/java/dooya/see/application/post/PostSearchCacheEventHandlerTest.java new file mode 100644 index 00000000..1bc04db2 --- /dev/null +++ b/src/test/java/dooya/see/application/post/PostSearchCacheEventHandlerTest.java @@ -0,0 +1,67 @@ +package dooya.see.application.post; + +import dooya.see.application.post.PostSearchCacheEventHandler.PostSearchCacheEvictCommand; +import dooya.see.application.post.required.PostSearchCacheRepository; +import dooya.see.domain.post.event.PostCreated; +import dooya.see.domain.post.event.PostDeleted; +import dooya.see.domain.post.event.PostHidden; +import dooya.see.domain.post.event.PostPublished; +import dooya.see.domain.post.event.PostUpdated; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PostSearchCacheEventHandlerTest { + private static final Long POST_ID = 42L; + + @Mock + private PostSearchCacheRepository cacheRepository; + + private PostSearchCacheEventHandler handler; + + @BeforeEach + void setUp() { + handler = new PostSearchCacheEventHandler(cacheRepository); + } + + @Test + void ๊ฒŒ์‹œ๊ธ€_์ƒ์„ฑ_์ด๋ฒคํŠธ_์ˆ˜์‹ ์‹œ_์บ์‹œ_๋ฌดํšจํ™”() { + handler.handlePostCreated(new PostCreated(POST_ID, null, null, false, null)); + verify(cacheRepository).evictAll(); + } + + @Test + void ๊ฒŒ์‹œ๊ธ€_์—…๋ฐ์ดํŠธ_์ด๋ฒคํŠธ_์ˆ˜์‹ ์‹œ_์บ์‹œ_๋ฌดํšจํ™”() { + handler.handlePostUpdated(new PostUpdated(POST_ID, null, false, false, false, false, null)); + verify(cacheRepository).evictAll(); + } + + @Test + void ๊ฒŒ์‹œ๊ธ€_๋ฐœํ–‰_์ด๋ฒคํŠธ_์ˆ˜์‹ ์‹œ_์บ์‹œ_๋ฌดํšจํ™”() { + handler.handlePostPublished(new PostPublished(POST_ID, null)); + verify(cacheRepository).evictAll(); + } + + @Test + void ๊ฒŒ์‹œ๊ธ€_์ˆจ๊น€_์ด๋ฒคํŠธ_์ˆ˜์‹ ์‹œ_์บ์‹œ_๋ฌดํšจํ™”() { + handler.handlePostHidden(new PostHidden(POST_ID, null, null)); + verify(cacheRepository).evictAll(); + } + + @Test + void ๊ฒŒ์‹œ๊ธ€_์‚ญ์ œ_์ด๋ฒคํŠธ_์ˆ˜์‹ ์‹œ_์บ์‹œ_๋ฌดํšจํ™”() { + handler.handlePostDeleted(new PostDeleted(POST_ID, null, null)); + verify(cacheRepository).evictAll(); + } + + @Test + void ๋ช…๋ น์œผ๋กœ_์บ์‹œ_๋ฌดํšจํ™”() { + handler.handleCacheEvictRequest(new PostSearchCacheEvictCommand("test", POST_ID)); + verify(cacheRepository).evictAll(); + } +} diff --git a/src/test/java/dooya/see/application/post/provided/PostFinderTest.java b/src/test/java/dooya/see/application/post/provided/PostFinderTest.java index 562f30d5..4820a453 100644 --- a/src/test/java/dooya/see/application/post/provided/PostFinderTest.java +++ b/src/test/java/dooya/see/application/post/provided/PostFinderTest.java @@ -3,6 +3,7 @@ import dooya.see.SeeTestConfiguration; import dooya.see.application.post.dto.PostSearchResult; import dooya.see.adapter.search.elasticsearch.repository.PostSearchElasticsearchRepository; +import dooya.see.application.post.required.PostSearchCacheRepository; import dooya.see.domain.post.Post; import dooya.see.domain.post.PostCategory; import dooya.see.domain.post.dto.PostSearchRequest; @@ -20,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; import static dooya.see.domain.post.PostFixture.*; import static org.assertj.core.api.Assertions.assertThat; @@ -32,7 +34,8 @@ record PostFinderTest( PostFinder postFinder, PostManager postManager, EntityManager entityManager, - PostSearchElasticsearchRepository repository) { + PostSearchElasticsearchRepository repository, + PostSearchCacheRepository postSearchCacheRepository) { private static final Long AUTHOR_ID = 1L; private static final Long ANOTHER_AUTHOR_ID = 2L; private static final Long VIEWER_ID = 2L; @@ -41,6 +44,7 @@ record PostFinderTest( void setUp() { entityManager.clear(); repository.deleteAll(); + postSearchCacheRepository.evictAll(); } @Nested @@ -349,6 +353,31 @@ class ๊ฒŒ์‹œ๊ธ€_๊ฒ€์ƒ‰_ES { } } + @Nested + class ๊ฒŒ์‹œ๊ธ€_๊ฒ€์ƒ‰_์บ์‹œ { + @Test + void ๊ฒ€์ƒ‰_๊ฒฐ๊ณผ๋ฅผ_Redis์—_์บ์‹ฑํ•˜๊ณ _์žฌ์‚ฌ์šฉํ•œ๋‹ค() { + createTestPostWithTitle("Spring Boot ์บ์‹ฑ"); + createTestPostWithTitle("Spring Data Redis"); + flushAndClearContext(); + + Pageable pageable = PageRequest.of(0, 10); + + Page firstResult = postFinder.searchPosts("Spring", pageable); + assertThat(firstResult.getContent()).isNotEmpty(); + + Optional> cached = postSearchCacheRepository.find("Spring", pageable); + assertThat(cached).isPresent(); + assertThat(cached.get().getContent()).isEqualTo(firstResult.getContent()); + + repository.deleteAll(); + flushAndClearContext(); + + Page secondResult = postFinder.searchPosts("Spring", pageable); + assertThat(secondResult.getContent()).isEqualTo(firstResult.getContent()); + } + } + @Nested class ๊ฒŒ์‹œ๊ธ€_์กฐํšŒ_์ด๋ฒคํŠธ { @Test