Conversation
WalkthroughRedis 캐시를 도입해 뉴스 목록을 캐싱하고 관련 캐시를 무효화하도록 변경했다. Gradle에 Spring Boot/Redis/Jackson 의존성을 추가했고 Redis 설정 클래스를 신설했으며 서비스에 @Cacheable, 스케줄러/관리자 API에 @CacheEvict를 적용하고 DTO들에 무인자 생성자를 추가했다. Changes
Sequence Diagram(s)sequenceDiagram
participant C as 클라이언트
participant S as NewsService
participant API as AdminApi/스케줄러
participant Cache as Redis Cache
participant DB as Repository/DB
rect rgb(234,248,255)
note over C,S: 뉴스 목록 조회 (캐시 우선)
C->>S: getNewsList(page,size,sort,category)
S->>Cache: 조회 (news:list:v2:key)
alt 캐시 적중
Cache-->>S: 캐시된 결과
S-->>C: 결과 반환
else 캐시 미스
S->>DB: 데이터 조회
DB-->>S: 결과
S->>Cache: 저장 (news:list:v2:key)
S-->>C: 결과 반환
end
end
rect rgb(244,240,255)
note over C,API: 수동 크롤/스케줄러 (캐시 무효화)
C->>API: crawlNewsManually()/crawlingForAdmin()/saveNewsArticles()
API->>Cache: @CacheEvict(allEntries=true) news:list:v2
API-->>C: 완료 응답
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
Poem
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 💡 Knowledge Base configuration:
You can enable these sources in your CodeRabbit configuration. 📒 Files selected for processing (1)
✅ Files skipped from review due to trivial changes (1)
✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/main/java/com/blockguard/server/domain/news/dto/response/NewsArticleResponse.java (2)
24-33: 상대시간 문자열을 DTO에 고정 저장하면 캐시 시점이 굳어져 ‘시간 경과’ 표시가 영구히 stale 됩니다현재 서비스 레벨에서 DTO 자체를 캐싱하고 TTL이 길면(요약에 따르면 24h) “x분 전/어제”가 실제 시간 경과와 불일치합니다. 해결책:
- 캐시에는 절대시간(ISO 문자열 혹은 epoch)을 저장하고, 상대시간 포맷은 응답 직전(컨트롤러) 혹은 클라이언트에서 계산.
- 또는 캐시 키에 시간 버킷(예: 10분) 포함 혹은 TTL을 수분 단위로 단축.
API 계약을 유지하면서 최소 변경으로 가려면 캐시 키에 시간 버킷을 넣는 방식을 추천합니다(서비스 코멘트 참조). 계약 변경이 가능하면 아래처럼 절대시간을 내려주세요.
- private String publishedAt; + // 절대시간 (예: ISO-8601). 상대시간은 클라이언트에서 계산 권장 + private String publishedAt; ... - .publishedAt(formatTime(newsArticle.getPublishedAt())) + .publishedAt(newsArticle.getPublishedAt().toString())
36-47: 미래 시각 입력 시 음수 지속시간 처리 버그크롤러/서버 시계 불일치 등으로 publishedAt이 현재보다 미래면 “-5분 전” 같은 잘못된 문자열이 생성됩니다. 음수인 경우를 처리해 주세요.
- private static String formatTime(LocalDateTime time) { - Duration duration = Duration.between(time, LocalDateTime.now()); + private static String formatTime(LocalDateTime time) { + Duration duration = Duration.between(time, LocalDateTime.now()); + if (duration.isNegative() || duration.isZero()) { + return "방금 전"; + }src/main/java/com/blockguard/server/domain/news/application/NewsService.java (1)
30-35: 검증 순서 오류로 PageRequest.of(...)에서 IllegalArgumentException이 먼저 발생할 수 있음page/size 유효성 검증을 PageRequest 생성 이전으로 이동하세요. 현재는 page=0/size<1일 때 커스텀 예외 대신 프레임워크 예외가 먼저 던져집니다.
- Pageable pageable = PageRequest.of(page - 1, size, getSort(sort)); - Page<NewsArticle> newsPage; - - if (page < 1 || size < 1) { + if (page < 1 || size < 1) { throw new BusinessExceptionHandler(ErrorCode.MUST_BE_POSITIVE_NUMBER); } + Pageable pageable = PageRequest.of(page - 1, size, getSort(sort)); + Page<NewsArticle> newsPage;build.gradle (1)
3-4: MySQL 커넥터 중복 선언 정리 필요
- org.springframework.boot(3.5.3) 및 io.spring.dependency-management(1.1.7) 플러그인이 build.gradle에 정상 선언되어 있습니다.
- implementation 'mysql:mysql-connector-java:8.0.29' 와 runtimeOnly 'com.mysql:mysql-connector-j' 두 개의 MySQL 커넥터가 중복 선언되어 있어 BOM 기반 관리나 런타임 의존성 충돌 우려가 있습니다. 하나로 통일하거나 그룹ID/버전을 일관되게 지정해 주세요.
🧹 Nitpick comments (7)
src/main/java/com/blockguard/server/domain/news/dto/response/PageableInfo.java (1)
12-17: 캐시 스키마 변화 대비 내구성 강화 제안향후 필드 추가/변경 시 역직렬화 오류를 줄이기 위해 아래와 같이 알 수 없는 필드를 무시하도록 하는 것을 고려해 주세요.
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -@Builder +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) public class PageableInfo {src/main/java/com/blockguard/server/domain/news/dto/response/NewsPageResponse.java (1)
14-18: 캐시 포맷 변경 내성 확보 제안앞으로의 필드 증감에 대비해 알 수 없는 필드를 무시하도록 설정을 권장합니다.
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties; ... -@NoArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) public class NewsPageResponse {build.gradle (1)
34-36: 불필요/중복 의존성 정리 제안
- spring-boot-starter는 개별 스타터(web/jpa/security 등)로 이미 커버되어 중복입니다.
- jackson-databind는 web 스타터를 통해 관리되므로 명시 추가는 보통 불필요합니다(특별 버전 고정 필요 없으면 제거 권장).
- implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'com.fasterxml.jackson.core:jackson-databind' + // Redis Value 직렬화에 Jackson을 사용할 때는 web 스타터의 Jackson으로 충분한 경우가 많습니다. + // 별도 버전 고정 필요 시에만 추가하세요.src/main/java/com/blockguard/server/global/config/RedisConfig.java (4)
11-11: 불필요한 import 제거위 빈 제거 후
LettuceConnectionFactoryimport가 사용되지 않습니다.-import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
36-39: null 캐싱 방지 및 키 프리픽스 추가 권장
null결과가 캐시에 저장되면 장애 시 복구 지연이 길어질 수 있습니다. 또한 키 프리픽스로 환경/서비스를 구분해 충돌을 줄이는 것이 좋습니다.- RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() - .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) - .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) - .entryTtl(Duration.ofMinutes(30)); + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) + .disableCachingNullValues() + .computePrefixWith(cacheName -> "bg:" + cacheName + "::") + .entryTtl(Duration.ofMinutes(30));
41-43: 24h TTL은 수동/배치 크롤 직후 최신 데이터 노출을 지연시킬 수 있습니다현재 무효화가 컨트롤러 측(비동기 호출)에서 이루어져 크롤 완료 시점과 어긋날 여지가 있습니다. 스케줄러/크롤러 완료 시점에 무효화가 이뤄지는지 확인해 주세요. 필요 시 TTL을 단축(예: 1–3h)하거나 완료 후 무효화로 전환을 권장합니다.
45-50: 트랜잭션 인식 캐시 활성화 검토DB 트랜잭션 롤백 시 캐시와 일관성 보장을 강화하려면 트랜잭션 인식을 켜는 것이 좋습니다.
.withInitialCacheConfigurations(cacheConfigs) + .transactionAware() .build();
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (7)
build.gradle(1 hunks)src/main/java/com/blockguard/server/domain/admin/api/AdminApi.java(2 hunks)src/main/java/com/blockguard/server/domain/news/application/NewsService.java(2 hunks)src/main/java/com/blockguard/server/domain/news/dto/response/NewsArticleResponse.java(1 hunks)src/main/java/com/blockguard/server/domain/news/dto/response/NewsPageResponse.java(1 hunks)src/main/java/com/blockguard/server/domain/news/dto/response/PageableInfo.java(1 hunks)src/main/java/com/blockguard/server/global/config/RedisConfig.java(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
src/main/java/com/blockguard/server/domain/news/dto/response/PageableInfo.java (2)
src/main/java/com/blockguard/server/domain/news/dto/response/NewsPageResponse.java (1)
Getter(10-18)src/main/java/com/blockguard/server/domain/news/api/NewsApi.java (1)
RestController(17-44)
src/main/java/com/blockguard/server/domain/admin/api/AdminApi.java (2)
src/main/java/com/blockguard/server/domain/news/scheduler/NewsSaveScheduler.java (4)
Async(43-47)Async(38-41)Component(14-62)crawlAll(49-61)src/main/java/com/blockguard/server/infra/crawler/DaumNewsCrawler.java (1)
Service(25-148)
src/main/java/com/blockguard/server/domain/news/dto/response/NewsArticleResponse.java (3)
src/main/java/com/blockguard/server/domain/news/dto/response/NewsPageResponse.java (1)
Getter(10-18)src/main/java/com/blockguard/server/domain/news/dto/response/PageableInfo.java (1)
Getter(8-17)src/main/java/com/blockguard/server/domain/news/domain/NewsArticle.java (1)
Entity(10-43)
src/main/java/com/blockguard/server/domain/news/dto/response/NewsPageResponse.java (2)
src/main/java/com/blockguard/server/domain/news/dto/response/NewsArticleResponse.java (1)
Getter(12-49)src/main/java/com/blockguard/server/domain/news/dto/response/PageableInfo.java (1)
Getter(8-17)
src/main/java/com/blockguard/server/domain/news/application/NewsService.java (2)
src/main/java/com/blockguard/server/domain/news/dao/NewsRepository.java (1)
NewsRepository(9-13)src/main/java/com/blockguard/server/domain/news/api/NewsApi.java (3)
RestController(17-44)GetMapping(36-42)GetMapping(24-34)
🔇 Additional comments (3)
src/main/java/com/blockguard/server/domain/news/dto/response/PageableInfo.java (1)
6-11: DTO에 무인자 생성자 추가, 캐시/직렬화 호환성 향상 LGTMRedis/Jackson 직렬화·역직렬화 시 문제 줄여주는 변경입니다.
src/main/java/com/blockguard/server/domain/news/dto/response/NewsPageResponse.java (1)
6-14: 무인자 생성자 추가 LGTMJackson/캐시 역직렬화 시 호환성에 도움 됩니다.
src/main/java/com/blockguard/server/domain/admin/api/AdminApi.java (1)
16-16: 필요한 import 추가 OK
@CacheEvict도입에 따른 import 추가는 적절합니다.
|
|
||
| @PostMapping("/crawl") | ||
| @Operation(summary = "뉴스 크롤링") | ||
| @CacheEvict(value = "news:list:v2", allEntries = true) |
There was a problem hiding this comment.
비동기 크롤과 컨트롤러의 @CacheEvict 조합으로 stale 캐시가 24시간 잠길 수 있습니다
newsSaveScheduler.crawlingForAdmin()이 @Async라 이 컨트롤러 메서드는 즉시 종료되며, 캐시 비우기(기본 afterInvocation)는 크롤 완료 전 수행됩니다. 이후 클라이언트가 곧바로 조회하면 “이전 데이터”로 캐시가 다시 채워지고(24h TTL) 크롤 완료 후에도 최신 데이터가 노출되지 않습니다.
권장 수정(중 택1):
- 옵션 A(권장): 컨트롤러의
@CacheEvict제거 → 스케줄러 공개 메서드(예:crawlingForAdmin,saveNewsArticles)에@CacheEvict(value = "news:list:v2", allEntries = true)를 붙이고 두 메서드의@Async를 제거(스케줄러는 별도 스레드에서 실행되므로 불필요). 크롤 완료 후 무효화 보장. - 옵션 B: 이벤트/리스너로 전환 — 크롤 종료 시
ApplicationEvent발행, 별도 리스너에서@CacheEvict(allEntries = true)실행.
이 파일에서의 1차 수정 diff(옵션 A 전제):
- @CacheEvict(value = "news:list:v2", allEntries = true)스케줄러 수정 예시(다른 파일):
// NewsSaveScheduler.java
@CacheEvict(value = "news:list:v2", allEntries = true)
public void crawlingForAdmin() {
crawlAll(); // @Async 제거
}
@CacheEvict(value = "news:list:v2", allEntries = true)
public void saveNewsArticles() {
crawlAll(); // @Async 제거 (@Scheduled는 그대로 사용)
}🤖 Prompt for AI Agents
In src/main/java/com/blockguard/server/domain/admin/api/AdminApi.java around
line 56, the controller-level @CacheEvict is clearing the cache immediately
while newsSaveScheduler.crawlingForAdmin() runs asynchronously, allowing the
cache to be repopulated with stale data before the crawl finishes; remove the
@CacheEvict from this controller method and instead annotate the scheduler's
public methods (e.g., crawlingForAdmin and saveNewsArticles) with
@CacheEvict(value = "news:list:v2", allEntries = true) and remove @Async from
those scheduler methods (or alternatively publish an ApplicationEvent from the
scheduler and have a listener perform @CacheEvict) so cache eviction happens
after crawling completes.
| @Cacheable(value = "news:list:v2", key = "'list:' + #page + ':' + #size + ':' + #sort + ':' + #category") | ||
| public NewsPageResponse getNewsList(int page, int size, String sort, String category) { |
There was a problem hiding this comment.
🛠️ Refactor suggestion
상대시간 필드가 포함된 DTO를 장시간 캐시하면 UI가 stale 됩니다 — 시간 버킷 포함 또는 TTL 단축 권장
현재 DTO(NewsArticleResponse.publishedAt)가 “x분 전” 식으로 포맷된 문자열이라 서비스 레벨 캐시(요약상 24h)와 충돌합니다. 두 가지 경로 중 하나를 선택하세요.
- 빠른 적용: 캐시 키에 10분 버킷을 포함해 상대시간이 주기적으로 갱신되도록 함.
- 근본 해결: 캐시는 엔티티/절대시간만 저장하고 DTO 변환은 캐시 hit 이후에 수행(또는 상대시간은 클라이언트 계산).
빠른 적용용 키 변경 예:
- @Cacheable(value = "news:list:v2", key = "'list:' + #page + ':' + #size + ':' + #sort + ':' + #category")
+ // 10분 단위 버킷 추가로 상대시간 표시 stale 완화
+ @Cacheable(
+ value = "news:list:v2",
+ key = "'list:' + #page + ':' + #size + ':' + #sort + ':' + #category + ':' + (T(java.time.Instant).now().getEpochSecond() / 600)"
+ )추가로 빈 결과는 캐시하지 않도록 할 수 있습니다(선택):
- @Cacheable( ... )
+ @Cacheable( ..., unless = "#result == null || #result.news == null || #result.news.isEmpty()" )📝 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.
| @Cacheable(value = "news:list:v2", key = "'list:' + #page + ':' + #size + ':' + #sort + ':' + #category") | |
| public NewsPageResponse getNewsList(int page, int size, String sort, String category) { | |
| // 10분 단위 버킷 추가로 상대시간 표시 stale 완화 | |
| @Cacheable( | |
| value = "news:list:v2", | |
| key = "'list:' + #page + ':' + #size + ':' + #sort + ':' + #category + ':' + (T(java.time.Instant).now().getEpochSecond() / 600)" | |
| ) | |
| public NewsPageResponse getNewsList(int page, int size, String sort, String category) { | |
| // … existing implementation … | |
| } |
| @Value("${spring.data.redis.host}") | ||
| private String host; | ||
|
|
||
| @Value("${spring.data.redis.port}") | ||
| private int port; | ||
|
|
||
| @Bean | ||
| public RedisConnectionFactory redisConnectionFactory() { | ||
| return new LettuceConnectionFactory(host, port); | ||
| } | ||
|
|
There was a problem hiding this comment.
직접 정의한 RedisConnectionFactory가 비밀번호/DB/SSL/타임아웃을 무시합니다
Spring Boot의 자동 설정을 덮어써서 spring.data.redis.password, database, ssl, sentinel/cluster, timeout 등 프로퍼티가 적용되지 않습니다. 운영에서 인증/DB 분리 시 연결 실패·데이터 오염 위험이 큽니다. 자동 설정으로 제공되는 RedisConnectionFactory를 그대로 주입받도록 이 빈을 제거하세요.
적용 diff:
- @Value("${spring.data.redis.host}")
- private String host;
-
- @Value("${spring.data.redis.port}")
- private int port;
-
- @Bean
- public RedisConnectionFactory redisConnectionFactory() {
- return new LettuceConnectionFactory(host, port);
- }📝 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.
| @Value("${spring.data.redis.host}") | |
| private String host; | |
| @Value("${spring.data.redis.port}") | |
| private int port; | |
| @Bean | |
| public RedisConnectionFactory redisConnectionFactory() { | |
| return new LettuceConnectionFactory(host, port); | |
| } |
🤖 Prompt for AI Agents
In src/main/java/com/blockguard/server/global/config/RedisConfig.java around
lines 23-33, the explicitly defined RedisConnectionFactory using only host/port
overrides Spring Boot auto-configuration and ignores properties like
spring.data.redis.password, database, ssl, sentinel/cluster and timeout; remove
the custom @Bean method (and the host/port @Value fields if they are only used
for this) so Spring Boot can provide the auto-configured RedisConnectionFactory,
or if you must customize, construct the LettuceConnectionFactory via Spring's
RedisProperties and LettuceClientConfiguration so all standard properties are
honored.
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (2)
src/main/java/com/blockguard/server/domain/news/scheduler/NewsSaveScheduler.java (2)
50-54: 스케줄러 주석 해제 의도 확인 필요 · 비동기 + 완료 후 캐시 무효화 동일 패턴 적용 제안운영에서 자동 크롤링이 필요하다면 프로퍼티 기반 크론으로 복원하고, 위와 동일하게 비동기 실행 후 종료 시 캐시를 비우는 패턴이 안전합니다.
- @CacheEvict(value = "news:list:v2", allEntries = true) - // @Scheduled(cron = "0 0 4 * * *") + @Async + @Scheduled(cron = "${app.news.crawl.cron:0 0 4 * * *}") public void saveNewsArticles() { crawlAll(); + evictNewsListCache(); }
7-7: 프로그램 방식 무효화로 전환 시 불필요한 import 정리위 제안대로
@CacheEvict를 제거하면 해당 import도 함께 정리해주세요.-import org.springframework.cache.annotation.CacheEvict;
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
build.gradle(1 hunks)src/main/java/com/blockguard/server/domain/news/scheduler/NewsSaveScheduler.java(3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- build.gradle
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/blockguard/server/domain/news/scheduler/NewsSaveScheduler.java (2)
src/main/java/com/blockguard/server/domain/admin/api/AdminApi.java (1)
PostMapping(53-58)src/main/java/com/blockguard/server/infra/crawler/DaumNewsCrawler.java (1)
Service(25-148)
🔇 Additional comments (1)
src/main/java/com/blockguard/server/domain/news/scheduler/NewsSaveScheduler.java (1)
41-48: 현재 구현은 의도된 동작이며 변경 불필요
crawlingForAdmin()에@Async가 없으므로@CacheEvict(afterInvocation=false)는 메서드 리턴(크롤링 완료) 시점에 캐시를 무효화합니다. 스케줄러 전용 메서드여서 HTTP 요청 처리와도 분리되어 있어 블로킹 이슈가 발생하지 않습니다. 제안된 비동기 재적용 및 프로그램 방식 캐시 클리어는 불필요합니다.Likely an incorrect or invalid review comment.
💻 Related Issue
closed #85
🚀 Work Description
Summary by CodeRabbit