Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e6e361c
[refactor] 피드 전체조회시 커서 postId로 변경 (#345)
hd0rable Feb 24, 2026
28b6033
[feat] Caffeine Cache 의존성 추가 (#345)
hd0rable Feb 24, 2026
5c10545
[feat] CacheConfig 설정 파일 추가 (#345)
hd0rable Feb 24, 2026
8aa7dd3
[feat] CacheType enum 추가 (#345)
hd0rable Feb 24, 2026
759d348
[feat] 이벤트 기반 피드 캐시 갱신 리스너 추가
hd0rable Feb 24, 2026
ce43b25
[feat] 피드관련 캐시 전담 FeedCacheHandler 구현
hd0rable Feb 24, 2026
9414636
[feat] 애플리케이션 기동 시 피드 캐시 워밍업 기능 추가
hd0rable Feb 24, 2026
6bfa843
[refactor] deleteAllFeedByUserId 시List<Long> 반환하도록 수정
hd0rable Feb 24, 2026
b9adce9
[refactor] deleteAllFeedByUserId 시List<Long> 반환하도록 수정
hd0rable Feb 24, 2026
ea1e8b3
[feat] 피드 생성 이벤트 정의
hd0rable Feb 24, 2026
94ae92a
[feat] 피드 삭제 이벤트 정의
hd0rable Feb 24, 2026
c19c9b4
[feat] 회원 탈퇴 이벤트 정의
hd0rable Feb 24, 2026
27b9c8b
[refactor] 피드 생성 후 캐시 갱신 이벤트 발행 로직 추가
hd0rable Feb 24, 2026
94e26b7
[refactor] 피드 삭제 후 캐시 갱신 이벤트 발행 로직 추가
hd0rable Feb 24, 2026
e10c383
[refactor] 회원 탈퇴 후 캐시 갱신 이벤트 발행 로직 추가
hd0rable Feb 24, 2026
764cebe
[feat] 피드 조회 캐시 우선 처리용 FeedQueryCacheAdapter 추가
hd0rable Feb 24, 2026
89c9825
[refactor] 피드 전체조회시 커서 postId로 변경
hd0rable Feb 24, 2026
a66654b
[refactor] 피드 전체조회시 커서 postId로 변경
hd0rable Feb 24, 2026
080c778
[refactor] 피드 캐싱 로직 추가관련 조회메서드 정의
hd0rable Feb 24, 2026
a041185
[refactor] 피드 캐싱 로직 추가관련 조회메서드 구현
hd0rable Feb 24, 2026
ee6787e
[refactor] @ EnableCaching 추가
hd0rable Feb 24, 2026
4b5e2a9
[test] 피드 전체 조회 캐시 어댑터 통합 테스트 코드 추가
hd0rable Feb 24, 2026
78cf95b
[test] 피드 전체 조회 Persistence 어댑터 통합 테스트 코드 수정
hd0rable Feb 24, 2026
bd4cc2c
[test] 피드 전체 조회 캐싱 도입 k6 부하테스트 스크립트 추가
hd0rable Feb 24, 2026
cb550de
[test] FeedCacheEventListener 단위 테스트 작성
hd0rable Feb 24, 2026
f67605c
[test] FeedCacheHandlerTest 단위 테스트 작성
hd0rable Feb 24, 2026
05a0916
[test] FeedCacheWarmupListener 단위 테스트 작성
hd0rable Feb 24, 2026
873db2b
[test] 피드 생성 통합테스트에 캐시 갱신 로직 검증 추가
hd0rable Feb 24, 2026
e1ac508
[test] 피드 삭제 통합테스트에 캐시 갱신 로직 검증 추가
hd0rable Feb 24, 2026
1094fdb
[test] 피드 수정 통합테스트에 캐시 갱신 로직 검증 추가
hd0rable Feb 24, 2026
dceca3f
[test] 회원 탈퇴 통합테스트에 캐시 갱신 로직 검증 추가
hd0rable Feb 24, 2026
b6e863d
[test] FeedQueryCacheAdapterTest 단위 테스트 작성
hd0rable Feb 24, 2026
06299cc
[test]
hd0rable Feb 24, 2026
86886de
[test] 커서변경으로 테스트코드 수정
hd0rable Feb 24, 2026
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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ dependencies {

// spring Retry
implementation 'org.springframework.retry:spring-retry'

// Caffeine Cache
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'
}

def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile
Expand Down
69 changes: 69 additions & 0 deletions loadtest/feed/feed_home_scrolling_test.js
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'],
},
};
Comment on lines +1 to +18
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 1 "100명의 사용자" MAX_VUS = 500
Line 10 "100명까지 증가" target: 200
Line 11 "300명까지 증가" target: MAX_VUS (500)
✏️ 수정 제안
-//100명의 사용자가 동시에 각자 다른 속도로 홈 피드를 1페이지부터 3페이지까지 탐색하는 시나리오
+//최대 500명의 사용자가 동시에 각자 다른 속도로 홈 피드를 1페이지부터 3페이지까지 탐색하는 시나리오
 ...
-        { duration: '20s', target: 200 }, // 20초 동안 100명까지 증가
-        { duration: '40s',  target: MAX_VUS }, // 40초 동안 300명까지 증가하며 피크 부하
+        { duration: '20s', target: 200 }, // 20초 동안 200명까지 증가
+        { duration: '40s',  target: MAX_VUS }, // 40초 동안 500명까지 증가하며 피크 부하
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@loadtest/feed/feed_home_scrolling_test.js` around lines 1 - 18, Update the
misleading comments to match the actual constants and stage targets: change the
top-line comment referencing "100명의 사용자" to reflect MAX_VUS = 500 (or set
MAX_VUS to 100 if you prefer to keep the comment), and update the stage comments
near the options.stages entries to correctly describe target: 200 ("200명까지 증가")
and target: MAX_VUS (500) ("500명까지 증가"); refer to the MAX_VUS constant and the
options object (stages -> target) when making the edits so comments and code
remain consistent.


// 테스트 전 사용자 별 토큰 발급
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
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

setup()에서 토큰 발급 실패 시에도 tokens에 그대로 push됩니다

check()는 k6 메트릭 기록용이며 실행을 중단하거나 push를 건너뛰지 않습니다. 토큰 요청이 실패(비-200 또는 네트워크 오류)할 경우 오류 응답 본문이 토큰으로 사용되어, 해당 VU의 모든 요청이 401로 실패하고 테스트 결과가 오염됩니다.

또한 res.body를 그대로 사용하는데, API가 JSON({"token":"..."} 형태)을 반환한다면 Authorization: Bearer {...} 형태가 되어 인증이 깨집니다. API 응답 형식을 확인하고, JSON이라면 파싱이 필요합니다.

🛡️ 수정 제안
 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
Verify each finding against the current code and only fix it if needed.

In `@loadtest/feed/feed_home_scrolling_test.js` around lines 20 - 32, The setup()
function currently pushes res.body into tokens even when the request failed or
returned a non-string JSON; change it to validate each HTTP response before
pushing: for each userId (1..MAX_VUS) call http.get(BASE_URL +
'/api/test/token/access?userId=' + userId), check res.status === 200 and that
the response body contains a usable token (if the API returns JSON like
{"token":"..."}, parse JSON and extract the token field); only push the
extracted token string into tokens when validation succeeds, otherwise log/throw
or skip pushing so failed responses (non-200 or network errors) are not stored;
ensure callers expecting "Authorization: Bearer <token>" receive the raw token
string to format the header later.


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
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

"정확히 3번" 주석이 실제 동작과 다릅니다

Line 47의 주석은 "모든 유저가 정확히 3번의 요청(1~3페이지) 수행"이라고 명시하지만, Line 61의 early break 조건(isLast || !currentCursor)으로 인해 3회 미만으로 종료될 수 있습니다.

✏️ 수정 제안
-    // 모든 유저가 정확히 3번의 요청(1~3페이지) 수행
+    // 최대 3번의 요청(1~3페이지) 수행, 데이터가 끝나면 조기 종료
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@loadtest/feed/feed_home_scrolling_test.js` around lines 47 - 68, The top
comment is inaccurate: the for-loop (for (let i = 1; i <= 3; i++)) with the
early-exit check on responseData.isLast || !currentCursor can result in fewer
than 3 requests; update the comment to say "최대 3번의 요청(1~3페이지) 수행" or similar to
reflect that currentCursor and responseData.isLast may end the loop early
(references: currentCursor, responseData, responseData.isLast, the for-loop).

}
2 changes: 2 additions & 0 deletions src/main/java/konkuk/thip/ThipServerApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableCaching
@EnableJpaAuditing
@EnableScheduling
@ConfigurationPropertiesScan
Expand Down
45 changes: 45 additions & 0 deletions src/main/java/konkuk/thip/config/cache/CacheConfig.java
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();
}
}
23 changes: 23 additions & 0 deletions src/main/java/konkuk/thip/config/cache/CacheType.java
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
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

