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..05e8bbc 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,18 @@ 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() + .entryTtl(Duration.ofSeconds(60)) // 기본 TTL .disableCachingNullValues() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); - return RedisCacheManager.builder(redisConnectionFactory) - .cacheDefaults(redisCacheConfiguration) + RedisCacheConfiguration popularExpert = base.entryTtl(Duration.ofMinutes(5)); + + return RedisCacheManager.builder(cf) + .cacheDefaults(base) + .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/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 new file mode 100644 index 0000000..fc89af8 --- /dev/null +++ b/src/main/java/com/backend/farmon/dto/home/PopularExpertPostRow.java @@ -0,0 +1,17 @@ +package com.backend.farmon.dto.home; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +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..933dde8 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 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 77d6ba1..7d6127e 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; @@ -20,8 +22,6 @@ import java.util.*; -import static com.backend.farmon.domain.QPostImg.postImg; - @Slf4j @Repository @RequiredArgsConstructor @@ -33,8 +33,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 +144,72 @@ public List findTopPostsByPostTypeWithCounts(PostType postType, int // 인기 전문가 칼럼 6개 조회 @Override - public List findTop6ExpertColumnPostsByPostId(List popularPostsIdList) { - return queryFactory.select(post) + public List findTopExpertColumnRowsByPopularIds(List popularPostsIdList, int limit) { + QPostImg pi2 = new QPostImg("pi2"); + + boolean hasPinned = popularPostsIdList != null && !popularPostsIdList.isEmpty(); + + var query = 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 - ) - ) - .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() // 작성일 내림차순 - ) - .limit(6) // 6개 제한 + .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 + ); + + // QueryDSL orderBy에 null 전달 방지 + pinned 조건부 생성 + if (hasPinned) { + // pinned 우선 정렬(1) / 나머지(2) + var pinnedFirstOrderExpr = new CaseBuilder() + .when(post.id.in(popularPostsIdList)).then(1) + .otherwise(2); + + // pinned 내부 순서 유지 (MySQL: FIELD) + var pinnedInnerOrderExpr = Expressions.numberTemplate( + Integer.class, + "FIELD({0}, {1})", + post.id, + Expressions.constant(popularPostsIdList) + ); + + query.orderBy( + pinnedFirstOrderExpr.asc(), + pinnedInnerOrderExpr.asc(), + likeCount.id.countDistinct().desc(), + post.createdAt.desc() + ); + } else { + query.orderBy( + likeCount.id.countDistinct().desc(), + post.createdAt.desc() + ); + } + + return query + .limit(limit) .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..f670047 100644 --- a/src/main/java/com/backend/farmon/service/PostService/PostQueryServiceImpl.java +++ b/src/main/java/com/backend/farmon/service/PostService/PostQueryServiceImpl.java @@ -4,13 +4,13 @@ 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; 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; @@ -42,6 +42,8 @@ public class PostQueryServiceImpl implements PostQueryService { private final BoardRepository boardRepository; private final S3Service s3Service; private static final Integer POST_LIMIT=3; + private static final Integer POPULAR_EXPERT_POST_LIMIT=6; + private static final long EXPERT_COLUMN_POST_ID = 4L; // 홈 화면 카테고리에 따른 커뮤니티 게시글 3개씩 조회 // 인기, 전체, QNA, 전문가 칼럼 @@ -59,21 +61,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(EXPERT_COLUMN_POST_ID); + + List expertColumnPostList = + postRepository.findTopExpertColumnRowsByPopularIds(popularPostsIdList, POPULAR_EXPERT_POST_LIMIT); - // 인기 전문가 칼럼 6개 조회 - List expertColumnPostList = postRepository.findTop6ExpertColumnPostsByPostId(popularPostsIdList); - log.info("홈 화면 인기 전문가 칼럼 조회 성공"); + if (log.isDebugEnabled()) + log.debug("홈 화면 인기 전문가 칼럼 조회 DB query executed"); return HomeConverter.toPopularPostListDTO(expertColumnPostList); }