diff --git a/build.gradle b/build.gradle index 40cc629a7..3e7e9f0c3 100644 --- a/build.gradle +++ b/build.gradle @@ -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 diff --git a/loadtest/feed/feed_home_scrolling_test.js b/loadtest/feed/feed_home_scrolling_test.js new file mode 100644 index 000000000..660d23f3e --- /dev/null +++ b/loadtest/feed/feed_home_scrolling_test.js @@ -0,0 +1,69 @@ +//100명의 사용자가 동시에 각자 다른 속도로 홈 피드를 1페이지부터 3페이지까지 탐색하는 시나리오 +import http from 'k6/http'; +import { sleep,check } from 'k6'; + +const BASE_URL = 'http://localhost:8000'; +const MAX_VUS = 500; + +export let options = { + stages: [ + { duration: '20s', target: 200 }, // 20초 동안 100명까지 증가 + { duration: '40s', target: MAX_VUS }, // 40초 동안 300명까지 증가하며 피크 부하 + { duration: '20s', target: 0 }, // 20초 동안 0명으로 하강 + ], + thresholds: { + http_req_duration: ['p(95)<80'], + http_req_failed: ['rate<0.01'], + }, +}; + +// 테스트 전 사용자 별 토큰 발급 +export function setup() { + let tokens = []; + + // 유저 ID에 대해 토큰을 미리 발급 + for (let userId = 1; userId <= MAX_VUS; userId++) { + const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`); + check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 }); + tokens.push(res.body); + } + + return { tokens: tokens }; +} + +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초 사이 랜덤하게 쉬기 + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/ThipServerApplication.java b/src/main/java/konkuk/thip/ThipServerApplication.java index 8a2d53b91..8403f429f 100644 --- a/src/main/java/konkuk/thip/ThipServerApplication.java +++ b/src/main/java/konkuk/thip/ThipServerApplication.java @@ -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 diff --git a/src/main/java/konkuk/thip/config/cache/CacheConfig.java b/src/main/java/konkuk/thip/config/cache/CacheConfig.java new file mode 100644 index 000000000..e547f50b4 --- /dev/null +++ b/src/main/java/konkuk/thip/config/cache/CacheConfig.java @@ -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 caches = Arrays.stream(CacheType.values()) + .map(cacheType -> new CaffeineCache( + cacheType.getCacheName(), + caffeineBuilder(cacheType) + )) + .collect(Collectors.toList()); + + cacheManager.setCaches(caches); + return cacheManager; + } + + private Cache caffeineBuilder(CacheType cacheType) { + Caffeine builder = Caffeine.newBuilder() + .recordStats() + .maximumSize(cacheType.getMaximumSize()); + + // 0초 무한유지 설정 + if (cacheType.getExpireAfterWrite() > 0) { + builder.expireAfterWrite(cacheType.getExpireAfterWrite(), TimeUnit.SECONDS); + } + + return builder.build(); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/config/cache/CacheType.java b/src/main/java/konkuk/thip/config/cache/CacheType.java new file mode 100644 index 000000000..f278ecae8 --- /dev/null +++ b/src/main/java/konkuk/thip/config/cache/CacheType.java @@ -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; +} diff --git a/src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListener.java b/src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListener.java new file mode 100644 index 000000000..379a73230 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListener.java @@ -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()); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListener.java b/src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListener.java new file mode 100644 index 000000000..ef274b978 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListener.java @@ -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 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()); + } + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandler.java b/src/main/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandler.java new file mode 100644 index 000000000..80b23c4b7 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandler.java @@ -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 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; + } + + public void updateTopIdsWithNewId(Long newId) { + Cache cache = cacheManager.getCache("feedIdTop"); + if (cache == null) return; + + String cacheKey = "top" + DEFAULT_CACHE_SIZE; + List currentIds = cache.get(cacheKey, List.class); + + if (currentIds == null) { + currentIds = feedJpaRepository.findTopFeedIds(DEFAULT_CACHE_SIZE); + } + + List updatedIds = new ArrayList<>(currentIds); + updatedIds.add(0, newId); // 최신 피드를 맨 앞으로 + + if (updatedIds.size() > DEFAULT_CACHE_SIZE) { + updatedIds.remove(DEFAULT_CACHE_SIZE); + } + + cache.put(cacheKey, updatedIds); + } + + 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 feedIds) { + Cache detailCache = cacheManager.getCache("feedDetail"); + if (detailCache == null || feedIds.isEmpty()) return; + + List details = feedJpaRepository.findFeedDetailsByIds(feedIds); + + for (FeedQueryDto dto : details) { + detailCache.put(dto.feedId(), dto); + } + } + + public void refreshCacheAfterBulkDelete(List deletedFeedIds) { + Cache cache = cacheManager.getCache("feedIdTop"); + if (cache == null) return; + + // 1. 현재 캐시된 인덱스 확보 + String cacheKey = "top" + DEFAULT_CACHE_SIZE; + List currentIds = cache.get(cacheKey, List.class); + if (currentIds == null) { + return; + } + // 2. 포함 여부 확인 + boolean hasTopFeed = deletedFeedIds.stream().anyMatch(currentIds::contains); + + if (hasTopFeed) { + // 3. 기존 리스트에서 삭제 대상 제거 + List oldIdsWithoutDeleted = new ArrayList<>(currentIds); + oldIdsWithoutDeleted.removeAll(deletedFeedIds); + + // 4. 상세 캐시에서 탈퇴자 피드 제거 + evictFeeds(deletedFeedIds); + + // 5. 인덱스 강제 갱신 + List newTopIds = feedJpaRepository.findTopFeedIds(DEFAULT_CACHE_SIZE); + cache.put(cacheKey, newTopIds); + + // 6. 신규 진입 ID 추출 + List newlyAddedIds = new ArrayList<>(newTopIds); + newlyAddedIds.removeAll(oldIdsWithoutDeleted); + + // 7. 신규 진입 피드들만 정밀 워밍업 + warmUpFeedDetails(newlyAddedIds); + } + } + + private void evictFeeds(List feedIds) { + Cache cache = cacheManager.getCache("feedDetail"); + if (cache != null && feedIds != null) { + feedIds.forEach(cache::evict); + } + } + +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/adapter/out/cache/FeedQueryCacheAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/cache/FeedQueryCacheAdapter.java new file mode 100644 index 000000000..1f1533930 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/out/cache/FeedQueryCacheAdapter.java @@ -0,0 +1,121 @@ +package konkuk.thip.feed.adapter.out.cache; + +import java.util.List; +import java.util.Set; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.feed.adapter.out.persistence.FeedQueryPersistenceAdapter; +import konkuk.thip.feed.application.port.out.FeedQueryPort; +import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Repository; + +@Primary +@Repository +@RequiredArgsConstructor +public class FeedQueryCacheAdapter implements FeedQueryPort { + + private final FeedCacheHandler feedCacheHandler; + private final FeedQueryPersistenceAdapter persistenceAdapter; + + @Override + public CursorBasedList findLatestFeedsByFeedId(Long userId, Cursor cursor) { + Long lastPostId = cursor.isFirstRequest() ? Long.MAX_VALUE : cursor.getLong(0); + int size = cursor.getPageSize(); + + // 1. 인덱스 캐시 확인 + List topIds = feedCacheHandler.getTopIds(); + + // 2. 캐시 범위 안의 데이터인지 확인 + if (!topIds.isEmpty() && lastPostId > topIds.get(topIds.size() - 1)) { + + // 캐시에서 조건에 맞는 데이터 필터링 + List feedQueryDtos = topIds.stream() + .filter(id -> id < lastPostId) + .map(feedCacheHandler::getFeedDetail) + .filter(dto -> dto != null && (dto.isPublic() || dto.creatorId().equals(userId))) + .limit(size + 1) + .toList(); + + if (feedQueryDtos.size() >= size) { + return CursorBasedList.of(feedQueryDtos, size, dto -> + new Cursor(List.of(dto.feedId().toString())).toEncodedString()); + } + } + + // 3. 캐시 범위를 벗어나면 DB 어댑터로 위임 + return persistenceAdapter.findLatestFeedsByFeedId(userId, cursor); + } + + @Override + public FeedQueryDto getFeedDetail(Long feedId) { + FeedQueryDto cachedDetail = feedCacheHandler.getFeedDetail(feedId); + + if (cachedDetail != null) { + return cachedDetail; + } + + return persistenceAdapter.getFeedDetail(feedId); + } + + @Override + public Set findUserIdsByBookId(Long bookId) { + return persistenceAdapter.findUserIdsByBookId(bookId); + } + + @Override + public CursorBasedList findFeedsByFollowingPriority(Long userId, Cursor cursor) { + return persistenceAdapter.findFeedsByFollowingPriority(userId, cursor); + } + + @Override + public CursorBasedList findMyFeedsByCreatedAt(Long userId, Cursor cursor) { + return persistenceAdapter.findMyFeedsByCreatedAt(userId, cursor); + } + + @Override + public CursorBasedList findSpecificUserFeedsByCreatedAt(Long feedOwnerId, Cursor cursor) { + return persistenceAdapter.findSpecificUserFeedsByCreatedAt(feedOwnerId, cursor); + } + + @Override + public int countAllFeedsByUserId(Long userId) { + return persistenceAdapter.countAllFeedsByUserId(userId); + } + + @Override + public int countPublicFeedsByUserId(Long userId) { + return persistenceAdapter.countPublicFeedsByUserId(userId); + } + + @Override + public Set findSavedFeedIdsByUserIdAndFeedIds(Set feedIds, Long userId) { + return persistenceAdapter.findSavedFeedIdsByUserIdAndFeedIds(feedIds, userId); + } + + @Override + public boolean existsSavedFeedByUserIdAndFeedId(Long userId, Long feedId) { + return persistenceAdapter.existsSavedFeedByUserIdAndFeedId(userId, feedId); + } + + @Override + public CursorBasedList findSavedFeedsBySavedAt(Long userId, Cursor cursor) { + return persistenceAdapter.findSavedFeedsBySavedAt(userId, cursor); + } + + @Override + public CursorBasedList findFeedsByBookIsbnOrderByLike(String isbn, Long userId, Cursor cursor) { + return persistenceAdapter.findFeedsByBookIsbnOrderByLike(isbn, userId, cursor); + } + + @Override + public CursorBasedList findFeedsByBookIsbnOrderByLatest(String isbn, Long userId, Cursor cursor) { + return persistenceAdapter.findFeedsByBookIsbnOrderByLatest(isbn, userId, cursor); + } + + @Override + public List findLatestPublicFeedCreatorsIn(Set userIds, int size) { + return persistenceAdapter.findLatestPublicFeedCreatorsIn(userIds, size); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedCreatedEvent.java b/src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedCreatedEvent.java new file mode 100644 index 000000000..703906515 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedCreatedEvent.java @@ -0,0 +1,7 @@ +package konkuk.thip.feed.adapter.out.event.dto; + +public record FeedCreatedEvent(Long feedId) { + public static FeedCreatedEvent from(Long feedId) { + return new FeedCreatedEvent(feedId); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedDeletedEvent.java b/src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedDeletedEvent.java new file mode 100644 index 000000000..045b7e190 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedDeletedEvent.java @@ -0,0 +1,7 @@ +package konkuk.thip.feed.adapter.out.event.dto; + +public record FeedDeletedEvent(Long feedId) { + public static FeedDeletedEvent from(Long feedId) { + return new FeedDeletedEvent(feedId); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedUpdatedEvent.java b/src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedUpdatedEvent.java new file mode 100644 index 000000000..f4950d597 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/out/event/dto/FeedUpdatedEvent.java @@ -0,0 +1,8 @@ + +package konkuk.thip.feed.adapter.out.event.dto; + +public record FeedUpdatedEvent(Long feedId) { + public static FeedUpdatedEvent from(Long feedId) { + return new FeedUpdatedEvent(feedId); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java index a998f4f67..32aa5edc1 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java @@ -100,12 +100,12 @@ public void deleteAllSavedFeedByUserId(Long userId) { } @Override - public void deleteAllFeedByUserId(Long userId) { + public List deleteAllFeedByUserId(Long userId) { // 1. 유저가 작성한 피드 게시글 ID 리스트 조회 List feedIds = feedJpaRepository.findFeedIdsByUserId(userId); // 1. 유저가 작성한 피드 게시글 ID 리스트 조회 if (feedIds == null || feedIds.isEmpty()) { - return; // early return + return null; // early return } // 2-1. 댓글 좋아요 일괄 삭제 commentLikeJpaRepository.deleteAllByPostIds(feedIds); @@ -117,6 +117,8 @@ public void deleteAllFeedByUserId(Long userId) { savedFeedJpaRepository.deleteAllByFeedIds(feedIds); // 5. 탈퇴한 유저가 작성한 피드 게시글 soft delete 일괄 처리 feedJpaRepository.softDeleteAllByUserId(userId); + + return feedIds; } @Override diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java index a0ffbe84a..153f533f2 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java @@ -1,6 +1,5 @@ package konkuk.thip.feed.adapter.out.persistence; -import konkuk.thip.common.entity.StatusType; import konkuk.thip.common.util.Cursor; import konkuk.thip.common.util.CursorBasedList; import konkuk.thip.feed.adapter.out.mapper.FeedMapper; @@ -30,33 +29,37 @@ public Set findUserIdsByBookId(Long bookId) { return feedJpaRepository.findUserIdsByBookId(bookId); } + @Override + public FeedQueryDto getFeedDetail(Long feedId) { + return feedJpaRepository.findFeedDetailById(feedId); + } + @Override public CursorBasedList findFeedsByFollowingPriority(Long userId, Cursor cursor) { Integer lastPriority = cursor.isFirstRequest() ? null : cursor.getInteger(0); - LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(1); + Long lastPostId = cursor.isFirstRequest() ? null : cursor.getLong(1); int size = cursor.getPageSize(); - List feedQueryDtos = feedJpaRepository.findFeedsByFollowingPriority(userId, lastPriority, lastCreatedAt, size); + List feedQueryDtos = feedJpaRepository.findFeedsByFollowingPriority(userId, lastPriority, lastPostId, size); return CursorBasedList.of(feedQueryDtos, size, feedQueryDto -> { Cursor nextCursor = new Cursor(List.of( Boolean.TRUE.equals(feedQueryDto.isPriorityFeed()) ? "1" : "0", - feedQueryDto.createdAt().toString() + feedQueryDto.feedId().toString() )); return nextCursor.toEncodedString(); }); } @Override - public CursorBasedList findLatestFeedsByCreatedAt(Long userId, Cursor cursor) { - LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); + public CursorBasedList findLatestFeedsByFeedId(Long userId, Cursor cursor) { + Long lastPostId = cursor.isFirstRequest() ? null : cursor.getLong(0); int size = cursor.getPageSize(); - List feedQueryDtos = feedJpaRepository.findLatestFeedsByCreatedAt(userId, lastCreatedAt, size); + List feedQueryDtos = feedJpaRepository.findLatestFeedsByFeedId(userId, lastPostId, size); return CursorBasedList.of(feedQueryDtos, size, feedQueryDto -> { - Cursor nextCursor = new Cursor(List.of(feedQueryDto.createdAt().toString())); - return nextCursor.toEncodedString(); + return new Cursor(List.of(feedQueryDto.feedId().toString())).toEncodedString(); }); } diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java index a9b2fcfdc..fdc49eac7 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java @@ -9,9 +9,9 @@ public interface FeedQueryRepository { Set findUserIdsByBookId(Long bookId); - List findFeedsByFollowingPriority(Long userId, Integer lastPriority, LocalDateTime lastCreatedAt, int size); + List findFeedsByFollowingPriority(Long userId, Integer lastPriority, Long lastPostId, int size); - List findLatestFeedsByCreatedAt(Long userId, LocalDateTime lastCreatedAt, int size); + List findLatestFeedsByFeedId(Long userId, Long lastPostId, int size); List findMyFeedsByCreatedAt(Long userId, LocalDateTime lastCreatedAt, int size); @@ -24,4 +24,10 @@ public interface FeedQueryRepository { List findLatestPublicFeedCreatorsIn(Set userIds, int size); List findSavedFeedsByCreatedAt(Long userId, LocalDateTime lastCreatedAt, int size); + + List findTopFeedIds(int size); + + FeedQueryDto findFeedDetailById(Long feedId); + + List findFeedDetailsByIds(List feedIds); } diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java index fbd079a4a..9b7d6341f 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java @@ -1,5 +1,7 @@ package konkuk.thip.feed.adapter.out.persistence.repository; +import static konkuk.thip.common.entity.StatusType.ACTIVE; + import com.querydsl.core.Tuple; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.CaseBuilder; @@ -13,8 +15,6 @@ import konkuk.thip.feed.adapter.out.jpa.QSavedFeedJpaEntity; import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; import konkuk.thip.feed.application.port.out.dto.QFeedQueryDto; -import konkuk.thip.post.application.port.out.dto.PostQueryDto; -import konkuk.thip.post.application.port.out.dto.QPostQueryDto; import konkuk.thip.user.adapter.out.jpa.QFollowingJpaEntity; import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; import lombok.RequiredArgsConstructor; @@ -53,9 +53,9 @@ public Set findUserIdsByBookId(Long bookId) { } @Override - public List findFeedsByFollowingPriority(Long userId, Integer lastPriority, LocalDateTime lastCreatedAt, int size) { + public List findFeedsByFollowingPriority(Long userId, Integer lastPriority, Long lastPostId, int size) { // 1) 게시글 ID만 우선순위 + 페이징으로 조회 - List tuples = fetchFeedIdsAndPriorityByFollowingPriority(userId, lastPriority, lastCreatedAt, size); + List tuples = fetchFeedIdsAndPriorityByFollowingPriority(userId, lastPriority, lastPostId, size); if (tuples.isEmpty()) { return List.of(); // early return } @@ -84,23 +84,26 @@ public List findFeedsByFollowingPriority(Long userId, Integer last } @Override - public List findLatestFeedsByCreatedAt(Long userId, LocalDateTime lastCreatedAt, int size) { - // 1) 게시글 ID만 최신순 페이징으로 조회 - List feedIds = fetchFeedIdsLatest(userId, lastCreatedAt, size); - if (feedIds.isEmpty()) { - return List.of(); // early return - } + public List findLatestFeedsByFeedId(Long userId, Long lastPostId, int size) { + // 1) 게시글 최신순 페이징으로 조회 + List entities = jpaQueryFactory + .selectFrom(feed) + .join(feed.userJpaEntity, user).fetchJoin() + .join(feed.bookJpaEntity, book).fetchJoin() + .where( + // 1) 공개 여부 및 내 글 필터링 + feed.userJpaEntity.userId.eq(userId).or(feed.isPublic.isTrue()), + // 2) 커서 기반 페이징 + lastPostId != null ? feed.postId.lt(lastPostId) : null + ) + .orderBy(feed.postId.desc()) + .limit(size + 1) + .fetch(); - // 2) 상세 엔티티 조회 및 정렬 - List entities = fetchFeedEntitiesByIds(feedIds); - Map entityMap = entities.stream() - .collect(Collectors.toMap(FeedJpaEntity::getPostId, e -> e)); - List ordered = feedIds.stream() - .map(entityMap::get) - .toList(); + if (entities.isEmpty()) return List.of(); // early return - // 3) DTO 변환 (priority 없음) - return ordered.stream() + // 2) DTO 변환 (priority 없음) + return entities.stream() .map(e -> toDto(e, null)) .toList(); } @@ -108,7 +111,7 @@ public List findLatestFeedsByCreatedAt(Long userId, LocalDateTime /** * ID 목록만 우선순위 & 커서 페이징으로 조회 */ - private List fetchFeedIdsAndPriorityByFollowingPriority(Long userId, Integer lastPriority, LocalDateTime lastCreatedAt, int size) { + private List fetchFeedIdsAndPriorityByFollowingPriority(Long userId, Integer lastPriority, Long lastPostId, int size) { // 내가 작성한 모든 글 + 내가 팔로우하는 다른 유저가 작성한 공개글을 우선적으로 최신순 조회 // 이후 내가 팔로우하지 않는 다른 유저가 작성한 공개글을 최신순 조회 NumberExpression priority = new CaseBuilder() @@ -121,10 +124,10 @@ private List fetchFeedIdsAndPriorityByFollowingPriority(Long userId, Inte .otherwise(0); // 복합 커서 조건: 우선순위 및 생성일시 기준 - BooleanExpression cursorCondition = (lastPriority != null && lastCreatedAt != null) + BooleanExpression cursorCondition = (lastPriority != null && lastPostId != null) ? priority.lt(lastPriority) .or(priority.eq(lastPriority) - .and(feed.createdAt.lt(lastCreatedAt))) + .and(feed.postId.lt(lastPostId))) : Expressions.TRUE; return jpaQueryFactory @@ -138,7 +141,7 @@ private List fetchFeedIdsAndPriorityByFollowingPriority(Long userId, Inte feed.userJpaEntity.userId.eq(userId).or(feed.isPublic.eq(true)), cursorCondition ) - .orderBy(priority.desc(), feed.createdAt.desc()) + .orderBy(priority.desc(), feed.postId.desc()) .limit(size + 1) .fetch(); } @@ -146,16 +149,16 @@ private List fetchFeedIdsAndPriorityByFollowingPriority(Long userId, Inte /** * ID 목록만 최신순 커서 페이징으로 조회 */ - private List fetchFeedIdsLatest(Long userId, LocalDateTime lastCreatedAt, int size) { + private List 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)), - lastCreatedAt != null ? feed.createdAt.lt(lastCreatedAt) : Expressions.TRUE + lastPostId != null ? feed.postId.lt(lastPostId) : null ) - .orderBy(feed.createdAt.desc()) + .orderBy(feed.postId.desc()) .limit(size + 1) .fetch(); } @@ -390,6 +393,53 @@ public List findSavedFeedsByCreatedAt(Long userId, LocalDateTime l .fetch(); } + @Override + public List findTopFeedIds(int size) { + return jpaQueryFactory + .select(feed.postId) + .from(feed) + .where(feed.status.eq(ACTIVE)) + .orderBy(feed.postId.desc()) + .limit(size) + .fetch(); + } + + @Override + public FeedQueryDto findFeedDetailById(Long feedId) { + FeedJpaEntity entity = jpaQueryFactory + .selectFrom(feed) + .join(feed.userJpaEntity, user).fetchJoin() + .join(feed.bookJpaEntity, book).fetchJoin() + .where(feed.postId.eq(feedId)) + .fetchOne(); + + if (entity == null) { + return null; + } + + return toDto(entity, null); + } + + @Override + public List findFeedDetailsByIds(List feedIds) { + if (feedIds == null || feedIds.isEmpty()) { + return List.of(); + } + + // 1. IN 절을 사용하여 여러 엔티티를 한 번에 페치 조인으로 조회 + List entities = jpaQueryFactory + .selectFrom(feed) + .join(feed.userJpaEntity, user).fetchJoin() + .join(feed.bookJpaEntity, book).fetchJoin() + .where(feed.postId.in(feedIds)) // 벌크 조회 + .fetch(); + + // 2. 조회된 엔티티들을 DTO 리스트로 변환하여 반환 + return entities.stream() + .map(entity -> toDto(entity, null)) + .toList(); + } + /** * SavedFeed 전용 DTO 매핑 */ diff --git a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java index 6bf3f89dd..168cf598d 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java @@ -1,6 +1,7 @@ package konkuk.thip.feed.application.port.out; +import java.util.List; import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.feed.domain.Feed; @@ -25,5 +26,5 @@ default Feed getByIdOrThrowForUpdate(Long id) { void saveSavedFeed(Long userId, Long feedId); void deleteSavedFeed(Long userId, Long feedId); void deleteAllSavedFeedByUserId(Long userId); - void deleteAllFeedByUserId(Long userId); + List deleteAllFeedByUserId(Long userId); } diff --git a/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java b/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java index d358bc70e..262235459 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java @@ -10,12 +10,13 @@ public interface FeedQueryPort { Set findUserIdsByBookId(Long bookId); + FeedQueryDto getFeedDetail(Long feedId); /** * 전체 피드 조회 */ CursorBasedList findFeedsByFollowingPriority(Long userId, Cursor cursor); - CursorBasedList findLatestFeedsByCreatedAt(Long userId, Cursor cursor); + CursorBasedList findLatestFeedsByFeedId(Long userId, Cursor cursor); /** * 내 피드 조회 diff --git a/src/main/java/konkuk/thip/feed/application/service/BasicFeedShowAllService.java b/src/main/java/konkuk/thip/feed/application/service/BasicFeedShowAllService.java index 94dbf4b14..bc83bbc33 100644 --- a/src/main/java/konkuk/thip/feed/application/service/BasicFeedShowAllService.java +++ b/src/main/java/konkuk/thip/feed/application/service/BasicFeedShowAllService.java @@ -42,7 +42,7 @@ public FeedShowAllResponse showAllFeeds(Long userId, String cursor) { Cursor nextCursor = Cursor.from(cursor, PAGE_SIZE); // 2. [최신순으로] 피드 조회 with 페이징 처리 - CursorBasedList result = feedQueryPort.findLatestFeedsByCreatedAt(userId, nextCursor); + CursorBasedList result = feedQueryPort.findLatestFeedsByFeedId(userId, nextCursor); Set feedIds = result.contents().stream() .map(FeedQueryDto::feedId) .collect(Collectors.toUnmodifiableSet()); diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java b/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java index b439dfcb5..b430d1d2d 100644 --- a/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java +++ b/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java @@ -5,6 +5,7 @@ import konkuk.thip.book.application.port.out.BookCommandPort; import konkuk.thip.book.domain.Book; import konkuk.thip.common.s3.service.ImageUrlValidationService; +import konkuk.thip.feed.adapter.out.event.dto.FeedCreatedEvent; import konkuk.thip.feed.application.port.in.FeedCreateUseCase; import konkuk.thip.feed.application.port.in.dto.FeedCreateCommand; import konkuk.thip.feed.application.port.out.FeedCommandPort; @@ -17,6 +18,7 @@ import konkuk.thip.user.application.port.out.UserQueryPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,6 +36,7 @@ public class FeedCreateService implements FeedCreateUseCase { private final ImageUrlValidationService imageUrlValidationService; private final FeedNotificationOrchestrator feedNotificationOrchestrator; + private final ApplicationEventPublisher eventPublisher; @Override @Transactional @@ -64,6 +67,9 @@ public Long createFeed(FeedCreateCommand command) { // 5. 피드 작성 푸쉬 알림 전송 sendNotifications(command, savedFeedId); + // 6. 캐시 갱신 이벤트 발행 + eventPublisher.publishEvent(FeedCreatedEvent.from(savedFeedId)); + return savedFeedId; } diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedDeleteService.java b/src/main/java/konkuk/thip/feed/application/service/FeedDeleteService.java index 723c2232b..bcc78c388 100644 --- a/src/main/java/konkuk/thip/feed/application/service/FeedDeleteService.java +++ b/src/main/java/konkuk/thip/feed/application/service/FeedDeleteService.java @@ -1,11 +1,13 @@ package konkuk.thip.feed.application.service; import konkuk.thip.comment.application.port.out.CommentCommandPort; +import konkuk.thip.feed.adapter.out.event.dto.FeedDeletedEvent; import konkuk.thip.feed.application.port.in.FeedDeleteUseCase; import konkuk.thip.feed.application.port.out.FeedCommandPort; import konkuk.thip.feed.domain.Feed; import konkuk.thip.post.application.port.out.PostLikeCommandPort; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +19,8 @@ public class FeedDeleteService implements FeedDeleteUseCase { private final CommentCommandPort commentCommandPort; private final PostLikeCommandPort postLikeCommandPort; + private final ApplicationEventPublisher eventPublisher; + @Override @Transactional public void deleteFeed(Long feedId, Long userId) { @@ -35,5 +39,7 @@ public void deleteFeed(Long feedId, Long userId) { postLikeCommandPort.deleteAllByPostId(feedId); // 3-3. 피드 삭제 및 관련 엔티티(피드_태그, 콘텐츠, 피드 저장) 삭제 feedCommandPort.delete(feed); + // 4. 캐시 갱신 이벤트 발행 + eventPublisher.publishEvent(FeedDeletedEvent.from(feedId)); } } diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java b/src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java index f22435342..9c0f9019d 100644 --- a/src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java +++ b/src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java @@ -1,5 +1,6 @@ package konkuk.thip.feed.application.service; +import konkuk.thip.feed.adapter.out.event.dto.FeedUpdatedEvent; import konkuk.thip.feed.application.port.in.FeedUpdateUseCase; import konkuk.thip.feed.application.port.in.dto.FeedUpdateCommand; import konkuk.thip.feed.application.port.out.FeedCommandPort; @@ -8,6 +9,7 @@ import konkuk.thip.feed.domain.value.TagList; import konkuk.thip.feed.domain.value.ContentList; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +19,8 @@ public class FeedUpdateService implements FeedUpdateUseCase { private final FeedCommandPort feedCommandPort; + private final ApplicationEventPublisher eventPublisher; + @Override @Transactional //TODO 추후 이벤트 기반으로 트랜잭션 커밋후 S3 삭제하도록 리펙토링 or 사용하지 않는 이미지 배치 삭제방식 논의 @@ -33,7 +37,12 @@ public Long updateFeed(FeedUpdateCommand command) { applyPartialFeedUpdate(feed, command); // 4. 업데이트 - return feedCommandPort.update(feed); + Long updatedFeedId = feedCommandPort.update(feed); + + // 5. 캐시 갱신 이벤트 발행 + eventPublisher.publishEvent(FeedUpdatedEvent.from(updatedFeedId)); + + return updatedFeedId; } private void applyPartialFeedUpdate(Feed feed, FeedUpdateCommand command) { diff --git a/src/main/java/konkuk/thip/user/adapter/out/event/dto/UserWithdrawnEvent.java b/src/main/java/konkuk/thip/user/adapter/out/event/dto/UserWithdrawnEvent.java new file mode 100644 index 000000000..b6eb168cc --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/out/event/dto/UserWithdrawnEvent.java @@ -0,0 +1,9 @@ +package konkuk.thip.user.adapter.out.event.dto; + +import java.util.List; + +public record UserWithdrawnEvent(Long userId, List deletedFeedIds) { + public static UserWithdrawnEvent of(Long userId, List deletedFeedIds) { + return new UserWithdrawnEvent(userId, deletedFeedIds); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java b/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java index 842f91dd3..f7bf5c8e7 100644 --- a/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java +++ b/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java @@ -1,5 +1,6 @@ package konkuk.thip.user.application.service; +import java.util.List; import konkuk.thip.book.application.port.out.BookCommandPort; import konkuk.thip.comment.application.port.out.CommentCommandPort; import konkuk.thip.comment.application.port.out.CommentLikeCommandPort; @@ -11,12 +12,14 @@ import konkuk.thip.roompost.application.port.out.AttendanceCheckCommandPort; import konkuk.thip.roompost.application.port.out.RecordCommandPort; import konkuk.thip.roompost.application.port.out.VoteCommandPort; +import konkuk.thip.user.adapter.out.event.dto.UserWithdrawnEvent; import konkuk.thip.user.application.port.UserTokenBlacklistCommandPort; import konkuk.thip.user.application.port.in.UserDeleteUseCase; import konkuk.thip.user.application.port.out.FollowingCommandPort; import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,6 +43,7 @@ public class UserDeleteService implements UserDeleteUseCase { private final RoomParticipantCommandPort roomParticipantCommandPort; private final UserTokenBlacklistCommandPort userTokenBlacklistCommandPort; + private final ApplicationEventPublisher eventPublisher; @Override @Transactional @@ -77,7 +81,7 @@ public void deleteUser(Long userId, String authToken) { postLikeCommandPort.deleteAllByUserId(userId); // 피드 삭제 - feedCommandPort.deleteAllFeedByUserId(userId); + List deletedFeedIds = feedCommandPort.deleteAllFeedByUserId(userId); // 기록 삭제 recordCommandPort.deleteAllByUserId(userId); // 투표 삭제 @@ -89,6 +93,7 @@ public void deleteUser(Long userId, String authToken) { userCommandPort.delete(user); // 토큰 블랙리스트 추가 userTokenBlacklistCommandPort.addTokenToBlacklist(authToken); + // 탈퇴 이벤트(캐시 갱신) 발행 + eventPublisher.publishEvent(UserWithdrawnEvent.of(userId, deletedFeedIds)); } - } diff --git a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java index a70d734f4..85f330d13 100644 --- a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java +++ b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java @@ -62,7 +62,7 @@ public static UserJpaEntity createUser(Alias alias) { return UserJpaEntity.builder() .nickname("테스터") .nicknameUpdatedAt(LocalDate.now().minusMonths(7)) - .oauth2Id("kakao_12345678") + .oauth2Id("kakao_" + UUID.randomUUID()) .alias(alias) .role(UserRole.USER) .build(); @@ -72,7 +72,7 @@ public static UserJpaEntity createUser(Alias alias, String nickname) { return UserJpaEntity.builder() .nickname(nickname) .nicknameUpdatedAt(LocalDate.now().minusMonths(7)) - .oauth2Id("kakao_12345678") + .oauth2Id("kakao_" + UUID.randomUUID()) .alias(alias) .role(UserRole.USER) .build(); diff --git a/src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListenerTest.java b/src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListenerTest.java new file mode 100644 index 000000000..42cbfb7d7 --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheEventListenerTest.java @@ -0,0 +1,110 @@ +package konkuk.thip.feed.adapter.in.event; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; +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 org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.transaction.TestTransaction; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("[단위] FeedCacheEventListener 단위 테스트") +class FeedCacheEventListenerTest { + + @Autowired + private ApplicationEventPublisher publisher; + + @MockitoBean + private FeedCacheHandler feedCacheHandler; + + @Test + @Transactional + @DisplayName("FeedCreatedEvent 발행 → 커밋 후 handleFeedCreatedEvent가 호출된다") + void handleFeedCreatedEvent_Success() { + // given + Long feedId = 1L; + FeedCreatedEvent event = FeedCreatedEvent.from(feedId); + + // when + publisher.publishEvent(event); + + // 커밋 시점 강제 시뮬레이션 + TestTransaction.flagForCommit(); + TestTransaction.end(); + + // then + verify(feedCacheHandler, times(1)).updateTopIdsWithNewId(feedId); + verify(feedCacheHandler, times(1)).getFeedDetail(feedId); + } + + @Test + @Transactional + @DisplayName("FeedDeletedEvent 이벤트 발행 → 커밋 후 handleFeedDeletedEvent가 호출된다") + void handleFeedDeletedEvent_Success() { + // given + Long feedId = 1L; + FeedDeletedEvent event = FeedDeletedEvent.from(feedId); + + // when + publisher.publishEvent(event); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + // then + verify(feedCacheHandler, times(1)).markAsDeleted(feedId); + } + + @Test + @Transactional + @DisplayName("FeedUpdatedEvent 이벤트 발행 → 커밋 후 handleFeedUpdatedEvent가 호출된다") + void handleFeedUpdatedEvent_Success() { + // given + Long feedId = 1L; + FeedUpdatedEvent event = FeedUpdatedEvent.from(feedId); + + // evictFeedDetailIfPresent가 true를 반환한다고 가정 + given(feedCacheHandler.evictFeedDetailIfPresent(feedId)).willReturn(true); + + // when + publisher.publishEvent(event); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + // then + verify(feedCacheHandler, times(1)).evictFeedDetailIfPresent(feedId); + verify(feedCacheHandler, times(1)).getFeedDetail(feedId); + } + + @Test + @Transactional + @DisplayName("UserWithdrawnEvent 이벤트 발행 → 커밋 후 handleUserWithdrawn가 호출된다") + void handleUserWithdrawn_Success() { + // given + Long userId = 1L; + List deletedFeedIds = List.of(1L, 2L, 3L); + UserWithdrawnEvent event = UserWithdrawnEvent.of(userId, deletedFeedIds); + + // when + publisher.publishEvent(event); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + // then + verify(feedCacheHandler, times(1)).refreshCacheAfterBulkDelete(deletedFeedIds); + } + +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListenerTest.java b/src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListenerTest.java new file mode 100644 index 000000000..996749790 --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/event/FeedCacheWarmupListenerTest.java @@ -0,0 +1,82 @@ +package konkuk.thip.feed.adapter.in.event; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; +import konkuk.thip.feed.adapter.out.cache.FeedCacheHandler; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@SpringBootTest( + properties = "cache.warmup.enabled=true" +) +@ActiveProfiles("test") +@DisplayName("[단위] FeedCacheWarmupListener 단위 테스트") +class FeedCacheWarmupListenerTest { + + @Autowired private ApplicationEventPublisher publisher; + @MockitoBean private FeedCacheHandler feedCacheHandler; + + @Test + @DisplayName("ApplicationReadyEvent 이벤트 발행 → 캐시 워밍업(ID 조회 및 상세 캐싱)이 수행된다") + void handleContextReady_Success() { + // 컨텍스트 로딩 시점에 자동 실행된 기록 지우기 + Mockito.clearInvocations(feedCacheHandler); + + // given + List mockTopIds = List.of(1L, 2L, 3L); + given(feedCacheHandler.getTopIds()).willReturn(mockTopIds); + + // when 수동으로 ApplicationReadyEvent 이벤트 발행 + publisher.publishEvent(new ApplicationReadyEvent( + Mockito.mock(SpringApplication.class), + new String[]{}, + Mockito.mock(ConfigurableApplicationContext.class), + null + )); + + // then + verify(feedCacheHandler, times(1)).getTopIds(); + verify(feedCacheHandler, times(1)).warmUpFeedDetails(mockTopIds); + } + + @Test + @DisplayName("워밍업 중 예외 발생 → 애플리케이션이 종료되지 않고 로그만 남겨야 한다") + void handleContextReady_Exception() { + // 컨텍스트 로딩 시점에 자동 실행된 기록 지우기 + Mockito.clearInvocations(feedCacheHandler); + + // given 첫 번째 단계인 getTopIds에서 에러가 터지도록 설정 + given(feedCacheHandler.getTopIds()).willThrow(new RuntimeException("DB Connection Error")); + + // when & then: 예외가 밖으로 던져지지 않는지 확인 + assertDoesNotThrow(() -> { + publisher.publishEvent(new ApplicationReadyEvent( + Mockito.mock(SpringApplication.class), + new String[]{}, + Mockito.mock(ConfigurableApplicationContext.class), + null + )); + }); + + // 에러가 났으므로 그 다음 단계인 상세 데이터 캐싱은 호출되지 않아야 함 + verify(feedCacheHandler, times(1)).getTopIds(); + verify(feedCacheHandler, never()).warmUpFeedDetails(anyList()); + } + + +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllCacheApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllCacheApiTest.java new file mode 100644 index 000000000..2183054ea --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllCacheApiTest.java @@ -0,0 +1,326 @@ +package konkuk.thip.feed.adapter.in.web; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.jpa.SavedFeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.SavedFeedJpaRepository; +import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; +import konkuk.thip.post.adapter.out.jpa.PostLikeJpaEntity; +import konkuk.thip.post.adapter.out.persistence.repository.PostLikeJpaRepository; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest( + properties = "feed.show.strategy=basic" +) +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@Transactional +@DisplayName("[통합] 피드 전체 조회(최신순 조회) api 통합 테스트 - Cache 어댑터") +class BasicFeedShowAllCacheApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private FeedJpaRepository feedJpaRepository; + + @Autowired + private BookJpaRepository bookJpaRepository; + + @Autowired + private SavedFeedJpaRepository savedFeedJpaRepository; + + @Autowired + private PostLikeJpaRepository postLikeJpaRepository; + + @BeforeEach + void clearCache() { + cacheManager.getCacheNames().forEach(name -> cacheManager.getCache(name).clear()); + } + + @Test + @DisplayName("피드 조회를 요청할 경우, [feedId, 작성자 닉네임, ,,] 의 피드 정보를 최신순으로 정렬해서 반환한다.") + void feed_show_all_test() throws Exception { + //given + Alias a0 = TestEntityFactory.createLiteratureAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user1")); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + + // 피드 생성 + FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true)); + savedFeedJpaRepository.save( + SavedFeedJpaEntity.builder() + .userJpaEntity(me) // me가 f1을 저장하였음 + .feedJpaEntity(f1) + .build() + ); + + FeedJpaEntity f2 = feedJpaRepository.save(TestEntityFactory.createFeed(user1, book, true)); + postLikeJpaRepository.save( + PostLikeJpaEntity.builder() + .userJpaEntity(me) // me가 f2를 좋아요 하였음 + .postJpaEntity(f2) + .build() + ); + + // 캐싱 데이터 삽입 + Cache topCache = cacheManager.getCache("feedIdTop"); + topCache.put("top100", List.of(f2.getPostId(), f1.getPostId())); + + Cache detailCache = cacheManager.getCache("feedDetail"); + detailCache.put(f1.getPostId(), createDto(f1.getPostId(), me.getUserId(), "me", true)); + detailCache.put(f2.getPostId(), createDto(f2.getPostId(), user1.getUserId(), "user1", true)); + + //when //then + mockMvc.perform(get("/feeds") + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedList", hasSize(2))) + /** + * 정렬 조건 + * 내 글 & 다른 모든 유저의 공개 글을 최신순 조회 + */ + // 1순위: 팔로잉 글 f2 + .andExpect(jsonPath("$.data.feedList[0].feedId", is(f2.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[0].creatorNickname", is("user1"))) + .andExpect(jsonPath("$.data.feedList[0].isSaved", is(false))) + .andExpect(jsonPath("$.data.feedList[0].isLiked", is(true))) + // 2순위: 내 글 f1 + .andExpect(jsonPath("$.data.feedList[1].feedId", is(f1.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[1].creatorNickname", is("me"))) + .andExpect(jsonPath("$.data.feedList[1].isSaved", is(true))) + .andExpect(jsonPath("$.data.feedList[1].isLiked", is(false))); + } + + @Test + @DisplayName("피드는 [유저 본인이 작성한 글, 다른 모든 유저가 작성한 공개 글을 최신순] 으로 반환한다.") + void feed_show_with_priority_and_order() throws Exception { + //given + Long myId = 999L; Long user1Id = 1L; Long user2Id = 2L; Long user3Id = 3L; + + Long f1Id = 1L; Long f2Id = 2L; Long f3Id = 3L; + Long f4Id = 4L; Long f5Id = 5L; Long f6Id = 6L; + Long f7Id = 7L; Long f8Id = 8L; Long f9Id = 9L; + Long f10Id = 10L; Long f11Id = 11L; Long f12Id = 12L; Long f13Id = 13L; Long f14Id = 14L; + + Cache topCache = cacheManager.getCache("feedIdTop"); + topCache.put("top100", + List.of( + f14Id, f13Id, f12Id, f11Id, f10Id, + f9Id, f8Id, f7Id, f6Id, f5Id, + f4Id, f3Id, f2Id, f1Id + ) + ); + + // 피드 생성 -> 비공개 글: f3, f5, f9, f12 + // feed 작성 순서 : f1 -> ... f14 (f14가 가장 최신) + Cache detailCache = cacheManager.getCache("feedDetail"); + detailCache.put(f1Id, createDto(f1Id, myId, "me", true)); + detailCache.put(f2Id, createDto(f2Id, user1Id, "user1", true)); + detailCache.put(f3Id, createDto(f3Id, user1Id, "user1", false)); // 비공개 + detailCache.put(f4Id, createDto(f4Id, user2Id, "user2", true)); + detailCache.put(f5Id, createDto(f5Id, user2Id, "user2", false)); // 비공개 + detailCache.put(f6Id, createDto(f6Id, user3Id, "user3", true)); + detailCache.put(f7Id, createDto(f7Id, user1Id, "user1", true)); + detailCache.put(f8Id, createDto(f8Id, user2Id, "user2", true)); + detailCache.put(f9Id, createDto(f9Id, user3Id, "user3", false)); // 비공개 + detailCache.put(f10Id, createDto(f10Id, user1Id, "user1", true)); + detailCache.put(f11Id, createDto(f11Id, user2Id, "user2", true)); + detailCache.put(f12Id, createDto(f12Id, user3Id, "user3", false)); // 비공개 + detailCache.put(f13Id, createDto(f13Id, user1Id, "user1", true)); + detailCache.put(f14Id, createDto(f14Id, user2Id, "user2", true)); + + //when //then + mockMvc.perform(get("/feeds") + .requestAttr("userId", myId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedList", hasSize(10))) + /** + * 정렬 조건 + * 내 글 & 다른 모든 유저의 공개 글을 최신순 조회 + */ + .andExpect(jsonPath("$.data.feedList[0].feedId", is(f14Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[1].feedId", is(f13Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[2].feedId", is(f11Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[3].feedId", is(f10Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[4].feedId", is(f8Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[5].feedId", is(f7Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[6].feedId", is(f6Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[7].feedId", is(f4Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[8].feedId", is(f2Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[9].feedId", is(f1Id.intValue()))); + + } + + @Test + @DisplayName("request parameter의 cursor 값이 null일 경우, 첫번째 페이지에 해당하는 피드 10개와, nextCursor, last 값을 반환한다.") + void feed_show_first_page() throws Exception { + //given + Long myId = 999L; Long user1Id = 1L; Long user2Id = 2L; Long user3Id = 3L; + + Long f1Id = 1L; Long f2Id = 2L; Long f3Id = 3L; + Long f4Id = 4L; Long f5Id = 5L; Long f6Id = 6L; + Long f7Id = 7L; Long f8Id = 8L; Long f9Id = 9L; + Long f10Id = 10L; Long f11Id = 11L; Long f12Id = 12L; + + Cache topCache = cacheManager.getCache("feedIdTop"); + topCache.put("top100", + List.of( + f12Id, f11Id, f10Id, f9Id, + f8Id, f7Id, f6Id, f5Id, + f4Id, f3Id, f2Id, f1Id + ) + ); + + // 피드 생성 및 생성일 직접 설정 -> 모두 공개 글 + // feed 작성 순서 : f1 -> f2 -> ... f12 + Cache detailCache = cacheManager.getCache("feedDetail"); + detailCache.put(f1Id, createDto(f1Id, myId, "me", true)); + detailCache.put(f2Id, createDto(f2Id, user1Id, "user1", true)); + detailCache.put(f3Id, createDto(f3Id, user1Id, "user1", true)); + detailCache.put(f4Id, createDto(f4Id, user2Id, "user2", true)); + detailCache.put(f5Id, createDto(f5Id, user2Id, "user2", true)); + detailCache.put(f6Id, createDto(f6Id, user3Id, "user3", true)); + detailCache.put(f7Id, createDto(f7Id, user1Id, "user1", true)); + detailCache.put(f8Id, createDto(f8Id, user2Id, "user2", true)); + detailCache.put(f9Id, createDto(f9Id, user3Id, "user3", true)); + detailCache.put(f10Id, createDto(f10Id, user1Id, "user1", true)); + detailCache.put(f11Id, createDto(f11Id, user2Id, "user2", true)); + detailCache.put(f12Id, createDto(f12Id, user3Id, "user3", true)); + + //when //then + mockMvc.perform(get("/feeds") + .requestAttr("userId", myId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.nextCursor", notNullValue())) + .andExpect(jsonPath("$.data.isLast", is(false))) + .andExpect(jsonPath("$.data.feedList", hasSize(10))) + /** + * 정렬 조건 + * 내 글 & 다른 모든 유저의 공개 글을 최신순 조회 + */ + .andExpect(jsonPath("$.data.feedList[0].feedId", is(f12Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[1].feedId", is(f11Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[2].feedId", is(f10Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[3].feedId", is(f9Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[4].feedId", is(f8Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[5].feedId", is(f7Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[6].feedId", is(f6Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[7].feedId", is(f5Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[8].feedId", is(f4Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[9].feedId", is(f3Id.intValue()))); + } + + @Test + @DisplayName("request parameter의 cursor 값이 존재할 경우, 해당 페이지에 해당하는 피드 10개와, nextCursor, last 값을 반환한다.") + void feed_show_with_cursor() throws Exception { + //given + Long myId = 999L; Long user1Id = 1L; + + Long f1Id = 1L; Long f2Id = 2L; Long f3Id = 3L; Long f4Id = 4L; Long f5Id = 5L; + Long f6Id = 6L; Long f7Id = 7L; Long f8Id = 8L; Long f9Id = 9L; Long f10Id = 10L; + Long f11Id = 11L; Long f12Id = 12L; Long f13Id = 13L; Long f14Id = 14L; Long f15Id = 15L; + Long f16Id = 16L; Long f17Id = 17L; Long f18Id = 18L; Long f19Id = 19L; Long f20Id = 20L; + + Cache topCache = cacheManager.getCache("feedIdTop"); + topCache.put("top100", + List.of( + f20Id, f19Id, f18Id, f17Id, f16Id, + f15Id, f14Id, f13Id, f12Id, f11Id, + f10Id, f9Id, f8Id, f7Id, f6Id, + f5Id, f4Id, f3Id, f2Id, f1Id + ) + ); + + // 피드 생성 및 생성일 직접 설정 -> 모두 공개 글 + // feed 작성 순서 : f1 -> f2 -> ... f20 + Cache detailCache = cacheManager.getCache("feedDetail"); + List allIds = List.of( + f1Id,f2Id,f3Id,f4Id,f5Id,f6Id,f7Id,f8Id,f9Id,f10Id, + f11Id,f12Id,f13Id,f14Id,f15Id,f16Id,f17Id,f18Id,f19Id,f20Id + ); + for (Long id : allIds) { + detailCache.put(id, createDto(id, user1Id, "user", true)); + } + + String nextCursor = f11Id.toString(); + + //when //then + mockMvc.perform(get("/feeds") + .requestAttr("userId", myId) + .param("cursor", nextCursor)) // 이전에 f11 까지 조회 -> 11의 postId가 커서 + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.nextCursor", nullValue())) // nextCursor 는 null + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.feedList", hasSize(10))) + /** + * 정렬 조건 + * 내 글 & 다른 모든 유저의 공개 글을 최신순 조회 + */ + .andExpect(jsonPath("$.data.feedList[0].feedId", is(f10Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[1].feedId", is(f9Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[2].feedId", is(f8Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[3].feedId", is(f7Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[4].feedId", is(f6Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[5].feedId", is(f5Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[6].feedId", is(f4Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[7].feedId", is(f3Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[8].feedId", is(f2Id.intValue()))) + .andExpect(jsonPath("$.data.feedList[9].feedId", is(f1Id.intValue()))); + } + + private FeedQueryDto createDto(Long feedId, Long creatorId, String nickname, boolean isPublic) { + return FeedQueryDto.builder() + .feedId(feedId) + .creatorId(creatorId) + .creatorNickname(nickname) + .creatorProfileImageUrl("/profile_science.png") + .alias("과학자") + .createdAt(LocalDateTime.now()) + .isbn(UUID.randomUUID().toString().replace("-", "").substring(0, 13)) + .bookTitle("테스트 책") + .bookAuthor("테스트 저자") + .contentBody("기본 피드 본문입니다.") + .contentUrls(new String[]{}) + .likeCount(0) + .commentCount(0) + .isPublic(isPublic) + .isPriorityFeed(false) + .savedCreatedAt(null) + .build(); + } +} diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllPersistenceApiTest.java similarity index 71% rename from src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllApiTest.java rename to src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllPersistenceApiTest.java index b9760a385..cf1eb8565 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/BasicFeedShowAllPersistenceApiTest.java @@ -3,7 +3,9 @@ import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.cache.FeedQueryCacheAdapter; import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.FeedQueryPersistenceAdapter; import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; import konkuk.thip.post.adapter.out.jpa.PostLikeJpaEntity; import konkuk.thip.post.adapter.out.persistence.repository.PostLikeJpaRepository; @@ -13,22 +15,23 @@ import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import konkuk.thip.user.adapter.out.persistence.repository.following.FollowingJpaRepository; import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; -import java.sql.Timestamp; -import java.time.LocalDateTime; import java.util.List; import static org.hamcrest.Matchers.*; import static org.hamcrest.Matchers.is; +import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -39,8 +42,9 @@ @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) @Transactional -@DisplayName("[통합] 피드 전체 조회(최신순 조회) api 통합 테스트") -class BasicFeedShowAllApiTest { +@DisplayName("[통합] 피드 전체 조회(최신순 조회) api 통합 테스트 - Persistence 어댑터") +class BasicFeedShowAllPersistenceApiTest { + @Autowired private MockMvc mockMvc; @@ -62,8 +66,31 @@ class BasicFeedShowAllApiTest { @Autowired private PostLikeJpaRepository postLikeJpaRepository; + @MockitoBean + private FeedQueryCacheAdapter feedQueryCacheAdapter; + @Autowired - private JdbcTemplate jdbcTemplate; + private FeedQueryPersistenceAdapter persistenceAdapter; + + @BeforeEach + void setup() { + // 캐시 어댑터 대신 실제 DB 어댑터 로직을 수행하도록 설정 + given(feedQueryCacheAdapter.findLatestFeedsByFeedId(any(), any())) + .willAnswer(invocation -> { + return persistenceAdapter.findLatestFeedsByFeedId( + invocation.getArgument(0), + invocation.getArgument(1) + ); + }); + + given(feedQueryCacheAdapter.findSavedFeedIdsByUserIdAndFeedIds(any(), any())) + .willAnswer(invocation -> { + return persistenceAdapter.findSavedFeedIdsByUserIdAndFeedIds( + invocation.getArgument(0), + invocation.getArgument(1) + ); + }); + } @Test @DisplayName("피드 조회를 요청할 경우, [feedId, 작성자 닉네임, ,,] 의 피드 정보를 최신순으로 정렬해서 반환한다.") @@ -75,8 +102,7 @@ void feed_show_all_test() throws Exception { BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); // 공통 Book - // 피드 생성 및 생성일 직접 설정 - LocalDateTime base = LocalDateTime.now(); + // 피드 생성 FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); savedFeedJpaRepository.save( SavedFeedJpaEntity.builder() @@ -93,16 +119,6 @@ void feed_show_all_test() throws Exception { .build() ); - // JPA flush 후, native update 로 created_at 덮어쓰기 - // feed 작성 순서 : f2 -> f1 (f1 이 가장 최신) - feedJpaRepository.flush(); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(1)), f1.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(10)), f2.getPostId()); - //when //then mockMvc.perform(get("/feeds") .requestAttr("userId", me.getUserId())) @@ -112,22 +128,22 @@ void feed_show_all_test() throws Exception { * 정렬 조건 * 내 글 & 다른 모든 유저의 공개 글을 최신순 조회 */ - // 1순위: 내 글 f1 - .andExpect(jsonPath("$.data.feedList[0].feedId", is(f1.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[0].creatorNickname", is("me"))) - .andExpect(jsonPath("$.data.feedList[0].contentUrls", hasSize(2))) - .andExpect(jsonPath("$.data.feedList[0].likeCount", is(10))) - .andExpect(jsonPath("$.data.feedList[0].commentCount", is(5))) - .andExpect(jsonPath("$.data.feedList[0].isSaved", is(true))) - .andExpect(jsonPath("$.data.feedList[0].isLiked", is(false))) - // 2순위: 팔로잉 글 f2 - .andExpect(jsonPath("$.data.feedList[1].feedId", is(f2.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[1].creatorNickname", is("user1"))) - .andExpect(jsonPath("$.data.feedList[1].contentUrls", hasSize(0))) - .andExpect(jsonPath("$.data.feedList[1].likeCount", is(50))) - .andExpect(jsonPath("$.data.feedList[1].commentCount", is(10))) - .andExpect(jsonPath("$.data.feedList[1].isSaved", is(false))) - .andExpect(jsonPath("$.data.feedList[1].isLiked", is(true))); + // 1순위: 팔로잉 글 f2 + .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 + .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))); } @Test @@ -145,7 +161,8 @@ void feed_show_with_priority_and_order() throws Exception { BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); // 공통 Book - // 피드 생성 및 생성일 직접 설정 -> f1, f2, f4, f6 : 공개 글, f3, f5 : 비공개 글 + // 피드 생성 -> f1, f2, f4, f6 : 공개 글, f3, f5 : 비공개 글 + // feed 작성 순서 : f1 -> f2 -> f3 -> f4 -> f5 -> f6 (f6이 가장 최신) FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); FeedJpaEntity f2 = feedJpaRepository.save(TestEntityFactory.createFeed(user1, book, true, 50, 10, List.of())); FeedJpaEntity f3 = feedJpaRepository.save(TestEntityFactory.createFeed(user1, book, false, 10, 5, List.of("contentUrl1", "contentUrl2"))); @@ -153,30 +170,6 @@ void feed_show_with_priority_and_order() throws Exception { FeedJpaEntity f5 = feedJpaRepository.save(TestEntityFactory.createFeed(user2, book, false, 10, 5, List.of("contentUrl1", "contentUrl2"))); FeedJpaEntity f6 = feedJpaRepository.save(TestEntityFactory.createFeed(user3, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); - // JPA flush 후, native update 로 created_at 덮어쓰기 - // feed 작성 순서 : f5 -> f4 -> f3 -> f2 -> f1 -> f6 (f6이 가장 최신) - feedJpaRepository.flush(); - - LocalDateTime base = LocalDateTime.now(); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(5)), f1.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(10)), f2.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(15)), f3.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(20)), f4.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(25)), f5.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(1)), f6.getPostId()); - //when //then mockMvc.perform(get("/feeds") .requestAttr("userId", me.getUserId())) @@ -187,9 +180,9 @@ void feed_show_with_priority_and_order() throws Exception { * 내 글 & 다른 모든 유저의 공개 글을 최신순 조회 */ .andExpect(jsonPath("$.data.feedList[0].feedId", is(f6.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[1].feedId", is(f1.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[1].feedId", is(f4.getPostId().intValue()))) .andExpect(jsonPath("$.data.feedList[2].feedId", is(f2.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[3].feedId", is(f4.getPostId().intValue()))); + .andExpect(jsonPath("$.data.feedList[3].feedId", is(f1.getPostId().intValue()))); } @Test @@ -207,6 +200,7 @@ void feed_show_first_page() throws Exception { BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); // 공통 Book // 피드 생성 및 생성일 직접 설정 -> 모두 공개 글 + // feed 작성 순서 : f1 -> f2 -> ... f12 FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); FeedJpaEntity f2 = feedJpaRepository.save(TestEntityFactory.createFeed(user1, book, true, 50, 10, List.of())); FeedJpaEntity f3 = feedJpaRepository.save(TestEntityFactory.createFeed(user1, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); @@ -220,48 +214,6 @@ void feed_show_first_page() throws Exception { FeedJpaEntity f11 = feedJpaRepository.save(TestEntityFactory.createFeed(user2, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); FeedJpaEntity f12 = feedJpaRepository.save(TestEntityFactory.createFeed(user2, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); - // JPA flush 후, native update 로 created_at 덮어쓰기 - // feed 작성 순서 : f12 -> f11 -> ,,, -> f1 순 - feedJpaRepository.flush(); - - LocalDateTime base = LocalDateTime.now(); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(5)), f1.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(10)), f2.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(15)), f3.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(20)), f4.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(25)), f5.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(30)), f6.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(35)), f7.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(40)), f8.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(45)), f9.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(50)), f10.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(55)), f11.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(60)), f12.getPostId()); - //when //then mockMvc.perform(get("/feeds") .requestAttr("userId", me.getUserId())) @@ -273,16 +225,16 @@ void feed_show_first_page() throws Exception { * 정렬 조건 * 내 글 & 다른 모든 유저의 공개 글을 최신순 조회 */ - .andExpect(jsonPath("$.data.feedList[0].feedId", is(f1.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[1].feedId", is(f2.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[2].feedId", is(f3.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[3].feedId", is(f4.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[4].feedId", is(f5.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[5].feedId", is(f6.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[6].feedId", is(f7.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[7].feedId", is(f8.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[8].feedId", is(f9.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[9].feedId", is(f10.getPostId().intValue()))); + .andExpect(jsonPath("$.data.feedList[0].feedId", is(f12.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[1].feedId", is(f11.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[2].feedId", is(f10.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[3].feedId", is(f9.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[4].feedId", is(f8.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[5].feedId", is(f7.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[6].feedId", is(f6.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[7].feedId", is(f5.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[8].feedId", is(f4.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[9].feedId", is(f3.getPostId().intValue()))); } @Test @@ -313,36 +265,13 @@ void feed_show_with_cursor() throws Exception { FeedJpaEntity f11 = feedJpaRepository.save(TestEntityFactory.createFeed(user2, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); FeedJpaEntity f12 = feedJpaRepository.save(TestEntityFactory.createFeed(user2, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); - // JPA flush 후, native update 로 created_at 덮어쓰기 - // feed 작성 순서 : f12 -> f11 -> ,,, -> f1 순 - feedJpaRepository.flush(); - - LocalDateTime base = LocalDateTime.now(); - LocalDateTime t10 = base.minusMinutes(50); - LocalDateTime t11 = base.minusMinutes(55); - LocalDateTime t12 = base.minusMinutes(60); - - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(t10), f10.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(t11), f11.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(t12), f12.getPostId()); - - // DB에 저장된 f10의 createdAt 값을 native query 로 조회 - LocalDateTime nextCursorVal = jdbcTemplate.queryForObject( - "SELECT created_at FROM posts WHERE post_id = ?", - (rs, rowNum) -> rs.getTimestamp("created_at").toLocalDateTime(), f10.getPostId() - ); - String nextCursor = nextCursorVal.toString(); + // feed 작성 순서 : f1 -> f2 -> ,,, -> f12 순 + String nextCursor = f3.getPostId().toString(); //when //then mockMvc.perform(get("/feeds") .requestAttr("userId", me.getUserId()) - .param("cursor", nextCursor)) // 이전에 f10 까지 조회 -> f10의 createdAt이 커서 + .param("cursor", nextCursor)) // 이전에 f3 까지 조회 -> f3의 postId가 커서 .andExpect(status().isOk()) .andExpect(jsonPath("$.data.nextCursor", nullValue())) // nextCursor 는 null .andExpect(jsonPath("$.data.isLast", is(true))) @@ -351,7 +280,7 @@ void feed_show_with_cursor() throws Exception { * 정렬 조건 * 내 글 & 다른 모든 유저의 공개 글을 최신순 조회 */ - .andExpect(jsonPath("$.data.feedList[0].feedId", is(f11.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[1].feedId", is(f12.getPostId().intValue()))); + .andExpect(jsonPath("$.data.feedList[0].feedId", is(f2.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[1].feedId", is(f1.getPostId().intValue()))); } } diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java index f59955975..69a27a41a 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java @@ -17,8 +17,11 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.transaction.TestTransaction; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.transaction.annotation.Transactional; @@ -46,6 +49,9 @@ class FeedCreateApiTest { @Value("${cloud.aws.s3.cloud-front-base-url}") private String cloudFrontBaseUrl; + @Autowired + private CacheManager cacheManager; + @Autowired private ObjectMapper objectMapper; @@ -305,4 +311,53 @@ void createFeedWithoutTags_shouldNotHaveFeedTags() throws Exception { FeedJpaEntity feedJpaEntity = feedJpaRepository.findById(postId).orElse(null); assertThat(feedJpaEntity.getTagList().toUnmodifiableList().size()).isEqualTo(0); } + + @Test + @DisplayName("피드 생성 후 캐시(feedIdTop, feedDetail)가 갱신된다.") + void createFeed_shouldRefreshCache() throws Exception { + + // given + bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + + Map 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 topIds = topCache.get("top100", List.class); + + assertThat(topIds).isNotNull(); + assertThat(topIds).contains(feedId); + } + } diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java index 0d4627b3d..5119e4a35 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java @@ -6,6 +6,7 @@ import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; import konkuk.thip.comment.adapter.out.persistence.repository.CommentLikeJpaRepository; import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.cache.FeedCacheHandler; import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; import konkuk.thip.feed.adapter.out.persistence.repository.SavedFeedJpaRepository; @@ -19,7 +20,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.transaction.TestTransaction; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; @@ -41,6 +45,10 @@ class FeedDeleteApiTest { @Autowired private MockMvc mockMvc; + @Autowired + private CacheManager cacheManager; + @Autowired + private FeedCacheHandler feedCacheHandler; @Autowired private UserJpaRepository userJpaRepository; @Autowired private BookJpaRepository bookJpaRepository; @@ -59,7 +67,7 @@ class FeedDeleteApiTest { @BeforeEach void setUp() { user = userJpaRepository.save(TestEntityFactory.createUser(Alias.ARTIST)); - book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + book = bookJpaRepository.save(TestEntityFactory.createBook()); feed = feedJpaRepository.save(TestEntityFactory.createFeed(user, book, true,1,1,List.of("url1", "url2", "url3"))); postLikeJpaRepository.save(TestEntityFactory.createPostLike(user,feed)); comment = commentJpaRepository.save(TestEntityFactory.createComment(feed, user, FEED)); @@ -97,4 +105,28 @@ void deleteFeed_success() throws Exception { // 7) 게시글 좋아요(PostLike) 삭제 assertThat(postLikeJpaRepository.count()).isEqualTo(0); } + + @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(); + } } diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java index e25950dbc..4b477c5a8 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java @@ -4,8 +4,10 @@ import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.cache.FeedCacheHandler; import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; import konkuk.thip.feed.domain.value.Tag; import konkuk.thip.room.domain.value.Category; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; @@ -17,8 +19,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.transaction.TestTransaction; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.transaction.annotation.Transactional; @@ -46,6 +51,8 @@ class FeedUpdateApiTest { @Autowired private UserJpaRepository userJpaRepository; @Autowired private BookJpaRepository bookJpaRepository; @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private CacheManager cacheManager; + @Autowired private FeedCacheHandler feedCacheHandler; private UserJpaEntity user; private BookJpaEntity book; @@ -58,7 +65,7 @@ void setUp() { user = userJpaRepository.save(TestEntityFactory.createUser(alias)); Category category = TestEntityFactory.createLiteratureCategory(); - book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + book = bookJpaRepository.save(TestEntityFactory.createBook()); tags = List.of(KOREAN_NOVEL, FOREIGN_NOVEL, CLASSIC_LITERATURE); feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true, tags)); @@ -166,4 +173,40 @@ void updateFeedWithAllFields_shouldModifyEverythingCorrectly() throws Exception assertThat(tagCount).isEqualTo(2); } + @Test + @DisplayName("피드 수정 시 수정한 피드가 캐시에 적재되어있다면 해당하는 상세 캐시가 갱신된다.") + void updateFeed_shouldRefreshDetailCache() throws Exception { + + // given + Long feedId = feed.getPostId(); + + feedCacheHandler.getFeedDetail(feedId); //캐시에 먼저 적재 + Map 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("캐시 테스트용"); + } + } diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java index 3fd5aa97e..aa744b457 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java @@ -53,7 +53,7 @@ class FeedUpdateControllerTest { void setUp() { Alias alias = TestEntityFactory.createLiteratureAlias(); UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(alias)); - BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); savedFeedId = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true, List.of(KOREAN_NOVEL, FOREIGN_NOVEL, CLASSIC_LITERATURE))).getPostId(); creatorUserId = user.getUserId(); } diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FollowingPriorityFeedShowAllApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FollowingPriorityFeedShowAllApiTest.java index e489e42a4..1826122fa 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FollowingPriorityFeedShowAllApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FollowingPriorityFeedShowAllApiTest.java @@ -77,8 +77,7 @@ void feed_show_all_test() throws Exception { BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); // 공통 Book - // 피드 생성 및 생성일 직접 설정 - LocalDateTime base = LocalDateTime.now(); + // feed 작성 순서 : f1 -> f2 (f2가 가장 최신) FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); savedFeedJpaRepository.save( SavedFeedJpaEntity.builder() @@ -95,16 +94,6 @@ void feed_show_all_test() throws Exception { .build() ); - // JPA flush 후, native update 로 created_at 덮어쓰기 - // feed 작성 순서 : f2 -> f1 (f1 이 가장 최신) - feedJpaRepository.flush(); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(1)), f1.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(10)), f2.getPostId()); - //when //then mockMvc.perform(get("/feeds") .requestAttr("userId", me.getUserId())) @@ -115,22 +104,22 @@ void feed_show_all_test() throws Exception { * 내 글 & 내가 팔로잉 하는 유저의 공개 글을 최신순 조회 * -> 이후 내가 팔로잉 하지 않는 유저의 공개 글을 최신순 조회 */ - // 1순위: 내 글 f1 - .andExpect(jsonPath("$.data.feedList[0].feedId", is(f1.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[0].creatorNickname", is("me"))) - .andExpect(jsonPath("$.data.feedList[0].contentUrls", hasSize(2))) - .andExpect(jsonPath("$.data.feedList[0].likeCount", is(10))) - .andExpect(jsonPath("$.data.feedList[0].commentCount", is(5))) - .andExpect(jsonPath("$.data.feedList[0].isSaved", is(true))) - .andExpect(jsonPath("$.data.feedList[0].isLiked", is(false))) - // 2순위: 팔로잉 글 f2 - .andExpect(jsonPath("$.data.feedList[1].feedId", is(f2.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[1].creatorNickname", is("user1"))) - .andExpect(jsonPath("$.data.feedList[1].contentUrls", hasSize(0))) - .andExpect(jsonPath("$.data.feedList[1].likeCount", is(50))) - .andExpect(jsonPath("$.data.feedList[1].commentCount", is(10))) - .andExpect(jsonPath("$.data.feedList[1].isSaved", is(false))) - .andExpect(jsonPath("$.data.feedList[1].isLiked", is(true))); + // 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))); } @Test @@ -149,6 +138,7 @@ void feed_show_with_priority_and_order() throws Exception { BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); // 공통 Book // 피드 생성 및 생성일 직접 설정 -> f1, f2, f4, f6 : 공개 글, f3, f5 : 비공개 글 + // feed 작성 순서 : f1 -> f2 -> f3 -> f4 -> f5 -> f6 (f6이 가장 최신) FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); FeedJpaEntity f2 = feedJpaRepository.save(TestEntityFactory.createFeed(user1, book, true, 50, 10, List.of())); FeedJpaEntity f3 = feedJpaRepository.save(TestEntityFactory.createFeed(user1, book, false, 10, 5, List.of("contentUrl1", "contentUrl2"))); @@ -156,30 +146,6 @@ void feed_show_with_priority_and_order() throws Exception { FeedJpaEntity f5 = feedJpaRepository.save(TestEntityFactory.createFeed(user2, book, false, 10, 5, List.of("contentUrl1", "contentUrl2"))); FeedJpaEntity f6 = feedJpaRepository.save(TestEntityFactory.createFeed(user3, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); - // JPA flush 후, native update 로 created_at 덮어쓰기 - // feed 작성 순서 : f5 -> f4 -> f3 -> f2 -> f1 -> f6 (f6이 가장 최신) - feedJpaRepository.flush(); - - LocalDateTime base = LocalDateTime.now(); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(5)), f1.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(10)), f2.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(15)), f3.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(20)), f4.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(25)), f5.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(1)), f6.getPostId()); - //when //then mockMvc.perform(get("/feeds") .requestAttr("userId", me.getUserId())) @@ -190,9 +156,9 @@ void feed_show_with_priority_and_order() throws Exception { * 내 글 & 내가 팔로잉 하는 유저의 공개 글을 최신순 조회 * -> 이후 내가 팔로잉 하지 않는 유저의 공개 글을 최신순 조회 */ - .andExpect(jsonPath("$.data.feedList[0].feedId", is(f1.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[0].feedId", is(f4.getPostId().intValue()))) .andExpect(jsonPath("$.data.feedList[1].feedId", is(f2.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[2].feedId", is(f4.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[2].feedId", is(f1.getPostId().intValue()))) .andExpect(jsonPath("$.data.feedList[3].feedId", is(f6.getPostId().intValue()))); // f6은 me가 팔로잉 하지 않는 user3이 작성한 게시글이므로 우선순위가 낮다 } @@ -211,6 +177,7 @@ void feed_show_first_page() throws Exception { BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); // 공통 Book // 피드 생성 및 생성일 직접 설정 -> 모두 공개 글 + // feed 작성 순서 : f1 -> f2 -> ,,, -> f12 순 FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); FeedJpaEntity f2 = feedJpaRepository.save(TestEntityFactory.createFeed(user1, book, true, 50, 10, List.of())); FeedJpaEntity f3 = feedJpaRepository.save(TestEntityFactory.createFeed(user1, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); @@ -224,48 +191,6 @@ void feed_show_first_page() throws Exception { FeedJpaEntity f11 = feedJpaRepository.save(TestEntityFactory.createFeed(user2, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); FeedJpaEntity f12 = feedJpaRepository.save(TestEntityFactory.createFeed(user2, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); - // JPA flush 후, native update 로 created_at 덮어쓰기 - // feed 작성 순서 : f12 -> f11 -> ,,, -> f1 순 - feedJpaRepository.flush(); - - LocalDateTime base = LocalDateTime.now(); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(5)), f1.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(10)), f2.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(15)), f3.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(20)), f4.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(25)), f5.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(30)), f6.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(35)), f7.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(40)), f8.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(45)), f9.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(50)), f10.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(55)), f11.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(base.minusMinutes(60)), f12.getPostId()); - //when //then mockMvc.perform(get("/feeds") .requestAttr("userId", me.getUserId())) @@ -278,16 +203,16 @@ void feed_show_first_page() throws Exception { * 내 글 & 내가 팔로잉 하는 유저의 공개 글을 최신순 조회 * -> 이후 내가 팔로잉 하지 않는 유저의 공개 글을 최신순 조회 */ - .andExpect(jsonPath("$.data.feedList[0].feedId", is(f1.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[1].feedId", is(f2.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[2].feedId", is(f3.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[3].feedId", is(f4.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[4].feedId", is(f5.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[5].feedId", is(f6.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[6].feedId", is(f7.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[7].feedId", is(f8.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[8].feedId", is(f9.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[9].feedId", is(f10.getPostId().intValue()))); + .andExpect(jsonPath("$.data.feedList[0].feedId", is(f12.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[1].feedId", is(f11.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[2].feedId", is(f10.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[3].feedId", is(f9.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[4].feedId", is(f8.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[5].feedId", is(f7.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[6].feedId", is(f6.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[7].feedId", is(f5.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[8].feedId", is(f4.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[9].feedId", is(f3.getPostId().intValue()))); } @Test @@ -305,6 +230,7 @@ void feed_show_with_cursor() throws Exception { BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); // 공통 Book // 피드 생성 및 생성일 직접 설정 -> 모두 공개 글 + // feed 작성 순서 : f1 -> f2 -> ,,, -> f12 순 FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); FeedJpaEntity f2 = feedJpaRepository.save(TestEntityFactory.createFeed(user1, book, true, 50, 10, List.of())); FeedJpaEntity f3 = feedJpaRepository.save(TestEntityFactory.createFeed(user1, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); @@ -318,36 +244,11 @@ void feed_show_with_cursor() throws Exception { FeedJpaEntity f11 = feedJpaRepository.save(TestEntityFactory.createFeed(user2, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); FeedJpaEntity f12 = feedJpaRepository.save(TestEntityFactory.createFeed(user2, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); - // JPA flush 후, native update 로 created_at 덮어쓰기 - // feed 작성 순서 : f12 -> f11 -> ,,, -> f1 순 - feedJpaRepository.flush(); - - LocalDateTime base = LocalDateTime.now(); - LocalDateTime t10 = base.minusMinutes(50); - LocalDateTime t11 = base.minusMinutes(55); - LocalDateTime t12 = base.minusMinutes(60); - - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(t10), f10.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(t11), f11.getPostId()); - jdbcTemplate.update( - "UPDATE posts SET created_at = ? WHERE post_id = ?", - Timestamp.valueOf(t12), f12.getPostId()); - - // DB에 저장된 f10의 createdAt 값을 native query 로 조회 - LocalDateTime lastCreatedAt = jdbcTemplate.queryForObject( - "SELECT created_at FROM posts WHERE post_id = ?", - (rs, rowNum) -> rs.getTimestamp("created_at").toLocalDateTime(), f10.getPostId() - ); - String nextCursor = "1|" + lastCreatedAt.toString(); // MockMvc.param() 이 문자열을 내부적으로 한번 더 인코딩하므로 Cursor.toEncodedString 메서드 사용 X - + String nextCursor = "1|" + f3.getPostId().toString(); // MockMvc.param() 이 문자열을 내부적으로 한번 더 인코딩하므로 Cursor.toEncodedString 메서드 사용 X //when //then mockMvc.perform(get("/feeds") .requestAttr("userId", me.getUserId()) - .param("cursor", nextCursor)) // 이전에 f10 까지 조회 -> f10의 createdAt이 커서 + .param("cursor", nextCursor)) // 이전에 f3 까지 조회 -> f3의 feedId가 커서 .andExpect(status().isOk()) .andExpect(jsonPath("$.data.isLast", is(true))) .andExpect(jsonPath("$.data.feedList", hasSize(2))) @@ -356,7 +257,7 @@ void feed_show_with_cursor() throws Exception { * 내 글 & 내가 팔로잉 하는 유저의 공개 글을 최신순 조회 * -> 이후 내가 팔로잉 하지 않는 유저의 공개 글을 최신순 조회 */ - .andExpect(jsonPath("$.data.feedList[0].feedId", is(f11.getPostId().intValue()))) - .andExpect(jsonPath("$.data.feedList[1].feedId", is(f12.getPostId().intValue()))); + .andExpect(jsonPath("$.data.feedList[0].feedId", is(f2.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[1].feedId", is(f1.getPostId().intValue()))); } } diff --git a/src/test/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandlerTest.java b/src/test/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandlerTest.java new file mode 100644 index 000000000..3da6c6351 --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/out/cache/FeedCacheHandlerTest.java @@ -0,0 +1,120 @@ +package konkuk.thip.feed.adapter.out.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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 org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; + +@ExtendWith(MockitoExtension.class) +@DisplayName("[단위] FeedCacheHandlerTest 단위 테스트") +public class FeedCacheHandlerTest { + + @Mock FeedJpaRepository feedJpaRepository; + @Mock CacheManager cacheManager; + @Mock Cache topCache; + @Mock Cache detailCache; + + @InjectMocks FeedCacheHandler handler; + + private static final int DEFAULT_CACHE_SIZE = 100; + + @Test + @DisplayName("updateTopIdsWithNewId: 기존 캐시가 있으면 맨 앞에 추가하고 size 초과 시 제거한다.") + void update_top_ids_success() { + // given + String cacheKey = "top" + DEFAULT_CACHE_SIZE; + List current = new ArrayList<>(List.of(3L, 2L, 1L)); + + when(cacheManager.getCache("feedIdTop")).thenReturn(topCache); + when(topCache.get(cacheKey, List.class)).thenReturn(current); + + // when + handler.updateTopIdsWithNewId(4L); + + // then + verify(topCache).put(eq(cacheKey), argThat((List list) -> + list.get(0).equals(4L) && list.size() == 4 + )); + } + + @Test + @DisplayName("evictFeedDetailIfPresent: 캐시에 존재하면 제거하고 true를 반환한다.") + void evict_when_present() { + when(cacheManager.getCache("feedDetail")).thenReturn(detailCache); + when(detailCache.get(1L)).thenReturn(() -> new Object()); + + boolean result = handler.evictFeedDetailIfPresent(1L); + + assertThat(result).isTrue(); + verify(detailCache).evict(1L); + } + + @Test + @DisplayName("evictFeedDetailIfPresent: 캐시에 없으면 false를 반환한다.") + void evict_when_not_present() { + when(cacheManager.getCache("feedDetail")).thenReturn(detailCache); + when(detailCache.get(1L)).thenReturn(null); + + boolean result = handler.evictFeedDetailIfPresent(1L); + + assertThat(result).isFalse(); + verify(detailCache, never()).evict(any()); + } + + @Test + @DisplayName("warmUpFeedDetails: DB 조회 후 캐시에 저장한다.") + void warm_up_feed_details() { + List ids = List.of(1L, 2L); + + FeedQueryDto dto1 = mock(FeedQueryDto.class); + FeedQueryDto dto2 = mock(FeedQueryDto.class); + + when(dto1.feedId()).thenReturn(1L); + when(dto2.feedId()).thenReturn(2L); + + when(cacheManager.getCache("feedDetail")).thenReturn(detailCache); + when(feedJpaRepository.findFeedDetailsByIds(ids)) + .thenReturn(List.of(dto1, dto2)); + + handler.warmUpFeedDetails(ids); + + verify(detailCache).put(1L, dto1); + verify(detailCache).put(2L, dto2); + } + + @Test + @DisplayName("refreshCacheAfterBulkDelete: 삭제 대상이 topIds에 포함되면 인덱스 갱신한다.") + void refresh_when_contains_deleted() { + List currentTop = new ArrayList<>(List.of(5L, 4L, 3L)); + List deleted = List.of(5L); + + String cacheKey = "top" + DEFAULT_CACHE_SIZE; + when(cacheManager.getCache("feedIdTop")).thenReturn(topCache); + when(topCache.get(cacheKey, List.class)) + .thenReturn(currentTop); + + when(feedJpaRepository.findTopFeedIds(DEFAULT_CACHE_SIZE)) + .thenReturn(List.of(4L, 3L, 2L)); + + handler.refreshCacheAfterBulkDelete(deleted); + + verify(topCache).put(cacheKey, List.of(4L, 3L, 2L)); + } +} diff --git a/src/test/java/konkuk/thip/feed/adapter/out/cache/FeedQueryCacheAdapterTest.java b/src/test/java/konkuk/thip/feed/adapter/out/cache/FeedQueryCacheAdapterTest.java new file mode 100644 index 000000000..f7115e3ff --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/out/cache/FeedQueryCacheAdapterTest.java @@ -0,0 +1,239 @@ +package konkuk.thip.feed.adapter.out.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.feed.adapter.out.persistence.FeedQueryPersistenceAdapter; +import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("[단위] FeedQueryCacheAdapterTest 단위 테스트") +public class FeedQueryCacheAdapterTest { + + @Mock private FeedCacheHandler feedCacheHandler; + @Mock private FeedQueryPersistenceAdapter persistenceAdapter; + + @InjectMocks private FeedQueryCacheAdapter cacheAdapter; + + private final Long USER_ID = 1L; + + @Test + @DisplayName("findLatestFeedsByFeedId: 캐시 범위 안, size 충족하면 캐시 결과 반환하고(캐시 히트) DB를 호출하지 않는다.") + void return_from_cache_when_hit() { + // given + int pageSize = 5; + Cursor cursor = Cursor.from(null, pageSize); + + when(feedCacheHandler.getTopIds()) + .thenReturn(List.of(50L, 40L, 30L, 20L, 10L)); + + when(feedCacheHandler.getFeedDetail(50L)) + .thenReturn(createDto(50L, 2L, true)); + when(feedCacheHandler.getFeedDetail(40L)) + .thenReturn(createDto(40L, 3L, true)); + when(feedCacheHandler.getFeedDetail(30L)) + .thenReturn(createDto(30L, 4L, true)); + when(feedCacheHandler.getFeedDetail(20L)) + .thenReturn(createDto(20L, 5L, true)); + when(feedCacheHandler.getFeedDetail(10L)) + .thenReturn(createDto(10L, 6L, true)); + + // when + CursorBasedList result = + cacheAdapter.findLatestFeedsByFeedId(USER_ID, cursor); + + // then + assertNotNull(result); + verify(persistenceAdapter, never()) + .findLatestFeedsByFeedId(any(), any()); + } + + @Test + @DisplayName("findLatestFeedsByFeedId: 캐시 범위 안이지만 size 부족하면(캐시 부분히트) DB로 위임한다.") + void delegate_to_db_when_cache_insufficient() { + // given + int pageSize = 3; + Cursor cursor = Cursor.from(null, pageSize); + + when(feedCacheHandler.getTopIds()) + .thenReturn(List.of(50L, 40L)); // 보여줘야하는 페이지 사이즈보다 1작음 + + when(feedCacheHandler.getFeedDetail(50L)) + .thenReturn(createDto(50L, 2L, true)); + when(feedCacheHandler.getFeedDetail(40L)) + .thenReturn(createDto(40L, 3L, true)); + + CursorBasedList dbResult = + new CursorBasedList<>(List.of(), null, false); + when(persistenceAdapter.findLatestFeedsByFeedId(USER_ID, cursor)) + .thenReturn(dbResult); + + // when + cacheAdapter.findLatestFeedsByFeedId(USER_ID, cursor); + + // then + verify(persistenceAdapter, times(1)) + .findLatestFeedsByFeedId(USER_ID, cursor); + } + + @Test + @DisplayName("findLatestFeedsByFeedId: 캐시가 비어있으면(캐시 미스) DB로 위임한다.") + void delegate_to_db_when_cache_is_empty() { + // given + int pageSize = 2; + Cursor cursor = Cursor.from(null, pageSize); + + when(feedCacheHandler.getTopIds()) + .thenReturn(List.of()); // 캐시 비어있음 → 바로 DB + + CursorBasedList dbResult = + new CursorBasedList<>(List.of(), null, false); + when(persistenceAdapter.findLatestFeedsByFeedId(USER_ID, cursor)) + .thenReturn(dbResult); + + // when + cacheAdapter.findLatestFeedsByFeedId(USER_ID, cursor); + + // then + verify(persistenceAdapter, times(1)) + .findLatestFeedsByFeedId(USER_ID, cursor); + } + + @Test + @DisplayName("findLatestFeedsByFeedId: 캐시 범위를 벗어나면(캐시 미스) DB로 위임한다.") + void delegate_to_db_when_cursor_out_of_cache_range() { + // given + int pageSize = 2; + + // 캐시에는 100, 90, 80 있음 + // 커서가 70이면 → 70 > 80 false → 캐시 범위 벗어남 + Cursor cursor = Cursor.from("70", pageSize); + + when(feedCacheHandler.getTopIds()) + .thenReturn(List.of(100L, 90L, 80L)); + + CursorBasedList dbResult = + new CursorBasedList<>(List.of(), null, false); + when(persistenceAdapter.findLatestFeedsByFeedId(USER_ID, cursor)) + .thenReturn(dbResult); + + // when + cacheAdapter.findLatestFeedsByFeedId(USER_ID, cursor); + + // then + verify(persistenceAdapter, times(1)) + .findLatestFeedsByFeedId(USER_ID, cursor); + } + + @Test + @DisplayName("findLatestFeedsByFeedId: 캐시 데이터 필터링이 올바르게 동작한다.(공개글, 내가작성한 비공개글)") + void filter_logic_should_work_correctly() { + // given + int pageSize = 2; + Cursor cursor = Cursor.from(null, pageSize); + when(feedCacheHandler.getTopIds()) + .thenReturn(List.of(50L, 40L, 30L, 20L)); + + // 50 → 공개 (포함) + when(feedCacheHandler.getFeedDetail(50L)) + .thenReturn(createDto(50L, 2L, true)); + // 40 → 비공개 + 타인 (제외) + when(feedCacheHandler.getFeedDetail(40L)) + .thenReturn(createDto(40L, 3L, false)); + // 30 → 비공개 + 본인 (포함) + when(feedCacheHandler.getFeedDetail(30L)) + .thenReturn(createDto(30L, USER_ID, false)); + // 20 → null (제외) + when(feedCacheHandler.getFeedDetail(20L)) + .thenReturn(null); + + // when + CursorBasedList result = + cacheAdapter.findLatestFeedsByFeedId(USER_ID, cursor); + + // then + assertNotNull(result); + assertThat(result.contents()).hasSize(2); + assertThat(result.contents()) + .extracting(FeedQueryDto::feedId) + .containsExactly(50L, 30L); + + verify(persistenceAdapter, never()) + .findLatestFeedsByFeedId(any(), any()); + } + + @Test + @DisplayName("getFeedDetail: 캐시에 존재하면 DB를 호출하지 않는다.(캐시 히트)") + void get_detail_from_cache() { + // given + when(feedCacheHandler.getFeedDetail(1L)) + .thenReturn(createDto(1L, USER_ID,true)); + + // when + FeedQueryDto result = cacheAdapter.getFeedDetail(1L); + + // then + assertNotNull(result); + verify(persistenceAdapter, never()) + .getFeedDetail(any()); + } + + @Test + @DisplayName("getFeedDetail: 캐시에 없으면 DB를 호출한다.(캐시 미스)") + void get_detail_from_db_when_cache_miss() { + // given + when(feedCacheHandler.getFeedDetail(1L)) + .thenReturn(null); + + FeedQueryDto dbResult = createDto(1L, USER_ID, true); + when(persistenceAdapter.getFeedDetail(1L)) + .thenReturn(dbResult); + + // when + FeedQueryDto result = cacheAdapter.getFeedDetail(1L); + + // then + assertNotNull(result); + assertThat(result).isEqualTo(dbResult); + + verify(persistenceAdapter, times(1)) + .getFeedDetail(1L); + } + + private FeedQueryDto createDto(Long feedId, Long creatorId, boolean isPublic) { + return FeedQueryDto.builder() + .feedId(feedId) + .creatorId(creatorId) + .creatorNickname("유저") + .creatorProfileImageUrl("/profile_science.png") + .alias("과학자") + .createdAt(LocalDateTime.now()) + .isbn(UUID.randomUUID().toString().replace("-", "").substring(0, 13)) + .bookTitle("테스트 책") + .bookAuthor("테스트 저자") + .contentBody("기본 피드 본문입니다.") + .contentUrls(new String[]{}) + .likeCount(0) + .commentCount(0) + .isPublic(isPublic) + .isPriorityFeed(false) + .savedCreatedAt(null) + .build(); + } +} diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/UserDeleteApiTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/UserDeleteApiTest.java index 016874078..bf1d19d8b 100644 --- a/src/test/java/konkuk/thip/user/adapter/in/web/UserDeleteApiTest.java +++ b/src/test/java/konkuk/thip/user/adapter/in/web/UserDeleteApiTest.java @@ -1,6 +1,7 @@ package konkuk.thip.user.adapter.in.web; import jakarta.persistence.EntityManager; +import java.util.List; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.book.adapter.out.persistence.repository.SavedBookJpaRepository; @@ -10,6 +11,7 @@ import konkuk.thip.comment.adapter.out.persistence.repository.CommentLikeJpaRepository; import konkuk.thip.common.security.util.JwtUtil; import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.cache.FeedCacheHandler; import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; import konkuk.thip.feed.adapter.out.persistence.repository.SavedFeedJpaRepository; @@ -39,8 +41,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.transaction.TestTransaction; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; @@ -52,7 +57,7 @@ import static konkuk.thip.room.domain.value.RoomParticipantRole.HOST; import static konkuk.thip.room.domain.value.RoomParticipantRole.MEMBER; import static konkuk.thip.room.domain.value.RoomStatus.*; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -88,6 +93,8 @@ public class UserDeleteApiTest { @Autowired private JwtUtil jwtUtil; @Autowired private EntityManager em; + @Autowired private CacheManager cacheManager; + @Autowired private FeedCacheHandler feedCacheHandler; @Test @DisplayName("회원탈퇴 성공시 모든 연관 엔티티가 각 엔티티 삭제 전략에 맞게 삭제되고 탈퇴한 회원의 토큰이 블랙리스트에 등록된다.") @@ -418,6 +425,67 @@ void deleteUser_whenHostInActiveRoom_thenThrowBusinessException() throws Excepti } + @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 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(); + } private RoomJpaEntity createRoom(BookJpaEntity book, Category category, RoomStatus roomStatus) { return roomJpaRepository.save(RoomJpaEntity.builder()