diff --git a/.docs/cache-strategy.md b/.docs/cache-strategy.md index b9b33823..ccba7502 100644 --- a/.docs/cache-strategy.md +++ b/.docs/cache-strategy.md @@ -57,7 +57,7 @@ post:stats:{postId} - 향후 Kafka 메시지를 이용한 비동기 갱신을 1차 릴리스에 포함할지, 후속 단계로 분리할지 결정. ## 8. 다음 단계 제안 -1. 위 정책을 근거로 Redis 인프라 구성 및 Spring Cache 설정 +1. 위 정책을 근거로 Redis 연결 설정과 템플릿 구성 2. 게시글 상세 캐시 구현 & 이벤트 무효화 PoC 3. Kafka 연동 확장 및 모니터링 지표 추가 4. 인기글/검색 캐시 도입 및 TTL 튜닝 diff --git a/.docs/redis-template-setup.md b/.docs/redis-template-setup.md new file mode 100644 index 00000000..3cc57357 --- /dev/null +++ b/.docs/redis-template-setup.md @@ -0,0 +1,34 @@ +# RedisTemplate 설정 & 키 스키마 정의 절차 + +캐시 1단계 구현(“RedisTemplate 설정 + 키 스키마 정의”)을 진행할 때 따라야 할 단계별 가이드입니다. + +## 1. 환경 설정 값 정의 +1. Redis 접속 정보를 `application.yml` 또는 프로파일별 설정에 추가합니다. + - 예: `spring.data.redis.host`, `spring.data.redis.port`, `spring.data.redis.password`. +2. 로컬·운영 환경에서 값을 어떻게 주입할지(환경 변수, docker-compose 등) 사전에 정리합니다. + +## 2. Redis 설정 클래스 작성 +1. 패키지 예시: `dooya.see.adapter.integration.cache`. +2. `@Configuration` 클래스를 만들고 다음 빈을 등록합니다. + - `RedisConnectionFactory` (Lettuce 클라이언트 사용 권장) + - `RedisTemplate` (직렬화 전략 선택: `StringRedisSerializer`, `GenericJackson2JsonRedisSerializer` 등) +3. 필요 시 `ObjectMapper` 등 공용 Bean을 주입하거나 별도 설정을 만듭니다. + +## 3. 키 스키마 유틸리티 구현 +1. 패키지 예시: `dooya.see.adapter.integration.cache.key`. +2. `PostCacheKey`와 같은 유틸 클래스를 만들고 정적 메서드로 키를 생성합니다. + - `detail(postId)`, `publicList(page,size)`, `search(hash,page,size)` 등. +3. 검색 조건 해시가 필요하면 `MessageDigest` 혹은 외부 라이브러리를 사용합니다. +4. 키 규칙: 소문자 + 콜론(`:`) 구분, null/빈 값은 normalize 후 처리. + +## 4. TTL/설정 상수 관리 +1. `@ConfigurationProperties("see.cache")` 같은 설정 클래스를 만들어 TTL을 외부화합니다. +2. 게시글 상세/목록/검색/통계별 기본 TTL 값을 명시하고 `application.yml`에 설정합니다. +3. 추후 모니터링 결과에 따라 조정할 수 있도록 기본값과 설명을 문서화합니다. + +## 5. 테스트 및 검증 메모 +1. 키 생성 유틸의 단위 테스트를 작성해 입력→키 문자열을 검증합니다. +2. RedisTemplate이 Bean으로 정상 등록되는지 `@SpringBootTest` 혹은 슬라이스 테스트로 확인합니다. +3. 향후 Kafka 이벤트와 연동할 때 사용할 `CacheService` 인터페이스/구현체 초안을 생각해둡니다. + +위 순서를 순차적으로 따라가면 RedisTemplate과 키 스키마를 준비한 뒤, 게시글 상세/목록 캐시에 재사용할 수 있습니다. diff --git a/build.gradle.kts b/build.gradle.kts index e80d36ba..e9d94363 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("org.springframework.security:spring-security-core") implementation("org.springframework.kafka:spring-kafka") implementation("org.springframework.retry:spring-retry") diff --git a/docker-compose.yml b/docker-compose.yml index 67f3ffa2..f18d65fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -97,6 +97,22 @@ services: timeout: 5s retries: 10 + redis: + image: redis:7-alpine + container_name: see-redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: ["redis-server", "--appendonly", "yes"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + volumes: mysql_data: es_data: + redis_data: \ No newline at end of file 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 new file mode 100644 index 00000000..5893e3e0 --- /dev/null +++ b/src/main/java/dooya/see/adapter/integration/cache/config/RedisConfig.java @@ -0,0 +1,22 @@ +package dooya.see.adapter.integration.cache.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + @Bean + RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/main/java/dooya/see/adapter/integration/cache/key/PostCacheKey.java b/src/main/java/dooya/see/adapter/integration/cache/key/PostCacheKey.java new file mode 100644 index 00000000..108d3489 --- /dev/null +++ b/src/main/java/dooya/see/adapter/integration/cache/key/PostCacheKey.java @@ -0,0 +1,64 @@ +package dooya.see.adapter.integration.cache.key; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +public final class PostCacheKey { + private static final String PREFIX = "post"; + private static final HexFormat HEX = HexFormat.of(); + private static final String HASH_ALGORITHM = "MD5"; + + private PostCacheKey() {} + + public static String detail(Long postId) { + return PREFIX + ":detail:" + requirePositive(postId); + } + + public static String publicList(int page, int size) { + return PREFIX + ":list:public:" + page + ":" + size; + } + + public static String categoryList(String category, int page, int size) { + return PREFIX + ":list:category:" + normalize(category) + ":" + page + ":" + size; + } + + public static String search(String normalizedQuery, int page, int size) { + String hash = hashToHex(normalizedQuery); + return PREFIX + ":search:" + hash + ":" + page + ":" + size; + } + + public static String stats(Long postId) { + return PREFIX + ":stats:" + requirePositive(postId); + } + + private static String normalize(String value) { + if (value == null) { + return "null"; + } + return value.trim().toUpperCase(); + } + + private static long requirePositive(Long id) { + Objects.requireNonNull(id, "postId must not be null"); + if (id <= 0) { + throw new IllegalArgumentException("postId must be positive"); + } + return id; + } + + private static String hashToHex(String input) { + if (input == null || input.isBlank()) { + return "empty"; + } + try { + MessageDigest md = MessageDigest.getInstance(HASH_ALGORITHM); + byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + return HEX.formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("MD5 algorithm not available", e); + } + } +} diff --git a/src/test/java/dooya/see/adapter/integration/cache/RedisConfigTest.java b/src/test/java/dooya/see/adapter/integration/cache/RedisConfigTest.java new file mode 100644 index 00000000..fa4487ad --- /dev/null +++ b/src/test/java/dooya/see/adapter/integration/cache/RedisConfigTest.java @@ -0,0 +1,15 @@ +package dooya.see.adapter.integration.cache; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +public record RedisConfigTest(RedisTemplate redisTemplate) { + @Test + void RedisTemplate_설정_확인() { + assertThat(redisTemplate).isNotNull(); + } +} diff --git a/src/test/java/dooya/see/adapter/integration/cache/key/PostCacheKeyTest.java b/src/test/java/dooya/see/adapter/integration/cache/key/PostCacheKeyTest.java new file mode 100644 index 00000000..6143e2cd --- /dev/null +++ b/src/test/java/dooya/see/adapter/integration/cache/key/PostCacheKeyTest.java @@ -0,0 +1,48 @@ +package dooya.see.adapter.integration.cache.key; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PostCacheKeyTest { + @Test + void 게시글_상세_키는_post_detail_id_형태로_생성된다() { + assertThat(PostCacheKey.detail(42L)).isEqualTo("post:detail:42"); + } + + @Test + void 검색_키는_해시값과_페이지_정보를_포함한다() { + String key = PostCacheKey.search("keyword=hello", 0, 20); + assertThat(key).startsWith("post:search:"); + assertThat(key).endsWith(":0:20"); + } + + @Test + void 카테고리_목록_키는_대문자_카테고리와_페이지_정보를_포함한다() { + String key = PostCacheKey.categoryList("tech", 1, 10); + assertThat(key).isEqualTo("post:list:category:TECH:1:10"); + } + + @Test + void 공개_목록_키는_페이지와_사이즈를_그대로_붙인다() { + assertThat(PostCacheKey.publicList(2, 50)).isEqualTo("post:list:public:2:50"); + } + + @Test + void 통계_키는_post_stats_id_형태로_생성된다() { + assertThat(PostCacheKey.stats(7L)).isEqualTo("post:stats:7"); + } + + @Test + void 음수_ID가_들어오면_예외를_던진다() { + assertThatThrownBy(() -> PostCacheKey.detail(-1L)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 검색어가_null_or_blank이면_empty_해시로_처리한다() { + assertThat(PostCacheKey.search(null, 0, 10)).contains("post:search:empty:"); + assertThat(PostCacheKey.search(" ", 0, 10)).contains("post:search:empty:"); + } +}