-
Notifications
You must be signed in to change notification settings - Fork 0
✨Feat: 인기 전문가 칼럼 조회 Redis 캐시 도입 및 DTO 조회 구조 개선 #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
d6826a0
0938457
c37359a
225eaad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.backend.farmon.dto.home; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| @AllArgsConstructor | ||
| public class PopularExpertPostRow { | ||
| private Long postId; | ||
| private String title; | ||
| private String content; | ||
| private String writer; | ||
| private String profileImageUrl; | ||
| private String firstImageStoredFileName; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,10 +4,12 @@ | |
| import com.backend.farmon.apiPayload.exception.GeneralException; | ||
| import com.backend.farmon.domain.*; | ||
| import com.backend.farmon.dto.home.HomePostRow; | ||
| import com.backend.farmon.dto.home.PopularExpertPostRow; | ||
| import com.backend.farmon.dto.post.PostType; | ||
| import com.backend.farmon.repository.BoardRepository.BoardRepository; | ||
| import com.querydsl.core.BooleanBuilder; | ||
| import com.querydsl.core.types.Projections; | ||
| import com.querydsl.core.types.dsl.CaseBuilder; | ||
| import com.querydsl.core.types.dsl.Expressions; | ||
| import com.querydsl.jpa.JPAExpressions; | ||
| import com.querydsl.jpa.impl.JPAQueryFactory; | ||
|
|
@@ -33,8 +35,10 @@ public class PostRepositoryImpl implements PostRepositoryCustom { | |
| private final QBoard board = QBoard.board; | ||
| private final QPost originalPost = new QPost("originalPost"); | ||
| private final QComment comment = QComment.comment; | ||
|
|
||
| private final QCrop crop = QCrop.crop; | ||
| private final QUser user = QUser.user; | ||
| private final QExpert expert = QExpert.expert; | ||
| private final QPostImg postImg = QPostImg.postImg; | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * 공통 select (원본 게시글 기준) + 좋아요/댓글 count를 한 번에 가져오기 위한 프로젝션 | ||
|
|
@@ -142,33 +146,68 @@ public List<HomePostRow> findTopPostsByPostTypeWithCounts(PostType postType, int | |
|
|
||
| // 인기 전문가 칼럼 6개 조회 | ||
| @Override | ||
| public List<Post> findTop6ExpertColumnPostsByPostId(List<Long> popularPostsIdList) { | ||
| return queryFactory.select(post) | ||
| public List<PopularExpertPostRow> findTop6ExpertColumnRowsByPopularIds(List<Long> popularPostsIdList) { | ||
| // 첫 번째 이미지만 가져오기 위한 서브쿼리: 해당 post의 postImg 중 가장 작은 id | ||
| QPostImg pi2 = new QPostImg("pi2"); | ||
|
|
||
| boolean hasPinned = popularPostsIdList != null && !popularPostsIdList.isEmpty(); | ||
|
|
||
| // 인기 ID 우선 정렬(1) / 나머지(2) | ||
| // pinnedFirst = 1이면 상단, 2면 하단 | ||
| var pinnedFirstOrderExpr = hasPinned | ||
| ? new CaseBuilder() | ||
| .when(post.id.in(popularPostsIdList)).then(1) | ||
| .otherwise(2) | ||
| : null; | ||
|
|
||
| // pinned list 내부 정렬 (MySQL: FIELD) | ||
| // pinnedIds가 있으면 FIELD(post.id, [ids]) ASC 로 pinned 내부 순서를 유지 | ||
| var pinnedInnerOrderExpr = hasPinned | ||
| ? Expressions.numberTemplate( | ||
| Integer.class, | ||
| "FIELD({0}, {1})", | ||
| post.id, | ||
| Expressions.constant(popularPostsIdList) | ||
| ) | ||
| : null; | ||
|
||
|
|
||
| return queryFactory | ||
| .select(Projections.constructor( | ||
| PopularExpertPostRow.class, | ||
| post.id, | ||
| post.postTitle, | ||
| post.postContent, | ||
| user.userName, | ||
| expert.profileImageUrl, | ||
| postImg.storedFileName | ||
| )) | ||
| .from(post) | ||
| .join(post.board, board).fetchJoin() | ||
| .leftJoin(post.postlikes, likeCount) | ||
| .where( | ||
| board.postType.eq(PostType.EXPERT_COLUMN) // 전문가 칼럼 조건 | ||
| .and( | ||
| popularPostsIdList != null && !popularPostsIdList.isEmpty() | ||
| ? post.id.in(popularPostsIdList).or(post.id.notIn(popularPostsIdList)) | ||
| : null | ||
| ) | ||
| .join(post.board, board) | ||
| .join(post.user, user) | ||
| .leftJoin(user.expert, expert) | ||
| .leftJoin(likeCount).on(likeCount.post.id.eq(post.id)) | ||
| // 첫 이미지 1개만 LEFT JOIN | ||
| .leftJoin(postImg).on(postImg.id.eq( | ||
| JPAExpressions.select(pi2.id.min()) | ||
| .from(pi2) | ||
| .where(pi2.post.id.eq(post.id)) | ||
| )) | ||
| .where(board.postType.eq(PostType.EXPERT_COLUMN)) | ||
| .groupBy( | ||
| post.id, | ||
| post.postTitle, | ||
| post.postContent, | ||
| user.userName, | ||
| expert.profileImageUrl, | ||
| postImg.storedFileName | ||
| ) | ||
| .groupBy(post) | ||
| .orderBy( | ||
| // 인기 게시글 우선 정렬 | ||
| popularPostsIdList != null && !popularPostsIdList.isEmpty() | ||
| ? Expressions.stringTemplate("CASE WHEN {0} IN ({1}) THEN 1 ELSE 2 END", post.id, Expressions.constant(popularPostsIdList)).asc() | ||
| : null, | ||
| // popularPostsIdList 내부 정렬 | ||
| popularPostsIdList != null && !popularPostsIdList.isEmpty() | ||
| ? Expressions.stringTemplate("FIELD({0}, {1})", post.id, Expressions.constant(popularPostsIdList)).asc() | ||
| : null, | ||
| likeCount.count().desc(), // 좋아요 개수 내림차순 | ||
| post.createdAt.desc() // 작성일 내림차순 | ||
| pinnedFirstOrderExpr != null ? pinnedFirstOrderExpr.asc() : null, | ||
| pinnedInnerOrderExpr != null ? pinnedInnerOrderExpr.asc() : null, | ||
| likeCount.id.countDistinct().desc(), | ||
| post.createdAt.desc() | ||
| ) | ||
mmije0ng marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| .limit(6) // 6개 제한 | ||
| .limit(6) | ||
| .fetch(); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,39 +29,64 @@ public class LikeServiceImpl { | |
| private final LikeCountRepository likeCountRepository; | ||
| private final UserAuthorizationUtil userAuthorizationUtil; | ||
|
|
||
| // 추가: 캐시 수동 evict를 위한 주입 | ||
| // 캐시 수동 evict | ||
| private final CacheManager cacheManager; | ||
|
|
||
| // 캐시 무효화 (커밋 이후 실행) | ||
| /** | ||
| * 캐시 무효화 (트랜잭션 커밋 이후 실행) | ||
| */ | ||
| private void evictHomeCommunityCacheAfterCommit(PostType postType) { | ||
| if (!TransactionSynchronizationManager.isSynchronizationActive()) { | ||
| evictHomeCommunityCacheNow(postType); | ||
| evictHomeCommunityCachesNow(postType); | ||
| return; | ||
| } | ||
|
|
||
| TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { | ||
| @Override | ||
| public void afterCommit() { | ||
| evictHomeCommunityCacheNow(postType); | ||
| evictHomeCommunityCachesNow(postType); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| // 캐시 무효화 (즉시 실행) | ||
| private void evictHomeCommunityCacheNow(PostType postType) { | ||
| Cache cache = cacheManager.getCache("home:community"); | ||
| if (cache == null) return; | ||
| /** | ||
| * 캐시 무효화 (즉시 실행) | ||
| * - home:community:popular (POPULAR 전용 캐시) 추가 무효화 | ||
| * - home:community (기존 캐시)도 함께 무효화 | ||
| */ | ||
| private void evictHomeCommunityCachesNow(PostType postType) { | ||
| // 1) 기존 캐시(home:community) 무효화 | ||
| Cache communityCache = cacheManager.getCache("home:community"); | ||
| if (communityCache != null) { | ||
| // POPULAR은 항상 무효화 | ||
| communityCache.evict("category:POPULAR"); | ||
|
|
||
| // 특정 타입 캐시만 무효화 (ALL/POPULAR 제외) | ||
| if (postType != null && postType != PostType.ALL && postType != PostType.POPULAR) { | ||
| communityCache.evict("category:" + postType.name()); | ||
| } | ||
| } | ||
|
|
||
| // POPULAR은 항상 무효화 | ||
| cache.evict("category:POPULAR"); | ||
| // 2) POPULAR 전용 캐시(home:community:popular)도 같이 무효화 (TTL 길게 가져갈 경우 필수) | ||
| Cache popularCache = cacheManager.getCache("home:community:popular"); | ||
| if (popularCache != null) { | ||
| popularCache.evict("category:POPULAR"); | ||
| } | ||
|
|
||
| // default 케이스(특정 타입)만 무효화하고 싶다면 ALL/POPULAR은 제외 | ||
| if (postType != null && postType != PostType.ALL && postType != PostType.POPULAR) { | ||
| cache.evict("category:" + postType.name()); | ||
| // 3) (선택) 인기 전문가 칼럼 별도 캐시를 쓰는 경우 함께 무효화 | ||
| // PostType.EXPERT_COLUMN의 인기 칼럼 리스트가 likeCount 기반 정렬에 영향받는 구조라면 추천 | ||
| if (postType == PostType.EXPERT_COLUMN) { | ||
| Cache popularExpertCache = cacheManager.getCache("home:popularExpertColumn"); | ||
| if (popularExpertCache != null) { | ||
| popularExpertCache.evict("list:v1"); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private void validateRole() throws IllegalAccessException { | ||
| /** | ||
| * 권한 검증: checked exception 사용하지 않음 (GeneralException으로 통일) | ||
| */ | ||
| private void validateRole() { | ||
| String currentUserRole = userAuthorizationUtil.getCurrentUserRole(); | ||
| if (!"FARMER".equals(currentUserRole) && !"EXPERT".equals(currentUserRole)) { | ||
| throw new GeneralException(ErrorStatus.UNAUTHORIZED_ACCESS); | ||
|
|
@@ -77,7 +102,7 @@ private Post getOriginalPost(Post post) { | |
|
|
||
| // 좋아요 추가 | ||
| @Transactional | ||
| public void postLikeUp(Long userId, Long postId) throws IllegalAccessException { | ||
| public void postLikeUp(Long userId, Long postId) { | ||
| validateRole(); | ||
|
|
||
| User user = userRepository.findById(userId) | ||
|
|
@@ -95,11 +120,10 @@ public void postLikeUp(Long userId, Long postId) throws IllegalAccessException { | |
| throw new GeneralException(ErrorStatus.LIKE_TYPE_NOT_SAVED); | ||
| } | ||
|
|
||
| // 중복 좋아요 방지 (originalPostId가 같은 모든 게시물 체크) | ||
| for (Post relatedPost : relatedPosts) { | ||
| if (likeCountRepository.findByUserIdAndPostId(userId, relatedPost.getId()) != null) { | ||
| throw new IllegalAccessException("이미 좋아요를 눌렀습니다!"); | ||
| } | ||
| // 중복 좋아요 방지 (원본 id 기준으로 한 번만 체크하는 게 더 안전/효율적) | ||
| // 기존 로직 유지가 필요하면 relatedPosts 전체를 검사해도 되지만, like는 originalPost에 저장하므로 originalPost만 확인해도 충분함 | ||
| if (likeCountRepository.findByUserIdAndPostId(userId, originalPost.getId()) != null) { | ||
| throw new GeneralException(ErrorStatus.LIKE_TYPE_NOT_SAVED); // 필요 시 별도 에러 코드 권장 | ||
| } | ||
|
|
||
| // 원본 게시물 기준으로 좋아요 저장 | ||
|
|
@@ -117,14 +141,14 @@ public void postLikeUp(Long userId, Long postId) throws IllegalAccessException { | |
| postRepository.saveAll(relatedPosts); | ||
| postRepository.flush(); | ||
|
|
||
| // 커밋 이후 캐시 무효화 (POPULAR + 해당 타입) | ||
| // 커밋 이후 캐시 무효화 (POPULAR + 해당 타입 + popular 캐시) | ||
| PostType postType = originalPost.getBoard().getPostType(); | ||
| evictHomeCommunityCacheAfterCommit(postType); | ||
| } | ||
|
|
||
| // 좋아요 감소 | ||
| @Transactional | ||
| public void postLikeDown(Long userId, Long postId) throws IllegalAccessException { | ||
| public void postLikeDown(Long userId, Long postId) { | ||
| validateRole(); | ||
|
|
||
| User user = userRepository.findById(userId) | ||
|
|
@@ -138,7 +162,7 @@ public void postLikeDown(Long userId, Long postId) throws IllegalAccessException | |
| // 좋아요 찾기 (원본 게시물 기준) | ||
| LikeCount like = likeCountRepository.findByUserIdAndPostId(userId, originalPost.getId()); | ||
| if (like == null) { | ||
| throw new IllegalAccessException("좋아요를 누른 적이 없습니다!"); | ||
| throw new GeneralException(ErrorStatus.LIKE_TYPE_NOT_SAVED); // 필요 시 별도 에러 코드 권장 | ||
| } | ||
|
Comment on lines
163
to
166
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 좋아요 미존재 시 부적절한 에러 코드 사용 좋아요가 존재하지 않는 경우에도 🐛 개선 제안 LikeCount like = likeCountRepository.findByUserIdAndPostId(userId, originalPost.getId());
if (like == null) {
- throw new GeneralException(ErrorStatus.LIKE_TYPE_NOT_SAVED);
+ throw new GeneralException(ErrorStatus.LIKE_NOT_FOUND);
}
🤖 Prompt for AI Agents |
||
|
|
||
| // 좋아요 삭제 | ||
|
|
@@ -155,7 +179,7 @@ public void postLikeDown(Long userId, Long postId) throws IllegalAccessException | |
| postRepository.saveAll(relatedPosts); | ||
| postRepository.flush(); | ||
|
|
||
| // 커밋 이후 캐시 무효화 (POPULAR + 해당 타입) | ||
| // 커밋 이후 캐시 무효화 (POPULAR + 해당 타입 + popular 캐시) | ||
| PostType postType = originalPost.getBoard().getPostType(); | ||
| evictHomeCommunityCacheAfterCommit(postType); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
다중 캐시 TTL 구성이 적절합니다.
캐시별 TTL 분리(home:community 60초, home:popularExpertColumn 5분)가 데이터 특성에 맞게 잘 설정되었습니다.
다만,
base설정에 기본 TTL이 없어 명시적으로 등록되지 않은 캐시는 무제한 TTL을 갖게 됩니다. 향후 다른 캐시가 추가될 경우를 대비해 기본 TTL 설정을 고려해 보세요.🔧 기본 TTL 추가 제안
RedisCacheConfiguration base = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(10)) // 기본 TTL .disableCachingNullValues() .serializeKeysWith(...) .serializeValuesWith(...);📝 Committable suggestion
🤖 Prompt for AI Agents