diff --git a/build.gradle b/build.gradle index d567f5e4..f9183ab7 100644 --- a/build.gradle +++ b/build.gradle @@ -72,6 +72,7 @@ dependencies { implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect' implementation 'org.webjars:bootstrap:5.3.7' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson-spring-boot-starter:3.31.0' implementation 'org.springframework.security:spring-security-oauth2-client' implementation 'org.springframework.security:spring-security-oauth2-jose' diff --git a/src/main/java/com/grepp/teamnotfound/app/model/board/LikeBatchProcessor.java b/src/main/java/com/grepp/teamnotfound/app/model/board/LikeBatchProcessor.java index 7361b738..4f55effa 100644 --- a/src/main/java/com/grepp/teamnotfound/app/model/board/LikeBatchProcessor.java +++ b/src/main/java/com/grepp/teamnotfound/app/model/board/LikeBatchProcessor.java @@ -1,20 +1,13 @@ package com.grepp.teamnotfound.app.model.board; -import com.grepp.teamnotfound.app.model.board.entity.ArticleLike; -import com.grepp.teamnotfound.app.model.board.repository.ArticleLikeRepository; -import com.grepp.teamnotfound.app.model.board.repository.ArticleRepository; -import com.grepp.teamnotfound.app.model.notification.code.NotiType; -import com.grepp.teamnotfound.app.model.notification.dto.NotiServiceCreateDto; -import com.grepp.teamnotfound.app.model.notification.handler.NotiAppender; -import com.grepp.teamnotfound.app.model.user.repository.UserRepository; -import java.time.OffsetDateTime; -import java.util.List; import java.util.Set; +import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor @@ -22,19 +15,35 @@ public class LikeBatchProcessor { private final RedisLikeService redisLikeService; - private final ArticleRepository articleRepository; - private final ArticleLikeRepository articleLikeRepository; - private final UserRepository userRepository; - private final NotiAppender notiAppender; + private final LikeService likeService; + private final RedissonClient redissonClient; + private static final String BATCH_LOCK_KEY = "lock:like_batch_processor"; // 1분마다 실행 @Scheduled(fixedDelay = 60000) - @Transactional public void processLikeBatch() { - log.info("Processing likes batch..."); + RLock lock = redissonClient.getLock(BATCH_LOCK_KEY); + try { + boolean isLocked = lock.tryLock(10, 55, TimeUnit.SECONDS); - // 요청이 들어온 articleId 리스트 - Set changedArticleIds = redisLikeService.getAllChangedArticleIdsAndClear(); + if (isLocked) { + log.info("Acquired lock. Starting likes batch processing..."); + syncLikesWithDB(); + } + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + log.info("Lock released"); + } + } + } + + // 좋아요 관련 요청을 DB에 반영 + public void syncLikesWithDB() { + Set changedArticleIds = redisLikeService.getAllChangedArticleIds(); if (changedArticleIds.isEmpty()) { log.info("No changed article found. Skipping batch processing."); @@ -42,60 +51,11 @@ public void processLikeBatch() { } for (Long articleId : changedArticleIds) { - Set likeRequests = redisLikeService.getAllLikeRequestsAndClear(articleId); - Set unlikeRequests = redisLikeService.getAllUnlikeRequestsAndClear(articleId); - - // 요청 송신자 userId 리스트 - List usersToLike = likeRequests.stream() - .map(object -> Long.valueOf(object.toString())) - .toList(); - - List usersToUnlike = unlikeRequests.stream() - .map(object -> Long.valueOf(object.toString())) - .toList(); - - // 좋아요를 한꺼번에 INSERT - // NOTE 단순 반복문을 사용하면 너무 잦은 I/O 로 Redis 를 도입한 장점이 사라짐 - if (!usersToLike.isEmpty()) { - List alreadyLiked = articleLikeRepository.findUserIdsByArticleId(articleId); - - List likesToInsert = usersToLike.stream() - .filter(userId -> !alreadyLiked.contains(userId)) - .map(userId -> ArticleLike.builder() - .article(articleRepository.getReferenceById(articleId)) - .user(userRepository.getReferenceById(userId)) - .createdAt(OffsetDateTime.now()) - .build() - ).toList(); - - articleLikeRepository.saveAll(likesToInsert); - - for (ArticleLike like : likesToInsert) { - Long senderId = like.getUser().getUserId(); - Long receiverId = like.getArticle().getUser().getUserId(); - Long targetId = like.getLikeId(); - - if (!senderId.equals(receiverId)) { - NotiServiceCreateDto dto = NotiServiceCreateDto.builder() - .targetId(targetId) - .notiType(NotiType.LIKE) - .build(); - - notiAppender.append(receiverId, NotiType.LIKE, dto); - } - } - } - - // 좋아요를 한꺼번에 DELETE - if (!usersToUnlike.isEmpty()) { - // NOTE 나중에 성능이 안나온다면 JPQL 쿼리로 직접 DELETE 쿼리 날려보기 - List likesToDelete = articleLikeRepository.findAllByArticleIdAndUserIds(articleId, usersToUnlike); - articleLikeRepository.deleteAllInBatch(likesToDelete); + try { + likeService.syncOneArticleLikes(articleId); + } catch (Exception e) { + log.error("Failed to sync likes for articleId: {}. Retrying on next batch.", articleId, e); } - - // DB 에 최종 반영된 좋아요 수를 가져와 Redis 캐시를 업데이트하여 정합성 유지 - Integer finalDbLikeCount = articleLikeRepository.countByArticle_ArticleId(articleId); - redisLikeService.setArticleLikesCount(articleId, finalDbLikeCount.longValue()); } log.info("Likes batch processing finished for {} articles.", changedArticleIds.size()); diff --git a/src/main/java/com/grepp/teamnotfound/app/model/board/LikeService.java b/src/main/java/com/grepp/teamnotfound/app/model/board/LikeService.java new file mode 100644 index 00000000..f52deddd --- /dev/null +++ b/src/main/java/com/grepp/teamnotfound/app/model/board/LikeService.java @@ -0,0 +1,91 @@ +package com.grepp.teamnotfound.app.model.board; + +import com.grepp.teamnotfound.app.model.board.entity.ArticleLike; +import com.grepp.teamnotfound.app.model.board.repository.ArticleLikeRepository; +import com.grepp.teamnotfound.app.model.board.repository.ArticleRepository; +import com.grepp.teamnotfound.app.model.notification.code.NotiType; +import com.grepp.teamnotfound.app.model.notification.dto.NotiServiceCreateDto; +import com.grepp.teamnotfound.app.model.notification.handler.NotiAppender; +import com.grepp.teamnotfound.app.model.user.repository.UserRepository; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class LikeService { + + private final ArticleRepository articleRepository; + private final ArticleLikeRepository articleLikeRepository; + private final UserRepository userRepository; + private final RedisLikeService redisLikeService; + private final NotiAppender notiAppender; + + // articleId 별로 좋아요 처리 트랜잭션을 분리 + @Transactional + public void syncOneArticleLikes(Long articleId) { + Set likeRequests = redisLikeService.getAllLikeRequests(articleId); + Set unlikeRequests = redisLikeService.getAllUnlikeRequests(articleId); + + // 요청 송신자 userId 리스트 + List usersToLike = likeRequests.stream() + .map(object -> Long.valueOf(object.toString())) + .toList(); + + List usersToUnlike = unlikeRequests.stream() + .map(object -> Long.valueOf(object.toString())) + .toList(); + + if (!usersToLike.isEmpty()) { + List alreadyLiked = articleLikeRepository.findUserIdsByArticleId(articleId); + + List likesToInsert = usersToLike.stream() + .filter(userId -> !alreadyLiked.contains(userId)) + .map(userId -> ArticleLike.builder() + .article(articleRepository.getReferenceById(articleId)) + .user(userRepository.getReferenceById(userId)) + .createdAt(OffsetDateTime.now()) + .build() + ).toList(); + + articleLikeRepository.saveAll(likesToInsert); + + for (ArticleLike like : likesToInsert) { + Long senderId = like.getUser().getUserId(); + Long receiverId = like.getArticle().getUser().getUserId(); + Long targetId = like.getLikeId(); + + if (!senderId.equals(receiverId)) { + NotiServiceCreateDto dto = NotiServiceCreateDto.builder() + .targetId(targetId) + .notiType(NotiType.LIKE) + .build(); + + notiAppender.append(receiverId, NotiType.LIKE, dto); + } + } + } + + // 좋아요를 한꺼번에 DELETE + if (!usersToUnlike.isEmpty()) { + // NOTE 나중에 성능이 안나온다면 JPQL 쿼리로 직접 DELETE 쿼리 날려보기 + List likesToDelete = articleLikeRepository.findAllByArticleIdAndUserIds( + articleId, usersToUnlike); + articleLikeRepository.deleteAllInBatch(likesToDelete); + } + + // DB 에 최종 반영된 좋아요 수를 가져와 Redis 캐시를 업데이트하여 정합성 유지 + Integer finalDbLikeCount = articleLikeRepository.countByArticle_ArticleId(articleId); + redisLikeService.setArticleLikesCount(articleId, finalDbLikeCount.longValue()); + + // 모든 DB 작업이 성공하면 Redis 데이터 삭제 + redisLikeService.clearLikeRequests(articleId); + redisLikeService.clearUnlikeRequests(articleId); + redisLikeService.clearChangedArticleId(articleId); + } +} diff --git a/src/main/java/com/grepp/teamnotfound/app/model/board/RedisLikeService.java b/src/main/java/com/grepp/teamnotfound/app/model/board/RedisLikeService.java index e29928a8..a8f1808b 100644 --- a/src/main/java/com/grepp/teamnotfound/app/model/board/RedisLikeService.java +++ b/src/main/java/com/grepp/teamnotfound/app/model/board/RedisLikeService.java @@ -164,6 +164,62 @@ public List execute(RedisOperations operations) throws Data return Collections.emptySet(); } + /** + * Batch 처리용 - 조회 + * Redis 에서 요청을 가져오기만 하고 삭제하지 않는 메서드 + **/ + + // 좋아요/취소 요청을 받은 articleId 반환 + public Set getAllChangedArticleIds() { + try { + Set members = redisTemplate.opsForSet().members(BATCH_ARTICLE_IDS_KEY); + if (members != null) { + return members.stream() + .map(object -> Long.valueOf(object.toString())) + .collect(Collectors.toSet()); + } + } catch (Exception e) { + log.error("[Redis fallback] getAllChangedArticleIds failed"); + } + return Collections.emptySet(); + } + + public Set getAllLikeRequests(Long articleId) { + return getSet(ARTICLE_LIKE_KEY + articleId, "getAllLikeRequests"); + } + + public Set getAllUnlikeRequests(Long articleId) { + return getSet(ARTICLE_UNLIKE_KEY + articleId, "getAllUnlikeRequests"); + } + + private Set getSet(String key, String logText) { + try { + Set members = redisTemplate.opsForSet().members(key); + if (members != null) { + return members; + } + } catch (Exception e) { + log.error("[Redis fallback] {} failed - key = {}", logText, key, e); + } + return Collections.emptySet(); + } + + /** + * Batch 처리용 - 삭제 + * DB 트랜잭션 성공 후 Redis 데이터를 삭제하는 메서드 + **/ + public void clearChangedArticleId(Long articleId) { + redisTemplate.opsForSet().remove(BATCH_ARTICLE_IDS_KEY, articleId); + } + + public void clearLikeRequests(Long articleId) { + redisTemplate.delete(ARTICLE_LIKE_KEY + articleId); + } + + public void clearUnlikeRequests(Long articleId) { + redisTemplate.delete(ARTICLE_UNLIKE_KEY + articleId); + } + /** * 게시글별 좋아요 수 캐시용 * 좋아요/좋아요 취소 요청 시 최종 좋아요 수를 계산하기 위한 IO를 줄이기 위함 diff --git a/src/main/java/com/grepp/teamnotfound/infra/config/RedissonConfig.java b/src/main/java/com/grepp/teamnotfound/infra/config/RedissonConfig.java new file mode 100644 index 00000000..adc44ae0 --- /dev/null +++ b/src/main/java/com/grepp/teamnotfound/infra/config/RedissonConfig.java @@ -0,0 +1,35 @@ +package com.grepp.teamnotfound.infra.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Value("${spring.data.redis.username}") + private String username; + + @Value("${spring.data.redis.password}") + private String password; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + + config.useSingleServer().setAddress("redis://" + host + ":" + port); + config.useSingleServer().setPassword(password); + config.useSingleServer().setUsername(username); + + return Redisson.create(config); + } +} diff --git a/src/main/resources/db/migration/V5__article_index.sql b/src/main/resources/db/migration/V5__article_index.sql new file mode 100644 index 00000000..d88118bd --- /dev/null +++ b/src/main/resources/db/migration/V5__article_index.sql @@ -0,0 +1,7 @@ +CREATE INDEX idx_articles_board_deleted_reported_created ON public.articles (board_id, deleted_at, reported_at, created_at DESC); + +CREATE INDEX idx_replies_article_deleted_reported ON public.replies (article_id, deleted_at, reported_at); + +CREATE INDEX idx_article_imgs_article_type_deleted ON public.article_imgs (article_id, type, deleted_at); + +CREATE INDEX idx_article_likes_article_id ON public.article_likes (article_id); \ No newline at end of file diff --git a/src/test/java/com/grepp/teamnotfound/app/model/report/ReportServiceTest.java b/src/test/java/com/grepp/teamnotfound/app/model/report/ReportServiceTest.java index 2327f9be..ffdf1967 100644 --- a/src/test/java/com/grepp/teamnotfound/app/model/report/ReportServiceTest.java +++ b/src/test/java/com/grepp/teamnotfound/app/model/report/ReportServiceTest.java @@ -9,7 +9,8 @@ import com.grepp.teamnotfound.app.model.report.dto.ReportDetailDto; import com.grepp.teamnotfound.app.model.report.entity.Report; import com.grepp.teamnotfound.app.model.report.repository.ReportRepository; -import com.grepp.teamnotfound.app.model.user.code.UserStateResponse; +//import com.grepp.teamnotfound.app.model.user.code.UserStateResponse; +import com.grepp.teamnotfound.app.model.user.code.UserStatus; import com.grepp.teamnotfound.app.model.user.entity.User; import com.grepp.teamnotfound.infra.error.exception.BusinessException; import com.grepp.teamnotfound.infra.error.exception.code.BoardErrorCode; @@ -108,8 +109,8 @@ void getReportDetail_forBoard_success() { assertThat(result.getBoardType()).isEqualTo(board.getName()); assertThat(result.getReporterNickname()).isEqualTo(reporter.getNickname()); assertThat(result.getReportedNickname()).isEqualTo(reported.getNickname()); - assertThat(result.getReportedState()).isEqualTo(reported.getUserState()); - assertThat(result.getReportedState()).isEqualTo(UserStateResponse.ACTIVE); + assertThat(result.getReportedState()).isEqualTo(reported.getStatus()); + assertThat(result.getReportedState()).isEqualTo(UserStatus.ACTIVE); // replyRepository의 메소드는 호출되지 않았는지 verify(replyRepository, never()).findArticleWithBoardByReplyId(anyLong()); @@ -247,6 +248,6 @@ void getReportDetail_whenReportedUserIsLeave() { ReportDetailDto result = reportService.getReportDetail(reportId); // then - assertThat(result.getReportedState()).isEqualTo(UserStateResponse.LEAVE); + assertThat(result.getReportedState()).isEqualTo(UserStatus.LEAVE); } } \ No newline at end of file