From df5dfeb6fb07de34735a7925430c51ffc379149d Mon Sep 17 00:00:00 2001 From: JongWook6 Date: Tue, 2 Sep 2025 17:22:46 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20article=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/db/migration/V5__article_index.sql | 7 +++++++ .../teamnotfound/app/model/report/ReportServiceTest.java | 9 +++++---- 2 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/db/migration/V5__article_index.sql 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 From e44c9c03f09f9b4253d415ddb52aab3681a89db2 Mon Sep 17 00:00:00 2001 From: JongWook6 Date: Tue, 2 Sep 2025 17:24:03 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=EC=B2=98=EB=A6=AC=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EB=B3=84=EB=8F=84=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/model/board/LikeBatchProcessor.java | 100 ++++++------------ .../app/model/board/LikeService.java | 86 +++++++++++++++ 2 files changed, 116 insertions(+), 70 deletions(-) create mode 100644 src/main/java/com/grepp/teamnotfound/app/model/board/LikeService.java 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..c74338e8 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,18 +15,34 @@ 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 리스트 + 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.getAllChangedArticleIdsAndClear(); if (changedArticleIds.isEmpty()) { @@ -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: {}", articleId); } - - // 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..293a997d --- /dev/null +++ b/src/main/java/com/grepp/teamnotfound/app/model/board/LikeService.java @@ -0,0 +1,86 @@ +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.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(); + + 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()); + } +} From ca08fcf2f63fe10818688f60a98dc4b26fbdc31d Mon Sep 17 00:00:00 2001 From: JongWook6 Date: Wed, 3 Sep 2025 00:59:29 +0900 Subject: [PATCH 3/4] =?UTF-8?q?chore:=20Redisson=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../infra/config/RedissonConfig.java | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/main/java/com/grepp/teamnotfound/infra/config/RedissonConfig.java 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/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); + } +} From f1346cc6596731bf85f6820adf21fb26cbfa426f Mon Sep 17 00:00:00 2001 From: JongWook6 Date: Wed, 3 Sep 2025 01:00:52 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EC=B2=98=EB=A6=AC=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/model/board/LikeBatchProcessor.java | 4 +- .../app/model/board/LikeService.java | 9 ++- .../app/model/board/RedisLikeService.java | 56 +++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) 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 c74338e8..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 @@ -43,7 +43,7 @@ public void processLikeBatch() { // 좋아요 관련 요청을 DB에 반영 public void syncLikesWithDB() { - Set changedArticleIds = redisLikeService.getAllChangedArticleIdsAndClear(); + Set changedArticleIds = redisLikeService.getAllChangedArticleIds(); if (changedArticleIds.isEmpty()) { log.info("No changed article found. Skipping batch processing."); @@ -54,7 +54,7 @@ public void syncLikesWithDB() { try { likeService.syncOneArticleLikes(articleId); } catch (Exception e) { - log.error("Failed to sync likes for articleId: {}", articleId); + log.error("Failed to sync likes for articleId: {}. Retrying on next batch.", articleId, e); } } 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 index 293a997d..f52deddd 100644 --- a/src/main/java/com/grepp/teamnotfound/app/model/board/LikeService.java +++ b/src/main/java/com/grepp/teamnotfound/app/model/board/LikeService.java @@ -29,8 +29,8 @@ public class LikeService { // articleId 별로 좋아요 처리 트랜잭션을 분리 @Transactional public void syncOneArticleLikes(Long articleId) { - Set likeRequests = redisLikeService.getAllLikeRequestsAndClear(articleId); - Set unlikeRequests = redisLikeService.getAllUnlikeRequestsAndClear(articleId); + Set likeRequests = redisLikeService.getAllLikeRequests(articleId); + Set unlikeRequests = redisLikeService.getAllUnlikeRequests(articleId); // 요청 송신자 userId 리스트 List usersToLike = likeRequests.stream() @@ -82,5 +82,10 @@ public void syncOneArticleLikes(Long articleId) { // 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를 줄이기 위함