From e45241df4f552e317a27e0856c72d972b3bc3327 Mon Sep 17 00:00:00 2001 From: Park JeongHyun Date: Thu, 30 Oct 2025 16:04:41 +0900 Subject: [PATCH 01/10] feat(build): enable Zip64 for JMH jar packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jmhJar 작업에 Zip64 지원 추가하여 대용량 JMH Jar 패키징 가능 - 특정 환경에서 발생할 수 있는 압축 파일 크기 제한 문제 해결 - JMH 성능 테스트 결과의 안정적 배포 지원을 목표 --- build.gradle.kts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index e9d9436..cf9a231 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") { From 595363fbfdc4a86a95447153449f796d2e481156 Mon Sep 17 00:00:00 2001 From: Park JeongHyun Date: Thu, 30 Oct 2025 16:05:18 +0900 Subject: [PATCH 02/10] feat(cache): enable configuration properties in Redis config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RedisConfig에 @EnableConfigurationProperties 추가하여 PostSearchCacheProperties 사용 가능하도록 설정 - Redis 관련 설정 확장성을 높이고 구성 관리 효율성 강화 - Cache 관련 속성 정의 및 유연한 설정 적용 기반 마련 --- .../dooya/see/adapter/integration/cache/config/RedisConfig.java | 2 ++ 1 file changed, 2 insertions(+) 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 5893e3e..0314faf 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) { From 0680279d70f5a864e64c809bdbc167745f791df9 Mon Sep 17 00:00:00 2001 From: Park JeongHyun Date: Thu, 30 Oct 2025 16:05:30 +0900 Subject: [PATCH 03/10] feat(cache): add PostSearchCacheProperties for configurable TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색 결과 캐시 TTL(time-to-live)을 설정할 수 있는 PostSearchCacheProperties 클래스 추가 - 기본 TTL 값은 3분으로 설정 - Spring Boot의 @ConfigurationProperties 사용으로 유연한 구성 지원 - 캐시 TTL 설정을 별도 속성으로 분리하여 유지보수성 강화 및 확장성 확보 --- .../config/PostSearchCacheProperties.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/main/java/dooya/see/adapter/integration/cache/config/PostSearchCacheProperties.java 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 0000000..299dc0a --- /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; + } +} From 4ab11fbf7309aa4e705207ea57df7f7d1cc3b7ff Mon Sep 17 00:00:00 2001 From: Park JeongHyun Date: Thu, 30 Oct 2025 16:06:00 +0900 Subject: [PATCH 04/10] feat(cache): add RedisPostSearchCacheRepository for search caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 검색 결과 캐시에 대한 Redis 기반 구현 클래스 추가 - PostSearchCacheRepository 인터페이스 구현하여 통합 가능성 제공 - 검색 키워드와 페이징 정보를 기반으로 캐시 조회 및 저장 처리 지원 - 검색 캐시 전체 삭제 기능 제공으로 관리 용이성 향상 - 콘텐츠 직렬화/역직렬화 오류 처리 로직 추가로 안정성 강화 - TTL 설정을 사용하여 캐시 만료 시간 관리 --- .../cache/RedisPostSearchCacheRepository.java | 103 ++++++++++++++++++ .../required/PostSearchCacheRepository.java | 35 ++++++ 2 files changed, 138 insertions(+) create mode 100644 src/main/java/dooya/see/adapter/integration/cache/RedisPostSearchCacheRepository.java create mode 100644 src/main/java/dooya/see/application/post/required/PostSearchCacheRepository.java 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 0000000..3256666 --- /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/application/post/required/PostSearchCacheRepository.java b/src/main/java/dooya/see/application/post/required/PostSearchCacheRepository.java new file mode 100644 index 0000000..b584487 --- /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(); +} From cad4823a409fc267a4de9bb12b0f14e0f4b7f449 Mon Sep 17 00:00:00 2001 From: Park JeongHyun Date: Thu, 30 Oct 2025 16:06:11 +0900 Subject: [PATCH 05/10] feat(cache): add PostSearchCacheEventHandler for reactive cache eviction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 관련 이벤트(PostCreated, PostUpdated, PostPublished, PostHidden, PostDeleted) 처리하여 검색 캐시를 자동으로 무효화하는 PostSearchCacheEventHandler 추가 - PostSearchCacheRepository.evictAll 호출로 캐시 무효화 처리 - 이벤트 비동기 처리와 트랜잭션 커밋 이후 실행 보장으로 성능 및 데이터 일관성 강화 - PostSearchCacheEvictCommand를 통한 수동 캐시 무효화 지원으로 유연성 제공 test(cache): add unit tests for PostSearchCacheEventHandler - 게시글 이벤트(PostCreated, PostUpdated 등) 처리 시 캐시 무효화 동작 검증 테스트 추가 - PostSearchCacheEvictCommand 처리에 대한 테스트로 전체 캐시 초기화 로직의 신뢰성 강화 --- .../post/PostSearchCacheEventHandler.java | 70 +++++++++++++++++++ .../post/PostSearchCacheEventHandlerTest.java | 67 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/main/java/dooya/see/application/post/PostSearchCacheEventHandler.java create mode 100644 src/test/java/dooya/see/application/post/PostSearchCacheEventHandlerTest.java 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 0000000..7f20d3f --- /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/test/java/dooya/see/application/post/PostSearchCacheEventHandlerTest.java b/src/test/java/dooya/see/application/post/PostSearchCacheEventHandlerTest.java new file mode 100644 index 0000000..1bc04db --- /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(); + } +} From 2c5850e5b22e837fefcabf49b991170748f75754 Mon Sep 17 00:00:00 2001 From: Park JeongHyun Date: Thu, 30 Oct 2025 16:06:26 +0900 Subject: [PATCH 06/10] feat(cache): add search caching with Redis in PostFinder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 검색 요청 시 Redis 캐시를 조회하고 없을 경우 검색 결과를 캐싱하는 로직 추가 - PostQueryService에 검색 캐싱 관련 fetchAndCacheSearchResults 메서드 구현 - Redis 캐시에 실패하더라도 검색 흐름에 영향을 주지 않도록 기록만 남기도록 설계 - PostFinderTest에 Redis 캐싱 기능을 검증하는 테스트 케이스 추가 - 기존 Elasticsearch 검색에 Redis 캐시 계층 추가로 검색 성능 최적화 --- .../application/post/PostQueryService.java | 18 ++++++++++- .../post/provided/PostFinderTest.java | 31 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/main/java/dooya/see/application/post/PostQueryService.java b/src/main/java/dooya/see/application/post/PostQueryService.java index 851c7a2..6a5c211 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/test/java/dooya/see/application/post/provided/PostFinderTest.java b/src/test/java/dooya/see/application/post/provided/PostFinderTest.java index 562f30d..4820a45 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 From 42f3ee6ff489997baa3ffa0f89d5378b460a3f56 Mon Sep 17 00:00:00 2001 From: Park JeongHyun Date: Thu, 30 Oct 2025 16:06:40 +0900 Subject: [PATCH 07/10] feat(benchmark): add detailed states for search performance testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색 시나리오별 벤치마킹 클래스를 분리하며 확장 가능한 상태 관리 설계 추가 - AbstractBenchmarkState 추상화로 Cold, Cached, Database SearchState에 공통 로직 재사용 - ColdSearchState에서 캐시 초기화 로직 추가로 콜드 스타트 성능 측정 용이성 제공 - CachedSearchState에서 캐시 적중 상태 벤치마킹 지원 - BenchmarkState의 중복 제거 및 분리로 코드 가독성과 유지보수성 강화 - Elasticsearch 및 데이터베이스 호출의 성능 비교를 위한 명확한 벤치마크 항목 정의 --- .../see/benchmark/PostSearchBenchmark.java | 98 ++++++++++++++----- 1 file changed, 74 insertions(+), 24 deletions(-) diff --git a/src/jmh/java/dooya/see/benchmark/PostSearchBenchmark.java b/src/jmh/java/dooya/see/benchmark/PostSearchBenchmark.java index 2a4cd3c..4d051fa 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()); } } From e892d3e414436fbccbd61c1c9e986410ccd35586 Mon Sep 17 00:00:00 2001 From: Park JeongHyun Date: Thu, 30 Oct 2025 16:07:17 +0900 Subject: [PATCH 08/10] feat(benchmark): enhance Gatling benchmark with cold and warm cache states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gatling 시뮬레이션에 cold cache와 warm cache 상태 추가로 성능 테스트 시나리오 확장 - coldKeyword와 hotKeyword 분리로 캐시되지 않은 상태와 이미 적중된 상태의 검색 성능 측정 가능 - es-cache-warm 및 es-cache-hit 액션 추가로 엘라스틱서치 캐시 시뮬레이션 개선 - dbSearch와 esSearch 호출 흐름에 캐시 관련 단계 추가로 테스트 정확성 강화 - 캐시 초기화부터 캐시 적중까지의 전 과정 성능 테스트 시나리오 제공 --- .../see/search/PostSearchSimulation.scala | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/gatling/scala/dooya/see/search/PostSearchSimulation.scala b/src/gatling/scala/dooya/see/search/PostSearchSimulation.scala index f78232d..48cb4bc 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( From 8523a3d7c624d8069bea7d3efd67d539b56fe0fe Mon Sep 17 00:00:00 2001 From: Park JeongHyun Date: Thu, 30 Oct 2025 16:07:23 +0900 Subject: [PATCH 09/10] feat(docs): add search cache description to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Elasticsearch 검색 결과를 Redis에 캐싱하는 기능 설명 추가 - 검색 응답 지연을 줄이기 위해 3분 TTL을 적용하는 방식 명시 - 프로젝트 주요 기능 목록에 검색 캐시 항목 추가하여 명확성 강화 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4e546bf..4f14e60 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - **카테고리 분류**: TECH, LIFE, TRAVEL, FOOD, HOBBY 카테고리 - **실시간 통계**: 조회수, 좋아요 수, 댓글 수 추적 - **도메인 이벤트**: 비즈니스 이벤트 기반 사이드 이펙트 처리 +- **검색 캐시**: Elasticsearch 키워드 검색 결과를 Redis에 3분 TTL로 캐싱하여 응답 지연 최소화 ### 댓글 시스템 - **계층형 댓글**: 대댓글 지원으로 깊이 있는 토론 From c7ecfd7374ec2eccd14430a5e72088aaa9c350fc Mon Sep 17 00:00:00 2001 From: Park JeongHyun Date: Thu, 30 Oct 2025 16:17:35 +0900 Subject: [PATCH 10/10] feat(ci): add Redis service to CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI 환경에서 Redis 컨테이너 추가로 Redis 연동 테스트 지원 - Redis 초기화 및 상태 확인을 위한 health check 설정 포함 - Elasticsearch, Kafka 대기 로직에 Redis 상태 검사 로직 통합 - 검색 캐시 및 Redis 관련 기능의 CI 테스트 커버리지 확장 --- .github/workflows/ci.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e10b48..3cdef78 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