Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,101 +1,61 @@
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
@Slf4j
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<Long> 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<Long> changedArticleIds = redisLikeService.getAllChangedArticleIds();

if (changedArticleIds.isEmpty()) {
log.info("No changed article found. Skipping batch processing.");
return;
}

for (Long articleId : changedArticleIds) {
Set<Object> likeRequests = redisLikeService.getAllLikeRequestsAndClear(articleId);
Set<Object> unlikeRequests = redisLikeService.getAllUnlikeRequestsAndClear(articleId);

// 요청 송신자 userId 리스트
List<Long> usersToLike = likeRequests.stream()
.map(object -> Long.valueOf(object.toString()))
.toList();

List<Long> usersToUnlike = unlikeRequests.stream()
.map(object -> Long.valueOf(object.toString()))
.toList();

// 좋아요를 한꺼번에 INSERT
// NOTE 단순 반복문을 사용하면 너무 잦은 I/O 로 Redis 를 도입한 장점이 사라짐
if (!usersToLike.isEmpty()) {
List<Long> alreadyLiked = articleLikeRepository.findUserIdsByArticleId(articleId);

List<ArticleLike> 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<ArticleLike> 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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Object> likeRequests = redisLikeService.getAllLikeRequests(articleId);
Set<Object> unlikeRequests = redisLikeService.getAllUnlikeRequests(articleId);

// 요청 송신자 userId 리스트
List<Long> usersToLike = likeRequests.stream()
.map(object -> Long.valueOf(object.toString()))
.toList();

List<Long> usersToUnlike = unlikeRequests.stream()
.map(object -> Long.valueOf(object.toString()))
.toList();

if (!usersToLike.isEmpty()) {
List<Long> alreadyLiked = articleLikeRepository.findUserIdsByArticleId(articleId);

List<ArticleLike> 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<ArticleLike> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,62 @@ public <K, V> List<Object> execute(RedisOperations<K, V> operations) throws Data
return Collections.emptySet();
}

/**
* Batch 처리용 - 조회
* Redis 에서 요청을 가져오기만 하고 삭제하지 않는 메서드
**/

// 좋아요/취소 요청을 받은 articleId 반환
public Set<Long> getAllChangedArticleIds() {
try {
Set<Object> 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<Object> getAllLikeRequests(Long articleId) {
return getSet(ARTICLE_LIKE_KEY + articleId, "getAllLikeRequests");
}

public Set<Object> getAllUnlikeRequests(Long articleId) {
return getSet(ARTICLE_UNLIKE_KEY + articleId, "getAllUnlikeRequests");
}

private Set<Object> getSet(String key, String logText) {
try {
Set<Object> 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를 줄이기 위함
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
7 changes: 7 additions & 0 deletions src/main/resources/db/migration/V5__article_index.sql
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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);
}
}