Skip to content

[Refactor] 피드 도메인 캐싱 도입 & 피드 전체 조회 api 캐싱 로직 추가#349

Open
hd0rable wants to merge 34 commits intodevelopfrom
refactor/#345-get-feed
Open

[Refactor] 피드 도메인 캐싱 도입 & 피드 전체 조회 api 캐싱 로직 추가#349
hd0rable wants to merge 34 commits intodevelopfrom
refactor/#345-get-feed

Conversation

@hd0rable
Copy link
Member

@hd0rable hd0rable commented Feb 24, 2026

#️⃣ 연관된 이슈

closes #345

📝 작업 내용

게시글 조회 성능 및 시스템 안정성을 위해 전체 피드 조회 로직을 로컬 캐시 기반 이벤트 드리븐 아키텍처로 리팩토링했습니다.

  • 메인 피드 조회의 DB Round Trip 제거: 상위 N개 피드는 JVM 메모리(feedIdTop/feedDetail)에 캐싱하여 즉시 응답 가능
  • 캐시 미스 또는 N개 밖 조회 시에만 DB 접근, 커서 기반 페이징 유지
  • 피드 생성/수정/삭제 시 이벤트(FeedCreatedEvent/FeedUpdatedEvent/FeedDeletedEvent)를 발행하여 캐시를 비동기로 갱신
  • 애플리케이션 구동 시 FeedCacheWarmupListener로 상위 피드 데이터를 선제적 적재
  • 상위 피드 데이터 웜업 작업을 특저환경에서 분기적으로 적용하기위해 yml에 관련 설정이 업데이트되었습니다. 노션에 작성해두었습니다!
  • Caffeine Cache 적용, Window TinyLFU 기반 자동 eviction, 개수 제한 및 이벤트 기반 무효화로 메모리 안정성 확보
  • 서비스 계층 코드 수정 없이 캐시 적용 가능하도록 FeedQueryCacheAdapter 구현, @primary 어댑터로 주입
  • 기존 피드 조회시 기존에 걸려있던 createdAt + 복합커서를 postId의 단일커서로 수정
  • 커서키 변경으로인한 기존 조회 테스트코드 수정 및, 캐싱로직 도입으로 인한 관련 테스트들도 수정하였습니다.
  • 관련해서 노션에 문서화해두었습니다. 트러블슈팅 -> 조회 성능 개선 -> 피드 조회 성능 개선 : 2. 캐싱 도입

📸 스크린샷

💬 리뷰 요구사항

  • 현재 이벤트 기반 캐시 갱신 과정에서 이벤트 유실/재처리에 대한 부분은 고려하지않았습니다. 현재 진행된 리팩토링을 기준으로 코드 리뷰 부탁드립니다! 이후 리뷰 및 피드백을 바탕으로 이벤트 유실/재처리까지 포함한 개선 작업 진행해보겠습니다

📌 PR 진행 시 이러한 점들을 참고해 주세요

* P1 : 꼭 반영해 주세요 (Request Changes) - 이슈가 발생하거나 취약점이 발견되는 케이스 등
* P2 : 반영을 적극적으로 고려해 주시면 좋을 것 같아요 (Comment)
* P3 : 이런 방법도 있을 것 같아요~ 등의 사소한 의견입니다 (Chore)

Summary by CodeRabbit

릴리스 노트

  • New Features

    • 피드 조회 시 캐싱 기능 추가로 성능 개선
    • 피드 페이지네이션이 시간 기반에서 ID 기반으로 변경
    • 애플리케이션 시작 시 자동 캐시 초기화
  • Tests

    • 캐싱 동작 검증을 위한 통합 및 단위 테스트 추가
    • 피드 생성/수정/삭제 시 캐시 업데이트 검증 테스트 추가
  • Chores

    • 캐싱 라이브러리 의존성 추가
    • 부하 테스트 스크립트 추가