예외 로깅 시 스택 트레이스가 누락됩니다.

e.getMessage()만 기록하면 예외의 근본 원인을 파악하기 어렵습니다. 두 번째 인자로 예외 객체를 전달해 주세요.

수정 제안
-            log.error("캐시 워밍업 중 오류가 발생했습니다: {}", e.getMessage());
+            log.error("캐시 워밍업 중 오류가 발생했습니다: {}", e.getMessage(), e);
📝 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
} catch (Exception e) {
log.error("캐시 워밍업 중 오류가 발생했습니다: {}", e.getMessage());
}
} catch (Exception e) {
log.error("캐시 워밍업 중 오류가 발생했습니다: {}", e.getMessage(), e);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListener.java`
around lines 38 - 40, The catch block in FeedCacheWarmupListener (inside the
method handling cache warmup) logs only e.getMessage(), losing the stack trace;
change the log.error call to pass the exception object as the second parameter
(e.g., use log.error("캐시 워밍업 중 오류가 발생했습니다", e)) so the full stack trace is
recorded for FeedCacheWarmupListener's error handling.

}
}
123 changes: 123 additions & 0 deletions src/main/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandler.java
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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

markAsDeleted는 feedDetail을 null로 캐싱하지만, 상위 ID 목록(feedIdTop)에서 해당 ID를 제거하지 않습니다.

FeedCacheEventListener.handleFeedDeletedEventmarkAsDeleted만 호출하므로, 삭제된 피드의 ID가 feedIdTop 캐시에 계속 남아있게 됩니다. 이후 조회 시 해당 ID의 상세 데이터가 null로 반환되어, 클라이언트에 빈 피드가 노출되거나 NPE가 발생할 수 있습니다.

삭제 시 상위 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
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandler.java`
around lines 33 - 36, The current markAsDeleted(Long feedId) only writes a null
into the "feedDetail" cache and does not remove the deleted ID from the
"feedIdTop" cache, so FeedCacheEventListener.handleFeedDeletedEvent leaving it
causes stale IDs to remain; update markAsDeleted to (1) keep the `@CachePut` for
"feedDetail" but also remove the feedId from the top-ID cache (feedIdTop) —
either by invoking a cache operation to evict that id from feedIdTop or by
loading, filtering and writing back the updated top-ID list; locate
markAsDeleted and modify it to perform the additional eviction/update to
feedIdTop so deleted IDs are not returned later.


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
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

updateTopIdsWithNewId의 read-modify-write 패턴에 동시성 보호가 없습니다.

여러 FeedCreatedEvent가 동시에 처리될 경우, 두 스레드가 동일한 currentIds를 읽은 뒤 각각 새 ID를 추가하고 cache.put하면 먼저 쓴 쪽의 업데이트가 유실됩니다. refreshCacheAfterBulkDelete에도 같은 패턴이 존재합니다.

이벤트 핸들러가 @Async이거나 동시 트랜잭션 커밋이 겹칠 수 있는 환경이라면, 최소한 캐시 키 단위 동기화(synchronized 블록 또는 ReentrantLock)를 고려해 주세요.

예시: 간단한 동기화 적용
+    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
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandler.java`
around lines 38 - 57, The read-modify-write in updateTopIdsWithNewId (and
likewise in refreshCacheAfterBulkDelete) needs cache-key-level synchronization
to avoid lost updates: introduce a per-cache-key lock (e.g., a
ConcurrentHashMap<String, ReentrantLock> or similar) and acquire the lock for
the computed cacheKey ("top" + DEFAULT_CACHE_SIZE) before calling cache.get,
mutating the List, and cache.put, ensuring the lock is released in a finally
block; apply the same locking pattern to refreshCacheAfterBulkDelete so both
methods serialize modifications to the same cache key and avoid race conditions
with feedJpaRepository-backed fallback reads.


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);
}
}

}
Loading