Skip to content
Merged
2 changes: 1 addition & 1 deletion .docs/cache-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ post:stats:{postId}
- 향후 Kafka 메시지를 이용한 비동기 갱신을 1차 릴리스에 포함할지, 후속 단계로 분리할지 결정.

## 8. 다음 단계 제안
1. 위 정책을 근거로 Redis 인프라 구성 및 Spring Cache 설정
1. 위 정책을 근거로 Redis 연결 설정과 템플릿 구성
2. 게시글 상세 캐시 구현 & 이벤트 무효화 PoC
3. Kafka 연동 확장 및 모니터링 지표 추가
4. 인기글/검색 캐시 도입 및 TTL 튜닝
Expand Down
34 changes: 34 additions & 0 deletions .docs/redis-template-setup.md
Original file line number Diff line number Diff line change
@@ -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 등) 사전에 정리합니다.
Comment on lines +6 to +8
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

프로퍼티 prefix가 잘못되었습니다. spring.redis.* 사용이 맞습니다.

Spring Boot 오토컨피그는 spring.redis.host|port|password를 사용합니다. 문서 예시를 수정해 주세요.

수정 예시:

-   - 예: `spring.data.redis.host`, `spring.data.redis.port`, `spring.data.redis.password`.  
+   - 예: `spring.redis.host`, `spring.redis.port`, `spring.redis.password`.  
🤖 Prompt for AI Agents
.docs/redis-template-setup.md lines 6-8: the documented Redis property prefix is
incorrect; replace references to spring.data.redis.* with the correct Spring
Boot auto-config prefix spring.redis.* in the example and any descriptive text,
and update the sample keys to use spring.redis.host, spring.redis.port,
spring.redis.password so the docs match Spring Boot conventions.


## 2. Redis 설정 클래스 작성
1. 패키지 예시: `dooya.see.adapter.integration.cache`.
2. `@Configuration` 클래스를 만들고 다음 빈을 등록합니다.
- `RedisConnectionFactory` (Lettuce 클라이언트 사용 권장)
- `RedisTemplate<String, ?>` (직렬화 전략 선택: `StringRedisSerializer`, `GenericJackson2JsonRedisSerializer` 등)
3. 필요 시 `ObjectMapper` 등 공용 Bean을 주입하거나 별도 설정을 만듭니다.
Comment on lines +12 to +15
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

RedisConnectionFactory 수동 등록은 피하고, 오토컨피그를 활용하세요.

수동 팩토리 빈을 정의하면 Boot의 spring.redis.* 바인딩이 무시될 수 있습니다. 연결 팩토리는 미정의(백오프)하고, 필요 시 RedisTemplate만 커스터마이즈하세요.

수정 예시(가이드 문구):

-2. `@Configuration` 클래스를 만들고 다음 빈을 등록합니다.
-   - `RedisConnectionFactory` (Lettuce 클라이언트 사용 권장)  
-   - `RedisTemplate<String, ?>` (직렬화 전략 선택: `StringRedisSerializer`, `GenericJackson2JsonRedisSerializer` 등)  
+2. `@Configuration` 클래스에서는 `RedisTemplate<String, ?>`만 커스터마이즈합니다.
+   - 연결 팩토리(`RedisConnectionFactory`)는 Spring Boot 오토컨피그에 맡깁니다(프로퍼티: `spring.redis.*`).  
+   - 직렬화 전략만 명시적으로 설정(`StringRedisSerializer`, `GenericJackson2JsonRedisSerializer` 등).  
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
2. `@Configuration` 클래스를 만들고 다음 빈을 등록합니다.
- `RedisConnectionFactory` (Lettuce 클라이언트 사용 권장)
- `RedisTemplate<String, ?>` (직렬화 전략 선택: `StringRedisSerializer`, `GenericJackson2JsonRedisSerializer` 등)
3. 필요 시 `ObjectMapper` 등 공용 Bean을 주입하거나 별도 설정을 만듭니다.
2. `@Configuration` 클래스에서는 `RedisTemplate<String, ?>`만 커스터마이즈합니다.
- 연결 팩토리(`RedisConnectionFactory`)는 Spring Boot 오토컨피그에 맡깁니다(프로퍼티: `spring.redis.*`).
- 직렬화 전략만 명시적으로 설정(`StringRedisSerializer`, `GenericJackson2JsonRedisSerializer` 등).
3. 필요 시 `ObjectMapper` 등 공용 Bean을 주입하거나 별도 설정을 만듭니다.
🤖 Prompt for AI Agents
In .docs/redis-template-setup.md around lines 12 to 15, the guidance currently
suggests manually registering a RedisConnectionFactory which can override Spring
Boot's spring.redis.* binding; update the text to instruct readers to avoid
defining a RedisConnectionFactory bean and instead rely on Spring Boot
auto-configuration so spring.redis.* properties bind correctly, and advise that
if customization is needed they should define a RedisTemplate bean (or customize
serializers/ObjectMapper) while leaving the connection factory undefined to let
Boot back off.


## 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과 키 스키마를 준비한 뒤, 게시글 상세/목록 캐시에 재사용할 수 있습니다.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
16 changes: 16 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
// Spring Boot auto-configures LettuceConnectionFactory based on spring.data.redis.* properties.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

주석 위치를 조정하세요.

Line 14의 주석은 LettuceConnectionFactory의 자동 구성에 대해 설명하고 있지만, redisTemplate 메서드 내부에 위치해 있어 문맥상 어색합니다. 클래스 레벨 JavaDoc이나 메서드 상단으로 이동하는 것이 더 명확합니다.

다음과 같이 수정하는 것을 권장합니다:

+/**
+ * Redis configuration class.
+ * Spring Boot auto-configures LettuceConnectionFactory based on spring.data.redis.* properties.
+ */
 @Configuration
 public class RedisConfig {
     @Bean
     RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
         RedisTemplate<String, String> template = new RedisTemplate<>();
-        // Spring Boot auto-configures LettuceConnectionFactory based on spring.data.redis.* properties.
         template.setConnectionFactory(connectionFactory);
🤖 Prompt for AI Agents
In src/main/java/dooya/see/adapter/integration/cache/config/RedisConfig.java
around line 14, the inline comment about Spring Boot auto-configuring
LettuceConnectionFactory is placed inside the redisTemplate method and reads
awkwardly in that context; move that comment to either the class-level JavaDoc
(above the class declaration) or immediately above the redisTemplate method
signature as a method-level comment, then remove the original inline comment
from inside the method so the documentation aligns with its intended scope and
improves readability.

template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> redisTemplate) {
@Test
void RedisTemplate_설정_확인() {
assertThat(redisTemplate).isNotNull();
}
}
Original file line number Diff line number Diff line change
@@ -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:");
}
}
Loading