- FeedCreatedEvent, FeedUpdatedEvent, FeedDeletedEvent, UserWithdrawnEvent 처리
- 트랜잭션 커밋 이후 캐시 갱신하도록 TransactionalEventListener 적용

 (#345)
- feedIdTop 및 feedDetail 캐시 조회, 갱신, 삭제 기능
- 신규 피드 추가 시 topId 리스트 업데이트 및 상세 캐시 적재
- 피드 수정 시 feedDetail 캐시 무효화 후 재적재
- 피드 삭제 및 사용자 탈퇴 시 관련 캐시 일괄 제거 및 인덱스 갱신
- 캐시 워밍업 기능

 (#345)
- ApplicationReadyEvent 수신 시 상위 피드 ID 및 상세 데이터 캐시 적재
(#345)
-FeedCacheHandler를 이용해 상위 피드 ID와 개별 피드 상세 캐시 우선 조회
-캐시 범위 벗어나거나 데이터 없을 경우 DB(FeedQueryPersistenceAdapter)로 위임

(#345)
-findTopFeedIds
-findFeedDetailById
-findFeedDetailsByIds
(#345)
-findTopFeedIds
-findFeedDetailById
-findFeedDetailsByIds
(#345)
@coderabbitai
Copy link

coderabbitai bot commented Feb 24, 2026

Walkthrough

스프링 캐시 인프라를 도입하여 피드 조회 성능을 개선하고, 피드 생성/수정/삭제 시 캐시 무효화 이벤트를 발행하는 구조를 구축했습니다. Caffeine 기반 두 개의 캐시(상위 피드 ID, 피드 상세)와 이벤트 기반 캐시 관리 체계를 추가했으며, 페이지 네이션 방식을 생성시간 기반에서 피드 ID 기반으로 변경했습니다.

Changes

Cohort / File(s) Summary
캐시 설정 및 인프라
build.gradle, src/main/java/konkuk/thip/ThipServerApplication.java, src/main/java/konkuk/thip/config/cache/CacheConfig.java, src/main/java/konkuk/thip/config/cache/CacheType.java
스프링 캐시 및 Caffeine 라이브러리 의존성 추가, @EnableCaching 적용, Caffeine 기반 캐시 매니저 구성 및 두 개의 캐시 타입(feedIdTop, feedDetail) 정의
캐시 이벤트 리스너
src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListener.java, src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListener.java
트랜잭션 후 피드 이벤트에 반응하는 캐시 업데이트 리스너 및 애플리케이션 시작 시 캐시 사전 로드 리스너 추가
캐시 처리 계층
src/main/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandler.java, src/main/java/konkuk/thip/feed/adapter/out/cache/FeedQueryCacheAdapter.java
피드 캐싱 로직 중앙 관리 핸들러 및 캐시 레이어를 통한 쿼리 포트 구현 추가
피드 이벤트 정의
src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedCreatedEvent.java, src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedDeletedEvent.java, src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedUpdatedEvent.java, src/main/java/konkuk/thip/user/adapter/out/event/dto/UserWithdrawnEvent.java
피드 생성/수정/삭제 및 사용자 탈퇴 이벤트 레코드 정의
페이지 네이션 방식 변경
src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java, src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java, src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java, src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java, src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java
생성시간 기반(lastCreatedAt) 페이지 네이션을 피드 ID 기반(lastPostId)으로 변경, 새로운 상세 조회 메서드 추가, deleteAllFeedByUserId 반환 타입을 void에서 List\<Long\>으로 변경
서비스 계층 이벤트 발행
src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java, src/main/java/konkuk/thip/feed/application/service/FeedDeleteService.java, src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java, src/main/java/konkuk/thip/user/application/service/UserDeleteService.java, src/main/java/konkuk/thip/feed/application/service/BasicFeedShowAllService.java
피드 생성/수정/삭제 및 사용자 삭제 시 캐시 무효화 이벤트 발행, 메서드 호출명 변경(findLatestFeedsByCreatedAt → findLatestFeedsByFeedId)
데이터 접근 계층 변경
src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java
deleteAllFeedByUserId 메서드가 삭제된 피드 ID 목록 반환하도록 변경
캐시 기능 테스트
src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListenerTest.java, src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListenerTest.java, src/test/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandlerTest.java, src/test/java/konkuk/thip/feed/adapter/out/cache/FeedQueryCacheAdapterTest.java
캐시 이벤트 리스너, 워밍업, 핸들러 및 캐시 어댑터의 단위 테스트 추가
통합 테스트 업데이트
src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllCacheApiTest.java, src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllPersistenceApiTest.java, src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java, src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java, src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java, src/test/java/konkuk/thip/feed/adapter/in/web/FollowingPriorityFeedShowAllApiTest.java, src/test/java/konkuk/thip/user/adapter/in/web/UserDeleteApiTest.java
캐시 기반 조회 테스트 추가, 페이지 네이션 변경 반영, 캐시 상태 검증 테스트 추가
부하 테스트
loadtest/feed/feed_home_scrolling_test.js
k6 기반 피드 홈 스크롤 부하 테스트 스크립트 추가
테스트 유틸리티
src/test/java/konkuk/thip/common/util/TestEntityFactory.java, src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java
동적 oauth2Id 생성 및 테스트 데이터 생성 방식 변경

Sequence Diagram

sequenceDiagram
    participant Client
    participant FeedController
    participant FeedQueryCacheAdapter
    participant CacheManager
    participant FeedQueryPersistenceAdapter
    participant Database

    Client->>FeedController: GET /feeds?cursor=...
    FeedController->>FeedQueryCacheAdapter: findLatestFeedsByFeedId(userId, cursor)
    FeedQueryCacheAdapter->>CacheManager: getCache("feedIdTop").get(key)
    alt Cache Hit - Sufficient Data
        CacheManager-->>FeedQueryCacheAdapter: topIds[...]
        FeedQueryCacheAdapter->>CacheManager: getCache("feedDetail").get(feedId)
        CacheManager-->>FeedQueryCacheAdapter: FeedQueryDto[...]
        FeedQueryCacheAdapter-->>FeedController: CursorBasedList<FeedQueryDto>
    else Cache Miss or Insufficient Data
        FeedQueryCacheAdapter->>FeedQueryPersistenceAdapter: findLatestFeedsByFeedId(userId, lastPostId, size)
        FeedQueryPersistenceAdapter->>Database: SELECT feeds WHERE ... ORDER BY feedId DESC
        Database-->>FeedQueryPersistenceAdapter: FeedQueryDto[...]
        FeedQueryPersistenceAdapter-->>FeedQueryCacheAdapter: CursorBasedList<FeedQueryDto>
        FeedQueryCacheAdapter-->>FeedController: CursorBasedList<FeedQueryDto>
    end
    FeedController-->>Client: 200 OK + Feed List JSON

    Client->>FeedController: POST /feeds (create feed)
    FeedController->>FeedCreateService: createFeed(request)
    FeedCreateService->>Database: INSERT feed
    FeedCreateService->>ApplicationEventPublisher: publishEvent(FeedCreatedEvent)
    ApplicationEventPublisher->>FeedCacheEventListener: handleFeedCreatedEvent(event)
    FeedCacheEventListener->>FeedCacheHandler: updateTopIdsWithNewId(feedId)
    FeedCacheHandler->>CacheManager: getCache("feedIdTop").put(...)
    FeedCacheEventListener->>FeedCacheHandler: warmUpFeedDetails([feedId])
    FeedCacheHandler->>CacheManager: getCache("feedDetail").put(feedId, detail)
    FeedCacheHandler-->>FeedCacheEventListener: done
    FeedCacheEventListener-->>FeedCreateService: done
    FeedCreateService-->>FeedController: FeedQueryDto
    FeedController-->>Client: 201 Created + Feed JSON
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60분

Possibly related PRs

Suggested labels

🧸 현준, 👻 성준

Suggested reviewers

  • seongjunnoh
  • buzz0331

Poem

🐰 캐시의 마법으로 피드는 빠르고,
이벤트의 파도가 캐시를 휩쓸고,
상위 ID들이 춤을 추며 정렬되고,
부하 테스트도 환호하는, 성능 개선의 축제! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 피드 도메인 캐싱 도입과 전체 조회 API 캐싱 로직 추가라는 주요 변경 사항을 명확하게 요약하고 있습니다.
Linked Issues check ✅ Passed PR은 연결된 이슈 #345의 모든 코딩 요구사항을 충족합니다: 커서 키를 postId로 변경, 부하테스트 스크립트 추가, Caffeine 캐싱으로 성능 개선 구현.
Out of Scope Changes check ✅ Passed 모든 변경사항은 피드 캐싱 도입 목표와 직접 관련되어 있으며, 캐시 설정, 이벤트 핸들러, 캐시 적응자, 테스트 코드 등이 범위 내에 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/#345-get-feed

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link

Test Results

514 tests   514 ✅  46s ⏱️
150 suites    0 💤
150 files      0 ❌

Results for commit 86886de.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java (1)

103-122: ⚠️ Potential issue | 🔴 Critical

null 반환 대신 빈 리스트를 반환하세요.

Line 108에서 피드가 없을 때 null을 반환하면 호출 측에서 null 체크 없이 직접 사용될 위험이 있습니다. UserDeleteService 84줄에서 deletedFeedIds를 받은 후 97줄의 이벤트 발행 시 null 체크 없이 전달되고 있으므로, NPE 가능성이 존재합니다. List.of() 또는 Collections.emptyList()를 반환하는 것이 안전합니다. 또한 Line 104와 Line 106에 동일한 주석이 중복되어 있습니다.

수정 제안
     public List<Long> deleteAllFeedByUserId(Long userId) {
         // 1. 유저가 작성한 피드 게시글 ID 리스트 조회
         List<Long> feedIds = feedJpaRepository.findFeedIdsByUserId(userId);
-        // 1. 유저가 작성한 피드 게시글 ID 리스트 조회
         if (feedIds == null || feedIds.isEmpty()) {
-            return null; // early return
+            return List.of(); // early return
         }
🤖 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/persistence/FeedCommandPersistenceAdapter.java`
around lines 103 - 122, In deleteAllFeedByUserId (FeedCommandPersistenceAdapter)
replace the null early-return with returning an empty immutable list (e.g.,
List.of() or Collections.emptyList()) so callers like UserDeleteService can
safely iterate without NPE; also remove the duplicated comment above feedIds
retrieval so the method has a single correct comment for step 1.
♻️ Duplicate comments (1)
src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListener.java (1)

25-28: handleFeedDeletedEventmarkAsDeleted만 호출하여 feedIdTop 캐시에 삭제된 피드 ID가 잔존합니다.

이 문제의 근본 원인은 FeedCacheHandler.markAsDeleted에서 상위 ID 목록을 갱신하지 않는 것이며, 해당 파일 리뷰에서 상세히 기술했습니다. 이 핸들러에서도 top IDs 갱신 로직 추가를 검토해 주세요.

🤖 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/FeedCacheEventListener.java`
around lines 25 - 28, handleFeedDeletedEvent only calls
feedCacheHandler.markAsDeleted which leaves the feedIdTop cache stale; after
calling markAsDeleted you must also update the top-IDs cache—add a call like
feedCacheHandler.removeFromTopIds(event.feedId()) (or
feedCacheHandler.refreshTopIds()/updateTopIdsAfterDeletion(event.feedId())) and
implement that method in FeedCacheHandler to remove the deleted feed ID from the
top IDs cache and persist the updated top list.
🧹 Nitpick comments (22)
loadtest/feed/feed_home_scrolling_test.js (2)

34-43: token이 null/undefined일 경우 별도 처리가 없습니다

setup()에서 특정 userId의 토큰 발급이 실패하면 해당 VU(vuIdx)는 유효하지 않은 토큰으로 모든 요청을 보내게 됩니다. VU 시나리오 초반에 토큰 유효성을 확인하고 조기 종료하면 실패 노이즈를 줄일 수 있습니다.

✏️ 수정 제안
 export default function (data) {
     const vuIdx = __VU - 1;
     const token = data.tokens[vuIdx];
 
+    if (!token) {
+        console.warn(`VU ${__VU}: no valid token, skipping scenario`);
+        return;
+    }
+
     const params = {
🤖 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 34 - 43, The test
does not handle a missing/invalid token (token from data.tokens[vuIdx]) so a VU
may send unauthorized requests; before building params in the default exported
function, check if token is null/undefined and bail out early: detect token
using the existing vuIdx/token variables and either abort the VU scenario (throw
or return) or call the appropriate k6 stop/sleep to avoid sending requests, and
log the failure for visibility; update any logic that uses params/headers (the
params object and Authorization header construction) to only run when token is
present.

14-17: p(95)<80ms 임계값은 500 VU 부하 시나리오에서 과도하게 엄격할 수 있습니다

로컬 환경(localhost:8000)에서 캐시 미스, DB 접근, JVM GC 등을 고려하면 500 VU 피크 부하 시 p95 80ms 달성이 어려울 수 있으며, 테스트 자체가 항상 실패로 기록되어 실질적인 성능 비교 지표로서의 의미가 희석될 수 있습니다. 캐시 도입 전/후 비교 측정이 목적이라면, 현실적인 기준선을 먼저 측정 후 임계값을 설정하는 것을 권장합니다.

🤖 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 14 - 17, 현재
thresholds 객체의 http_req_duration: ['p(95)<80'] 임계값은 500 VU 시나리오에서 과도하게 엄격해 테스트가
항상 실패할 수 있으니, feed_home_scrolling_test.js의 thresholds.http_req_duration 설정을 현실적인
값으로 완화하거나(예: p(95)<200ms 또는 환경변수로 조정 가능하게) 임계값을 제거해 먼저 베이스라인을 측정하도록 변경하세요; 수정할
위치는 파일의 thresholds 블록(키명: http_req_duration, http_req_failed)이며 필요하면 환경변수 기반
구성(예: PROCESS_ENV 또는 옵션으로 전달)으로 만들고 문서에 새로운 기본값을 기록해 주세요.
src/test/java/konkuk/thip/feed/adapter/in/web/FollowingPriorityFeedShowAllApiTest.java (2)

21-22: 수동 타임스탬프 조작 코드 제거 후 남은 불필요한 임포트·필드를 정리해야 합니다.

JdbcTemplate 주입(Line 65–66) 및 Timestamp, LocalDateTime 임포트(Line 26–27), JdbcTemplate 임포트(Line 21)는 이전의 created_at 직접 수정 로직이 제거된 이후 사용되지 않습니다.

♻️ 제안하는 정리
-import org.springframework.jdbc.core.JdbcTemplate;
 ...
-import java.sql.Timestamp;
-import java.time.LocalDateTime;
 ...
-    `@Autowired`
-    private JdbcTemplate jdbcTemplate;

Also applies to: 26-27, 65-66

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/konkuk/thip/feed/adapter/in/web/FollowingPriorityFeedShowAllApiTest.java`
around lines 21 - 22, Remove leftover unused timestamp-manipulation artifacts in
FollowingPriorityFeedShowAllApiTest: delete the unused imports for JdbcTemplate,
java.sql.Timestamp, and java.time.LocalDateTime, and remove the injected
JdbcTemplate field (and any related unused fields) from the test class so the
compiler/IDE no longer reports unused imports/fields; search for symbols
JdbcTemplate, Timestamp, and LocalDateTime in
FollowingPriorityFeedShowAllApiTest to locate and delete the dead code.

247-251: 커서 형식을 하드코딩하면 형식 변경 시 테스트가 오동작할 수 있습니다.

FeedQueryPersistenceAdapter.findFeedsByFollowingPriority()에서는 CursorList.of("1", feedId)로 생성하여 인코딩하는데, 이 테스트는 같은 형식을 수동으로 조합하고 있습니다. 커서 포맷이 변경되면 테스트가 조용히 깨질 위험이 있습니다.

Cursor 또는 CursorUtils에 테스트 전용 팩토리 메서드를 추가하거나, 최소한 "1|" 포맷을 상수로 추출하여 변경 지점을 단일화하는 것을 검토하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/konkuk/thip/feed/adapter/in/web/FollowingPriorityFeedShowAllApiTest.java`
around lines 247 - 251, The test hardcodes the cursor string "1|"+f3.getPostId()
which will break if the Cursor encoding changes; update the test to build the
cursor using the Cursor/CursorUtils API instead of manual concatenation (e.g.,
call the existing Cursor factory/encoder used by
FeedQueryPersistenceAdapter.findFeedsByFollowingPriority or add a small
test-only factory method on Cursor/CursorUtils and use that to produce
nextCursor from List.of("1", f3.getPostId().toString())); replace the manual
nextCursor construction in FollowingPriorityFeedShowAllApiTest with that encoded
value so the test remains correct if the cursor format changes.
src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java (1)

29-29: 인터페이스 계약에 @Nullable 여부를 명시하거나, 구현체에서 빈 리스트를 반환하도록 보장하세요.

FeedCommandPersistenceAdapter에서 현재 null을 반환할 수 있으므로, 구현체 수정(이미 위에서 제안)과 함께 이 인터페이스의 반환값이 non-null임을 보장하는 것이 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java` at
line 29, The interface method FeedCommandPort.deleteAllFeedByUserId currently
allows a null return implicitly; either annotate the contract explicitly or make
it non-null. Update FeedCommandPort.deleteAllFeedByUserId to declare a non-null
contract (add your project’s `@NotNull/`@NonNull annotation or Javadoc stating
non-null) and ensure FeedCommandPersistenceAdapter.deleteAllFeedByUserId
implementation returns an empty List (e.g., Collections.emptyList()) instead of
null so callers can rely on a non-null List<Long>.
src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedUpdatedEvent.java (1)

1-8: 패키지 선언 앞의 빈 줄을 제거하세요.

Line 1에 불필요한 빈 줄이 있습니다. 다른 이벤트 DTO(FeedCreatedEvent, FeedDeletedEvent)와 일관성을 위해 제거하는 것이 좋습니다.

♻️ 수정 제안
-
 package konkuk.thip.feed.adapter.out.event.dto;
🤖 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/event/dto/FeedUpdatedEvent.java`
around lines 1 - 8, Remove the leading blank line before the package declaration
so the file starts immediately with "package
konkuk.thip.feed.adapter.out.event.dto;" to match the style of the other event
DTOs (FeedCreatedEvent, FeedDeletedEvent); update the top of the
FeedUpdatedEvent record to have no empty line above the package statement.
src/main/java/konkuk/thip/user/adapter/out/event/dto/UserWithdrawnEvent.java (1)

5-8: 팩토리 메서드 네이밍 불일치

FeedCreatedEvent.from(), FeedDeletedEvent.from() 등 다른 이벤트 DTO들이 from()을 사용하는 반면, 이 클래스만 of()를 사용하고 있습니다. 코드베이스 내 일관성을 위해 from()으로 통일하는 것을 고려해 주세요.

♻️ 네이밍 통일 제안
-    public static UserWithdrawnEvent of(Long userId, List<Long> deletedFeedIds) {
-        return new UserWithdrawnEvent(userId, deletedFeedIds);
-    }
+    public static UserWithdrawnEvent from(Long userId, List<Long> deletedFeedIds) {
+        return new UserWithdrawnEvent(userId, deletedFeedIds);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/user/adapter/out/event/dto/UserWithdrawnEvent.java`
around lines 5 - 8, The factory method in UserWithdrawnEvent is named of(),
which is inconsistent with other event DTOs like FeedCreatedEvent.from() and
FeedDeletedEvent.from(); rename the static method UserWithdrawnEvent.of(Long
userId, List<Long> deletedFeedIds) to UserWithdrawnEvent.from(Long userId,
List<Long> deletedFeedIds) and update any call sites to use
UserWithdrawnEvent.from(...) to keep factory naming consistent across the
codebase.
src/main/java/konkuk/thip/config/cache/CacheConfig.java (2)

33-43: caffeineBuilder 메서드명이 반환 타입과 불일치

메서드명 caffeineBuilderCaffeine<Object, Object> 빌더를 반환하는 것처럼 읽히지만 실제로는 .build()까지 호출한 완성된 Cache<Object, Object> 인스턴스를 반환합니다.

♻️ 메서드명 수정 제안
-    private Cache<Object, Object> caffeineBuilder(CacheType cacheType) {
+    private Cache<Object, Object> buildCaffeineCache(CacheType cacheType) {
-                        caffeineBuilder(cacheType)
+                        buildCaffeineCache(cacheType)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/config/cache/CacheConfig.java` around lines 33 -
43, The method caffeineBuilder returns a built Cache<Object,Object> but its name
implies it returns a Caffeine builder; rename the method caffeineBuilder to a
name that reflects it returns a Cache (e.g., buildCache, createCache, or
cacheForType) and update all callers accordingly; ensure the signature remains
private Cache<Object,Object> caffeineBuilder(...) → private Cache<Object,Object>
buildCache(...) (or chosen name) and keep the internal logic
(Caffeine.newBuilder()... .build()) unchanged so callers receive the constructed
Cache instance.

22-27: Collectors.toList() 대신 Stream.toList() 사용 권장

Java 16 이상 환경에서는 Stream.toList()가 더 간결하고 불변 리스트를 반환하는 관용적 표현입니다.

♻️ 리팩토링 제안
-        List<CaffeineCache> caches = Arrays.stream(CacheType.values())
-                .map(cacheType -> new CaffeineCache(
-                        cacheType.getCacheName(),
-                        caffeineBuilder(cacheType)
-                ))
-                .collect(Collectors.toList());
+        List<CaffeineCache> caches = Arrays.stream(CacheType.values())
+                .map(cacheType -> new CaffeineCache(
+                        cacheType.getCacheName(),
+                        buildCaffeineCache(cacheType)
+                ))
+                .toList();

(import java.util.stream.Collectors; 불필요해지므로 제거 가능)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/config/cache/CacheConfig.java` around lines 22 -
27, 현재 CacheConfig의 캐시 리스트 생성 코드에서
Arrays.stream(...).collect(Collectors.toList())를 사용하고 있는데 Java 16+ 환경에서는 더 간결하고
불변 리스트를 반환하는 Stream.toList()로 교체하세요: CacheConfig에서
Arrays.stream(CacheType.values()).map(...).toList()로 바꾸고 불필요해진 import
java.util.stream.Collectors를 제거하며 caffeineBuilder 및 CacheType.getCacheName 참조는
그대로 유지해 주세요.
src/main/java/konkuk/thip/config/cache/CacheType.java (2)

9-13: FEED_ID_TOP 주석과 maximumSize 불일치

주석에 "100개 인덱스 캐시"라고 명시되어 있으나 maximumSize=1입니다. 이는 캐시 엔트리 1개 안에 ID 리스트 전체를 담는 설계일 경우 올바르지만, 주석만 보면 100개의 캐시 엔트리가 있는 것으로 오해할 수 있습니다. 주석을 명확히 해주세요.

♻️ 주석 명확화 제안
-    // 최신순 피드 ID 100개 인덱스 캐시
-    FEED_ID_TOP(
-            "feedIdTop",
-            0,  // 만료 시간 없음
-            1), //
+    // 최신순 피드 ID 목록 전체를 단일 엔트리로 저장하는 인덱스 캐시 (maximumSize=1: 키 1개, 값은 ID 리스트)
+    FEED_ID_TOP(
+            "feedIdTop",
+            0,  // 만료 시간 없음
+            1),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/config/cache/CacheType.java` around lines 9 - 13,
FEED_ID_TOP의 주석과 실제 설정(maximumSize=1)이 불일치합니다; 주석을 수정하여 의도한 설계를 명확히 하세요 — 만약 설계가
"각 캐시 엔트리에 최신 100개 ID 리스트를 담는 단일 엔트리"라면 주석을 "최신 100개 ID를 하나의 캐시 엔트리에 저장"처럼 명확히
바꾸고, 반대로 의도가 "최신 피드 ID 100개를 각각의 캐시 엔트리로 보관"이라면 FEED_ID_TOP의 maximumSize를 100으로
변경하세요; 관련 식별자는 FEED_ID_TOP과 해당 maximumSize 설정입니다.

20-22: expireAfterWrite=0의 의미가 타입에서 드러나지 않음

int 타입과 0 값으로 "만료 없음"을 표현하는 것은 CacheConfig 구현 내부 컨벤션에 의존합니다. 향후 유지보수 시 다른 개발자가 0의 의미를 오해할 수 있습니다.

♻️ 명시적 표현 제안 (선택 사항)
-    private final int expireAfterWrite;
+    private final long expireAfterWriteSeconds; // 0 이하: 만료 없음(무한)

또는 Java Duration을 사용해 null을 "만료 없음"으로 표현하는 방법도 고려할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/config/cache/CacheType.java` around lines 20 - 22,
The field expireAfterWrite in CacheType currently uses int with 0 meaning "no
expiry", which is unclear; change expireAfterWrite to an explicit type (e.g.,
Optional<Long> / OptionalInt or java.time.Duration / `@Nullable` Integer) or
introduce a named sentinel constant (e.g., NO_EXPIRE = -1) and update
CacheType's constructor, getters and any code that reads expireAfterWrite
(including CacheConfig consumer code) to use the new representation and treat
Optional.empty()/Duration.ZERO or NO_EXPIRE consistently as "no expiry"; also
add a short javadoc on CacheType.expireAfterWrite to document the meaning and
update any callers (e.g., where CacheType.expireAfterWrite is checked) to handle
the new semantics.
src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java (1)

30-30: findFeedDetailById의 반환 타입을 Optional<FeedQueryDto>로 변경 권장

현재 구현에서 fetchOne()이 null을 반환할 수 있으며, 이를 명시적으로 반환하고 있습니다. FeedCacheHandlerFeedQueryPersistenceAdapter의 호출부 모두에서 null 체크 없이 반환값을 직접 사용하고 있어, 존재하지 않는 feedId 조회 시 NPE 또는 null 캐싱 문제가 발생할 수 있습니다. Java 관용 표현인 Optional<FeedQueryDto> 반환으로 null 안전성을 개선하세요.

♻️ Optional 적용 제안
-    FeedQueryDto findFeedDetailById(Long feedId);
+    Optional<FeedQueryDto> findFeedDetailById(Long feedId);

인터페이스 상단에 import java.util.Optional; 추가 필요, 구현체 및 호출부(FeedCacheHandler, FeedQueryPersistenceAdapter 등)에도 동일하게 반영해야 합니다.

🤖 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/persistence/repository/FeedQueryRepository.java`
at line 30, Change the signature of findFeedDetailById to return
Optional<FeedQueryDto> instead of nullable FeedQueryDto and update its
implementation to wrap fetchOne() result with Optional.ofNullable; add import
java.util.Optional and adjust all callers (FeedCacheHandler,
FeedQueryPersistenceAdapter, etc.) to handle the Optional (e.g., orElse /
orElseThrow / isPresent) instead of assuming non-null, ensuring no direct null
usage or caching of null values.
src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java (1)

13-13: getFeedDetail 네이밍이 인터페이스 내 다른 메서드(findXxx)와 일관되지 않습니다.

다른 QueryPort 메서드들은 find 접두사를 사용하고 있습니다. 일관성을 위해 findFeedDetail로 변경을 고려해 볼 수 있습니다. 다만 기능적 문제는 아닙니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java` at
line 13, 인터페이스 FeedQueryPort의 메서드명 getFeedDetail이 다른 메서드들(findXxx)과 일관성이 없으니
getFeedDetail을 findFeedDetail로 변경하고 관련 구현체와 호출부 모두에서 메서드명을 동일하게 갱신하세요; 변경 대상
식별자: FeedQueryPort 인터페이스의 getFeedDetail, 반환형 FeedQueryDto, 그리고 이를 구현하는 클래스들 및
서비스/컨트롤러에서 호출하는 모든 참조를 찾아 findFeedDetail로 리네임하고 컴파일/테스트 통과 여부를 확인하면 됩니다.
src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListenerTest.java (1)

24-29: @SpringBootTest를 사용하므로 "단위 테스트"보다는 "통합 테스트"로 분류하는 것이 적절합니다.

전체 Spring 컨텍스트를 로드하는 @SpringBootTest 기반 테스트는 일반적으로 통합 테스트로 간주됩니다. @DisplayName의 "[단위]" 표기가 혼동을 줄 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListenerTest.java`
around lines 24 - 29, The test class FeedCacheWarmupListenerTest is annotated
with `@SpringBootTest` (loads full Spring context) but its `@DisplayName` labels it
as "[단위]" (unit); update the `@DisplayName` to indicate it's an integration test
(e.g., replace "[단위]" with "[통합]" or "[통합] FeedCacheWarmupListener 통합 테스트") so
the test classification matches the use of `@SpringBootTest` and avoids confusion.
src/test/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandlerTest.java (1)

102-119: refreshCacheAfterBulkDelete 테스트에서 feedDetail 캐시 mock 설정이 누락되어, 상세 캐시 제거(evictFeeds) 및 워밍업 경로가 검증되지 않습니다.

refreshCacheAfterBulkDelete 내부에서 evictFeedscacheManager.getCache("feedDetail")을 호출하는데, 이 테스트에서는 "feedDetail"에 대한 stub이 없어 null이 반환되고 eviction이 무시됩니다. 또한 warmUpFeedDetails에서도 마찬가지입니다. 상세 캐시 제거와 신규 진입 피드 워밍업까지 검증하려면 detailCache stub을 추가해 주세요.

제안: detailCache stub 추가
     void refresh_when_contains_deleted() {
         List<Long> currentTop = new ArrayList<>(List.of(5L, 4L, 3L));
         List<Long> deleted = List.of(5L);
 
         String cacheKey = "top" + DEFAULT_CACHE_SIZE;
         when(cacheManager.getCache("feedIdTop")).thenReturn(topCache);
+        when(cacheManager.getCache("feedDetail")).thenReturn(detailCache);
         when(topCache.get(cacheKey, List.class))
                 .thenReturn(currentTop);
 
         when(feedJpaRepository.findTopFeedIds(DEFAULT_CACHE_SIZE))
                 .thenReturn(List.of(4L, 3L, 2L));
+        when(feedJpaRepository.findFeedDetailsByIds(List.of(2L)))
+                .thenReturn(List.of());
 
         handler.refreshCacheAfterBulkDelete(deleted);
 
         verify(topCache).put(cacheKey, List.of(4L, 3L, 2L));
+        verify(detailCache).evict(5L);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandlerTest.java`
around lines 102 - 119, The test is missing a stub for the "feedDetail" cache so
evictFeeds and warmUpFeedDetails paths inside refreshCacheAfterBulkDelete are
not exercised; update the test to mock cacheManager.getCache("feedDetail") to
return a mocked detailCache (e.g., detailCache) and set expected behaviors on
detailCache (such as allowing evict calls and any get/put used by
warmUpFeedDetails) so you can verify detailCache.evict(...) and any
detailCache.put(...) interactions when
handler.refreshCacheAfterBulkDelete(deleted) is invoked.
src/main/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandler.java (1)

23-26: @Cacheable 애노테이션과 CacheManager 직접 조작을 혼용하고 있어, 캐시 키 불일치 시 사일런트 버그 위험이 있습니다.

getTopIds()@Cacheable로 SpEL 키 'top' + 100"top100"을 사용하고, updateTopIdsWithNewId()"top" + DEFAULT_CACHE_SIZE"top100"을 프로그래밍 방식으로 사용합니다. 현재는 일치하지만, DEFAULT_CACHE_SIZE를 변경하거나 SpEL 표현식을 수정하면 두 곳이 분리되어 키 불일치가 발생할 수 있습니다.

캐시 키를 상수로 추출하거나, @Cacheable을 제거하고 CacheManager 기반으로 일원화하는 것을 권장합니다.

🤖 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 23 - 26, Extract a single cache-key constant (e.g., CACHE_KEY_TOP =
"top" + DEFAULT_CACHE_SIZE) in FeedCacheHandler and use that constant everywhere
instead of hard-coded SpEL or string concatenation: update the `@Cacheable` on
getTopIds() to reference the constant (or replace the annotation with a
programmatic cache lookup using CacheManager and the same constant) and change
updateTopIdsWithNewId() to use the same CACHE_KEY_TOP when reading/writing via
CacheManager; ensure all usages (getTopIds, updateTopIdsWithNewId,
DEFAULT_CACHE_SIZE references) are unified to avoid key drift.
src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListener.java (1)

19-40: 이벤트 핸들러에 에러 처리가 없어, 캐시 조작 실패 시 예외가 전파됩니다.

@TransactionalEventListener(AFTER_COMMIT) 핸들러에서 예외가 발생하면 트랜잭션에는 영향이 없지만, 로그 없이 실패하거나 Spring의 기본 예외 처리에 의존하게 됩니다. PR 설명에 이벤트 유실/재처리 미고려 상태라고 명시되어 있으므로, 최소한 try-catch로 예외를 로깅하는 방어 코드를 추가하면 운영 시 추적이 용이해집니다.

🤖 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/FeedCacheEventListener.java`
around lines 19 - 40, Wrap each transactional event handler
(handleFeedCreatedEvent, handleFeedDeletedEvent, handleFeedUpdatedEvent,
handleUserWithdrawn) in a try-catch that catches Exception and logs an error
with contextual identifiers (e.g., event.feedId() or event.deletedFeedIds()) and
the exception stacktrace, then swallow or handle the exception so it does not
propagate; use the class logger (SLF4J org.slf4j.Logger) to emit a clear message
per handler indicating which operation failed (updateTopIdsWithNewId,
getFeedDetail, markAsDeleted, evictFeedDetailIfPresent,
refreshCacheAfterBulkDelete) to aid production tracing.
src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListenerTest.java (1)

71-90: FeedUpdatedEvent 테스트에서 evictFeedDetailIfPresentfalse를 반환하는 경우(캐시에 없는 피드 수정)의 검증이 누락되어 있습니다.

handleFeedUpdatedEventevictFeedDetailIfPresentfalse이면 getFeedDetail을 호출하지 않는 분기가 있습니다. 이 경로도 테스트하면 핸들러 로직의 완전성을 확보할 수 있습니다.

추가 테스트 케이스 예시
`@Test`
`@Transactional`
`@DisplayName`("FeedUpdatedEvent 발행 → 캐시에 없는 피드면 getFeedDetail을 호출하지 않는다")
void handleFeedUpdatedEvent_NotCached() {
    Long feedId = 1L;
    FeedUpdatedEvent event = FeedUpdatedEvent.from(feedId);
    given(feedCacheHandler.evictFeedDetailIfPresent(feedId)).willReturn(false);

    publisher.publishEvent(event);
    TestTransaction.flagForCommit();
    TestTransaction.end();

    verify(feedCacheHandler, times(1)).evictFeedDetailIfPresent(feedId);
    verify(feedCacheHandler, never()).getFeedDetail(feedId);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListenerTest.java`
around lines 71 - 90, Add a new test in FeedCacheEventListenerTest to cover the
branch where evictFeedDetailIfPresent returns false: create a FeedUpdatedEvent
(use FeedUpdatedEvent.from(feedId)), stub
feedCacheHandler.evictFeedDetailIfPresent(feedId) to return false, publish the
event with publisher.publishEvent(event) and commit via
TestTransaction.flagForCommit()/TestTransaction.end(), then verify
evictFeedDetailIfPresent was called once and that
feedCacheHandler.getFeedDetail(feedId) was never called; this ensures the
handleFeedUpdatedEvent path where the cache miss skips getFeedDetail is
exercised.
src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllPersistenceApiTest.java (1)

69-93: Mock 설정이 FeedQueryCacheAdapter의 일부 메서드만 커버합니다.

@MockitoBean은 stub 되지 않은 메서드에 대해 기본값(null, 0, false 등)을 반환합니다. 현재 findLatestFeedsByFeedIdfindSavedFeedIdsByUserIdAndFeedIds 두 메서드만 stub 되어 있으므로, basic 전략의 플로우가 변경되어 다른 FeedQueryPort 메서드를 호출하게 되면 테스트가 NullPointerException으로 실패할 수 있습니다.

Mockito의 delegatesTo 기능이나 @Autowired로 실제 persistence adapter를 직접 주입하는 방식을 고려해보세요.

🔧 보다 안전한 대안: Mockito delegatesTo 사용
`@MockitoBean`(answers = Answers.CALLS_REAL_METHODS) // 또는 아래처럼 delegatesTo 활용
private FeedQueryCacheAdapter feedQueryCacheAdapter;

`@BeforeEach`
void setup() {
    // 모든 호출을 persistenceAdapter로 위임
    given(feedQueryCacheAdapter.findLatestFeedsByFeedId(any(), any()))
            .willAnswer(invocation -> persistenceAdapter.findLatestFeedsByFeedId(
                    invocation.getArgument(0), invocation.getArgument(1)));
    given(feedQueryCacheAdapter.findSavedFeedIdsByUserIdAndFeedIds(any(), any()))
            .willAnswer(invocation -> persistenceAdapter.findSavedFeedIdsByUserIdAndFeedIds(
                    invocation.getArgument(0), invocation.getArgument(1)));
    // ... 필요 시 추가 stub
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllPersistenceApiTest.java`
around lines 69 - 93, The test stubs only two methods on the MockitoBean
FeedQueryCacheAdapter (findLatestFeedsByFeedId and
findSavedFeedIdsByUserIdAndFeedIds), leaving other methods returning null and
risking NPEs; change the mock to delegate all calls to the real persistence
adapter by configuring the FeedQueryCacheAdapter mock to use delegatesTo/preset
Answers.CALLS_REAL_METHODS or otherwise delegate to persistenceAdapter
(persistenceAdapter) so every call falls through to
persistenceAdapter.findLatestFeedsByFeedId /
persistenceAdapter.findSavedFeedIdsByUserIdAndFeedIds and avoid partial stubbing
issues.
src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java (1)

152-164: fetchFeedIdsLatest는 사용되지 않는 데드 코드입니다. 제거하세요.

findLatestFeedsByFeedId (Line 87-109)가 inline fetch join을 활용한 통합 쿼리로 리팩토링되면서, 이전에 ID 목록만 조회하던 fetchFeedIdsLatest는 더 이상 호출되지 않습니다. 클래스 내 어디에서도 사용되지 않는 private 메서드이므로 삭제해도 안전합니다.

🧹 dead code 제거
-    /**
-     * ID 목록만 최신순 커서 페이징으로 조회
-     */
-    private List<Long> fetchFeedIdsLatest(Long userId, Long lastPostId , int size) {
-        return jpaQueryFactory
-                .select(feed.postId)
-                .from(feed)
-                .where(
-                        // ACTIVE 인 feed & (내가 작성한 글 or 다른 유저가 작성한 공개글) & cursorCondition
-                        feed.userJpaEntity.userId.eq(userId).or(feed.isPublic.eq(true)),
-                        lastPostId != null ? feed.postId.lt(lastPostId) : null
-                )
-                .orderBy(feed.postId.desc())
-                .limit(size + 1)
-                .fetch();
-    }
🤖 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/persistence/repository/FeedQueryRepositoryImpl.java`
around lines 152 - 164, Remove the dead private method fetchFeedIdsLatest(Long
userId, Long lastPostId, int size) from FeedQueryRepositoryImpl: locate the
method definition and delete it, ensure there are no remaining references to
fetchFeedIdsLatest in the class, and run a build/tests to confirm compilation;
keep the refactored findLatestFeedsByFeedId intact (it replaces the old ID-list
flow).
src/main/java/konkuk/thip/feed/adapter/out/cache/FeedQueryCacheAdapter.java (2)

37-37: creatorId() null 시 NPE 가능성

dto.creatorId().equals(userId)creatorId()null을 반환하면 NullPointerException이 발생합니다. 실제로 피드의 작성자 ID가 null일 가능성은 낮지만, 방어적으로 Objects.equals(dto.creatorId(), userId)를 사용하면 안전합니다.

🛡️ 방어적 null 처리
-                    .filter(dto -> dto != null && (dto.isPublic() || dto.creatorId().equals(userId)))
+                    .filter(dto -> dto != null && (dto.isPublic() || Objects.equals(dto.creatorId(), userId)))
🤖 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/FeedQueryCacheAdapter.java`
at line 37, The filter predicate in FeedQueryCacheAdapter uses
dto.creatorId().equals(userId) which can throw NPE if creatorId() is null;
update the lambda to perform a null-safe equality check using
Objects.equals(dto.creatorId(), userId) (or invert to userId.equals(...) after
null-checking userId) so the expression becomes defensive against null creatorId
values while preserving the original public-or-owner logic.

14-20: FeedQueryPersistenceAdapter 구체 클래스 직접 의존

FeedQueryPort 인터페이스 대신 구체 구현체에 의존하고 있습니다. 두 어댑터 모두 같은 포트를 구현하기 때문에 인터페이스를 주입하면 순환/모호 문제가 발생하므로 현 구조는 이해됩니다만, 향후 DB 어댑터 구현 변경 시 이 클래스도 수정해야 하는 커플링이 존재합니다.

대안으로 DB 전용 포트 인터페이스를 분리하거나, @Qualifier를 통해 인터페이스 주입하는 방식을 고려할 수 있습니다.

🤖 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/FeedQueryCacheAdapter.java`
around lines 14 - 20, FeedQueryCacheAdapter currently depends on the concrete
FeedQueryPersistenceAdapter causing tight coupling; change injection to use an
abstraction by either (1) introduce a DB-specific port interface (e.g.,
FeedQueryDbPort) implemented by FeedQueryPersistenceAdapter and inject that into
FeedQueryCacheAdapter instead of the concrete class, or (2) keep the existing
FeedQueryPort but mark the persistence implementation with a `@Qualifier` and
inject by that qualifier into FeedQueryCacheAdapter; update the
constructor/field types in FeedQueryCacheAdapter (and add the new interface or
`@Qualifier` annotation to FeedQueryPersistenceAdapter) and adjust any wiring so
FeedCacheHandler and FeedQueryCacheAdapter remain decoupled from the concrete
persistence class.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@loadtest/feed/feed_home_scrolling_test.js`:
- Around line 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.
- Around line 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).
- Around line 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.

In
`@src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListener.java`:
- Around line 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.

In `@src/main/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandler.java`:
- Around line 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.
- Around line 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.

In `@src/main/java/konkuk/thip/feed/adapter/out/cache/FeedQueryCacheAdapter.java`:
- Around line 31-44: The code filters topIds using
feedCacheHandler.getFeedDetail and silently drops items when getFeedDetail
returns null, then returns CursorBasedList.of(...) if feedQueryDtos.size() >=
size, causing permanently missing feeds; update the logic in the block handling
topIds/lastPostId (the stream that builds feedQueryDtos) to detect any
cache-miss (null from feedCacheHandler.getFeedDetail) and on any miss either (A)
perform a DB fallback for the whole request or (B) fetch only the missing
FeedQueryDto(s) from the DB to fill feedQueryDtos before applying .limit(size +
1) so that CursorBasedList.of(...) is only returned when the result is truly
complete; ensure to reference feedCacheHandler.getFeedDetail, FeedQueryDto,
topIds, size, and CursorBasedList.of when locating and changing the code.

In
`@src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java`:
- Around line 87-109: The queries in findLatestFeedsByFeedId and
findFeedDetailById are missing the explicit active-status filter so soft-deleted
feeds can be returned (the BaseJpaEntity `@Filter` isn't enabled here); update
both methods to include feed.status.eq(ACTIVE) in their where clauses (same
approach used in findTopFeedIds) so only feeds with ACTIVE status are returned.

In `@src/main/java/konkuk/thip/user/application/service/UserDeleteService.java`:
- Line 84: deletedFeedIds can be null if
feedCommandPort.deleteAllFeedByUserId(userId) returns null, which would pass a
null into the UserWithdrawnEvent and cause NPEs in FeedCacheEventListener when
iterating; after calling feedCommandPort.deleteAllFeedByUserId(userId) in
UserDeleteService, defensively replace a null result with
Collections.emptyList() (or use
Optional.ofNullable(...).orElse(Collections.emptyList())) before constructing
UserWithdrawnEvent, or alternatively ensure the FeedCommandPort implementation
always returns Collections.emptyList() instead of null.

In
`@src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllPersistenceApiTest.java`:
- Around line 131-132: 이 테스트의 주석이 실제 정렬 로직과 불일치합니다: Basic
전략(feed.show.strategy=basic)은 팔로잉 우선순위가 아니라 최신순(postId desc) 정렬이므로 "// 1순위: 팔로잉
글 f2"를 수정하세요; 예를 들어 BasicFeedShowAllPersistenceApiTest의 해당 assertion
라인(.andExpect(jsonPath("$.data.feedList[0].feedId",
is(f2.getPostId().intValue()))) 및 관련 f1/f2 변수 참조를 찾아 주석을 "최신 글 f2 (postId가 더 큼)"
또는 유사한 설명으로 바꿔 혼동을 제거하십시오.

In `@src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java`:
- Around line 315-361: The test createFeed_shouldRefreshCache assumes
synchronous cache updates; change it to poll the caches with a short retry loop
and timeout after committing (replace the immediate checks after
TestTransaction.flagForCommit() / TestTransaction.end() with a loop that sleeps
briefly and re-fetches both cacheManager.getCache("feedDetail") -> get(feedId,
...) and cacheManager.getCache("feedIdTop") -> get("top100", List.class) until
the expected values appear or a timeout is reached; if timeout expires, fail the
test with the same assertions to preserve diagnostics.

In `@src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java`:
- Around line 109-131: The test deleteFeed_shouldPutNullInDetailCache asserts
the detail cache immediately after committing but cache-refresh is asynchronous;
modify the test to poll the feedDetail cache for up to a short timeout (e.g.,
1–2s) with a small sleep/retry interval until detailCache.get(feedId) returns
null or timeout expires, using feedCacheHandler/cacheManager to read the cache
and failing the test if timeout is reached; keep existing TestTransaction commit
but replace the direct assertThat(cachedObject).isNull() with this retry loop to
make the assertion robust to async cache updates.

In `@src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java`:
- Around line 176-210: The test updateFeed_shouldRefreshDetailCache assumes
synchronous cache refresh; change it to retry/wait for the updated cache value
instead of asserting immediately after TestTransaction.end(): after performing
the patch and committing (TestTransaction.flagForCommit()/end()), poll the
feedDetail cache obtained via cacheManager.getCache("feedDetail") (or call
feedCacheHandler.getFeedDetail(feedId) if that triggers load) in a short loop
with a small sleep and overall timeout (or use Awaitility) until the cached
FeedQueryDto.contentBody() equals "캐시 테스트용", then assert; fail if timeout
elapses.

In
`@src/test/java/konkuk/thip/feed/adapter/in/web/FollowingPriorityFeedShowAllApiTest.java`:
- Around line 107-122: The comments above the assertions in
FollowingPriorityFeedShowAllApiTest are reversed: the comment "// 1순위: 팔로잉 글 f2"
is placed over assertions for feedList[1] (which assert f1), and "// 2순위: 내 글
f1" is placed over assertions for feedList[0] (which assert f2); update the
comments to match the assertions (either swap the two comment lines or rewrite
them so "// 1순위: 팔로잉 글 f2" sits above feedList[0] assertions and "// 2순위: 내 글
f1" above feedList[1] assertions) so the comments align with the assertions in
FollowingPriorityFeedShowAllApiTest.

In `@src/test/java/konkuk/thip/user/adapter/in/web/UserDeleteApiTest.java`:
- Around line 428-488: The test deleteUser_shouldRefreshFeedCache assumes
synchronous cache invalidation; add a short retry/wait loop after
TestTransaction.flagForCommit()/TestTransaction.end() and before accessing
cacheManager.getCache(...) to poll until the expected cache state appears (or
timeout) to account for asynchronous event processing. Specifically, after
committing the transaction (TestTransaction.flagForCommit();
TestTransaction.end();) call feedCacheHandler.getTopIds() or read from
cacheManager.getCache("feedIdTop") in a small loop (sleep ~50-200ms between
attempts) and re-evaluate assertions (topIds and detailCache reads) until they
match the expected post-deletion state or a max wait is reached, failing the
test if timed out; update assertions to use the final polled values. Ensure you
reference the existing symbols: deleteUser_shouldRefreshFeedCache,
TestTransaction.flagForCommit, TestTransaction.end, feedCacheHandler.getTopIds,
cacheManager.getCache("feedIdTop"), and cacheManager.getCache("feedDetail").

---

Outside diff comments:
In
`@src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java`:
- Around line 103-122: In deleteAllFeedByUserId (FeedCommandPersistenceAdapter)
replace the null early-return with returning an empty immutable list (e.g.,
List.of() or Collections.emptyList()) so callers like UserDeleteService can
safely iterate without NPE; also remove the duplicated comment above feedIds
retrieval so the method has a single correct comment for step 1.

---

Duplicate comments:
In `@src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListener.java`:
- Around line 25-28: handleFeedDeletedEvent only calls
feedCacheHandler.markAsDeleted which leaves the feedIdTop cache stale; after
calling markAsDeleted you must also update the top-IDs cache—add a call like
feedCacheHandler.removeFromTopIds(event.feedId()) (or
feedCacheHandler.refreshTopIds()/updateTopIdsAfterDeletion(event.feedId())) and
implement that method in FeedCacheHandler to remove the deleted feed ID from the
top IDs cache and persist the updated top list.

---

Nitpick comments:
In `@loadtest/feed/feed_home_scrolling_test.js`:
- Around line 34-43: The test does not handle a missing/invalid token (token
from data.tokens[vuIdx]) so a VU may send unauthorized requests; before building
params in the default exported function, check if token is null/undefined and
bail out early: detect token using the existing vuIdx/token variables and either
abort the VU scenario (throw or return) or call the appropriate k6 stop/sleep to
avoid sending requests, and log the failure for visibility; update any logic
that uses params/headers (the params object and Authorization header
construction) to only run when token is present.
- Around line 14-17: 현재 thresholds 객체의 http_req_duration: ['p(95)<80'] 임계값은 500
VU 시나리오에서 과도하게 엄격해 테스트가 항상 실패할 수 있으니, feed_home_scrolling_test.js의
thresholds.http_req_duration 설정을 현실적인 값으로 완화하거나(예: p(95)<200ms 또는 환경변수로 조정 가능하게)
임계값을 제거해 먼저 베이스라인을 측정하도록 변경하세요; 수정할 위치는 파일의 thresholds 블록(키명: http_req_duration,
http_req_failed)이며 필요하면 환경변수 기반 구성(예: PROCESS_ENV 또는 옵션으로 전달)으로 만들고 문서에 새로운 기본값을
기록해 주세요.

In `@src/main/java/konkuk/thip/config/cache/CacheConfig.java`:
- Around line 33-43: The method caffeineBuilder returns a built
Cache<Object,Object> but its name implies it returns a Caffeine builder; rename
the method caffeineBuilder to a name that reflects it returns a Cache (e.g.,
buildCache, createCache, or cacheForType) and update all callers accordingly;
ensure the signature remains private Cache<Object,Object> caffeineBuilder(...) →
private Cache<Object,Object> buildCache(...) (or chosen name) and keep the
internal logic (Caffeine.newBuilder()... .build()) unchanged so callers receive
the constructed Cache instance.
- Around line 22-27: 현재 CacheConfig의 캐시 리스트 생성 코드에서
Arrays.stream(...).collect(Collectors.toList())를 사용하고 있는데 Java 16+ 환경에서는 더 간결하고
불변 리스트를 반환하는 Stream.toList()로 교체하세요: CacheConfig에서
Arrays.stream(CacheType.values()).map(...).toList()로 바꾸고 불필요해진 import
java.util.stream.Collectors를 제거하며 caffeineBuilder 및 CacheType.getCacheName 참조는
그대로 유지해 주세요.

In `@src/main/java/konkuk/thip/config/cache/CacheType.java`:
- Around line 9-13: FEED_ID_TOP의 주석과 실제 설정(maximumSize=1)이 불일치합니다; 주석을 수정하여 의도한
설계를 명확히 하세요 — 만약 설계가 "각 캐시 엔트리에 최신 100개 ID 리스트를 담는 단일 엔트리"라면 주석을 "최신 100개 ID를
하나의 캐시 엔트리에 저장"처럼 명확히 바꾸고, 반대로 의도가 "최신 피드 ID 100개를 각각의 캐시 엔트리로 보관"이라면
FEED_ID_TOP의 maximumSize를 100으로 변경하세요; 관련 식별자는 FEED_ID_TOP과 해당 maximumSize
설정입니다.
- Around line 20-22: The field expireAfterWrite in CacheType currently uses int
with 0 meaning "no expiry", which is unclear; change expireAfterWrite to an
explicit type (e.g., Optional<Long> / OptionalInt or java.time.Duration /
`@Nullable` Integer) or introduce a named sentinel constant (e.g., NO_EXPIRE = -1)
and update CacheType's constructor, getters and any code that reads
expireAfterWrite (including CacheConfig consumer code) to use the new
representation and treat Optional.empty()/Duration.ZERO or NO_EXPIRE
consistently as "no expiry"; also add a short javadoc on
CacheType.expireAfterWrite to document the meaning and update any callers (e.g.,
where CacheType.expireAfterWrite is checked) to handle the new semantics.

In `@src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListener.java`:
- Around line 19-40: Wrap each transactional event handler
(handleFeedCreatedEvent, handleFeedDeletedEvent, handleFeedUpdatedEvent,
handleUserWithdrawn) in a try-catch that catches Exception and logs an error
with contextual identifiers (e.g., event.feedId() or event.deletedFeedIds()) and
the exception stacktrace, then swallow or handle the exception so it does not
propagate; use the class logger (SLF4J org.slf4j.Logger) to emit a clear message
per handler indicating which operation failed (updateTopIdsWithNewId,
getFeedDetail, markAsDeleted, evictFeedDetailIfPresent,
refreshCacheAfterBulkDelete) to aid production tracing.

In `@src/main/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandler.java`:
- Around line 23-26: Extract a single cache-key constant (e.g., CACHE_KEY_TOP =
"top" + DEFAULT_CACHE_SIZE) in FeedCacheHandler and use that constant everywhere
instead of hard-coded SpEL or string concatenation: update the `@Cacheable` on
getTopIds() to reference the constant (or replace the annotation with a
programmatic cache lookup using CacheManager and the same constant) and change
updateTopIdsWithNewId() to use the same CACHE_KEY_TOP when reading/writing via
CacheManager; ensure all usages (getTopIds, updateTopIdsWithNewId,
DEFAULT_CACHE_SIZE references) are unified to avoid key drift.

In `@src/main/java/konkuk/thip/feed/adapter/out/cache/FeedQueryCacheAdapter.java`:
- Line 37: The filter predicate in FeedQueryCacheAdapter uses
dto.creatorId().equals(userId) which can throw NPE if creatorId() is null;
update the lambda to perform a null-safe equality check using
Objects.equals(dto.creatorId(), userId) (or invert to userId.equals(...) after
null-checking userId) so the expression becomes defensive against null creatorId
values while preserving the original public-or-owner logic.
- Around line 14-20: FeedQueryCacheAdapter currently depends on the concrete
FeedQueryPersistenceAdapter causing tight coupling; change injection to use an
abstraction by either (1) introduce a DB-specific port interface (e.g.,
FeedQueryDbPort) implemented by FeedQueryPersistenceAdapter and inject that into
FeedQueryCacheAdapter instead of the concrete class, or (2) keep the existing
FeedQueryPort but mark the persistence implementation with a `@Qualifier` and
inject by that qualifier into FeedQueryCacheAdapter; update the
constructor/field types in FeedQueryCacheAdapter (and add the new interface or
`@Qualifier` annotation to FeedQueryPersistenceAdapter) and adjust any wiring so
FeedCacheHandler and FeedQueryCacheAdapter remain decoupled from the concrete
persistence class.

In `@src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedUpdatedEvent.java`:
- Around line 1-8: Remove the leading blank line before the package declaration
so the file starts immediately with "package
konkuk.thip.feed.adapter.out.event.dto;" to match the style of the other event
DTOs (FeedCreatedEvent, FeedDeletedEvent); update the top of the
FeedUpdatedEvent record to have no empty line above the package statement.

In
`@src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java`:
- Line 30: Change the signature of findFeedDetailById to return
Optional<FeedQueryDto> instead of nullable FeedQueryDto and update its
implementation to wrap fetchOne() result with Optional.ofNullable; add import
java.util.Optional and adjust all callers (FeedCacheHandler,
FeedQueryPersistenceAdapter, etc.) to handle the Optional (e.g., orElse /
orElseThrow / isPresent) instead of assuming non-null, ensuring no direct null
usage or caching of null values.

In
`@src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java`:
- Around line 152-164: Remove the dead private method fetchFeedIdsLatest(Long
userId, Long lastPostId, int size) from FeedQueryRepositoryImpl: locate the
method definition and delete it, ensure there are no remaining references to
fetchFeedIdsLatest in the class, and run a build/tests to confirm compilation;
keep the refactored findLatestFeedsByFeedId intact (it replaces the old ID-list
flow).

In `@src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java`:
- Line 29: The interface method FeedCommandPort.deleteAllFeedByUserId currently
allows a null return implicitly; either annotate the contract explicitly or make
it non-null. Update FeedCommandPort.deleteAllFeedByUserId to declare a non-null
contract (add your project’s `@NotNull/`@NonNull annotation or Javadoc stating
non-null) and ensure FeedCommandPersistenceAdapter.deleteAllFeedByUserId
implementation returns an empty List (e.g., Collections.emptyList()) instead of
null so callers can rely on a non-null List<Long>.

In `@src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java`:
- Line 13: 인터페이스 FeedQueryPort의 메서드명 getFeedDetail이 다른 메서드들(findXxx)과 일관성이 없으니
getFeedDetail을 findFeedDetail로 변경하고 관련 구현체와 호출부 모두에서 메서드명을 동일하게 갱신하세요; 변경 대상
식별자: FeedQueryPort 인터페이스의 getFeedDetail, 반환형 FeedQueryDto, 그리고 이를 구현하는 클래스들 및
서비스/컨트롤러에서 호출하는 모든 참조를 찾아 findFeedDetail로 리네임하고 컴파일/테스트 통과 여부를 확인하면 됩니다.

In
`@src/main/java/konkuk/thip/user/adapter/out/event/dto/UserWithdrawnEvent.java`:
- Around line 5-8: The factory method in UserWithdrawnEvent is named of(), which
is inconsistent with other event DTOs like FeedCreatedEvent.from() and
FeedDeletedEvent.from(); rename the static method UserWithdrawnEvent.of(Long
userId, List<Long> deletedFeedIds) to UserWithdrawnEvent.from(Long userId,
List<Long> deletedFeedIds) and update any call sites to use
UserWithdrawnEvent.from(...) to keep factory naming consistent across the
codebase.

In
`@src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListenerTest.java`:
- Around line 71-90: Add a new test in FeedCacheEventListenerTest to cover the
branch where evictFeedDetailIfPresent returns false: create a FeedUpdatedEvent
(use FeedUpdatedEvent.from(feedId)), stub
feedCacheHandler.evictFeedDetailIfPresent(feedId) to return false, publish the
event with publisher.publishEvent(event) and commit via
TestTransaction.flagForCommit()/TestTransaction.end(), then verify
evictFeedDetailIfPresent was called once and that
feedCacheHandler.getFeedDetail(feedId) was never called; this ensures the
handleFeedUpdatedEvent path where the cache miss skips getFeedDetail is
exercised.

In
`@src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListenerTest.java`:
- Around line 24-29: The test class FeedCacheWarmupListenerTest is annotated
with `@SpringBootTest` (loads full Spring context) but its `@DisplayName` labels it
as "[단위]" (unit); update the `@DisplayName` to indicate it's an integration test
(e.g., replace "[단위]" with "[통합]" or "[통합] FeedCacheWarmupListener 통합 테스트") so
the test classification matches the use of `@SpringBootTest` and avoids confusion.

In
`@src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllPersistenceApiTest.java`:
- Around line 69-93: The test stubs only two methods on the MockitoBean
FeedQueryCacheAdapter (findLatestFeedsByFeedId and
findSavedFeedIdsByUserIdAndFeedIds), leaving other methods returning null and
risking NPEs; change the mock to delegate all calls to the real persistence
adapter by configuring the FeedQueryCacheAdapter mock to use delegatesTo/preset
Answers.CALLS_REAL_METHODS or otherwise delegate to persistenceAdapter
(persistenceAdapter) so every call falls through to
persistenceAdapter.findLatestFeedsByFeedId /
persistenceAdapter.findSavedFeedIdsByUserIdAndFeedIds and avoid partial stubbing
issues.

In
`@src/test/java/konkuk/thip/feed/adapter/in/web/FollowingPriorityFeedShowAllApiTest.java`:
- Around line 21-22: Remove leftover unused timestamp-manipulation artifacts in
FollowingPriorityFeedShowAllApiTest: delete the unused imports for JdbcTemplate,
java.sql.Timestamp, and java.time.LocalDateTime, and remove the injected
JdbcTemplate field (and any related unused fields) from the test class so the
compiler/IDE no longer reports unused imports/fields; search for symbols
JdbcTemplate, Timestamp, and LocalDateTime in
FollowingPriorityFeedShowAllApiTest to locate and delete the dead code.
- Around line 247-251: The test hardcodes the cursor string "1|"+f3.getPostId()
which will break if the Cursor encoding changes; update the test to build the
cursor using the Cursor/CursorUtils API instead of manual concatenation (e.g.,
call the existing Cursor factory/encoder used by
FeedQueryPersistenceAdapter.findFeedsByFollowingPriority or add a small
test-only factory method on Cursor/CursorUtils and use that to produce
nextCursor from List.of("1", f3.getPostId().toString())); replace the manual
nextCursor construction in FollowingPriorityFeedShowAllApiTest with that encoded
value so the test remains correct if the cursor format changes.

In `@src/test/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandlerTest.java`:
- Around line 102-119: The test is missing a stub for the "feedDetail" cache so
evictFeeds and warmUpFeedDetails paths inside refreshCacheAfterBulkDelete are
not exercised; update the test to mock cacheManager.getCache("feedDetail") to
return a mocked detailCache (e.g., detailCache) and set expected behaviors on
detailCache (such as allowing evict calls and any get/put used by
warmUpFeedDetails) so you can verify detailCache.evict(...) and any
detailCache.put(...) interactions when
handler.refreshCacheAfterBulkDelete(deleted) is invoked.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 9e87db0 and 86886de.

📒 Files selected for processing (37)
  • build.gradle
  • loadtest/feed/feed_home_scrolling_test.js
  • src/main/java/konkuk/thip/ThipServerApplication.java
  • src/main/java/konkuk/thip/config/cache/CacheConfig.java
  • src/main/java/konkuk/thip/config/cache/CacheType.java
  • src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListener.java
  • src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListener.java
  • src/main/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandler.java
  • src/main/java/konkuk/thip/feed/adapter/out/cache/FeedQueryCacheAdapter.java
  • src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedCreatedEvent.java
  • src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedDeletedEvent.java
  • src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedUpdatedEvent.java
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java
  • src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java
  • src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java
  • src/main/java/konkuk/thip/feed/application/service/BasicFeedShowAllService.java
  • src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java
  • src/main/java/konkuk/thip/feed/application/service/FeedDeleteService.java
  • src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java
  • src/main/java/konkuk/thip/user/adapter/out/event/dto/UserWithdrawnEvent.java
  • src/main/java/konkuk/thip/user/application/service/UserDeleteService.java
  • src/test/java/konkuk/thip/common/util/TestEntityFactory.java
  • src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListenerTest.java
  • src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListenerTest.java
  • src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllCacheApiTest.java
  • src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllPersistenceApiTest.java
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java
  • src/test/java/konkuk/thip/feed/adapter/in/web/FollowingPriorityFeedShowAllApiTest.java
  • src/test/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandlerTest.java
  • src/test/java/konkuk/thip/feed/adapter/out/cache/FeedQueryCacheAdapterTest.java
  • src/test/java/konkuk/thip/user/adapter/in/web/UserDeleteApiTest.java

Comment on lines +1 to +18
//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'],
},
};
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.

Comment on lines +20 to +32
// 테스트 전 사용자 별 토큰 발급
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 };
}
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.

Comment on lines +47 to +68
// 모든 유저가 정확히 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초 사이 랜덤하게 쉬기
}
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).

Comment on lines +38 to +40
} catch (Exception e) {
log.error("캐시 워밍업 중 오류가 발생했습니다: {}", e.getMessage());
}
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.

Comment on lines +33 to +36
@CachePut(cacheNames = "feedDetail", key = "#feedId")
public FeedQueryDto markAsDeleted(Long feedId) {
return null;
}
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.

Comment on lines +315 to +361
@Test
@DisplayName("피드 생성 후 캐시(feedIdTop, feedDetail)가 갱신된다.")
void createFeed_shouldRefreshCache() throws Exception {

// given
bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152"));

Map<String, Object> request = new HashMap<>();
request.put("isbn", "9788954682152");
request.put("contentBody", "캐시 갱신 테스트");
request.put("isPublic", true);
request.put("tagList", List.of());

// when
ResultActions result = mockMvc.perform(post("/feeds")
.contentType(MediaType.APPLICATION_JSON)
.requestAttr("userId", user.getUserId())
.content(objectMapper.writeValueAsString(request)));

result.andExpect(status().isOk());

String json = result.andReturn().getResponse().getContentAsString();
Long feedId = objectMapper.readTree(json)
.path("data")
.path("feedId")
.asLong();

// 캐시 갱신 확인하기위해 강제 커밋
TestTransaction.flagForCommit();
TestTransaction.end();

// then
// 1. feedDetail 캐시 확인
Cache detailCache = cacheManager.getCache("feedDetail");
assertThat(detailCache).isNotNull();
Object cachedDetail = detailCache.get(feedId, Object.class);
assertThat(cachedDetail).isNotNull();

// 2. feedIdTop 캐시 확인
Cache topCache = cacheManager.getCache("feedIdTop");
assertThat(topCache).isNotNull();

List<Long> topIds = topCache.get("top100", List.class);

assertThat(topIds).isNotNull();
assertThat(topIds).contains(feedId);
}
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

캐시 갱신이 비동기라면 대기/재시도 보강이 필요합니다.

커밋 직후 즉시 캐시를 단정하고 있어, 이벤트 리스너가 비동기로 처리되면 간헐적으로 실패할 수 있습니다. 캐시가 갱신될 때까지 짧게 재시도/대기하는 방식으로 안정화해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java` around
lines 315 - 361, The test createFeed_shouldRefreshCache assumes synchronous
cache updates; change it to poll the caches with a short retry loop and timeout
after committing (replace the immediate checks after
TestTransaction.flagForCommit() / TestTransaction.end() with a loop that sleeps
briefly and re-fetches both cacheManager.getCache("feedDetail") -> get(feedId,
...) and cacheManager.getCache("feedIdTop") -> get("top100", List.class) until
the expected values appear or a timeout is reached; if timeout expires, fail the
test with the same assertions to preserve diagnostics.

Comment on lines +109 to +131
@Test
@DisplayName("피드 삭제 시 삭제한 피드가 캐시에 적재되어있다면 해당하는 상세 캐시에 null이 저장된다.")
void deleteFeed_shouldPutNullInDetailCache() throws Exception {
// given
Long feedId = feed.getPostId();

feedCacheHandler.getFeedDetail(feedId); //캐시에 먼저 적재

// when
mockMvc.perform(delete("/feeds/{feedId}", feedId)
.requestAttr("userId", user.getUserId()))
.andExpect(status().isOk());

// 캐시 갱신 확인하기위해 강제 커밋
TestTransaction.flagForCommit();
TestTransaction.end();

// then
Cache detailCache = cacheManager.getCache("feedDetail");
Object cachedObject = detailCache.get(feedId, Object.class);

assertThat(cachedObject).isNull();
}
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

캐시 갱신이 비동기라면 대기/재시도 보강이 필요합니다.

커밋 직후 바로 캐시를 단정하고 있어 비동기 갱신 시 테스트가 흔들릴 수 있습니다. 캐시 갱신 완료 시점까지 짧게 재시도/대기하도록 보강해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java` around
lines 109 - 131, The test deleteFeed_shouldPutNullInDetailCache asserts the
detail cache immediately after committing but cache-refresh is asynchronous;
modify the test to poll the feedDetail cache for up to a short timeout (e.g.,
1–2s) with a small sleep/retry interval until detailCache.get(feedId) returns
null or timeout expires, using feedCacheHandler/cacheManager to read the cache
and failing the test if timeout is reached; keep existing TestTransaction commit
but replace the direct assertThat(cachedObject).isNull() with this retry loop to
make the assertion robust to async cache updates.

Comment on lines +176 to +210
@Test
@DisplayName("피드 수정 시 수정한 피드가 캐시에 적재되어있다면 해당하는 상세 캐시가 갱신된다.")
void updateFeed_shouldRefreshDetailCache() throws Exception {

// given
Long feedId = feed.getPostId();

feedCacheHandler.getFeedDetail(feedId); //캐시에 먼저 적재
Map<String, Object> request = new HashMap<>();
request.put("contentBody", "캐시 테스트용");
request.put("isPublic", true);


// when
ResultActions result = mockMvc.perform(patch("/feeds/{feedId}", feedId)
.requestAttr("userId", user.getUserId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)));

result.andExpect(status().isOk());

// 캐시 갱신 확인하기위해 강제 커밋
TestTransaction.flagForCommit();
TestTransaction.end();

// then
Cache detailCache = cacheManager.getCache("feedDetail");
assertThat(detailCache).isNotNull();

Object cachedObject = detailCache.get(feedId, Object.class);
assertThat(cachedObject).isNotNull();

FeedQueryDto cached = (FeedQueryDto) cachedObject;
assertThat(cached.contentBody()).isEqualTo("캐시 테스트용");
}
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

캐시 갱신이 비동기라면 대기/재시도 보강이 필요합니다.

커밋 직후 즉시 캐시를 단정하고 있어, 비동기 갱신일 경우 테스트가 흔들릴 수 있습니다. 캐시 갱신 완료 시점까지 짧게 재시도/대기하도록 보강해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java` around
lines 176 - 210, The test updateFeed_shouldRefreshDetailCache assumes
synchronous cache refresh; change it to retry/wait for the updated cache value
instead of asserting immediately after TestTransaction.end(): after performing
the patch and committing (TestTransaction.flagForCommit()/end()), poll the
feedDetail cache obtained via cacheManager.getCache("feedDetail") (or call
feedCacheHandler.getFeedDetail(feedId) if that triggers load) in a short loop
with a small sleep and overall timeout (or use Awaitility) until the cached
FeedQueryDto.contentBody() equals "캐시 테스트용", then assert; fail if timeout
elapses.

Comment on lines +107 to +122
// 1순위: 팔로잉f2
.andExpect(jsonPath("$.data.feedList[1].feedId", is(f1.getPostId().intValue())))
.andExpect(jsonPath("$.data.feedList[1].creatorNickname", is("me")))
.andExpect(jsonPath("$.data.feedList[1].contentUrls", hasSize(2)))
.andExpect(jsonPath("$.data.feedList[1].likeCount", is(10)))
.andExpect(jsonPath("$.data.feedList[1].commentCount", is(5)))
.andExpect(jsonPath("$.data.feedList[1].isSaved", is(true)))
.andExpect(jsonPath("$.data.feedList[1].isLiked", is(false)))
// 2순위: f1
.andExpect(jsonPath("$.data.feedList[0].feedId", is(f2.getPostId().intValue())))
.andExpect(jsonPath("$.data.feedList[0].creatorNickname", is("user1")))
.andExpect(jsonPath("$.data.feedList[0].contentUrls", hasSize(0)))
.andExpect(jsonPath("$.data.feedList[0].likeCount", is(50)))
.andExpect(jsonPath("$.data.feedList[0].commentCount", is(10)))
.andExpect(jsonPath("$.data.feedList[0].isSaved", is(false)))
.andExpect(jsonPath("$.data.feedList[0].isLiked", is(true)));
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

주석과 어설션 블록의 순서가 뒤바뀌어 있습니다.

// 1순위: 팔로잉 글 f2 주석이 feedList[1] (f1) 블록 위에, // 2순위: 내 글 f1 주석이 feedList[0] (f2) 블록 위에 위치하고 있습니다. 어설션 자체의 논리(f2가 index 0)는 맞지만, 주석이 반대로 붙어 있어 유지보수 시 혼란을 초래합니다.

🐛 제안하는 주석 수정
-               // 1순위: 팔로잉 글 f2
-               .andExpect(jsonPath("$.data.feedList[1].feedId", is(f1.getPostId().intValue())))
-               .andExpect(jsonPath("$.data.feedList[1].creatorNickname", is("me")))
-               .andExpect(jsonPath("$.data.feedList[1].contentUrls", hasSize(2)))
-               .andExpect(jsonPath("$.data.feedList[1].likeCount", is(10)))
-               .andExpect(jsonPath("$.data.feedList[1].commentCount", is(5)))
-               .andExpect(jsonPath("$.data.feedList[1].isSaved", is(true)))
-               .andExpect(jsonPath("$.data.feedList[1].isLiked", is(false)))
-               // 2순위: 내 글 f1
-               .andExpect(jsonPath("$.data.feedList[0].feedId", is(f2.getPostId().intValue())))
-               .andExpect(jsonPath("$.data.feedList[0].creatorNickname", is("user1")))
-               .andExpect(jsonPath("$.data.feedList[0].contentUrls", hasSize(0)))
-               .andExpect(jsonPath("$.data.feedList[0].likeCount", is(50)))
-               .andExpect(jsonPath("$.data.feedList[0].commentCount", is(10)))
-               .andExpect(jsonPath("$.data.feedList[0].isSaved", is(false)))
-               .andExpect(jsonPath("$.data.feedList[0].isLiked", is(true)));
+               // 1순위: 팔로잉 글 f2 (f2가 f1보다 최신이므로 index 0)
+               .andExpect(jsonPath("$.data.feedList[0].feedId", is(f2.getPostId().intValue())))
+               .andExpect(jsonPath("$.data.feedList[0].creatorNickname", is("user1")))
+               .andExpect(jsonPath("$.data.feedList[0].contentUrls", hasSize(0)))
+               .andExpect(jsonPath("$.data.feedList[0].likeCount", is(50)))
+               .andExpect(jsonPath("$.data.feedList[0].commentCount", is(10)))
+               .andExpect(jsonPath("$.data.feedList[0].isSaved", is(false)))
+               .andExpect(jsonPath("$.data.feedList[0].isLiked", is(true)))
+               // 2순위: 내 글 f1 (f1이 더 오래됐으므로 index 1)
+               .andExpect(jsonPath("$.data.feedList[1].feedId", is(f1.getPostId().intValue())))
+               .andExpect(jsonPath("$.data.feedList[1].creatorNickname", is("me")))
+               .andExpect(jsonPath("$.data.feedList[1].contentUrls", hasSize(2)))
+               .andExpect(jsonPath("$.data.feedList[1].likeCount", is(10)))
+               .andExpect(jsonPath("$.data.feedList[1].commentCount", is(5)))
+               .andExpect(jsonPath("$.data.feedList[1].isSaved", is(true)))
+               .andExpect(jsonPath("$.data.feedList[1].isLiked", is(false)));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/konkuk/thip/feed/adapter/in/web/FollowingPriorityFeedShowAllApiTest.java`
around lines 107 - 122, The comments above the assertions in
FollowingPriorityFeedShowAllApiTest are reversed: the comment "// 1순위: 팔로잉 글 f2"
is placed over assertions for feedList[1] (which assert f1), and "// 2순위: 내 글
f1" is placed over assertions for feedList[0] (which assert f2); update the
comments to match the assertions (either swap the two comment lines or rewrite
them so "// 1순위: 팔로잉 글 f2" sits above feedList[0] assertions and "// 2순위: 내 글
f1" above feedList[1] assertions) so the comments align with the assertions in
FollowingPriorityFeedShowAllApiTest.

Comment on lines +428 to +488
@Test
@DisplayName("회원탈퇴 시 탈퇴 유저의 피드가 캐시에 적재되어있다면 해당하는 캐시들이 삭제된다.")
void deleteUser_shouldRefreshFeedCache() throws Exception {

// given
UserJpaEntity withdrawUser = userJpaRepository.save(TestEntityFactory.createUser(Alias.ARTIST));
UserJpaEntity otherUser = userJpaRepository.save(TestEntityFactory.createUser(Alias.ARTIST));
BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook());

// 탈퇴 유저 피드 2개
FeedJpaEntity feed1 = feedJpaRepository.save(
TestEntityFactory.createFeed(withdrawUser, book, true)
);
FeedJpaEntity feed2 = feedJpaRepository.save(
TestEntityFactory.createFeed(withdrawUser, book, true)
);

// 다른 유저 피드 1개
FeedJpaEntity feed3 = feedJpaRepository.save(
TestEntityFactory.createFeed(otherUser, book, true)
);

// 캐시 강제 적재
feedCacheHandler.getTopIds();
feedCacheHandler.getFeedDetail(feed1.getPostId());
feedCacheHandler.getFeedDetail(feed2.getPostId());
feedCacheHandler.getFeedDetail(feed3.getPostId());


// when
String accessToken = jwtUtil.createAccessToken(withdrawUser.getUserId());
mockMvc.perform(delete("/users")
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());

// 캐시 갱신 확인하기위해 강제 커밋
TestTransaction.flagForCommit();
TestTransaction.end();

// then
Cache topCache = cacheManager.getCache("feedIdTop");
assertThat(topCache).isNotNull();
Cache detailCache = cacheManager.getCache("feedDetail");
assertThat(detailCache).isNotNull();

List<Long> topIds = topCache.get("top100", List.class);

// 1. 탈퇴 유저 피드 제거 확인
assertThat(topIds)
.doesNotContain(feed1.getPostId(), feed2.getPostId());
// 2. 다른 유저 피드는 유지
assertThat(topIds)
.contains(feed3.getPostId());
// 3. detail 캐시에서 탈퇴 유저 피드만 제거
assertThat(detailCache.get(feed1.getPostId())).isNull();
assertThat(detailCache.get(feed2.getPostId())).isNull();

// 4. 다른 유저 피드는 여전히 존재
assertThat(detailCache.get(feed3.getPostId())).isNotNull();
}
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

캐시 갱신이 비동기라면 대기/재시도 보강이 필요합니다.

커밋 직후 즉시 캐시를 단정하고 있어, 비동기 이벤트 처리 시 간헐적 실패 가능성이 있습니다. 캐시가 갱신될 때까지 짧게 재시도/대기하는 방식으로 안정화해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/konkuk/thip/user/adapter/in/web/UserDeleteApiTest.java` around
lines 428 - 488, The test deleteUser_shouldRefreshFeedCache assumes synchronous
cache invalidation; add a short retry/wait loop after
TestTransaction.flagForCommit()/TestTransaction.end() and before accessing
cacheManager.getCache(...) to poll until the expected cache state appears (or
timeout) to account for asynchronous event processing. Specifically, after
committing the transaction (TestTransaction.flagForCommit();
TestTransaction.end();) call feedCacheHandler.getTopIds() or read from
cacheManager.getCache("feedIdTop") in a small loop (sleep ~50-200ms between
attempts) and re-evaluate assertions (topIds and detailCache reads) until they
match the expected post-deletion state or a max wait is reached, failing the
test if timed out; update assertions to use the final polled values. Ensure you
reference the existing symbols: deleteUser_shouldRefreshFeedCache,
TestTransaction.flagForCommit, TestTransaction.end, feedCacheHandler.getTopIds,
cacheManager.getCache("feedIdTop"), and cacheManager.getCache("feedDetail").

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[THIP2025-403] [refactor] 피드조회 api 성능 개선

1 participant