-
Notifications
You must be signed in to change notification settings - Fork 0
[Refactor] 피드 도메인 캐싱 도입 & 피드 전체 조회 api 캐싱 로직 추가 #349
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
e6e361c
28b6033
5c10545
8aa7dd3
759d348
ce43b25
9414636
6bfa843
b9adce9
ea1e8b3
94ae92a
c19c9b4
27b9c8b
94e26b7
e10c383
764cebe
89c9825
a66654b
080c778
a041185
ee6787e
4b5e2a9
78cf95b
bd4cc2c
cb550de
f67605c
05a0916
873db2b
e1ac508
1094fdb
dceca3f
b6e863d
06299cc
86886de
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| //100명의 사용자가 동시에 각자 다른 속도로 홈 피드를 1페이지부터 3페이지까지 탐색하는 시나리오 | ||
| import http from 'k6/http'; | ||
| import { sleep,check } from 'k6'; | ||
|
|
||
| const BASE_URL = 'http://localhost:8000'; | ||
| const MAX_VUS = 500; | ||
|
|
||
| export let options = { | ||
| stages: [ | ||
| { duration: '20s', target: 200 }, // 20초 동안 100명까지 증가 | ||
| { duration: '40s', target: MAX_VUS }, // 40초 동안 300명까지 증가하며 피크 부하 | ||
| { duration: '20s', target: 0 }, // 20초 동안 0명으로 하강 | ||
| ], | ||
| thresholds: { | ||
| http_req_duration: ['p(95)<80'], | ||
| http_req_failed: ['rate<0.01'], | ||
| }, | ||
| }; | ||
|
|
||
| // 테스트 전 사용자 별 토큰 발급 | ||
| export function setup() { | ||
| let tokens = []; | ||
|
|
||
| // 유저 ID에 대해 토큰을 미리 발급 | ||
| for (let userId = 1; userId <= MAX_VUS; userId++) { | ||
| const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`); | ||
| check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 }); | ||
| tokens.push(res.body); | ||
| } | ||
|
|
||
| return { tokens: tokens }; | ||
| } | ||
|
Comment on lines
+20
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
또한 🛡️ 수정 제안 export function setup() {
let tokens = [];
for (let userId = 1; userId <= MAX_VUS; userId++) {
const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`);
- check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 });
- tokens.push(res.body);
+ const ok = check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 });
+ if (!ok) {
+ console.error(`Failed to get token for userId=${userId}: status=${res.status}`);
+ tokens.push(null);
+ continue;
+ }
+ // API가 JSON을 반환하는 경우: res.json().token 등으로 파싱 필요
+ tokens.push(res.body);
}
return { tokens: tokens };
}🤖 Prompt for AI Agents |
||
|
|
||
| export default function (data) { | ||
| const vuIdx = __VU - 1; | ||
| const token = data.tokens[vuIdx]; | ||
|
|
||
| const params = { | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'Authorization': `Bearer ${token}`, | ||
| }, | ||
| }; | ||
|
|
||
| let currentCursor = null; | ||
|
|
||
| // 모든 유저가 정확히 3번의 요청(1~3페이지) 수행 | ||
| for (let i = 1; i <= 3; i++) { | ||
| let url = `${BASE_URL}/feeds`; | ||
| if (currentCursor) { | ||
| url += `?cursor=${encodeURIComponent(currentCursor)}`; | ||
| } | ||
|
|
||
| let res = http.get(url, params); | ||
|
|
||
| if (check(res, { 'status is 200': (r) => r.status === 200 })) { | ||
| const responseData = res.json().data; | ||
| currentCursor = responseData.nextCursor; | ||
|
|
||
| // 만약 3페이지가 되기 전에 데이터가 끝났다면 루프 탈출 | ||
| if (responseData.isLast || !currentCursor) break; | ||
| } else { | ||
| // 요청 실패 시 해당 유저 시나리오 중단 | ||
| break; | ||
| } | ||
|
|
||
| sleep(Math.random() * 1 + 0.5); // 0.5초 ~ 1.5초 사이 랜덤하게 쉬기 | ||
| } | ||
|
Comment on lines
+47
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "정확히 3번" 주석이 실제 동작과 다릅니다 Line 47의 주석은 "모든 유저가 정확히 3번의 요청(1~3페이지) 수행"이라고 명시하지만, Line 61의 early break 조건( ✏️ 수정 제안- // 모든 유저가 정확히 3번의 요청(1~3페이지) 수행
+ // 최대 3번의 요청(1~3페이지) 수행, 데이터가 끝나면 조기 종료🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package konkuk.thip.config.cache; | ||
|
|
||
| import com.github.benmanes.caffeine.cache.Cache; | ||
| import com.github.benmanes.caffeine.cache.Caffeine; | ||
| import java.util.Arrays; | ||
| import java.util.List; | ||
| import java.util.concurrent.TimeUnit; | ||
| import java.util.stream.Collectors; | ||
| import org.springframework.cache.CacheManager; | ||
| import org.springframework.cache.caffeine.CaffeineCache; | ||
| import org.springframework.cache.support.SimpleCacheManager; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
|
|
||
| @Configuration | ||
| public class CacheConfig { | ||
|
|
||
| @Bean | ||
| public CacheManager cacheManager() { | ||
| SimpleCacheManager cacheManager = new SimpleCacheManager(); | ||
|
|
||
| List<CaffeineCache> caches = Arrays.stream(CacheType.values()) | ||
| .map(cacheType -> new CaffeineCache( | ||
| cacheType.getCacheName(), | ||
| caffeineBuilder(cacheType) | ||
| )) | ||
| .collect(Collectors.toList()); | ||
|
|
||
| cacheManager.setCaches(caches); | ||
| return cacheManager; | ||
| } | ||
|
|
||
| private Cache<Object, Object> caffeineBuilder(CacheType cacheType) { | ||
| Caffeine<Object, Object> builder = Caffeine.newBuilder() | ||
| .recordStats() | ||
| .maximumSize(cacheType.getMaximumSize()); | ||
|
|
||
| // 0초 무한유지 설정 | ||
| if (cacheType.getExpireAfterWrite() > 0) { | ||
| builder.expireAfterWrite(cacheType.getExpireAfterWrite(), TimeUnit.SECONDS); | ||
| } | ||
|
|
||
| return builder.build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package konkuk.thip.config.cache; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| @AllArgsConstructor | ||
| public enum CacheType { | ||
| // 최신순 피드 ID 100개 인덱스 캐시 | ||
| FEED_ID_TOP( | ||
| "feedIdTop", | ||
| 0, // 만료 시간 없음 | ||
| 1), // | ||
| // 개별 피드 상세 정보를 담는 데이터 캐시 | ||
| FEED_DETAIL( | ||
| "feedDetail", | ||
| 0, // 만료 시간 없음 | ||
| 100); | ||
|
|
||
| private final String cacheName; | ||
| private final int expireAfterWrite; | ||
| private final int maximumSize; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| package konkuk.thip.feed.adapter.in.event; | ||
|
|
||
| import konkuk.thip.feed.adapter.out.cache.FeedCacheHandler; | ||
| import konkuk.thip.feed.adapter.out.event.dto.FeedCreatedEvent; | ||
| import konkuk.thip.feed.adapter.out.event.dto.FeedDeletedEvent; | ||
| import konkuk.thip.feed.adapter.out.event.dto.FeedUpdatedEvent; | ||
| import konkuk.thip.user.adapter.out.event.dto.UserWithdrawnEvent; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.event.TransactionPhase; | ||
| import org.springframework.transaction.event.TransactionalEventListener; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class FeedCacheEventListener { | ||
|
|
||
| private final FeedCacheHandler feedCacheHandler; | ||
|
|
||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handleFeedCreatedEvent(FeedCreatedEvent event) { | ||
| feedCacheHandler.updateTopIdsWithNewId(event.feedId()); | ||
| feedCacheHandler.getFeedDetail(event.feedId()); | ||
| } | ||
|
|
||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handleFeedDeletedEvent(FeedDeletedEvent event) { | ||
| feedCacheHandler.markAsDeleted(event.feedId()); | ||
| } | ||
|
|
||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handleFeedUpdatedEvent(FeedUpdatedEvent event) { | ||
| if (feedCacheHandler.evictFeedDetailIfPresent(event.feedId())) { | ||
| feedCacheHandler.getFeedDetail(event.feedId()); | ||
| } | ||
| } | ||
|
|
||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handleUserWithdrawn(UserWithdrawnEvent event) { | ||
| feedCacheHandler.refreshCacheAfterBulkDelete(event.deletedFeedIds()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,42 @@ | ||||||||||||||
| package konkuk.thip.feed.adapter.in.event; | ||||||||||||||
|
|
||||||||||||||
| import java.util.List; | ||||||||||||||
| import konkuk.thip.feed.adapter.out.cache.FeedCacheHandler; | ||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||
| import lombok.extern.slf4j.Slf4j; | ||||||||||||||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; | ||||||||||||||
| import org.springframework.boot.context.event.ApplicationReadyEvent; | ||||||||||||||
| import org.springframework.context.event.EventListener; | ||||||||||||||
| import org.springframework.stereotype.Component; | ||||||||||||||
| import org.springframework.transaction.annotation.Transactional; | ||||||||||||||
|
|
||||||||||||||
| @Slf4j | ||||||||||||||
| @Component | ||||||||||||||
| @ConditionalOnProperty( | ||||||||||||||
| name="cache.warmup.enabled", | ||||||||||||||
| havingValue = "true") | ||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||
| public class FeedCacheWarmupListener { | ||||||||||||||
|
|
||||||||||||||
| private final FeedCacheHandler feedCacheHandler; | ||||||||||||||
|
|
||||||||||||||
| @EventListener(ApplicationReadyEvent.class) | ||||||||||||||
| @Transactional(readOnly = true) | ||||||||||||||
| public void handleContextReady() { | ||||||||||||||
| log.info("애플리케이션 준비 완료: 캐시 워밍업을 시작합니다."); | ||||||||||||||
|
|
||||||||||||||
| try { | ||||||||||||||
| // 1. 상위 ID 리스트 조회 및 인덱스 캐싱 | ||||||||||||||
| List<Long> topIds = feedCacheHandler.getTopIds(); | ||||||||||||||
| log.info("상위 ID {}개 추출 완료", topIds.size()); | ||||||||||||||
|
|
||||||||||||||
| // 2. 상세 데이터 일괄 캐싱 | ||||||||||||||
| if (!topIds.isEmpty()) { | ||||||||||||||
| feedCacheHandler.warmUpFeedDetails(topIds); | ||||||||||||||
| } | ||||||||||||||
| log.info("총 {}개의 피드 상세 데이터 캐시 워밍업 완료", topIds.size()); | ||||||||||||||
| } catch (Exception e) { | ||||||||||||||
| log.error("캐시 워밍업 중 오류가 발생했습니다: {}", e.getMessage()); | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+38
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 예외 로깅 시 스택 트레이스가 누락됩니다.
수정 제안- log.error("캐시 워밍업 중 오류가 발생했습니다: {}", e.getMessage());
+ log.error("캐시 워밍업 중 오류가 발생했습니다: {}", e.getMessage(), e);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| package konkuk.thip.feed.adapter.out.cache; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; | ||
| import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.cache.Cache; | ||
| import org.springframework.cache.CacheManager; | ||
| import org.springframework.cache.annotation.CachePut; | ||
| import org.springframework.cache.annotation.Cacheable; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class FeedCacheHandler { | ||
|
|
||
| private static final int DEFAULT_CACHE_SIZE = 100; | ||
|
|
||
| private final FeedJpaRepository feedJpaRepository; | ||
| private final CacheManager cacheManager; | ||
|
|
||
| @Cacheable(cacheNames = "feedIdTop", key = "'top' + " + DEFAULT_CACHE_SIZE) | ||
| public List<Long> getTopIds() { | ||
| return feedJpaRepository.findTopFeedIds(DEFAULT_CACHE_SIZE); | ||
| } | ||
|
|
||
| @Cacheable(cacheNames = "feedDetail", key = "#feedId") | ||
| public FeedQueryDto getFeedDetail(Long feedId) { | ||
| return feedJpaRepository.findFeedDetailById(feedId); | ||
| } | ||
|
|
||
| @CachePut(cacheNames = "feedDetail", key = "#feedId") | ||
| public FeedQueryDto markAsDeleted(Long feedId) { | ||
| return null; | ||
| } | ||
|
Comment on lines
+33
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
삭제 시 상위 ID 목록에서도 해당 ID를 제거하는 로직이 필요합니다. 제안: 삭제 시 top IDs에서도 제거- `@CachePut`(cacheNames = "feedDetail", key = "#feedId")
- public FeedQueryDto markAsDeleted(Long feedId) {
- return null;
- }
+ public void markAsDeleted(Long feedId) {
+ // 1. feedDetail 캐시에서 제거
+ evictFeedDetailIfPresent(feedId);
+
+ // 2. feedIdTop 캐시에서 해당 ID 제거
+ Cache cache = cacheManager.getCache("feedIdTop");
+ if (cache == null) return;
+
+ String cacheKey = "top" + DEFAULT_CACHE_SIZE;
+ List<Long> currentIds = cache.get(cacheKey, List.class);
+ if (currentIds != null && currentIds.contains(feedId)) {
+ List<Long> updatedIds = new ArrayList<>(currentIds);
+ updatedIds.remove(feedId);
+ cache.put(cacheKey, updatedIds);
+ }
+ }🤖 Prompt for AI Agents |
||
|
|
||
| public void updateTopIdsWithNewId(Long newId) { | ||
| Cache cache = cacheManager.getCache("feedIdTop"); | ||
| if (cache == null) return; | ||
|
|
||
| String cacheKey = "top" + DEFAULT_CACHE_SIZE; | ||
| List<Long> currentIds = cache.get(cacheKey, List.class); | ||
|
|
||
| if (currentIds == null) { | ||
| currentIds = feedJpaRepository.findTopFeedIds(DEFAULT_CACHE_SIZE); | ||
| } | ||
|
|
||
| List<Long> updatedIds = new ArrayList<>(currentIds); | ||
| updatedIds.add(0, newId); // 최신 피드를 맨 앞으로 | ||
|
|
||
| if (updatedIds.size() > DEFAULT_CACHE_SIZE) { | ||
| updatedIds.remove(DEFAULT_CACHE_SIZE); | ||
| } | ||
|
|
||
| cache.put(cacheKey, updatedIds); | ||
| } | ||
|
Comment on lines
+38
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
여러 이벤트 핸들러가 예시: 간단한 동기화 적용+ private final Object topIdsLock = new Object();
+
public void updateTopIdsWithNewId(Long newId) {
Cache cache = cacheManager.getCache("feedIdTop");
if (cache == null) return;
- String cacheKey = "top" + DEFAULT_CACHE_SIZE;
- List<Long> currentIds = cache.get(cacheKey, List.class);
-
- if (currentIds == null) {
- currentIds = feedJpaRepository.findTopFeedIds(DEFAULT_CACHE_SIZE);
- }
-
- List<Long> updatedIds = new ArrayList<>(currentIds);
- updatedIds.add(0, newId);
-
- if (updatedIds.size() > DEFAULT_CACHE_SIZE) {
- updatedIds.remove(DEFAULT_CACHE_SIZE);
- }
-
- cache.put(cacheKey, updatedIds);
+ synchronized (topIdsLock) {
+ String cacheKey = "top" + DEFAULT_CACHE_SIZE;
+ List<Long> currentIds = cache.get(cacheKey, List.class);
+
+ if (currentIds == null) {
+ currentIds = feedJpaRepository.findTopFeedIds(DEFAULT_CACHE_SIZE);
+ }
+
+ List<Long> updatedIds = new ArrayList<>(currentIds);
+ updatedIds.add(0, newId);
+
+ if (updatedIds.size() > DEFAULT_CACHE_SIZE) {
+ updatedIds.remove(DEFAULT_CACHE_SIZE);
+ }
+
+ cache.put(cacheKey, updatedIds);
+ }
}🤖 Prompt for AI Agents |
||
|
|
||
| public boolean evictFeedDetailIfPresent(Long feedId) { | ||
| Cache cache = cacheManager.getCache("feedDetail"); | ||
| if (cache == null) return false; | ||
|
|
||
| Cache.ValueWrapper valueWrapper = cache.get(feedId); | ||
| if (valueWrapper != null) { | ||
| cache.evict(feedId); | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| public void warmUpFeedDetails(List<Long> feedIds) { | ||
| Cache detailCache = cacheManager.getCache("feedDetail"); | ||
| if (detailCache == null || feedIds.isEmpty()) return; | ||
|
|
||
| List<FeedQueryDto> details = feedJpaRepository.findFeedDetailsByIds(feedIds); | ||
|
|
||
| for (FeedQueryDto dto : details) { | ||
| detailCache.put(dto.feedId(), dto); | ||
| } | ||
| } | ||
|
|
||
| public void refreshCacheAfterBulkDelete(List<Long> deletedFeedIds) { | ||
| Cache cache = cacheManager.getCache("feedIdTop"); | ||
| if (cache == null) return; | ||
|
|
||
| // 1. 현재 캐시된 인덱스 확보 | ||
| String cacheKey = "top" + DEFAULT_CACHE_SIZE; | ||
| List<Long> currentIds = cache.get(cacheKey, List.class); | ||
| if (currentIds == null) { | ||
| return; | ||
| } | ||
| // 2. 포함 여부 확인 | ||
| boolean hasTopFeed = deletedFeedIds.stream().anyMatch(currentIds::contains); | ||
|
|
||
| if (hasTopFeed) { | ||
| // 3. 기존 리스트에서 삭제 대상 제거 | ||
| List<Long> oldIdsWithoutDeleted = new ArrayList<>(currentIds); | ||
| oldIdsWithoutDeleted.removeAll(deletedFeedIds); | ||
|
|
||
| // 4. 상세 캐시에서 탈퇴자 피드 제거 | ||
| evictFeeds(deletedFeedIds); | ||
|
|
||
| // 5. 인덱스 강제 갱신 | ||
| List<Long> newTopIds = feedJpaRepository.findTopFeedIds(DEFAULT_CACHE_SIZE); | ||
| cache.put(cacheKey, newTopIds); | ||
|
|
||
| // 6. 신규 진입 ID 추출 | ||
| List<Long> newlyAddedIds = new ArrayList<>(newTopIds); | ||
| newlyAddedIds.removeAll(oldIdsWithoutDeleted); | ||
|
|
||
| // 7. 신규 진입 피드들만 정밀 워밍업 | ||
| warmUpFeedDetails(newlyAddedIds); | ||
| } | ||
| } | ||
|
|
||
| private void evictFeeds(List<Long> feedIds) { | ||
| Cache cache = cacheManager.getCache("feedDetail"); | ||
| if (cache != null && feedIds != null) { | ||
| feedIds.forEach(cache::evict); | ||
| } | ||
| } | ||
|
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
주석과 실제 설정값 불일치
주석에 기술된 사용자 수와 실제 설정값이 세 곳에서 맞지 않습니다.
MAX_VUS = 500target: 200target: MAX_VUS (500)✏️ 수정 제안
🤖 Prompt for AI Agents