diff --git a/backend/src/main/java/hanglog/community/service/CommunityService.java b/backend/src/main/java/hanglog/community/service/CommunityService.java index c23bbc041..8a8bc5ef2 100644 --- a/backend/src/main/java/hanglog/community/service/CommunityService.java +++ b/backend/src/main/java/hanglog/community/service/CommunityService.java @@ -2,7 +2,11 @@ import static hanglog.community.domain.recommendstrategy.RecommendType.LIKE; import static hanglog.global.exception.ExceptionCode.NOT_FOUND_TRIP_ID; +import static hanglog.like.domain.LikeRedisConstants.EMPTY_MARKER; +import static hanglog.like.domain.LikeRedisConstants.LIKE_TTL; +import static hanglog.like.domain.LikeRedisConstants.generateLikeKey; import static hanglog.trip.domain.type.PublishedStatusType.PUBLISHED; +import static java.lang.Boolean.TRUE; import hanglog.auth.domain.Accessor; import hanglog.city.domain.City; @@ -14,26 +18,27 @@ import hanglog.community.dto.response.CommunityTripResponse; import hanglog.community.dto.response.RecommendTripListResponse; import hanglog.global.exception.BadRequestException; -import hanglog.like.domain.LikeCount; -import hanglog.like.domain.LikeInfo; -import hanglog.like.domain.MemberLike; +import hanglog.like.dto.LikeInfo; +import hanglog.like.domain.repository.CustomLikeRepository; import hanglog.like.dto.LikeElement; import hanglog.like.dto.LikeElements; -import hanglog.like.domain.repository.LikeCountRepository; -import hanglog.like.domain.repository.LikeRepository; -import hanglog.like.domain.repository.MemberLikeRepository; import hanglog.trip.domain.Trip; import hanglog.trip.domain.repository.TripCityRepository; import hanglog.trip.domain.repository.TripRepository; import hanglog.trip.dto.TripCityElements; import hanglog.trip.dto.response.TripDetailResponse; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; +import java.util.Objects; +import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SetOperations; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -45,14 +50,13 @@ public class CommunityService { private static final int RECOMMEND_AMOUNT = 5; - private final LikeRepository likeRepository; private final TripRepository tripRepository; private final TripCityRepository tripCityRepository; private final CityRepository cityRepository; - private final RecommendStrategies recommendStrategies; private final PublishedTripRepository publishedTripRepository; - private final LikeCountRepository likeCountRepository; - private final MemberLikeRepository memberLikeRepository; + private final CustomLikeRepository customLikeRepository; + private final RecommendStrategies recommendStrategies; + private final RedisTemplate redisTemplate; @Transactional(readOnly = true) public CommunityTripListResponse getCommunityTripsByPage(final Accessor accessor, final Pageable pageable) { @@ -78,38 +82,17 @@ private List getCommunityTripResponses(final Accessor acc tripCityRepository.findTripIdAndCitiesByTripIds(tripIds) ); final Map> citiesByTrip = tripCityElements.toCityMap(); - - final LikeElements likeElements = new LikeElements(likeRepository.findLikeCountAndIsLikeByTripIds( - accessor.getMemberId(), - tripIds - )); - final Map likeInfoByTrip = likeElements.toLikeMap(); + final Map likeInfoByTrip = getLikeInfoByTripIds(accessor.getMemberId(), tripIds); return trips.stream() .map(trip -> CommunityTripResponse.of( trip, citiesByTrip.get(trip.getId()), - isLike(likeInfoByTrip, trip.getId()), - getLikeCount(likeInfoByTrip, trip.getId()) + likeInfoByTrip.get(trip.getId()).isLike(), + likeInfoByTrip.get(trip.getId()).getLikeCount() )).toList(); } - private boolean isLike(final Map likeInfoByTrip, final Long tripId) { - final LikeInfo likeInfo = likeInfoByTrip.get(tripId); - if (likeInfo == null) { - return false; - } - return likeInfo.isLike(); - } - - private Long getLikeCount(final Map likeInfoByTrip, final Long tripId) { - final LikeInfo likeInfo = likeInfoByTrip.get(tripId); - if (likeInfo == null) { - return 0L; - } - return likeInfo.getLikeCount(); - } - private Long getLastPageIndex(final int pageSize) { final Long totalTripCount = tripRepository.countTripByPublishedStatus(PUBLISHED); final long lastPageIndex = totalTripCount / pageSize; @@ -119,6 +102,43 @@ private Long getLastPageIndex(final int pageSize) { return lastPageIndex + 1; } + private Map getLikeInfoByTripIds(final Long memberId, final List tripIds) { + final Map likeInfoByTrip = new HashMap<>(); + + final List nonCachedTripIds = new ArrayList<>(); + for (final Long tripId : tripIds) { + final String key = generateLikeKey(tripId); + if (TRUE.equals(redisTemplate.hasKey(key))) { + likeInfoByTrip.put(tripId, readLikeInfoFromCache(key, memberId)); + } else { + nonCachedTripIds.add(tripId); + } + } + + if (!nonCachedTripIds.isEmpty()) { + final List likeElements = customLikeRepository.findLikeElementByTripIds(nonCachedTripIds); + likeElements.addAll(getEmptyLikeElements(likeElements, nonCachedTripIds)); + likeElements.forEach(this::storeLikeInCache); + likeInfoByTrip.putAll(new LikeElements(likeElements).toLikeInfo(memberId)); + } + return likeInfoByTrip; + } + + private List getEmptyLikeElements( + final List likeElements, + final List nonCachedTripIds + ) { + return nonCachedTripIds.stream() + .filter(tripId -> doesNotContainTripId(likeElements, tripId)) + .map(LikeElement::empty) + .toList(); + } + + private boolean doesNotContainTripId(final List likeElements, final Long tripId) { + return likeElements.stream() + .noneMatch(likeElement -> likeElement.getTripId().equals(tripId)); + } + @Transactional(readOnly = true) public TripDetailResponse getTripDetail(final Accessor accessor, final Long tripId) { final Trip trip = tripRepository.findById(tripId) @@ -128,30 +148,46 @@ public TripDetailResponse getTripDetail(final Accessor accessor, final Long trip .orElseThrow(() -> new BadRequestException(NOT_FOUND_TRIP_ID)) .getCreatedAt(); - final LikeElement likeElement = getLikeElement(accessor.getMemberId(), tripId); + final LikeInfo likeInfo = getLikeInfoByTripId(accessor.getMemberId(), tripId); final Boolean isWriter = trip.isWriter(accessor.getMemberId()); return TripDetailResponse.publishedTrip( trip, cities, isWriter, - likeElement.isLike(), - likeElement.getLikeCount(), + likeInfo.isLike(), + likeInfo.getLikeCount(), publishedDate ); } - private LikeElement getLikeElement(final Long memberId, final Long tripId) { - final Optional likeCount = likeCountRepository.findById(tripId); - final Optional memberLike = memberLikeRepository.findById(memberId); - if (likeCount.isPresent() && memberLike.isPresent()) { - final Map tripLikeStatusMap = memberLike.get().getLikeStatusForTrip(); - if (tripLikeStatusMap.containsKey(tripId)) { - return new LikeElement(tripId, likeCount.get().getCount(), tripLikeStatusMap.get(tripId)); - } - return new LikeElement(tripId, likeCount.get().getCount(), false); + private LikeInfo getLikeInfoByTripId(final Long memberId, final Long tripId) { + final String key = generateLikeKey(tripId); + if (TRUE.equals(redisTemplate.hasKey(key))) { + return readLikeInfoFromCache(key, memberId); + } + + final LikeElement likeElement = customLikeRepository.findLikesElementByTripId(tripId) + .orElse(LikeElement.empty(tripId)); + storeLikeInCache(likeElement); + return new LikeInfo(likeElement.getLikeCount(), likeElement.isLike(memberId)); + } + + private LikeInfo readLikeInfoFromCache(final String key, final Long memberId) { + final SetOperations opsForSet = redisTemplate.opsForSet(); + final boolean isLike = TRUE.equals(opsForSet.isMember(key, memberId)); + final long count = Objects.requireNonNull(opsForSet.size(key)) - 1; + return new LikeInfo(count, isLike); + } + + private void storeLikeInCache(final LikeElement likeElement) { + final SetOperations opsForSet = redisTemplate.opsForSet(); + final String key = generateLikeKey(likeElement.getTripId()); + opsForSet.add(key, EMPTY_MARKER); + final Set memberIds = likeElement.getMemberIds(); + if (!memberIds.isEmpty()) { + opsForSet.add(key, likeElement.getMemberIds().toArray()); } - return likeRepository.findLikeCountAndIsLikeByTripId(memberId, tripId) - .orElseGet(() -> new LikeElement(tripId, 0, false)); + redisTemplate.expire(key, LIKE_TTL); } } diff --git a/backend/src/main/java/hanglog/global/config/RedisConfig.java b/backend/src/main/java/hanglog/global/config/RedisConfig.java index 8835a5f83..63c533f7d 100644 --- a/backend/src/main/java/hanglog/global/config/RedisConfig.java +++ b/backend/src/main/java/hanglog/global/config/RedisConfig.java @@ -5,7 +5,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericToStringSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @EnableRedisRepositories @@ -20,4 +23,13 @@ public class RedisConfig { public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(host, port); } + + @Bean + public RedisTemplate redisTemplate() { + final RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class)); + return redisTemplate; + } } diff --git a/backend/src/main/java/hanglog/like/domain/LikeCount.java b/backend/src/main/java/hanglog/like/domain/LikeCount.java deleted file mode 100644 index 62730449f..000000000 --- a/backend/src/main/java/hanglog/like/domain/LikeCount.java +++ /dev/null @@ -1,17 +0,0 @@ -package hanglog.like.domain; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.data.annotation.Id; -import org.springframework.data.redis.core.RedisHash; - -@Getter -@AllArgsConstructor -@RedisHash(value = "likeCount") -public class LikeCount { - - @Id - private Long tripId; - - private Long count; -} diff --git a/backend/src/main/java/hanglog/like/domain/LikeRedisConstants.java b/backend/src/main/java/hanglog/like/domain/LikeRedisConstants.java new file mode 100644 index 000000000..695b8e163 --- /dev/null +++ b/backend/src/main/java/hanglog/like/domain/LikeRedisConstants.java @@ -0,0 +1,16 @@ +package hanglog.like.domain; + +import java.time.Duration; + +public class LikeRedisConstants { + + public static final String LIKE_KEY_PREFIX = "like:"; + public static final String WILD_CARD = "*"; + public static final String KEY_SEPARATOR = ":"; + public static final Long EMPTY_MARKER = -1L; + public static final Duration LIKE_TTL = Duration.ofMinutes(90L); + + public static String generateLikeKey(final Long tripId) { + return LIKE_KEY_PREFIX + tripId; + } +} diff --git a/backend/src/main/java/hanglog/like/domain/MemberLike.java b/backend/src/main/java/hanglog/like/domain/MemberLike.java deleted file mode 100644 index 50101bee5..000000000 --- a/backend/src/main/java/hanglog/like/domain/MemberLike.java +++ /dev/null @@ -1,18 +0,0 @@ -package hanglog.like.domain; - -import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.data.annotation.Id; -import org.springframework.data.redis.core.RedisHash; - -@Getter -@AllArgsConstructor -@RedisHash(value = "memberLike", timeToLive = 5400) -public class MemberLike { - - @Id - private Long memberId; - - private Map likeStatusForTrip; -} diff --git a/backend/src/main/java/hanglog/like/domain/repository/CustomLikeRepository.java b/backend/src/main/java/hanglog/like/domain/repository/CustomLikeRepository.java index 32e06b818..51c74b57e 100644 --- a/backend/src/main/java/hanglog/like/domain/repository/CustomLikeRepository.java +++ b/backend/src/main/java/hanglog/like/domain/repository/CustomLikeRepository.java @@ -1,9 +1,15 @@ package hanglog.like.domain.repository; import hanglog.like.domain.Likes; +import hanglog.like.dto.LikeElement; import java.util.List; +import java.util.Optional; public interface CustomLikeRepository { void saveAll(final List likes); + + Optional findLikesElementByTripId(final Long tripId); + + List findLikeElementByTripIds(final List tripIds); } diff --git a/backend/src/main/java/hanglog/like/domain/repository/LikeCountRepository.java b/backend/src/main/java/hanglog/like/domain/repository/LikeCountRepository.java deleted file mode 100644 index de64fea80..000000000 --- a/backend/src/main/java/hanglog/like/domain/repository/LikeCountRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package hanglog.like.domain.repository; - -import hanglog.like.domain.LikeCount; -import java.util.List; -import org.springframework.data.repository.CrudRepository; - -public interface LikeCountRepository extends CrudRepository { - - List findLikeCountsByTripIdIn(final List tripIds); -} diff --git a/backend/src/main/java/hanglog/like/domain/repository/LikeRepository.java b/backend/src/main/java/hanglog/like/domain/repository/LikeRepository.java index 1cdbe97e4..7f1338b46 100644 --- a/backend/src/main/java/hanglog/like/domain/repository/LikeRepository.java +++ b/backend/src/main/java/hanglog/like/domain/repository/LikeRepository.java @@ -1,40 +1,14 @@ package hanglog.like.domain.repository; import hanglog.like.domain.Likes; -import hanglog.like.dto.LikeElement; -import hanglog.like.dto.TripLikeCount; -import java.util.List; -import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; public interface LikeRepository extends JpaRepository { - @Query(""" - SELECT new hanglog.like.dto.LikeElement - (l.tripId, COUNT(l.memberId), EXISTS(SELECT 1 FROM Likes l_1 WHERE l_1.memberId = :memberId AND l_1.tripId = l.tripId)) - FROM Likes l - WHERE l.tripId in :tripIds - GROUP BY l.tripId - """) - List findLikeCountAndIsLikeByTripIds(@Param("memberId") final Long memberId, - @Param("tripIds") final List tripIds); - - @Query(""" - SELECT new hanglog.like.dto.LikeElement - (l.tripId, COUNT(l.memberId), EXISTS(SELECT 1 FROM Likes l_1 WHERE l_1.memberId = :memberId AND l_1.tripId = l.tripId)) - FROM Likes l - WHERE l.tripId = :tripId - GROUP BY l.tripId - """) - Optional findLikeCountAndIsLikeByTripId(@Param("memberId") final Long memberId, - @Param("tripId") final Long tripId); - - @Query(""" - SELECT new hanglog.like.dto.TripLikeCount(l.tripId, COUNT(l.memberId)) - FROM Likes l - GROUP BY l.tripId - """) - List findCountByAllTrips(); + @Modifying + @Query("DELETE FROM Likes WHERE tripId IN :tripIds") + void deleteByTripIds(final Set tripIds); } diff --git a/backend/src/main/java/hanglog/like/domain/repository/MemberLikeRepository.java b/backend/src/main/java/hanglog/like/domain/repository/MemberLikeRepository.java deleted file mode 100644 index f338c37cd..000000000 --- a/backend/src/main/java/hanglog/like/domain/repository/MemberLikeRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package hanglog.like.domain.repository; - -import hanglog.like.domain.MemberLike; -import org.springframework.data.repository.CrudRepository; - -public interface MemberLikeRepository extends CrudRepository { -} diff --git a/backend/src/main/java/hanglog/like/dto/LikeElement.java b/backend/src/main/java/hanglog/like/dto/LikeElement.java index d94d9674f..ef55bb780 100644 --- a/backend/src/main/java/hanglog/like/dto/LikeElement.java +++ b/backend/src/main/java/hanglog/like/dto/LikeElement.java @@ -1,5 +1,7 @@ package hanglog.like.dto; +import java.util.Collections; +import java.util.Set; import lombok.AllArgsConstructor; import lombok.Getter; @@ -9,5 +11,13 @@ public class LikeElement { private final Long tripId; private final long likeCount; - private final boolean isLike; + private final Set memberIds; + + public boolean isLike(final Long memberId) { + return memberIds.contains(memberId); + } + + public static LikeElement empty(final Long tripId) { + return new LikeElement(tripId, 0, Collections.emptySet()); + } } diff --git a/backend/src/main/java/hanglog/like/dto/LikeElements.java b/backend/src/main/java/hanglog/like/dto/LikeElements.java index 2552e8a91..7c3aac67e 100644 --- a/backend/src/main/java/hanglog/like/dto/LikeElements.java +++ b/backend/src/main/java/hanglog/like/dto/LikeElements.java @@ -1,20 +1,21 @@ package hanglog.like.dto; -import hanglog.like.domain.LikeInfo; import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.AllArgsConstructor; +import lombok.Getter; +@Getter @AllArgsConstructor public class LikeElements { private final List elements; - public Map toLikeMap() { + public Map toLikeInfo(final Long memberId) { final Map map = new HashMap<>(); for (final LikeElement likeElement : elements) { - final LikeInfo likeInfo = new LikeInfo(likeElement.getLikeCount(), likeElement.isLike()); + final LikeInfo likeInfo = new LikeInfo(likeElement.getLikeCount(), likeElement.isLike(memberId)); map.put(likeElement.getTripId(), likeInfo); } return map; diff --git a/backend/src/main/java/hanglog/like/domain/LikeInfo.java b/backend/src/main/java/hanglog/like/dto/LikeInfo.java similarity index 86% rename from backend/src/main/java/hanglog/like/domain/LikeInfo.java rename to backend/src/main/java/hanglog/like/dto/LikeInfo.java index c968fffa8..d418acdae 100644 --- a/backend/src/main/java/hanglog/like/domain/LikeInfo.java +++ b/backend/src/main/java/hanglog/like/dto/LikeInfo.java @@ -1,4 +1,4 @@ -package hanglog.like.domain; +package hanglog.like.dto; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/backend/src/main/java/hanglog/like/dto/TripLikeCount.java b/backend/src/main/java/hanglog/like/dto/TripLikeCount.java deleted file mode 100644 index 5f43296c9..000000000 --- a/backend/src/main/java/hanglog/like/dto/TripLikeCount.java +++ /dev/null @@ -1,12 +0,0 @@ -package hanglog.like.dto; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public class TripLikeCount { - - private final long tripId; - private final long count; -} diff --git a/backend/src/main/java/hanglog/like/infrastrcutrue/CustomLikeRepositoryImpl.java b/backend/src/main/java/hanglog/like/infrastrcutrue/CustomLikeRepositoryImpl.java index 98e983850..f4ba8c249 100644 --- a/backend/src/main/java/hanglog/like/infrastrcutrue/CustomLikeRepositoryImpl.java +++ b/backend/src/main/java/hanglog/like/infrastrcutrue/CustomLikeRepositoryImpl.java @@ -2,22 +2,36 @@ import hanglog.like.domain.Likes; import hanglog.like.domain.repository.CustomLikeRepository; +import hanglog.like.dto.LikeElement; +import java.util.Arrays; +import java.util.Collections; import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; @RequiredArgsConstructor @Repository public class CustomLikeRepositoryImpl implements CustomLikeRepository { + private static final RowMapper likeElementRowMapper = (rs, rowNum) -> + new LikeElement( + rs.getLong("tripId"), + rs.getLong("likeCount"), + parseMemberIds(rs.getString("memberIds"))); + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; @Override public void saveAll(final List likes) { final String sql = """ - INSERT INTO likes (trip_id, member_id) + INSERT INTO likes (trip_id, member_id) VALUES (:tripId, :memberId) """; namedParameterJdbcTemplate.batchUpdate(sql, getLikesToSqlParameterSources(likes)); @@ -34,4 +48,47 @@ private MapSqlParameterSource getLikeToSqlParameterSource(final Likes likes) { .addValue("tripId", likes.getTripId()) .addValue("memberId", likes.getMemberId()); } + + @Override + public Optional findLikesElementByTripId(final Long tripId) { + final String sql = """ + SELECT l.trip_id AS tripId, COUNT(l.id) AS likeCount, GROUP_CONCAT(l.member_id) AS memberIds + FROM likes l + WHERE l.trip_id = :tripId + GROUP BY l.trip_id + """; + + final MapSqlParameterSource parameters = new MapSqlParameterSource(); + parameters.addValue("tripId", tripId); + final List results = namedParameterJdbcTemplate.query(sql, parameters, likeElementRowMapper); + if (results.isEmpty()) { + return Optional.empty(); + } + return Optional.of(results.get(0)); + } + + @Override + public List findLikeElementByTripIds(final List tripIds) { + final String sql = """ + SELECT l.trip_id AS tripId, COUNT(l.id) AS likeCount, GROUP_CONCAT(l.member_id) AS memberIds + FROM likes l + WHERE l.trip_id IN (:tripIds) + GROUP BY l.trip_id + """; + + final MapSqlParameterSource parameters = new MapSqlParameterSource(); + parameters.addValue("tripIds", tripIds); + return namedParameterJdbcTemplate.query(sql, parameters, likeElementRowMapper); + } + + private static Set parseMemberIds(final String memberIds) { + if (!StringUtils.hasText(memberIds)) { + return Collections.emptySet(); + } + final String[] idArray = memberIds.strip().split(","); + return Arrays.stream(idArray) + .filter(StringUtils::hasText) + .map(Long::valueOf) + .collect(Collectors.toSet()); + } } diff --git a/backend/src/main/java/hanglog/like/service/LikeService.java b/backend/src/main/java/hanglog/like/service/LikeService.java index fc4799265..fd29deb5c 100644 --- a/backend/src/main/java/hanglog/like/service/LikeService.java +++ b/backend/src/main/java/hanglog/like/service/LikeService.java @@ -1,22 +1,17 @@ package hanglog.like.service; -import hanglog.like.domain.LikeCount; -import hanglog.like.domain.Likes; -import hanglog.like.domain.MemberLike; -import hanglog.like.dto.TripLikeCount; -import hanglog.like.dto.request.LikeRequest; +import static hanglog.like.domain.LikeRedisConstants.EMPTY_MARKER; +import static hanglog.like.domain.LikeRedisConstants.LIKE_TTL; +import static hanglog.like.domain.LikeRedisConstants.generateLikeKey; +import static java.lang.Boolean.FALSE; + import hanglog.like.domain.repository.CustomLikeRepository; -import hanglog.like.domain.repository.LikeCountRepository; -import hanglog.like.domain.repository.LikeRepository; -import hanglog.like.domain.repository.MemberLikeRepository; -import hanglog.member.domain.repository.MemberRepository; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Stream; +import hanglog.like.dto.LikeElement; +import hanglog.like.dto.request.LikeRequest; +import java.util.Set; import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SetOperations; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,50 +20,47 @@ @Transactional public class LikeService { - private final LikeRepository likeRepository; - private final MemberLikeRepository memberLikeRepository; - private final LikeCountRepository likeCountRepository; - private final MemberRepository memberRepository; private final CustomLikeRepository customLikeRepository; + private final RedisTemplate redisTemplate; public void update(final Long memberId, final Long tripId, final LikeRequest likeRequest) { - Map tripLikeStatusMap = new HashMap<>(); - final Optional memberLike = memberLikeRepository.findById(memberId); - if (memberLike.isPresent()) { - tripLikeStatusMap = memberLike.get().getLikeStatusForTrip(); + final String key = generateLikeKey(tripId); + if (FALSE.equals(redisTemplate.hasKey(key))) { + final LikeElement likeElement = customLikeRepository.findLikesElementByTripId(tripId) + .orElse(LikeElement.empty(tripId)); + storeLikeInCache(likeElement); } - tripLikeStatusMap.put(tripId, likeRequest.getIsLike()); - memberLikeRepository.save(new MemberLike(memberId, tripLikeStatusMap)); - updateLikeCountCache(tripId, likeRequest); + updateToCache(key, memberId, likeRequest.getIsLike()); } - private void updateLikeCountCache(final Long tripId, final LikeRequest likeRequest) { - final Optional likeCount = likeCountRepository.findById(tripId); - if (Boolean.TRUE.equals(likeRequest.getIsLike())) { - likeCount.ifPresent(count -> likeCountRepository.save(new LikeCount(tripId, count.getCount() + 1))); + private void storeLikeInCache(final LikeElement likeElement) { + final SetOperations opsForSet = redisTemplate.opsForSet(); + final String key = generateLikeKey(likeElement.getTripId()); + opsForSet.add(key, EMPTY_MARKER); + final Set memberIds = likeElement.getMemberIds(); + if (!memberIds.isEmpty()) { + opsForSet.add(key, likeElement.getMemberIds().toArray()); + } + redisTemplate.expire(key, LIKE_TTL); + } + + private void updateToCache(final String key, final Long memberId, final Boolean isLike) { + if (isLike) { + addMember(key, memberId); return; } - likeCount.ifPresent(count -> likeCountRepository.save(new LikeCount(tripId, count.getCount() - 1))); + removeMember(key, memberId); } - @Scheduled(cron = "0 0 * * * *") - public void writeBackMemberLikeCache() { - final List likes = memberRepository.findAll().stream() - .flatMap(member -> memberLikeRepository.findById(member.getId()) - .map(memberLike -> memberLike.getLikeStatusForTrip() - .entrySet().stream() - .filter(Map.Entry::getValue) - .map(entry -> new Likes(entry.getKey(), member.getId()))) - .orElseGet(Stream::empty)) - .toList(); - customLikeRepository.saveAll(likes); + private void addMember(final String key, final Long memberId) { + final SetOperations opsForSet = redisTemplate.opsForSet(); + opsForSet.add(key, memberId); + redisTemplate.expire(key, LIKE_TTL); } - @Scheduled(cron = "0 0 0 * * *") - public void cacheLikeCount() { - final List tripLikeCounts = likeRepository.findCountByAllTrips(); - for (final TripLikeCount tripLikeCount : tripLikeCounts) { - likeCountRepository.save(new LikeCount(tripLikeCount.getTripId(), tripLikeCount.getCount())); - } + private void removeMember(final String key, final Long memberId) { + final SetOperations opsForSet = redisTemplate.opsForSet(); + opsForSet.remove(key, memberId); + redisTemplate.expire(key, LIKE_TTL); } } diff --git a/backend/src/main/java/hanglog/like/service/LikeSyncScheduler.java b/backend/src/main/java/hanglog/like/service/LikeSyncScheduler.java new file mode 100644 index 000000000..2a4a81eba --- /dev/null +++ b/backend/src/main/java/hanglog/like/service/LikeSyncScheduler.java @@ -0,0 +1,68 @@ +package hanglog.like.service; + +import static hanglog.like.domain.LikeRedisConstants.EMPTY_MARKER; +import static hanglog.like.domain.LikeRedisConstants.KEY_SEPARATOR; +import static hanglog.like.domain.LikeRedisConstants.LIKE_KEY_PREFIX; +import static hanglog.like.domain.LikeRedisConstants.WILD_CARD; +import static hanglog.like.domain.LikeRedisConstants.generateLikeKey; + +import hanglog.like.domain.Likes; +import hanglog.like.domain.repository.CustomLikeRepository; +import hanglog.like.domain.repository.LikeRepository; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SetOperations; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class LikeSyncScheduler { + + private final LikeRepository likeRepository; + private final CustomLikeRepository customLikeRepository; + private final RedisTemplate redisTemplate; + + @Scheduled(cron = "0 0 * * * *") + public void writeBackLikeCache() { + final Set likeKeys = redisTemplate.keys(LIKE_KEY_PREFIX + WILD_CARD); + if (Objects.isNull(likeKeys)) { + return; + } + + final Set tripIds = extractTripIdsInRedisKeys(likeKeys); + likeRepository.deleteByTripIds(tripIds); + + final List likes = extractLikesInRedisValues(tripIds); + customLikeRepository.saveAll(likes); + } + + private Set extractTripIdsInRedisKeys(final Set likeKeys) { + return likeKeys.stream().map(key -> { + final int indexOfColon = key.indexOf(KEY_SEPARATOR); + return Long.valueOf(key.substring(indexOfColon + 1)); + }).collect(Collectors.toSet()); + } + + private List extractLikesInRedisValues(final Set tripIds) { + final SetOperations opsForSet = redisTemplate.opsForSet(); + return tripIds.stream() + .flatMap(tripId -> { + final String key = generateLikeKey(tripId); + final Set memberIds = opsForSet.members(key); + return Optional.ofNullable(memberIds) + .map(ids -> ids.stream() + .filter(memberId -> !EMPTY_MARKER.equals(memberId)) + .map(memberId -> new Likes(tripId, (Long) memberId)) + ).orElseGet(Stream::empty); + }).toList(); + } +} diff --git a/backend/src/test/java/hanglog/integration/service/LikeServiceIntegrationTest.java b/backend/src/test/java/hanglog/integration/service/LikeServiceIntegrationTest.java index 843d661e9..815b702ac 100644 --- a/backend/src/test/java/hanglog/integration/service/LikeServiceIntegrationTest.java +++ b/backend/src/test/java/hanglog/integration/service/LikeServiceIntegrationTest.java @@ -1,15 +1,22 @@ package hanglog.integration.service; import static hanglog.integration.IntegrationFixture.TRIP_CREATE_REQUEST; +import static hanglog.like.domain.LikeRedisConstants.generateLikeKey; +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; import static org.assertj.core.api.SoftAssertions.assertSoftly; +import hanglog.like.domain.Likes; +import hanglog.like.domain.repository.LikeRepository; import hanglog.like.dto.request.LikeRequest; -import hanglog.like.domain.repository.MemberLikeRepository; import hanglog.like.service.LikeService; import hanglog.trip.service.TripService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SetOperations; class LikeServiceIntegrationTest extends RedisServiceIntegrationTest { @@ -20,7 +27,23 @@ class LikeServiceIntegrationTest extends RedisServiceIntegrationTest { private LikeService likeService; @Autowired - private MemberLikeRepository memberLikeRepository; + private RedisTemplate redisTemplate; + + @Autowired + private LikeRepository likeRepository; + + private SetOperations opsForSet; + private Long memberId; + private Long tripId; + private String key; + + @BeforeEach + void setUp() { + opsForSet = redisTemplate.opsForSet(); + memberId = member.getId(); + tripId = tripService.save(memberId, TRIP_CREATE_REQUEST); + key = generateLikeKey(tripId); + } @DisplayName("해당 게시물의 좋아요 여부를 변경할 수 있다.") @Test @@ -29,25 +52,66 @@ void update() { final LikeRequest likeTrueRequest = new LikeRequest(true); final LikeRequest likeFalseRequest = new LikeRequest(false); - final Long tripId = tripService.save(member.getId(), TRIP_CREATE_REQUEST); + // when & then + assertSoftly(softly -> { + softly.assertThat(opsForSet.isMember(key, memberId)).isEqualTo(FALSE); + + likeService.update(memberId, tripId, likeTrueRequest); + softly.assertThat(opsForSet.isMember(key, memberId)).isEqualTo(TRUE); + + likeService.update(memberId, tripId, likeFalseRequest); + softly.assertThat(opsForSet.isMember(key, memberId)).isEqualTo(FALSE); + }); + } + + @DisplayName("이미 여행에 좋아요를 누른 유저가 다시 좋아요 추가 요청을 보내면 무시하고 값을 그대로 유지한다.") + @Test + void update_TrueRequestWhenIsMember() { + // given + final LikeRequest likeTrueRequest = new LikeRequest(true); + + // when + likeService.update(memberId, tripId, likeTrueRequest); + + // then + assertSoftly(softly -> { + softly.assertThat(opsForSet.isMember(key, memberId)).isEqualTo(TRUE); + likeService.update(memberId, tripId, likeTrueRequest); + softly.assertThat(opsForSet.isMember(key, memberId)).isEqualTo(TRUE); + }); + } + + @DisplayName("여행에 좋아요를 누르지 않은 유저가 좋아요 삭제 요청을 보내면 무시하고 값을 그대로 유지한다.") + @Test + void update_FalseRequestWhenIsNotMember() { + // given + final LikeRequest likeFalseRequest = new LikeRequest(false); + + // when + likeService.update(memberId, tripId, likeFalseRequest); + + // then + assertSoftly(softly -> { + softly.assertThat(opsForSet.isMember(key, memberId)).isEqualTo(FALSE); + likeService.update(memberId, tripId, likeFalseRequest); + softly.assertThat(opsForSet.isMember(key, memberId)).isEqualTo(FALSE); + }); + } + + @DisplayName("TripId 키가 Redis에 존재하지 않을 경우 DB에 조회해서 업데이트한다.") + @Test + void update_WhenTripIdIsNotExistInRedis() { + // given + final LikeRequest likeTrueRequest = new LikeRequest(true); + + final Likes likes = new Likes(tripId, memberId); + likeRepository.save(likes); // when & then assertSoftly(softly -> { - softly.assertThat(memberLikeRepository.findById(member.getId())).isEmpty(); - - likeService.update(member.getId(), tripId, likeTrueRequest); - softly.assertThat(memberLikeRepository.findById(member.getId())).isPresent(); - softly.assertThat(memberLikeRepository.findById(member.getId()) - .get() - .getLikeStatusForTrip() - .get(tripId)).isTrue(); - - likeService.update(member.getId(), tripId, likeFalseRequest); - softly.assertThat(memberLikeRepository.findById(member.getId())).isPresent(); - softly.assertThat(memberLikeRepository.findById(member.getId()) - .get() - .getLikeStatusForTrip() - .get(tripId)).isFalse(); + softly.assertThat(opsForSet.isMember(key, memberId)).isEqualTo(FALSE); + likeService.update(memberId, tripId, likeTrueRequest); + softly.assertThat(opsForSet.isMember(key, memberId)).isEqualTo(TRUE); }); } } diff --git a/backend/src/test/java/hanglog/like/service/LikeSyncSchedulerTest.java b/backend/src/test/java/hanglog/like/service/LikeSyncSchedulerTest.java new file mode 100644 index 000000000..e5eb1364e --- /dev/null +++ b/backend/src/test/java/hanglog/like/service/LikeSyncSchedulerTest.java @@ -0,0 +1,64 @@ +package hanglog.like.service; + +import static hanglog.integration.IntegrationFixture.TRIP_CREATE_REQUEST; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import hanglog.integration.service.RedisServiceIntegrationTest; +import hanglog.like.domain.Likes; +import hanglog.like.domain.repository.LikeRepository; +import hanglog.like.dto.request.LikeRequest; +import hanglog.trip.service.TripService; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class LikeSyncSchedulerTest extends RedisServiceIntegrationTest { + + @Autowired + private LikeSyncScheduler likeSyncScheduler; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private TripService tripService; + + @Autowired + private LikeService likeService; + + private Long memberId; + private Long tripId1; + private Long tripId2; + + @BeforeEach + void setUp() { + memberId = member.getId(); + tripId1 = tripService.save(memberId, TRIP_CREATE_REQUEST); + tripId2 = tripService.save(memberId, TRIP_CREATE_REQUEST); + } + + @DisplayName("Redis에서 업데이트 된 좋아요 값을 DB에 저장한다. (기존에 저장되어 있는 TripId 1 삭제하고 TripId 2 데이터만 남는다.)") + @Test + void writeBackLikeCache() { + // given + final Likes likeTripId1 = new Likes(tripId1, memberId); + likeRepository.save(likeTripId1); + + likeService.update(memberId, tripId1, new LikeRequest(false)); + likeService.update(memberId, tripId2, new LikeRequest(true)); + + // when + likeSyncScheduler.writeBackLikeCache(); + final List likes = likeRepository.findAll(); + + // then + assertSoftly(softly -> { + softly.assertThat(likes.size()).isEqualTo(1); + softly.assertThat(likes.get(0)).usingRecursiveComparison() + .ignoringFields("id") + .isEqualTo(new Likes(tripId2, memberId)); + }); + } +}