Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,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<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> 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;
}
}
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