From d6826a021f0557e8acf7a7b0b72bc419ea2be798 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Thu, 8 Jan 2026 14:57:30 +0900 Subject: [PATCH 1/4] =?UTF-8?q?Feat:=20=EC=9D=B8=EA=B8=B0=20=EC=A0=84?= =?UTF-8?q?=EB=AC=B8=EA=B0=80=20=EC=B9=BC=EB=9F=BC=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?Redis=20=EC=BA=90=EC=8B=9C=20=EB=B0=8F=20DTO=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홈 화면 인기 전문가 칼럼 조회 API의 성능과 캐시 안정성을 개선함. - 인기 전문가 칼럼 조회 결과를 Redis 캐시(home:popularExpertColumn)로 저장하도록 추가함 - 캐시 저장 대상을 엔티티가 아닌 DTO로 통일하여 직렬화 및 LAZY 로딩 이슈를 제거함 - 좋아요 변경 시 트랜잭션 커밋 이후(afterCommit)에 캐시를 무효화하도록 처리하여 데이터 정합성을 보장함 조회 구조 개선: - 기존에는 List 조회 후 Converter에서 연관 엔티티(user, expert, postImgs)에 접근하는 구조였음 - 환경/설정에 따라 추가 쿼리가 1~N개 발생할 수 있는 구조였으며, 데이터 규모에 따라 N+1 가능성이 존재했음 - QueryDSL DTO 프로젝션(전용 Row)을 도입하여 필요한 필드만 한 번의 쿼리로 조회하도록 변경함 - 첫 번째 이미지, 작성자 정보, 전문가 프로필 이미지를 조회 단계에서 함께 가져오도록 수정함 Converter 책임 분리: - Post 엔티티 기반 변환 로직과 Row(DTO projection) 기반 변환 로직을 분리함 - 캐시 및 성능 민감 영역에서는 엔티티 접근을 완전히 제거함 결과: - 인기 전문가 칼럼 조회 시 쿼리 수를 1회로 고정 - 캐시 miss 시에도 안정적인 성능 보장 - 캐시 TTL을 길게 가져가더라도 좋아요 이벤트에 즉시 반영되는 구조 확보 --- README.md | 10 +-- .../backend/farmon/config/RedisConfig.java | 16 ++-- .../farmon/converter/HomeConverter.java | 30 +++---- .../farmon/dto/home/PopularExpertPostRow.java | 15 ++++ .../PostRepository/PostRepositoryCustom.java | 3 +- .../PostRepository/PostRepositoryImpl.java | 87 ++++++++++++++----- .../service/LikeService/LikeServiceImpl.java | 72 ++++++++++----- .../PostService/PostQueryServiceImpl.java | 21 +++-- 8 files changed, 169 insertions(+), 85 deletions(-) create mode 100644 src/main/java/com/backend/farmon/dto/home/PopularExpertPostRow.java diff --git a/README.md b/README.md index cd9b2b7..977cb93 100644 --- a/README.md +++ b/README.md @@ -113,11 +113,11 @@ ## 6. 최종 성능 개선 목표 및 달성 현황 요약 -| 구분 | 목표치 (Thresholds) | v1 (기존) | v4 (최종) | 결과 | -| :--- | :--- |:----------| :--- | :--- | -| **p(95) Latency** | **2.0s 미만** | 6.71s | **2.56s** | **미달 (CPU 자원 임계)** | -| **p(99) Latency** | **5.0s 미만** | **7.94s** | **3.98s** | **통과** | -| **Error Rate** | **1.0% 미만** | 0.005% | **0.009%** | **통과** | +| 구분 | 목표치 (Thresholds) | v1 (기존) | v4 (최종) | 결과 | +| :--- | :--- |:--------| :--- | :--- | +| **p(95) Latency** | **2.0s 미만** | 6.71s | **2.56s** | **미달 (CPU 자원 임계)** | +| **p(99) Latency** | **5.0s 미만** | 7.94s | **3.98s** | **통과** | +| **Error Rate** | **1.0% 미만** | 0.005% | **0.009%** | **통과** | **향후 목표**: 로직, 인프라, 캐싱 최적화를 통해 비약적인 성능 향상을 거두었으나, 1,000 VU 환경에서 단일 인스턴스의 CPU 부하로 인해 p(95) 2.0s 목표에는 미달했습니다. 향후 **인스턴스 확장(Scale-out)** 및 **로드밸런서(ALB)** 적용을 통해 자원 부하를 분산하고 최종 목표 지표를 달성할 예정입니다. \ No newline at end of file diff --git a/src/main/java/com/backend/farmon/config/RedisConfig.java b/src/main/java/com/backend/farmon/config/RedisConfig.java index b7874a9..15e2bee 100644 --- a/src/main/java/com/backend/farmon/config/RedisConfig.java +++ b/src/main/java/com/backend/farmon/config/RedisConfig.java @@ -12,6 +12,8 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.*; +import java.time.Duration; + @Configuration public class RedisConfig { @@ -81,15 +83,19 @@ public RedisTemplate autoSearchLogRedisTemplate() { } @Bean - public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) { - RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() - .entryTtl(java.time.Duration.ofSeconds(60)) + public RedisCacheManager redisCacheManager(RedisConnectionFactory cf) { + RedisCacheConfiguration base = RedisCacheConfiguration.defaultCacheConfig() .disableCachingNullValues() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); - return RedisCacheManager.builder(redisConnectionFactory) - .cacheDefaults(redisCacheConfiguration) + RedisCacheConfiguration homeCommunity = base.entryTtl(Duration.ofSeconds(60)); + RedisCacheConfiguration popularExpert = base.entryTtl(Duration.ofMinutes(5)); + + return RedisCacheManager.builder(cf) + .cacheDefaults(base) + .withCacheConfiguration("home:community", homeCommunity) + .withCacheConfiguration("home:popularExpertColumn", popularExpert) .build(); } } diff --git a/src/main/java/com/backend/farmon/converter/HomeConverter.java b/src/main/java/com/backend/farmon/converter/HomeConverter.java index 8e9c266..e905086 100644 --- a/src/main/java/com/backend/farmon/converter/HomeConverter.java +++ b/src/main/java/com/backend/farmon/converter/HomeConverter.java @@ -6,6 +6,7 @@ import com.backend.farmon.domain.User; import com.backend.farmon.dto.home.HomePostRow; import com.backend.farmon.dto.home.HomeResponse; +import com.backend.farmon.dto.home.PopularExpertPostRow; import java.util.List; import java.util.Optional; @@ -27,9 +28,9 @@ public static HomeResponse.PostListDTO toPostListDTO(List rows) { return new HomeResponse.PostListDTO(list); } - public static HomeResponse.PopularPostListDTO toPopularPostListDTO(List postList) { - List popularPostDetailDTOList = postList.stream() - .map(HomeConverter::toPopularPostDetailListDTO) + public static HomeResponse.PopularPostListDTO toPopularPostListDTO(List rows) { + List popularPostDetailDTOList = rows.stream() + .map(HomeConverter::toPopularPostDetailDTOF) .toList(); return HomeResponse.PopularPostListDTO.builder() @@ -37,23 +38,14 @@ public static HomeResponse.PopularPostListDTO toPopularPostListDTO(List po .build(); } - public static HomeResponse.PopularPostDetailDTO toPopularPostDetailListDTO(Post post) { + public static HomeResponse.PopularPostDetailDTO toPopularPostDetailDTOF(PopularExpertPostRow row) { return HomeResponse.PopularPostDetailDTO.builder() - .popularPostId(post.getId()) - .popularPostTitle(post.getPostTitle()) - .popularPostContent(post.getPostContent()) - .writer(post.getUser().getUserName()) - .profileImage(Optional.ofNullable(post.getUser()) - .map(User::getExpert) - .map(Expert::getProfileImageUrl) - .orElse(null)) - .popularPostImage( - Optional.ofNullable(post.getPostImgs()) - .filter(list -> !list.isEmpty()) // 리스트가 비어 있지 않은 경우만 처리 - .map(list -> list.get(0)) // 첫 번째 이미지 가져오기 - .map(PostImg::getStoredFileName) // 파일명 가져오기 - .orElse(null) // 없으면 null 반환 - ) + .popularPostId(row.getPostId()) + .popularPostTitle(row.getTitle()) + .popularPostContent(row.getContent()) + .writer(row.getWriter()) + .profileImage(row.getProfileImageUrl()) + .popularPostImage(row.getFirstImageStoredFileName()) .build(); } diff --git a/src/main/java/com/backend/farmon/dto/home/PopularExpertPostRow.java b/src/main/java/com/backend/farmon/dto/home/PopularExpertPostRow.java new file mode 100644 index 0000000..8296c25 --- /dev/null +++ b/src/main/java/com/backend/farmon/dto/home/PopularExpertPostRow.java @@ -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; +} diff --git a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryCustom.java b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryCustom.java index e54270f..2373849 100644 --- a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryCustom.java +++ b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryCustom.java @@ -2,6 +2,7 @@ import com.backend.farmon.domain.Post; import com.backend.farmon.dto.home.HomePostRow; +import com.backend.farmon.dto.home.PopularExpertPostRow; import com.backend.farmon.dto.post.PostType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -20,7 +21,7 @@ public interface PostRepositoryCustom { List findTopPostsByPostTypeWithCounts(PostType postType, int limit); // 인기 전문가 칼럼 6개 조회 - List findTop6ExpertColumnPostsByPostId(List popularPostsIdList); + List findTop6ExpertColumnRowsByPopularIds(List popularPostsIdList); // 필터링없이 조회 Page findAllByBoardId(Long boardId, Pageable pageable); diff --git a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java index 77d6ba1..ccdc8a2 100644 --- a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java +++ b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java @@ -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; /** * 공통 select (원본 게시글 기준) + 좋아요/댓글 count를 한 번에 가져오기 위한 프로젝션 @@ -142,33 +146,68 @@ public List findTopPostsByPostTypeWithCounts(PostType postType, int // 인기 전문가 칼럼 6개 조회 @Override - public List findTop6ExpertColumnPostsByPostId(List popularPostsIdList) { - return queryFactory.select(post) + public List findTop6ExpertColumnRowsByPopularIds(List 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() ) - .limit(6) // 6개 제한 + .limit(6) .fetch(); } diff --git a/src/main/java/com/backend/farmon/service/LikeService/LikeServiceImpl.java b/src/main/java/com/backend/farmon/service/LikeService/LikeServiceImpl.java index 9311907..2ca1342 100644 --- a/src/main/java/com/backend/farmon/service/LikeService/LikeServiceImpl.java +++ b/src/main/java/com/backend/farmon/service/LikeService/LikeServiceImpl.java @@ -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); // 필요 시 별도 에러 코드 권장 } // 좋아요 삭제 @@ -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); } diff --git a/src/main/java/com/backend/farmon/service/PostService/PostQueryServiceImpl.java b/src/main/java/com/backend/farmon/service/PostService/PostQueryServiceImpl.java index 3e79248..3f02e18 100644 --- a/src/main/java/com/backend/farmon/service/PostService/PostQueryServiceImpl.java +++ b/src/main/java/com/backend/farmon/service/PostService/PostQueryServiceImpl.java @@ -11,6 +11,7 @@ import com.backend.farmon.dto.Comment.CommentResponseDTO; import com.backend.farmon.dto.home.HomePostRow; import com.backend.farmon.dto.home.HomeResponse; +import com.backend.farmon.dto.home.PopularExpertPostRow; import com.backend.farmon.dto.post.PostPagingResponseDTO; import com.backend.farmon.dto.post.PostResponseDTO; import com.backend.farmon.dto.post.PostType; @@ -59,21 +60,27 @@ public HomeResponse.PostListDTO findHomePostsByCategory(PostType category) { default -> postRepository.findTopPostsByPostTypeWithCounts(category, POST_LIMIT); }; - log.info("[findHomePostsByCategory] DB query executed. category={}", category); + if (log.isDebugEnabled()) + log.debug("홈 화면 커뮤니티 게시글 조회 DB query executed. category={}", category); return HomeConverter.toPostListDTO(rows); } // 인기 전문가 칼럼 6개 조회 @Override + @Cacheable( + cacheNames = "home:popularExpertColumn", + key = "'list:v1'", // 고정 키(파라미터 없으니) + unless = "#result == null" + ) public HomeResponse.PopularPostListDTO findPopularExpertColumnPosts() { - // 별도로 인기 칼럼으로 지정할 지정할 전문가 칼럼 게시글 아이디 리스트 - List popularPostsIdList = new ArrayList<>(); - popularPostsIdList.add(4L); + List popularPostsIdList = List.of(4L); + + List expertColumnPostList = + postRepository.findTop6ExpertColumnRowsByPopularIds(popularPostsIdList); - // 인기 전문가 칼럼 6개 조회 - List expertColumnPostList = postRepository.findTop6ExpertColumnPostsByPostId(popularPostsIdList); - log.info("홈 화면 인기 전문가 칼럼 조회 성공"); + if (log.isDebugEnabled()) + log.debug("홈 화면 인기 전문가 칼럼 조회 DB query executed"); return HomeConverter.toPopularPostListDTO(expertColumnPostList); } From 09384576ce38a9d6dbe3f8cb3f088305dd3ed293 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Thu, 8 Jan 2026 15:16:43 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Refactor:=20Redis=20cache=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20TTL=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=97=AD?= =?UTF-8?q?=EC=A7=81=EB=A0=AC=ED=99=94=20=EA=B8=B0=EB=B3=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RedisCacheManager 기본 TTL을 명확히 설정하고, 특정 캐시(home:popularExpertColumn)는 별도 TTL을 적용함. DTO(Row) 직렬화/역직렬화 안정성을 위해 기본 생성자를 추가하고, QueryDSL 조회 로직 및 서비스 조회 흐름을 정리함. --- src/main/java/com/backend/farmon/config/RedisConfig.java | 3 +-- src/main/java/com/backend/farmon/dto/home/HomePostRow.java | 2 +- .../java/com/backend/farmon/dto/home/PopularExpertPostRow.java | 2 ++ .../farmon/repository/PostRepository/PostRepositoryImpl.java | 2 -- .../farmon/service/PostService/PostQueryServiceImpl.java | 1 - 5 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/backend/farmon/config/RedisConfig.java b/src/main/java/com/backend/farmon/config/RedisConfig.java index 15e2bee..05e8bbc 100644 --- a/src/main/java/com/backend/farmon/config/RedisConfig.java +++ b/src/main/java/com/backend/farmon/config/RedisConfig.java @@ -85,16 +85,15 @@ public RedisTemplate autoSearchLogRedisTemplate() { @Bean public RedisCacheManager redisCacheManager(RedisConnectionFactory cf) { RedisCacheConfiguration base = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofSeconds(60)) // 기본 TTL .disableCachingNullValues() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); - RedisCacheConfiguration homeCommunity = base.entryTtl(Duration.ofSeconds(60)); RedisCacheConfiguration popularExpert = base.entryTtl(Duration.ofMinutes(5)); return RedisCacheManager.builder(cf) .cacheDefaults(base) - .withCacheConfiguration("home:community", homeCommunity) .withCacheConfiguration("home:popularExpertColumn", popularExpert) .build(); } diff --git a/src/main/java/com/backend/farmon/dto/home/HomePostRow.java b/src/main/java/com/backend/farmon/dto/home/HomePostRow.java index 734d5b8..7221ee3 100644 --- a/src/main/java/com/backend/farmon/dto/home/HomePostRow.java +++ b/src/main/java/com/backend/farmon/dto/home/HomePostRow.java @@ -10,4 +10,4 @@ public record HomePostRow( LocalDateTime createdAt, Long likeCount, Long commentCount -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/backend/farmon/dto/home/PopularExpertPostRow.java b/src/main/java/com/backend/farmon/dto/home/PopularExpertPostRow.java index 8296c25..fc89af8 100644 --- a/src/main/java/com/backend/farmon/dto/home/PopularExpertPostRow.java +++ b/src/main/java/com/backend/farmon/dto/home/PopularExpertPostRow.java @@ -2,9 +2,11 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @AllArgsConstructor +@NoArgsConstructor public class PopularExpertPostRow { private Long postId; private String title; diff --git a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java index ccdc8a2..1902f23 100644 --- a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java +++ b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java @@ -22,8 +22,6 @@ import java.util.*; -import static com.backend.farmon.domain.QPostImg.postImg; - @Slf4j @Repository @RequiredArgsConstructor diff --git a/src/main/java/com/backend/farmon/service/PostService/PostQueryServiceImpl.java b/src/main/java/com/backend/farmon/service/PostService/PostQueryServiceImpl.java index 3f02e18..74725f0 100644 --- a/src/main/java/com/backend/farmon/service/PostService/PostQueryServiceImpl.java +++ b/src/main/java/com/backend/farmon/service/PostService/PostQueryServiceImpl.java @@ -4,7 +4,6 @@ import com.backend.farmon.apiPayload.exception.GeneralException; import com.backend.farmon.config.security.UserAuthorizationUtil; import com.backend.farmon.converter.HomeConverter; -import com.backend.farmon.converter.PostConverter; import com.backend.farmon.domain.*; import com.backend.farmon.domain.commons.TimeDifferenceUtil; import com.backend.farmon.dto.Answer.AnswerResponseDTO; From c37359aa525bb3c049c6b6a528df78defdd20f4b Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Thu, 8 Jan 2026 15:28:04 +0900 Subject: [PATCH 3/4] =?UTF-8?q?Refactor:=20=EC=9D=B8=EA=B8=B0=20=EC=A0=84?= =?UTF-8?q?=EB=AC=B8=EA=B0=80=20=EC=B9=BC=EB=9F=BC=20pinned=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QueryDSL orderBy()에 null OrderSpecifier가 전달되지 않도록 pinned(고정 노출) ID 존재 여부에 따라 정렬 로직을 명시적으로 분기함. - pinned ID가 있는 경우: - CASE 기반 pinned 우선 정렬 - FIELD 함수로 pinned 내부 순서 유지 - 좋아요 수 및 작성일 기준 보조 정렬 적용 - pinned ID가 없는 경우: - 좋아요 수 및 작성일 기준 정렬만 적용 이를 통해 조건부 정렬 로직의 가독성을 개선하고, QueryDSL orderBy 호출 시 발생 가능한 NullPointerException 위험을 제거함. --- .../PostRepository/PostRepositoryCustom.java | 2 +- .../PostRepository/PostRepositoryImpl.java | 51 ++++++++++--------- .../PostService/PostQueryServiceImpl.java | 6 ++- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryCustom.java b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryCustom.java index 2373849..7fb72ae 100644 --- a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryCustom.java +++ b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryCustom.java @@ -21,7 +21,7 @@ public interface PostRepositoryCustom { List findTopPostsByPostTypeWithCounts(PostType postType, int limit); // 인기 전문가 칼럼 6개 조회 - List findTop6ExpertColumnRowsByPopularIds(List popularPostsIdList); + List findTop6ExpertColumnRowsByPopularIds(List popularPostsIdList, int limit); // 필터링없이 조회 Page findAllByBoardId(Long boardId, Pageable pageable); diff --git a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java index 1902f23..7b3d63c 100644 --- a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java +++ b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java @@ -144,32 +144,26 @@ public List findTopPostsByPostTypeWithCounts(PostType postType, int // 인기 전문가 칼럼 6개 조회 @Override - public List findTop6ExpertColumnRowsByPopularIds(List popularPostsIdList) { - // 첫 번째 이미지만 가져오기 위한 서브쿼리: 해당 post의 postImg 중 가장 작은 id + public List findTop6ExpertColumnRowsByPopularIds(List popularPostsIdList, int limit) { QPostImg pi2 = new QPostImg("pi2"); boolean hasPinned = popularPostsIdList != null && !popularPostsIdList.isEmpty(); - // 인기 ID 우선 정렬(1) / 나머지(2) - // pinnedFirst = 1이면 상단, 2면 하단 - var pinnedFirstOrderExpr = hasPinned - ? new CaseBuilder() + // pinned 우선 정렬(1) / 나머지(2) + var pinnedFirstOrderExpr = new CaseBuilder() .when(post.id.in(popularPostsIdList)).then(1) - .otherwise(2) - : null; + .otherwise(2); - // pinned list 내부 정렬 (MySQL: FIELD) - // pinnedIds가 있으면 FIELD(post.id, [ids]) ASC 로 pinned 내부 순서를 유지 - var pinnedInnerOrderExpr = hasPinned - ? Expressions.numberTemplate( + // pinned 내부 순서 유지 (MySQL: FIELD) + assert popularPostsIdList != null; + var pinnedInnerOrderExpr = Expressions.numberTemplate( Integer.class, "FIELD({0}, {1})", post.id, Expressions.constant(popularPostsIdList) - ) - : null; + ); - return queryFactory + var query = queryFactory .select(Projections.constructor( PopularExpertPostRow.class, post.id, @@ -198,14 +192,25 @@ public List findTop6ExpertColumnRowsByPopularIds(List popularPostsIdList = List.of(4L); + List popularPostsIdList = List.of(EXPERT_COLUMN_BOARD_ID); List expertColumnPostList = - postRepository.findTop6ExpertColumnRowsByPopularIds(popularPostsIdList); + postRepository.findTop6ExpertColumnRowsByPopularIds(popularPostsIdList, POPULAR_EXPERT_POST_LIMIT); if (log.isDebugEnabled()) log.debug("홈 화면 인기 전문가 칼럼 조회 DB query executed"); From 225eaad8370aa8e9dd8d8b8b45281536e12f0413 Mon Sep 17 00:00:00 2001 From: mmije0ng Date: Thu, 8 Jan 2026 15:39:42 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Refactor:=20=EC=9D=B8=EA=B8=B0=20=EC=A0=84?= =?UTF-8?q?=EB=AC=B8=EA=B0=80=20=EC=B9=BC=EB=9F=BC=20pinned=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20Expression=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9C=BC=EB=A1=9C=20assert=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QueryDSL 인기 전문가 칼럼 조회 로직에서 assert 기반 보호 코드를 제거하고, pinned(고정 노출) ID 존재 여부(hasPinned)에 따라 정렬 Expression을 조건부로 생성하도록 개선함. - JVM 옵션(-da)에 따라 비활성화될 수 있는 assert 사용 제거 - pinnedFirstOrderExpr / pinnedInnerOrderExpr를 hasPinned=true인 경우에만 생성 - 불필요한 Expression 객체 생성 및 상수 바인딩 방지 - orderBy 분기 구조를 유지하면서 런타임 안정성과 가독성 개선 리뷰 지적 사항을 반영하여 QueryDSL 정렬 로직의 의도를 명확히 하고 비즈니스 로직 레벨에서의 안전성을 강화함. --- .../PostRepository/PostRepositoryCustom.java | 2 +- .../PostRepository/PostRepositoryImpl.java | 31 +++++++++---------- .../PostService/PostQueryServiceImpl.java | 6 ++-- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryCustom.java b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryCustom.java index 7fb72ae..933dde8 100644 --- a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryCustom.java +++ b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryCustom.java @@ -21,7 +21,7 @@ public interface PostRepositoryCustom { List findTopPostsByPostTypeWithCounts(PostType postType, int limit); // 인기 전문가 칼럼 6개 조회 - List findTop6ExpertColumnRowsByPopularIds(List popularPostsIdList, int limit); + List findTopExpertColumnRowsByPopularIds(List popularPostsIdList, int limit); // 필터링없이 조회 Page findAllByBoardId(Long boardId, Pageable pageable); diff --git a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java index 7b3d63c..7d6127e 100644 --- a/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java +++ b/src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java @@ -144,25 +144,11 @@ public List findTopPostsByPostTypeWithCounts(PostType postType, int // 인기 전문가 칼럼 6개 조회 @Override - public List findTop6ExpertColumnRowsByPopularIds(List popularPostsIdList, int limit) { + public List findTopExpertColumnRowsByPopularIds(List popularPostsIdList, int limit) { QPostImg pi2 = new QPostImg("pi2"); boolean hasPinned = popularPostsIdList != null && !popularPostsIdList.isEmpty(); - // pinned 우선 정렬(1) / 나머지(2) - var pinnedFirstOrderExpr = new CaseBuilder() - .when(post.id.in(popularPostsIdList)).then(1) - .otherwise(2); - - // pinned 내부 순서 유지 (MySQL: FIELD) - assert popularPostsIdList != null; - var pinnedInnerOrderExpr = Expressions.numberTemplate( - Integer.class, - "FIELD({0}, {1})", - post.id, - Expressions.constant(popularPostsIdList) - ); - var query = queryFactory .select(Projections.constructor( PopularExpertPostRow.class, @@ -194,8 +180,21 @@ public List findTop6ExpertColumnRowsByPopularIds(List popularPostsIdList = List.of(EXPERT_COLUMN_BOARD_ID); + List popularPostsIdList = List.of(EXPERT_COLUMN_POST_ID); List expertColumnPostList = - postRepository.findTop6ExpertColumnRowsByPopularIds(popularPostsIdList, POPULAR_EXPERT_POST_LIMIT); + postRepository.findTopExpertColumnRowsByPopularIds(popularPostsIdList, POPULAR_EXPERT_POST_LIMIT); if (log.isDebugEnabled()) log.debug("홈 화면 인기 전문가 칼럼 조회 DB query executed");